less copy protection, more size visualization
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.
 
 
 

6649 lignes
195 KiB

  1. //#region variables
  2. let selected = null;
  3. let prevSelected = null;
  4. let selectedEntity = null;
  5. let prevSelectedEntity = null;
  6. let entityIndex = 0;
  7. let firsterror = true;
  8. let clicked = null;
  9. let movingInBounds = false;
  10. let dragging = false;
  11. let clickTimeout = null;
  12. let dragOffsetX = null;
  13. let dragOffsetY = null;
  14. let preloaded = new Set();
  15. let panning = false;
  16. let panReady = true;
  17. let panOffsetX = null;
  18. let panOffsetY = null;
  19. let shiftHeld = false;
  20. let altHeld = false;
  21. let entityX;
  22. let canvasWidth;
  23. let canvasHeight;
  24. let dragScale = 1;
  25. let dragScaleHandle = null;
  26. let dragEntityScale = 1;
  27. let dragEntityScaleHandle = null;
  28. let scrollDirection = 0;
  29. let scrollHandle = null;
  30. let zoomDirection = 0;
  31. let zoomHandle = null;
  32. let sizeDirection = 0;
  33. let sizeHandle = null;
  34. let worldSizeDirty = false;
  35. let rulerMode = false;
  36. let rulers = [];
  37. let currentRuler = undefined;
  38. let webkitCanvasBug = false;
  39. const tagDefs = {
  40. anthro: "Anthro",
  41. feral: "Feral",
  42. taur: "Taur",
  43. naga: "Naga",
  44. goo: "Goo",
  45. };
  46. const availableEntities = {};
  47. const availableEntitiesByName = {};
  48. const entities = {};
  49. let ratioInfo;
  50. //#endregion
  51. //#region units
  52. function dimsEqual(unit1, unit2) {
  53. a = unit1.dimensions;
  54. b = unit2.dimensions;
  55. if (a.length != b.length) {
  56. return false;
  57. }
  58. for (let i = 0; i < a.length && i < b.length; i++) {
  59. if (a[i] != b[i]) {
  60. return false;
  61. }
  62. }
  63. return true;
  64. }
  65. // Determines if a unit is one of a length, area, volume, mass, or energy
  66. function typeOfUnit(unit) {
  67. if (dimsEqual(unit, math.unit(1, "meters"))) {
  68. return "length"
  69. }
  70. if (dimsEqual(unit, math.unit(1, "meters^2"))) {
  71. return "area"
  72. }
  73. if (dimsEqual(unit, math.unit(1, "meters^3"))) {
  74. return "volume"
  75. }
  76. if (dimsEqual(unit, math.unit(1, "kilograms"))) {
  77. return "mass"
  78. }
  79. if (dimsEqual(unit, math.unit(1, "joules"))) {
  80. return "energy"
  81. }
  82. return null;
  83. }
  84. const unitPowers = {
  85. "length": 1,
  86. "area": 2,
  87. "volume": 3,
  88. "mass": 3,
  89. "energy": 3 * (3 / 4)
  90. };
  91. math.createUnit("morbillion", "352.9e12");
  92. math.createUnit({
  93. ShoeSizeMensUS: {
  94. prefixes: "long",
  95. definition: "0.3333333333333333333 inches",
  96. offset: 22,
  97. },
  98. ShoeSizeWomensUS: {
  99. prefixes: "long",
  100. definition: "0.3333333333333333333 inches",
  101. offset: 21,
  102. },
  103. ShoeSizeEU: {
  104. prefixes: "long",
  105. definition: "0.666666666667 cm",
  106. offset: -2,
  107. },
  108. ShoeSizeUK: {
  109. prefixes: "long",
  110. definition: "0.3333333333333333333 in",
  111. offset: 23,
  112. },
  113. RingSizeNA: {
  114. prefixes: "long",
  115. definition: "0.0327 inches",
  116. offset: 13.883792
  117. },
  118. RingSizeISO: {
  119. prefixes: "long",
  120. definition: "0.318309886 mm",
  121. offset: 0
  122. },
  123. RingSizeIndia: {
  124. prefixes: "long",
  125. definition: "0.318309886 mm",
  126. offset: 40
  127. }
  128. });
  129. math.createUnit("humans", {
  130. definition: "5.75 feet",
  131. });
  132. math.createUnit("stories", {
  133. definition: "12 feet",
  134. prefixes: "long",
  135. aliases: ["story", "floor", "floors", "storey", "storeys"]
  136. });
  137. math.createUnit("buses", {
  138. definition: "11.95 meters",
  139. prefixes: "long",
  140. aliases: ["bus"]
  141. });
  142. math.createUnit("marathons", {
  143. definition: "26.2 miles",
  144. prefixes: "long",
  145. aliases: ["marathon"]
  146. });
  147. math.createUnit("timezones", {
  148. definition: "1037.54167 miles",
  149. prefixes: "long",
  150. aliases: ["timezone"],
  151. });
  152. math.createUnit("nauticalMiles", {
  153. definition: "6080 feet",
  154. prefixes: "long",
  155. aliases: ["nauticalMile"],
  156. });
  157. math.createUnit("fathoms", {
  158. definition: "6 feet",
  159. prefixes: "long",
  160. aliases: ["fathom"],
  161. });
  162. math.createUnit("U", {
  163. definition: "1.75 inches",
  164. prefixes: "short",
  165. aliases: ["rackUnits"]
  166. });
  167. math.createUnit("earths", {
  168. definition: "12756km",
  169. prefixes: "long",
  170. aliases: ["earth", "earths", "Earth", "Earths"],
  171. });
  172. math.createUnit("lightseconds", {
  173. definition: "299792458 meters",
  174. prefixes: "long",
  175. aliases: ["lightsecond"]
  176. });
  177. math.createUnit("parsecs", {
  178. definition: "3.086e16 meters",
  179. prefixes: "long",
  180. aliases: ["parsec"]
  181. });
  182. math.createUnit("lightyears", {
  183. definition: "9.461e15 meters",
  184. prefixes: "long",
  185. aliases: ["lightyear"]
  186. });
  187. math.createUnit("AUs", {
  188. definition: "149597870700 meters",
  189. aliases: ["AU", "astronomicalUnits", "astronomicalUnit"]
  190. });
  191. math.createUnit("daltons", {
  192. definition: "1.66e-27 kg",
  193. prefixes: "long",
  194. aliases: ["dalton", "Daltons", "Dalton"]
  195. });
  196. math.createUnit("solarradii", {
  197. definition: "695990 km",
  198. prefixes: "long",
  199. aliases: ["solarRadii"]
  200. });
  201. math.createUnit("solarmasses", {
  202. definition: "2e30 kg",
  203. prefixes: "long",
  204. aliases: ["solarMasses"]
  205. });
  206. math.createUnit("galaxies", {
  207. definition: "105700 lightyears",
  208. prefixes: "long",
  209. aliases: ["galaxy"]
  210. });
  211. math.createUnit("universes", {
  212. definition: "93.016e9 lightyears",
  213. prefixes: "long",
  214. aliases: ["universe"]
  215. });
  216. math.createUnit("multiverses", {
  217. definition: "1e30 lightyears",
  218. prefixes: "long",
  219. aliases: ["multiverse"]
  220. });
  221. math.createUnit("pinHeads", {
  222. definition: "3.14159 mm^2",
  223. prefixes: "long",
  224. aliases: ["pinHead"]
  225. });
  226. math.createUnit("dinnerPlates", {
  227. definition: "95 inches^2",
  228. prefixes: "long",
  229. aliases: ["dinnerPlate"]
  230. });
  231. math.createUnit("suburbanHouses", {
  232. definition: "2000 feet^2",
  233. prefixes: "long",
  234. aliases: ["suburbanHouse"]
  235. });
  236. math.createUnit("footballFields", {
  237. definition: "57600 feet^2",
  238. prefixes: "long",
  239. aliases: ["footballField"]
  240. });
  241. math.createUnit("blocks", {
  242. definition: "20000 m^2",
  243. prefixes: "long",
  244. aliases: ["block"],
  245. });
  246. math.createUnit("peopleInRural", {
  247. definition: "0.02 miles^2",
  248. });
  249. math.createUnit("peopleInManhattan", {
  250. definition: "15 m^2",
  251. });
  252. math.createUnit("peopleInLooseCrowd", {
  253. definition: "1 m^2",
  254. });
  255. math.createUnit("peopleInCrowd", {
  256. definition: "0.3333333333333333 m^2",
  257. });
  258. math.createUnit("peopleInDenseCrowd", {
  259. definition: "0.2 m^2",
  260. });
  261. math.createUnit("people", {
  262. definition: "75 liters",
  263. prefixes: "long",
  264. aliases: ["prey", "preys"]
  265. });
  266. math.createUnit("shippingContainers", {
  267. definition: "1169 ft^3",
  268. prefixes: "long",
  269. aliases: ["shippingContainer"]
  270. });
  271. math.createUnit("olympicPools", {
  272. definition: "2500 m^3",
  273. prefixes: "long",
  274. aliases: ["olympicPool"]
  275. });
  276. math.createUnit("oceans", {
  277. definition: "700000000 km^3",
  278. prefixes: "long",
  279. aliases: ["ocean"]
  280. });
  281. math.createUnit("earthVolumes", {
  282. definition: "1.0867813e12 km^3",
  283. prefixes: "long",
  284. aliases: ["earthVolume"]
  285. });
  286. math.createUnit("universeVolumes", {
  287. definition: "4.2137775e+32 lightyears^3",
  288. prefixes: "long",
  289. aliases: ["universeVolume"]
  290. });
  291. math.createUnit("multiverseVolumes", {
  292. definition: "5.2359878e+89 lightyears^3",
  293. prefixes: "long",
  294. aliases: ["multiverseVolume"]
  295. });
  296. math.createUnit("peopleMass", {
  297. definition: "80 kg",
  298. prefixes: "long",
  299. aliases: ["peopleMasses"]
  300. });
  301. math.createUnit("cars", {
  302. definition: "1250kg",
  303. prefixes: "long",
  304. aliases: ["car"]
  305. });
  306. math.createUnit("busMasses", {
  307. definition: "15000kg",
  308. prefixes: "long",
  309. aliases: ["busMass"]
  310. });
  311. math.createUnit("earthMasses", {
  312. definition: "5.97e24 kg",
  313. prefixes: "long",
  314. aliases: ["earthMass"]
  315. });
  316. math.createUnit("kcal", {
  317. definition: "4184 joules",
  318. prefixes: "long",
  319. });
  320. math.createUnit("foodPounds", {
  321. definition: "867 kcal",
  322. prefixes: "long",
  323. });
  324. math.createUnit("foodKilograms", {
  325. definition: "1909 kcal",
  326. prefixes: "long",
  327. });
  328. math.createUnit("chickenNuggets", {
  329. definition: "42 kcal",
  330. prefixes: "long",
  331. });
  332. math.createUnit("peopleEaten", {
  333. definition: "125000 kcal",
  334. prefixes: "long",
  335. });
  336. math.createUnit("villagesEaten", {
  337. definition: "1000 peopleEaten",
  338. prefixes: "long",
  339. });
  340. math.createUnit("townsEaten", {
  341. definition: "10000 peopleEaten",
  342. prefixes: "long",
  343. });
  344. math.createUnit("citiesEaten", {
  345. definition: "100000 peopleEaten",
  346. prefixes: "long",
  347. });
  348. math.createUnit("metrosEaten", {
  349. definition: "1000000 peopleEaten",
  350. prefixes: "long",
  351. });
  352. math.createUnit("barns", {
  353. definition: "10e-28 m^2",
  354. prefixes: "long",
  355. aliases: ["barn"]
  356. });
  357. math.createUnit("points", {
  358. definition: "0.013888888888888888888888888 inches",
  359. prefixes: "long",
  360. aliases: ["point"]
  361. });
  362. math.createUnit("picas", {
  363. definition: "12 points",
  364. prefixes: "long",
  365. aliases: ["pica"]
  366. });
  367. math.createUnit("beardSeconds", {
  368. definition: "10 nanometers",
  369. prefixes: "long",
  370. aliases: ["beardSecond"]
  371. });
  372. math.createUnit("smoots", {
  373. definition: "5.5833333 feet",
  374. prefixes: "long",
  375. aliases: ["smoot"]
  376. });
  377. math.createUnit("furlongs", {
  378. definition: "660 feet",
  379. prefixes: "long",
  380. aliases: ["furlong"]
  381. });
  382. math.createUnit("nanoacres", {
  383. definition: "1e-9 acres",
  384. prefixes: "long",
  385. aliases: ["nanoacre"]
  386. });
  387. math.createUnit("barnMegaparsecs", {
  388. definition: "1 barn megaparsec",
  389. prefixes: "long",
  390. aliases: ["barnMegaparsec"]
  391. });
  392. math.createUnit("firkins", {
  393. definition: "90 lb",
  394. prefixes: "long",
  395. aliases: ["firkin"]
  396. });
  397. math.createUnit("donkeySeconds", {
  398. definition: "250 joules",
  399. prefixes: "long",
  400. aliases: ["donkeySecond"]
  401. });
  402. math.createUnit("HU", {
  403. definition: "0.75 inches",
  404. aliases: ["HUs", "hammerUnits"],
  405. });
  406. math.createUnit("sections", {
  407. definition: "640 acres",
  408. aliases: ["section"]
  409. });
  410. math.createUnit("townships", {
  411. definition: "36 sections",
  412. aliases: ["township", "surveytownships", "surveytownships"]
  413. });
  414. math.createUnit("hands", {
  415. definition: "4 inches",
  416. prefixes: "long",
  417. aliases: ["hand", "hands"]
  418. });
  419. math.createUnit("acreFeet", {
  420. definition: "1 acre * foot",
  421. prefixes: "long",
  422. });
  423. //#endregion
  424. const defaultUnits = {
  425. length: {
  426. metric: "meters",
  427. customary: "feet",
  428. relative: "stories",
  429. quirky: "smoots",
  430. human: "humans",
  431. },
  432. area: {
  433. metric: "meters^2",
  434. customary: "feet^2",
  435. relative: "footballFields",
  436. quirky: "nanoacres",
  437. human: "peopleInCrowd",
  438. },
  439. volume: {
  440. metric: "liters",
  441. customary: "gallons",
  442. relative: "olympicPools",
  443. volume: "barnMegaparsecs",
  444. human: "people",
  445. },
  446. mass: {
  447. metric: "kilograms",
  448. customary: "lbs",
  449. relative: "peopleMass",
  450. quirky: "firkins",
  451. human: "peopleMass",
  452. },
  453. energy: {
  454. metric: "kJ",
  455. customary: "kcal",
  456. relative: "chickenNuggets",
  457. quirky: "donkeySeconds",
  458. human: "peopleEaten",
  459. },
  460. };
  461. const unitChoices = {
  462. length: {
  463. metric: [
  464. "angstroms",
  465. "millimeters",
  466. "centimeters",
  467. "meters",
  468. "kilometers",
  469. ],
  470. customary: ["inches", "feet", "yards", "miles", "nauticalMiles"],
  471. relative: [
  472. "RingSizeNA",
  473. "RingSizeISO",
  474. "RingSizeIndia",
  475. "ShoeSizeEU",
  476. "ShoeSizeUK",
  477. "ShoeSizeMensUS",
  478. "ShoeSizeWomensUS",
  479. "hands",
  480. "stories",
  481. "buses",
  482. "marathons",
  483. "timezones",
  484. "earths",
  485. "lightseconds",
  486. "solarradii",
  487. "AUs",
  488. "lightyears",
  489. "parsecs",
  490. "galaxies",
  491. "universes",
  492. "multiverses",
  493. ],
  494. quirky: [
  495. "beardSeconds",
  496. "points",
  497. "picas",
  498. "smoots",
  499. "links",
  500. "rods",
  501. "chains",
  502. "furlongs",
  503. "HUs",
  504. "U",
  505. "fathoms",
  506. ],
  507. human: ["humans"],
  508. },
  509. area: {
  510. metric: ["cm^2", "meters^2", "kilometers^2"],
  511. customary: ["inches^2", "feet^2", "chains^2", "acres", "miles^2", "sections", "townships"],
  512. relative: [
  513. "pinHeads",
  514. "dinnerPlates",
  515. "suburbanHouses",
  516. "footballFields",
  517. "blocks",
  518. ],
  519. quirky: ["barns", "nanoacres"],
  520. human: [
  521. "peopleInRural",
  522. "peopleInManhattan",
  523. "peopleInLooseCrowd",
  524. "peopleInCrowd",
  525. "peopleInDenseCrowd",
  526. ],
  527. },
  528. volume: {
  529. metric: ["milliliters", "liters", "m^3"],
  530. customary: ["in^3", "floz", "teaspoons", "tablespoons", "cups", "pints", "quarts", "gallons", "acreFeet"],
  531. relative: [
  532. "oilbarrels",
  533. "shippingContainers",
  534. "olympicPools",
  535. "oceans",
  536. "earthVolumes",
  537. "universeVolumes",
  538. "multiverseVolumes",
  539. ],
  540. quirky: ["barnMegaparsecs"],
  541. human: ["people"],
  542. },
  543. mass: {
  544. metric: ["kilograms", "milligrams", "grams", "tonnes"],
  545. customary: ["grains", "lbs", "ounces", "tons"],
  546. relative: ["cars", "busMasses", "earthMasses", "solarmasses"],
  547. quirky: ["firkins"],
  548. human: ["peopleMass"],
  549. },
  550. energy: {
  551. metric: ["kJ", "foodKilograms"],
  552. customary: ["kcal", "foodPounds"],
  553. relative: ["chickenNuggets"],
  554. quirky: ["donkeySeconds"],
  555. human: [
  556. "peopleEaten",
  557. "villagesEaten",
  558. "townsEaten",
  559. "citiesEaten",
  560. "metrosEaten",
  561. ],
  562. },
  563. };
  564. const config = {
  565. height: math.unit(1500, "meters"),
  566. x: 0,
  567. y: 0,
  568. minLineSize: 100,
  569. maxLineSize: 150,
  570. autoFit: false,
  571. drawYAxis: true,
  572. drawXAxis: false,
  573. autoMass: "off",
  574. autoFoodIntake: false,
  575. autoPreyCapacity: "off",
  576. autoSwallowSize: "off"
  577. };
  578. //#region transforms
  579. function constrainRel(coords) {
  580. const worldWidth =
  581. (config.height.toNumber("meters") / canvasHeight) * canvasWidth;
  582. const worldHeight = config.height.toNumber("meters");
  583. if (altHeld) {
  584. return coords;
  585. }
  586. return {
  587. x: Math.min(
  588. Math.max(coords.x, -worldWidth / 2 + config.x),
  589. worldWidth / 2 + config.x
  590. ),
  591. y: Math.min(Math.max(coords.y, config.y), worldHeight + config.y),
  592. };
  593. }
  594. // not using constrainRel anymore
  595. function snapPos(coords) {
  596. return {
  597. x: coords.x,
  598. y:
  599. !config.groundSnap || altHeld
  600. ? coords.y
  601. : Math.abs(coords.y) < config.height.toNumber("meters") / 20
  602. ? 0
  603. : coords.y,
  604. };
  605. }
  606. function adjustAbs(coords, oldHeight, newHeight) {
  607. const ratio = math.divide(newHeight, oldHeight);
  608. const x = (coords.x - config.x) * ratio + config.x;
  609. const y = (coords.y - config.y) * ratio + config.y;
  610. return { x: x, y: y };
  611. }
  612. function pos2pix(coords) {
  613. const worldWidth =
  614. (config.height.toNumber("meters") / canvasHeight) * canvasWidth;
  615. const worldHeight = config.height.toNumber("meters");
  616. const x =
  617. ((coords.x - config.x) / worldWidth + 0.5) * (canvasWidth - 50) + 50;
  618. const y =
  619. (1 - (coords.y - config.y) / worldHeight) * (canvasHeight - 50) + 50;
  620. return { x: x, y: y };
  621. }
  622. function pix2pos(coords) {
  623. const worldWidth =
  624. (config.height.toNumber("meters") / canvasHeight) * canvasWidth;
  625. const worldHeight = config.height.toNumber("meters");
  626. const x =
  627. ((coords.x - 50) / (canvasWidth - 50) - 0.5) * worldWidth + config.x;
  628. const y =
  629. (1 - (coords.y - 50) / (canvasHeight - 50)) * worldHeight + config.y;
  630. return { x: x, y: y };
  631. }
  632. //#endregion
  633. //#region update
  634. function updateEntityElement(entity, element) {
  635. const position = pos2pix({ x: element.dataset.x, y: element.dataset.y });
  636. const view = entity.view;
  637. const form = entity.form;
  638. element.style.left = position.x + "px";
  639. element.style.top = position.y + "px";
  640. element.style.setProperty("--xpos", position.x + "px");
  641. element.style.setProperty(
  642. "--entity-height",
  643. "'" +
  644. entity.views[view].height
  645. .to(config.height.units[0].unit.name)
  646. .format({ precision: 2 }) +
  647. "'"
  648. );
  649. const pixels =
  650. math.divide(entity.views[view].height, config.height) *
  651. (canvasHeight - 50);
  652. const extra = entity.views[view].image.extra;
  653. const bottom = entity.views[view].image.bottom;
  654. const bonus = (extra ? extra : 1) * (1 / (1 - (bottom ? bottom : 0)));
  655. let height = pixels * bonus;
  656. // working around a Firefox issue here
  657. if (height > 17895698) {
  658. height = 0;
  659. }
  660. element.style.setProperty("--height", height + "px");
  661. element.style.setProperty("--extra", height - pixels + "px");
  662. if (entity.views[view].rename)
  663. element.querySelector(".entity-name").innerText =
  664. entity.name == "" ? "" : entity.views[view].name;
  665. else if (
  666. entity.forms !== undefined &&
  667. Object.keys(entity.forms).length > 0 &&
  668. entity.forms[form].rename
  669. )
  670. element.querySelector(".entity-name").innerText =
  671. entity.name == "" ? "" : entity.forms[form].name;
  672. else element.querySelector(".entity-name").innerText = entity.name;
  673. const bottomName = document.querySelector(
  674. "#bottom-name-" + element.dataset.key
  675. );
  676. bottomName.style.left = position.x + entityX + "px";
  677. bottomName.style.bottom = "0vh";
  678. bottomName.innerText = entity.name;
  679. const topName = document.querySelector("#top-name-" + element.dataset.key);
  680. topName.style.left = position.x + entityX + "px";
  681. topName.style.top = "20vh";
  682. topName.innerText = entity.name;
  683. if (
  684. entity.views[view].height.toNumber("meters") / 10 >
  685. config.height.toNumber("meters")
  686. ) {
  687. topName.classList.add("top-name-needed");
  688. } else {
  689. topName.classList.remove("top-name-needed");
  690. }
  691. updateInfo();
  692. }
  693. function updateInfo() {
  694. let text = "";
  695. if (config.showRatios) {
  696. if (
  697. selectedEntity !== null &&
  698. prevSelectedEntity !== null &&
  699. selectedEntity !== prevSelectedEntity
  700. ) {
  701. let first = selectedEntity.currentView.height;
  702. let second = prevSelectedEntity.currentView.height;
  703. if (first.toNumber("meters") < second.toNumber("meters")) {
  704. text +=
  705. selectedEntity.name +
  706. " is " +
  707. math.format(math.divide(second, first), { precision: 5 }) +
  708. " times smaller than " +
  709. prevSelectedEntity.name;
  710. } else {
  711. text +=
  712. selectedEntity.name +
  713. " is " +
  714. math.format(math.divide(first, second), { precision: 5 }) +
  715. " times taller than " +
  716. prevSelectedEntity.name;
  717. }
  718. text += "\n";
  719. let apparentHeight = math.multiply(
  720. math.divide(second, first),
  721. math.unit(6, "feet")
  722. );
  723. if (config.units === "metric") {
  724. apparentHeight = apparentHeight.to("meters");
  725. }
  726. text +=
  727. prevSelectedEntity.name +
  728. " looks " +
  729. math.format(apparentHeight, { precision: 3 }) +
  730. " tall to " +
  731. selectedEntity.name +
  732. "\n";
  733. if (
  734. selectedEntity.currentView.weight &&
  735. prevSelectedEntity.currentView.weight
  736. ) {
  737. const ratio = math.divide(
  738. selectedEntity.currentView.weight,
  739. prevSelectedEntity.currentView.weight
  740. );
  741. if (ratio > 1) {
  742. text +=
  743. selectedEntity.name +
  744. " is " +
  745. math.format(ratio, { precision: 2 }) +
  746. " times heavier than " +
  747. prevSelectedEntity.name +
  748. "\n";
  749. } else {
  750. text +=
  751. selectedEntity.name +
  752. " is " +
  753. math.format(1 / ratio, { precision: 2 }) +
  754. " times lighter than " +
  755. prevSelectedEntity.name +
  756. "\n";
  757. }
  758. }
  759. const capacity =
  760. selectedEntity.currentView.preyCapacity ??
  761. selectedEntity.currentView.capacity ??
  762. selectedEntity.currentView.volume;
  763. if (capacity && prevSelectedEntity.currentView.weight) {
  764. const containCount = math.divide(
  765. capacity,
  766. math.divide(
  767. prevSelectedEntity.currentView.weight,
  768. math.unit("80kg/people")
  769. )
  770. );
  771. if (containCount > 0.1) {
  772. text +=
  773. selectedEntity.name +
  774. " can fit " +
  775. math.format(containCount, { precision: 1 }) +
  776. " of " +
  777. prevSelectedEntity.name +
  778. " inside them" +
  779. "\n";
  780. }
  781. }
  782. const swallowSize =
  783. selectedEntity.currentView.swallowSize;
  784. if (swallowSize && prevSelectedEntity.currentView.weight) {
  785. const containCount = math.divide(
  786. swallowSize,
  787. math.divide(
  788. prevSelectedEntity.currentView.weight,
  789. math.unit("80kg/people")
  790. )
  791. );
  792. if (containCount > 0.1) {
  793. text +=
  794. selectedEntity.name +
  795. " can swallow " +
  796. math.format(containCount, { precision: 3 }) +
  797. " of " +
  798. prevSelectedEntity.name +
  799. " at once" +
  800. "\n";
  801. }
  802. }
  803. if (
  804. selectedEntity.currentView.energyIntake &&
  805. prevSelectedEntity.currentView.energyValue
  806. ) {
  807. const consumeCount = math.divide(
  808. selectedEntity.currentView.energyIntake,
  809. prevSelectedEntity.currentView.energyValue
  810. );
  811. if (consumeCount > 0.1) {
  812. text +=
  813. selectedEntity.name +
  814. " needs to eat " +
  815. math.format(consumeCount, { precision: 1 }) +
  816. " of " +
  817. prevSelectedEntity.name +
  818. " per day" +
  819. "\n";
  820. }
  821. }
  822. // todo needs a nice system for formatting this
  823. Object.entries(selectedEntity.currentView.attributes).forEach(
  824. ([key, attr]) => {
  825. if (key !== "height") {
  826. if (attr.type === "length") {
  827. const ratio = math.divide(
  828. selectedEntity.currentView[key],
  829. prevSelectedEntity.currentView.height
  830. );
  831. if (ratio > 1) {
  832. text +=
  833. selectedEntity.name +
  834. "'s " +
  835. attr.name +
  836. " is " +
  837. math.format(ratio, { precision: 2 }) +
  838. " times longer than " +
  839. prevSelectedEntity.name +
  840. " is tall\n";
  841. } else {
  842. text +=
  843. selectedEntity.name +
  844. "'s " +
  845. attr.name +
  846. " is " +
  847. math.format(1 / ratio, { precision: 2 }) +
  848. " times shorter than " +
  849. prevSelectedEntity.name +
  850. " is tall\n";
  851. }
  852. }
  853. }
  854. }
  855. );
  856. }
  857. }
  858. if (config.showHorizon) {
  859. if (selectedEntity !== null) {
  860. const y = document.querySelector("#entity-" + selectedEntity.index)
  861. .dataset.y;
  862. const R = math.unit(1.2756e7, "meters");
  863. const h = math.add(
  864. selectedEntity.currentView.height,
  865. math.unit(y, "meters")
  866. );
  867. const first = math.multiply(2, math.multiply(R, h));
  868. const second = math.multiply(h, h);
  869. const sightline = math
  870. .sqrt(math.add(first, second))
  871. .to(config.height.units[0].unit.name);
  872. sightline.fixPrefix = false;
  873. text +=
  874. selectedEntity.name +
  875. " could see for " +
  876. math.format(sightline, { precision: 3 }) +
  877. "\n";
  878. }
  879. }
  880. if (config.showRatios && config.showHorizon) {
  881. if (
  882. selectedEntity !== null &&
  883. prevSelectedEntity !== null &&
  884. selectedEntity !== prevSelectedEntity
  885. ) {
  886. const y1 = document.querySelector("#entity-" + selectedEntity.index)
  887. .dataset.y;
  888. const y2 = document.querySelector(
  889. "#entity-" + prevSelectedEntity.index
  890. ).dataset.y;
  891. const R = math.unit(1.2756e7, "meters");
  892. const R2 = math.subtract(
  893. math.subtract(R, prevSelectedEntity.currentView.height),
  894. math.unit(y2, "meters")
  895. );
  896. const h = math.add(
  897. selectedEntity.currentView.height,
  898. math.unit(y1, "meters")
  899. );
  900. const first = math.pow(math.add(R, h), 2);
  901. const second = math.pow(R2, 2);
  902. const sightline = math
  903. .sqrt(math.subtract(first, second))
  904. .to(config.height.units[0].unit.name);
  905. sightline.fixPrefix = false;
  906. text +=
  907. selectedEntity.name +
  908. " could see " +
  909. prevSelectedEntity.name +
  910. " from " +
  911. math.format(sightline, { precision: 3 }) +
  912. " away\n";
  913. }
  914. }
  915. ratioInfo.innerText = text;
  916. }
  917. function updateEntityProperties(element) {
  918. entity = entities[element.dataset.key]
  919. element.style.setProperty("--flipped", entity.flipped ? -1 : 1);
  920. element.style.setProperty(
  921. "--rotation",
  922. (entity.rotation * 180) / Math.PI +
  923. "deg"
  924. );
  925. element.style.setProperty("--brightness", entity.brightness);
  926. }
  927. function updateSizes(dirtyOnly = false) {
  928. updateInfo();
  929. if (config.lockYAxis) {
  930. config.y = -getVerticalOffset();
  931. }
  932. drawScales(dirtyOnly);
  933. let ordered = Object.entries(entities);
  934. ordered.sort((e1, e2) => {
  935. if (e1[1].priority != e2[1].priority) {
  936. return e2[1].priority - e1[1].priority;
  937. } else {
  938. return (
  939. e1[1].views[e1[1].view].height.value -
  940. e2[1].views[e2[1].view].height.value
  941. );
  942. }
  943. });
  944. let zIndex = ordered.length + 1;
  945. let groundSet = false;
  946. ordered.forEach((entity) => {
  947. if (!groundSet && entity[1].priority < 0) {
  948. document.querySelector("#ground").style.zIndex = zIndex;
  949. zIndex -= 1;
  950. groundSet = true;
  951. }
  952. const element = document.querySelector("#entity-" + entity[0]);
  953. element.style.zIndex = zIndex;
  954. if (!dirtyOnly || entity[1].dirty) {
  955. updateEntityElement(entity[1], element, zIndex);
  956. entity[1].dirty = false;
  957. }
  958. zIndex -= 1;
  959. });
  960. if (!groundSet) {
  961. document.querySelector("#ground").style.zIndex = zIndex;
  962. }
  963. document.querySelector("#ground").style.top =
  964. pos2pix({ x: 0, y: 0 }).y + "px";
  965. drawRulers();
  966. }
  967. //#endregion
  968. function pickUnit() {
  969. if (!config.autoUnits) {
  970. return;
  971. }
  972. let type = null;
  973. let category = null;
  974. const heightSelect = document.querySelector("#options-height-unit");
  975. currentUnit = heightSelect.value;
  976. Object.keys(unitChoices).forEach((unitType) => {
  977. Object.keys(unitChoices[unitType]).forEach((unitCategory) => {
  978. if (unitChoices[unitType][unitCategory].includes(currentUnit)) {
  979. type = unitType;
  980. category = unitCategory;
  981. }
  982. });
  983. });
  984. // This should only happen if the unit selector isn't set up yet.
  985. // It doesn't really matter what goes into it.
  986. if (type === null || category === null) {
  987. return "meters";
  988. }
  989. const choices = unitChoices[type][category].map((unit) => {
  990. let value = config.height.toNumber(unit);
  991. if (value < 1) {
  992. value = 1 / value / value;
  993. }
  994. return [unit, value];
  995. });
  996. heightSelect.value = choices.sort((a, b) => {
  997. return a[1] - b[1];
  998. })[0][0];
  999. selectNewUnit();
  1000. }
  1001. //#region drawing
  1002. function cleanRulers() {
  1003. rulers = rulers.filter(ruler => {
  1004. if (!ruler.entityKey) {
  1005. return true;
  1006. } else {
  1007. return entities[ruler.entityKey] !== undefined;
  1008. }
  1009. });
  1010. }
  1011. function drawRulers() {
  1012. cleanRulers();
  1013. const canvas = document.querySelector("#rulers");
  1014. /** @type {CanvasRenderingContext2D} */
  1015. const ctx = canvas.getContext("2d");
  1016. const deviceScale = window.devicePixelRatio;
  1017. ctx.canvas.width = Math.floor(canvas.clientWidth * deviceScale);
  1018. ctx.canvas.height = Math.floor(canvas.clientHeight * deviceScale);
  1019. ctx.scale(deviceScale, deviceScale);
  1020. rulers.concat(currentRuler ? [currentRuler] : []).forEach((rulerDef) => {
  1021. let x0 = rulerDef.x0;
  1022. let y0 = rulerDef.y0;
  1023. let x1 = rulerDef.x1;
  1024. let y1 = rulerDef.y1;
  1025. if (rulerDef.entityKey !== null) {
  1026. const entity = entities[rulerDef.entityKey];
  1027. const entityElement = document.querySelector(
  1028. "#entity-" + rulerDef.entityKey
  1029. );
  1030. x0 *= entity.scale;
  1031. y0 *= entity.scale;
  1032. x1 *= entity.scale;
  1033. y1 *= entity.scale;
  1034. x0 += parseFloat(entityElement.dataset.x);
  1035. x1 += parseFloat(entityElement.dataset.x);
  1036. y0 += parseFloat(entityElement.dataset.y);
  1037. y1 += parseFloat(entityElement.dataset.y);
  1038. }
  1039. ctx.save();
  1040. ctx.beginPath();
  1041. const start = pos2pix({ x: x0, y: y0 });
  1042. const end = pos2pix({ x: x1, y: y1 });
  1043. ctx.moveTo(start.x, start.y);
  1044. ctx.lineTo(end.x, end.y);
  1045. ctx.lineWidth = 5;
  1046. ctx.strokeStyle = "#f8f";
  1047. ctx.stroke();
  1048. const center = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
  1049. ctx.fillStyle = "#eeeeee";
  1050. ctx.font = "normal 24pt coda";
  1051. ctx.translate(center.x, center.y);
  1052. let angle = Math.atan2(end.y - start.y, end.x - start.x);
  1053. if (angle < -Math.PI / 2) {
  1054. angle += Math.PI;
  1055. }
  1056. if (angle > Math.PI / 2) {
  1057. angle -= Math.PI;
  1058. }
  1059. ctx.rotate(angle);
  1060. const offsetX = Math.cos(angle + Math.PI / 2);
  1061. const offsetY = Math.sin(angle + Math.PI / 2);
  1062. const distance = Math.sqrt(Math.pow(y1 - y0, 2) + Math.pow(x1 - x0, 2));
  1063. const distanceInUnits = math
  1064. .unit(distance, "meters")
  1065. .to(document.querySelector("#options-height-unit").value);
  1066. const textSize = ctx.measureText(
  1067. distanceInUnits.format({ precision: 3 })
  1068. );
  1069. ctx.fillText(
  1070. distanceInUnits.format({ precision: 3 }),
  1071. -offsetX * 10 - textSize.width / 2,
  1072. -offsetY * 10
  1073. );
  1074. ctx.restore();
  1075. });
  1076. }
  1077. function drawScales(ifDirty = false) {
  1078. const canvas = document.querySelector("#display");
  1079. /** @type {CanvasRenderingContext2D} */
  1080. const ctx = canvas.getContext("2d");
  1081. const deviceScale = window.devicePixelRatio;
  1082. ctx.canvas.width = Math.floor(canvas.clientWidth * deviceScale);
  1083. ctx.canvas.height = Math.floor(canvas.clientHeight * deviceScale);
  1084. ctx.scale(deviceScale, deviceScale);
  1085. ctx.beginPath();
  1086. ctx.rect(
  1087. 0,
  1088. 0,
  1089. ctx.canvas.width / deviceScale,
  1090. ctx.canvas.height / deviceScale
  1091. );
  1092. switch (config.background) {
  1093. case "black":
  1094. ctx.fillStyle = "#000";
  1095. break;
  1096. case "dark":
  1097. ctx.fillStyle = "#111";
  1098. break;
  1099. case "medium":
  1100. ctx.fillStyle = "#333";
  1101. break;
  1102. case "light":
  1103. ctx.fillStyle = "#555";
  1104. break;
  1105. }
  1106. ctx.fill();
  1107. if (config.drawYAxis || config.drawAltitudes !== "none") {
  1108. drawVerticalScale(ifDirty);
  1109. }
  1110. if (config.drawXAxis) {
  1111. drawHorizontalScale(ifDirty);
  1112. }
  1113. }
  1114. function drawVerticalScale(ifDirty = false) {
  1115. if (ifDirty && !worldSizeDirty) return;
  1116. function drawTicks(
  1117. /** @type {CanvasRenderingContext2D} */ ctx,
  1118. pixelsPer,
  1119. heightPer
  1120. ) {
  1121. let total = heightPer.clone();
  1122. total.value = config.y;
  1123. let y = ctx.canvas.clientHeight - 50;
  1124. let offset = total.toNumber("meters") % heightPer.toNumber("meters");
  1125. y += (offset / heightPer.toNumber("meters")) * pixelsPer;
  1126. total = math.subtract(total, math.unit(offset, "meters"));
  1127. for (; y >= 50; y -= pixelsPer) {
  1128. drawTick(ctx, 50, y, total.format({ precision: 3 }));
  1129. total = math.add(total, heightPer);
  1130. }
  1131. }
  1132. function drawTick(
  1133. /** @type {CanvasRenderingContext2D} */ ctx,
  1134. x,
  1135. y,
  1136. label,
  1137. flipped = false
  1138. ) {
  1139. const oldStroke = ctx.strokeStyle;
  1140. const oldFill = ctx.fillStyle;
  1141. x = Math.round(x);
  1142. y = Math.round(y);
  1143. ctx.beginPath();
  1144. ctx.moveTo(x, y);
  1145. ctx.lineTo(x + 20, y);
  1146. ctx.strokeStyle = "#000000";
  1147. ctx.stroke();
  1148. ctx.beginPath();
  1149. ctx.moveTo(x + 20, y);
  1150. ctx.lineTo(ctx.canvas.clientWidth - 70, y);
  1151. if (flipped) {
  1152. ctx.strokeStyle = "#666666";
  1153. } else {
  1154. ctx.strokeStyle = "#aaaaaa";
  1155. }
  1156. ctx.stroke();
  1157. ctx.beginPath();
  1158. ctx.moveTo(ctx.canvas.clientWidth - 70, y);
  1159. ctx.lineTo(ctx.canvas.clientWidth - 50, y);
  1160. ctx.strokeStyle = "#000000";
  1161. ctx.stroke();
  1162. const oldFont = ctx.font;
  1163. ctx.font = "normal 24pt coda";
  1164. ctx.fillStyle = "#dddddd";
  1165. ctx.beginPath();
  1166. if (flipped) {
  1167. ctx.textAlign = "end";
  1168. ctx.fillText(label, ctx.canvas.clientWidth - 70, y + 35);
  1169. } else {
  1170. ctx.fillText(label, x + 20, y + 35);
  1171. }
  1172. ctx.textAlign = "start";
  1173. ctx.font = oldFont;
  1174. ctx.strokeStyle = oldStroke;
  1175. ctx.fillStyle = oldFill;
  1176. }
  1177. function drawAltitudeLine(ctx, height, label) {
  1178. const pixelScale =
  1179. (ctx.canvas.clientHeight - 100) / config.height.toNumber("meters");
  1180. const y =
  1181. ctx.canvas.clientHeight -
  1182. 50 -
  1183. (height.toNumber("meters") - config.y) * pixelScale;
  1184. const offsetY = y + getVerticalOffset() * pixelScale;
  1185. if (offsetY < ctx.canvas.clientHeight - 100) {
  1186. drawTick(ctx, 50, y, label, true);
  1187. }
  1188. }
  1189. const canvas = document.querySelector("#display");
  1190. /** @type {CanvasRenderingContext2D} */
  1191. const ctx = canvas.getContext("2d");
  1192. const pixelScale =
  1193. (ctx.canvas.clientHeight - 100) / config.height.toNumber();
  1194. let pixelsPer = pixelScale;
  1195. heightPer = 1;
  1196. if (pixelsPer < config.minLineSize) {
  1197. const factor = math.ceil(config.minLineSize / pixelsPer);
  1198. heightPer *= factor;
  1199. pixelsPer *= factor;
  1200. }
  1201. if (pixelsPer > config.maxLineSize) {
  1202. const factor = math.ceil(pixelsPer / config.maxLineSize);
  1203. heightPer /= factor;
  1204. pixelsPer /= factor;
  1205. }
  1206. if (heightPer == 0) {
  1207. console.error(
  1208. "The world size is invalid! Refusing to draw the scale..."
  1209. );
  1210. return;
  1211. }
  1212. heightPer = math.unit(
  1213. heightPer,
  1214. document.querySelector("#options-height-unit").value
  1215. );
  1216. ctx.beginPath();
  1217. ctx.moveTo(50, 50);
  1218. ctx.lineTo(50, ctx.canvas.clientHeight - 50);
  1219. ctx.stroke();
  1220. ctx.beginPath();
  1221. ctx.moveTo(ctx.canvas.clientWidth - 50, 50);
  1222. ctx.lineTo(ctx.canvas.clientWidth - 50, ctx.canvas.clientHeight - 50);
  1223. ctx.stroke();
  1224. if (config.drawYAxis) {
  1225. drawTicks(ctx, pixelsPer, heightPer);
  1226. }
  1227. if (config.drawAltitudes == "atmosphere" || config.drawAltitudes == "all") {
  1228. drawAltitudeLine(ctx, math.unit(8, "km"), "Troposphere");
  1229. drawAltitudeLine(ctx, math.unit(17.5, "km"), "Ozone Layer");
  1230. drawAltitudeLine(ctx, math.unit(50, "km"), "Stratosphere");
  1231. drawAltitudeLine(ctx, math.unit(85, "km"), "Mesosphere");
  1232. drawAltitudeLine(ctx, math.unit(675, "km"), "Thermosphere");
  1233. drawAltitudeLine(ctx, math.unit(10000, "km"), "Exosphere");
  1234. }
  1235. if (config.drawAltitudes == "orbits" || config.drawAltitudes == "all") {
  1236. drawAltitudeLine(ctx, math.unit(7, "miles"), "Cruising Altitude");
  1237. drawAltitudeLine(
  1238. ctx,
  1239. math.unit(100, "km"),
  1240. "Edge of Space (Kármán line)"
  1241. );
  1242. drawAltitudeLine(ctx, math.unit(211.3, "miles"), "Space Station");
  1243. drawAltitudeLine(ctx, math.unit(369.7, "miles"), "Hubble Telescope");
  1244. drawAltitudeLine(ctx, math.unit(1500, "km"), "Low Earth Orbit");
  1245. drawAltitudeLine(ctx, math.unit(20350, "km"), "GPS Satellites");
  1246. drawAltitudeLine(ctx, math.unit(35786, "km"), "Geosynchronous Orbit");
  1247. drawAltitudeLine(ctx, math.unit(238900, "miles"), "Lunar Orbit");
  1248. drawAltitudeLine(ctx, math.unit(57.9e6, "km"), "Orbit of Mercury");
  1249. drawAltitudeLine(ctx, math.unit(108.2e6, "km"), "Orbit of Venus");
  1250. drawAltitudeLine(ctx, math.unit(1, "AU"), "Orbit of Earth");
  1251. drawAltitudeLine(ctx, math.unit(227.9e6, "km"), "Orbit of Mars");
  1252. drawAltitudeLine(ctx, math.unit(778.6e6, "km"), "Orbit of Jupiter");
  1253. drawAltitudeLine(ctx, math.unit(1433.5e6, "km"), "Orbit of Saturn");
  1254. drawAltitudeLine(ctx, math.unit(2872.5e6, "km"), "Orbit of Uranus");
  1255. drawAltitudeLine(ctx, math.unit(4495.1e6, "km"), "Orbit of Neptune");
  1256. drawAltitudeLine(ctx, math.unit(5906.4e6, "km"), "Orbit of Pluto");
  1257. drawAltitudeLine(ctx, math.unit(2.7, "AU"), "Asteroid Belt");
  1258. drawAltitudeLine(ctx, math.unit(123, "AU"), "Heliopause");
  1259. drawAltitudeLine(ctx, math.unit(26e3, "lightyears"), "Orbit of Sol");
  1260. }
  1261. if (config.drawAltitudes == "weather" || config.drawAltitudes == "all") {
  1262. drawAltitudeLine(ctx, math.unit(1000, "meters"), "Low-level Clouds");
  1263. drawAltitudeLine(ctx, math.unit(3000, "meters"), "Mid-level Clouds");
  1264. drawAltitudeLine(ctx, math.unit(10000, "meters"), "High-level Clouds");
  1265. drawAltitudeLine(
  1266. ctx,
  1267. math.unit(20, "km"),
  1268. "Polar Stratospheric Clouds"
  1269. );
  1270. drawAltitudeLine(ctx, math.unit(80, "km"), "Noctilucent Clouds");
  1271. drawAltitudeLine(ctx, math.unit(100, "km"), "Aurora");
  1272. }
  1273. if (config.drawAltitudes == "water" || config.drawAltitudes == "all") {
  1274. drawAltitudeLine(ctx, math.unit(12100, "feet"), "Average Ocean Depth");
  1275. drawAltitudeLine(ctx, math.unit(8376, "meters"), "Milkwaukee Deep");
  1276. drawAltitudeLine(ctx, math.unit(10984, "meters"), "Challenger Deep");
  1277. drawAltitudeLine(ctx, math.unit(5550, "meters"), "Molloy Deep");
  1278. drawAltitudeLine(ctx, math.unit(7290, "meters"), "Sunda Deep");
  1279. drawAltitudeLine(ctx, math.unit(592, "meters"), "Crater Lake");
  1280. drawAltitudeLine(ctx, math.unit(7.5, "meters"), "Littoral Zone");
  1281. drawAltitudeLine(ctx, math.unit(140, "meters"), "Continental Shelf");
  1282. }
  1283. if (config.drawAltitudes == "geology" || config.drawAltitudes == "all") {
  1284. drawAltitudeLine(ctx, math.unit(35, "km"), "Crust");
  1285. drawAltitudeLine(ctx, math.unit(670, "km"), "Upper Mantle");
  1286. drawAltitudeLine(ctx, math.unit(2890, "km"), "Lower Mantle");
  1287. drawAltitudeLine(ctx, math.unit(5150, "km"), "Outer Core");
  1288. drawAltitudeLine(ctx, math.unit(6370, "km"), "Inner Core");
  1289. }
  1290. if (
  1291. config.drawAltitudes == "thicknesses" ||
  1292. config.drawAltitudes == "all"
  1293. ) {
  1294. drawAltitudeLine(ctx, math.unit(0.335, "nm"), "Monolayer Graphene");
  1295. drawAltitudeLine(ctx, math.unit(3, "um"), "Spider Silk");
  1296. drawAltitudeLine(ctx, math.unit(0.07, "mm"), "Human Hair");
  1297. drawAltitudeLine(ctx, math.unit(0.1, "mm"), "Sheet of Paper");
  1298. drawAltitudeLine(ctx, math.unit(0.5, "mm"), "Yarn");
  1299. drawAltitudeLine(ctx, math.unit(0.0155, "inches"), "Thread");
  1300. drawAltitudeLine(ctx, math.unit(0.1, "um"), "Gold Leaf");
  1301. drawAltitudeLine(ctx, math.unit(35, "um"), "PCB Trace");
  1302. }
  1303. if (config.drawAltitudes == "airspaces" || config.drawAltitudes == "all") {
  1304. drawAltitudeLine(ctx, math.unit(18000, "feet"), "Class A");
  1305. drawAltitudeLine(ctx, math.unit(14500, "feet"), "Class E");
  1306. drawAltitudeLine(ctx, math.unit(10000, "feet"), "Class B");
  1307. drawAltitudeLine(ctx, math.unit(4000, "feet"), "Class C");
  1308. drawAltitudeLine(ctx, math.unit(2500, "feet"), "Class D");
  1309. }
  1310. if (config.drawAltitudes == "races" || config.drawAltitudes == "all") {
  1311. drawAltitudeLine(ctx, math.unit(100, "meters"), "100m Dash");
  1312. drawAltitudeLine(ctx, math.unit(26.2188 / 2, "miles"), "Half Marathon");
  1313. drawAltitudeLine(ctx, math.unit(26.2188, "miles"), "Marathon");
  1314. drawAltitudeLine(ctx, math.unit(161.734, "miles"), "Monaco Grand Prix");
  1315. drawAltitudeLine(ctx, math.unit(500, "miles"), "Daytona 500");
  1316. drawAltitudeLine(ctx, math.unit(2121.6, "miles"), "Tour de France");
  1317. }
  1318. if (
  1319. config.drawAltitudes == "olympic-records" ||
  1320. config.drawAltitudes == "all"
  1321. ) {
  1322. drawAltitudeLine(ctx, math.unit(2.39, "meters"), "High Jump");
  1323. drawAltitudeLine(ctx, math.unit(6.03, "meters"), "Pole Vault");
  1324. drawAltitudeLine(ctx, math.unit(8.9, "meters"), "Long Jump");
  1325. drawAltitudeLine(ctx, math.unit(18.09, "meters"), "Triple Jump");
  1326. drawAltitudeLine(ctx, math.unit(23.3, "meters"), "Shot Put");
  1327. drawAltitudeLine(ctx, math.unit(72.3, "meters"), "Discus Throw");
  1328. drawAltitudeLine(ctx, math.unit(84.8, "meters"), "Hammer Throw");
  1329. drawAltitudeLine(ctx, math.unit(90.57, "meters"), "Javelin Throw");
  1330. }
  1331. if (config.drawAltitudes == "d&d-sizes" || config.drawAltitudes == "all") {
  1332. drawAltitudeLine(ctx, math.unit(0.375, "feet"), "Fine");
  1333. drawAltitudeLine(ctx, math.unit(0.75, "feet"), "Dimnutive");
  1334. drawAltitudeLine(ctx, math.unit(1.5, "feet"), "Tiny");
  1335. drawAltitudeLine(ctx, math.unit(3, "feet"), "Small");
  1336. drawAltitudeLine(ctx, math.unit(6, "feet"), "Medium");
  1337. drawAltitudeLine(ctx, math.unit(12, "feet"), "Large");
  1338. drawAltitudeLine(ctx, math.unit(24, "feet"), "Huge");
  1339. drawAltitudeLine(ctx, math.unit(48, "feet"), "Gargantuan");
  1340. drawAltitudeLine(ctx, math.unit(96, "feet"), "Colossal");
  1341. }
  1342. }
  1343. // this is a lot of copypizza...
  1344. function drawHorizontalScale(ifDirty = false) {
  1345. if (ifDirty && !worldSizeDirty) return;
  1346. function drawTicks(
  1347. /** @type {CanvasRenderingContext2D} */ ctx,
  1348. pixelsPer,
  1349. heightPer
  1350. ) {
  1351. let total = heightPer.clone();
  1352. total.value = math.unit(-config.x, "meters").toNumber(config.unit);
  1353. // further adjust it to put the current position in the center
  1354. total.value -=
  1355. ((heightPer.toNumber("meters") / pixelsPer) * (canvasWidth + 50)) /
  1356. 2;
  1357. let x = ctx.canvas.clientWidth - 50;
  1358. let offset = total.toNumber("meters") % heightPer.toNumber("meters");
  1359. x += (offset / heightPer.toNumber("meters")) * pixelsPer;
  1360. total = math.subtract(total, math.unit(offset, "meters"));
  1361. for (; x >= 50 - pixelsPer; x -= pixelsPer) {
  1362. // negate it so that the left side is negative
  1363. drawTick(
  1364. ctx,
  1365. x,
  1366. 50,
  1367. math.multiply(-1, total).format({ precision: 3 })
  1368. );
  1369. total = math.add(total, heightPer);
  1370. }
  1371. }
  1372. function drawTick(
  1373. /** @type {CanvasRenderingContext2D} */ ctx,
  1374. x,
  1375. y,
  1376. label
  1377. ) {
  1378. ctx.save();
  1379. x = Math.round(x);
  1380. y = Math.round(y);
  1381. ctx.beginPath();
  1382. ctx.moveTo(x, y);
  1383. ctx.lineTo(x, y + 20);
  1384. ctx.strokeStyle = "#000000";
  1385. ctx.stroke();
  1386. ctx.beginPath();
  1387. ctx.moveTo(x, y + 20);
  1388. ctx.lineTo(x, ctx.canvas.clientHeight - 70);
  1389. ctx.strokeStyle = "#aaaaaa";
  1390. ctx.stroke();
  1391. ctx.beginPath();
  1392. ctx.moveTo(x, ctx.canvas.clientHeight - 70);
  1393. ctx.lineTo(x, ctx.canvas.clientHeight - 50);
  1394. ctx.strokeStyle = "#000000";
  1395. ctx.stroke();
  1396. const oldFont = ctx.font;
  1397. ctx.font = "normal 24pt coda";
  1398. ctx.fillStyle = "#dddddd";
  1399. ctx.beginPath();
  1400. ctx.fillText(label, x + 35, y + 20);
  1401. ctx.restore();
  1402. }
  1403. const canvas = document.querySelector("#display");
  1404. /** @type {CanvasRenderingContext2D} */
  1405. const ctx = canvas.getContext("2d");
  1406. let pixelsPer = (ctx.canvas.clientHeight - 100) / config.height.toNumber();
  1407. heightPer = 1;
  1408. if (pixelsPer < config.minLineSize * 2) {
  1409. const factor = math.ceil((config.minLineSize * 2) / pixelsPer);
  1410. heightPer *= factor;
  1411. pixelsPer *= factor;
  1412. }
  1413. if (pixelsPer > config.maxLineSize * 2) {
  1414. const factor = math.ceil(pixelsPer / 2 / config.maxLineSize);
  1415. heightPer /= factor;
  1416. pixelsPer /= factor;
  1417. }
  1418. if (heightPer == 0) {
  1419. console.error(
  1420. "The world size is invalid! Refusing to draw the scale..."
  1421. );
  1422. return;
  1423. }
  1424. heightPer = math.unit(
  1425. heightPer,
  1426. document.querySelector("#options-height-unit").value
  1427. );
  1428. ctx.beginPath();
  1429. ctx.moveTo(0, 50);
  1430. ctx.lineTo(ctx.canvas.clientWidth, 50);
  1431. ctx.stroke();
  1432. ctx.beginPath();
  1433. ctx.moveTo(0, ctx.canvas.clientHeight - 50);
  1434. ctx.lineTo(ctx.canvas.clientWidth, ctx.canvas.clientHeight - 50);
  1435. ctx.stroke();
  1436. drawTicks(ctx, pixelsPer, heightPer);
  1437. }
  1438. //#endregion
  1439. //#region entities
  1440. // Entities are generated as needed, and we make a copy
  1441. // every time - the resulting objects get mutated, after all.
  1442. // But we also want to be able to read some information without
  1443. // calling the constructor -- e.g. making a list of authors and
  1444. // owners. So, this function is used to generate that information.
  1445. // It is invoked like makeEntity so that it can be dropped in easily,
  1446. // but returns an object that lets you construct many copies of an entity,
  1447. // rather than creating a new entity.
  1448. function createEntityMaker(info, views, sizes, forms) {
  1449. const maker = {};
  1450. maker.name = info.name;
  1451. maker.info = info;
  1452. maker.sizes = sizes;
  1453. maker.constructor = () => makeEntity(info, views, sizes, forms);
  1454. maker.authors = [];
  1455. maker.owners = [];
  1456. maker.nsfw = false;
  1457. Object.values(views).forEach((view) => {
  1458. const authors = authorsOf(view.image.source);
  1459. if (authors) {
  1460. authors.forEach((author) => {
  1461. if (maker.authors.indexOf(author) == -1) {
  1462. maker.authors.push(author);
  1463. }
  1464. });
  1465. }
  1466. const owners = ownersOf(view.image.source);
  1467. if (owners) {
  1468. owners.forEach((owner) => {
  1469. if (maker.owners.indexOf(owner) == -1) {
  1470. maker.owners.push(owner);
  1471. }
  1472. });
  1473. }
  1474. if (isNsfw(view.image.source)) {
  1475. maker.nsfw = true;
  1476. }
  1477. });
  1478. return maker;
  1479. }
  1480. // Sets up the getters for each attribute. This needs to be
  1481. // re-run if we add new attributes to an entity, so it's
  1482. // broken out from makeEntity.
  1483. function defineAttributeGetters(view) {
  1484. Object.entries(view.attributes).forEach(([key, val]) => {
  1485. if (val.defaultUnit !== undefined) {
  1486. view.units[key] = val.defaultUnit;
  1487. }
  1488. if (view[key] !== undefined) {
  1489. return;
  1490. }
  1491. Object.defineProperty(view, key, {
  1492. get: function () {
  1493. return math.multiply(
  1494. Math.pow(
  1495. this.parent.scale,
  1496. this.attributes[key].power
  1497. ),
  1498. this.attributes[key].base
  1499. );
  1500. },
  1501. set: function (value) {
  1502. const newScale = Math.pow(
  1503. math.divide(value, this.attributes[key].base),
  1504. 1 / this.attributes[key].power
  1505. );
  1506. this.parent.scale = newScale;
  1507. },
  1508. });
  1509. });
  1510. }
  1511. // This function serializes and parses its arguments to avoid sharing
  1512. // references to a common object. This allows for the objects to be
  1513. // safely mutated.
  1514. function makeEntity(info, views, sizes, forms = {}) {
  1515. const entityTemplate = {
  1516. name: info.name,
  1517. identifier: info.name,
  1518. scale: 1,
  1519. rotation: 0,
  1520. flipped: false,
  1521. info: JSON.parse(JSON.stringify(info)),
  1522. views: JSON.parse(JSON.stringify(views), math.reviver),
  1523. sizes:
  1524. sizes === undefined
  1525. ? []
  1526. : JSON.parse(JSON.stringify(sizes), math.reviver),
  1527. forms: forms,
  1528. init: function () {
  1529. const entity = this;
  1530. Object.entries(this.forms).forEach(([formKey, form]) => {
  1531. if (form.default) {
  1532. this.defaultForm = formKey;
  1533. }
  1534. });
  1535. Object.entries(this.forms).forEach(([formKey, form]) => {
  1536. if (this.defaultForm === undefined) {
  1537. this.defaultForm = formKey;
  1538. }
  1539. });
  1540. Object.entries(this.views).forEach(([viewKey, view]) => {
  1541. view.parent = this;
  1542. if (this.defaultView === undefined) {
  1543. this.defaultView = viewKey;
  1544. this.view = viewKey;
  1545. this.form = view.form;
  1546. }
  1547. if (view.default) {
  1548. if (forms === {} || this.defaultForm === view.form || this.defaultForm === undefined) {
  1549. this.defaultView = viewKey;
  1550. this.view = viewKey;
  1551. this.form = view.form;
  1552. }
  1553. }
  1554. // to remember the units the user last picked
  1555. // also handles default unit overrides
  1556. view.units = {};
  1557. if (
  1558. config.autoMass !== "off" &&
  1559. view.attributes.weight === undefined
  1560. ) {
  1561. let base = undefined;
  1562. switch (config.autoMass) {
  1563. case "human":
  1564. baseMass = math.unit(150, "lbs");
  1565. baseHeight = math.unit(5.917, "feet");
  1566. break;
  1567. case "quadruped at shoulder":
  1568. baseMass = math.unit(80, "lbs");
  1569. baseHeight = math.unit(30, "inches");
  1570. break;
  1571. }
  1572. const ratio = math.divide(
  1573. view.attributes.height.base,
  1574. baseHeight
  1575. );
  1576. view.attributes.weight = {
  1577. name: "Mass",
  1578. power: 3,
  1579. type: "mass",
  1580. base: math.multiply(baseMass, Math.pow(ratio, 3)),
  1581. };
  1582. }
  1583. if (
  1584. config.autoFoodIntake &&
  1585. view.attributes.weight !== undefined &&
  1586. view.attributes.energyIntake === undefined
  1587. ) {
  1588. view.attributes.energyIntake = {
  1589. name: "Food Intake",
  1590. power: (3 * 3) / 4,
  1591. type: "energy",
  1592. base: math.unit(
  1593. 2000 *
  1594. Math.pow(
  1595. view.attributes.weight.base.toNumber(
  1596. "lbs"
  1597. ) / 150,
  1598. 3 / 4
  1599. ),
  1600. "kcal"
  1601. ),
  1602. };
  1603. }
  1604. if (
  1605. config.autoCaloricValue &&
  1606. view.attributes.weight !== undefined &&
  1607. view.attributes.energyWorth === undefined
  1608. ) {
  1609. view.attributes.energyValue = {
  1610. name: "Caloric Value",
  1611. power: 3,
  1612. type: "energy",
  1613. base: math.unit(
  1614. 860 * view.attributes.weight.base.toNumber("lbs"),
  1615. "kcal"
  1616. ),
  1617. };
  1618. }
  1619. if (
  1620. config.autoPreyCapacity !== "off" &&
  1621. view.attributes.weight !== undefined &&
  1622. view.attributes.preyCapacity === undefined
  1623. ) {
  1624. view.attributes.preyCapacity = {
  1625. name: "Prey Capacity",
  1626. power: 3,
  1627. type: "volume",
  1628. base: math.unit(
  1629. ((config.autoPreyCapacity == "same-size"
  1630. ? 1
  1631. : 0.05) *
  1632. view.attributes.weight.base.toNumber("lbs")) /
  1633. 150,
  1634. "people"
  1635. ),
  1636. };
  1637. }
  1638. if (
  1639. config.autoSwallowSize !== "off" &&
  1640. view.attributes.swallowSize === undefined
  1641. ) {
  1642. let size;
  1643. switch (config.autoSwallowSize) {
  1644. case "casual": size = math.unit(20, "mL"); break;
  1645. case "big-swallow": size = math.unit(50, "mL"); break;
  1646. case "same-size-predator": size = math.unit(1, "people"); break;
  1647. }
  1648. view.attributes.swallowSize = {
  1649. name: "Swallow Size",
  1650. power: 3,
  1651. type: "volume",
  1652. base: math.multiply(size, math.pow(math.divide(view.attributes.height.base, math.unit(6, "feet")), 3))
  1653. };
  1654. }
  1655. defineAttributeGetters(view);
  1656. });
  1657. this.sizes.forEach((size) => {
  1658. if (size.default === true) {
  1659. if (Object.keys(forms).length > 0) {
  1660. if (this.defaultForm !== size.form && !size.allForms) {
  1661. return;
  1662. }
  1663. }
  1664. this.views[this.defaultView].height = size.height;
  1665. this.size = size;
  1666. }
  1667. });
  1668. if (this.size === undefined && this.sizes.length > 0) {
  1669. this.views[this.defaultView].height = this.sizes[0].height;
  1670. this.size = this.sizes[0];
  1671. console.warn("No default size set for " + info.name);
  1672. } else if (this.sizes.length == 0) {
  1673. this.sizes = [
  1674. {
  1675. name: "Normal",
  1676. height: this.views[this.defaultView].height,
  1677. },
  1678. ];
  1679. this.size = this.sizes[0];
  1680. }
  1681. this.desc = {};
  1682. Object.entries(this.info).forEach(([key, value]) => {
  1683. Object.defineProperty(this.desc, key, {
  1684. get: function () {
  1685. let text = value.text;
  1686. if (entity.views[entity.view].info) {
  1687. if (entity.views[entity.view].info[key]) {
  1688. text = combineInfo(
  1689. text,
  1690. entity.views[entity.view].info[key]
  1691. );
  1692. }
  1693. }
  1694. if (entity.size.info) {
  1695. if (entity.size.info[key]) {
  1696. text = combineInfo(text, entity.size.info[key]);
  1697. }
  1698. }
  1699. return { title: value.title, text: text };
  1700. },
  1701. });
  1702. });
  1703. Object.defineProperty(this, "currentView", {
  1704. get: function () {
  1705. return entity.views[entity.view];
  1706. },
  1707. });
  1708. this.formViews = {};
  1709. this.formSizes = {};
  1710. this.formSizesMatch = this.sizes.every(x => x.allForms);
  1711. Object.entries(views).forEach(([key, value]) => {
  1712. if (value.default) {
  1713. this.formViews[value.form] = key;
  1714. }
  1715. });
  1716. Object.entries(views).forEach(([key, value]) => {
  1717. if (this.formViews[value.form] === undefined) {
  1718. this.formViews[value.form] = key;
  1719. }
  1720. });
  1721. this.sizes.forEach((size) => {
  1722. if (size.default) {
  1723. if (size.allForms) {
  1724. Object.keys(forms).forEach(form => {
  1725. if (!forms[form].ignoreAllFormSizes) {
  1726. this.formSizes[form] = size;
  1727. }
  1728. });
  1729. } else {
  1730. this.formSizes[size.form] = size;
  1731. }
  1732. }
  1733. });
  1734. this.sizes.forEach((size) => {
  1735. if (this.formSizes[size.form] === undefined) {
  1736. this.formSizes[size.form] = size;
  1737. }
  1738. });
  1739. Object.values(views).forEach((view) => {
  1740. if (this.formSizes[view.form] === undefined) {
  1741. this.formSizes[view.form] = {
  1742. name: "Normal",
  1743. height: view.attributes.height.base,
  1744. default: true,
  1745. form: view.form,
  1746. };
  1747. }
  1748. });
  1749. delete this.init;
  1750. return this;
  1751. },
  1752. }.init();
  1753. return entityTemplate;
  1754. }
  1755. //#endregion
  1756. function combineInfo(existing, next) {
  1757. switch (next.mode) {
  1758. case "replace":
  1759. return next.text;
  1760. case "prepend":
  1761. return next.text + existing;
  1762. case "append":
  1763. return existing + next.text;
  1764. }
  1765. return existing;
  1766. }
  1767. //#region interaction
  1768. function clickDown(target, x, y) {
  1769. clicked = target;
  1770. movingInBounds = false;
  1771. const rect = target.getBoundingClientRect();
  1772. let entX = document.querySelector("#entities").getBoundingClientRect().x;
  1773. let entY = document.querySelector("#entities").getBoundingClientRect().y;
  1774. dragOffsetX = x - rect.left + entX;
  1775. dragOffsetY = y - rect.top + entY;
  1776. x = x - dragOffsetX;
  1777. y = y - dragOffsetY;
  1778. if (x >= 0 && x <= canvasWidth && y >= 0 && y <= canvasHeight) {
  1779. movingInBounds = true;
  1780. }
  1781. clickTimeout = setTimeout(() => {
  1782. dragging = true;
  1783. }, 200);
  1784. target.classList.add("no-transition");
  1785. }
  1786. // could we make this actually detect the menu area?
  1787. function hoveringInDeleteArea(e) {
  1788. return e.clientY < document.querySelector("#menubar").clientHeight;
  1789. }
  1790. function clickUp(e) {
  1791. if (e.which != 1) {
  1792. return;
  1793. }
  1794. clearTimeout(clickTimeout);
  1795. if (clicked) {
  1796. clicked.classList.remove("no-transition");
  1797. if (dragging) {
  1798. dragging = false;
  1799. if (hoveringInDeleteArea(e)) {
  1800. removeEntity(clicked);
  1801. document
  1802. .querySelector("#menubar")
  1803. .classList.remove("hover-delete");
  1804. }
  1805. } else {
  1806. select(clicked);
  1807. }
  1808. clicked = null;
  1809. }
  1810. }
  1811. function deselect(e) {
  1812. if (rulerMode) {
  1813. return;
  1814. }
  1815. if (e !== undefined && e.which != 1) {
  1816. return;
  1817. }
  1818. if (selected) {
  1819. selected.classList.remove("selected");
  1820. }
  1821. if (prevSelected) {
  1822. prevSelected.classList.remove("prevSelected");
  1823. }
  1824. document.getElementById("options-selected-entity-none").selected =
  1825. "selected";
  1826. document.getElementById("delete-entity").style.display = "none";
  1827. clearAttribution();
  1828. selected = null;
  1829. clearViewList();
  1830. clearEntityOptions();
  1831. clearViewOptions();
  1832. document.querySelector("#delete-entity").disabled = true;
  1833. document.querySelector("#grow").disabled = true;
  1834. document.querySelector("#shrink").disabled = true;
  1835. document.querySelector("#fit").disabled = true;
  1836. }
  1837. function select(target) {
  1838. if (prevSelected !== null) {
  1839. prevSelected.classList.remove("prevSelected");
  1840. }
  1841. prevSelected = selected;
  1842. prevSelectedEntity = selectedEntity;
  1843. deselect();
  1844. selected = target;
  1845. selectedEntity = entities[target.dataset.key];
  1846. updateInfo();
  1847. document.getElementById(
  1848. "options-selected-entity-" + target.dataset.key
  1849. ).selected = "selected";
  1850. document.getElementById("delete-entity").style.display = "";
  1851. if (
  1852. prevSelected !== null &&
  1853. config.showRatios &&
  1854. selected !== prevSelected
  1855. ) {
  1856. prevSelected.classList.add("prevSelected");
  1857. }
  1858. selected.classList.add("selected");
  1859. displayAttribution(selectedEntity.views[selectedEntity.view].image.source);
  1860. configFormList(selectedEntity, selectedEntity.form);
  1861. configViewList(selectedEntity, selectedEntity.view);
  1862. configEntityOptions(selectedEntity, selectedEntity.view);
  1863. configViewOptions(selectedEntity, selectedEntity.view);
  1864. document.querySelector("#delete-entity").disabled = false;
  1865. document.querySelector("#grow").disabled = false;
  1866. document.querySelector("#shrink").disabled = false;
  1867. document.querySelector("#fit").disabled = false;
  1868. }
  1869. //#endregion
  1870. //#region ui
  1871. function configFormList(entity, selectedForm) {
  1872. const label = document.querySelector("#options-label-form");
  1873. const list = document.querySelector("#entity-form");
  1874. list.innerHTML = "";
  1875. if (selectedForm === undefined) {
  1876. label.style.display = "none";
  1877. list.style.display = "none";
  1878. return;
  1879. }
  1880. label.style.display = "block";
  1881. list.style.display = "block";
  1882. Object.keys(entity.forms).forEach((form) => {
  1883. const option = document.createElement("option");
  1884. option.innerText = entity.forms[form].name;
  1885. option.value = form;
  1886. if (form === selectedForm) {
  1887. option.selected = true;
  1888. }
  1889. list.appendChild(option);
  1890. });
  1891. }
  1892. function configViewList(entity, selectedView) {
  1893. const list = document.querySelector("#entity-view");
  1894. list.innerHTML = "";
  1895. list.style.display = "block";
  1896. Object.keys(entity.views).forEach((view) => {
  1897. if (Object.keys(entity.forms).length > 0) {
  1898. if (entity.views[view].form !== undefined && entity.views[view].form !== entity.form) {
  1899. return;
  1900. }
  1901. }
  1902. const option = document.createElement("option");
  1903. option.innerText = entity.views[view].name;
  1904. option.value = view;
  1905. if (isNsfw(entity.views[view].image.source)) {
  1906. option.classList.add("nsfw");
  1907. }
  1908. if (view === selectedView) {
  1909. option.selected = true;
  1910. if (option.classList.contains("nsfw")) {
  1911. list.classList.add("nsfw");
  1912. } else {
  1913. list.classList.remove("nsfw");
  1914. }
  1915. }
  1916. list.appendChild(option);
  1917. });
  1918. }
  1919. function clearViewList() {
  1920. const list = document.querySelector("#entity-view");
  1921. list.innerHTML = "";
  1922. list.style.display = "none";
  1923. }
  1924. function updateWorldOptions(entity, view) {
  1925. const heightInput = document.querySelector("#options-height-value");
  1926. const heightSelect = document.querySelector("#options-height-unit");
  1927. const converted = config.height.toNumber(heightSelect.value);
  1928. setNumericInput(heightInput, converted);
  1929. }
  1930. function configEntityOptions(entity, view) {
  1931. const holder = document.querySelector("#options-entity");
  1932. document.querySelector("#entity-category-header").style.display = "block";
  1933. document.querySelector("#entity-category").style.display = "block";
  1934. holder.innerHTML = "";
  1935. const scaleLabel = document.createElement("div");
  1936. scaleLabel.classList.add("options-label");
  1937. scaleLabel.innerText = "Scale";
  1938. const scaleRow = document.createElement("div");
  1939. scaleRow.classList.add("options-row");
  1940. const scaleInput = document.createElement("input");
  1941. scaleInput.classList.add("options-field-numeric");
  1942. scaleInput.id = "options-entity-scale";
  1943. scaleInput.addEventListener("change", (e) => {
  1944. try {
  1945. const newScale =
  1946. e.target.value == 0 ? 1 : math.evaluate(e.target.value);
  1947. if (typeof newScale !== "number") {
  1948. toast("Invalid input: scale can't have any units!");
  1949. return;
  1950. }
  1951. entity.scale = newScale;
  1952. } catch {
  1953. toast("Invalid input: could not parse " + e.target.value);
  1954. }
  1955. entity.dirty = true;
  1956. if (config.autoFit) {
  1957. fitWorld();
  1958. } else {
  1959. updateSizes(true);
  1960. }
  1961. updateEntityOptions(entity, entity.view);
  1962. updateViewOptions(entity, entity.view);
  1963. });
  1964. scaleInput.addEventListener("keydown", (e) => {
  1965. e.stopPropagation();
  1966. });
  1967. setNumericInput(scaleInput, entity.scale);
  1968. scaleRow.appendChild(scaleInput);
  1969. holder.appendChild(scaleLabel);
  1970. holder.appendChild(scaleRow);
  1971. const nameLabel = document.createElement("div");
  1972. nameLabel.classList.add("options-label");
  1973. nameLabel.innerText = "Name";
  1974. const nameRow = document.createElement("div");
  1975. nameRow.classList.add("options-row");
  1976. const nameInput = document.createElement("input");
  1977. nameInput.classList.add("options-field-text");
  1978. nameInput.value = entity.name;
  1979. nameInput.addEventListener("input", (e) => {
  1980. entity.name = e.target.value;
  1981. entity.dirty = true;
  1982. updateSizes(true);
  1983. });
  1984. nameInput.addEventListener("keydown", (e) => {
  1985. e.stopPropagation();
  1986. });
  1987. nameRow.appendChild(nameInput);
  1988. holder.appendChild(nameLabel);
  1989. holder.appendChild(nameRow);
  1990. configSizeList(entity);
  1991. document.querySelector("#options-order-display").innerText =
  1992. entity.priority;
  1993. document.querySelector("#options-brightness-display").innerText =
  1994. entity.brightness;
  1995. document.querySelector("#options-ordering").style.display = "flex";
  1996. }
  1997. function configSizeList(entity) {
  1998. const defaultHolder = document.querySelector("#options-entity-defaults");
  1999. defaultHolder.innerHTML = "";
  2000. entity.sizes.forEach((defaultInfo) => {
  2001. if (Object.keys(entity.forms).length > 0) {
  2002. if (!defaultInfo.allForms && defaultInfo.form !== entity.form) {
  2003. return;
  2004. }
  2005. if (defaultInfo.allForms && entity.forms[entity.form].ignoreAllFormSizes) {
  2006. return;
  2007. }
  2008. }
  2009. const button = document.createElement("button");
  2010. button.classList.add("options-button");
  2011. button.innerText = defaultInfo.name;
  2012. button.addEventListener("click", (e) => {
  2013. if (Object.keys(entity.forms).length > 0) {
  2014. entity.views[entity.formViews[entity.form]].height =
  2015. defaultInfo.height;
  2016. } else {
  2017. entity.views[entity.defaultView].height = defaultInfo.height;
  2018. }
  2019. entity.dirty = true;
  2020. updateEntityOptions(entity, entity.view);
  2021. updateViewOptions(entity, entity.view);
  2022. if (!checkFitWorld()) {
  2023. updateSizes(true);
  2024. }
  2025. if (config.autoFitSize) {
  2026. let targets = {};
  2027. targets[selected.dataset.key] = entities[selected.dataset.key];
  2028. fitEntities(targets);
  2029. }
  2030. });
  2031. defaultHolder.appendChild(button);
  2032. });
  2033. }
  2034. function updateEntityOptions(entity, view) {
  2035. const scaleInput = document.querySelector("#options-entity-scale");
  2036. setNumericInput(scaleInput, entity.scale);
  2037. document.querySelector("#options-order-display").innerText =
  2038. entity.priority;
  2039. document.querySelector("#options-brightness-display").innerText =
  2040. entity.brightness;
  2041. }
  2042. function clearEntityOptions() {
  2043. document.querySelector("#entity-category-header").style.display = "none";
  2044. document.querySelector("#entity-category").style.display = "none";
  2045. /*
  2046. const holder = document.querySelector("#options-entity");
  2047. holder.innerHTML = "";
  2048. document.querySelector("#options-entity-defaults").innerHTML = "";
  2049. document.querySelector("#options-ordering").style.display = "none";
  2050. document.querySelector("#options-ordering").style.display = "none";*/
  2051. }
  2052. function configViewOptions(entity, view) {
  2053. const holder = document.querySelector("#options-view");
  2054. document.querySelector("#view-category-header").style.display = "block";
  2055. document.querySelector("#view-category").style.display = "block";
  2056. holder.innerHTML = "";
  2057. Object.entries(entity.views[view].attributes).forEach(([key, val]) => {
  2058. if (val.editing) {
  2059. const name = document.createElement("input");
  2060. name.placeholder = "Name";
  2061. name.value = val.name;
  2062. holder.appendChild(name);
  2063. holder.addEventListener("keydown", (e) => {
  2064. e.stopPropagation();
  2065. });
  2066. const input = document.createElement("input");
  2067. input.placeholder = "Measurement (e.g. '3 feet')";
  2068. input.value = val.text;
  2069. holder.appendChild(input);
  2070. input.addEventListener("keydown", (e) => {
  2071. e.stopPropagation();
  2072. });
  2073. const button = document.createElement("button");
  2074. button.innerText = "Confirm";
  2075. holder.appendChild(button);
  2076. button.addEventListener("click", e => {
  2077. let unit;
  2078. try {
  2079. unit = math.unit(input.value);
  2080. } catch {
  2081. toast("Invalid unit: " + input.value);
  2082. return;
  2083. }
  2084. const unitType = typeOfUnit(unit);
  2085. if (unitType === null) {
  2086. toast("Unit must be one of length, area, volume, mass, or energy.");
  2087. return;
  2088. }
  2089. const power = unitPowers[unitType];
  2090. const baseValue = math.multiply(unit, math.pow(1 / entity.scale, power));
  2091. entity.views[view].attributes[key] = {
  2092. name: name.value,
  2093. power: power,
  2094. type: unitType,
  2095. base: baseValue,
  2096. custom: true
  2097. };
  2098. // since we might have changed unit types, we should
  2099. // clear this.
  2100. entity.currentView.units[key] = undefined;
  2101. defineAttributeGetters(entity.views[view]);
  2102. configViewOptions(entity, view);
  2103. updateSizes();
  2104. });
  2105. } else {
  2106. const label = document.createElement("div");
  2107. label.classList.add("options-label");
  2108. label.innerText = val.name;
  2109. holder.appendChild(label);
  2110. if (config.editDefaultAttributes || val.custom) {
  2111. const editButton = document.createElement("button");
  2112. editButton.classList.add("attribute-edit-button");
  2113. editButton.innerText = "Edit Attribute";
  2114. editButton.addEventListener("click", e => {
  2115. entity.currentView.attributes[key] = {
  2116. name: val.name,
  2117. text: entity.currentView[key],
  2118. editing: true
  2119. }
  2120. configViewOptions(entity, view);
  2121. });
  2122. holder.appendChild(editButton);
  2123. }
  2124. if (val.custom) {
  2125. const deleteButton = document.createElement("button");
  2126. deleteButton.classList.add("attribute-edit-button");
  2127. deleteButton.innerText = "Delete Attribute";
  2128. deleteButton.addEventListener("click", e => {
  2129. delete entity.currentView.attributes[key];
  2130. configViewOptions(entity, view);
  2131. });
  2132. holder.appendChild(deleteButton);
  2133. }
  2134. const row = document.createElement("div");
  2135. row.classList.add("options-row");
  2136. holder.appendChild(row);
  2137. const input = document.createElement("input");
  2138. input.classList.add("options-field-numeric");
  2139. input.id = "options-view-" + key + "-input";
  2140. const select = document.createElement("select");
  2141. select.classList.add("options-field-unit");
  2142. select.id = "options-view-" + key + "-select";
  2143. Object.entries(unitChoices[val.type]).forEach(([group, entries]) => {
  2144. const optGroup = document.createElement("optgroup");
  2145. optGroup.label = group;
  2146. select.appendChild(optGroup);
  2147. entries.forEach((entry) => {
  2148. const option = document.createElement("option");
  2149. option.innerText = entry;
  2150. if (entry == defaultUnits[val.type][config.units]) {
  2151. option.selected = true;
  2152. }
  2153. select.appendChild(option);
  2154. });
  2155. });
  2156. input.addEventListener("change", (e) => {
  2157. const raw_value = input.value == 0 ? 1 : input.value;
  2158. let value;
  2159. try {
  2160. value = math.evaluate(raw_value).toNumber(select.value);
  2161. } catch {
  2162. try {
  2163. value = math.evaluate(input.value);
  2164. if (typeof value !== "number") {
  2165. toast(
  2166. "Invalid input: " +
  2167. value.format() +
  2168. " can't convert to " +
  2169. select.value
  2170. );
  2171. value = undefined;
  2172. }
  2173. } catch {
  2174. toast("Invalid input: could not parse: " + input.value);
  2175. value = undefined;
  2176. }
  2177. }
  2178. if (value === undefined) {
  2179. return;
  2180. }
  2181. input.value = value;
  2182. entity.views[view][key] = math.unit(value, select.value);
  2183. entity.dirty = true;
  2184. if (config.autoFit) {
  2185. fitWorld();
  2186. } else {
  2187. updateSizes(true);
  2188. }
  2189. updateEntityOptions(entity, view);
  2190. updateViewOptions(entity, view, key);
  2191. });
  2192. input.addEventListener("keydown", (e) => {
  2193. e.stopPropagation();
  2194. });
  2195. if (entity.currentView.units[key]) {
  2196. select.value = entity.currentView.units[key];
  2197. } else {
  2198. entity.currentView.units[key] = select.value;
  2199. }
  2200. select.dataset.oldUnit = select.value;
  2201. setNumericInput(input, entity.views[view][key].toNumber(select.value));
  2202. // TODO does this ever cause a change in the world?
  2203. select.addEventListener("input", (e) => {
  2204. const value = input.value == 0 ? 1 : input.value;
  2205. const oldUnit = select.dataset.oldUnit;
  2206. entity.views[entity.view][key] = math
  2207. .unit(value, oldUnit)
  2208. .to(select.value);
  2209. entity.dirty = true;
  2210. setNumericInput(
  2211. input,
  2212. entity.views[entity.view][key].toNumber(select.value)
  2213. );
  2214. select.dataset.oldUnit = select.value;
  2215. entity.views[view].units[key] = select.value;
  2216. if (config.autoFit) {
  2217. fitWorld();
  2218. } else {
  2219. updateSizes(true);
  2220. }
  2221. updateEntityOptions(entity, view);
  2222. updateViewOptions(entity, view, key);
  2223. });
  2224. row.appendChild(input);
  2225. row.appendChild(select);
  2226. }
  2227. });
  2228. const customButton = document.createElement("button");
  2229. customButton.innerText = "New Attribute";
  2230. holder.appendChild(customButton);
  2231. customButton.addEventListener("click", e => {
  2232. entity.currentView.attributes["custom" + (Object.keys(entity.currentView.attributes).length + 1)] = {
  2233. name: "",
  2234. text: "",
  2235. editing: true,
  2236. }
  2237. configViewOptions(entity, view);
  2238. });
  2239. }
  2240. function updateViewOptions(entity, view, changed) {
  2241. Object.entries(entity.views[view].attributes).forEach(([key, val]) => {
  2242. if (key != changed) {
  2243. const input = document.querySelector(
  2244. "#options-view-" + key + "-input"
  2245. );
  2246. const select = document.querySelector(
  2247. "#options-view-" + key + "-select"
  2248. );
  2249. const currentUnit = select.value;
  2250. const convertedAmount =
  2251. entity.views[view][key].toNumber(currentUnit);
  2252. setNumericInput(input, convertedAmount);
  2253. }
  2254. });
  2255. }
  2256. function setNumericInput(input, value, round = 6) {
  2257. if (typeof value == "string") {
  2258. value = parseFloat(value);
  2259. }
  2260. input.value = value.toPrecision(round);
  2261. }
  2262. //#endregion
  2263. function getSortedEntities() {
  2264. return Object.keys(entities).sort((a, b) => {
  2265. const entA = entities[a];
  2266. const entB = entities[b];
  2267. const viewA = entA.view;
  2268. const viewB = entB.view;
  2269. const heightA = entA.views[viewA].height.to("meter").value;
  2270. const heightB = entB.views[viewB].height.to("meter").value;
  2271. return heightA - heightB;
  2272. });
  2273. }
  2274. function clearViewOptions() {
  2275. document.querySelector("#view-category-header").style.display = "none";
  2276. document.querySelector("#view-category").style.display = "none";
  2277. }
  2278. // this is a crime against humanity, and also stolen from
  2279. // stack overflow
  2280. // https://stackoverflow.com/questions/38487569/click-through-png-image-only-if-clicked-coordinate-is-transparent
  2281. const testCanvas = document.createElement("canvas");
  2282. testCanvas.id = "test-canvas";
  2283. function rotate(point, angle) {
  2284. return [
  2285. point[0] * Math.cos(angle) - point[1] * Math.sin(angle),
  2286. point[0] * Math.sin(angle) + point[1] * Math.cos(angle),
  2287. ];
  2288. }
  2289. const testCtx = testCanvas.getContext("2d");
  2290. function testClick(event) {
  2291. const target = event.target;
  2292. if (webkitCanvasBug) {
  2293. return clickDown(target.parentElement, event.clientX, event.clientY);
  2294. }
  2295. testCtx.save();
  2296. if (rulerMode) {
  2297. return;
  2298. }
  2299. // Get click coordinates
  2300. let w = target.width;
  2301. let h = target.height;
  2302. let ratioW = 1,
  2303. ratioH = 1;
  2304. // Limit the size of the canvas so that very large images don't cause problems)
  2305. if (w > 1000) {
  2306. ratioW = w / 1000;
  2307. w /= ratioW;
  2308. h /= ratioW;
  2309. }
  2310. if (h > 1000) {
  2311. ratioH = h / 1000;
  2312. w /= ratioH;
  2313. h /= ratioH;
  2314. }
  2315. // todo remove some of this unused stuff
  2316. const ratio = ratioW * ratioH;
  2317. const entity = entities[target.parentElement.dataset.key];
  2318. const angle = entity.rotation;
  2319. var x = event.clientX - target.getBoundingClientRect().x,
  2320. y = event.clientY - target.getBoundingClientRect().y,
  2321. alpha;
  2322. [xTarget, yTarget] = [x, y];
  2323. [actualW, actualH] = [
  2324. target.getBoundingClientRect().width,
  2325. target.getBoundingClientRect().height,
  2326. ];
  2327. xTarget /= ratio;
  2328. yTarget /= ratio;
  2329. actualW /= ratio;
  2330. actualH /= ratio;
  2331. testCtx.canvas.width = actualW;
  2332. testCtx.canvas.height = actualH;
  2333. testCtx.save();
  2334. // dear future me: Sorry :(
  2335. testCtx.resetTransform();
  2336. testCtx.translate(actualW / 2, actualH / 2);
  2337. testCtx.rotate(angle);
  2338. if (entity.flipped) {
  2339. testCtx.scale(-1, 1);
  2340. }
  2341. testCtx.translate(-actualW / 2, -actualH / 2);
  2342. testCtx.drawImage(target, actualW / 2 - w / 2, actualH / 2 - h / 2, w, h);
  2343. testCtx.fillStyle = "red";
  2344. testCtx.fillRect(actualW / 2, actualH / 2, 10, 10);
  2345. testCtx.restore();
  2346. testCtx.fillStyle = "red";
  2347. alpha = testCtx.getImageData(xTarget, yTarget, 1, 1).data[3];
  2348. testCtx.fillRect(xTarget, yTarget, 3, 3);
  2349. // If the pixel is transparent,
  2350. // retrieve the element underneath and trigger its click event
  2351. if (alpha === 0) {
  2352. const oldDisplay = target.style.display;
  2353. target.style.display = "none";
  2354. const newTarget = document.elementFromPoint(
  2355. event.clientX,
  2356. event.clientY
  2357. );
  2358. newTarget.dispatchEvent(
  2359. new MouseEvent(event.type, {
  2360. clientX: event.clientX,
  2361. clientY: event.clientY,
  2362. })
  2363. );
  2364. target.style.display = oldDisplay;
  2365. } else {
  2366. clickDown(target.parentElement, event.clientX, event.clientY);
  2367. }
  2368. testCtx.restore();
  2369. }
  2370. function arrangeEntities(order) {
  2371. const worldWidth =
  2372. (config.height.toNumber("meters") / canvasHeight) * canvasWidth;
  2373. let sum = 0;
  2374. order.forEach((key) => {
  2375. const image = document.querySelector(
  2376. "#entity-" + key + " > .entity-image"
  2377. );
  2378. const meters =
  2379. entities[key].views[entities[key].view].height.toNumber("meters");
  2380. let height = image.height;
  2381. let width = image.width;
  2382. if (height == 0) {
  2383. height = 100;
  2384. }
  2385. if (width == 0) {
  2386. width = height;
  2387. }
  2388. sum += (meters * width) / height;
  2389. });
  2390. let x = config.x - sum / 2;
  2391. order.forEach((key) => {
  2392. const image = document.querySelector(
  2393. "#entity-" + key + " > .entity-image"
  2394. );
  2395. const meters =
  2396. entities[key].views[entities[key].view].height.toNumber("meters");
  2397. let height = image.height;
  2398. let width = image.width;
  2399. if (height == 0) {
  2400. height = 100;
  2401. }
  2402. if (width == 0) {
  2403. width = height;
  2404. }
  2405. x += (meters * width) / height / 2;
  2406. document.querySelector("#entity-" + key).dataset.x = x;
  2407. document.querySelector("#entity-" + key).dataset.y = config.y;
  2408. x += (meters * width) / height / 2;
  2409. });
  2410. fitWorld();
  2411. updateSizes();
  2412. }
  2413. function removeAllEntities() {
  2414. Object.keys(entities).forEach((key) => {
  2415. removeEntity(document.querySelector("#entity-" + key));
  2416. });
  2417. }
  2418. function clearAttribution() {
  2419. document.querySelector("#attribution-category-header").style.display =
  2420. "none";
  2421. document.querySelector("#options-attribution").style.display = "none";
  2422. }
  2423. function displayAttribution(file) {
  2424. document.querySelector("#attribution-category-header").style.display =
  2425. "block";
  2426. document.querySelector("#options-attribution").style.display = "inline";
  2427. const authors = authorsOfFull(file);
  2428. const owners = ownersOfFull(file);
  2429. const citations = citationsOf(file);
  2430. const source = sourceOf(file);
  2431. const authorHolder = document.querySelector("#options-attribution-authors");
  2432. const ownerHolder = document.querySelector("#options-attribution-owners");
  2433. const citationHolder = document.querySelector(
  2434. "#options-attribution-citations"
  2435. );
  2436. const sourceHolder = document.querySelector("#options-attribution-source");
  2437. if (authors === []) {
  2438. const div = document.createElement("div");
  2439. div.innerText = "Unknown";
  2440. authorHolder.innerHTML = "";
  2441. authorHolder.appendChild(div);
  2442. } else if (authors === undefined) {
  2443. const div = document.createElement("div");
  2444. div.innerText = "Not yet entered";
  2445. authorHolder.innerHTML = "";
  2446. authorHolder.appendChild(div);
  2447. } else {
  2448. authorHolder.innerHTML = "";
  2449. const list = document.createElement("ul");
  2450. authorHolder.appendChild(list);
  2451. authors.forEach((author) => {
  2452. const authorEntry = document.createElement("li");
  2453. if (author.url) {
  2454. const link = document.createElement("a");
  2455. link.href = author.url;
  2456. link.innerText = author.name;
  2457. link.rel = "noreferrer no opener";
  2458. link.target = "_blank";
  2459. authorEntry.appendChild(link);
  2460. } else {
  2461. const div = document.createElement("div");
  2462. div.innerText = author.name;
  2463. authorEntry.appendChild(div);
  2464. }
  2465. list.appendChild(authorEntry);
  2466. });
  2467. }
  2468. if (owners === []) {
  2469. const div = document.createElement("div");
  2470. div.innerText = "Unknown";
  2471. ownerHolder.innerHTML = "";
  2472. ownerHolder.appendChild(div);
  2473. } else if (owners === undefined) {
  2474. const div = document.createElement("div");
  2475. div.innerText = "Not yet entered";
  2476. ownerHolder.innerHTML = "";
  2477. ownerHolder.appendChild(div);
  2478. } else {
  2479. ownerHolder.innerHTML = "";
  2480. const list = document.createElement("ul");
  2481. ownerHolder.appendChild(list);
  2482. owners.forEach((owner) => {
  2483. const ownerEntry = document.createElement("li");
  2484. if (owner.url) {
  2485. const link = document.createElement("a");
  2486. link.href = owner.url;
  2487. link.innerText = owner.name;
  2488. link.rel = "noreferrer no opener";
  2489. link.target = "_blank";
  2490. ownerEntry.appendChild(link);
  2491. } else {
  2492. const div = document.createElement("div");
  2493. div.innerText = owner.name;
  2494. ownerEntry.appendChild(div);
  2495. }
  2496. list.appendChild(ownerEntry);
  2497. });
  2498. }
  2499. citationHolder.innerHTML = "";
  2500. if (citations === [] || citations === undefined) {
  2501. } else {
  2502. citationHolder.innerHTML = "";
  2503. const list = document.createElement("ul");
  2504. citationHolder.appendChild(list);
  2505. citations.forEach((citation) => {
  2506. const citationEntry = document.createElement("li");
  2507. const link = document.createElement("a");
  2508. link.style.display = "block";
  2509. link.href = citation;
  2510. link.innerText = new URL(citation).host;
  2511. link.rel = "noreferrer no opener";
  2512. link.target = "_blank";
  2513. citationEntry.appendChild(link);
  2514. list.appendChild(citationEntry);
  2515. });
  2516. }
  2517. if (source === null) {
  2518. const div = document.createElement("div");
  2519. div.innerText = "No link";
  2520. sourceHolder.innerHTML = "";
  2521. sourceHolder.appendChild(div);
  2522. } else if (source === undefined) {
  2523. const div = document.createElement("div");
  2524. div.innerText = "Not yet entered";
  2525. sourceHolder.innerHTML = "";
  2526. sourceHolder.appendChild(div);
  2527. } else {
  2528. sourceHolder.innerHTML = "";
  2529. const link = document.createElement("a");
  2530. link.style.display = "block";
  2531. link.href = source;
  2532. link.innerText = new URL(source).host;
  2533. link.rel = "noreferrer no opener";
  2534. link.target = "_blank";
  2535. sourceHolder.appendChild(link);
  2536. }
  2537. }
  2538. function removeEntity(element) {
  2539. if (selected == element) {
  2540. deselect();
  2541. }
  2542. if (clicked == element) {
  2543. clicked = null;
  2544. }
  2545. const option = document.querySelector(
  2546. "#options-selected-entity-" + element.dataset.key
  2547. );
  2548. option.parentElement.removeChild(option);
  2549. delete entities[element.dataset.key];
  2550. const bottomName = document.querySelector(
  2551. "#bottom-name-" + element.dataset.key
  2552. );
  2553. const topName = document.querySelector("#top-name-" + element.dataset.key);
  2554. bottomName.parentElement.removeChild(bottomName);
  2555. topName.parentElement.removeChild(topName);
  2556. element.parentElement.removeChild(element);
  2557. selectedEntity = null;
  2558. prevSelectedEntity = null;
  2559. updateInfo();
  2560. }
  2561. function checkEntity(entity) {
  2562. Object.values(entity.views).forEach((view) => {
  2563. if (authorsOf(view.image.source) === undefined) {
  2564. console.warn("No authors: " + view.image.source);
  2565. }
  2566. });
  2567. }
  2568. function preloadViews(entity) {
  2569. Object.values(entity.views).forEach((view) => {
  2570. if (Object.keys(entity.forms).length > 0) {
  2571. if (entity.form !== view.form) {
  2572. return;
  2573. }
  2574. }
  2575. if (!preloaded.has(view.image.source)) {
  2576. let img = new Image();
  2577. img.src = view.image.source;
  2578. preloaded.add(view.image.source);
  2579. }
  2580. });
  2581. }
  2582. function displayEntity(
  2583. entity,
  2584. view,
  2585. x,
  2586. y,
  2587. selectEntity = false,
  2588. refresh = false
  2589. ) {
  2590. checkEntity(entity);
  2591. // preload all of the entity's views
  2592. preloadViews(entity);
  2593. const box = document.createElement("div");
  2594. box.classList.add("entity-box");
  2595. const img = document.createElement("img");
  2596. img.classList.add("entity-image");
  2597. img.addEventListener("dragstart", (e) => {
  2598. e.preventDefault();
  2599. });
  2600. const nameTag = document.createElement("div");
  2601. nameTag.classList.add("entity-name");
  2602. nameTag.innerText = entity.name;
  2603. box.appendChild(img);
  2604. box.appendChild(nameTag);
  2605. const image = entity.views[view].image;
  2606. img.src = image.source;
  2607. if (image.bottom !== undefined) {
  2608. img.style.setProperty("--offset", (-1 + image.bottom) * 100 + "%");
  2609. } else {
  2610. img.style.setProperty("--offset", -1 * 100 + "%");
  2611. }
  2612. box.dataset.x = x;
  2613. box.dataset.y = y;
  2614. img.addEventListener("mousedown", (e) => {
  2615. if (e.which == 1) {
  2616. testClick(e);
  2617. if (clicked) {
  2618. e.stopPropagation();
  2619. }
  2620. }
  2621. });
  2622. img.addEventListener("touchstart", (e) => {
  2623. const fakeEvent = {
  2624. target: e.target,
  2625. clientX: e.touches[0].clientX,
  2626. clientY: e.touches[0].clientY,
  2627. which: 1,
  2628. };
  2629. testClick(fakeEvent);
  2630. if (clicked) {
  2631. e.stopPropagation();
  2632. }
  2633. });
  2634. const heightBar = document.createElement("div");
  2635. heightBar.classList.add("height-bar");
  2636. box.appendChild(heightBar);
  2637. box.id = "entity-" + entityIndex;
  2638. box.dataset.key = entityIndex;
  2639. entity.view = view;
  2640. if (entity.priority === undefined) entity.priority = 0;
  2641. if (entity.brightness === undefined) entity.brightness = 1;
  2642. entities[entityIndex] = entity;
  2643. entity.index = entityIndex;
  2644. const world = document.querySelector("#entities");
  2645. world.appendChild(box);
  2646. const bottomName = document.createElement("div");
  2647. bottomName.classList.add("bottom-name");
  2648. bottomName.id = "bottom-name-" + entityIndex;
  2649. bottomName.innerText = entity.name;
  2650. bottomName.addEventListener("click", () => select(box));
  2651. world.appendChild(bottomName);
  2652. const topName = document.createElement("div");
  2653. topName.classList.add("top-name");
  2654. topName.id = "top-name-" + entityIndex;
  2655. topName.innerText = entity.name;
  2656. topName.addEventListener("click", () => select(box));
  2657. world.appendChild(topName);
  2658. const entityOption = document.createElement("option");
  2659. entityOption.id = "options-selected-entity-" + entityIndex;
  2660. entityOption.value = entityIndex;
  2661. entityOption.innerText = entity.name;
  2662. document
  2663. .getElementById("options-selected-entity")
  2664. .appendChild(entityOption);
  2665. entityIndex += 1;
  2666. if (config.autoFit) {
  2667. fitWorld();
  2668. }
  2669. updateEntityProperties(box);
  2670. if (selectEntity) select(box);
  2671. entity.dirty = true;
  2672. if (refresh && config.autoFitAdd) {
  2673. let targets = {};
  2674. targets[entityIndex - 1] = entity;
  2675. fitEntities(targets);
  2676. }
  2677. if (refresh) updateSizes(true);
  2678. }
  2679. window.onblur = function () {
  2680. altHeld = false;
  2681. shiftHeld = false;
  2682. };
  2683. window.onfocus = function () {
  2684. window.dispatchEvent(new Event("keydown"));
  2685. };
  2686. // thanks to https://developers.google.com/web/fundamentals/native-hardware/fullscreen
  2687. function toggleFullScreen() {
  2688. var doc = window.document;
  2689. var docEl = doc.documentElement;
  2690. var requestFullScreen =
  2691. docEl.requestFullscreen ||
  2692. docEl.mozRequestFullScreen ||
  2693. docEl.webkitRequestFullScreen ||
  2694. docEl.msRequestFullscreen;
  2695. var cancelFullScreen =
  2696. doc.exitFullscreen ||
  2697. doc.mozCancelFullScreen ||
  2698. doc.webkitExitFullscreen ||
  2699. doc.msExitFullscreen;
  2700. if (
  2701. !doc.fullscreenElement &&
  2702. !doc.mozFullScreenElement &&
  2703. !doc.webkitFullscreenElement &&
  2704. !doc.msFullscreenElement
  2705. ) {
  2706. requestFullScreen.call(docEl);
  2707. } else {
  2708. cancelFullScreen.call(doc);
  2709. }
  2710. }
  2711. function handleResize(update = true) {
  2712. entityX = document.querySelector("#entities").getBoundingClientRect().x;
  2713. canvasWidth = document.querySelector("#display").clientWidth - 100;
  2714. canvasHeight = document.querySelector("#display").clientHeight - 50;
  2715. if (update)
  2716. updateSizes();
  2717. }
  2718. function preparePopoutMenu() {
  2719. const menubars = {
  2720. "menu": document.querySelector("#menu-menu"),
  2721. "scene": document.querySelector("#scene-menu")
  2722. };
  2723. [
  2724. {
  2725. name: "Show/hide sidebar",
  2726. id: "menu-toggle-sidebar",
  2727. icon: "fas fa-chevron-circle-down",
  2728. rotates: true,
  2729. type: "menu"
  2730. },
  2731. {
  2732. name: "Fullscreen",
  2733. id: "menu-fullscreen",
  2734. icon: "fas fa-compress",
  2735. type: "menu"
  2736. },
  2737. {
  2738. name: "Clear",
  2739. id: "menu-clear",
  2740. icon: "fas fa-file",
  2741. type: "scene"
  2742. },
  2743. {
  2744. name: "Sort by height",
  2745. id: "menu-order-height",
  2746. icon: "fas fa-sort-numeric-up",
  2747. type: "scene"
  2748. },
  2749. {
  2750. name: "Permalink",
  2751. id: "menu-permalink",
  2752. icon: "fas fa-link",
  2753. type: "scene"
  2754. },
  2755. {
  2756. name: "Export to clipboard",
  2757. id: "menu-export",
  2758. icon: "fas fa-share",
  2759. type: "scene"
  2760. },
  2761. {
  2762. name: "Import from clipboard",
  2763. id: "menu-import",
  2764. icon: "fas fa-share",
  2765. classes: ["flipped"],
  2766. type: "scene"
  2767. },
  2768. {
  2769. name: "Save Scene",
  2770. id: "menu-save",
  2771. icon: "fas fa-download",
  2772. input: true,
  2773. type: "scene"
  2774. },
  2775. {
  2776. name: "Load Scene",
  2777. id: "menu-load",
  2778. icon: "fas fa-upload",
  2779. select: true,
  2780. type: "scene"
  2781. },
  2782. {
  2783. name: "Delete Scene",
  2784. id: "menu-delete",
  2785. icon: "fas fa-trash",
  2786. select: true,
  2787. type: "scene"
  2788. },
  2789. {
  2790. name: "Load Autosave",
  2791. id: "menu-load-autosave",
  2792. icon: "fas fa-redo",
  2793. type: "scene"
  2794. },
  2795. {
  2796. name: "Load Preset",
  2797. id: "menu-preset",
  2798. icon: "fas fa-play",
  2799. select: true,
  2800. type: "scene"
  2801. },
  2802. {
  2803. name: "Add Image",
  2804. id: "menu-add-image",
  2805. icon: "fas fa-camera",
  2806. type: "menu"
  2807. },
  2808. {
  2809. name: "Clear Rulers",
  2810. id: "menu-clear-rulers",
  2811. icon: "fas fa-ruler",
  2812. type: "menu"
  2813. },
  2814. ].forEach((entry) => {
  2815. const buttonHolder = document.createElement("div");
  2816. buttonHolder.classList.add("menu-button-holder");
  2817. const button = document.createElement("button");
  2818. button.id = entry.id;
  2819. button.classList.add("menu-button");
  2820. const icon = document.createElement("i");
  2821. icon.classList.add(...entry.icon.split(" "));
  2822. if (entry.rotates) {
  2823. icon.classList.add("rotate-backward", "transitions");
  2824. }
  2825. if (entry.classes) {
  2826. entry.classes.forEach((cls) => icon.classList.add(cls));
  2827. }
  2828. const actionText = document.createElement("span");
  2829. actionText.innerText = entry.name;
  2830. actionText.classList.add("menu-text");
  2831. const srText = document.createElement("span");
  2832. srText.classList.add("sr-only");
  2833. srText.innerText = entry.name;
  2834. button.appendChild(icon);
  2835. button.appendChild(srText);
  2836. buttonHolder.appendChild(button);
  2837. buttonHolder.appendChild(actionText);
  2838. if (entry.input) {
  2839. const input = document.createElement("input");
  2840. buttonHolder.appendChild(input);
  2841. input.placeholder = "default";
  2842. input.addEventListener("keyup", (e) => {
  2843. if (e.key === "Enter") {
  2844. const name =
  2845. document.querySelector("#menu-save ~ input").value;
  2846. if (/\S/.test(name)) {
  2847. saveScene(name);
  2848. }
  2849. updateSaveInfo();
  2850. e.preventDefault();
  2851. }
  2852. });
  2853. }
  2854. if (entry.select) {
  2855. const select = document.createElement("select");
  2856. buttonHolder.appendChild(select);
  2857. }
  2858. menubars[entry.type].appendChild(buttonHolder);
  2859. });
  2860. document
  2861. .querySelector("#menu-toggle-sidebar")
  2862. .addEventListener("click", (e) => {
  2863. const sidebar = document.querySelector("#options");
  2864. if (sidebar.classList.contains("hidden")) {
  2865. sidebar.classList.remove("hidden");
  2866. e.target.classList.remove("rotate-forward");
  2867. e.target.classList.add("rotate-backward");
  2868. } else {
  2869. sidebar.classList.add("hidden");
  2870. e.target.classList.add("rotate-forward");
  2871. e.target.classList.remove("rotate-backward");
  2872. }
  2873. handleResize();
  2874. });
  2875. document
  2876. .querySelector("#menu-fullscreen")
  2877. .addEventListener("click", toggleFullScreen);
  2878. const sceneChoices = document.querySelector("#menu-preset ~ select");
  2879. Object.entries(scenes).forEach(([id, scene]) => {
  2880. const option = document.createElement("option");
  2881. option.innerText = id;
  2882. option.value = id;
  2883. sceneChoices.appendChild(option);
  2884. });
  2885. document.querySelector("#menu-preset").addEventListener("click", (e) => {
  2886. const chosen = sceneChoices.value;
  2887. removeAllEntities();
  2888. scenes[chosen]();
  2889. });
  2890. document.querySelector("#menu-clear").addEventListener("click", (e) => {
  2891. removeAllEntities();
  2892. });
  2893. document.querySelector("#delete-entity").disabled = true;
  2894. document.querySelector("#delete-entity").addEventListener("click", (e) => {
  2895. if (selected) {
  2896. removeEntity(selected);
  2897. selected = null;
  2898. }
  2899. });
  2900. document
  2901. .querySelector("#menu-order-height")
  2902. .addEventListener("click", (e) => {
  2903. const order = Object.keys(entities).sort((a, b) => {
  2904. const entA = entities[a];
  2905. const entB = entities[b];
  2906. const viewA = entA.view;
  2907. const viewB = entB.view;
  2908. const heightA = entA.views[viewA].height.to("meter").value;
  2909. const heightB = entB.views[viewB].height.to("meter").value;
  2910. return heightA - heightB;
  2911. });
  2912. arrangeEntities(order);
  2913. });
  2914. document
  2915. .querySelector("#options-world-fit")
  2916. .addEventListener("click", () => fitWorld(true));
  2917. document
  2918. .querySelector("#options-reset-pos-x")
  2919. .addEventListener("click", () => {
  2920. config.x = 0;
  2921. updateSizes();
  2922. });
  2923. document
  2924. .querySelector("#options-reset-pos-y")
  2925. .addEventListener("click", () => {
  2926. config.y = 0;
  2927. updateSizes();
  2928. });
  2929. document.querySelector("#menu-permalink").addEventListener("click", (e) => {
  2930. linkScene();
  2931. });
  2932. document.querySelector("#menu-export").addEventListener("click", (e) => {
  2933. copyScene();
  2934. });
  2935. document.querySelector("#menu-import").addEventListener("click", (e) => {
  2936. pasteScene();
  2937. });
  2938. document.querySelector("#menu-save").addEventListener("click", (e) => {
  2939. const name = document.querySelector("#menu-save ~ input").value;
  2940. if (/\S/.test(name)) {
  2941. saveScene(name);
  2942. }
  2943. updateSaveInfo();
  2944. });
  2945. document.querySelector("#menu-load").addEventListener("click", (e) => {
  2946. const name = document.querySelector("#menu-load ~ select").value;
  2947. if (/\S/.test(name)) {
  2948. loadScene(name);
  2949. }
  2950. });
  2951. document.querySelector("#menu-delete").addEventListener("click", (e) => {
  2952. const name = document.querySelector("#menu-delete ~ select").value;
  2953. if (/\S/.test(name)) {
  2954. deleteScene(name);
  2955. }
  2956. });
  2957. }
  2958. function checkBodyClass(cls) {
  2959. return document.body.classList.contains(cls);
  2960. }
  2961. function toggleBodyClass(cls, setting) {
  2962. if (setting) {
  2963. document.body.classList.add(cls);
  2964. } else {
  2965. document.body.classList.remove(cls);
  2966. }
  2967. }
  2968. const backgroundColors = {
  2969. none: "#00000000",
  2970. black: "#000",
  2971. dark: "#111",
  2972. medium: "#333",
  2973. light: "#555",
  2974. };
  2975. const groundPosChoices = [
  2976. "very-high",
  2977. "high",
  2978. "medium",
  2979. "low",
  2980. "very-low",
  2981. "bottom",
  2982. ];
  2983. const settingsCategories = [
  2984. {
  2985. id: "scales",
  2986. name: "Scales"
  2987. },
  2988. {
  2989. id: "controls",
  2990. name: "Controls"
  2991. },
  2992. {
  2993. id: "appearance",
  2994. name: "Appearance"
  2995. },
  2996. {
  2997. id: "info",
  2998. name: "Info"
  2999. },
  3000. {
  3001. id: "estimates",
  3002. name: "Estimates"
  3003. },
  3004. ]
  3005. const settingsData = {
  3006. "show-vertical-scale": {
  3007. name: "Vertical Scale",
  3008. desc: "Draw vertical scale marks",
  3009. category: "scales",
  3010. type: "toggle",
  3011. default: true,
  3012. get value() {
  3013. return config.drawYAxis;
  3014. },
  3015. set value(param) {
  3016. config.drawYAxis = param;
  3017. drawScales(false);
  3018. },
  3019. },
  3020. "show-horizontal-scale": {
  3021. name: "Horiziontal Scale",
  3022. desc: "Draw horizontal scale marks",
  3023. category: "scales",
  3024. type: "toggle",
  3025. default: false,
  3026. get value() {
  3027. return config.drawXAxis;
  3028. },
  3029. set value(param) {
  3030. config.drawXAxis = param;
  3031. drawScales(false);
  3032. },
  3033. },
  3034. "show-altitudes": {
  3035. name: "Altitudes",
  3036. desc: "Draw interesting altitudes",
  3037. category: "scales",
  3038. type: "select",
  3039. default: "none",
  3040. disabled: "none",
  3041. options: [
  3042. "none",
  3043. "all",
  3044. "atmosphere",
  3045. "orbits",
  3046. "weather",
  3047. "water",
  3048. "geology",
  3049. "thicknesses",
  3050. "airspaces",
  3051. "races",
  3052. "olympic-records",
  3053. "d&d-sizes",
  3054. ],
  3055. get value() {
  3056. return config.drawAltitudes;
  3057. },
  3058. set value(param) {
  3059. config.drawAltitudes = param;
  3060. drawScales(false);
  3061. },
  3062. },
  3063. "lock-y-axis": {
  3064. name: "Lock Y-Axis",
  3065. desc: "Keep the camera at ground-level",
  3066. category: "controls",
  3067. type: "toggle",
  3068. default: true,
  3069. get value() {
  3070. return config.lockYAxis;
  3071. },
  3072. set value(param) {
  3073. config.lockYAxis = param;
  3074. updateScrollButtons();
  3075. if (param) {
  3076. updateSizes();
  3077. }
  3078. },
  3079. },
  3080. "ground-snap": {
  3081. name: "Snap to Ground",
  3082. desc: "Snap things to the ground",
  3083. category: "controls",
  3084. type: "toggle",
  3085. default: true,
  3086. get value() {
  3087. return config.groundSnap;
  3088. },
  3089. set value(param) {
  3090. config.groundSnap = param;
  3091. },
  3092. },
  3093. "axis-spacing": {
  3094. name: "Axis Spacing",
  3095. desc: "How frequent the axis lines are",
  3096. category: "scales",
  3097. type: "select",
  3098. default: "standard",
  3099. options: ["dense", "standard", "sparse"],
  3100. get value() {
  3101. return config.axisSpacing;
  3102. },
  3103. set value(param) {
  3104. config.axisSpacing = param;
  3105. const factor = {
  3106. dense: 0.5,
  3107. standard: 1,
  3108. sparse: 2,
  3109. }[param];
  3110. config.minLineSize = factor * 100;
  3111. config.maxLineSize = factor * 150;
  3112. updateSizes();
  3113. },
  3114. },
  3115. "ground-type": {
  3116. name: "Ground",
  3117. desc: "What kind of ground to show, if any",
  3118. category: "appearance",
  3119. type: "select",
  3120. default: "black",
  3121. disabled: "none",
  3122. options: ["none", "black", "dark", "medium", "light"],
  3123. get value() {
  3124. return config.groundKind;
  3125. },
  3126. set value(param) {
  3127. config.groundKind = param;
  3128. document
  3129. .querySelector("#ground")
  3130. .style.setProperty("--ground-color", backgroundColors[param]);
  3131. },
  3132. },
  3133. "ground-pos": {
  3134. name: "Ground Position",
  3135. desc: "How high the ground is if the y-axis is locked",
  3136. category: "appearance",
  3137. type: "select",
  3138. default: "very-low",
  3139. options: groundPosChoices,
  3140. get value() {
  3141. return config.groundPos;
  3142. },
  3143. set value(param) {
  3144. config.groundPos = param;
  3145. updateScrollButtons();
  3146. updateSizes();
  3147. },
  3148. },
  3149. "background-brightness": {
  3150. name: "Background Color",
  3151. desc: "How bright the background is",
  3152. category: "appearance",
  3153. type: "select",
  3154. default: "medium",
  3155. options: ["black", "dark", "medium", "light"],
  3156. get value() {
  3157. return config.background;
  3158. },
  3159. set value(param) {
  3160. config.background = param;
  3161. drawScales();
  3162. },
  3163. },
  3164. "auto-scale": {
  3165. name: "Auto-Size World",
  3166. desc: "Constantly zoom to fit the largest entity",
  3167. category: "controls",
  3168. type: "toggle",
  3169. default: false,
  3170. get value() {
  3171. return config.autoFit;
  3172. },
  3173. set value(param) {
  3174. config.autoFit = param;
  3175. checkFitWorld();
  3176. },
  3177. },
  3178. "auto-units": {
  3179. name: "Auto-Select Units",
  3180. desc: "Automatically switch units when zooming in and out",
  3181. category: "controls",
  3182. type: "toggle",
  3183. default: false,
  3184. get value() {
  3185. return config.autoUnits;
  3186. },
  3187. set value(param) {
  3188. config.autoUnits = param;
  3189. },
  3190. },
  3191. "zoom-when-adding": {
  3192. name: "Zoom On Add",
  3193. desc: "Zoom to fit when you add a new entity",
  3194. category: "controls",
  3195. type: "toggle",
  3196. default: false,
  3197. get value() {
  3198. return config.autoFitAdd;
  3199. },
  3200. set value(param) {
  3201. config.autoFitAdd = param;
  3202. },
  3203. },
  3204. "zoom-when-sizing": {
  3205. name: "Zoom On Size",
  3206. desc: "Zoom to fit when you select an entity's size",
  3207. category: "controls",
  3208. type: "toggle",
  3209. default: false,
  3210. get value() {
  3211. return config.autoFitSize;
  3212. },
  3213. set value(param) {
  3214. config.autoFitSize = param;
  3215. },
  3216. },
  3217. "show-ratios": {
  3218. name: "Show Ratios",
  3219. desc: "Show the proportions between the current selection and the most recent selection.",
  3220. category: "info",
  3221. type: "toggle",
  3222. default: false,
  3223. get value() {
  3224. return config.showRatios;
  3225. },
  3226. set value(param) {
  3227. config.showRatios = param;
  3228. updateInfo();
  3229. },
  3230. },
  3231. "show-horizon": {
  3232. name: "Show Horizon",
  3233. desc: "Show how far the horizon would be for the selected character",
  3234. category: "info",
  3235. type: "toggle",
  3236. default: false,
  3237. get value() {
  3238. return config.showHorizon;
  3239. },
  3240. set value(param) {
  3241. config.showHorizon = param;
  3242. updateInfo();
  3243. },
  3244. },
  3245. "attach-rulers": {
  3246. name: "Attach Rulers",
  3247. desc: "Rulers will attach to the currently-selected entity, moving around with it.",
  3248. category: "controls",
  3249. type: "toggle",
  3250. default: true,
  3251. get value() {
  3252. return config.rulersStick;
  3253. },
  3254. set value(param) {
  3255. config.rulersStick = param;
  3256. },
  3257. },
  3258. units: {
  3259. name: "Default Units",
  3260. desc: "Which kind of unit to use by default",
  3261. category: "info",
  3262. type: "select",
  3263. default: "metric",
  3264. options: ["metric", "customary", "relative", "quirky", "human"],
  3265. get value() {
  3266. return config.units;
  3267. },
  3268. set value(param) {
  3269. config.units = param;
  3270. updateSizes();
  3271. },
  3272. },
  3273. names: {
  3274. name: "Show Names",
  3275. desc: "Display names over entities",
  3276. category: "info",
  3277. type: "toggle",
  3278. default: true,
  3279. get value() {
  3280. return checkBodyClass("toggle-entity-name");
  3281. },
  3282. set value(param) {
  3283. toggleBodyClass("toggle-entity-name", param);
  3284. },
  3285. },
  3286. "bottom-names": {
  3287. name: "Bottom Names",
  3288. desc: "Display names at the bottom",
  3289. category: "info",
  3290. type: "toggle",
  3291. default: false,
  3292. get value() {
  3293. return checkBodyClass("toggle-bottom-name");
  3294. },
  3295. set value(param) {
  3296. toggleBodyClass("toggle-bottom-name", param);
  3297. },
  3298. },
  3299. "top-names": {
  3300. name: "Show Arrows",
  3301. desc: "Point to entities that are much larger than the current view",
  3302. category: "info",
  3303. type: "toggle",
  3304. default: false,
  3305. get value() {
  3306. return checkBodyClass("toggle-top-name");
  3307. },
  3308. set value(param) {
  3309. toggleBodyClass("toggle-top-name", param);
  3310. },
  3311. },
  3312. "height-bars": {
  3313. name: "Height Bars",
  3314. desc: "Draw dashed lines to the top of each entity",
  3315. category: "info",
  3316. type: "toggle",
  3317. default: false,
  3318. get value() {
  3319. return checkBodyClass("toggle-height-bars");
  3320. },
  3321. set value(param) {
  3322. toggleBodyClass("toggle-height-bars", param);
  3323. },
  3324. },
  3325. "flag-nsfw": {
  3326. name: "Flag NSFW",
  3327. desc: "Highlight NSFW things in red",
  3328. category: "info",
  3329. type: "toggle",
  3330. default: false,
  3331. get value() {
  3332. return checkBodyClass("flag-nsfw");
  3333. },
  3334. set value(param) {
  3335. toggleBodyClass("flag-nsfw", param);
  3336. },
  3337. },
  3338. "glowing-entities": {
  3339. name: "Glowing Edges",
  3340. desc: "Makes all entities glow",
  3341. category: "appearance",
  3342. type: "toggle",
  3343. default: false,
  3344. get value() {
  3345. return checkBodyClass("toggle-entity-glow");
  3346. },
  3347. set value(param) {
  3348. toggleBodyClass("toggle-entity-glow", param);
  3349. },
  3350. },
  3351. "select-style": {
  3352. name: "Selection Style",
  3353. desc: "How to highlight selected entities (outlines are laggier)",
  3354. category: "appearance",
  3355. type: "select",
  3356. default: "color",
  3357. options: ["color", "outline"],
  3358. get value() {
  3359. if (checkBodyClass("highlight-color")) {
  3360. return "color";
  3361. } else {
  3362. return "outline";
  3363. }
  3364. },
  3365. set value(param) {
  3366. toggleBodyClass("highlight-color", param === "color");
  3367. toggleBodyClass("highlight-outline", param === "outline");
  3368. },
  3369. },
  3370. smoothing: {
  3371. name: "Smoothing",
  3372. desc: "Smooth out movements and size changes. Disable for better performance.",
  3373. category: "appearance",
  3374. type: "toggle",
  3375. default: true,
  3376. get value() {
  3377. return checkBodyClass("smoothing");
  3378. },
  3379. set value(param) {
  3380. toggleBodyClass("smoothing", param);
  3381. },
  3382. },
  3383. "auto-mass": {
  3384. name: "Estimate Mass",
  3385. desc: "Guess the mass of things that don't have one specified using the selected body type",
  3386. category: "estimates",
  3387. type: "select",
  3388. default: "off",
  3389. disabled: "off",
  3390. options: ["off", "human", "quadruped at shoulder"],
  3391. get value() {
  3392. return config.autoMass;
  3393. },
  3394. set value(param) {
  3395. config.autoMass = param;
  3396. },
  3397. },
  3398. "auto-food-intake": {
  3399. name: "Estimate Food Intake",
  3400. desc: "Guess how much food creatures need, based on their mass -- 2000kcal per 150lbs",
  3401. category: "estimates",
  3402. type: "toggle",
  3403. default: false,
  3404. get value() {
  3405. return config.autoFoodIntake;
  3406. },
  3407. set value(param) {
  3408. config.autoFoodIntake = param;
  3409. },
  3410. },
  3411. "auto-caloric-value": {
  3412. name: "Estimate Caloric Value",
  3413. desc: "Guess how much food a creature is worth -- 860kcal per pound",
  3414. category: "estimates",
  3415. type: "toggle",
  3416. default: false,
  3417. get value() {
  3418. return config.autoCaloricValue;
  3419. },
  3420. set value(param) {
  3421. config.autoCaloricValue = param;
  3422. },
  3423. },
  3424. "auto-prey-capacity": {
  3425. name: "Estimate Prey Capacity",
  3426. desc: "Guess how much prey creatures can hold, based on their mass",
  3427. category: "estimates",
  3428. type: "select",
  3429. default: "off",
  3430. disabled: "off",
  3431. options: ["off", "realistic", "same-size"],
  3432. get value() {
  3433. return config.autoPreyCapacity;
  3434. },
  3435. set value(param) {
  3436. config.autoPreyCapacity = param;
  3437. },
  3438. },
  3439. "auto-swallow-size": {
  3440. name: "Estimate Swallow Size",
  3441. desc: "Guess how much creatures can swallow at once, based on their height",
  3442. category: "estimates",
  3443. type: "select",
  3444. default: "off",
  3445. disabled: "off",
  3446. options: ["off", "casual", "big-swallow", "same-size-predator"],
  3447. get value() {
  3448. return config.autoSwallowSize;
  3449. },
  3450. set value(param) {
  3451. config.autoSwallowSize = param;
  3452. },
  3453. },
  3454. "edit-default-attributes": {
  3455. name: "Edit Default Attributes",
  3456. desc: "Lets you edit non-custom attributes",
  3457. category: "info",
  3458. type: "toggle",
  3459. default: false,
  3460. get value() {
  3461. return config.editDefaultAttributes
  3462. },
  3463. set value(param) {
  3464. config.editDefaultAttributes = param;
  3465. if (selected) {
  3466. const entity = entities[selected.dataset.key]
  3467. configViewOptions(entity, entity.view);
  3468. }
  3469. }
  3470. }
  3471. };
  3472. function prepareSettings(userSettings) {
  3473. const menubar = document.querySelector("#settings-menu");
  3474. settingsCategories.forEach(category => {
  3475. const categoryLabel = document.createElement("div");
  3476. categoryLabel.classList.add("settings-category-label");
  3477. categoryLabel.innerText = category.name;
  3478. menubar.appendChild(categoryLabel);
  3479. Object.entries(settingsData).forEach(([id, entry]) => {
  3480. if (settingsCategories.every(category => category.id != entry.category)) {
  3481. console.warn(id + " has a bogus category of " + entry.category);
  3482. }
  3483. if (entry.category != category.id) {
  3484. return;
  3485. }
  3486. const holder = document.createElement("label");
  3487. holder.classList.add("settings-holder");
  3488. const input = document.createElement("input");
  3489. input.id = "setting-" + id;
  3490. const vertical = document.createElement("div");
  3491. vertical.classList.add("settings-vertical");
  3492. const name = document.createElement("label");
  3493. name.innerText = entry.name;
  3494. name.classList.add("settings-name");
  3495. name.setAttribute("for", input.id);
  3496. const desc = document.createElement("label");
  3497. desc.innerText = entry.desc;
  3498. desc.classList.add("settings-desc");
  3499. desc.setAttribute("for", input.id);
  3500. if (entry.type == "toggle") {
  3501. input.type = "checkbox";
  3502. input.checked =
  3503. userSettings[id] === undefined
  3504. ? entry.default
  3505. : userSettings[id];
  3506. holder.setAttribute("for", input.id);
  3507. vertical.appendChild(name);
  3508. vertical.appendChild(desc);
  3509. holder.appendChild(vertical);
  3510. holder.appendChild(input);
  3511. menubar.appendChild(holder);
  3512. const update = () => {
  3513. if (input.checked) {
  3514. holder.classList.add("enabled");
  3515. holder.classList.remove("disabled");
  3516. } else {
  3517. holder.classList.remove("enabled");
  3518. holder.classList.add("disabled");
  3519. }
  3520. entry.value = input.checked;
  3521. };
  3522. setTimeout(update);
  3523. input.addEventListener("change", update);
  3524. } else if (entry.type == "select") {
  3525. // we don't use the input element we made!
  3526. const select = document.createElement("select");
  3527. select.id = "setting-" + id;
  3528. entry.options.forEach((choice) => {
  3529. const option = document.createElement("option");
  3530. option.innerText = choice;
  3531. select.appendChild(option);
  3532. });
  3533. select.value =
  3534. userSettings[id] === undefined
  3535. ? entry.default
  3536. : userSettings[id];
  3537. vertical.appendChild(name);
  3538. vertical.appendChild(desc);
  3539. holder.appendChild(vertical);
  3540. holder.appendChild(select);
  3541. menubar.appendChild(holder);
  3542. const update = () => {
  3543. entry.value = select.value;
  3544. if (
  3545. entry.disabled !== undefined &&
  3546. entry.value !== entry.disabled
  3547. ) {
  3548. holder.classList.add("enabled");
  3549. holder.classList.remove("disabled");
  3550. } else {
  3551. holder.classList.remove("enabled");
  3552. holder.classList.add("disabled");
  3553. }
  3554. };
  3555. update();
  3556. select.addEventListener("change", update);
  3557. }
  3558. });
  3559. });
  3560. }
  3561. function updateSaveInfo() {
  3562. const saves = getSaves();
  3563. const load = document.querySelector("#menu-load ~ select");
  3564. load.innerHTML = "";
  3565. saves.forEach((save) => {
  3566. const option = document.createElement("option");
  3567. option.innerText = save;
  3568. option.value = save;
  3569. load.appendChild(option);
  3570. });
  3571. const del = document.querySelector("#menu-delete ~ select");
  3572. del.innerHTML = "";
  3573. saves.forEach((save) => {
  3574. const option = document.createElement("option");
  3575. option.innerText = save;
  3576. option.value = save;
  3577. del.appendChild(option);
  3578. });
  3579. }
  3580. function getSaves() {
  3581. try {
  3582. const results = [];
  3583. Object.keys(localStorage).forEach((key) => {
  3584. if (key.startsWith("macrovision-save-")) {
  3585. results.push(key.replace("macrovision-save-", ""));
  3586. }
  3587. });
  3588. return results;
  3589. } catch (err) {
  3590. alert(
  3591. "Something went wrong while loading (maybe you didn't have anything saved. Check the F12 console for the error."
  3592. );
  3593. console.error(err);
  3594. return false;
  3595. }
  3596. }
  3597. function getUserSettings() {
  3598. try {
  3599. const settings = JSON.parse(localStorage.getItem("settings"));
  3600. return settings === null ? {} : settings;
  3601. } catch {
  3602. return {};
  3603. }
  3604. }
  3605. function exportUserSettings() {
  3606. const settings = {};
  3607. Object.entries(settingsData).forEach(([id, entry]) => {
  3608. settings[id] = entry.value;
  3609. });
  3610. return settings;
  3611. }
  3612. function setUserSettings(settings) {
  3613. try {
  3614. localStorage.setItem("settings", JSON.stringify(settings));
  3615. } catch {
  3616. // :(
  3617. }
  3618. }
  3619. function doYScroll() {
  3620. const worldHeight = config.height.toNumber("meters");
  3621. config.y += (scrollDirection * worldHeight) / 180;
  3622. updateSizes();
  3623. scrollDirection *= 1.05;
  3624. }
  3625. function doXScroll() {
  3626. const worldWidth =
  3627. (config.height.toNumber("meters") / canvasHeight) * canvasWidth;
  3628. config.x += (scrollDirection * worldWidth) / 180;
  3629. updateSizes();
  3630. scrollDirection *= 1.05;
  3631. }
  3632. function doZoom() {
  3633. const oldHeight = config.height;
  3634. setWorldHeight(oldHeight, math.multiply(oldHeight, 1 + zoomDirection / 10));
  3635. zoomDirection *= 1.05;
  3636. }
  3637. function doSize() {
  3638. if (selected) {
  3639. const entity = entities[selected.dataset.key];
  3640. const oldHeight = entity.views[entity.view].height;
  3641. entity.views[entity.view].height = math.multiply(
  3642. oldHeight,
  3643. sizeDirection < 0 ? -1 / sizeDirection : sizeDirection
  3644. );
  3645. entity.dirty = true;
  3646. updateEntityOptions(entity, entity.view);
  3647. updateViewOptions(entity, entity.view);
  3648. updateSizes(true);
  3649. sizeDirection *= 1.01;
  3650. const ownHeight = entity.views[entity.view].height.toNumber("meters");
  3651. let extra = entity.views[entity.view].image.extra;
  3652. extra = extra === undefined ? 1 : extra;
  3653. const worldHeight = config.height.toNumber("meters");
  3654. if (ownHeight * extra > worldHeight) {
  3655. setWorldHeight(
  3656. config.height,
  3657. math.multiply(entity.views[entity.view].height, extra)
  3658. );
  3659. } else if (ownHeight * extra * 10 < worldHeight) {
  3660. setWorldHeight(
  3661. config.height,
  3662. math.multiply(entity.views[entity.view].height, extra * 10)
  3663. );
  3664. }
  3665. }
  3666. }
  3667. function selectNewUnit() {
  3668. const unitSelector = document.querySelector("#options-height-unit");
  3669. checkFitWorld();
  3670. const scaleInput = document.querySelector("#options-height-value");
  3671. const newVal = math
  3672. .unit(scaleInput.value, unitSelector.dataset.oldUnit)
  3673. .toNumber(unitSelector.value);
  3674. setNumericInput(scaleInput, newVal);
  3675. updateWorldHeight();
  3676. unitSelector.dataset.oldUnit = unitSelector.value;
  3677. }
  3678. // given a world position, return the position relative to the entity at normal scale
  3679. function entityRelativePosition(pos, entityElement) {
  3680. const entity = entities[entityElement.dataset.key];
  3681. const x = parseFloat(entityElement.dataset.x);
  3682. const y = parseFloat(entityElement.dataset.y);
  3683. pos.x -= x;
  3684. pos.y -= y;
  3685. pos.x /= entity.scale;
  3686. pos.y /= entity.scale;
  3687. return pos;
  3688. }
  3689. function hidePopoutMenu(id) {
  3690. document.querySelector(id).classList.remove("visible");
  3691. }
  3692. function showPopoutMenu(id, event) {
  3693. document
  3694. .querySelectorAll(".popout-menu")
  3695. .forEach((menu) => menu.classList.remove("visible"));
  3696. const popoutMenu = document.querySelector(id);
  3697. const rect = event.currentTarget.getBoundingClientRect();
  3698. popoutMenu.classList.add("visible");
  3699. popoutMenu.style.left = rect.x + "px";
  3700. popoutMenu.style.top = rect.y + rect.height + "px";
  3701. let menuWidth = popoutMenu.getBoundingClientRect().width;
  3702. let screenWidth = window.innerWidth;
  3703. if (rect.x + menuWidth > screenWidth) {
  3704. popoutMenu.style.left = Math.max(0, screenWidth - menuWidth) + "px";
  3705. }
  3706. }
  3707. function setupPopoutMenu(type) {
  3708. let buttonId = "#toggle-" + type;
  3709. let menuId = "#" + type + "-menu";
  3710. document.querySelector(buttonId).addEventListener("click", (e) => {
  3711. const popoutMenu = document.querySelector(menuId);
  3712. if (popoutMenu.classList.contains("visible")) {
  3713. hidePopoutMenu(menuId);
  3714. } else {
  3715. showPopoutMenu(menuId, e);
  3716. }
  3717. e.stopPropagation();
  3718. });
  3719. document.querySelector(buttonId).addEventListener("touchend", (e) => {
  3720. e.stopPropagation();
  3721. });
  3722. document.querySelector(menuId).addEventListener("click", (e) => {
  3723. e.stopPropagation();
  3724. });
  3725. document.querySelector(menuId).addEventListener("touchend", (e) => {
  3726. e.stopPropagation();
  3727. });
  3728. document.addEventListener("click", (e) => {
  3729. document.querySelector(menuId).classList.remove("visible");
  3730. });
  3731. document.addEventListener("touchend", (e) => {
  3732. document.querySelector(menuId).classList.remove("visible");
  3733. });
  3734. }
  3735. function setupMenuButtons() {
  3736. document
  3737. .querySelector("#copy-screenshot")
  3738. .addEventListener("click", (e) => {
  3739. copyScreenshot();
  3740. });
  3741. document
  3742. .querySelector("#save-screenshot")
  3743. .addEventListener("click", (e) => {
  3744. saveScreenshot();
  3745. });
  3746. document
  3747. .querySelector("#open-screenshot")
  3748. .addEventListener("click", (e) => {
  3749. openScreenshot();
  3750. });
  3751. setupPopoutMenu("menu");
  3752. setupPopoutMenu("scene");
  3753. setupPopoutMenu("settings");
  3754. setupPopoutMenu("filters");
  3755. setupPopoutMenu("info");
  3756. }
  3757. function setupSidebar() {
  3758. document
  3759. .querySelector("#options-selected-entity")
  3760. .addEventListener("input", (e) => {
  3761. if (e.target.value == "None") {
  3762. deselect();
  3763. } else {
  3764. select(document.querySelector("#entity-" + e.target.value));
  3765. }
  3766. });
  3767. document
  3768. .querySelector("#options-order-forward")
  3769. .addEventListener("click", (e) => {
  3770. if (selected) {
  3771. entities[selected.dataset.key].priority += 1;
  3772. }
  3773. document.querySelector("#options-order-display").innerText =
  3774. entities[selected.dataset.key].priority;
  3775. updateSizes();
  3776. });
  3777. document
  3778. .querySelector("#options-order-back")
  3779. .addEventListener("click", (e) => {
  3780. if (selected) {
  3781. entities[selected.dataset.key].priority -= 1;
  3782. }
  3783. document.querySelector("#options-order-display").innerText =
  3784. entities[selected.dataset.key].priority;
  3785. updateSizes();
  3786. });
  3787. document
  3788. .querySelector("#options-brightness-up")
  3789. .addEventListener("click", (e) => {
  3790. if (selected) {
  3791. entities[selected.dataset.key].brightness += 0.5;
  3792. updateEntityProperties(selected);
  3793. }
  3794. document.querySelector("#options-brightness-display").innerText =
  3795. entities[selected.dataset.key].brightness;
  3796. updateSizes();
  3797. });
  3798. document
  3799. .querySelector("#options-brightness-down")
  3800. .addEventListener("click", (e) => {
  3801. if (selected) {
  3802. entities[selected.dataset.key].brightness -= 0.5;
  3803. updateEntityProperties(selected);
  3804. }
  3805. document.querySelector("#options-brightness-display").innerText =
  3806. entities[selected.dataset.key].brightness;
  3807. updateSizes();
  3808. });
  3809. document
  3810. .querySelector("#options-rotate-left")
  3811. .addEventListener("click", (e) => {
  3812. if (selected) {
  3813. entities[selected.dataset.key].rotation -= Math.PI / 4;
  3814. updateEntityProperties(selected);
  3815. }
  3816. updateSizes();
  3817. });
  3818. document
  3819. .querySelector("#options-rotate-right")
  3820. .addEventListener("click", (e) => {
  3821. if (selected) {
  3822. entities[selected.dataset.key].rotation += Math.PI / 4;
  3823. updateEntityProperties(selected);
  3824. }
  3825. updateSizes();
  3826. });
  3827. document.querySelector("#options-flip").addEventListener("click", (e) => {
  3828. if (selected) {
  3829. entities[selected.dataset.key].flipped = !entities[selected.dataset.key].flipped
  3830. updateEntityProperties(selected);
  3831. }
  3832. updateSizes();
  3833. });
  3834. const formList = document.querySelector("#entity-form");
  3835. formList.addEventListener("input", (e) => {
  3836. const entity = entities[selected.dataset.key];
  3837. entity.form = e.target.value;
  3838. const oldView = entity.currentView;
  3839. entity.view = entity.formViews[entity.form];
  3840. // to set the size properly, even if we use a non-default view
  3841. if (Object.keys(entity.forms).length > 0 && !entity.formSizesMatch)
  3842. entity.views[entity.view].height =
  3843. entity.formSizes[entity.form].height;
  3844. let found = Object.entries(entity.views).find(([key, view]) => {
  3845. return view.form === entity.form && view.name === oldView.name;
  3846. });
  3847. const newView = found ? found[0] : entity.formViews[entity.form];
  3848. entity.view = newView;
  3849. preloadViews(entity);
  3850. configViewList(entity, entity.view);
  3851. const image = entity.views[entity.view].image;
  3852. selected.querySelector(".entity-image").src = image.source;
  3853. configViewOptions(entity, entity.view);
  3854. displayAttribution(image.source);
  3855. if (image.bottom !== undefined) {
  3856. selected
  3857. .querySelector(".entity-image")
  3858. .style.setProperty("--offset", (-1 + image.bottom) * 100 + "%");
  3859. } else {
  3860. selected
  3861. .querySelector(".entity-image")
  3862. .style.setProperty("--offset", -1 * 100 + "%");
  3863. }
  3864. if (config.autoFitSize) {
  3865. let targets = {};
  3866. targets[selected.dataset.key] = entities[selected.dataset.key];
  3867. fitEntities(targets);
  3868. }
  3869. configSizeList(entity);
  3870. updateSizes();
  3871. updateEntityOptions(entities[selected.dataset.key], e.target.value);
  3872. updateViewOptions(entities[selected.dataset.key], entity.view);
  3873. });
  3874. const viewList = document.querySelector("#entity-view");
  3875. document.querySelector("#entity-view").addEventListener("input", (e) => {
  3876. const entity = entities[selected.dataset.key];
  3877. entity.view = e.target.value;
  3878. preloadViews(entity);
  3879. const image =
  3880. entities[selected.dataset.key].views[e.target.value].image;
  3881. selected.querySelector(".entity-image").src = image.source;
  3882. configViewOptions(entity, entity.view);
  3883. displayAttribution(image.source);
  3884. if (image.bottom !== undefined) {
  3885. selected
  3886. .querySelector(".entity-image")
  3887. .style.setProperty("--offset", (-1 + image.bottom) * 100 + "%");
  3888. } else {
  3889. selected
  3890. .querySelector(".entity-image")
  3891. .style.setProperty("--offset", -1 * 100 + "%");
  3892. }
  3893. updateSizes();
  3894. updateEntityOptions(entities[selected.dataset.key], e.target.value);
  3895. updateViewOptions(entities[selected.dataset.key], e.target.value);
  3896. });
  3897. document.querySelector("#entity-view").addEventListener("input", (e) => {
  3898. if (
  3899. viewList.options[viewList.selectedIndex].classList.contains("nsfw")
  3900. ) {
  3901. viewList.classList.add("nsfw");
  3902. } else {
  3903. viewList.classList.remove("nsfw");
  3904. }
  3905. });
  3906. }
  3907. function setupScrollButtons() {
  3908. // TODO: write some generic logic for this lol
  3909. document
  3910. .querySelector("#scroll-left")
  3911. .addEventListener("mousedown", (e) => {
  3912. scrollDirection = -1;
  3913. clearInterval(scrollHandle);
  3914. scrollHandle = setInterval(doXScroll, 1000 / 20);
  3915. e.stopPropagation();
  3916. });
  3917. document
  3918. .querySelector("#scroll-right")
  3919. .addEventListener("mousedown", (e) => {
  3920. scrollDirection = 1;
  3921. clearInterval(scrollHandle);
  3922. scrollHandle = setInterval(doXScroll, 1000 / 20);
  3923. e.stopPropagation();
  3924. });
  3925. document
  3926. .querySelector("#scroll-left")
  3927. .addEventListener("touchstart", (e) => {
  3928. scrollDirection = -1;
  3929. clearInterval(scrollHandle);
  3930. scrollHandle = setInterval(doXScroll, 1000 / 20);
  3931. e.stopPropagation();
  3932. });
  3933. document
  3934. .querySelector("#scroll-right")
  3935. .addEventListener("touchstart", (e) => {
  3936. scrollDirection = 1;
  3937. clearInterval(scrollHandle);
  3938. scrollHandle = setInterval(doXScroll, 1000 / 20);
  3939. e.stopPropagation();
  3940. });
  3941. document.querySelector("#scroll-up").addEventListener("mousedown", (e) => {
  3942. if (config.lockYAxis) {
  3943. moveGround(true);
  3944. } else {
  3945. scrollDirection = 1;
  3946. clearInterval(scrollHandle);
  3947. scrollHandle = setInterval(doYScroll, 1000 / 20);
  3948. e.stopPropagation();
  3949. }
  3950. });
  3951. document
  3952. .querySelector("#scroll-down")
  3953. .addEventListener("mousedown", (e) => {
  3954. if (config.lockYAxis) {
  3955. moveGround(false);
  3956. } else {
  3957. scrollDirection = -1;
  3958. clearInterval(scrollHandle);
  3959. scrollHandle = setInterval(doYScroll, 1000 / 20);
  3960. e.stopPropagation();
  3961. }
  3962. });
  3963. document.querySelector("#scroll-up").addEventListener("touchstart", (e) => {
  3964. if (config.lockYAxis) {
  3965. moveGround(true);
  3966. } else {
  3967. scrollDirection = 1;
  3968. clearInterval(scrollHandle);
  3969. scrollHandle = setInterval(doYScroll, 1000 / 20);
  3970. e.stopPropagation();
  3971. }
  3972. });
  3973. document
  3974. .querySelector("#scroll-down")
  3975. .addEventListener("touchstart", (e) => {
  3976. if (config.lockYAxis) {
  3977. moveGround(false);
  3978. } else {
  3979. scrollDirection = -1;
  3980. clearInterval(scrollHandle);
  3981. scrollHandle = setInterval(doYScroll, 1000 / 20);
  3982. e.stopPropagation();
  3983. }
  3984. });
  3985. document.addEventListener("mouseup", (e) => {
  3986. clearInterval(scrollHandle);
  3987. scrollHandle = null;
  3988. });
  3989. document.addEventListener("touchend", (e) => {
  3990. clearInterval(scrollHandle);
  3991. scrollHandle = null;
  3992. });
  3993. document.querySelector("#zoom-in").addEventListener("mousedown", (e) => {
  3994. zoomDirection = -1;
  3995. clearInterval(zoomHandle);
  3996. zoomHandle = setInterval(doZoom, 1000 / 20);
  3997. e.stopPropagation();
  3998. });
  3999. document.querySelector("#zoom-out").addEventListener("mousedown", (e) => {
  4000. zoomDirection = 1;
  4001. clearInterval(zoomHandle);
  4002. zoomHandle = setInterval(doZoom, 1000 / 20);
  4003. e.stopPropagation();
  4004. });
  4005. document.querySelector("#zoom-in").addEventListener("touchstart", (e) => {
  4006. zoomDirection = -1;
  4007. clearInterval(zoomHandle);
  4008. zoomHandle = setInterval(doZoom, 1000 / 20);
  4009. e.stopPropagation();
  4010. });
  4011. document.querySelector("#zoom-out").addEventListener("touchstart", (e) => {
  4012. zoomDirection = 1;
  4013. clearInterval(zoomHandle);
  4014. zoomHandle = setInterval(doZoom, 1000 / 20);
  4015. e.stopPropagation();
  4016. });
  4017. document.addEventListener("mouseup", (e) => {
  4018. clearInterval(zoomHandle);
  4019. zoomHandle = null;
  4020. });
  4021. document.addEventListener("touchend", (e) => {
  4022. clearInterval(zoomHandle);
  4023. zoomHandle = null;
  4024. });
  4025. document.querySelector("#shrink").addEventListener("mousedown", (e) => {
  4026. sizeDirection = -1;
  4027. clearInterval(sizeHandle);
  4028. sizeHandle = setInterval(doSize, 1000 / 20);
  4029. e.stopPropagation();
  4030. });
  4031. document.querySelector("#grow").addEventListener("mousedown", (e) => {
  4032. sizeDirection = 1;
  4033. clearInterval(sizeHandle);
  4034. sizeHandle = setInterval(doSize, 1000 / 20);
  4035. e.stopPropagation();
  4036. });
  4037. document.querySelector("#shrink").addEventListener("touchstart", (e) => {
  4038. sizeDirection = -1;
  4039. clearInterval(sizeHandle);
  4040. sizeHandle = setInterval(doSize, 1000 / 20);
  4041. e.stopPropagation();
  4042. });
  4043. document.querySelector("#grow").addEventListener("touchstart", (e) => {
  4044. sizeDirection = 1;
  4045. clearInterval(sizeHandle);
  4046. sizeHandle = setInterval(doSize, 1000 / 20);
  4047. e.stopPropagation();
  4048. });
  4049. document.addEventListener("mouseup", (e) => {
  4050. clearInterval(sizeHandle);
  4051. sizeHandle = null;
  4052. });
  4053. document.addEventListener("touchend", (e) => {
  4054. clearInterval(sizeHandle);
  4055. sizeHandle = null;
  4056. });
  4057. document.querySelector("#ruler").addEventListener("click", (e) => {
  4058. rulerMode = !rulerMode;
  4059. if (rulerMode) {
  4060. toast("Ready to draw a ruler mark");
  4061. } else {
  4062. toast("Cancelled ruler mode");
  4063. }
  4064. });
  4065. document.querySelector("#ruler").addEventListener("mousedown", (e) => {
  4066. e.stopPropagation();
  4067. });
  4068. document.querySelector("#ruler").addEventListener("touchstart", (e) => {
  4069. e.stopPropagation();
  4070. });
  4071. document.querySelector("#fit").addEventListener("click", (e) => {
  4072. if (selected) {
  4073. let targets = {};
  4074. targets[selected.dataset.key] = entities[selected.dataset.key];
  4075. fitEntities(targets);
  4076. }
  4077. });
  4078. document.querySelector("#fit").addEventListener("mousedown", (e) => {
  4079. e.stopPropagation();
  4080. });
  4081. document.querySelector("#fit").addEventListener("touchstart", (e) => {
  4082. e.stopPropagation();
  4083. });
  4084. }
  4085. function prepareMenu() {
  4086. preparePopoutMenu();
  4087. updateSaveInfo();
  4088. setupMenuButtons();
  4089. setupSidebar();
  4090. setupScrollButtons();
  4091. }
  4092. function prepareEvents() {
  4093. window.addEventListener("unload", () => {
  4094. saveScene("autosave");
  4095. setUserSettings(exportUserSettings());
  4096. });
  4097. document.querySelector("#world").addEventListener("mousedown", (e) => {
  4098. // only middle mouse clicks
  4099. if (e.which == 2) {
  4100. panning = true;
  4101. panOffsetX = e.clientX;
  4102. panOffsetY = e.clientY;
  4103. Object.keys(entities).forEach((key) => {
  4104. document
  4105. .querySelector("#entity-" + key)
  4106. .classList.add("no-transition");
  4107. });
  4108. }
  4109. });
  4110. document.addEventListener("mouseup", (e) => {
  4111. if (e.which == 2) {
  4112. panning = false;
  4113. Object.keys(entities).forEach((key) => {
  4114. document
  4115. .querySelector("#entity-" + key)
  4116. .classList.remove("no-transition");
  4117. });
  4118. }
  4119. });
  4120. document.querySelector("#world").addEventListener("touchstart", (e) => {
  4121. if (!rulerMode) {
  4122. panning = true;
  4123. panOffsetX = e.touches[0].clientX;
  4124. panOffsetY = e.touches[0].clientY;
  4125. e.preventDefault();
  4126. Object.keys(entities).forEach((key) => {
  4127. document
  4128. .querySelector("#entity-" + key)
  4129. .classList.add("no-transition");
  4130. });
  4131. }
  4132. });
  4133. document.querySelector("#world").addEventListener("touchend", (e) => {
  4134. panning = false;
  4135. Object.keys(entities).forEach((key) => {
  4136. document
  4137. .querySelector("#entity-" + key)
  4138. .classList.remove("no-transition");
  4139. });
  4140. });
  4141. document.querySelector("#world").addEventListener("mousedown", (e) => {
  4142. // only left mouse clicks
  4143. if (e.which == 1 && rulerMode) {
  4144. let entX = document
  4145. .querySelector("#entities")
  4146. .getBoundingClientRect().x;
  4147. let entY = document
  4148. .querySelector("#entities")
  4149. .getBoundingClientRect().y;
  4150. let pos = pix2pos({ x: e.clientX - entX, y: e.clientY - entY });
  4151. if (config.rulersStick && selected) {
  4152. pos = entityRelativePosition(pos, selected);
  4153. }
  4154. currentRuler = {
  4155. x0: pos.x,
  4156. y0: pos.y,
  4157. x1: pos.y,
  4158. y1: pos.y,
  4159. entityKey: null,
  4160. };
  4161. if (config.rulersStick && selected) {
  4162. currentRuler.entityKey = selected.dataset.key;
  4163. }
  4164. }
  4165. });
  4166. document.querySelector("#world").addEventListener("mouseup", (e) => {
  4167. // only left mouse clicks
  4168. if (e.which == 1 && currentRuler) {
  4169. rulers.push(currentRuler);
  4170. currentRuler = null;
  4171. rulerMode = false;
  4172. }
  4173. });
  4174. document.querySelector("#world").addEventListener("touchstart", (e) => {
  4175. if (rulerMode) {
  4176. let entX = document
  4177. .querySelector("#entities")
  4178. .getBoundingClientRect().x;
  4179. let entY = document
  4180. .querySelector("#entities")
  4181. .getBoundingClientRect().y;
  4182. let pos = pix2pos({
  4183. x: e.touches[0].clientX - entX,
  4184. y: e.touches[0].clientY - entY,
  4185. });
  4186. if (config.rulersStick && selected) {
  4187. pos = entityRelativePosition(pos, selected);
  4188. }
  4189. currentRuler = {
  4190. x0: pos.x,
  4191. y0: pos.y,
  4192. x1: pos.y,
  4193. y1: pos.y,
  4194. entityKey: null,
  4195. };
  4196. if (config.rulersStick && selected) {
  4197. currentRuler.entityKey = selected.dataset.key;
  4198. }
  4199. }
  4200. });
  4201. document.querySelector("#world").addEventListener("touchend", (e) => {
  4202. if (currentRuler) {
  4203. rulers.push(currentRuler);
  4204. currentRuler = null;
  4205. rulerMode = false;
  4206. }
  4207. });
  4208. document.querySelector("body").appendChild(testCtx.canvas);
  4209. world.addEventListener("mousedown", (e) => deselect(e));
  4210. world.addEventListener("touchstart", (e) =>
  4211. deselect({
  4212. which: 1,
  4213. })
  4214. );
  4215. document.querySelector("#entities").addEventListener("mousedown", deselect);
  4216. document.querySelector("#display").addEventListener("mousedown", deselect);
  4217. document.addEventListener("mouseup", (e) => clickUp(e));
  4218. document.addEventListener("touchend", (e) => {
  4219. const fakeEvent = {
  4220. target: e.target,
  4221. clientX: e.changedTouches[0].clientX,
  4222. clientY: e.changedTouches[0].clientY,
  4223. which: 1,
  4224. };
  4225. clickUp(fakeEvent);
  4226. });
  4227. document.addEventListener("keydown", (e) => {
  4228. if (e.key == "Delete" || e.key == "Backspace") {
  4229. if (selected) {
  4230. removeEntity(selected);
  4231. selected = null;
  4232. }
  4233. }
  4234. });
  4235. document.addEventListener("keydown", (e) => {
  4236. if (e.key == "Shift") {
  4237. shiftHeld = true;
  4238. e.preventDefault();
  4239. } else if (e.key == "Alt") {
  4240. altHeld = true;
  4241. movingInBounds = false; // don't snap the object back in bounds when we let go
  4242. e.preventDefault();
  4243. }
  4244. });
  4245. document.addEventListener("keyup", (e) => {
  4246. if (e.key == "Shift") {
  4247. shiftHeld = false;
  4248. e.preventDefault();
  4249. } else if (e.key == "Alt") {
  4250. altHeld = false;
  4251. e.preventDefault();
  4252. }
  4253. });
  4254. document.addEventListener("paste", (e) => {
  4255. let index = 0;
  4256. let item = null;
  4257. let found = false;
  4258. for (; index < e.clipboardData.items.length; index++) {
  4259. item = e.clipboardData.items[index];
  4260. if (item.type == "image/png") {
  4261. found = true;
  4262. break;
  4263. }
  4264. }
  4265. if (!found) {
  4266. return;
  4267. }
  4268. let url = null;
  4269. const file = item.getAsFile();
  4270. customEntityFromFile(file);
  4271. });
  4272. document.querySelector("#world").addEventListener("dragover", (e) => {
  4273. e.preventDefault();
  4274. });
  4275. document.querySelector("#world").addEventListener("drop", (e) => {
  4276. e.preventDefault();
  4277. if (e.dataTransfer.files.length > 0) {
  4278. let entX = document
  4279. .querySelector("#entities")
  4280. .getBoundingClientRect().x;
  4281. let entY = document
  4282. .querySelector("#entities")
  4283. .getBoundingClientRect().y;
  4284. let coords = pix2pos({ x: e.clientX - entX, y: e.clientY - entY });
  4285. customEntityFromFile(e.dataTransfer.files[0], coords.x, coords.y);
  4286. }
  4287. });
  4288. document.querySelector("#world").addEventListener("wheel", (e) => {
  4289. let magnitude = Math.abs(e.deltaY / 100);
  4290. if (shiftHeld) {
  4291. // macs do horizontal scrolling with shift held
  4292. let delta = e.deltaY;
  4293. if (e.deltaY == 0) {
  4294. magnitude = Math.abs(e.deltaX / 100);
  4295. delta = e.deltaX;
  4296. }
  4297. if (selected) {
  4298. let dir = delta > 0 ? 10 / 11 : 11 / 10;
  4299. dir -= 1;
  4300. dir *= magnitude;
  4301. dir += 1;
  4302. const entity = entities[selected.dataset.key];
  4303. entity.views[entity.view].height = math.multiply(
  4304. entity.views[entity.view].height,
  4305. dir
  4306. );
  4307. entity.dirty = true;
  4308. updateEntityOptions(entity, entity.view);
  4309. updateViewOptions(entity, entity.view);
  4310. updateSizes(true);
  4311. } else {
  4312. const worldWidth =
  4313. (config.height.toNumber("meters") / canvasHeight) *
  4314. canvasWidth;
  4315. config.x += ((e.deltaY > 0 ? 1 : -1) * worldWidth) / 20;
  4316. updateSizes();
  4317. updateSizes();
  4318. }
  4319. } else {
  4320. if (config.autoFit) {
  4321. toastRateLimit(
  4322. "Zoom is locked! Check Settings to disable.",
  4323. "zoom-lock",
  4324. 1000
  4325. );
  4326. } else {
  4327. let dir = e.deltaY < 0 ? 10 / 11 : 11 / 10;
  4328. dir -= 1;
  4329. dir *= magnitude;
  4330. dir += 1;
  4331. const change =
  4332. config.height.toNumber("meters") -
  4333. math.multiply(config.height, dir).toNumber("meters");
  4334. if (!config.lockYAxis) {
  4335. config.y += change / 2;
  4336. }
  4337. setWorldHeight(
  4338. config.height,
  4339. math.multiply(config.height, dir)
  4340. );
  4341. updateWorldOptions();
  4342. }
  4343. }
  4344. checkFitWorld();
  4345. });
  4346. document.addEventListener("mousemove", (e) => {
  4347. if (currentRuler) {
  4348. let entX = document
  4349. .querySelector("#entities")
  4350. .getBoundingClientRect().x;
  4351. let entY = document
  4352. .querySelector("#entities")
  4353. .getBoundingClientRect().y;
  4354. let position = pix2pos({
  4355. x: e.clientX - entX,
  4356. y: e.clientY - entY,
  4357. });
  4358. if (config.rulersStick && selected) {
  4359. position = entityRelativePosition(position, selected);
  4360. }
  4361. currentRuler.x1 = position.x;
  4362. currentRuler.y1 = position.y;
  4363. }
  4364. drawRulers();
  4365. });
  4366. document.addEventListener("touchmove", (e) => {
  4367. if (currentRuler) {
  4368. let entX = document
  4369. .querySelector("#entities")
  4370. .getBoundingClientRect().x;
  4371. let entY = document
  4372. .querySelector("#entities")
  4373. .getBoundingClientRect().y;
  4374. let position = pix2pos({
  4375. x: e.touches[0].clientX - entX,
  4376. y: e.touches[0].clientY - entY,
  4377. });
  4378. if (config.rulersStick && selected) {
  4379. position = entityRelativePosition(position, selected);
  4380. }
  4381. currentRuler.x1 = position.x;
  4382. currentRuler.y1 = position.y;
  4383. }
  4384. drawRulers();
  4385. });
  4386. document.addEventListener("mousemove", (e) => {
  4387. if (clicked) {
  4388. let position = pix2pos({
  4389. x: e.clientX - dragOffsetX,
  4390. y: e.clientY - dragOffsetY,
  4391. });
  4392. if (movingInBounds) {
  4393. position = snapPos(position);
  4394. } else {
  4395. let x = e.clientX - dragOffsetX;
  4396. let y = e.clientY - dragOffsetY;
  4397. if (x >= 0 && x <= canvasWidth && y >= 0 && y <= canvasHeight) {
  4398. movingInBounds = true;
  4399. }
  4400. }
  4401. clicked.dataset.x = position.x;
  4402. clicked.dataset.y = position.y;
  4403. updateEntityElement(entities[clicked.dataset.key], clicked);
  4404. if (hoveringInDeleteArea(e)) {
  4405. document
  4406. .querySelector("#menubar")
  4407. .classList.add("hover-delete");
  4408. } else {
  4409. document
  4410. .querySelector("#menubar")
  4411. .classList.remove("hover-delete");
  4412. }
  4413. }
  4414. if (panning && panReady) {
  4415. const worldWidth =
  4416. (config.height.toNumber("meters") / canvasHeight) * canvasWidth;
  4417. const worldHeight = config.height.toNumber("meters");
  4418. config.x -= ((e.clientX - panOffsetX) / canvasWidth) * worldWidth;
  4419. config.y += ((e.clientY - panOffsetY) / canvasHeight) * worldHeight;
  4420. panOffsetX = e.clientX;
  4421. panOffsetY = e.clientY;
  4422. updateSizes();
  4423. panReady = false;
  4424. setTimeout(() => (panReady = true), 1000 / 120);
  4425. }
  4426. });
  4427. document.addEventListener(
  4428. "touchmove",
  4429. (e) => {
  4430. if (clicked) {
  4431. e.preventDefault();
  4432. let x = e.touches[0].clientX;
  4433. let y = e.touches[0].clientY;
  4434. const position = snapPos(
  4435. pix2pos({ x: x - dragOffsetX, y: y - dragOffsetY })
  4436. );
  4437. clicked.dataset.x = position.x;
  4438. clicked.dataset.y = position.y;
  4439. updateEntityElement(entities[clicked.dataset.key], clicked);
  4440. // what a hack
  4441. // I should centralize this 'fake event' creation...
  4442. if (hoveringInDeleteArea({ clientY: y })) {
  4443. document
  4444. .querySelector("#menubar")
  4445. .classList.add("hover-delete");
  4446. } else {
  4447. document
  4448. .querySelector("#menubar")
  4449. .classList.remove("hover-delete");
  4450. }
  4451. }
  4452. if (panning && panReady) {
  4453. const worldWidth =
  4454. (config.height.toNumber("meters") / canvasHeight) *
  4455. canvasWidth;
  4456. const worldHeight = config.height.toNumber("meters");
  4457. config.x -=
  4458. ((e.touches[0].clientX - panOffsetX) / canvasWidth) *
  4459. worldWidth;
  4460. config.y +=
  4461. ((e.touches[0].clientY - panOffsetY) / canvasHeight) *
  4462. worldHeight;
  4463. panOffsetX = e.touches[0].clientX;
  4464. panOffsetY = e.touches[0].clientY;
  4465. updateSizes();
  4466. panReady = false;
  4467. setTimeout(() => (panReady = true), 1000 / 60);
  4468. }
  4469. },
  4470. { passive: false }
  4471. );
  4472. document
  4473. .querySelector("#search-box")
  4474. .addEventListener("change", (e) => doSearch(e.target.value));
  4475. document
  4476. .querySelector("#search-box")
  4477. .addEventListener("keydown", e => e.stopPropagation());
  4478. }
  4479. document.addEventListener("DOMContentLoaded", () => {
  4480. prepareMenu();
  4481. prepareEntities();
  4482. prepareEvents();
  4483. handleResize(false);
  4484. document
  4485. .querySelector("#options-height-value")
  4486. .addEventListener("change", (e) => {
  4487. updateWorldHeight();
  4488. });
  4489. document
  4490. .querySelector("#options-height-value")
  4491. .addEventListener("keydown", (e) => {
  4492. e.stopPropagation();
  4493. });
  4494. const unitSelector = document.querySelector("#options-height-unit");
  4495. Object.entries(unitChoices.length).forEach(([group, entries]) => {
  4496. const optGroup = document.createElement("optgroup");
  4497. optGroup.label = group;
  4498. unitSelector.appendChild(optGroup);
  4499. entries.forEach((entry) => {
  4500. const option = document.createElement("option");
  4501. option.innerText = entry;
  4502. // we haven't loaded user settings yet, so we can't choose the unit just yet
  4503. unitSelector.appendChild(option);
  4504. });
  4505. });
  4506. unitSelector.addEventListener("input", selectNewUnit);
  4507. param = window.location.hash;
  4508. // we now use the fragment for links, but we should still support old stuff:
  4509. if (param.length > 0) {
  4510. param = param.substring(1);
  4511. } else {
  4512. param = new URL(window.location.href).searchParams.get("scene");
  4513. }
  4514. clearViewList();
  4515. window.addEventListener("resize", handleResize);
  4516. // TODO: further investigate why the tool initially starts out with wrong
  4517. // values under certain circumstances (seems to be narrow aspect ratios -
  4518. // maybe the menu bar is animating when it shouldn't)
  4519. setTimeout(handleResize, 250);
  4520. setTimeout(handleResize, 500);
  4521. setTimeout(handleResize, 750);
  4522. setTimeout(handleResize, 1000);
  4523. document
  4524. .querySelector("#menu-load-autosave")
  4525. .addEventListener("click", (e) => {
  4526. loadScene("autosave");
  4527. });
  4528. document.querySelector("#menu-add-image").addEventListener("click", (e) => {
  4529. document.querySelector("#file-upload-picker").click();
  4530. });
  4531. document
  4532. .querySelector("#file-upload-picker")
  4533. .addEventListener("change", (e) => {
  4534. if (e.target.files.length > 0) {
  4535. for (let i = 0; i < e.target.files.length; i++) {
  4536. customEntityFromFile(e.target.files[i]);
  4537. }
  4538. }
  4539. });
  4540. document
  4541. .querySelector("#menu-clear-rulers")
  4542. .addEventListener("click", (e) => {
  4543. rulers = [];
  4544. drawRulers();
  4545. });
  4546. clearEntityOptions();
  4547. clearViewOptions();
  4548. clearAttribution();
  4549. // we do this last because configuring settings can cause things
  4550. // to happen (e.g. auto-fit)
  4551. prepareSettings(getUserSettings());
  4552. // now that we have this loaded, we can set it
  4553. unitSelector.dataset.oldUnit = defaultUnits.length[config.units];
  4554. document.querySelector("#options-height-unit").value =
  4555. defaultUnits.length[config.units];
  4556. // ...and then update the world height by setting off an input event
  4557. document
  4558. .querySelector("#options-height-unit")
  4559. .dispatchEvent(new Event("input", {}));
  4560. if (param === null) {
  4561. scenes["Empty"]();
  4562. } else {
  4563. try {
  4564. const data = JSON.parse(b64DecodeUnicode(param), math.reviver);
  4565. if (data.entities === undefined) {
  4566. return;
  4567. }
  4568. if (data.world === undefined) {
  4569. return;
  4570. }
  4571. importScene(data);
  4572. } catch (err) {
  4573. console.error(err);
  4574. scenes["Empty"]();
  4575. // probably wasn't valid data
  4576. }
  4577. }
  4578. updateWorldHeight();
  4579. // Webkit doesn't draw resized SVGs correctly. It will always draw them at their intrinsic size, I think
  4580. // This checks for that.
  4581. webkitBugTest.onload = () => {
  4582. testCtx.canvas.width = 500;
  4583. testCtx.canvas.height = 500;
  4584. testCtx.clearRect(0, 0, 500, 500);
  4585. testCtx.drawImage(webkitBugTest, 0, 0, 500, 500);
  4586. webkitCanvasBug = testCtx.getImageData(250, 250, 1, 1).data[3] == 0;
  4587. if (webkitCanvasBug) {
  4588. toast(
  4589. "Heads up: Safari can't select through gaps or take screenshots (check the console for info!)"
  4590. );
  4591. console.log(
  4592. "Webkit messes up the process of drawing an SVG image to a canvas. This is important for both selecting things (it lets you click through a gap and hit something else) and for taking screenshots (since it needs to render them to a canvas). Sorry :("
  4593. );
  4594. }
  4595. };
  4596. updateFilter();
  4597. });
  4598. let searchText = "";
  4599. function doSearch(value) {
  4600. searchText = value;
  4601. updateFilter();
  4602. }
  4603. function customEntityFromFile(file, x = 0.5, y = 0.5) {
  4604. file.arrayBuffer().then((buf) => {
  4605. arr = new Uint8Array(buf);
  4606. blob = new Blob([arr], { type: file.type });
  4607. url = window.URL.createObjectURL(blob);
  4608. makeCustomEntity(url, x, y);
  4609. });
  4610. }
  4611. function makeCustomEntity(url, x = 0.5, y = 0.5) {
  4612. const maker = createEntityMaker(
  4613. {
  4614. name: "Custom Entity",
  4615. },
  4616. {
  4617. custom: {
  4618. attributes: {
  4619. height: {
  4620. name: "Height",
  4621. power: 1,
  4622. type: "length",
  4623. base: math.unit(6, "feet"),
  4624. },
  4625. },
  4626. image: {
  4627. source: url,
  4628. },
  4629. name: "Image",
  4630. info: {},
  4631. rename: false,
  4632. },
  4633. },
  4634. []
  4635. );
  4636. const entity = maker.constructor();
  4637. entity.scale = config.height.toNumber("feet") / 20;
  4638. entity.ephemeral = true;
  4639. displayEntity(entity, "custom", x, y, true, true);
  4640. }
  4641. const filterDefs = {
  4642. author: {
  4643. id: "author",
  4644. name: "Authors",
  4645. extract: (maker) => (maker.authors ? maker.authors : []),
  4646. render: (author) => attributionData.people[author].name,
  4647. sort: (tag1, tag2) => tag1[1].localeCompare(tag2[1]),
  4648. },
  4649. owner: {
  4650. id: "owner",
  4651. name: "Owners",
  4652. extract: (maker) => (maker.owners ? maker.owners : []),
  4653. render: (owner) => attributionData.people[owner].name,
  4654. sort: (tag1, tag2) => tag1[1].localeCompare(tag2[1]),
  4655. },
  4656. species: {
  4657. id: "species",
  4658. name: "Species",
  4659. extract: (maker) =>
  4660. maker.info && maker.info.species
  4661. ? getSpeciesInfo(maker.info.species)
  4662. : [],
  4663. render: (species) => speciesData[species].name,
  4664. sort: (tag1, tag2) => tag1[1].localeCompare(tag2[1]),
  4665. },
  4666. tags: {
  4667. id: "tags",
  4668. name: "Tags",
  4669. extract: (maker) =>
  4670. maker.info && maker.info.tags ? maker.info.tags : [],
  4671. render: (tag) => tagDefs[tag],
  4672. sort: (tag1, tag2) => tag1[1].localeCompare(tag2[1]),
  4673. },
  4674. size: {
  4675. id: "size",
  4676. name: "Normal Size",
  4677. extract: (maker) =>
  4678. maker.sizes && maker.sizes.length > 0
  4679. ? Array.from(
  4680. maker.sizes.reduce((result, size) => {
  4681. if (result && !size.default) {
  4682. return result;
  4683. }
  4684. let meters = size.height.toNumber("meters");
  4685. if (meters < 1e-1) {
  4686. return ["micro"];
  4687. } else if (meters < 1e1) {
  4688. return ["moderate"];
  4689. } else {
  4690. return ["macro"];
  4691. }
  4692. }, null)
  4693. )
  4694. : [],
  4695. render: (tag) => {
  4696. return {
  4697. micro: "Micro",
  4698. moderate: "Moderate",
  4699. macro: "Macro",
  4700. }[tag];
  4701. },
  4702. sort: (tag1, tag2) => {
  4703. const order = {
  4704. micro: 0,
  4705. moderate: 1,
  4706. macro: 2,
  4707. };
  4708. return order[tag1[0]] - order[tag2[0]];
  4709. },
  4710. },
  4711. allSizes: {
  4712. id: "allSizes",
  4713. name: "Possible Size",
  4714. extract: (maker) =>
  4715. maker.sizes
  4716. ? Array.from(
  4717. maker.sizes.reduce((set, size) => {
  4718. const height = size.height;
  4719. let result = Object.entries(sizeCategories).reduce(
  4720. (result, [name, value]) => {
  4721. if (result) {
  4722. return result;
  4723. } else {
  4724. if (math.compare(height, value) <= 0) {
  4725. return name;
  4726. }
  4727. }
  4728. },
  4729. null
  4730. );
  4731. set.add(result ? result : "infinite");
  4732. return set;
  4733. }, new Set())
  4734. )
  4735. : [],
  4736. render: (tag) => tag[0].toUpperCase() + tag.slice(1),
  4737. sort: (tag1, tag2) => {
  4738. const order = [
  4739. "atomic",
  4740. "microscopic",
  4741. "tiny",
  4742. "small",
  4743. "moderate",
  4744. "large",
  4745. "macro",
  4746. "megamacro",
  4747. "planetary",
  4748. "stellar",
  4749. "galactic",
  4750. "universal",
  4751. "omniversal",
  4752. "infinite",
  4753. ];
  4754. return order.indexOf(tag1[0]) - order.indexOf(tag2[0]);
  4755. },
  4756. },
  4757. };
  4758. const filterStates = {};
  4759. const sizeCategories = {
  4760. atomic: math.unit(100, "angstroms"),
  4761. microscopic: math.unit(100, "micrometers"),
  4762. tiny: math.unit(100, "millimeters"),
  4763. small: math.unit(1, "meter"),
  4764. moderate: math.unit(3, "meters"),
  4765. large: math.unit(10, "meters"),
  4766. macro: math.unit(300, "meters"),
  4767. megamacro: math.unit(1000, "kilometers"),
  4768. planetary: math.unit(10, "earths"),
  4769. stellar: math.unit(10, "solarradii"),
  4770. galactic: math.unit(10, "galaxies"),
  4771. universal: math.unit(10, "universes"),
  4772. omniversal: math.unit(10, "multiverses"),
  4773. };
  4774. function prepareEntities() {
  4775. availableEntities["buildings"] = makeBuildings();
  4776. availableEntities["characters"] = makeCharacters();
  4777. availableEntities["clothing"] = makeClothing();
  4778. availableEntities["creatures"] = makeCreatures();
  4779. availableEntities["fiction"] = makeFiction();
  4780. availableEntities["food"] = makeFood();
  4781. availableEntities["furniture"] = makeFurniture();
  4782. availableEntities["landmarks"] = makeLandmarks();
  4783. availableEntities["naturals"] = makeNaturals();
  4784. availableEntities["objects"] = makeObjects();
  4785. availableEntities["plants"] = makePlants();
  4786. availableEntities["pokemon"] = makePokemon();
  4787. availableEntities["real-buildings"] = makeRealBuildings();
  4788. availableEntities["real-terrain"] = makeRealTerrains();
  4789. availableEntities["species"] = makeSpecies();
  4790. availableEntities["vehicles"] = makeVehicles();
  4791. availableEntities["species"].forEach((x) => {
  4792. if (x.name == "Human") {
  4793. availableEntities["food"].push(x);
  4794. }
  4795. });
  4796. availableEntities["characters"].sort((x, y) => {
  4797. return x.name.localeCompare(y.name);
  4798. });
  4799. availableEntities["species"].sort((x, y) => {
  4800. return x.name.localeCompare(y.name);
  4801. });
  4802. availableEntities["objects"].sort((x, y) => {
  4803. return x.name.localeCompare(y.name);
  4804. });
  4805. availableEntities["furniture"].sort((x, y) => {
  4806. return x.name.localeCompare(y.name);
  4807. });
  4808. const holder = document.querySelector("#spawners");
  4809. const filterMenu = document.querySelector("#filters-menu");
  4810. const categorySelect = document.createElement("select");
  4811. categorySelect.id = "category-picker";
  4812. holder.appendChild(categorySelect);
  4813. const filterSets = {};
  4814. Object.values(filterDefs).forEach((filter) => {
  4815. filterSets[filter.id] = new Set();
  4816. filterStates[filter.id] = false;
  4817. });
  4818. Object.entries(availableEntities).forEach(([category, entityList]) => {
  4819. const select = document.createElement("select");
  4820. select.id = "create-entity-" + category;
  4821. select.classList.add("entity-select");
  4822. for (let i = 0; i < entityList.length; i++) {
  4823. const entity = entityList[i];
  4824. const option = document.createElement("option");
  4825. option.value = i;
  4826. option.innerText = entity.name;
  4827. select.appendChild(option);
  4828. if (entity.nsfw) {
  4829. option.classList.add("nsfw");
  4830. }
  4831. Object.values(filterDefs).forEach((filter) => {
  4832. filter.extract(entity).forEach((result) => {
  4833. filterSets[filter.id].add(result);
  4834. });
  4835. });
  4836. availableEntitiesByName[entity.name] = entity;
  4837. }
  4838. select.addEventListener("change", (e) => {
  4839. if (
  4840. select.options[select.selectedIndex]?.classList.contains("nsfw")
  4841. ) {
  4842. select.classList.add("nsfw");
  4843. } else {
  4844. select.classList.remove("nsfw");
  4845. }
  4846. // preload the entity's first image
  4847. const entity = entityList[select.selectedIndex]?.constructor();
  4848. if (entity) {
  4849. let img = new Image();
  4850. img.src = entity.currentView.image.source;
  4851. }
  4852. });
  4853. const button = document.createElement("button");
  4854. button.id = "create-entity-" + category + "-button";
  4855. button.classList.add("entity-button");
  4856. button.innerHTML = '<i class="far fa-plus-square"></i>';
  4857. button.addEventListener("click", (e) => {
  4858. if (entityList[select.value] == null) return;
  4859. const newEntity = entityList[select.value].constructor();
  4860. let yOffset = 0;
  4861. if (config.lockYAxis) {
  4862. yOffset = getVerticalOffset();
  4863. } else {
  4864. // Snap to the ground if it's visible.
  4865. if (config.groundSnap && pos2pix({x: 0, y: 0}).y < canvasHeight + 50) {
  4866. yOffset = -config.y;
  4867. } else {
  4868. yOffset = config.height.toNumber("meters") / 2;
  4869. }
  4870. }
  4871. displayEntity(
  4872. newEntity,
  4873. newEntity.defaultView,
  4874. config.x,
  4875. config.y + yOffset,
  4876. true,
  4877. true
  4878. );
  4879. });
  4880. const categoryOption = document.createElement("option");
  4881. categoryOption.value = category;
  4882. categoryOption.innerText = category;
  4883. if (category == "characters") {
  4884. categoryOption.selected = true;
  4885. select.classList.add("category-visible");
  4886. button.classList.add("category-visible");
  4887. }
  4888. categorySelect.appendChild(categoryOption);
  4889. holder.appendChild(select);
  4890. holder.appendChild(button);
  4891. });
  4892. Object.values(filterDefs).forEach((filter) => {
  4893. const filterHolder = document.createElement("label");
  4894. filterHolder.setAttribute("for", "filter-toggle-" + filter.id);
  4895. filterHolder.classList.add("filter-holder");
  4896. const filterToggle = document.createElement("input");
  4897. filterToggle.type = "checkbox";
  4898. filterToggle.id = "filter-toggle-" + filter.id;
  4899. filterHolder.appendChild(filterToggle);
  4900. filterToggle.addEventListener("input", e => {
  4901. filterStates[filter.id] = filterToggle.checked
  4902. if (filterToggle.checked) {
  4903. filterHolder.classList.add("enabled");
  4904. } else {
  4905. filterHolder.classList.remove("enabled");
  4906. }
  4907. clearFilter();
  4908. updateFilter();
  4909. });
  4910. const filterLabel = document.createElement("div");
  4911. filterLabel.innerText = filter.name;
  4912. filterHolder.appendChild(filterLabel);
  4913. const filterNameSelect = document.createElement("select");
  4914. filterNameSelect.classList.add("filter-select");
  4915. filterNameSelect.id = "filter-" + filter.id;
  4916. filterHolder.appendChild(filterNameSelect);
  4917. filterMenu.appendChild(filterHolder);
  4918. Array.from(filterSets[filter.id])
  4919. .map((name) => [name, filter.render(name)])
  4920. .sort(filterDefs[filter.id].sort)
  4921. .forEach((name) => {
  4922. const option = document.createElement("option");
  4923. option.innerText = name[1];
  4924. option.value = name[0];
  4925. filterNameSelect.appendChild(option);
  4926. });
  4927. filterNameSelect.addEventListener("change", (e) => {
  4928. updateFilter();
  4929. });
  4930. });
  4931. const spawnButton = document.createElement("button");
  4932. spawnButton.id = "spawn-all"
  4933. spawnButton.addEventListener("click", e => {
  4934. spawnAll();
  4935. });
  4936. filterMenu.appendChild(spawnButton);
  4937. console.log(
  4938. "Loaded " + Object.keys(availableEntitiesByName).length + " entities"
  4939. );
  4940. categorySelect.addEventListener("input", (e) => {
  4941. const oldSelect = document.querySelector(
  4942. ".entity-select.category-visible"
  4943. );
  4944. oldSelect.classList.remove("category-visible");
  4945. const oldButton = document.querySelector(
  4946. ".entity-button.category-visible"
  4947. );
  4948. oldButton.classList.remove("category-visible");
  4949. const newSelect = document.querySelector(
  4950. "#create-entity-" + e.target.value
  4951. );
  4952. newSelect.classList.add("category-visible");
  4953. const newButton = document.querySelector(
  4954. "#create-entity-" + e.target.value + "-button"
  4955. );
  4956. newButton.classList.add("category-visible");
  4957. recomputeFilters();
  4958. updateFilter();
  4959. });
  4960. recomputeFilters();
  4961. ratioInfo = document.body.querySelector(".extra-info");
  4962. }
  4963. function spawnAll() {
  4964. const makers = Array.from(
  4965. document.querySelector(".entity-select.category-visible")
  4966. ).filter((element) => !element.classList.contains("filtered"));
  4967. const count = makers.length + 2;
  4968. let index = 1;
  4969. if (makers.length > 50) {
  4970. if (!confirm("Really spawn " + makers.length + " things at once?")) {
  4971. return;
  4972. }
  4973. }
  4974. const worldWidth =
  4975. (config.height.toNumber("meters") / canvasHeight) * canvasWidth;
  4976. const spawned = makers.map((element) => {
  4977. const category = document.querySelector("#category-picker").value;
  4978. const maker = availableEntities[category][element.value];
  4979. const entity = maker.constructor();
  4980. if (config.lockYAxis) {
  4981. yOffset = getVerticalOffset();
  4982. } else {
  4983. // Snap to the ground if it's visible.
  4984. if (config.groundSnap && pos2pix({x: 0, y: 0}).y < canvasHeight + 50) {
  4985. yOffset = -config.y;
  4986. } else {
  4987. yOffset = config.height.toNumber("meters") / 2;
  4988. }
  4989. }
  4990. displayEntity(
  4991. entity,
  4992. entity.view,
  4993. -worldWidth * 0.45 +
  4994. config.x +
  4995. (worldWidth * 0.9 * index) / (count - 1),
  4996. config.y + yOffset
  4997. );
  4998. index += 1;
  4999. return entityIndex - 1;
  5000. });
  5001. updateSizes(true);
  5002. if (config.autoFitAdd) {
  5003. let targets = {};
  5004. spawned.forEach((key) => {
  5005. targets[key] = entities[key];
  5006. });
  5007. fitEntities(targets);
  5008. }
  5009. }
  5010. // Only display authors and owners if they appear
  5011. // somewhere in the current entity list
  5012. function recomputeFilters() {
  5013. const category = document.querySelector("#category-picker").value;
  5014. const filterSets = {};
  5015. Object.values(filterDefs).forEach((filter) => {
  5016. filterSets[filter.id] = new Set();
  5017. });
  5018. availableEntities[category].forEach((entity) => {
  5019. Object.values(filterDefs).forEach((filter) => {
  5020. filter.extract(entity).forEach((result) => {
  5021. filterSets[filter.id].add(result);
  5022. });
  5023. });
  5024. });
  5025. Object.values(filterDefs).forEach((filter) => {
  5026. filterStates[filter.id] = false;
  5027. document.querySelector("#filter-toggle-" + filter.id).checked = false
  5028. document.querySelector("#filter-toggle-" + filter.id).dispatchEvent(new Event("click"))
  5029. // always show the "none" option
  5030. let found = filter.id == "none";
  5031. const filterSelect = document.querySelector("#filter-" + filter.id);
  5032. const filterSelectHolder = filterSelect.parentElement;
  5033. filterSelect.querySelectorAll("option").forEach((element) => {
  5034. if (
  5035. filterSets[filter.id].has(element.value) ||
  5036. filter.id == "none"
  5037. ) {
  5038. element.classList.remove("filtered");
  5039. element.disabled = false;
  5040. found = true;
  5041. } else {
  5042. element.classList.add("filtered");
  5043. element.disabled = true;
  5044. }
  5045. });
  5046. if (found) {
  5047. filterSelectHolder.style.display = "";
  5048. } else {
  5049. filterSelectHolder.style.display = "none";
  5050. }
  5051. });
  5052. }
  5053. function updateFilter() {
  5054. const category = document.querySelector("#category-picker").value;
  5055. const types = Object.values(filterDefs).filter(def => filterStates[def.id]).map(def => def.id)
  5056. const keys = {
  5057. }
  5058. types.forEach(type => {
  5059. const filterKeySelect = document.querySelector("#filter-" + type);
  5060. keys[type] = filterKeySelect.value;
  5061. })
  5062. clearFilter();
  5063. let current = document.querySelector(
  5064. ".entity-select.category-visible"
  5065. ).value;
  5066. let replace = current == "";
  5067. let first = null;
  5068. let count = 0;
  5069. const lowerSearchText = searchText !== "" ? searchText.toLowerCase() : null;
  5070. document
  5071. .querySelectorAll(".entity-select.category-visible > option")
  5072. .forEach((element) => {
  5073. let keep = true
  5074. types.forEach(type => {
  5075. if (
  5076. !(filterDefs[type]
  5077. .extract(availableEntities[category][element.value])
  5078. .indexOf(keys[type]) >= 0)
  5079. ) {
  5080. keep = false;
  5081. }
  5082. })
  5083. if (
  5084. searchText != "" &&
  5085. !availableEntities[category][element.value].name
  5086. .toLowerCase()
  5087. .includes(lowerSearchText)
  5088. ) {
  5089. keep = false;
  5090. }
  5091. if (!keep) {
  5092. element.classList.add("filtered");
  5093. element.disabled = true;
  5094. if (current == element.value) {
  5095. replace = true;
  5096. }
  5097. } else {
  5098. count += 1;
  5099. if (!first) {
  5100. first = element.value;
  5101. }
  5102. }
  5103. });
  5104. const button = document.querySelector("#spawn-all")
  5105. button.innerText = "Spawn " + count + " filtered " + (count == 1 ? "entity" : "entities") + ".";
  5106. if (replace) {
  5107. document.querySelector(".entity-select.category-visible").value = first;
  5108. document
  5109. .querySelector("#create-entity-" + category)
  5110. .dispatchEvent(new Event("change"));
  5111. }
  5112. }
  5113. function clearFilter() {
  5114. document
  5115. .querySelectorAll(".entity-select.category-visible > option")
  5116. .forEach((element) => {
  5117. element.classList.remove("filtered");
  5118. element.disabled = false;
  5119. });
  5120. }
  5121. function checkFitWorld() {
  5122. if (config.autoFit) {
  5123. fitWorld();
  5124. return true;
  5125. }
  5126. return false;
  5127. }
  5128. function fitWorld(manual = false, factor = 1.1) {
  5129. if (Object.keys(entities).length > 0) {
  5130. fitEntities(entities, factor);
  5131. }
  5132. }
  5133. function fitEntities(targetEntities, manual = false, factor = 1.1) {
  5134. let minX = Infinity;
  5135. let maxX = -Infinity;
  5136. let minY = Infinity;
  5137. let maxY = -Infinity;
  5138. let count = 0;
  5139. const worldWidth =
  5140. (config.height.toNumber("meters") / canvasHeight) * canvasWidth;
  5141. const worldHeight = config.height.toNumber("meters");
  5142. Object.entries(targetEntities).forEach(([key, entity]) => {
  5143. const view = entity.view;
  5144. let extra = entity.views[view].image.extra;
  5145. extra = extra === undefined ? 1 : extra;
  5146. const image = document.querySelector(
  5147. "#entity-" + key + " > .entity-image"
  5148. );
  5149. const x = parseFloat(
  5150. document.querySelector("#entity-" + key).dataset.x
  5151. );
  5152. let width = image.width;
  5153. let height = image.height;
  5154. // only really relevant if the images haven't loaded in yet
  5155. if (height == 0) {
  5156. height = 100;
  5157. }
  5158. if (width == 0) {
  5159. width = height;
  5160. }
  5161. const xBottom =
  5162. x -
  5163. (entity.views[view].height.toNumber("meters") * width) / height / 2;
  5164. const xTop =
  5165. x +
  5166. (entity.views[view].height.toNumber("meters") * width) / height / 2;
  5167. const y = parseFloat(
  5168. document.querySelector("#entity-" + key).dataset.y
  5169. );
  5170. const yBottom = y;
  5171. const yTop = entity.views[view].height.toNumber("meters") + yBottom;
  5172. minX = Math.min(minX, xBottom);
  5173. maxX = Math.max(maxX, xTop);
  5174. minY = Math.min(minY, yBottom);
  5175. maxY = Math.max(maxY, yTop);
  5176. count += 1;
  5177. });
  5178. if (config.lockYAxis) {
  5179. minY = 0;
  5180. }
  5181. let ySize = (maxY - minY) * factor;
  5182. let xSize = (maxX - minX) * factor;
  5183. if (xSize / ySize > worldWidth / worldHeight) {
  5184. ySize *= xSize / ySize / (worldWidth / worldHeight);
  5185. }
  5186. config.x = (maxX + minX) / 2;
  5187. config.y = minY;
  5188. height = math.unit(ySize, "meter");
  5189. setWorldHeight(config.height, math.multiply(height, factor));
  5190. }
  5191. function updateWorldHeight() {
  5192. const unit = document.querySelector("#options-height-unit").value;
  5193. const rawValue = document.querySelector("#options-height-value").value;
  5194. var value;
  5195. try {
  5196. value = math.evaluate(rawValue);
  5197. if (typeof value !== "number") {
  5198. try {
  5199. value = value.toNumber(unit);
  5200. } catch {
  5201. toast(
  5202. "Invalid input: " +
  5203. rawValue +
  5204. " can't be converted to " +
  5205. unit
  5206. );
  5207. }
  5208. }
  5209. } catch {
  5210. toast("Invalid input: could not parse " + rawValue);
  5211. return;
  5212. }
  5213. const newHeight = Math.max(1e-40, value);
  5214. const oldHeight = config.height;
  5215. setWorldHeight(oldHeight, math.unit(newHeight, unit), true);
  5216. }
  5217. function setWorldHeight(oldHeight, newHeight, keepUnit = false) {
  5218. worldSizeDirty = true;
  5219. config.height = newHeight.to(
  5220. document.querySelector("#options-height-unit").value
  5221. );
  5222. const unit = document.querySelector("#options-height-unit").value;
  5223. setNumericInput(
  5224. document.querySelector("#options-height-value"),
  5225. config.height.toNumber(unit)
  5226. );
  5227. Object.entries(entities).forEach(([key, entity]) => {
  5228. const element = document.querySelector("#entity-" + key);
  5229. let newPosition;
  5230. if (altHeld) {
  5231. newPosition = adjustAbs(
  5232. { x: element.dataset.x, y: element.dataset.y },
  5233. oldHeight,
  5234. config.height
  5235. );
  5236. } else {
  5237. newPosition = { x: element.dataset.x, y: element.dataset.y };
  5238. }
  5239. element.dataset.x = newPosition.x;
  5240. element.dataset.y = newPosition.y;
  5241. });
  5242. if (!keepUnit) {
  5243. pickUnit();
  5244. }
  5245. updateSizes();
  5246. }
  5247. function loadScene(name = "default") {
  5248. if (name === "") {
  5249. name = "default";
  5250. }
  5251. try {
  5252. const data = JSON.parse(
  5253. localStorage.getItem("macrovision-save-" + name), math.reviver
  5254. );
  5255. if (data === null) {
  5256. console.error("Couldn't load " + name);
  5257. return false;
  5258. }
  5259. importScene(data);
  5260. toast("Loaded " + name);
  5261. return true;
  5262. } catch (err) {
  5263. alert(
  5264. "Something went wrong while loading (maybe you didn't have anything saved. Check the F12 console for the error."
  5265. );
  5266. console.error(err);
  5267. return false;
  5268. }
  5269. }
  5270. function saveScene(name = "default") {
  5271. try {
  5272. const string = JSON.stringify(exportScene());
  5273. localStorage.setItem("macrovision-save-" + name, string);
  5274. toast("Saved as " + name);
  5275. } catch (err) {
  5276. alert(
  5277. "Something went wrong while saving (maybe I don't have localStorage permissions, or exporting failed). Check the F12 console for the error."
  5278. );
  5279. console.error(err);
  5280. }
  5281. }
  5282. function deleteScene(name = "default") {
  5283. if (confirm("Really delete the " + name + " scene?")) {
  5284. try {
  5285. localStorage.removeItem("macrovision-save-" + name);
  5286. toast("Deleted " + name);
  5287. } catch (err) {
  5288. console.error(err);
  5289. }
  5290. }
  5291. updateSaveInfo();
  5292. }
  5293. function exportScene() {
  5294. const results = {};
  5295. results.entities = [];
  5296. Object.entries(entities)
  5297. .filter(([key, entity]) => entity.ephemeral !== true)
  5298. .forEach(([key, entity]) => {
  5299. const element = document.querySelector("#entity-" + key);
  5300. const entityData = {
  5301. name: entity.identifier,
  5302. customName: entity.name,
  5303. scale: entity.scale,
  5304. rotation: entity.rotation,
  5305. flipped: entity.flipped,
  5306. view: entity.view,
  5307. form: entity.form,
  5308. x: element.dataset.x,
  5309. y: element.dataset.y,
  5310. priority: entity.priority,
  5311. brightness: entity.brightness,
  5312. };
  5313. entityData.views = {};
  5314. Object.entries(entity.views).forEach(([viewId, viewData]) => {
  5315. Object.entries(viewData.attributes).forEach(([attrId, attrData]) => {
  5316. if (attrData.custom) {
  5317. if (entityData.views[viewId] === undefined) {
  5318. entityData.views[viewId] = {};
  5319. }
  5320. entityData.views[viewId][attrId] = attrData;
  5321. }
  5322. });
  5323. });
  5324. results.entities.push(entityData);
  5325. });
  5326. const unit = document.querySelector("#options-height-unit").value;
  5327. results.world = {
  5328. height: config.height.toNumber(unit),
  5329. unit: unit,
  5330. x: config.x,
  5331. y: config.y,
  5332. };
  5333. results.version = migrationDefs.length;
  5334. return results;
  5335. }
  5336. // btoa doesn't like anything that isn't ASCII
  5337. // great
  5338. // thanks to https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings
  5339. // for providing an alternative
  5340. function b64EncodeUnicode(str) {
  5341. // first we use encodeURIComponent to get percent-encoded UTF-8,
  5342. // then we convert the percent encodings into raw bytes which
  5343. // can be fed into btoa.
  5344. return btoa(
  5345. encodeURIComponent(str).replace(
  5346. /%([0-9A-F]{2})/g,
  5347. function toSolidBytes(match, p1) {
  5348. return String.fromCharCode("0x" + p1);
  5349. }
  5350. )
  5351. );
  5352. }
  5353. function b64DecodeUnicode(str) {
  5354. // Going backwards: from bytestream, to percent-encoding, to original string.
  5355. return decodeURIComponent(
  5356. atob(str)
  5357. .split("")
  5358. .map(function (c) {
  5359. return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
  5360. })
  5361. .join("")
  5362. );
  5363. }
  5364. function linkScene() {
  5365. loc = new URL(window.location);
  5366. const link =
  5367. loc.protocol +
  5368. "//" +
  5369. loc.host +
  5370. loc.pathname +
  5371. "#" +
  5372. b64EncodeUnicode(JSON.stringify(exportScene()));
  5373. window.history.replaceState(null, "Macrovision", link);
  5374. try {
  5375. navigator.clipboard.writeText(link);
  5376. toast("Copied permalink to clipboard");
  5377. } catch {
  5378. toast("Couldn't copy permalink");
  5379. }
  5380. }
  5381. function copyScene() {
  5382. const results = exportScene();
  5383. navigator.clipboard.writeText(JSON.stringify(results));
  5384. }
  5385. function pasteScene() {
  5386. try {
  5387. navigator.clipboard
  5388. .readText()
  5389. .then((text) => {
  5390. const data = JSON.parse(text, math.reviver);
  5391. if (data.entities === undefined) {
  5392. return;
  5393. }
  5394. if (data.world === undefined) {
  5395. return;
  5396. }
  5397. importScene(data);
  5398. })
  5399. .catch((err) => { toast("Something went wrong when importing: " + err), console.error(err) });
  5400. } catch (err) {
  5401. console.error(err);
  5402. // probably wasn't valid data
  5403. }
  5404. }
  5405. function findEntity(name) {
  5406. return availableEntitiesByName[name];
  5407. }
  5408. const migrationDefs = [
  5409. /*
  5410. Migration: 0 -> 1
  5411. Adds x and y coordinates for the camera
  5412. */
  5413. (data) => {
  5414. data.world.x = 0;
  5415. data.world.y = 0;
  5416. },
  5417. /*
  5418. Migration: 1 -> 2
  5419. Adds priority and brightness to each entity
  5420. */
  5421. (data) => {
  5422. data.entities.forEach((entity) => {
  5423. entity.priority = 0;
  5424. entity.brightness = 1;
  5425. });
  5426. },
  5427. /*
  5428. Migration: 2 -> 3
  5429. Custom names are exported
  5430. */
  5431. (data) => {
  5432. data.entities.forEach((entity) => {
  5433. entity.customName = entity.name;
  5434. });
  5435. },
  5436. /*
  5437. Migration: 3 -> 4
  5438. Rotation is now stored
  5439. */
  5440. (data) => {
  5441. data.entities.forEach((entity) => {
  5442. entity.rotation = 0;
  5443. });
  5444. },
  5445. /*
  5446. Migration: 4 -> 5
  5447. Flipping is now stored
  5448. */
  5449. (data) => {
  5450. data.entities.forEach((entity) => {
  5451. entity.flipped = false;
  5452. });
  5453. },
  5454. /*
  5455. Migration: 5 -> 6
  5456. Entities can now have custom attributes
  5457. */
  5458. (data) => {
  5459. data.entities.forEach((entity) => {
  5460. entity.views = {};
  5461. });
  5462. }
  5463. ];
  5464. function migrateScene(data) {
  5465. if (data.version === undefined) {
  5466. alert(
  5467. "This save was created before save versions were tracked. The scene may import incorrectly."
  5468. );
  5469. console.trace();
  5470. data.version = 0;
  5471. } else if (data.version < migrationDefs.length) {
  5472. migrationDefs[data.version](data);
  5473. data.version += 1;
  5474. migrateScene(data);
  5475. }
  5476. }
  5477. function importScene(data) {
  5478. removeAllEntities();
  5479. migrateScene(data);
  5480. data.entities.forEach((entityInfo) => {
  5481. const entity = findEntity(entityInfo.name).constructor();
  5482. entity.name = entityInfo.customName;
  5483. entity.scale = entityInfo.scale;
  5484. entity.rotation = entityInfo.rotation;
  5485. entity.flipped = entityInfo.flipped;
  5486. entity.priority = entityInfo.priority;
  5487. entity.brightness = entityInfo.brightness;
  5488. entity.form = entityInfo.form;
  5489. Object.entries(entityInfo.views).forEach(([viewId, viewData]) => {
  5490. if (entityInfo.views[viewId] !== undefined) {
  5491. Object.entries(entityInfo.views[viewId]).forEach(([attrId, attrData]) => {
  5492. entity.views[viewId].attributes[attrId] = attrData;
  5493. });
  5494. }
  5495. });
  5496. Object.keys(entityInfo.views).forEach(key => defineAttributeGetters(entity.views[key]));
  5497. displayEntity(entity, entityInfo.view, entityInfo.x, entityInfo.y);
  5498. });
  5499. config.height = math.unit(data.world.height, data.world.unit);
  5500. config.x = data.world.x;
  5501. config.y = data.world.y;
  5502. const height = math
  5503. .unit(data.world.height, data.world.unit)
  5504. .toNumber(defaultUnits.length[config.units]);
  5505. document.querySelector("#options-height-value").value = height;
  5506. document.querySelector("#options-height-unit").dataset.oldUnit =
  5507. defaultUnits.length[config.units];
  5508. document.querySelector("#options-height-unit").value =
  5509. defaultUnits.length[config.units];
  5510. if (data.canvasWidth) {
  5511. doHorizReposition(data.canvasWidth / canvasWidth);
  5512. }
  5513. updateSizes();
  5514. }
  5515. function renderGround(ctx) {
  5516. if (config.groundKind !== "none") {
  5517. ctx.fillStyle = backgroundColors[config.groundKind];
  5518. ctx.fillRect(
  5519. 0,
  5520. pos2pix({ x: 0, y: 0 }).y,
  5521. canvasWidth + 100,
  5522. canvasHeight
  5523. );
  5524. }
  5525. }
  5526. function renderToCanvas() {
  5527. const ctx = document.querySelector("#display").getContext("2d");
  5528. let groundDrawn = false;
  5529. Object.entries(entities)
  5530. .sort((ent1, ent2) => {
  5531. z1 = document.querySelector("#entity-" + ent1[0]).style.zIndex;
  5532. z2 = document.querySelector("#entity-" + ent2[0]).style.zIndex;
  5533. return z1 - z2;
  5534. })
  5535. .forEach(([id, entity]) => {
  5536. if (entity.priority >= 0 && !groundDrawn) {
  5537. renderGround(ctx);
  5538. groundDrawn = true;
  5539. }
  5540. element = document.querySelector("#entity-" + id);
  5541. img = element.querySelector("img");
  5542. let x = parseFloat(element.dataset.x);
  5543. let y = parseFloat(element.dataset.y);
  5544. let coords = pos2pix({ x: x, y: y });
  5545. let offset = img.style.getPropertyValue("--offset");
  5546. offset = parseFloat(offset.substring(0, offset.length - 1));
  5547. let xSize = img.width;
  5548. let ySize = img.height;
  5549. x = coords.x;
  5550. y = coords.y + ySize / 2 + (ySize * offset) / 100;
  5551. const oldFilter = ctx.filter;
  5552. const brightness =
  5553. getComputedStyle(element).getPropertyValue("--brightness");
  5554. ctx.filter = `brightness(${brightness})`;
  5555. ctx.save();
  5556. ctx.resetTransform();
  5557. ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
  5558. ctx.translate(x, y);
  5559. ctx.rotate(entity.rotation);
  5560. ctx.scale(entity.flipped ? -1 : 1, 1);
  5561. ctx.drawImage(img, -xSize / 2, -ySize / 2, xSize, ySize);
  5562. ctx.restore();
  5563. ctx.filter = oldFilter;
  5564. });
  5565. if (!groundDrawn) {
  5566. renderGround(ctx);
  5567. }
  5568. ctx.save();
  5569. ctx.resetTransform();
  5570. ctx.drawImage(document.querySelector("#rulers"), 0, 0);
  5571. ctx.restore();
  5572. }
  5573. function exportCanvas(callback) {
  5574. /** @type {CanvasRenderingContext2D} */
  5575. const ctx = document.querySelector("#display").getContext("2d");
  5576. ctx.canvas.toBlob(callback);
  5577. }
  5578. function generateScreenshot(callback) {
  5579. /** @type {CanvasRenderingContext2D} */
  5580. const ctx = document.querySelector("#display").getContext("2d");
  5581. renderToCanvas();
  5582. ctx.resetTransform();
  5583. ctx.fillStyle = "#999";
  5584. ctx.font = "normal normal lighter 16pt coda";
  5585. ctx.fillText("macrovision.crux.sexy", 10, 25);
  5586. exportCanvas((blob) => {
  5587. callback(blob);
  5588. });
  5589. }
  5590. function copyScreenshot() {
  5591. if (window.ClipboardItem === undefined) {
  5592. alert(
  5593. "Sorry, this browser doesn't yet support writing images to the clipboard."
  5594. );
  5595. return;
  5596. }
  5597. generateScreenshot((blob) => {
  5598. navigator.clipboard
  5599. .write([
  5600. new ClipboardItem({
  5601. "image/png": blob,
  5602. }),
  5603. ])
  5604. .then((e) => toast("Copied to clipboard!"))
  5605. .catch((e) => {
  5606. console.error(e);
  5607. toast(
  5608. "Couldn't write to the clipboard. Make sure the screenshot completes before switching tabs. Also, currently busted in Safari :("
  5609. );
  5610. });
  5611. });
  5612. drawScales(false);
  5613. }
  5614. function saveScreenshot() {
  5615. generateScreenshot((blob) => {
  5616. const a = document.createElement("a");
  5617. a.href = URL.createObjectURL(blob);
  5618. a.setAttribute("download", "macrovision.png");
  5619. a.click();
  5620. });
  5621. drawScales(false);
  5622. }
  5623. function openScreenshot() {
  5624. generateScreenshot((blob) => {
  5625. const a = document.createElement("a");
  5626. a.href = URL.createObjectURL(blob);
  5627. a.setAttribute("target", "_blank");
  5628. a.click();
  5629. });
  5630. drawScales(false);
  5631. }
  5632. const rateLimits = {};
  5633. function toast(msg) {
  5634. let div = document.createElement("div");
  5635. div.innerHTML = msg;
  5636. div.classList.add("toast");
  5637. document.body.appendChild(div);
  5638. setTimeout(() => {
  5639. document.body.removeChild(div);
  5640. }, 5000);
  5641. }
  5642. function toastRateLimit(msg, key, delay) {
  5643. if (!rateLimits[key]) {
  5644. toast(msg);
  5645. rateLimits[key] = setTimeout(() => {
  5646. delete rateLimits[key];
  5647. }, delay);
  5648. }
  5649. }
  5650. let lastTime = undefined;
  5651. function pan(fromX, fromY, fromHeight, toX, toY, toHeight, duration) {
  5652. Object.keys(entities).forEach((key) => {
  5653. document.querySelector("#entity-" + key).classList.add("no-transition");
  5654. });
  5655. config.x = fromX;
  5656. config.y = fromY;
  5657. config.height = math.unit(fromHeight, "meters");
  5658. updateSizes();
  5659. lastTime = undefined;
  5660. requestAnimationFrame((timestamp) =>
  5661. panTo(
  5662. toX,
  5663. toY,
  5664. toHeight,
  5665. (toX - fromX) / duration,
  5666. (toY - fromY) / duration,
  5667. (toHeight - fromHeight) / duration,
  5668. timestamp,
  5669. duration
  5670. )
  5671. );
  5672. }
  5673. function panTo(
  5674. x,
  5675. y,
  5676. height,
  5677. xSpeed,
  5678. ySpeed,
  5679. heightSpeed,
  5680. timestamp,
  5681. remaining
  5682. ) {
  5683. if (lastTime === undefined) {
  5684. lastTime = timestamp;
  5685. }
  5686. dt = timestamp - lastTime;
  5687. remaining -= dt;
  5688. if (remaining < 0) {
  5689. dt += remaining;
  5690. }
  5691. let newX = config.x + xSpeed * dt;
  5692. let newY = config.y + ySpeed * dt;
  5693. let newHeight = config.height.toNumber("meters") + heightSpeed * dt;
  5694. if (remaining > 0) {
  5695. requestAnimationFrame((timestamp) =>
  5696. panTo(
  5697. x,
  5698. y,
  5699. height,
  5700. xSpeed,
  5701. ySpeed,
  5702. heightSpeed,
  5703. timestamp,
  5704. remaining
  5705. )
  5706. );
  5707. } else {
  5708. Object.keys(entities).forEach((key) => {
  5709. document
  5710. .querySelector("#entity-" + key)
  5711. .classList.remove("no-transition");
  5712. });
  5713. }
  5714. config.x = newX;
  5715. config.y = newY;
  5716. config.height = math.unit(newHeight, "meters");
  5717. updateSizes();
  5718. }
  5719. function getVerticalOffset() {
  5720. if (config.groundPos === "very-high") {
  5721. return (config.height.toNumber("meters") / 12) * 5;
  5722. } else if (config.groundPos === "high") {
  5723. return (config.height.toNumber("meters") / 12) * 4;
  5724. } else if (config.groundPos === "medium") {
  5725. return (config.height.toNumber("meters") / 12) * 3;
  5726. } else if (config.groundPos === "low") {
  5727. return (config.height.toNumber("meters") / 12) * 2;
  5728. } else if (config.groundPos === "very-low") {
  5729. return config.height.toNumber("meters") / 12;
  5730. } else {
  5731. return 0;
  5732. }
  5733. }
  5734. function moveGround(down) {
  5735. const index = groundPosChoices.indexOf(config.groundPos);
  5736. if (down) {
  5737. if (index < groundPosChoices.length - 1) {
  5738. config.groundPos = groundPosChoices[index + 1];
  5739. }
  5740. } else {
  5741. if (index > 0) {
  5742. config.groundPos = groundPosChoices[index - 1];
  5743. }
  5744. }
  5745. updateScrollButtons();
  5746. updateSizes();
  5747. }
  5748. function updateScrollButtons() {
  5749. const up = document.querySelector("#scroll-up");
  5750. const down = document.querySelector("#scroll-down");
  5751. up.disabled = false;
  5752. down.disabled = false;
  5753. document.querySelector("#setting-ground-pos").value = config.groundPos;
  5754. if (config.lockYAxis) {
  5755. const index = groundPosChoices.indexOf(config.groundPos);
  5756. if (index == 0) {
  5757. down.disabled = true;
  5758. }
  5759. if (index == groundPosChoices.length - 1) {
  5760. up.disabled = true;
  5761. }
  5762. }
  5763. }