let selected = null; let prevSelected = null; let selectedEntity = null; let prevSelectedEntity = null; let entityIndex = 0; let firsterror = true; let clicked = null; let movingInBounds = false; let dragging = false; let clickTimeout = null; let dragOffsetX = null; let dragOffsetY = null; let preloaded = new Set(); let panning = false; let panReady = true; let panOffsetX = null; let panOffsetY = null; let shiftHeld = false; let altHeld = false; let entityX; let canvasWidth; let canvasHeight; let dragScale = 1; let dragScaleHandle = null; let dragEntityScale = 1; let dragEntityScaleHandle = null; let scrollDirection = 0; let scrollHandle = null; let zoomDirection = 0; let zoomHandle = null; let sizeDirection = 0; let sizeHandle = null; let worldSizeDirty = false; let rulerMode = false; let rulers = []; let currentRuler = undefined; let webkitCanvasBug = false; const tagDefs = { anthro: "Anthro", feral: "Feral", taur: "Taur", naga: "Naga", goo: "Goo", }; math.createUnit({ ShoeSizeMensUS: { prefixes: "long", definition: "0.3333333333333333333 inches", offset: 22, }, ShoeSizeWomensUS: { prefixes: "long", definition: "0.3333333333333333333 inches", offset: 21, }, ShoeSizeEU: { prefixes: "long", definition: "0.666666666667 cm", offset: -2, }, ShoeSizeUK: { prefixes: "long", definition: "0.3333333333333333333 in", offset: 23, }, RingSizeNA: { prefixes: "long", definition: "0.0327 inches", offset: 13.883792 }, RingSizeISO: { prefixes: "long", definition: "0.318309886 mm", offset: 0 }, RingSizeIndia: { prefixes: "long", definition: "0.318309886 mm", offset: 40 } }); math.createUnit("humans", { definition: "5.75 feet", }); math.createUnit("story", { definition: "12 feet", prefixes: "long", }); math.createUnit("stories", { definition: "12 feet", prefixes: "long", }); math.createUnit("buses", { definition: "11.95 meters", prefixes: "long", }); math.createUnit("marathons", { definition: "26.2 miles", prefixes: "long", }); math.createUnit("timezones", { definition: "1037.54167 miles", prefixes: "long", aliases: ["timezone", "timezones"], }); math.createUnit("nauticalMiles", { definition: "6080 feet", prefixes: "long", aliases: ["nauticalMile", "nauticalMiles"], }); math.createUnit("fathoms", { definition: "6 feet", prefixes: "long", aliases: ["fathom", "fathoms"], }); math.createUnit("U", { definition: "1.75 inches", prefixes: "short", }); math.createUnit("earths", { definition: "12756km", prefixes: "long", aliases: ["earth", "earths", "Earth", "Earths"], }); math.createUnit("lightsecond", { definition: "299792458 meters", prefixes: "long", }); math.createUnit("lightseconds", { definition: "299792458 meters", prefixes: "long", }); math.createUnit("parsec", { definition: "3.086e16 meters", prefixes: "long", }); math.createUnit("parsecs", { definition: "3.086e16 meters", prefixes: "long", }); math.createUnit("lightyears", { definition: "9.461e15 meters", prefixes: "long", }); math.createUnit("AU", { definition: "149597870700 meters", }); math.createUnit("AUs", { definition: "149597870700 meters", }); math.createUnit("dalton", { definition: "1.66e-27 kg", prefixes: "long", }); math.createUnit("daltons", { definition: "1.66e-27 kg", prefixes: "long", }); math.createUnit("solarradii", { definition: "695990 km", prefixes: "long", }); math.createUnit("solarmasses", { definition: "2e30 kg", prefixes: "long", }); math.createUnit("galaxy", { definition: "105700 lightyears", prefixes: "long", }); math.createUnit("galaxies", { definition: "105700 lightyears", prefixes: "long", }); math.createUnit("universe", { definition: "93.016e9 lightyears", prefixes: "long", }); math.createUnit("universes", { definition: "93.016e9 lightyears", prefixes: "long", }); math.createUnit("multiverse", { definition: "1e30 lightyears", prefixes: "long", }); math.createUnit("multiverses", { definition: "1e30 lightyears", prefixes: "long", }); math.createUnit("pinHeads", { definition: "3.14159 mm^2", prefixes: "long", }); math.createUnit("dinnerPlates", { definition: "95 inches^2", prefixes: "long", }); math.createUnit("suburbanHouses", { definition: "2000 feet^2", prefixes: "long", }); math.createUnit("footballFields", { definition: "57600 feet^2", prefixes: "long", }); math.createUnit("blocks", { definition: "20000 m^2", prefixes: "long", aliases: ["block", "blocks"], }); math.createUnit("peopleInRural", { definition: "0.02 miles^2", }); math.createUnit("peopleInManhattan", { definition: "15 m^2", }); math.createUnit("peopleInLooseCrowd", { definition: "1 m^2", }); math.createUnit("peopleInCrowd", { definition: "0.3333333333333333 m^2", }); math.createUnit("peopleInDenseCrowd", { definition: "0.2 m^2", }); math.createUnit("people", { definition: "75 liters", prefixes: "long", }); math.createUnit("shippingContainers", { definition: "1169 ft^3", prefixes: "long", }); math.createUnit("olympicPools", { definition: "2500 m^3", prefixes: "long", }); math.createUnit("oceans", { definition: "700000000 km^3", prefixes: "long", }); math.createUnit("earthVolumes", { definition: "1.0867813e12 km^3", prefixes: "long", }); math.createUnit("universeVolumes", { definition: "4.2137775e+32 lightyears^3", prefixes: "long", }); math.createUnit("multiverseVolumes", { definition: "5.2359878e+89 lightyears^3", prefixes: "long", }); math.createUnit("peopleMass", { definition: "80 kg", prefixes: "long", }); math.createUnit("cars", { definition: "1250kg", prefixes: "long", }); math.createUnit("busMasses", { definition: "15000kg", prefixes: "long", }); math.createUnit("earthMass", { definition: "5.97e24 kg", prefixes: "long", }); math.createUnit("kcal", { definition: "4184 joules", prefixes: "long", }); math.createUnit("foodPounds", { definition: "867 kcal", prefixes: "long", }); math.createUnit("foodKilograms", { definition: "1909 kcal", prefixes: "long", }); math.createUnit("chickenNuggets", { definition: "42 kcal", prefixes: "long", }); math.createUnit("peopleEaten", { definition: "125000 kcal", prefixes: "long", }); math.createUnit("villagesEaten", { definition: "1000 peopleEaten", prefixes: "long", }); math.createUnit("townsEaten", { definition: "10000 peopleEaten", prefixes: "long", }); math.createUnit("citiesEaten", { definition: "100000 peopleEaten", prefixes: "long", }); math.createUnit("metrosEaten", { definition: "1000000 peopleEaten", prefixes: "long", }); math.createUnit("barn", { definition: "10e-28 m^2", prefixes: "long", }); math.createUnit("barns", { definition: "10e-28 m^2", prefixes: "long", }); math.createUnit("points", { definition: "0.013888888888888888888888888 inches", prefixes: "long", }); math.createUnit("beardSeconds", { definition: "10 nanometers", prefixes: "long", }); math.createUnit("smoots", { definition: "5.5833333 feet", prefixes: "long", }); math.createUnit("furlongs", { definition: "660 feet", prefixes: "long", }); math.createUnit("nanoacres", { definition: "1e-9 acres", prefixes: "long", }); math.createUnit("barnMegaparsecs", { definition: "1 barn megaparsec", prefixes: "long", }); math.createUnit("firkins", { definition: "90 lb", prefixes: "long", }); math.createUnit("donkeySeconds", { definition: "250 joules", prefixes: "long", }); math.createUnit("HU", { definition: "0.75 inches", aliases: ["HUs", "hammerUnits"], }); const defaultUnits = { length: { metric: "meters", customary: "feet", relative: "stories", quirky: "smoots", human: "humans", }, area: { metric: "meters^2", customary: "feet^2", relative: "footballFields", quirky: "nanoacres", human: "peopleInCrowd", }, volume: { metric: "liters", customary: "gallons", relative: "olympicPools", volume: "barnMegaparsecs", human: "people", }, mass: { metric: "kilograms", customary: "lbs", relative: "peopleMass", quirky: "firkins", human: "peopleMass", }, energy: { metric: "kJ", customary: "kcal", relative: "chickenNuggets", quirky: "donkeySeconds", human: "peopleEaten", }, }; const unitChoices = { length: { metric: [ "angstroms", "millimeters", "centimeters", "meters", "kilometers", ], customary: ["inches", "feet", "yards", "miles", "nauticalMiles"], relative: [ "RingSizeNA", "RingSizeISO", "RingSizeIndia", "ShoeSizeEU", "ShoeSizeUK", "ShoeSizeMensUS", "ShoeSizeWomensUS", "stories", "buses", "marathons", "timezones", "earths", "lightseconds", "solarradii", "AUs", "lightyears", "parsecs", "galaxies", "universes", "multiverses", ], quirky: [ "beardSeconds", "points", "smoots", "furlongs", "HUs", "U", "fathoms", ], human: ["humans"], }, area: { metric: ["cm^2", "meters^2", "kilometers^2"], customary: ["inches^2", "feet^2", "acres", "miles^2"], relative: [ "pinHeads", "dinnerPlates", "suburbanHouses", "footballFields", "blocks", ], quirky: ["barns", "nanoacres"], human: [ "peopleInRural", "peopleInManhattan", "peopleInLooseCrowd", "peopleInCrowd", "peopleInDenseCrowd", ], }, volume: { metric: ["milliliters", "liters", "m^3"], customary: ["in^3", "floz", "cups", "pints", "quarts", "gallons"], relative: [ "shippingContainers", "olympicPools", "oceans", "earthVolumes", "universeVolumes", "multiverseVolumes", ], quirky: ["barnMegaparsecs"], human: ["people"], }, mass: { metric: ["kilograms", "milligrams", "grams", "tonnes"], customary: ["lbs", "ounces", "tons"], relative: ["cars", "busMasses", "earthMass", "solarmasses"], quirky: ["firkins"], human: ["peopleMass"], }, energy: { metric: ["kJ", "foodKilograms"], customary: ["kcal", "foodPounds"], relative: ["chickenNuggets"], quirky: ["donkeySeconds"], human: [ "peopleEaten", "villagesEaten", "townsEaten", "citiesEaten", "metrosEaten", ], }, }; const config = { height: math.unit(1500, "meters"), x: 0, y: 0, minLineSize: 100, maxLineSize: 150, autoFit: false, drawYAxis: true, drawXAxis: false, autoMass: "off", autoFoodIntake: false, autoPreyCapacity: "off", }; const availableEntities = {}; const availableEntitiesByName = {}; const entities = {}; function constrainRel(coords) { const worldWidth = (config.height.toNumber("meters") / canvasHeight) * canvasWidth; const worldHeight = config.height.toNumber("meters"); if (altHeld) { return coords; } return { x: Math.min( Math.max(coords.x, -worldWidth / 2 + config.x), worldWidth / 2 + config.x ), y: Math.min(Math.max(coords.y, config.y), worldHeight + config.y), }; } // not using constrainRel anymore function snapPos(coords) { return { x: coords.x, y: !config.groundSnap || altHeld ? coords.y : Math.abs(coords.y) < config.height.toNumber("meters") / 20 ? 0 : coords.y, }; } function adjustAbs(coords, oldHeight, newHeight) { const ratio = math.divide(newHeight, oldHeight); const x = (coords.x - config.x) * ratio + config.x; const y = (coords.y - config.y) * ratio + config.y; return { x: x, y: y }; } function pos2pix(coords) { const worldWidth = (config.height.toNumber("meters") / canvasHeight) * canvasWidth; const worldHeight = config.height.toNumber("meters"); const x = ((coords.x - config.x) / worldWidth + 0.5) * (canvasWidth - 50) + 50; const y = (1 - (coords.y - config.y) / worldHeight) * (canvasHeight - 50) + 50; return { x: x, y: y }; } function pix2pos(coords) { const worldWidth = (config.height.toNumber("meters") / canvasHeight) * canvasWidth; const worldHeight = config.height.toNumber("meters"); const x = ((coords.x - 50) / (canvasWidth - 50) - 0.5) * worldWidth + config.x; const y = (1 - (coords.y - 50) / (canvasHeight - 50)) * worldHeight + config.y; return { x: x, y: y }; } function updateEntityElement(entity, element) { const position = pos2pix({ x: element.dataset.x, y: element.dataset.y }); const view = entity.view; const form = entity.form; element.style.left = position.x + "px"; element.style.top = position.y + "px"; element.style.setProperty("--xpos", position.x + "px"); element.style.setProperty( "--entity-height", "'" + entity.views[view].height .to(config.height.units[0].unit.name) .format({ precision: 2 }) + "'" ); const pixels = math.divide(entity.views[view].height, config.height) * (canvasHeight - 50); const extra = entity.views[view].image.extra; const bottom = entity.views[view].image.bottom; const bonus = (extra ? extra : 1) * (1 / (1 - (bottom ? bottom : 0))); let height = pixels * bonus; // working around a Firefox bug here if (height > 17895698) { height = 0; } element.style.setProperty("--height", height + "px"); element.style.setProperty("--extra", height - pixels + "px"); element.style.setProperty("--brightness", entity.brightness); if (entity.views[view].rename) element.querySelector(".entity-name").innerText = entity.name == "" ? "" : entity.views[view].name; else if ( entity.forms !== undefined && Object.keys(entity.forms).length > 0 && entity.forms[form].rename ) element.querySelector(".entity-name").innerText = entity.name == "" ? "" : entity.forms[form].name; else element.querySelector(".entity-name").innerText = entity.name; const bottomName = document.querySelector( "#bottom-name-" + element.dataset.key ); bottomName.style.left = position.x + entityX + "px"; bottomName.style.bottom = "0vh"; bottomName.innerText = entity.name; const topName = document.querySelector("#top-name-" + element.dataset.key); topName.style.left = position.x + entityX + "px"; topName.style.top = "20vh"; topName.innerText = entity.name; if ( entity.views[view].height.toNumber("meters") / 10 > config.height.toNumber("meters") ) { topName.classList.add("top-name-needed"); } else { topName.classList.remove("top-name-needed"); } updateInfo(); } let ratioInfo; function updateInfo() { let text = ""; if (config.showRatios) { if ( selectedEntity !== null && prevSelectedEntity !== null && selectedEntity !== prevSelectedEntity ) { let first = selectedEntity.currentView.height; let second = prevSelectedEntity.currentView.height; if (first.toNumber("meters") < second.toNumber("meters")) { text += selectedEntity.name + " is " + math.format(math.divide(second, first), { precision: 5 }) + " times smaller than " + prevSelectedEntity.name; } else { text += selectedEntity.name + " is " + math.format(math.divide(first, second), { precision: 5 }) + " times taller than " + prevSelectedEntity.name; } text += "\n"; let apparentHeight = math.multiply( math.divide(second, first), math.unit(6, "feet") ); if (config.units === "metric") { apparentHeight = apparentHeight.to("meters"); } text += prevSelectedEntity.name + " looks " + math.format(apparentHeight, { precision: 3 }) + " tall to " + selectedEntity.name + "\n"; if ( selectedEntity.currentView.weight && prevSelectedEntity.currentView.weight ) { const ratio = math.divide( selectedEntity.currentView.weight, prevSelectedEntity.currentView.weight ); if (ratio > 1) { text += selectedEntity.name + " is " + math.format(ratio, { precision: 2 }) + " times heavier than " + prevSelectedEntity.name + "\n"; } else { text += selectedEntity.name + " is " + math.format(1 / ratio, { precision: 2 }) + " times lighter than " + prevSelectedEntity.name + "\n"; } } const capacity = selectedEntity.currentView.preyCapacity ?? selectedEntity.currentView.capacity ?? selectedEntity.currentView.volume; if (capacity && prevSelectedEntity.currentView.weight) { const containCount = math.divide( capacity, math.divide( prevSelectedEntity.currentView.weight, math.unit("80kg/people") ) ); if (containCount > 0.1) { text += selectedEntity.name + " can fit " + math.format(containCount, { precision: 1 }) + " of " + prevSelectedEntity.name + " inside them" + "\n"; } } if ( selectedEntity.currentView.energyIntake && prevSelectedEntity.currentView.energyValue ) { const consumeCount = math.divide( selectedEntity.currentView.energyIntake, prevSelectedEntity.currentView.energyValue ); if (consumeCount > 0.1) { text += selectedEntity.name + " needs to eat " + math.format(consumeCount, { precision: 1 }) + " of " + prevSelectedEntity.name + " per day" + "\n"; } } // todo needs a nice system for formatting this Object.entries(selectedEntity.currentView.attributes).forEach( ([key, attr]) => { if (key !== "height") { if (attr.type === "length") { const ratio = math.divide( selectedEntity.currentView[key], prevSelectedEntity.currentView.height ); if (ratio > 1) { text += selectedEntity.name + "'s " + attr.name + " is " + math.format(ratio, { precision: 2 }) + " times longer than " + prevSelectedEntity.name + " is tall\n"; } else { text += selectedEntity.name + "'s " + attr.name + " is " + math.format(1 / ratio, { precision: 2 }) + " times shorter than " + prevSelectedEntity.name + " is tall\n"; } } } } ); } } if (config.showHorizon) { if (selectedEntity !== null) { const y = document.querySelector("#entity-" + selectedEntity.index) .dataset.y; const R = math.unit(1.2756e7, "meters"); const h = math.add( selectedEntity.currentView.height, math.unit(y, "meters") ); const first = math.multiply(2, math.multiply(R, h)); const second = math.multiply(h, h); const sightline = math .sqrt(math.add(first, second)) .to(config.height.units[0].unit.name); sightline.fixPrefix = false; text += selectedEntity.name + " could see for " + math.format(sightline, { precision: 3 }) + "\n"; } } if (config.showRatios && config.showHorizon) { if ( selectedEntity !== null && prevSelectedEntity !== null && selectedEntity !== prevSelectedEntity ) { const y1 = document.querySelector("#entity-" + selectedEntity.index) .dataset.y; const y2 = document.querySelector( "#entity-" + prevSelectedEntity.index ).dataset.y; const R = math.unit(1.2756e7, "meters"); const R2 = math.subtract( math.subtract(R, prevSelectedEntity.currentView.height), math.unit(y2, "meters") ); const h = math.add( selectedEntity.currentView.height, math.unit(y1, "meters") ); const first = math.pow(math.add(R, h), 2); const second = math.pow(R2, 2); const sightline = math .sqrt(math.subtract(first, second)) .to(config.height.units[0].unit.name); sightline.fixPrefix = false; text += selectedEntity.name + " could see " + prevSelectedEntity.name + " from " + math.format(sightline, { precision: 3 }) + " away\n"; } } ratioInfo.innerText = text; } function pickUnit() { if (!config.autoUnits) { return; } let type = null; let category = null; const heightSelect = document.querySelector("#options-height-unit"); currentUnit = heightSelect.value; Object.keys(unitChoices).forEach((unitType) => { Object.keys(unitChoices[unitType]).forEach((unitCategory) => { if (unitChoices[unitType][unitCategory].includes(currentUnit)) { type = unitType; category = unitCategory; } }); }); // This should only happen if the unit selector isn't set up yet. // It doesn't really matter what goes into it. if (type === null || category === null) { return "meters"; } const choices = unitChoices[type][category].map((unit) => { let value = config.height.toNumber(unit); if (value < 1) { value = 1 / value / value; } return [unit, value]; }); heightSelect.value = choices.sort((a, b) => { return a[1] - b[1]; })[0][0]; selectNewUnit(); } function updateSizes(dirtyOnly = false) { updateInfo(); if (config.lockYAxis) { config.y = -getVerticalOffset(); } drawScales(dirtyOnly); let ordered = Object.entries(entities); ordered.sort((e1, e2) => { if (e1[1].priority != e2[1].priority) { return e2[1].priority - e1[1].priority; } else { return ( e1[1].views[e1[1].view].height.value - e2[1].views[e2[1].view].height.value ); } }); let zIndex = ordered.length; ordered.forEach((entity) => { const element = document.querySelector("#entity-" + entity[0]); element.style.zIndex = zIndex; if (!dirtyOnly || entity[1].dirty) { updateEntityElement(entity[1], element, zIndex); entity[1].dirty = false; } zIndex -= 1; }); document.querySelector("#ground").style.top = pos2pix({ x: 0, y: 0 }).y + "px"; drawRulers(); } function cleanRulers() { rulers = rulers.filter(ruler => { if (!ruler.entityKey) { return true; } else { return entities[ruler.entityKey] !== undefined; } }); } function drawRulers() { cleanRulers(); const canvas = document.querySelector("#rulers"); /** @type {CanvasRenderingContext2D} */ const ctx = canvas.getContext("2d"); const deviceScale = window.devicePixelRatio; ctx.canvas.width = Math.floor(canvas.clientWidth * deviceScale); ctx.canvas.height = Math.floor(canvas.clientHeight * deviceScale); ctx.scale(deviceScale, deviceScale); rulers.concat(currentRuler ? [currentRuler] : []).forEach((rulerDef) => { let x0 = rulerDef.x0; let y0 = rulerDef.y0; let x1 = rulerDef.x1; let y1 = rulerDef.y1; if (rulerDef.entityKey !== null) { const entity = entities[rulerDef.entityKey]; const entityElement = document.querySelector( "#entity-" + rulerDef.entityKey ); x0 *= entity.scale; y0 *= entity.scale; x1 *= entity.scale; y1 *= entity.scale; x0 += parseFloat(entityElement.dataset.x); x1 += parseFloat(entityElement.dataset.x); y0 += parseFloat(entityElement.dataset.y); y1 += parseFloat(entityElement.dataset.y); } ctx.save(); ctx.beginPath(); const start = pos2pix({ x: x0, y: y0 }); const end = pos2pix({ x: x1, y: y1 }); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); ctx.lineWidth = 5; ctx.strokeStyle = "#f8f"; ctx.stroke(); const center = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }; ctx.fillStyle = "#eeeeee"; ctx.font = "normal 24pt coda"; ctx.translate(center.x, center.y); let angle = Math.atan2(end.y - start.y, end.x - start.x); if (angle < -Math.PI / 2) { angle += Math.PI; } if (angle > Math.PI / 2) { angle -= Math.PI; } ctx.rotate(angle); const offsetX = Math.cos(angle + Math.PI / 2); const offsetY = Math.sin(angle + Math.PI / 2); const distance = Math.sqrt(Math.pow(y1 - y0, 2) + Math.pow(x1 - x0, 2)); const distanceInUnits = math .unit(distance, "meters") .to(document.querySelector("#options-height-unit").value); const textSize = ctx.measureText( distanceInUnits.format({ precision: 3 }) ); ctx.fillText( distanceInUnits.format({ precision: 3 }), -offsetX * 10 - textSize.width / 2, -offsetY * 10 ); ctx.restore(); }); } function drawScales(ifDirty = false) { const canvas = document.querySelector("#display"); /** @type {CanvasRenderingContext2D} */ const ctx = canvas.getContext("2d"); const deviceScale = window.devicePixelRatio; ctx.canvas.width = Math.floor(canvas.clientWidth * deviceScale); ctx.canvas.height = Math.floor(canvas.clientHeight * deviceScale); ctx.scale(deviceScale, deviceScale); ctx.beginPath(); ctx.rect( 0, 0, ctx.canvas.width / deviceScale, ctx.canvas.height / deviceScale ); switch (config.background) { case "black": ctx.fillStyle = "#000"; break; case "dark": ctx.fillStyle = "#111"; break; case "medium": ctx.fillStyle = "#333"; break; case "light": ctx.fillStyle = "#555"; break; } ctx.fill(); if (config.drawYAxis || config.drawAltitudes !== "none") { drawVerticalScale(ifDirty); } if (config.drawXAxis) { drawHorizontalScale(ifDirty); } } function drawVerticalScale(ifDirty = false) { if (ifDirty && !worldSizeDirty) return; function drawTicks( /** @type {CanvasRenderingContext2D} */ ctx, pixelsPer, heightPer ) { let total = heightPer.clone(); total.value = config.y; let y = ctx.canvas.clientHeight - 50; let offset = total.toNumber("meters") % heightPer.toNumber("meters"); y += (offset / heightPer.toNumber("meters")) * pixelsPer; total = math.subtract(total, math.unit(offset, "meters")); for (; y >= 50; y -= pixelsPer) { drawTick(ctx, 50, y, total.format({ precision: 3 })); total = math.add(total, heightPer); } } function drawTick( /** @type {CanvasRenderingContext2D} */ ctx, x, y, label, flipped = false ) { const oldStroke = ctx.strokeStyle; const oldFill = ctx.fillStyle; x = Math.round(x); y = Math.round(y); ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + 20, y); ctx.strokeStyle = "#000000"; ctx.stroke(); ctx.beginPath(); ctx.moveTo(x + 20, y); ctx.lineTo(ctx.canvas.clientWidth - 70, y); if (flipped) { ctx.strokeStyle = "#666666"; } else { ctx.strokeStyle = "#aaaaaa"; } ctx.stroke(); ctx.beginPath(); ctx.moveTo(ctx.canvas.clientWidth - 70, y); ctx.lineTo(ctx.canvas.clientWidth - 50, y); ctx.strokeStyle = "#000000"; ctx.stroke(); const oldFont = ctx.font; ctx.font = "normal 24pt coda"; ctx.fillStyle = "#dddddd"; ctx.beginPath(); if (flipped) { ctx.textAlign = "end"; ctx.fillText(label, ctx.canvas.clientWidth - 70, y + 35); } else { ctx.fillText(label, x + 20, y + 35); } ctx.textAlign = "start"; ctx.font = oldFont; ctx.strokeStyle = oldStroke; ctx.fillStyle = oldFill; } function drawAltitudeLine(ctx, height, label) { const pixelScale = (ctx.canvas.clientHeight - 100) / config.height.toNumber("meters"); const y = ctx.canvas.clientHeight - 50 - (height.toNumber("meters") - config.y) * pixelScale; const offsetY = y + getVerticalOffset() * pixelScale; if (offsetY < ctx.canvas.clientHeight - 100) { drawTick(ctx, 50, y, label, true); } } const canvas = document.querySelector("#display"); /** @type {CanvasRenderingContext2D} */ const ctx = canvas.getContext("2d"); const pixelScale = (ctx.canvas.clientHeight - 100) / config.height.toNumber(); let pixelsPer = pixelScale; heightPer = 1; if (pixelsPer < config.minLineSize) { const factor = math.ceil(config.minLineSize / pixelsPer); heightPer *= factor; pixelsPer *= factor; } if (pixelsPer > config.maxLineSize) { const factor = math.ceil(pixelsPer / config.maxLineSize); heightPer /= factor; pixelsPer /= factor; } if (heightPer == 0) { console.error( "The world size is invalid! Refusing to draw the scale..." ); return; } heightPer = math.unit( heightPer, document.querySelector("#options-height-unit").value ); ctx.beginPath(); ctx.moveTo(50, 50); ctx.lineTo(50, ctx.canvas.clientHeight - 50); ctx.stroke(); ctx.beginPath(); ctx.moveTo(ctx.canvas.clientWidth - 50, 50); ctx.lineTo(ctx.canvas.clientWidth - 50, ctx.canvas.clientHeight - 50); ctx.stroke(); if (config.drawYAxis) { drawTicks(ctx, pixelsPer, heightPer); } if (config.drawAltitudes == "atmosphere" || config.drawAltitudes == "all") { drawAltitudeLine(ctx, math.unit(8, "km"), "Troposphere"); drawAltitudeLine(ctx, math.unit(17.5, "km"), "Ozone Layer"); drawAltitudeLine(ctx, math.unit(50, "km"), "Stratosphere"); drawAltitudeLine(ctx, math.unit(85, "km"), "Mesosphere"); drawAltitudeLine(ctx, math.unit(675, "km"), "Thermosphere"); drawAltitudeLine(ctx, math.unit(10000, "km"), "Exosphere"); } if (config.drawAltitudes == "orbits" || config.drawAltitudes == "all") { drawAltitudeLine(ctx, math.unit(7, "miles"), "Cruising Altitude"); drawAltitudeLine( ctx, math.unit(100, "km"), "Edge of Space (Kármán line)" ); drawAltitudeLine(ctx, math.unit(211.3, "miles"), "Space Station"); drawAltitudeLine(ctx, math.unit(369.7, "miles"), "Hubble Telescope"); drawAltitudeLine(ctx, math.unit(1500, "km"), "Low Earth Orbit"); drawAltitudeLine(ctx, math.unit(20350, "km"), "GPS Satellites"); drawAltitudeLine(ctx, math.unit(35786, "km"), "Geosynchronous Orbit"); drawAltitudeLine(ctx, math.unit(238900, "miles"), "Lunar Orbit"); drawAltitudeLine(ctx, math.unit(57.9e6, "km"), "Orbit of Mercury"); drawAltitudeLine(ctx, math.unit(108.2e6, "km"), "Orbit of Venus"); drawAltitudeLine(ctx, math.unit(1, "AU"), "Orbit of Earth"); drawAltitudeLine(ctx, math.unit(227.9e6, "km"), "Orbit of Mars"); drawAltitudeLine(ctx, math.unit(778.6e6, "km"), "Orbit of Jupiter"); drawAltitudeLine(ctx, math.unit(1433.5e6, "km"), "Orbit of Saturn"); drawAltitudeLine(ctx, math.unit(2872.5e6, "km"), "Orbit of Uranus"); drawAltitudeLine(ctx, math.unit(4495.1e6, "km"), "Orbit of Neptune"); drawAltitudeLine(ctx, math.unit(5906.4e6, "km"), "Orbit of Pluto"); drawAltitudeLine(ctx, math.unit(2.7, "AU"), "Asteroid Belt"); drawAltitudeLine(ctx, math.unit(123, "AU"), "Heliopause"); drawAltitudeLine(ctx, math.unit(26e3, "lightyears"), "Orbit of Sol"); } if (config.drawAltitudes == "weather" || config.drawAltitudes == "all") { drawAltitudeLine(ctx, math.unit(1000, "meters"), "Low-level Clouds"); drawAltitudeLine(ctx, math.unit(3000, "meters"), "Mid-level Clouds"); drawAltitudeLine(ctx, math.unit(10000, "meters"), "High-level Clouds"); drawAltitudeLine( ctx, math.unit(20, "km"), "Polar Stratospheric Clouds" ); drawAltitudeLine(ctx, math.unit(80, "km"), "Noctilucent Clouds"); drawAltitudeLine(ctx, math.unit(100, "km"), "Aurora"); } if (config.drawAltitudes == "water" || config.drawAltitudes == "all") { drawAltitudeLine(ctx, math.unit(12100, "feet"), "Average Ocean Depth"); drawAltitudeLine(ctx, math.unit(8376, "meters"), "Milkwaukee Deep"); drawAltitudeLine(ctx, math.unit(10984, "meters"), "Challenger Deep"); drawAltitudeLine(ctx, math.unit(5550, "meters"), "Molloy Deep"); drawAltitudeLine(ctx, math.unit(7290, "meters"), "Sunda Deep"); drawAltitudeLine(ctx, math.unit(592, "meters"), "Crater Lake"); drawAltitudeLine(ctx, math.unit(7.5, "meters"), "Littoral Zone"); drawAltitudeLine(ctx, math.unit(140, "meters"), "Continental Shelf"); } if (config.drawAltitudes == "geology" || config.drawAltitudes == "all") { drawAltitudeLine(ctx, math.unit(35, "km"), "Crust"); drawAltitudeLine(ctx, math.unit(670, "km"), "Upper Mantle"); drawAltitudeLine(ctx, math.unit(2890, "km"), "Lower Mantle"); drawAltitudeLine(ctx, math.unit(5150, "km"), "Outer Core"); drawAltitudeLine(ctx, math.unit(6370, "km"), "Inner Core"); } if ( config.drawAltitudes == "thicknesses" || config.drawAltitudes == "all" ) { drawAltitudeLine(ctx, math.unit(0.335, "nm"), "Monolayer Graphene"); drawAltitudeLine(ctx, math.unit(3, "um"), "Spider Silk"); drawAltitudeLine(ctx, math.unit(0.07, "mm"), "Human Hair"); drawAltitudeLine(ctx, math.unit(0.1, "mm"), "Sheet of Paper"); drawAltitudeLine(ctx, math.unit(0.5, "mm"), "Yarn"); drawAltitudeLine(ctx, math.unit(0.0155, "inches"), "Thread"); drawAltitudeLine(ctx, math.unit(0.1, "um"), "Gold Leaf"); drawAltitudeLine(ctx, math.unit(35, "um"), "PCB Trace"); } if (config.drawAltitudes == "airspaces" || config.drawAltitudes == "all") { drawAltitudeLine(ctx, math.unit(18000, "feet"), "Class A"); drawAltitudeLine(ctx, math.unit(14500, "feet"), "Class E"); drawAltitudeLine(ctx, math.unit(10000, "feet"), "Class B"); drawAltitudeLine(ctx, math.unit(4000, "feet"), "Class C"); drawAltitudeLine(ctx, math.unit(2500, "feet"), "Class D"); } if (config.drawAltitudes == "races" || config.drawAltitudes == "all") { drawAltitudeLine(ctx, math.unit(100, "meters"), "100m Dash"); drawAltitudeLine(ctx, math.unit(26.2188 / 2, "miles"), "Half Marathon"); drawAltitudeLine(ctx, math.unit(26.2188, "miles"), "Marathon"); drawAltitudeLine(ctx, math.unit(161.734, "miles"), "Monaco Grand Prix"); drawAltitudeLine(ctx, math.unit(500, "miles"), "Daytona 500"); drawAltitudeLine(ctx, math.unit(2121.6, "miles"), "Tour de France"); } if ( config.drawAltitudes == "olympic-records" || config.drawAltitudes == "all" ) { drawAltitudeLine(ctx, math.unit(2.39, "meters"), "High Jump"); drawAltitudeLine(ctx, math.unit(6.03, "meters"), "Pole Vault"); drawAltitudeLine(ctx, math.unit(8.9, "meters"), "Long Jump"); drawAltitudeLine(ctx, math.unit(18.09, "meters"), "Triple Jump"); drawAltitudeLine(ctx, math.unit(23.3, "meters"), "Shot Put"); drawAltitudeLine(ctx, math.unit(72.3, "meters"), "Discus Throw"); drawAltitudeLine(ctx, math.unit(84.8, "meters"), "Hammer Throw"); drawAltitudeLine(ctx, math.unit(90.57, "meters"), "Javelin Throw"); } if (config.drawAltitudes == "d&d-sizes" || config.drawAltitudes == "all") { drawAltitudeLine(ctx, math.unit(0.375, "feet"), "Fine"); drawAltitudeLine(ctx, math.unit(0.75, "feet"), "Dimnutive"); drawAltitudeLine(ctx, math.unit(1.5, "feet"), "Tiny"); drawAltitudeLine(ctx, math.unit(3, "feet"), "Small"); drawAltitudeLine(ctx, math.unit(6, "feet"), "Medium"); drawAltitudeLine(ctx, math.unit(12, "feet"), "Large"); drawAltitudeLine(ctx, math.unit(24, "feet"), "Huge"); drawAltitudeLine(ctx, math.unit(48, "feet"), "Gargantuan"); drawAltitudeLine(ctx, math.unit(96, "feet"), "Colossal"); } } // this is a lot of copypizza... function drawHorizontalScale(ifDirty = false) { if (ifDirty && !worldSizeDirty) return; function drawTicks( /** @type {CanvasRenderingContext2D} */ ctx, pixelsPer, heightPer ) { let total = heightPer.clone(); total.value = math.unit(-config.x, "meters").toNumber(config.unit); // further adjust it to put the current position in the center total.value -= ((heightPer.toNumber("meters") / pixelsPer) * (canvasWidth + 50)) / 2; let x = ctx.canvas.clientWidth - 50; let offset = total.toNumber("meters") % heightPer.toNumber("meters"); x += (offset / heightPer.toNumber("meters")) * pixelsPer; total = math.subtract(total, math.unit(offset, "meters")); for (; x >= 50 - pixelsPer; x -= pixelsPer) { // negate it so that the left side is negative drawTick( ctx, x, 50, math.multiply(-1, total).format({ precision: 3 }) ); total = math.add(total, heightPer); } } function drawTick( /** @type {CanvasRenderingContext2D} */ ctx, x, y, label ) { ctx.save(); x = Math.round(x); y = Math.round(y); ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y + 20); ctx.strokeStyle = "#000000"; ctx.stroke(); ctx.beginPath(); ctx.moveTo(x, y + 20); ctx.lineTo(x, ctx.canvas.clientHeight - 70); ctx.strokeStyle = "#aaaaaa"; ctx.stroke(); ctx.beginPath(); ctx.moveTo(x, ctx.canvas.clientHeight - 70); ctx.lineTo(x, ctx.canvas.clientHeight - 50); ctx.strokeStyle = "#000000"; ctx.stroke(); const oldFont = ctx.font; ctx.font = "normal 24pt coda"; ctx.fillStyle = "#dddddd"; ctx.beginPath(); ctx.fillText(label, x + 35, y + 20); ctx.restore(); } const canvas = document.querySelector("#display"); /** @type {CanvasRenderingContext2D} */ const ctx = canvas.getContext("2d"); let pixelsPer = (ctx.canvas.clientHeight - 100) / config.height.toNumber(); heightPer = 1; if (pixelsPer < config.minLineSize * 2) { const factor = math.ceil((config.minLineSize * 2) / pixelsPer); heightPer *= factor; pixelsPer *= factor; } if (pixelsPer > config.maxLineSize * 2) { const factor = math.ceil(pixelsPer / 2 / config.maxLineSize); heightPer /= factor; pixelsPer /= factor; } if (heightPer == 0) { console.error( "The world size is invalid! Refusing to draw the scale..." ); return; } heightPer = math.unit( heightPer, document.querySelector("#options-height-unit").value ); ctx.beginPath(); ctx.moveTo(0, 50); ctx.lineTo(ctx.canvas.clientWidth, 50); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, ctx.canvas.clientHeight - 50); ctx.lineTo(ctx.canvas.clientWidth, ctx.canvas.clientHeight - 50); ctx.stroke(); drawTicks(ctx, pixelsPer, heightPer); } // Entities are generated as needed, and we make a copy // every time - the resulting objects get mutated, after all. // But we also want to be able to read some information without // calling the constructor -- e.g. making a list of authors and // owners. So, this function is used to generate that information. // It is invoked like makeEntity so that it can be dropped in easily, // but returns an object that lets you construct many copies of an entity, // rather than creating a new entity. function createEntityMaker(info, views, sizes, forms) { const maker = {}; maker.name = info.name; maker.info = info; maker.sizes = sizes; maker.constructor = () => makeEntity(info, views, sizes, forms); maker.authors = []; maker.owners = []; maker.nsfw = false; Object.values(views).forEach((view) => { const authors = authorsOf(view.image.source); if (authors) { authors.forEach((author) => { if (maker.authors.indexOf(author) == -1) { maker.authors.push(author); } }); } const owners = ownersOf(view.image.source); if (owners) { owners.forEach((owner) => { if (maker.owners.indexOf(owner) == -1) { maker.owners.push(owner); } }); } if (isNsfw(view.image.source)) { maker.nsfw = true; } }); return maker; } // This function serializes and parses its arguments to avoid sharing // references to a common object. This allows for the objects to be // safely mutated. function makeEntity(info, views, sizes, forms = {}) { const entityTemplate = { name: info.name, identifier: info.name, scale: 1, rotation: 0, info: JSON.parse(JSON.stringify(info)), views: JSON.parse(JSON.stringify(views), math.reviver), sizes: sizes === undefined ? [] : JSON.parse(JSON.stringify(sizes), math.reviver), forms: forms, init: function () { const entity = this; Object.entries(this.forms).forEach(([formKey, form]) => { if (form.default) { this.defaultForm = formKey; } }); Object.entries(this.views).forEach(([viewKey, view]) => { view.parent = this; if (this.defaultView === undefined) { this.defaultView = viewKey; this.view = viewKey; this.form = view.form; } if (view.default) { if (forms === {} || this.defaultForm === view.form) { this.defaultView = viewKey; this.view = viewKey; this.form = view.form; } } // to remember the units the user last picked // also handles default unit overrides view.units = {}; Object.entries(view.attributes).forEach(([key, val]) => { if (val.defaultUnit !== undefined) { view.units[key] = val.defaultUnit; } }); if ( config.autoMass !== "off" && view.attributes.weight === undefined ) { let base = undefined; switch (config.autoMass) { case "human": baseMass = math.unit(150, "lbs"); baseHeight = math.unit(5.917, "feet"); break; case "quadruped at shoulder": baseMass = math.unit(80, "lbs"); baseHeight = math.unit(30, "inches"); break; } const ratio = math.divide( view.attributes.height.base, baseHeight ); view.attributes.weight = { name: "Mass", power: 3, type: "mass", base: math.multiply(baseMass, Math.pow(ratio, 3)), }; } if ( config.autoFoodIntake && view.attributes.weight !== undefined && view.attributes.energyIntake === undefined ) { view.attributes.energyIntake = { name: "Food Intake", power: (3 * 3) / 4, type: "energy", base: math.unit( 2000 * Math.pow( view.attributes.weight.base.toNumber( "lbs" ) / 150, 3 / 4 ), "kcal" ), }; } if ( config.autoCaloricValue && view.attributes.weight !== undefined && view.attributes.energyWorth === undefined ) { view.attributes.energyValue = { name: "Caloric Value", power: 3, type: "energy", base: math.unit( 860 * view.attributes.weight.base.toNumber("lbs"), "kcal" ), }; } if ( config.autoPreyCapacity !== "off" && view.attributes.weight !== undefined && view.attributes.preyCapacity === undefined ) { view.attributes.preyCapacity = { name: "Prey Capacity", power: 3, type: "volume", base: math.unit( ((config.autoPreyCapacity == "same-size" ? 1 : 0.05) * view.attributes.weight.base.toNumber("lbs")) / 150, "people" ), }; } Object.entries(view.attributes).forEach(([key, val]) => { Object.defineProperty(view, key, { get: function () { return math.multiply( Math.pow( this.parent.scale, this.attributes[key].power ), this.attributes[key].base ); }, set: function (value) { const newScale = Math.pow( math.divide(value, this.attributes[key].base), 1 / this.attributes[key].power ); this.parent.scale = newScale; }, }); }); }); this.sizes.forEach((size) => { if (size.default === true) { if (Object.keys(forms).length > 0) { if (this.defaultForm !== size.form && !size.allForms) { return; } } this.views[this.defaultView].height = size.height; this.size = size; } }); if (this.size === undefined && this.sizes.length > 0) { this.views[this.defaultView].height = this.sizes[0].height; this.size = this.sizes[0]; console.warn("No default size set for " + info.name); } else if (this.sizes.length == 0) { this.sizes = [ { name: "Normal", height: this.views[this.defaultView].height, }, ]; this.size = this.sizes[0]; } this.desc = {}; Object.entries(this.info).forEach(([key, value]) => { Object.defineProperty(this.desc, key, { get: function () { let text = value.text; if (entity.views[entity.view].info) { if (entity.views[entity.view].info[key]) { text = combineInfo( text, entity.views[entity.view].info[key] ); } } if (entity.size.info) { if (entity.size.info[key]) { text = combineInfo(text, entity.size.info[key]); } } return { title: value.title, text: text }; }, }); }); Object.defineProperty(this, "currentView", { get: function () { return entity.views[entity.view]; }, }); this.formViews = {}; this.formSizes = {}; Object.entries(views).forEach(([key, value]) => { if (value.default) { this.formViews[value.form] = key; } }); Object.entries(views).forEach(([key, value]) => { if (this.formViews[value.form] === undefined) { this.formViews[value.form] = key; } }); this.sizes.forEach((size) => { if (size.default) { this.formSizes[size.form] = size; } }); this.sizes.forEach((size) => { if (this.formSizes[size.form] === undefined) { this.formSizes[size.form] = size; } }); Object.values(views).forEach((view) => { if (this.formSizes[view.form] === undefined) { this.formSizes[view.form] = { name: "Normal", height: view.attributes.height.base, default: true, form: view.form, }; } }); delete this.init; return this; }, }.init(); return entityTemplate; } function combineInfo(existing, next) { switch (next.mode) { case "replace": return next.text; case "prepend": return next.text + existing; case "append": return existing + next.text; } return existing; } function clickDown(target, x, y) { clicked = target; movingInBounds = false; const rect = target.getBoundingClientRect(); let entX = document.querySelector("#entities").getBoundingClientRect().x; let entY = document.querySelector("#entities").getBoundingClientRect().y; dragOffsetX = x - rect.left + entX; dragOffsetY = y - rect.top + entY; x = x - dragOffsetX; y = y - dragOffsetY; if (x >= 0 && x <= canvasWidth && y >= 0 && y <= canvasHeight) { movingInBounds = true; } clickTimeout = setTimeout(() => { dragging = true; }, 200); target.classList.add("no-transition"); } // could we make this actually detect the menu area? function hoveringInDeleteArea(e) { return e.clientY < document.querySelector("#menubar").clientHeight; } function clickUp(e) { if (e.which != 1) { return; } clearTimeout(clickTimeout); if (clicked) { clicked.classList.remove("no-transition"); if (dragging) { dragging = false; if (hoveringInDeleteArea(e)) { removeEntity(clicked); document .querySelector("#menubar") .classList.remove("hover-delete"); } } else { select(clicked); } clicked = null; } } function deselect(e) { if (rulerMode) { return; } if (e !== undefined && e.which != 1) { return; } if (selected) { selected.classList.remove("selected"); } if (prevSelected) { prevSelected.classList.remove("prevSelected"); } document.getElementById("options-selected-entity-none").selected = "selected"; document.getElementById("delete-entity").style.display = "none"; clearAttribution(); selected = null; clearViewList(); clearEntityOptions(); clearViewOptions(); document.querySelector("#delete-entity").disabled = true; document.querySelector("#grow").disabled = true; document.querySelector("#shrink").disabled = true; document.querySelector("#fit").disabled = true; } function select(target) { if (prevSelected !== null) { prevSelected.classList.remove("prevSelected"); } prevSelected = selected; prevSelectedEntity = selectedEntity; deselect(); selected = target; selectedEntity = entities[target.dataset.key]; updateInfo(); document.getElementById( "options-selected-entity-" + target.dataset.key ).selected = "selected"; document.getElementById("delete-entity").style.display = ""; if ( prevSelected !== null && config.showRatios && selected !== prevSelected ) { prevSelected.classList.add("prevSelected"); } selected.classList.add("selected"); displayAttribution(selectedEntity.views[selectedEntity.view].image.source); configFormList(selectedEntity, selectedEntity.form); configViewList(selectedEntity, selectedEntity.view); configEntityOptions(selectedEntity, selectedEntity.view); configViewOptions(selectedEntity, selectedEntity.view); document.querySelector("#delete-entity").disabled = false; document.querySelector("#grow").disabled = false; document.querySelector("#shrink").disabled = false; document.querySelector("#fit").disabled = false; } function configFormList(entity, selectedForm) { const label = document.querySelector("#options-label-form"); const list = document.querySelector("#entity-form"); list.innerHTML = ""; if (selectedForm === undefined) { label.style.display = "none"; list.style.display = "none"; return; } label.style.display = "block"; list.style.display = "block"; Object.keys(entity.forms).forEach((form) => { const option = document.createElement("option"); option.innerText = entity.forms[form].name; option.value = form; if (form === selectedForm) { option.selected = true; } list.appendChild(option); }); } function configViewList(entity, selectedView) { const list = document.querySelector("#entity-view"); list.innerHTML = ""; list.style.display = "block"; Object.keys(entity.views).forEach((view) => { if (Object.keys(entity.forms).length > 0) { if (entity.views[view].form !== entity.form) { return; } } const option = document.createElement("option"); option.innerText = entity.views[view].name; option.value = view; if (isNsfw(entity.views[view].image.source)) { option.classList.add("nsfw"); } if (view === selectedView) { option.selected = true; if (option.classList.contains("nsfw")) { list.classList.add("nsfw"); } else { list.classList.remove("nsfw"); } } list.appendChild(option); }); } function clearViewList() { const list = document.querySelector("#entity-view"); list.innerHTML = ""; list.style.display = "none"; } function updateWorldOptions(entity, view) { const heightInput = document.querySelector("#options-height-value"); const heightSelect = document.querySelector("#options-height-unit"); const converted = config.height.toNumber(heightSelect.value); setNumericInput(heightInput, converted); } function configEntityOptions(entity, view) { const holder = document.querySelector("#options-entity"); document.querySelector("#entity-category-header").style.display = "block"; document.querySelector("#entity-category").style.display = "block"; holder.innerHTML = ""; const scaleLabel = document.createElement("div"); scaleLabel.classList.add("options-label"); scaleLabel.innerText = "Scale"; const scaleRow = document.createElement("div"); scaleRow.classList.add("options-row"); const scaleInput = document.createElement("input"); scaleInput.classList.add("options-field-numeric"); scaleInput.id = "options-entity-scale"; scaleInput.addEventListener("change", (e) => { try { const newScale = e.target.value == 0 ? 1 : math.evaluate(e.target.value); if (typeof newScale !== "number") { toast("Invalid input: scale can't have any units!"); return; } entity.scale = newScale; } catch { toast("Invalid input: could not parse " + e.target.value); } entity.dirty = true; if (config.autoFit) { fitWorld(); } else { updateSizes(true); } updateEntityOptions(entity, entity.view); updateViewOptions(entity, entity.view); }); scaleInput.addEventListener("keydown", (e) => { e.stopPropagation(); }); setNumericInput(scaleInput, entity.scale); scaleRow.appendChild(scaleInput); holder.appendChild(scaleLabel); holder.appendChild(scaleRow); const nameLabel = document.createElement("div"); nameLabel.classList.add("options-label"); nameLabel.innerText = "Name"; const nameRow = document.createElement("div"); nameRow.classList.add("options-row"); const nameInput = document.createElement("input"); nameInput.classList.add("options-field-text"); nameInput.value = entity.name; nameInput.addEventListener("input", (e) => { entity.name = e.target.value; entity.dirty = true; updateSizes(true); }); nameInput.addEventListener("keydown", (e) => { e.stopPropagation(); }); nameRow.appendChild(nameInput); holder.appendChild(nameLabel); holder.appendChild(nameRow); configSizeList(entity); document.querySelector("#options-order-display").innerText = entity.priority; document.querySelector("#options-brightness-display").innerText = entity.brightness; document.querySelector("#options-ordering").style.display = "flex"; } function configSizeList(entity) { const defaultHolder = document.querySelector("#options-entity-defaults"); defaultHolder.innerHTML = ""; entity.sizes.forEach((defaultInfo) => { if (Object.keys(entity.forms).length > 0) { if (!defaultInfo.allForms && defaultInfo.form !== entity.form) { return; } } const button = document.createElement("button"); button.classList.add("options-button"); button.innerText = defaultInfo.name; button.addEventListener("click", (e) => { if (Object.keys(entity.forms).length > 0) { entity.views[entity.formViews[entity.form]].height = defaultInfo.height; } else { entity.views[entity.defaultView].height = defaultInfo.height; } entity.dirty = true; updateEntityOptions(entity, entity.view); updateViewOptions(entity, entity.view); if (!checkFitWorld()) { updateSizes(true); } if (config.autoFitSize) { let targets = {}; targets[selected.dataset.key] = entities[selected.dataset.key]; fitEntities(targets); } }); defaultHolder.appendChild(button); }); } function updateEntityOptions(entity, view) { const scaleInput = document.querySelector("#options-entity-scale"); setNumericInput(scaleInput, entity.scale); document.querySelector("#options-order-display").innerText = entity.priority; document.querySelector("#options-brightness-display").innerText = entity.brightness; } function clearEntityOptions() { document.querySelector("#entity-category-header").style.display = "none"; document.querySelector("#entity-category").style.display = "none"; /* const holder = document.querySelector("#options-entity"); holder.innerHTML = ""; document.querySelector("#options-entity-defaults").innerHTML = ""; document.querySelector("#options-ordering").style.display = "none"; document.querySelector("#options-ordering").style.display = "none";*/ } function configViewOptions(entity, view) { const holder = document.querySelector("#options-view"); document.querySelector("#view-category-header").style.display = "block"; document.querySelector("#view-category").style.display = "block"; holder.innerHTML = ""; Object.entries(entity.views[view].attributes).forEach(([key, val]) => { const label = document.createElement("div"); label.classList.add("options-label"); label.innerText = val.name; holder.appendChild(label); const row = document.createElement("div"); row.classList.add("options-row"); holder.appendChild(row); const input = document.createElement("input"); input.classList.add("options-field-numeric"); input.id = "options-view-" + key + "-input"; const select = document.createElement("select"); select.classList.add("options-field-unit"); select.id = "options-view-" + key + "-select"; Object.entries(unitChoices[val.type]).forEach(([group, entries]) => { const optGroup = document.createElement("optgroup"); optGroup.label = group; select.appendChild(optGroup); entries.forEach((entry) => { const option = document.createElement("option"); option.innerText = entry; if (entry == defaultUnits[val.type][config.units]) { option.selected = true; } select.appendChild(option); }); }); input.addEventListener("change", (e) => { const raw_value = input.value == 0 ? 1 : input.value; let value; try { value = math.evaluate(raw_value).toNumber(select.value); } catch { try { value = math.evaluate(input.value); if (typeof value !== "number") { toast( "Invalid input: " + value.format() + " can't convert to " + select.value ); value = undefined; } } catch { toast("Invalid input: could not parse: " + input.value); value = undefined; } } if (value === undefined) { return; } input.value = value; entity.views[view][key] = math.unit(value, select.value); entity.dirty = true; if (config.autoFit) { fitWorld(); } else { updateSizes(true); } updateEntityOptions(entity, view); updateViewOptions(entity, view, key); }); input.addEventListener("keydown", (e) => { e.stopPropagation(); }); if (entity.currentView.units[key]) { select.value = entity.currentView.units[key]; } else { entity.currentView.units[key] = select.value; } select.dataset.oldUnit = select.value; setNumericInput(input, entity.views[view][key].toNumber(select.value)); // TODO does this ever cause a change in the world? select.addEventListener("input", (e) => { const value = input.value == 0 ? 1 : input.value; const oldUnit = select.dataset.oldUnit; entity.views[entity.view][key] = math .unit(value, oldUnit) .to(select.value); entity.dirty = true; setNumericInput( input, entity.views[entity.view][key].toNumber(select.value) ); select.dataset.oldUnit = select.value; entity.views[view].units[key] = select.value; if (config.autoFit) { fitWorld(); } else { updateSizes(true); } updateEntityOptions(entity, view); updateViewOptions(entity, view, key); }); row.appendChild(input); row.appendChild(select); }); } function updateViewOptions(entity, view, changed) { Object.entries(entity.views[view].attributes).forEach(([key, val]) => { if (key != changed) { const input = document.querySelector( "#options-view-" + key + "-input" ); const select = document.querySelector( "#options-view-" + key + "-select" ); const currentUnit = select.value; const convertedAmount = entity.views[view][key].toNumber(currentUnit); setNumericInput(input, convertedAmount); } }); } function setNumericInput(input, value, round = 6) { if (typeof value == "string") { value = parseFloat(value); } input.value = value.toPrecision(round); } function getSortedEntities() { return Object.keys(entities).sort((a, b) => { const entA = entities[a]; const entB = entities[b]; const viewA = entA.view; const viewB = entB.view; const heightA = entA.views[viewA].height.to("meter").value; const heightB = entB.views[viewB].height.to("meter").value; return heightA - heightB; }); } function clearViewOptions() { document.querySelector("#view-category-header").style.display = "none"; document.querySelector("#view-category").style.display = "none"; } // this is a crime against humanity, and also stolen from // stack overflow // https://stackoverflow.com/questions/38487569/click-through-png-image-only-if-clicked-coordinate-is-transparent const testCanvas = document.createElement("canvas"); testCanvas.id = "test-canvas"; function rotate(point, angle) { return [ point[0] * Math.cos(angle) - point[1] * Math.sin(angle), point[0] * Math.sin(angle) + point[1] * Math.cos(angle), ]; } const testCtx = testCanvas.getContext("2d"); function testClick(event) { const target = event.target; if (webkitCanvasBug) { return clickDown(target.parentElement, event.clientX, event.clientY); } testCtx.save(); if (rulerMode) { return; } // Get click coordinates let w = target.width; let h = target.height; let ratioW = 1, ratioH = 1; // Limit the size of the canvas so that very large images don't cause problems) if (w > 1000) { ratioW = w / 1000; w /= ratioW; h /= ratioW; } if (h > 1000) { ratioH = h / 1000; w /= ratioH; h /= ratioH; } // todo remove some of this unused stuff const ratio = ratioW * ratioH; const entity = entities[target.parentElement.dataset.key]; const angle = entity.rotation; var x = event.clientX - target.getBoundingClientRect().x, y = event.clientY - target.getBoundingClientRect().y, alpha; [xTarget, yTarget] = [x, y]; [actualW, actualH] = [ target.getBoundingClientRect().width, target.getBoundingClientRect().height, ]; xTarget /= ratio; yTarget /= ratio; actualW /= ratio; actualH /= ratio; testCtx.canvas.width = actualW; testCtx.canvas.height = actualH; testCtx.save(); // dear future me: Sorry :( testCtx.resetTransform(); testCtx.translate(actualW / 2, actualH / 2); testCtx.rotate(angle); testCtx.translate(-actualW / 2, -actualH / 2); testCtx.drawImage(target, actualW / 2 - w / 2, actualH / 2 - h / 2, w, h); testCtx.fillStyle = "red"; testCtx.fillRect(actualW / 2, actualH / 2, 10, 10); testCtx.restore(); testCtx.fillStyle = "red"; alpha = testCtx.getImageData(xTarget, yTarget, 1, 1).data[3]; testCtx.fillRect(xTarget, yTarget, 3, 3); // If the pixel is transparent, // retrieve the element underneath and trigger its click event if (alpha === 0) { const oldDisplay = target.style.display; target.style.display = "none"; const newTarget = document.elementFromPoint( event.clientX, event.clientY ); newTarget.dispatchEvent( new MouseEvent(event.type, { clientX: event.clientX, clientY: event.clientY, }) ); target.style.display = oldDisplay; } else { clickDown(target.parentElement, event.clientX, event.clientY); } testCtx.restore(); } function arrangeEntities(order) { const worldWidth = (config.height.toNumber("meters") / canvasHeight) * canvasWidth; let sum = 0; order.forEach((key) => { const image = document.querySelector( "#entity-" + key + " > .entity-image" ); const meters = entities[key].views[entities[key].view].height.toNumber("meters"); let height = image.height; let width = image.width; if (height == 0) { height = 100; } if (width == 0) { width = height; } sum += (meters * width) / height; }); let x = config.x - sum / 2; order.forEach((key) => { const image = document.querySelector( "#entity-" + key + " > .entity-image" ); const meters = entities[key].views[entities[key].view].height.toNumber("meters"); let height = image.height; let width = image.width; if (height == 0) { height = 100; } if (width == 0) { width = height; } x += (meters * width) / height / 2; document.querySelector("#entity-" + key).dataset.x = x; document.querySelector("#entity-" + key).dataset.y = config.y; x += (meters * width) / height / 2; }); fitWorld(); updateSizes(); } function removeAllEntities() { Object.keys(entities).forEach((key) => { removeEntity(document.querySelector("#entity-" + key)); }); } function clearAttribution() { document.querySelector("#attribution-category-header").style.display = "none"; document.querySelector("#options-attribution").style.display = "none"; } function displayAttribution(file) { document.querySelector("#attribution-category-header").style.display = "block"; document.querySelector("#options-attribution").style.display = "inline"; const authors = authorsOfFull(file); const owners = ownersOfFull(file); const citations = citationsOf(file); const source = sourceOf(file); const authorHolder = document.querySelector("#options-attribution-authors"); const ownerHolder = document.querySelector("#options-attribution-owners"); const citationHolder = document.querySelector( "#options-attribution-citations" ); const sourceHolder = document.querySelector("#options-attribution-source"); if (authors === []) { const div = document.createElement("div"); div.innerText = "Unknown"; authorHolder.innerHTML = ""; authorHolder.appendChild(div); } else if (authors === undefined) { const div = document.createElement("div"); div.innerText = "Not yet entered"; authorHolder.innerHTML = ""; authorHolder.appendChild(div); } else { authorHolder.innerHTML = ""; const list = document.createElement("ul"); authorHolder.appendChild(list); authors.forEach((author) => { const authorEntry = document.createElement("li"); if (author.url) { const link = document.createElement("a"); link.href = author.url; link.innerText = author.name; link.rel = "noreferrer no opener"; link.target = "_blank"; authorEntry.appendChild(link); } else { const div = document.createElement("div"); div.innerText = author.name; authorEntry.appendChild(div); } list.appendChild(authorEntry); }); } if (owners === []) { const div = document.createElement("div"); div.innerText = "Unknown"; ownerHolder.innerHTML = ""; ownerHolder.appendChild(div); } else if (owners === undefined) { const div = document.createElement("div"); div.innerText = "Not yet entered"; ownerHolder.innerHTML = ""; ownerHolder.appendChild(div); } else { ownerHolder.innerHTML = ""; const list = document.createElement("ul"); ownerHolder.appendChild(list); owners.forEach((owner) => { const ownerEntry = document.createElement("li"); if (owner.url) { const link = document.createElement("a"); link.href = owner.url; link.innerText = owner.name; link.rel = "noreferrer no opener"; link.target = "_blank"; ownerEntry.appendChild(link); } else { const div = document.createElement("div"); div.innerText = owner.name; ownerEntry.appendChild(div); } list.appendChild(ownerEntry); }); } citationHolder.innerHTML = ""; if (citations === [] || citations === undefined) { } else { citationHolder.innerHTML = ""; const list = document.createElement("ul"); citationHolder.appendChild(list); citations.forEach((citation) => { const citationEntry = document.createElement("li"); const link = document.createElement("a"); link.style.display = "block"; link.href = citation; link.innerText = new URL(citation).host; link.rel = "noreferrer no opener"; link.target = "_blank"; citationEntry.appendChild(link); list.appendChild(citationEntry); }); } if (source === null) { const div = document.createElement("div"); div.innerText = "No link"; sourceHolder.innerHTML = ""; sourceHolder.appendChild(div); } else if (source === undefined) { const div = document.createElement("div"); div.innerText = "Not yet entered"; sourceHolder.innerHTML = ""; sourceHolder.appendChild(div); } else { sourceHolder.innerHTML = ""; const link = document.createElement("a"); link.style.display = "block"; link.href = source; link.innerText = new URL(source).host; link.rel = "noreferrer no opener"; link.target = "_blank"; sourceHolder.appendChild(link); } } function removeEntity(element) { if (selected == element) { deselect(); } if (clicked == element) { clicked = null; } const option = document.querySelector( "#options-selected-entity-" + element.dataset.key ); option.parentElement.removeChild(option); delete entities[element.dataset.key]; const bottomName = document.querySelector( "#bottom-name-" + element.dataset.key ); const topName = document.querySelector("#top-name-" + element.dataset.key); bottomName.parentElement.removeChild(bottomName); topName.parentElement.removeChild(topName); element.parentElement.removeChild(element); selectedEntity = null; prevSelectedEntity = null; updateInfo(); } function checkEntity(entity) { Object.values(entity.views).forEach((view) => { if (authorsOf(view.image.source) === undefined) { console.warn("No authors: " + view.image.source); } }); } function preloadViews(entity) { Object.values(entity.views).forEach((view) => { if (Object.keys(entity.forms).length > 0) { if (entity.form !== view.form) { return; } } if (!preloaded.has(view.image.source)) { let img = new Image(); img.src = view.image.source; preloaded.add(view.image.source); } }); } function displayEntity( entity, view, x, y, selectEntity = false, refresh = false ) { checkEntity(entity); // preload all of the entity's views preloadViews(entity); const box = document.createElement("div"); box.classList.add("entity-box"); const img = document.createElement("img"); img.classList.add("entity-image"); img.addEventListener("dragstart", (e) => { e.preventDefault(); }); const nameTag = document.createElement("div"); nameTag.classList.add("entity-name"); nameTag.innerText = entity.name; box.appendChild(img); box.appendChild(nameTag); const image = entity.views[view].image; img.src = image.source; if (image.bottom !== undefined) { img.style.setProperty("--offset", (-1 + image.bottom) * 100 + "%"); } else { img.style.setProperty("--offset", -1 * 100 + "%"); } img.style.setProperty( "--rotation", (entity.rotation * 180) / Math.PI + "deg" ); box.dataset.x = x; box.dataset.y = y; img.addEventListener("mousedown", (e) => { if (e.which == 1) { testClick(e); if (clicked) { e.stopPropagation(); } } }); img.addEventListener("touchstart", (e) => { const fakeEvent = { target: e.target, clientX: e.touches[0].clientX, clientY: e.touches[0].clientY, which: 1, }; testClick(fakeEvent); if (clicked) { e.stopPropagation(); } }); const heightBar = document.createElement("div"); heightBar.classList.add("height-bar"); box.appendChild(heightBar); box.id = "entity-" + entityIndex; box.dataset.key = entityIndex; entity.view = view; if (entity.priority === undefined) entity.priority = 0; if (entity.brightness === undefined) entity.brightness = 1; entities[entityIndex] = entity; entity.index = entityIndex; const world = document.querySelector("#entities"); world.appendChild(box); const bottomName = document.createElement("div"); bottomName.classList.add("bottom-name"); bottomName.id = "bottom-name-" + entityIndex; bottomName.innerText = entity.name; bottomName.addEventListener("click", () => select(box)); world.appendChild(bottomName); const topName = document.createElement("div"); topName.classList.add("top-name"); topName.id = "top-name-" + entityIndex; topName.innerText = entity.name; topName.addEventListener("click", () => select(box)); world.appendChild(topName); const entityOption = document.createElement("option"); entityOption.id = "options-selected-entity-" + entityIndex; entityOption.value = entityIndex; entityOption.innerText = entity.name; document .getElementById("options-selected-entity") .appendChild(entityOption); entityIndex += 1; if (config.autoFit) { fitWorld(); } if (selectEntity) select(box); entity.dirty = true; if (refresh && config.autoFitAdd) { let targets = {}; targets[entityIndex - 1] = entity; fitEntities(targets); } if (refresh) updateSizes(true); } window.onblur = function () { altHeld = false; shiftHeld = false; }; window.onfocus = function () { window.dispatchEvent(new Event("keydown")); }; // thanks to https://developers.google.com/web/fundamentals/native-hardware/fullscreen function toggleFullScreen() { var doc = window.document; var docEl = doc.documentElement; var requestFullScreen = docEl.requestFullscreen || docEl.mozRequestFullScreen || docEl.webkitRequestFullScreen || docEl.msRequestFullscreen; var cancelFullScreen = doc.exitFullscreen || doc.mozCancelFullScreen || doc.webkitExitFullscreen || doc.msExitFullscreen; if ( !doc.fullscreenElement && !doc.mozFullScreenElement && !doc.webkitFullscreenElement && !doc.msFullscreenElement ) { requestFullScreen.call(docEl); } else { cancelFullScreen.call(doc); } } function handleResize() { const oldCanvasWidth = canvasWidth; entityX = document.querySelector("#entities").getBoundingClientRect().x; canvasWidth = document.querySelector("#display").clientWidth - 100; canvasHeight = document.querySelector("#display").clientHeight - 50; const change = oldCanvasWidth / canvasWidth; updateSizes(); } function prepareSidebar() { const menubar = document.querySelector("#sidebar-menu"); [ { name: "Show/hide sidebar", id: "menu-toggle-sidebar", icon: "fas fa-chevron-circle-down", rotates: true, }, { name: "Fullscreen", id: "menu-fullscreen", icon: "fas fa-compress", }, { name: "Clear", id: "menu-clear", icon: "fas fa-file", }, { name: "Sort by height", id: "menu-order-height", icon: "fas fa-sort-numeric-up", }, { name: "Permalink", id: "menu-permalink", icon: "fas fa-link", }, { name: "Export to clipboard", id: "menu-export", icon: "fas fa-share", }, { name: "Import from clipboard", id: "menu-import", icon: "fas fa-share", classes: ["flipped"], }, { name: "Save Scene", id: "menu-save", icon: "fas fa-download", input: true, }, { name: "Load Scene", id: "menu-load", icon: "fas fa-upload", select: true, }, { name: "Delete Scene", id: "menu-delete", icon: "fas fa-trash", select: true, }, { name: "Load Autosave", id: "menu-load-autosave", icon: "fas fa-redo", }, { name: "Load Preset", id: "menu-preset", icon: "fas fa-play", select: true, }, { name: "Add Image", id: "menu-add-image", icon: "fas fa-camera", }, { name: "Clear Rulers", id: "menu-clear-rulers", icon: "fas fa-ruler", }, ].forEach((entry) => { const buttonHolder = document.createElement("div"); buttonHolder.classList.add("menu-button-holder"); const button = document.createElement("button"); button.id = entry.id; button.classList.add("menu-button"); const icon = document.createElement("i"); icon.classList.add(...entry.icon.split(" ")); if (entry.rotates) { icon.classList.add("rotate-backward", "transitions"); } if (entry.classes) { entry.classes.forEach((cls) => icon.classList.add(cls)); } const actionText = document.createElement("span"); actionText.innerText = entry.name; actionText.classList.add("menu-text"); const srText = document.createElement("span"); srText.classList.add("sr-only"); srText.innerText = entry.name; button.appendChild(icon); button.appendChild(srText); buttonHolder.appendChild(button); buttonHolder.appendChild(actionText); if (entry.input) { const input = document.createElement("input"); buttonHolder.appendChild(input); input.placeholder = "default"; input.addEventListener("keyup", (e) => { if (e.key === "Enter") { const name = document.querySelector("#menu-save ~ input").value; if (/\S/.test(name)) { saveScene(name); } updateSaveInfo(); e.preventDefault(); } }); } if (entry.select) { const select = document.createElement("select"); buttonHolder.appendChild(select); } menubar.appendChild(buttonHolder); }); } function checkBodyClass(cls) { return document.body.classList.contains(cls); } function toggleBodyClass(cls, setting) { if (setting) { document.body.classList.add(cls); } else { document.body.classList.remove(cls); } } const backgroundColors = { none: "#00000000", black: "#000", dark: "#111", medium: "#333", light: "#555", }; const settingsCategories = { background: "Background", controls: "Controls", info: "Info", visuals: "Visuals", }; const groundPosChoices = [ "very-high", "high", "medium", "low", "very-low", "bottom", ]; const settingsData = { "show-vertical-scale": { name: "Vertical Scale", desc: "Draw vertical scale marks", type: "toggle", default: true, get value() { return config.drawYAxis; }, set value(param) { config.drawYAxis = param; drawScales(false); }, }, "show-horizontal-scale": { name: "Horiziontal Scale", desc: "Draw horizontal scale marks", type: "toggle", default: false, get value() { return config.drawXAxis; }, set value(param) { config.drawXAxis = param; drawScales(false); }, }, "show-altitudes": { name: "Altitudes", desc: "Draw interesting altitudes", type: "select", default: "none", disabled: "none", options: [ "none", "all", "atmosphere", "orbits", "weather", "water", "geology", "thicknesses", "airspaces", "races", "olympic-records", "d&d-sizes", ], get value() { return config.drawAltitudes; }, set value(param) { config.drawAltitudes = param; drawScales(false); }, }, "lock-y-axis": { name: "Lock Y-Axis", desc: "Keep the camera at ground-level", type: "toggle", default: true, get value() { return config.lockYAxis; }, set value(param) { config.lockYAxis = param; updateScrollButtons(); if (param) { updateSizes(); } }, }, "ground-snap": { name: "Snap to Ground", desc: "Snap things to the ground", type: "toggle", default: true, get value() { return config.groundSnap; }, set value(param) { config.groundSnap = param; }, }, "axis-spacing": { name: "Axis Spacing", desc: "How frequent the axis lines are", type: "select", default: "standard", options: ["dense", "standard", "sparse"], get value() { return config.axisSpacing; }, set value(param) { config.axisSpacing = param; const factor = { dense: 0.5, standard: 1, sparse: 2, }[param]; config.minLineSize = factor * 100; config.maxLineSize = factor * 150; updateSizes(); }, }, "ground-type": { name: "Ground", desc: "What kind of ground to show, if any", type: "select", default: "black", disabled: "none", options: ["none", "black", "dark", "medium", "light"], get value() { return config.groundKind; }, set value(param) { config.groundKind = param; document .querySelector("#ground") .style.setProperty("--ground-color", backgroundColors[param]); }, }, "ground-pos": { name: "Ground Position", desc: "How high the ground is if the y-axis is locked", type: "select", default: "very-low", options: groundPosChoices, get value() { return config.groundPos; }, set value(param) { config.groundPos = param; updateScrollButtons(); updateSizes(); }, }, "background-brightness": { name: "Background Color", desc: "How bright the background is", type: "select", default: "medium", options: ["black", "dark", "medium", "light"], get value() { return config.background; }, set value(param) { config.background = param; drawScales(); }, }, "auto-scale": { name: "Auto-Size World", desc: "Constantly zoom to fit the largest entity", type: "toggle", default: false, get value() { return config.autoFit; }, set value(param) { config.autoFit = param; checkFitWorld(); }, }, "auto-units": { name: "Auto-Select Units", desc: "Automatically switch units when zooming in and out", type: "toggle", default: false, get value() { return config.autoUnits; }, set value(param) { config.autoUnits = param; }, }, "zoom-when-adding": { name: "Zoom On Add", desc: "Zoom to fit when you add a new entity", type: "toggle", default: false, get value() { return config.autoFitAdd; }, set value(param) { config.autoFitAdd = param; }, }, "zoom-when-sizing": { name: "Zoom On Size", desc: "Zoom to fit when you select an entity's size", type: "toggle", default: false, get value() { return config.autoFitSize; }, set value(param) { config.autoFitSize = param; }, }, "show-ratios": { name: "Show Ratios", desc: "Show the proportions between the current selection and the most recent selection.", type: "toggle", default: false, get value() { return config.showRatios; }, set value(param) { config.showRatios = param; updateInfo(); }, }, "show-horizon": { name: "Show Horizon", desc: "Show how far the horizon would be for the selected character", type: "toggle", default: false, get value() { return config.showHorizon; }, set value(param) { config.showHorizon = param; updateInfo(); }, }, "attach-rulers": { name: "Attach Rulers", desc: "Rulers will attach to the currently-selected entity, moving around with it.", type: "toggle", default: true, get value() { return config.rulersStick; }, set value(param) { config.rulersStick = param; }, }, units: { name: "Default Units", desc: "Which kind of unit to use by default", type: "select", default: "metric", options: ["metric", "customary", "relative", "quirky", "human"], get value() { return config.units; }, set value(param) { config.units = param; updateSizes(); }, }, names: { name: "Show Names", desc: "Display names over entities", type: "toggle", default: true, get value() { return checkBodyClass("toggle-entity-name"); }, set value(param) { toggleBodyClass("toggle-entity-name", param); }, }, "bottom-names": { name: "Bottom Names", desc: "Display names at the bottom", type: "toggle", default: false, get value() { return checkBodyClass("toggle-bottom-name"); }, set value(param) { toggleBodyClass("toggle-bottom-name", param); }, }, "top-names": { name: "Show Arrows", desc: "Point to entities that are much larger than the current view", type: "toggle", default: false, get value() { return checkBodyClass("toggle-top-name"); }, set value(param) { toggleBodyClass("toggle-top-name", param); }, }, "height-bars": { name: "Height Bars", desc: "Draw dashed lines to the top of each entity", type: "toggle", default: false, get value() { return checkBodyClass("toggle-height-bars"); }, set value(param) { toggleBodyClass("toggle-height-bars", param); }, }, "flag-nsfw": { name: "Flag NSFW", desc: "Highlight NSFW things in red", type: "toggle", default: false, get value() { return checkBodyClass("flag-nsfw"); }, set value(param) { toggleBodyClass("flag-nsfw", param); }, }, "glowing-entities": { name: "Glowing Edges", desc: "Makes all entities glow", type: "toggle", default: false, get value() { return checkBodyClass("toggle-entity-glow"); }, set value(param) { toggleBodyClass("toggle-entity-glow", param); }, }, "select-style": { name: "Selection Style", desc: "How to highlight selected entities (outlines are laggier", type: "select", default: "color", options: ["color", "outline"], get value() { if (checkBodyClass("highlight-color")) { return "color"; } else { return "outline"; } }, set value(param) { toggleBodyClass("highlight-color", param === "color"); toggleBodyClass("highlight-outline", param === "outline"); }, }, smoothing: { name: "Smoothing", desc: "Smooth out movements and size changes. Disable for better performance.", type: "toggle", default: true, get value() { return checkBodyClass("smoothing"); }, set value(param) { toggleBodyClass("smoothing", param); }, }, "auto-mass": { name: "Estimate Mass", desc: "Guess the mass of things that don't have one specified using the selected body type", type: "select", default: "off", disabled: "off", options: ["off", "human", "quadruped at shoulder"], get value() { return config.autoMass; }, set value(param) { config.autoMass = param; }, }, "auto-food-intake": { name: "Estimate Food Intake", desc: "Guess how much food creatures need, based on their mass -- 2000kcal per 150lbs", type: "toggle", default: false, get value() { return config.autoFoodIntake; }, set value(param) { config.autoFoodIntake = param; }, }, "auto-caloric-value": { name: "Estimate Caloric Value", desc: "Guess how much food a creature is worth -- 860kcal per pound", type: "toggle", default: false, get value() { return config.autoCaloricValue; }, set value(param) { config.autoCaloricValue = param; }, }, "auto-prey-capacity": { name: "Estimate Prey Capacity", desc: "Guess how much prey creatures can hold, based on their mass", type: "select", default: "off", disabled: "off", options: ["off", "realistic", "same-size"], get value() { return config.autoPreyCapacity; }, set value(param) { config.autoPreyCapacity = param; }, }, }; function prepareSettings(userSettings) { const menubar = document.querySelector("#settings-menu"); Object.entries(settingsData).forEach(([id, entry]) => { const holder = document.createElement("label"); holder.classList.add("settings-holder"); const input = document.createElement("input"); input.id = "setting-" + id; const vertical = document.createElement("div"); vertical.classList.add("settings-vertical"); const name = document.createElement("label"); name.innerText = entry.name; name.classList.add("settings-name"); name.setAttribute("for", input.id); const desc = document.createElement("label"); desc.innerText = entry.desc; desc.classList.add("settings-desc"); desc.setAttribute("for", input.id); if (entry.type == "toggle") { input.type = "checkbox"; input.checked = userSettings[id] === undefined ? entry.default : userSettings[id]; holder.setAttribute("for", input.id); vertical.appendChild(name); vertical.appendChild(desc); holder.appendChild(vertical); holder.appendChild(input); menubar.appendChild(holder); const update = () => { if (input.checked) { holder.classList.add("enabled"); holder.classList.remove("disabled"); } else { holder.classList.remove("enabled"); holder.classList.add("disabled"); } entry.value = input.checked; }; setTimeout(update); input.addEventListener("change", update); } else if (entry.type == "select") { // we don't use the input element we made! const select = document.createElement("select"); select.id = "setting-" + id; entry.options.forEach((choice) => { const option = document.createElement("option"); option.innerText = choice; select.appendChild(option); }); select.value = userSettings[id] === undefined ? entry.default : userSettings[id]; vertical.appendChild(name); vertical.appendChild(desc); holder.appendChild(vertical); holder.appendChild(select); menubar.appendChild(holder); const update = () => { entry.value = select.value; if ( entry.disabled !== undefined && entry.value !== entry.disabled ) { holder.classList.add("enabled"); holder.classList.remove("disabled"); } else { holder.classList.remove("enabled"); holder.classList.add("disabled"); } }; update(); select.addEventListener("change", update); } }); } function prepareMenu() { prepareSidebar(); updateSaveInfo(); if (checkHelpDate()) { document.querySelector("#open-help").classList.add("highlighted"); } } function updateSaveInfo() { const saves = getSaves(); const load = document.querySelector("#menu-load ~ select"); load.innerHTML = ""; saves.forEach((save) => { const option = document.createElement("option"); option.innerText = save; option.value = save; load.appendChild(option); }); const del = document.querySelector("#menu-delete ~ select"); del.innerHTML = ""; saves.forEach((save) => { const option = document.createElement("option"); option.innerText = save; option.value = save; del.appendChild(option); }); } function getSaves() { try { const results = []; Object.keys(localStorage).forEach((key) => { if (key.startsWith("macrovision-save-")) { results.push(key.replace("macrovision-save-", "")); } }); return results; } catch (err) { alert( "Something went wrong while loading (maybe you didn't have anything saved. Check the F12 console for the error." ); console.error(err); return false; } } function getUserSettings() { try { const settings = JSON.parse(localStorage.getItem("settings")); return settings === null ? {} : settings; } catch { return {}; } } function exportUserSettings() { const settings = {}; Object.entries(settingsData).forEach(([id, entry]) => { settings[id] = entry.value; }); return settings; } function setUserSettings(settings) { try { localStorage.setItem("settings", JSON.stringify(settings)); } catch { // :( } } const lastHelpChange = 1601955834693; function checkHelpDate() { // disabling this for now return false; try { const old = localStorage.getItem("help-viewed"); if (old === null || old < lastHelpChange) { return true; } return false; } catch { console.warn("Could not set the help-viewed date"); return false; } } function setHelpDate() { try { localStorage.setItem("help-viewed", Date.now()); } catch { console.warn("Could not set the help-viewed date"); } } function doYScroll() { const worldHeight = config.height.toNumber("meters"); config.y += (scrollDirection * worldHeight) / 180; updateSizes(); scrollDirection *= 1.05; } function doXScroll() { const worldWidth = (config.height.toNumber("meters") / canvasHeight) * canvasWidth; config.x += (scrollDirection * worldWidth) / 180; updateSizes(); scrollDirection *= 1.05; } function doZoom() { const oldHeight = config.height; setWorldHeight(oldHeight, math.multiply(oldHeight, 1 + zoomDirection / 10)); zoomDirection *= 1.05; } function doSize() { if (selected) { const entity = entities[selected.dataset.key]; const oldHeight = entity.views[entity.view].height; entity.views[entity.view].height = math.multiply( oldHeight, sizeDirection < 0 ? -1 / sizeDirection : sizeDirection ); entity.dirty = true; updateEntityOptions(entity, entity.view); updateViewOptions(entity, entity.view); updateSizes(true); sizeDirection *= 1.01; const ownHeight = entity.views[entity.view].height.toNumber("meters"); let extra = entity.views[entity.view].image.extra; extra = extra === undefined ? 1 : extra; const worldHeight = config.height.toNumber("meters"); if (ownHeight * extra > worldHeight) { setWorldHeight( config.height, math.multiply(entity.views[entity.view].height, extra) ); } else if (ownHeight * extra * 10 < worldHeight) { setWorldHeight( config.height, math.multiply(entity.views[entity.view].height, extra * 10) ); } } } function selectNewUnit() { const unitSelector = document.querySelector("#options-height-unit"); checkFitWorld(); const scaleInput = document.querySelector("#options-height-value"); const newVal = math .unit(scaleInput.value, unitSelector.dataset.oldUnit) .toNumber(unitSelector.value); setNumericInput(scaleInput, newVal); updateWorldHeight(); unitSelector.dataset.oldUnit = unitSelector.value; } // given a world position, return the position relative to the entity at normal scale function entityRelativePosition(pos, entityElement) { const entity = entities[entityElement.dataset.key]; const x = parseFloat(entityElement.dataset.x); const y = parseFloat(entityElement.dataset.y); pos.x -= x; pos.y -= y; pos.x /= entity.scale; pos.y /= entity.scale; return pos; } document.addEventListener("DOMContentLoaded", () => { prepareMenu(); prepareEntities(); document .querySelector("#copy-screenshot") .addEventListener("click", (e) => { copyScreenshot(); }); document .querySelector("#save-screenshot") .addEventListener("click", (e) => { saveScreenshot(); }); document .querySelector("#open-screenshot") .addEventListener("click", (e) => { openScreenshot(); }); document.querySelector("#toggle-menu").addEventListener("click", (e) => { const popoutMenu = document.querySelector("#sidebar-menu"); if (popoutMenu.classList.contains("visible")) { popoutMenu.classList.remove("visible"); } else { document .querySelectorAll(".popout-menu") .forEach((menu) => menu.classList.remove("visible")); const rect = e.target.getBoundingClientRect(); popoutMenu.classList.add("visible"); popoutMenu.style.left = rect.x + rect.width + 10 + "px"; popoutMenu.style.top = rect.y + rect.height + 10 + "px"; let menuWidth = popoutMenu.getBoundingClientRect().width; let screenWidth = window.innerWidth; if (menuWidth * 1.5 > screenWidth) { popoutMenu.style.left = 25 + "px"; } } e.stopPropagation(); }); document.querySelector("#sidebar-menu").addEventListener("click", (e) => { e.stopPropagation(); }); document.querySelector("#sidebar-menu").addEventListener("touchstart", (e) => { e.stopPropagation(); }); document.addEventListener("click", (e) => { document.querySelector("#sidebar-menu").classList.remove("visible"); }); document.addEventListener("touchstart", (e) => { document.querySelector("#sidebar-menu").classList.remove("visible"); }); document .querySelector("#toggle-settings") .addEventListener("click", (e) => { const popoutMenu = document.querySelector("#settings-menu"); if (popoutMenu.classList.contains("visible")) { popoutMenu.classList.remove("visible"); } else { document .querySelectorAll(".popout-menu") .forEach((menu) => menu.classList.remove("visible")); const rect = e.target.getBoundingClientRect(); popoutMenu.classList.add("visible"); popoutMenu.style.left = rect.x + rect.width + 10 + "px"; popoutMenu.style.top = rect.y + rect.height + 10 + "px"; let menuWidth = popoutMenu.getBoundingClientRect().width; let screenWidth = window.innerWidth; if (menuWidth * 1.5 > screenWidth) { popoutMenu.style.left = 25 + "px"; } } e.stopPropagation(); }); document.querySelector("#settings-menu").addEventListener("click", (e) => { e.stopPropagation(); }); document.querySelector("#settings-menu").addEventListener("touchstart", (e) => { e.stopPropagation(); }); document.addEventListener("click", (e) => { document.querySelector("#settings-menu").classList.remove("visible"); }); document.addEventListener("touchstart", (e) => { document.querySelector("#settings-menu").classList.remove("visible"); }); document.querySelector("#toggle-filters").addEventListener("click", (e) => { const popoutMenu = document.querySelector("#filters-menu"); if (popoutMenu.classList.contains("visible")) { popoutMenu.classList.remove("visible"); } else { document .querySelectorAll(".popout-menu") .forEach((menu) => menu.classList.remove("visible")); const rect = e.target.getBoundingClientRect(); popoutMenu.classList.add("visible"); popoutMenu.style.left = rect.x + rect.width + 10 + "px"; popoutMenu.style.top = rect.y + rect.height + 10 + "px"; let menuWidth = popoutMenu.getBoundingClientRect().width; let screenWidth = window.innerWidth; if (menuWidth * 1.5 > screenWidth) { popoutMenu.style.left = 25 + "px"; } } e.stopPropagation(); }); document.querySelector("#filters-menu").addEventListener("click", (e) => { e.stopPropagation(); }); document.querySelector("#filters-menu").addEventListener("touchstart", (e) => { e.stopPropagation(); }); document.addEventListener("click", (e) => { document.querySelector("#filters-menu").classList.remove("visible"); }); document.addEventListener("touchstart", (e) => { document.querySelector("#filters-menu").classList.remove("visible"); }); document.querySelector("#toggle-info").addEventListener("click", (e) => { const popoutMenu = document.querySelector("#info-menu"); if (popoutMenu.classList.contains("visible")) { popoutMenu.classList.remove("visible"); } else { document .querySelectorAll(".popout-menu") .forEach((menu) => menu.classList.remove("visible")); const rect = e.target.getBoundingClientRect(); popoutMenu.classList.add("visible"); popoutMenu.style.left = rect.x + rect.width + 10 + "px"; popoutMenu.style.top = rect.y + rect.height + 10 + "px"; let menuWidth = popoutMenu.getBoundingClientRect().width; let screenWidth = window.innerWidth; if (menuWidth * 1.5 > screenWidth) { popoutMenu.style.left = 25 + "px"; } } e.stopPropagation(); }); document.querySelector("#info-menu").addEventListener("click", (e) => { e.stopPropagation(); }); document.querySelector("#info-menu").addEventListener("touchstart", (e) => { e.stopPropagation(); }); document.addEventListener("click", (e) => { document.querySelector("#info-menu").classList.remove("visible"); }); document.addEventListener("touchstart", (e) => { document.querySelector("#info-menu").classList.remove("visible"); }); window.addEventListener("unload", () => { saveScene("autosave"); setUserSettings(exportUserSettings()); }); document .querySelector("#options-selected-entity") .addEventListener("input", (e) => { if (e.target.value == "None") { deselect(); } else { select(document.querySelector("#entity-" + e.target.value)); } }); document .querySelector("#menu-toggle-sidebar") .addEventListener("click", (e) => { const sidebar = document.querySelector("#options"); if (sidebar.classList.contains("hidden")) { sidebar.classList.remove("hidden"); e.target.classList.remove("rotate-forward"); e.target.classList.add("rotate-backward"); } else { sidebar.classList.add("hidden"); e.target.classList.add("rotate-forward"); e.target.classList.remove("rotate-backward"); } handleResize(); }); document .querySelector("#menu-fullscreen") .addEventListener("click", toggleFullScreen); document .querySelector("#options-order-forward") .addEventListener("click", (e) => { if (selected) { entities[selected.dataset.key].priority += 1; } document.querySelector("#options-order-display").innerText = entities[selected.dataset.key].priority; updateSizes(); }); document .querySelector("#options-order-back") .addEventListener("click", (e) => { if (selected) { entities[selected.dataset.key].priority -= 1; } document.querySelector("#options-order-display").innerText = entities[selected.dataset.key].priority; updateSizes(); }); document .querySelector("#options-brightness-up") .addEventListener("click", (e) => { if (selected) { entities[selected.dataset.key].brightness += 1; } document.querySelector("#options-brightness-display").innerText = entities[selected.dataset.key].brightness; updateSizes(); }); document .querySelector("#options-brightness-down") .addEventListener("click", (e) => { if (selected) { entities[selected.dataset.key].brightness -= 1; } document.querySelector("#options-brightness-display").innerText = entities[selected.dataset.key].brightness; updateSizes(); }); document .querySelector("#options-rotate-left") .addEventListener("click", (e) => { if (selected) { entities[selected.dataset.key].rotation -= Math.PI / 4; } selected .querySelector("img") .style.setProperty( "--rotation", (entities[selected.dataset.key].rotation * 180) / Math.PI + "deg" ); updateSizes(); }); document .querySelector("#options-rotate-right") .addEventListener("click", (e) => { if (selected) { entities[selected.dataset.key].rotation += Math.PI / 4; } selected .querySelector("img") .style.setProperty( "--rotation", (entities[selected.dataset.key].rotation * 180) / Math.PI + "deg" ); updateSizes(); }); document.querySelector("#options-flip").addEventListener("click", (e) => { if (selected) { selected.querySelector(".entity-image").classList.toggle("flipped"); } document.querySelector("#options-brightness-display").innerText = entities[selected.dataset.key].brightness; updateSizes(); }); const sceneChoices = document.querySelector("#menu-preset ~ select"); Object.entries(scenes).forEach(([id, scene]) => { const option = document.createElement("option"); option.innerText = id; option.value = id; sceneChoices.appendChild(option); }); document.querySelector("#menu-preset").addEventListener("click", (e) => { const chosen = sceneChoices.value; removeAllEntities(); scenes[chosen](); }); entityX = document.querySelector("#entities").getBoundingClientRect().x; canvasWidth = document.querySelector("#display").clientWidth - 100; canvasHeight = document.querySelector("#display").clientHeight - 50; document .querySelector("#options-height-value") .addEventListener("change", (e) => { updateWorldHeight(); }); document .querySelector("#options-height-value") .addEventListener("keydown", (e) => { e.stopPropagation(); }); const unitSelector = document.querySelector("#options-height-unit"); Object.entries(unitChoices.length).forEach(([group, entries]) => { const optGroup = document.createElement("optgroup"); optGroup.label = group; unitSelector.appendChild(optGroup); entries.forEach((entry) => { const option = document.createElement("option"); option.innerText = entry; // we haven't loaded user settings yet, so we can't choose the unit just yet unitSelector.appendChild(option); }); }); unitSelector.addEventListener("input", selectNewUnit); param = window.location.hash; // we now use the fragment for links, but we should still support old stuff: if (param.length > 0) { param = param.substring(1); } else { param = new URL(window.location.href).searchParams.get("scene"); } document.querySelector("#world").addEventListener("mousedown", (e) => { // only middle mouse clicks if (e.which == 2) { panning = true; panOffsetX = e.clientX; panOffsetY = e.clientY; Object.keys(entities).forEach((key) => { document .querySelector("#entity-" + key) .classList.add("no-transition"); }); } }); document.addEventListener("mouseup", (e) => { if (e.which == 2) { panning = false; Object.keys(entities).forEach((key) => { document .querySelector("#entity-" + key) .classList.remove("no-transition"); }); } }); document.querySelector("#world").addEventListener("touchstart", (e) => { if (!rulerMode) { panning = true; panOffsetX = e.touches[0].clientX; panOffsetY = e.touches[0].clientY; e.preventDefault(); Object.keys(entities).forEach((key) => { document .querySelector("#entity-" + key) .classList.add("no-transition"); }); } }); document.querySelector("#world").addEventListener("touchend", (e) => { panning = false; Object.keys(entities).forEach((key) => { document .querySelector("#entity-" + key) .classList.remove("no-transition"); }); }); document.querySelector("#world").addEventListener("mousedown", (e) => { // only left mouse clicks if (e.which == 1 && rulerMode) { let entX = document .querySelector("#entities") .getBoundingClientRect().x; let entY = document .querySelector("#entities") .getBoundingClientRect().y; let pos = pix2pos({ x: e.clientX - entX, y: e.clientY - entY }); if (config.rulersStick && selected) { pos = entityRelativePosition(pos, selected); } currentRuler = { x0: pos.x, y0: pos.y, x1: pos.y, y1: pos.y, entityKey: null, }; if (config.rulersStick && selected) { currentRuler.entityKey = selected.dataset.key; } } }); document.querySelector("#world").addEventListener("mouseup", (e) => { // only left mouse clicks if (e.which == 1 && currentRuler) { rulers.push(currentRuler); currentRuler = null; rulerMode = false; } }); document.querySelector("#world").addEventListener("touchstart", (e) => { if (rulerMode) { let entX = document .querySelector("#entities") .getBoundingClientRect().x; let entY = document .querySelector("#entities") .getBoundingClientRect().y; let pos = pix2pos({ x: e.touches[0].clientX - entX, y: e.touches[0].clientY - entY, }); if (config.rulersStick && selected) { pos = entityRelativePosition(pos, selected); } currentRuler = { x0: pos.x, y0: pos.y, x1: pos.y, y1: pos.y, entityKey: null, }; if (config.rulersStick && selected) { currentRuler.entityKey = selected.dataset.key; } } }); document.querySelector("#world").addEventListener("touchend", (e) => { if (currentRuler) { rulers.push(currentRuler); currentRuler = null; rulerMode = false; } }); document.querySelector("body").appendChild(testCtx.canvas); world.addEventListener("mousedown", (e) => deselect(e)); world.addEventListener("touchstart", (e) => deselect({ which: 1, }) ); document.querySelector("#entities").addEventListener("mousedown", deselect); document.querySelector("#display").addEventListener("mousedown", deselect); document.addEventListener("mouseup", (e) => clickUp(e)); document.addEventListener("touchend", (e) => { const fakeEvent = { target: e.target, clientX: e.changedTouches[0].clientX, clientY: e.changedTouches[0].clientY, which: 1, }; clickUp(fakeEvent); }); const formList = document.querySelector("#entity-form"); formList.addEventListener("input", (e) => { const entity = entities[selected.dataset.key]; entity.form = e.target.value; const oldView = entity.currentView; entity.view = entity.formViews[entity.form]; // to set the size properly, even if we use a non-default view if (Object.keys(entity.forms).length > 0) entity.views[entity.view].height = entity.formSizes[entity.form].height; let found = Object.entries(entity.views).find(([key, view]) => { return view.form === entity.form && view.name === oldView.name; }); const newView = found ? found[0] : entity.formViews[entity.form]; entity.view = newView; preloadViews(entity); configViewList(entity, entity.view); const image = entity.views[entity.view].image; selected.querySelector(".entity-image").src = image.source; configViewOptions(entity, entity.view); displayAttribution(image.source); if (image.bottom !== undefined) { selected .querySelector(".entity-image") .style.setProperty("--offset", (-1 + image.bottom) * 100 + "%"); } else { selected .querySelector(".entity-image") .style.setProperty("--offset", -1 * 100 + "%"); } if (config.autoFitSize) { let targets = {}; targets[selected.dataset.key] = entities[selected.dataset.key]; fitEntities(targets); } configSizeList(entity); updateSizes(); updateEntityOptions(entities[selected.dataset.key], e.target.value); updateViewOptions(entities[selected.dataset.key], entity.view); }); const viewList = document.querySelector("#entity-view"); document.querySelector("#entity-view").addEventListener("input", (e) => { const entity = entities[selected.dataset.key]; entity.view = e.target.value; preloadViews(entity); const image = entities[selected.dataset.key].views[e.target.value].image; selected.querySelector(".entity-image").src = image.source; configViewOptions(entity, entity.view); displayAttribution(image.source); if (image.bottom !== undefined) { selected .querySelector(".entity-image") .style.setProperty("--offset", (-1 + image.bottom) * 100 + "%"); } else { selected .querySelector(".entity-image") .style.setProperty("--offset", -1 * 100 + "%"); } updateSizes(); updateEntityOptions(entities[selected.dataset.key], e.target.value); updateViewOptions(entities[selected.dataset.key], e.target.value); }); document.querySelector("#entity-view").addEventListener("input", (e) => { if ( viewList.options[viewList.selectedIndex].classList.contains("nsfw") ) { viewList.classList.add("nsfw"); } else { viewList.classList.remove("nsfw"); } }); clearViewList(); document.querySelector("#menu-clear").addEventListener("click", (e) => { removeAllEntities(); }); document.querySelector("#delete-entity").disabled = true; document.querySelector("#delete-entity").addEventListener("click", (e) => { if (selected) { removeEntity(selected); selected = null; } }); document .querySelector("#menu-order-height") .addEventListener("click", (e) => { const order = Object.keys(entities).sort((a, b) => { const entA = entities[a]; const entB = entities[b]; const viewA = entA.view; const viewB = entB.view; const heightA = entA.views[viewA].height.to("meter").value; const heightB = entB.views[viewB].height.to("meter").value; return heightA - heightB; }); arrangeEntities(order); }); // TODO: write some generic logic for this lol document .querySelector("#scroll-left") .addEventListener("mousedown", (e) => { scrollDirection = -1; clearInterval(scrollHandle); scrollHandle = setInterval(doXScroll, 1000 / 20); e.stopPropagation(); }); document .querySelector("#scroll-right") .addEventListener("mousedown", (e) => { scrollDirection = 1; clearInterval(scrollHandle); scrollHandle = setInterval(doXScroll, 1000 / 20); e.stopPropagation(); }); document .querySelector("#scroll-left") .addEventListener("touchstart", (e) => { scrollDirection = -1; clearInterval(scrollHandle); scrollHandle = setInterval(doXScroll, 1000 / 20); e.stopPropagation(); }); document .querySelector("#scroll-right") .addEventListener("touchstart", (e) => { scrollDirection = 1; clearInterval(scrollHandle); scrollHandle = setInterval(doXScroll, 1000 / 20); e.stopPropagation(); }); document.querySelector("#scroll-up").addEventListener("mousedown", (e) => { if (config.lockYAxis) { moveGround(true); } else { scrollDirection = 1; clearInterval(scrollHandle); scrollHandle = setInterval(doYScroll, 1000 / 20); e.stopPropagation(); } }); document .querySelector("#scroll-down") .addEventListener("mousedown", (e) => { if (config.lockYAxis) { moveGround(false); } else { scrollDirection = -1; clearInterval(scrollHandle); scrollHandle = setInterval(doYScroll, 1000 / 20); e.stopPropagation(); } }); document.querySelector("#scroll-up").addEventListener("touchstart", (e) => { if (config.lockYAxis) { moveGround(true); } else { scrollDirection = 1; clearInterval(scrollHandle); scrollHandle = setInterval(doYScroll, 1000 / 20); e.stopPropagation(); } }); document .querySelector("#scroll-down") .addEventListener("touchstart", (e) => { if (config.lockYAxis) { moveGround(false); } else { scrollDirection = -1; clearInterval(scrollHandle); scrollHandle = setInterval(doYScroll, 1000 / 20); e.stopPropagation(); } }); document.addEventListener("mouseup", (e) => { clearInterval(scrollHandle); scrollHandle = null; }); document.addEventListener("touchend", (e) => { clearInterval(scrollHandle); scrollHandle = null; }); document.querySelector("#zoom-in").addEventListener("mousedown", (e) => { zoomDirection = -1; clearInterval(zoomHandle); zoomHandle = setInterval(doZoom, 1000 / 20); e.stopPropagation(); }); document.querySelector("#zoom-out").addEventListener("mousedown", (e) => { zoomDirection = 1; clearInterval(zoomHandle); zoomHandle = setInterval(doZoom, 1000 / 20); e.stopPropagation(); }); document.querySelector("#zoom-in").addEventListener("touchstart", (e) => { zoomDirection = -1; clearInterval(zoomHandle); zoomHandle = setInterval(doZoom, 1000 / 20); e.stopPropagation(); }); document.querySelector("#zoom-out").addEventListener("touchstart", (e) => { zoomDirection = 1; clearInterval(zoomHandle); zoomHandle = setInterval(doZoom, 1000 / 20); e.stopPropagation(); }); document.addEventListener("mouseup", (e) => { clearInterval(zoomHandle); zoomHandle = null; }); document.addEventListener("touchend", (e) => { clearInterval(zoomHandle); zoomHandle = null; }); document.querySelector("#shrink").addEventListener("mousedown", (e) => { sizeDirection = -1; clearInterval(sizeHandle); sizeHandle = setInterval(doSize, 1000 / 20); e.stopPropagation(); }); document.querySelector("#grow").addEventListener("mousedown", (e) => { sizeDirection = 1; clearInterval(sizeHandle); sizeHandle = setInterval(doSize, 1000 / 20); e.stopPropagation(); }); document.querySelector("#shrink").addEventListener("touchstart", (e) => { sizeDirection = -1; clearInterval(sizeHandle); sizeHandle = setInterval(doSize, 1000 / 20); e.stopPropagation(); }); document.querySelector("#grow").addEventListener("touchstart", (e) => { sizeDirection = 1; clearInterval(sizeHandle); sizeHandle = setInterval(doSize, 1000 / 20); e.stopPropagation(); }); document.addEventListener("mouseup", (e) => { clearInterval(sizeHandle); sizeHandle = null; }); document.addEventListener("touchend", (e) => { clearInterval(sizeHandle); sizeHandle = null; }); document.querySelector("#ruler").addEventListener("click", (e) => { rulerMode = !rulerMode; if (rulerMode) { toast("Ready to draw a ruler mark"); } else { toast("Cancelled ruler mode"); } }); document.querySelector("#ruler").addEventListener("mousedown", (e) => { e.stopPropagation(); }); document.querySelector("#ruler").addEventListener("touchstart", (e) => { e.stopPropagation(); }); document.querySelector("#fit").addEventListener("click", (e) => { if (selected) { let targets = {}; targets[selected.dataset.key] = entities[selected.dataset.key]; fitEntities(targets); } }); document.querySelector("#fit").addEventListener("mousedown", (e) => { e.stopPropagation(); }); document.querySelector("#fit").addEventListener("touchstart", (e) => { e.stopPropagation(); }); document .querySelector("#options-world-fit") .addEventListener("click", () => fitWorld(true)); document .querySelector("#options-reset-pos-x") .addEventListener("click", () => { config.x = 0; updateSizes(); }); document .querySelector("#options-reset-pos-y") .addEventListener("click", () => { config.y = 0; updateSizes(); }); document.addEventListener("keydown", (e) => { if (e.key == "Delete" || e.key == "Backspace") { if (selected) { removeEntity(selected); selected = null; } } }); document.addEventListener("keydown", (e) => { if (e.key == "Shift") { shiftHeld = true; e.preventDefault(); } else if (e.key == "Alt") { altHeld = true; movingInBounds = false; // don't snap the object back in bounds when we let go e.preventDefault(); } }); document.addEventListener("keyup", (e) => { if (e.key == "Shift") { shiftHeld = false; e.preventDefault(); } else if (e.key == "Alt") { altHeld = false; e.preventDefault(); } }); window.addEventListener("resize", handleResize); // TODO: further investigate why the tool initially starts out with wrong // values under certain circumstances (seems to be narrow aspect ratios - // maybe the menu bar is animating when it shouldn't) setTimeout(handleResize, 250); setTimeout(handleResize, 500); setTimeout(handleResize, 750); setTimeout(handleResize, 1000); document.querySelector("#menu-permalink").addEventListener("click", (e) => { linkScene(); }); document.querySelector("#menu-export").addEventListener("click", (e) => { copyScene(); }); document.querySelector("#menu-import").addEventListener("click", (e) => { pasteScene(); }); document.querySelector("#menu-save").addEventListener("click", (e) => { const name = document.querySelector("#menu-save ~ input").value; if (/\S/.test(name)) { saveScene(name); } updateSaveInfo(); }); document.querySelector("#menu-load").addEventListener("click", (e) => { const name = document.querySelector("#menu-load ~ select").value; if (/\S/.test(name)) { loadScene(name); } }); document.querySelector("#menu-delete").addEventListener("click", (e) => { const name = document.querySelector("#menu-delete ~ select").value; if (/\S/.test(name)) { deleteScene(name); } }); document .querySelector("#menu-load-autosave") .addEventListener("click", (e) => { loadScene("autosave"); }); document.querySelector("#menu-add-image").addEventListener("click", (e) => { document.querySelector("#file-upload-picker").click(); }); document .querySelector("#file-upload-picker") .addEventListener("change", (e) => { if (e.target.files.length > 0) { for (let i = 0; i < e.target.files.length; i++) { customEntityFromFile(e.target.files[i]); } } }); document .querySelector("#menu-clear-rulers") .addEventListener("click", (e) => { rulers = []; drawRulers(); }); document.addEventListener("paste", (e) => { let index = 0; let item = null; let found = false; for (; index < e.clipboardData.items.length; index++) { item = e.clipboardData.items[index]; if (item.type == "image/png") { found = true; break; } } if (!found) { return; } let url = null; const file = item.getAsFile(); customEntityFromFile(file); }); document.querySelector("#world").addEventListener("dragover", (e) => { e.preventDefault(); }); document.querySelector("#world").addEventListener("drop", (e) => { e.preventDefault(); if (e.dataTransfer.files.length > 0) { let entX = document .querySelector("#entities") .getBoundingClientRect().x; let entY = document .querySelector("#entities") .getBoundingClientRect().y; let coords = pix2pos({ x: e.clientX - entX, y: e.clientY - entY }); customEntityFromFile(e.dataTransfer.files[0], coords.x, coords.y); } }); clearEntityOptions(); clearViewOptions(); clearAttribution(); // we do this last because configuring settings can cause things // to happen (e.g. auto-fit) prepareSettings(getUserSettings()); // now that we have this loaded, we can set it unitSelector.dataset.oldUnit = defaultUnits.length[config.units]; document.querySelector("#options-height-unit").value = defaultUnits.length[config.units]; // ...and then update the world height by setting off an input event document .querySelector("#options-height-unit") .dispatchEvent(new Event("input", {})); if (param === null) { scenes["Empty"](); } else { try { const data = JSON.parse(b64DecodeUnicode(param)); if (data.entities === undefined) { return; } if (data.world === undefined) { return; } importScene(data); } catch (err) { console.error(err); scenes["Empty"](); // probably wasn't valid data } } document.querySelector("#world").addEventListener("wheel", (e) => { let magnitude = Math.abs(e.deltaY / 100); if (shiftHeld) { // macs do horizontal scrolling with shift held let delta = e.deltaY; if (e.deltaY == 0) { magnitude = Math.abs(e.deltaX / 100); delta = e.deltaX; } if (selected) { let dir = delta > 0 ? 10 / 11 : 11 / 10; dir -= 1; dir *= magnitude; dir += 1; const entity = entities[selected.dataset.key]; entity.views[entity.view].height = math.multiply( entity.views[entity.view].height, dir ); entity.dirty = true; updateEntityOptions(entity, entity.view); updateViewOptions(entity, entity.view); updateSizes(true); } else { const worldWidth = (config.height.toNumber("meters") / canvasHeight) * canvasWidth; config.x += ((e.deltaY > 0 ? 1 : -1) * worldWidth) / 20; updateSizes(); updateSizes(); } } else { if (config.autoFit) { toastRateLimit( "Zoom is locked! Check Settings to disable.", "zoom-lock", 1000 ); } else { let dir = e.deltaY < 0 ? 10 / 11 : 11 / 10; dir -= 1; dir *= magnitude; dir += 1; const change = config.height.toNumber("meters") - math.multiply(config.height, dir).toNumber("meters"); if (!config.lockYAxis) { config.y += change / 2; } setWorldHeight( config.height, math.multiply(config.height, dir) ); updateWorldOptions(); } } checkFitWorld(); }); document.addEventListener("mousemove", (e) => { if (currentRuler) { let entX = document .querySelector("#entities") .getBoundingClientRect().x; let entY = document .querySelector("#entities") .getBoundingClientRect().y; let position = pix2pos({ x: e.clientX - entX, y: e.clientY - entY, }); if (config.rulersStick && selected) { position = entityRelativePosition(position, selected); } currentRuler.x1 = position.x; currentRuler.y1 = position.y; } drawRulers(); }); document.addEventListener("touchmove", (e) => { if (currentRuler) { let entX = document .querySelector("#entities") .getBoundingClientRect().x; let entY = document .querySelector("#entities") .getBoundingClientRect().y; let position = pix2pos({ x: e.touches[0].clientX - entX, y: e.touches[0].clientY - entY, }); if (config.rulersStick && selected) { position = entityRelativePosition(position, selected); } currentRuler.x1 = position.x; currentRuler.y1 = position.y; } drawRulers(); }); document.addEventListener("mousemove", (e) => { if (clicked) { let position = pix2pos({ x: e.clientX - dragOffsetX, y: e.clientY - dragOffsetY, }); if (movingInBounds) { position = snapPos(position); } else { let x = e.clientX - dragOffsetX; let y = e.clientY - dragOffsetY; if (x >= 0 && x <= canvasWidth && y >= 0 && y <= canvasHeight) { movingInBounds = true; } } clicked.dataset.x = position.x; clicked.dataset.y = position.y; updateEntityElement(entities[clicked.dataset.key], clicked); if (hoveringInDeleteArea(e)) { document .querySelector("#menubar") .classList.add("hover-delete"); } else { document .querySelector("#menubar") .classList.remove("hover-delete"); } } if (panning && panReady) { const worldWidth = (config.height.toNumber("meters") / canvasHeight) * canvasWidth; const worldHeight = config.height.toNumber("meters"); config.x -= ((e.clientX - panOffsetX) / canvasWidth) * worldWidth; config.y += ((e.clientY - panOffsetY) / canvasHeight) * worldHeight; panOffsetX = e.clientX; panOffsetY = e.clientY; updateSizes(); panReady = false; setTimeout(() => (panReady = true), 1000 / 120); } }); document.addEventListener( "touchmove", (e) => { if (clicked) { e.preventDefault(); let x = e.touches[0].clientX; let y = e.touches[0].clientY; const position = snapPos( pix2pos({ x: x - dragOffsetX, y: y - dragOffsetY }) ); clicked.dataset.x = position.x; clicked.dataset.y = position.y; updateEntityElement(entities[clicked.dataset.key], clicked); // what a hack // I should centralize this 'fake event' creation... if (hoveringInDeleteArea({ clientY: y })) { document .querySelector("#menubar") .classList.add("hover-delete"); } else { document .querySelector("#menubar") .classList.remove("hover-delete"); } } if (panning && panReady) { const worldWidth = (config.height.toNumber("meters") / canvasHeight) * canvasWidth; const worldHeight = config.height.toNumber("meters"); config.x -= ((e.touches[0].clientX - panOffsetX) / canvasWidth) * worldWidth; config.y += ((e.touches[0].clientY - panOffsetY) / canvasHeight) * worldHeight; panOffsetX = e.touches[0].clientX; panOffsetY = e.touches[0].clientY; updateSizes(); panReady = false; setTimeout(() => (panReady = true), 1000 / 60); } }, { passive: false } ); updateWorldHeight(); document .querySelector("#search-box") .addEventListener("change", (e) => doSearch(e.target.value)); document .querySelector("#search-box") .addEventListener("keydown", e => e.stopPropagation()); // Webkit doesn't draw resized SVGs correctly. It will always draw them at their intrinsic size, I think // This checks for that. webkitBugTest.onload = () => { testCtx.canvas.width = 500; testCtx.canvas.height = 500; testCtx.clearRect(0, 0, 500, 500); testCtx.drawImage(webkitBugTest, 0, 0, 500, 500); webkitCanvasBug = testCtx.getImageData(250, 250, 1, 1).data[3] == 0; if (webkitCanvasBug) { toast( "Heads up: Safari can't select through gaps or take screenshots (check the console for info!)" ); console.log( "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 :(" ); } }; updateFilter(); }); let searchText = ""; function doSearch(value) { searchText = value; updateFilter(); } function customEntityFromFile(file, x = 0.5, y = 0.5) { file.arrayBuffer().then((buf) => { arr = new Uint8Array(buf); blob = new Blob([arr], { type: file.type }); url = window.URL.createObjectURL(blob); makeCustomEntity(url, x, y); }); } function makeCustomEntity(url, x = 0.5, y = 0.5) { const maker = createEntityMaker( { name: "Custom Entity", }, { custom: { attributes: { height: { name: "Height", power: 1, type: "length", base: math.unit(6, "feet"), }, }, image: { source: url, }, name: "Image", info: {}, rename: false, }, }, [] ); const entity = maker.constructor(); entity.scale = config.height.toNumber("feet") / 20; entity.ephemeral = true; displayEntity(entity, "custom", x, y, true, true); } const filterDefs = { author: { id: "author", name: "Authors", extract: (maker) => (maker.authors ? maker.authors : []), render: (author) => attributionData.people[author].name, sort: (tag1, tag2) => tag1[1].localeCompare(tag2[1]), }, owner: { id: "owner", name: "Owners", extract: (maker) => (maker.owners ? maker.owners : []), render: (owner) => attributionData.people[owner].name, sort: (tag1, tag2) => tag1[1].localeCompare(tag2[1]), }, species: { id: "species", name: "Species", extract: (maker) => maker.info && maker.info.species ? getSpeciesInfo(maker.info.species) : [], render: (species) => speciesData[species].name, sort: (tag1, tag2) => tag1[1].localeCompare(tag2[1]), }, tags: { id: "tags", name: "Tags", extract: (maker) => maker.info && maker.info.tags ? maker.info.tags : [], render: (tag) => tagDefs[tag], sort: (tag1, tag2) => tag1[1].localeCompare(tag2[1]), }, size: { id: "size", name: "Normal Size", extract: (maker) => maker.sizes && maker.sizes.length > 0 ? Array.from( maker.sizes.reduce((result, size) => { if (result && !size.default) { return result; } let meters = size.height.toNumber("meters"); if (meters < 1e-1) { return ["micro"]; } else if (meters < 1e1) { return ["moderate"]; } else { return ["macro"]; } }, null) ) : [], render: (tag) => { return { micro: "Micro", moderate: "Moderate", macro: "Macro", }[tag]; }, sort: (tag1, tag2) => { const order = { micro: 0, moderate: 1, macro: 2, }; return order[tag1[0]] - order[tag2[0]]; }, }, allSizes: { id: "allSizes", name: "Possible Size", extract: (maker) => maker.sizes ? Array.from( maker.sizes.reduce((set, size) => { const height = size.height; let result = Object.entries(sizeCategories).reduce( (result, [name, value]) => { if (result) { return result; } else { if (math.compare(height, value) <= 0) { return name; } } }, null ); set.add(result ? result : "infinite"); return set; }, new Set()) ) : [], render: (tag) => tag[0].toUpperCase() + tag.slice(1), sort: (tag1, tag2) => { const order = [ "atomic", "microscopic", "tiny", "small", "moderate", "large", "macro", "megamacro", "planetary", "stellar", "galactic", "universal", "omniversal", "infinite", ]; return order.indexOf(tag1[0]) - order.indexOf(tag2[0]); }, }, }; const filterStates = {}; const sizeCategories = { atomic: math.unit(100, "angstroms"), microscopic: math.unit(100, "micrometers"), tiny: math.unit(100, "millimeters"), small: math.unit(1, "meter"), moderate: math.unit(3, "meters"), large: math.unit(10, "meters"), macro: math.unit(300, "meters"), megamacro: math.unit(1000, "kilometers"), planetary: math.unit(10, "earths"), stellar: math.unit(10, "solarradii"), galactic: math.unit(10, "galaxies"), universal: math.unit(10, "universes"), omniversal: math.unit(10, "multiverses"), }; function prepareEntities() { availableEntities["buildings"] = makeBuildings(); availableEntities["characters"] = makeCharacters(); availableEntities["clothing"] = makeClothing(); availableEntities["creatures"] = makeCreatures(); availableEntities["fiction"] = makeFiction(); availableEntities["food"] = makeFood(); availableEntities["furniture"] = makeFurniture(); availableEntities["landmarks"] = makeLandmarks(); availableEntities["naturals"] = makeNaturals(); availableEntities["objects"] = makeObjects(); availableEntities["pokemon"] = makePokemon(); availableEntities["real-buildings"] = makeRealBuildings(); availableEntities["real-terrain"] = makeRealTerrains(); availableEntities["species"] = makeSpecies(); availableEntities["vehicles"] = makeVehicles(); availableEntities["species"].forEach((x) => { if (x.name == "Human") { availableEntities["food"].push(x); } }); availableEntities["characters"].sort((x, y) => { return x.name.localeCompare(y.name); }); availableEntities["species"].sort((x, y) => { return x.name.localeCompare(y.name); }); availableEntities["objects"].sort((x, y) => { return x.name.localeCompare(y.name); }); availableEntities["furniture"].sort((x, y) => { return x.name.localeCompare(y.name); }); const holder = document.querySelector("#spawners"); const filterMenu = document.querySelector("#filters-menu"); const categorySelect = document.createElement("select"); categorySelect.id = "category-picker"; holder.appendChild(categorySelect); const filterSets = {}; Object.values(filterDefs).forEach((filter) => { filterSets[filter.id] = new Set(); filterStates[filter.id] = false; }); Object.entries(availableEntities).forEach(([category, entityList]) => { const select = document.createElement("select"); select.id = "create-entity-" + category; select.classList.add("entity-select"); for (let i = 0; i < entityList.length; i++) { const entity = entityList[i]; const option = document.createElement("option"); option.value = i; option.innerText = entity.name; select.appendChild(option); if (entity.nsfw) { option.classList.add("nsfw"); } Object.values(filterDefs).forEach((filter) => { filter.extract(entity).forEach((result) => { filterSets[filter.id].add(result); }); }); availableEntitiesByName[entity.name] = entity; } select.addEventListener("change", (e) => { if ( select.options[select.selectedIndex]?.classList.contains("nsfw") ) { select.classList.add("nsfw"); } else { select.classList.remove("nsfw"); } // preload the entity's first image const entity = entityList[select.selectedIndex]?.constructor(); if (entity) { let img = new Image(); img.src = entity.currentView.image.source; } }); const button = document.createElement("button"); button.id = "create-entity-" + category + "-button"; button.classList.add("entity-button"); button.innerHTML = ''; button.addEventListener("click", (e) => { if (entityList[select.value] == null) return; const newEntity = entityList[select.value].constructor(); let yOffset = 0; if (config.lockYAxis) { yOffset = getVerticalOffset(); } else { yOffset = config.lockYAxis ? 0 : config.height.toNumber("meters") / 2; } displayEntity( newEntity, newEntity.defaultView, config.x, config.y + yOffset, true, true ); }); const categoryOption = document.createElement("option"); categoryOption.value = category; categoryOption.innerText = category; if (category == "characters") { categoryOption.selected = true; select.classList.add("category-visible"); button.classList.add("category-visible"); } categorySelect.appendChild(categoryOption); holder.appendChild(select); holder.appendChild(button); }); Object.values(filterDefs).forEach((filter) => { const filterHolder = document.createElement("label"); filterHolder.setAttribute("for", "filter-toggle-" + filter.id); filterHolder.classList.add("filter-holder"); const filterToggle = document.createElement("input"); filterToggle.type = "checkbox"; filterToggle.id = "filter-toggle-" + filter.id; filterHolder.appendChild(filterToggle); filterToggle.addEventListener("input", e => { filterStates[filter.id] = filterToggle.checked if (filterToggle.checked) { filterHolder.classList.add("enabled"); } else { filterHolder.classList.remove("enabled"); } clearFilter(); updateFilter(); }); const filterLabel = document.createElement("div"); filterLabel.innerText = filter.name; filterHolder.appendChild(filterLabel); const filterNameSelect = document.createElement("select"); filterNameSelect.classList.add("filter-select"); filterNameSelect.id = "filter-" + filter.id; filterHolder.appendChild(filterNameSelect); filterMenu.appendChild(filterHolder); Array.from(filterSets[filter.id]) .map((name) => [name, filter.render(name)]) .sort(filterDefs[filter.id].sort) .forEach((name) => { const option = document.createElement("option"); option.innerText = name[1]; option.value = name[0]; filterNameSelect.appendChild(option); }); filterNameSelect.addEventListener("change", (e) => { updateFilter(); }); }); const spawnButton = document.createElement("button"); spawnButton.id = "spawn-all" spawnButton.addEventListener("click", e => { spawnAll(); }); filterMenu.appendChild(spawnButton); console.log( "Loaded " + Object.keys(availableEntitiesByName).length + " entities" ); categorySelect.addEventListener("input", (e) => { const oldSelect = document.querySelector( ".entity-select.category-visible" ); oldSelect.classList.remove("category-visible"); const oldButton = document.querySelector( ".entity-button.category-visible" ); oldButton.classList.remove("category-visible"); const newSelect = document.querySelector( "#create-entity-" + e.target.value ); newSelect.classList.add("category-visible"); const newButton = document.querySelector( "#create-entity-" + e.target.value + "-button" ); newButton.classList.add("category-visible"); recomputeFilters(); updateFilter(); }); recomputeFilters(); ratioInfo = document.body.querySelector(".extra-info"); } function spawnAll() { const makers = Array.from( document.querySelector(".entity-select.category-visible") ).filter((element) => !element.classList.contains("filtered")); const count = makers.length + 2; let index = 1; if (makers.length > 50) { if (!confirm("Really spawn " + makers.length + " things at once?")) { return; } } const worldWidth = (config.height.toNumber("meters") / canvasHeight) * canvasWidth; const spawned = makers.map((element) => { const category = document.querySelector("#category-picker").value; const maker = availableEntities[category][element.value]; const entity = maker.constructor(); displayEntity( entity, entity.view, -worldWidth * 0.45 + config.x + (worldWidth * 0.9 * index) / (count - 1), config.y ); index += 1; return entityIndex - 1; }); updateSizes(true); if (config.autoFitAdd) { let targets = {}; spawned.forEach((key) => { targets[key] = entities[key]; }); fitEntities(targets); } } // Only display authors and owners if they appear // somewhere in the current entity list function recomputeFilters() { const category = document.querySelector("#category-picker").value; const filterSets = {}; Object.values(filterDefs).forEach((filter) => { filterSets[filter.id] = new Set(); }); availableEntities[category].forEach((entity) => { Object.values(filterDefs).forEach((filter) => { filter.extract(entity).forEach((result) => { filterSets[filter.id].add(result); }); }); }); Object.values(filterDefs).forEach((filter) => { filterStates[filter.id] = false; document.querySelector("#filter-toggle-" + filter.id).checked = false document.querySelector("#filter-toggle-" + filter.id).dispatchEvent(new Event("click")) // always show the "none" option let found = filter.id == "none"; const filterSelect = document.querySelector("#filter-" + filter.id); const filterSelectHolder = filterSelect.parentElement; filterSelect.querySelectorAll("option").forEach((element) => { if ( filterSets[filter.id].has(element.value) || filter.id == "none" ) { element.classList.remove("filtered"); element.disabled = false; found = true; } else { element.classList.add("filtered"); element.disabled = true; } }); if (found) { filterSelectHolder.style.display = ""; } else { filterSelectHolder.style.display = "none"; } }); } function updateFilter() { const category = document.querySelector("#category-picker").value; const types = Object.values(filterDefs).filter(def => filterStates[def.id]).map(def => def.id) const keys = { } types.forEach(type => { const filterKeySelect = document.querySelector("#filter-" + type); keys[type] = filterKeySelect.value; }) clearFilter(); let current = document.querySelector( ".entity-select.category-visible" ).value; let replace = current == ""; let first = null; let count = 0; const lowerSearchText = searchText !== "" ? searchText.toLowerCase() : null; document .querySelectorAll(".entity-select.category-visible > option") .forEach((element) => { let keep = true types.forEach(type => { if ( !(filterDefs[type] .extract(availableEntities[category][element.value]) .indexOf(keys[type]) >= 0) ) { keep = false; } }) if ( searchText != "" && !availableEntities[category][element.value].name .toLowerCase() .includes(lowerSearchText) ) { keep = false; } if (!keep) { element.classList.add("filtered"); element.disabled = true; if (current == element.value) { replace = true; } } else { count += 1; if (!first) { first = element.value; } } }); const button = document.querySelector("#spawn-all") button.innerText = "Spawn " + count + " filtered " + (count == 1 ? "entity" : "entities") + "."; if (replace) { document.querySelector(".entity-select.category-visible").value = first; document .querySelector("#create-entity-" + category) .dispatchEvent(new Event("change")); } } function clearFilter() { document .querySelectorAll(".entity-select.category-visible > option") .forEach((element) => { element.classList.remove("filtered"); element.disabled = false; }); } function checkFitWorld() { if (config.autoFit) { fitWorld(); return true; } return false; } function fitWorld(manual = false, factor = 1.1) { if (Object.keys(entities).length > 0) { fitEntities(entities, factor); } } function fitEntities(targetEntities, manual = false, factor = 1.1) { let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; let count = 0; const worldWidth = (config.height.toNumber("meters") / canvasHeight) * canvasWidth; const worldHeight = config.height.toNumber("meters"); Object.entries(targetEntities).forEach(([key, entity]) => { const view = entity.view; let extra = entity.views[view].image.extra; extra = extra === undefined ? 1 : extra; const image = document.querySelector( "#entity-" + key + " > .entity-image" ); const x = parseFloat( document.querySelector("#entity-" + key).dataset.x ); let width = image.width; let height = image.height; // only really relevant if the images haven't loaded in yet if (height == 0) { height = 100; } if (width == 0) { width = height; } const xBottom = x - (entity.views[view].height.toNumber("meters") * width) / height / 2; const xTop = x + (entity.views[view].height.toNumber("meters") * width) / height / 2; const y = parseFloat( document.querySelector("#entity-" + key).dataset.y ); const yBottom = y; const yTop = entity.views[view].height.toNumber("meters") + yBottom; minX = Math.min(minX, xBottom); maxX = Math.max(maxX, xTop); minY = Math.min(minY, yBottom); maxY = Math.max(maxY, yTop); count += 1; }); if (config.lockYAxis) { minY = 0; } let ySize = (maxY - minY) * factor; let xSize = (maxX - minX) * factor; if (xSize / ySize > worldWidth / worldHeight) { ySize *= xSize / ySize / (worldWidth / worldHeight); } config.x = (maxX + minX) / 2; config.y = minY; height = math.unit(ySize, "meter"); setWorldHeight(config.height, math.multiply(height, factor)); } function updateWorldHeight() { const unit = document.querySelector("#options-height-unit").value; const rawValue = document.querySelector("#options-height-value").value; var value; try { value = math.evaluate(rawValue); if (typeof value !== "number") { try { value = value.toNumber(unit); } catch { toast( "Invalid input: " + rawValue + " can't be converted to " + unit ); } } } catch { toast("Invalid input: could not parse " + rawValue); return; } const newHeight = Math.max(0.000000001, value); const oldHeight = config.height; setWorldHeight(oldHeight, math.unit(newHeight, unit), true); } function setWorldHeight(oldHeight, newHeight, keepUnit = false) { worldSizeDirty = true; config.height = newHeight.to( document.querySelector("#options-height-unit").value ); const unit = document.querySelector("#options-height-unit").value; setNumericInput( document.querySelector("#options-height-value"), config.height.toNumber(unit) ); Object.entries(entities).forEach(([key, entity]) => { const element = document.querySelector("#entity-" + key); let newPosition; if (altHeld) { newPosition = adjustAbs( { x: element.dataset.x, y: element.dataset.y }, oldHeight, config.height ); } else { newPosition = { x: element.dataset.x, y: element.dataset.y }; } element.dataset.x = newPosition.x; element.dataset.y = newPosition.y; }); if (!keepUnit) { pickUnit(); } updateSizes(); } function loadScene(name = "default") { if (name === "") { name = "default"; } try { const data = JSON.parse( localStorage.getItem("macrovision-save-" + name) ); if (data === null) { console.error("Couldn't load " + name); return false; } importScene(data); toast("Loaded " + name); return true; } catch (err) { alert( "Something went wrong while loading (maybe you didn't have anything saved. Check the F12 console for the error." ); console.error(err); return false; } } function saveScene(name = "default") { try { const string = JSON.stringify(exportScene()); localStorage.setItem("macrovision-save-" + name, string); toast("Saved as " + name); } catch (err) { alert( "Something went wrong while saving (maybe I don't have localStorage permissions, or exporting failed). Check the F12 console for the error." ); console.error(err); } } function deleteScene(name = "default") { if (confirm("Really delete the " + name + " scene?")) { try { localStorage.removeItem("macrovision-save-" + name); toast("Deleted " + name); } catch (err) { console.error(err); } } updateSaveInfo(); } function exportScene() { const results = {}; results.entities = []; Object.entries(entities) .filter(([key, entity]) => entity.ephemeral !== true) .forEach(([key, entity]) => { const element = document.querySelector("#entity-" + key); results.entities.push({ name: entity.identifier, customName: entity.name, scale: entity.scale, rotation: entity.rotation, view: entity.view, form: entity.form, x: element.dataset.x, y: element.dataset.y, priority: entity.priority, brightness: entity.brightness, }); }); const unit = document.querySelector("#options-height-unit").value; results.world = { height: config.height.toNumber(unit), unit: unit, x: config.x, y: config.y, }; results.version = migrationDefs.length; return results; } // btoa doesn't like anything that isn't ASCII // great // thanks to https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings // for providing an alternative function b64EncodeUnicode(str) { // first we use encodeURIComponent to get percent-encoded UTF-8, // then we convert the percent encodings into raw bytes which // can be fed into btoa. return btoa( encodeURIComponent(str).replace( /%([0-9A-F]{2})/g, function toSolidBytes(match, p1) { return String.fromCharCode("0x" + p1); } ) ); } function b64DecodeUnicode(str) { // Going backwards: from bytestream, to percent-encoding, to original string. return decodeURIComponent( atob(str) .split("") .map(function (c) { return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); }) .join("") ); } function linkScene() { loc = new URL(window.location); const link = loc.protocol + "//" + loc.host + loc.pathname + "#" + b64EncodeUnicode(JSON.stringify(exportScene())); window.history.replaceState(null, "Macrovision", link); try { navigator.clipboard.writeText(link); toast("Copied permalink to clipboard"); } catch { toast("Couldn't copy permalink"); } } function copyScene() { const results = exportScene(); navigator.clipboard.writeText(JSON.stringify(results)); } function pasteScene() { try { navigator.clipboard .readText() .then((text) => { const data = JSON.parse(text); if (data.entities === undefined) { return; } if (data.world === undefined) { return; } importScene(data); }) .catch((err) => alert(err)); } catch (err) { console.error(err); // probably wasn't valid data } } // TODO - don't just search through every single entity // probably just have a way to do lookups directly function findEntity(name) { return availableEntitiesByName[name]; } const migrationDefs = [ /* Migration: 0 -> 1 Adds x and y coordinates for the camera */ (data) => { data.world.x = 0; data.world.y = 0; }, /* Migration: 1 -> 2 Adds priority and brightness to each entity */ (data) => { data.entities.forEach((entity) => { entity.priority = 0; entity.brightness = 1; }); }, /* Migration: 2 -> 3 Custom names are exported */ (data) => { data.entities.forEach((entity) => { entity.customName = entity.name; }); }, /* Migration: 3 -> 4 Rotation is now stored */ (data) => { data.entities.forEach((entity) => { entity.rotation = 0; }); }, ]; function migrateScene(data) { if (data.version === undefined) { alert( "This save was created before save versions were tracked. The scene may import incorrectly." ); console.trace(); data.version = 0; } else if (data.version < migrationDefs.length) { migrationDefs[data.version](data); data.version += 1; migrateScene(data); } } function importScene(data) { removeAllEntities(); migrateScene(data); data.entities.forEach((entityInfo) => { const entity = findEntity(entityInfo.name).constructor(); entity.name = entityInfo.customName; entity.scale = entityInfo.scale; entity.rotation = entityInfo.rotation; entity.priority = entityInfo.priority; entity.brightness = entityInfo.brightness; entity.form = entityInfo.form; displayEntity(entity, entityInfo.view, entityInfo.x, entityInfo.y); }); config.height = math.unit(data.world.height, data.world.unit); config.x = data.world.x; config.y = data.world.y; const height = math .unit(data.world.height, data.world.unit) .toNumber(defaultUnits.length[config.units]); document.querySelector("#options-height-value").value = height; document.querySelector("#options-height-unit").dataset.oldUnit = defaultUnits.length[config.units]; document.querySelector("#options-height-unit").value = defaultUnits.length[config.units]; if (data.canvasWidth) { doHorizReposition(data.canvasWidth / canvasWidth); } updateSizes(); } function renderToCanvas() { const ctx = document.querySelector("#display").getContext("2d"); Object.entries(entities) .sort((ent1, ent2) => { z1 = document.querySelector("#entity-" + ent1[0]).style.zIndex; z2 = document.querySelector("#entity-" + ent2[0]).style.zIndex; return z1 - z2; }) .forEach(([id, entity]) => { element = document.querySelector("#entity-" + id); img = element.querySelector("img"); let x = parseFloat(element.dataset.x); let y = parseFloat(element.dataset.y); let coords = pos2pix({ x: x, y: y }); let offset = img.style.getPropertyValue("--offset"); offset = parseFloat(offset.substring(0, offset.length - 1)); let xSize = img.width; let ySize = img.height; x = coords.x; y = coords.y + ySize / 2 + (ySize * offset) / 100; const oldFilter = ctx.filter; const brightness = getComputedStyle(element).getPropertyValue("--brightness"); ctx.filter = `brightness(${brightness})`; ctx.save(); ctx.resetTransform(); ctx.scale(window.devicePixelRatio, window.devicePixelRatio); ctx.translate(x, y); ctx.rotate(entity.rotation); if (Array.from(img.classList).includes("flipped")) { ctx.scale(-1, 1); } ctx.drawImage(img, -xSize / 2, -ySize / 2, xSize, ySize); ctx.restore(); ctx.filter = oldFilter; }); ctx.save(); ctx.resetTransform(); ctx.drawImage(document.querySelector("#rulers"), 0, 0); ctx.restore(); } function exportCanvas(callback) { /** @type {CanvasRenderingContext2D} */ const ctx = document.querySelector("#display").getContext("2d"); const blob = ctx.canvas.toBlob(callback); } function generateScreenshot(callback) { /** @type {CanvasRenderingContext2D} */ const ctx = document.querySelector("#display").getContext("2d"); if (config.groundKind !== "none") { ctx.fillStyle = backgroundColors[config.groundKind]; ctx.fillRect( 0, pos2pix({ x: 0, y: 0 }).y, canvasWidth + 100, canvasHeight ); } renderToCanvas(); ctx.resetTransform(); ctx.fillStyle = "#999"; ctx.font = "normal normal lighter 16pt coda"; ctx.fillText("macrovision.crux.sexy", 10, 25); exportCanvas((blob) => { callback(blob); }); } function copyScreenshot() { if (window.ClipboardItem === undefined) { alert( "Sorry, this browser doesn't yet support writing images to the clipboard." ); return; } generateScreenshot((blob) => { navigator.clipboard .write([ new ClipboardItem({ "image/png": blob, }), ]) .then((e) => toast("Copied to clipboard!")) .catch((e) => { console.error(e); toast( "Couldn't write to the clipboard. Make sure the screenshot completes before switching tabs. Also, currently busted in Safari :(" ); }); }); drawScales(false); } function saveScreenshot() { generateScreenshot((blob) => { const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.setAttribute("download", "macrovision.png"); a.click(); }); drawScales(false); } function openScreenshot() { generateScreenshot((blob) => { const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.setAttribute("target", "_blank"); a.click(); }); drawScales(false); } const rateLimits = {}; function toast(msg) { let div = document.createElement("div"); div.innerHTML = msg; div.classList.add("toast"); document.body.appendChild(div); setTimeout(() => { document.body.removeChild(div); }, 5000); } function toastRateLimit(msg, key, delay) { if (!rateLimits[key]) { toast(msg); rateLimits[key] = setTimeout(() => { delete rateLimits[key]; }, delay); } } let lastTime = undefined; function pan(fromX, fromY, fromHeight, toX, toY, toHeight, duration) { Object.keys(entities).forEach((key) => { document.querySelector("#entity-" + key).classList.add("no-transition"); }); config.x = fromX; config.y = fromY; config.height = math.unit(fromHeight, "meters"); updateSizes(); lastTime = undefined; requestAnimationFrame((timestamp) => panTo( toX, toY, toHeight, (toX - fromX) / duration, (toY - fromY) / duration, (toHeight - fromHeight) / duration, timestamp, duration ) ); } function panTo( x, y, height, xSpeed, ySpeed, heightSpeed, timestamp, remaining ) { if (lastTime === undefined) { lastTime = timestamp; } dt = timestamp - lastTime; remaining -= dt; if (remaining < 0) { dt += remaining; } let newX = config.x + xSpeed * dt; let newY = config.y + ySpeed * dt; let newHeight = config.height.toNumber("meters") + heightSpeed * dt; if (remaining > 0) { requestAnimationFrame((timestamp) => panTo( x, y, height, xSpeed, ySpeed, heightSpeed, timestamp, remaining ) ); } else { Object.keys(entities).forEach((key) => { document .querySelector("#entity-" + key) .classList.remove("no-transition"); }); } config.x = newX; config.y = newY; config.height = math.unit(newHeight, "meters"); updateSizes(); } function getVerticalOffset() { if (config.groundPos === "very-high") { return (config.height.toNumber("meters") / 12) * 5; } else if (config.groundPos === "high") { return (config.height.toNumber("meters") / 12) * 4; } else if (config.groundPos === "medium") { return (config.height.toNumber("meters") / 12) * 3; } else if (config.groundPos === "low") { return (config.height.toNumber("meters") / 12) * 2; } else if (config.groundPos === "very-low") { return config.height.toNumber("meters") / 12; } else { return 0; } } function moveGround(down) { const index = groundPosChoices.indexOf(config.groundPos); if (down) { if (index < groundPosChoices.length - 1) { config.groundPos = groundPosChoices[index + 1]; } } else { if (index > 0) { config.groundPos = groundPosChoices[index - 1]; } } updateScrollButtons(); updateSizes(); } function updateScrollButtons() { const up = document.querySelector("#scroll-up"); const down = document.querySelector("#scroll-down"); up.disabled = false; down.disabled = false; document.querySelector("#setting-ground-pos").value = config.groundPos; if (config.lockYAxis) { const index = groundPosChoices.indexOf(config.groundPos); if (index == 0) { down.disabled = true; } if (index == groundPosChoices.length - 1) { up.disabled = true; } } }