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