let selected = null; let prevSelected = null; let selectedEntity = null; let prevSelectedEntity = null; let entityIndex = 0; 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; const tagDefs = { "anthro": "Anthro", "feral": "Feral", "taur": "Taur", "naga": "Naga", "goo": "Goo" } 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("earths", { definition: "12756km", prefixes: "long" }); 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("footballFields", { definition: "57600 feet^2", prefixes: "long" }); math.createUnit("people", { definition: "75 liters", 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("peopleEaten", { definition: "125000 kcal", 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: "barn megaparsec", prefixes: "long" }) math.createUnit("firkins", { definition: "90 lb", prefixes: "long" }) math.createUnit("donkeySeconds", { definition: "250 joules", prefixes: "long" }) const defaultUnits = { length: { metric: "meters", customary: "feet", relative: "stories", quirky: "smoots" }, area: { metric: "meters^2", customary: "feet^2", relative: "footballFields", quirky: "nanoacres" }, volume: { metric: "liters", customary: "gallons", relative: "olympicPools", volume: "barnmegaparsecs" }, mass: { metric: "kilograms", customary: "lbs", relative: "peopleMass", quirky: "firkins" }, energy: { metric: "kJ", customary: "kcal", relative: "peopleEaten", quirky: "donkeySeconds" } } const unitChoices = { length: { "metric": [ "angstroms", "millimeters", "centimeters", "meters", "kilometers", ], "customary": [ "inches", "feet", "miles", ], "relative": [ "humans", "stories", "buses", "marathons", "earths", "lightseconds", "solarradii", "AUs", "lightyears", "parsecs", "galaxies", "universes", "multiverses" ], "quirky": [ "beardSeconds", "points", "smoots", "furlongs" ] }, area: { "metric": [ "cm^2", "meters^2", "kilometers^2", ], "customary": [ "feet^2", "acres", "miles^2" ], "relative": [ "footballFields" ], "quirky": [ "barns", "nanoacres" ] }, volume: { "metric": [ "milliliters", "liters", "m^3", ], "customary": [ "floz", "cups", "pints", "quarts", "gallons", ], "relative": [ "people", "olympicPools", "oceans", "earthVolumes", "universeVolumes", "multiverseVolumes", ], "quirky": [ "barnMegaparsecs" ] }, mass: { "metric": [ "kilograms", "milligrams", "grams", "tonnes", ], "customary": [ "lbs", "ounces", "tons" ], "relative": [ "peopleMass", "cars", "busMasses", "earthMass", "solarmasses" ], "quirky": [ "firkins" ] }, energy: { "metric": [ "kJ", "foodKilograms" ], "customary": [ "kcal", "foodPounds" ], "relative": [ "peopleEaten" ], "quirky": [ "donkeySeconds" ] } } const config = { height: math.unit(1500, "meters"), x: 0, y: 0, minLineSize: 100, maxLineSize: 150, autoFit: false, drawYAxis: true, drawXAxis: false, autoFoodIntake: false, autoPreyCapacity: false } 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) } } function snapPos(coords) { return constrainRel({ x: coords.x, y: (!config.lockYAxis || 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; 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 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"); } } function updateRatios() { if (config.showRatios) { if (selectedEntity !== null && prevSelectedEntity !== null && selectedEntity !== prevSelectedEntity) { let first = selectedEntity.currentView.height; let second = prevSelectedEntity.currentView.height; let text = "" 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; document.querySelector(".ratio-info").innerText = text; } else { document.querySelector(".ratio-info").innerText = ""; } } } function updateSizes(dirtyOnly = false) { updateRatios(); if (config.lockYAxis) { config.y = 0; } 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 drawRulers() { 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 => { ctx.save(); ctx.beginPath(); const start = pos2pix({x: rulerDef.x0, y: rulerDef.y0}); const end = pos2pix({x: rulerDef.x1, y: rulerDef.y1}); ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); ctx.lineWidth = 5; ctx.strokeStyle = "#f2f"; 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(rulerDef.y1 - rulerDef.y0, 2) + Math.pow(rulerDef.x1 - rulerDef.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); ctx.fillStyle = "#333"; 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; 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; if (y < 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"); } } // 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) { const oldStroke = ctx.strokeStyle; const oldFill = ctx.fillStyle; 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.font = oldFont; ctx.strokeStyle = oldStroke; ctx.fillStyle = oldFill; } 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) { const maker = {}; maker.name = info.name; maker.info = info; maker.sizes = sizes; maker.constructor = () => makeEntity(info, views, sizes); 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) { const entityTemplate = { name: info.name, identifier: info.name, scale: 1, info: JSON.parse(JSON.stringify(info)), views: JSON.parse(JSON.stringify(views), math.reviver), sizes: sizes === undefined ? [] : JSON.parse(JSON.stringify(sizes), math.reviver), init: function () { const entity = this; Object.entries(this.views).forEach(([viewKey, view]) => { view.parent = this; if (this.defaultView === undefined) { this.defaultView = viewKey; this.view = viewKey; } if (view.default) { this.defaultView = viewKey; this.view = viewKey; } // to remember the units the user last picked view.units = {}; if (config.autoFoodIntake && view.attributes.weight !== undefined && view.attributes.energyNeed === undefined) { view.attributes.energyNeed = { name: "Food Intake", power: 3, type: "energy", base: math.unit(2000 * view.attributes.weight.base.toNumber("lbs") / 150, "kcal") } } if (config.autoPreyCapacity !== "none" && view.attributes.weight !== undefined && view.attributes.capacity === undefined) { view.attributes.capacity = { name: "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) { 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]; } } ) 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.body.clientHeight / 10; } 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 (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"; 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]; updateRatios(); document.getElementById("options-selected-entity-" + target.dataset.key).selected = "selected"; if (prevSelected !== null && config.showRatios && selected !== prevSelected) { prevSelected.classList.add("prevSelected"); } selected.classList.add("selected"); displayAttribution(selectedEntity.views[selectedEntity.view].image.source); 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 configViewList(entity, selectedView) { const list = document.querySelector("#entity-view"); list.innerHTML = ""; list.style.display = "block"; Object.keys(entity.views).forEach(view => { 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 => { entity.scale = e.target.value == 0 ? 1 : 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(); }) scaleInput.setAttribute("min", 1); scaleInput.setAttribute("type", "number"); 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); const defaultHolder = document.querySelector("#options-entity-defaults"); defaultHolder.innerHTML = ""; entity.sizes.forEach(defaultInfo => { const button = document.createElement("button"); button.classList.add("options-button"); button.innerText = defaultInfo.name; button.addEventListener("click", e => { 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); }); document.querySelector("#options-order-display").innerText = entity.priority; document.querySelector("#options-brightness-display").innerText = entity.brightness; document.querySelector("#options-ordering").style.display = "flex"; } 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"; input.setAttribute("type", "number"); input.setAttribute("min", 1); 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 value = input.value == 0 ? 1 : input.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"; const testCtx = testCanvas.getContext("2d"); function testClick(event) { // oh my god I can't believe I'm doing this const target = event.target; 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; } const ratio = ratioW * ratioH; var x = event.clientX - target.getBoundingClientRect().x, y = event.clientY - target.getBoundingClientRect().y, alpha; testCtx.canvas.width = w; testCtx.canvas.height = h; // Draw image to canvas // and read Alpha channel value testCtx.drawImage(target, 0, 0, w, h); alpha = testCtx.getImageData(Math.floor(x / ratio), Math.floor(y / ratio), 1, 1).data[3]; // [0]R [1]G [2]B [3]A // If 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); } } 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; 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; ownerEntry.appendChild(link); } else { const div = document.createElement("div"); div.innerText = owner.name; ownerEntry.appendChild(div); } list.appendChild(ownerEntry); }); } 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; 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; 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); } function checkEntity(entity) { Object.values(entity.views).forEach(view => { if (authorsOf(view.image.source) === undefined) { console.warn("No authors: " + view.image.source); } }); } function displayEntity(entity, view, x, y, selectEntity = false, refresh = false) { checkEntity(entity); // preload all of the entity's views Object.values(entity.views).forEach(view => { if (!preloaded.has(view.image.source)) { let img = new Image(); img.src = view.image.source; preloaded.add(view.image.source); } }); 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) + "%") } 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: "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 settingsData = { "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; if (param) { config.y = 0; updateSizes(); document.querySelector("#scroll-up").disabled = true; document.querySelector("#scroll-down").disabled = true; } else { document.querySelector("#scroll-up").disabled = false; document.querySelector("#scroll-down").disabled = false; } } }, "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(); } }, "show-vertical-scale": { name: "Show 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-altitudes": { name: "Show Altitudes", desc: "Draw interesting altitudes", type: "select", default: "none", options: [ "none", "all", "atmosphere", "orbits", "weather", "water", "geology", "thicknesses" ], get value() { return config.drawAltitudes; }, set value(param) { config.drawAltitudes = param; drawScales(false); } }, "show-horizontal-scale": { name: "Show Horiziontal Scale", desc: "Draw horizontal scale marks", type: "toggle", default: false, get value() { return config.drawXAxis; }, set value(param) { config.drawXAxis = param; drawScales(false); } }, "zoom-when-adding": { name: "Zoom When Adding", desc: "Zoom to fit when you add a new entity", type: "toggle", default: true, get value() { return config.autoFitAdd; }, set value(param) { config.autoFitAdd = param; } }, "zoom-when-sizing": { name: "Zoom When Sizing", desc: "Zoom to fit when you select an entity's size", type: "toggle", default: true, 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: true, get value() { return config.showRatios; }, set value(param) { config.showRatios = param; if (param) { document.body.querySelector(".ratio-info").style.display = "block"; } else { document.body.querySelector(".ratio-info").style.display = "none"; } } }, "units": { name: "Default Units", desc: "Which kind of unit to use by default", type: "select", default: "metric", options: [ "metric", "customary", "relative", "quirky" ], 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); } }, "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); } }, "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); } }, "solid-ground": { name: "Solid Ground", desc: "Draw solid ground at the y=0 line", type: "toggle", default: true, get value() { return checkBodyClass("toggle-bottom-cover"); }, set value(param) { toggleBodyClass("toggle-bottom-cover", 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-prey-capacity": { name: "Estimate Prey Capacity", desc: "Guess how much prey creatures can hold, based on their mass", type: "select", default: "none", options: [ "none", "realistic", "same-size" ], get value() { return config.autoPreyCapacity; }, set value(param) { config.autoPreyCapacity = param; } }, } function getBoundingBox(entities, margin = 0.05) { } 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 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); holder.appendChild(name); holder.appendChild(input); holder.appendChild(desc); 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; } 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]; holder.appendChild(name); holder.appendChild(select); holder.appendChild(desc); menubar.appendChild(holder); holder.classList.add("enabled"); const update = () => { entry.value = select.value; } 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() { 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"); const worldHeight = config.height.toNumber("meters"); if (ownHeight > worldHeight) { setWorldHeight(config.height, entity.views[entity.view].height) } else if (ownHeight * 10 < worldHeight) { setWorldHeight(config.height, math.multiply(entity.views[entity.view].height, 10)); } } } document.addEventListener("DOMContentLoaded", () => { prepareMenu(); prepareEntities(); document.querySelector("#open-help").addEventListener("click", e => { setHelpDate(); document.querySelector("#open-help").classList.remove("highlighted"); window.open("https://www.notion.so/Macrovision-5c7f9377424743358ddf6db5671f439e", "_blank"); }); document.querySelector("#copy-screenshot").addEventListener("click", e => { copyScreenshot(); toast("Copied to clipboard!"); }); 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"; } e.stopPropagation(); }); document.querySelector("#sidebar-menu").addEventListener("click", e => { e.stopPropagation(); }); document.addEventListener("click", 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"; } e.stopPropagation(); }); document.querySelector("#settings-menu").addEventListener("click", e => { e.stopPropagation(); }); document.addEventListener("click", e => { document.querySelector("#settings-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 = Math.max(entities[selected.dataset.key].brightness -1, 0); } document.querySelector("#options-brightness-display").innerText = entities[selected.dataset.key].brightness; 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("#scene-choices"); Object.entries(scenes).forEach(([id, scene]) => { const option = document.createElement("option"); option.innerText = id; option.value = id; sceneChoices.appendChild(option); }); document.querySelector("#load-scene").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", e => { checkFitWorld(); const scaleInput = document.querySelector("#options-height-value"); const newVal = math.unit(scaleInput.value, unitSelector.dataset.oldUnit).toNumber(e.target.value); setNumericInput(scaleInput, newVal); updateWorldHeight(); unitSelector.dataset.oldUnit = unitSelector.value; }); 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; const pos = pix2pos({ x: e.clientX - entX, y: e.clientY - entY }); currentRuler = { x0: pos.x, y0: pos.y, x1: pos.y, y1: pos.y }; } }); 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; const pos = pix2pos({ x: e.touches[0].clientX - entX, y: e.touches[0].clientY - entY }); currentRuler = { x0: pos.x, y0: pos.y, x1: pos.y, y1: pos.y }; } }); 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 viewList = document.querySelector("#entity-view"); document.querySelector("#entity-view").addEventListener("input", e => { const entity = entities[selected.dataset.key]; entity.view = e.target.value; 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 => { scrollDirection = 1; clearInterval(scrollHandle); scrollHandle = setInterval(doYScroll, 1000 / 20); e.stopPropagation(); }); document.querySelector("#scroll-down").addEventListener("mousedown", e => { scrollDirection = -1; clearInterval(scrollHandle); scrollHandle = setInterval(doYScroll, 1000 / 20); e.stopPropagation(); }); document.querySelector("#scroll-up").addEventListener("touchstart", e => { scrollDirection = 1; clearInterval(scrollHandle); scrollHandle = setInterval(doYScroll, 1000 / 20); e.stopPropagation(); }); document.querySelector("#scroll-down").addEventListener("touchstart", e => { 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") { 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 { 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 => { if (shiftHeld) { if (selected) { const dir = e.deltaY > 0 ? 10 / 11 : 11 / 10; 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 { const dir = e.deltaY < 0 ? 10 / 11 : 11 / 10; 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(); }) updateWorldHeight(); }); 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 = { none: { id: "none", name: "No Filter", extract: maker => [], render: name => name, sort: (tag1, tag2) => tag1[1].localeCompare(tag2[1]) }, 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 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["dildos"] = makeDildos(); 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.toLowerCase() < y.name.toLowerCase() ? -1 : 1 }); availableEntities["species"].sort((x, y) => { return x.name.toLowerCase() < y.name.toLowerCase() ? -1 : 1 }); const holder = document.querySelector("#spawners"); const filterHolder = document.querySelector("#filters"); const categorySelect = document.createElement("select"); categorySelect.id = "category-picker"; const filterSelect = document.createElement("select"); filterSelect.id = "filter-picker"; holder.appendChild(categorySelect); filterHolder.appendChild(filterSelect); const filterSets = {}; Object.values(filterDefs).forEach(filter => { filterSets[filter.id] = new Set(); }) 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(); 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 => { const newEntity = entityList[select.value].constructor() displayEntity(newEntity, newEntity.defaultView, config.x, config.y + (config.lockYAxis ? 0 : config.height.toNumber("meters")/2), 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 option = document.createElement("option"); option.innerText = filter.name; option.value = filter.id; filterSelect.appendChild(option); const filterNameSelect = document.createElement("select"); filterNameSelect.classList.add("filter-select"); filterNameSelect.id = "filter-" + filter.id; filterHolder.appendChild(filterNameSelect); const button = document.createElement("button"); button.classList.add("filter-button"); button.id = "create-filtered-" + filter.id + "-button"; filterHolder.appendChild(button); const counter = document.createElement("div"); counter.classList.add("button-counter"); counter.innerText = "10"; button.appendChild(counter); const i = document.createElement("i"); i.classList.add("fas"); i.classList.add("fa-plus"); button.appendChild(i); button.addEventListener("click", e => { 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); } }); 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(); }); }); 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(); filterSelect.addEventListener("input", e => { const oldSelect = document.querySelector(".filter-select.category-visible"); if (oldSelect) oldSelect.classList.remove("category-visible"); const newSelect = document.querySelector("#filter-" + e.target.value); if (newSelect && e.target.value != "none") newSelect.classList.add("category-visible"); updateFilter(); }); } // 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(); }); document.querySelectorAll(".entity-select.category-visible > option").forEach(element => { const entity = availableEntities[category][element.value]; Object.values(filterDefs).forEach(filter => { filter.extract(entity).forEach(result => { filterSets[filter.id].add(result); }); }); }); Object.values(filterDefs).forEach(filter => { // always show the "none" option let found = filter.id == "none"; document.querySelectorAll("#filter-" + filter.id + " > 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; } }); const filterOption = document.querySelector("#filter-picker > option[value='" + filter.id + "']"); if (found) { filterOption.classList.remove("filtered"); filterOption.disabled = false; } else { filterOption.classList.add("filtered"); filterOption.disabled = true; } }); document.querySelector("#filter-picker").value = "none"; document.querySelector("#filter-picker").dispatchEvent(new Event("input")); } function updateFilter() { const category = document.querySelector("#category-picker").value; const type = document.querySelector("#filter-picker").value; const filterKeySelect = document.querySelector(".filter-select.category-visible"); clearFilter(); if (!filterKeySelect) { return; } const key = filterKeySelect.value; let current = document.querySelector(".entity-select.category-visible").value; let replace = false; let first = null; let count = 0; document.querySelectorAll(".entity-select.category-visible > option").forEach(element => { let keep = type == "none"; if (filterDefs[type].extract(availableEntities[category][element.value]).indexOf(key) >= 0) { keep = true; } 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(".filter-select.category-visible + button"); if (button) { button.querySelector(".button-counter").innerText = count; } 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; }); } 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 }); 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 }); 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 }); 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)); } // TODO why am I doing this function updateWorldHeight() { const unit = document.querySelector("#options-height-unit").value; const value = Math.max(0.000000001, document.querySelector("#options-height-value").value); const oldHeight = config.height; setWorldHeight(oldHeight, math.unit(value, unit)); } function setWorldHeight(oldHeight, newHeight) { 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; }); 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, view: entity.view, 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 + "?scene=" + 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 }); } ] 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.priority = entityInfo.priority; entity.brightness = entityInfo.brightness; 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)) x = coords.x - img.getBoundingClientRect().width/2; y = coords.y - img.getBoundingClientRect().height * (-offset/100); let xSize = img.getBoundingClientRect().width; let ySize = img.getBoundingClientRect().height; const oldFilter = ctx.filter const brightness = getComputedStyle(element).getPropertyValue("--brightness") ctx.filter = `brightness(${brightness})`; ctx.drawImage(img, x, y, xSize, ySize); ctx.drawImage(document.querySelector("#rulers"), 0, 0); ctx.filter = oldFilter }); } 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 (checkBodyClass("toggle-bottom-cover")) { ctx.fillStyle = "#000"; ctx.fillRect(0, pos2pix({x: 0, y: 0}).y, canvasWidth + 100, canvasHeight); } renderToCanvas(); ctx.fillStyle = "#777"; 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 }) ]); }); 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; console.log(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; console.log(newX); 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(); }