(function (global, factory) {
  typeof exports === "object" && typeof module !== "undefined" ? (module.exports = factory()) : typeof define === "function" && define.amd ? define(factory) : (global.ThreeD = factory());
})(this, function () {
  "use strict";

  // set default options
  const options = {scale: 50, lightness: 0.7, shadow: 0.5, sun: {x: 100, y: 600, z: 1000}, rotateMesh: 0, rotateGlobe: 0.5, skyColor: "#9ecef5", waterColor: "#466eab", extendedWater: 0, labels3d: 0, resolution: 2};

  // set variables
  let Renderer, scene, camera, controls, animationFrame, material, texture, geometry, mesh, ambientLight, spotLight, waterPlane, waterMaterial, waterMesh, raycaster;

  const drawCtx = document.createElement("canvas").getContext("2d");
  const drawSVG = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  document.body.appendChild(drawSVG);

  let labels = [];
  let icons = [];
  let lines = [];

  // initiate 3d scene
  const create = async function (canvas, type = "viewMesh") {
    options.isOn = true;
    options.isGlobe = type === "viewGlobe";
    return options.isGlobe ? newGlobe(canvas) : newMesh(canvas);
  };

  // redraw 3d scene
  const redraw = function () {
    deleteLabels();
    scene.remove(mesh);
    Renderer.setSize(Renderer.domElement.width, Renderer.domElement.height);
    if (options.isGlobe) updateGlobeTexure();
    else createMesh(graphWidth, graphHeight, grid.cellsX, grid.cellsY);
    render();
  };

  // update 3d texture
  const update = function () {
    if (options.isGlobe) updateGlobeTexure();
    else update3dTexture();
  };

  // try to clean the memory as much as possible
  const stop = function () {
    cancelAnimationFrame(animationFrame);
    texture.dispose();
    geometry.dispose();
    material.dispose();
    if (waterPlane) waterPlane.dispose();
    if (waterMaterial) waterMaterial.dispose();
    deleteLabels();

    Renderer.renderLists.dispose();
    Renderer.dispose();
    scene.remove(mesh);
    scene.remove(spotLight);
    scene.remove(ambientLight);
    scene.remove(waterMesh);

    Renderer = undefined;
    scene = undefined;
    controls = undefined;
    camera = undefined;
    material = undefined;
    texture = undefined;
    geometry = undefined;
    mesh = undefined;

    ThreeD.options.isOn = false;
  };

  const setScale = function (scale) {
    options.scale = scale;
    geometry.vertices.forEach((v, i) => (v.z = getMeshHeight(i)));
    geometry.verticesNeedUpdate = true;
    geometry.computeVertexNormals();
    geometry.verticesNeedUpdate = false;

    redraw();
  };

  const setLightness = function (intensity) {
    options.lightness = intensity;
    ambientLight.intensity = intensity;
    render();
  };

  const setSun = function (x, y, z) {
    options.sun = {x, y, z};
    spotLight.position.set(x, y, z);
    render();
  };

  const setRotation = function (speed) {
    if (options.isGlobe) options.rotateGlobe = speed;
    else options.rotateMesh = speed;
    controls.autoRotateSpeed = speed;

    const startAnimation = !controls.autoRotate && Boolean(speed);
    const endAnimation = controls.autoRotate && !Boolean(speed);

    controls.autoRotate = Boolean(speed);

    if (startAnimation) animate();
    if (endAnimation) cancelAnimationFrame(animationFrame);
  };

  const toggleSky = function () {
    if (options.extendedWater) {
      scene.background = null;
      scene.fog = null;
      scene.remove(waterMesh);
    } else extendWater(graphWidth, graphHeight);

    options.extendedWater = !options.extendedWater;
    redraw();
  };

  const toggleLabels = function () {
    options.labels3d = !options.labels3d;

    if (options.labels3d) {
      createLabels().then(() => update());
    } else {
      deleteLabels();
      update();
    }
  };

  const setColors = function (sky, water) {
    options.skyColor = sky;
    scene.background = scene.fog.color = new THREE.Color(sky);
    options.waterColor = water;
    waterMaterial.color = new THREE.Color(water);
    render();
  };

  const setResolution = function (resolution) {
    options.resolution = resolution;
    update();
  };

  // download screenshot
  const saveScreenshot = async function () {
    const URL = Renderer.domElement.toDataURL("image/jpeg");
    const link = document.createElement("a");
    link.download = getFileName() + ".jpeg";
    link.href = URL;
    link.click();
    tip(`Screenshot is saved. Open "Downloads" screen (CTRL + J) to check`, true, "success", 7000);
    window.setTimeout(() => window.URL.revokeObjectURL(URL), 5000);
  };

  const saveOBJ = async function () {
    downloadFile(await getOBJ(), getFileName() + ".obj", "text/plain;charset=UTF-8");
  };

  // start 3d view and heightmap edit preview
  async function newMesh(canvas) {
    const loaded = await loadTHREE();
    if (!loaded) {
      tip("Cannot load 3d library", false, "error", 4000);
      return false;
    }

    scene = new THREE.Scene();

    // light
    ambientLight = new THREE.AmbientLight(0xcccccc, options.lightness);
    scene.add(ambientLight);
    spotLight = new THREE.SpotLight(0xcccccc, 0.8, 2000, 0.8, 0, 0);
    spotLight.position.set(options.sun.x, options.sun.y, options.sun.z);
    spotLight.castShadow = true;
    scene.add(spotLight);
    //scene.add(new THREE.SpotLightHelper(spotLight));

    // Rendered
    Renderer = new THREE.WebGLRenderer({canvas, antialias: true, preserveDrawingBuffer: true});
    Renderer.setSize(canvas.width, canvas.height);
    Renderer.shadowMap.enabled = true;
    if (options.extendedWater) extendWater(graphWidth, graphHeight);
    createMesh(graphWidth, graphHeight, grid.cellsX, grid.cellsY);

    // camera
    camera = new THREE.PerspectiveCamera(70, canvas.width / canvas.height, 0.1, 2000);
    camera.position.set(0, rn(svgWidth / 3.5), 500);

    // controls
    controls = await OrbitControls(camera, canvas);
    controls.enableKeys = false;
    controls.minDistance = 10;
    controls.maxDistance = 1000;
    controls.maxPolarAngle = Math.PI / 2;
    controls.autoRotate = Boolean(options.rotateMesh);
    controls.autoRotateSpeed = options.rotateMesh;
    if (controls.autoRotate) animate();

    controls.addEventListener("change", render);

    return true;
  }

  function textureToSprite(texture, width, height) {
    const map = new THREE.TextureLoader().load(texture);
    map.anisotropy = Renderer.getMaxAnisotropy();
    const material = new THREE.SpriteMaterial({map});

    const sprite = new THREE.Sprite(material);
    sprite.scale.set(width, height, 1);
    sprite.renderOrder = 1;
    return sprite;
  }

  async function createTextLabel({text, font, size, color, quality}) {
    drawCtx.font = `${size * quality}px ${font}`;
    drawCtx.canvas.width = drawCtx.measureText(text).width;
    drawCtx.canvas.height = size * quality * 1.25; // 25% margin as text can overflow the font size
    drawCtx.clearRect(0, 0, drawCtx.canvas.width, drawCtx.canvas.height);

    drawCtx.font = `${size * quality}px ${font}`;
    drawCtx.fillStyle = color;
    drawCtx.fillText(text, 0, size * quality);

    return textureToSprite(drawCtx.canvas.toDataURL(), drawCtx.canvas.width / quality, drawCtx.canvas.height / quality);
  }

  function get3dCoords(baseX, baseY) {
    const x = baseX - graphWidth / 2;
    const z = baseY - graphHeight / 2;

    raycaster.ray.origin.x = x;
    raycaster.ray.origin.z = z;
    const y = raycaster.intersectObject(mesh)[0].point.y;
    return [x, y, z];
  }

  async function createLabels() {
    raycaster = new THREE.Raycaster();
    raycaster.set(new THREE.Vector3(0, 1000, 0), new THREE.Vector3(0, -1, 0));

    const states = viewbox.select("#labels #states");
    const cities = burgLabels.select("#cities");
    const towns = burgLabels.select("#towns");
    const city_icons = burgIcons.select("#cities");
    const town_icons = burgIcons.select("#towns");

    const stateOptions = {
      font: states.attr("font-family"),
      size: +states.attr("data-size"),
      color: states.attr("fill"),
      elevation: 20,
      quality: 20
    };

    const cityOptions = {
      font: cities.attr("font-family"),
      size: +cities.attr("data-size"),
      color: cities.attr("fill"),
      elevation: 10,
      quality: 20,
      iconSize: 1,
      iconColor: "#666",
      line: 10 - cities.attr("data-size") / 2
    };

    const townOptions = {
      font: towns.attr("font-family"),
      size: +towns.attr("data-size"),
      color: towns.attr("fill"),
      elevation: 5,
      quality: 30,
      iconSize: 0.5,
      iconColor: "#666",
      line: 5 - towns.attr("data-size") / 2
    };

    const city_icon_material = new THREE.MeshPhongMaterial({color: cityOptions.iconColor});
    const town_icon_material = new THREE.MeshPhongMaterial({color: townOptions.iconColor});
    const city_icon_geometry = new THREE.CylinderGeometry(cityOptions.iconSize * 2, cityOptions.iconSize * 2, cityOptions.iconSize, 16, 1);
    const town_icon_geometry = new THREE.CylinderGeometry(townOptions.iconSize * 2, townOptions.iconSize * 2, townOptions.iconSize, 16, 1);
    const line_material = new THREE.LineBasicMaterial({color: cityOptions.iconColor});

    // burg labels
    for (let i = 1; i < pack.burgs.length; i++) {
      const burg = pack.burgs[i];
      if (burg.removed) continue;

      const isCity = burg.capital;
      const [x, y, z] = get3dCoords(burg.x, burg.y);
      const options = isCity ? cityOptions : townOptions;

      if (layerIsOn("toggleLabels")) {
        const burgSprite = await createTextLabel({text: burg.name, ...options});

        burgSprite.position.set(x, y + options.elevation, z);
        burgSprite.size = options.size;

        labels.push(burgSprite);
        scene.add(burgSprite);
      }

      // icons
      if (layerIsOn("toggleIcons")) {
        const geometry = isCity ? city_icon_geometry : town_icon_geometry;
        const material = isCity ? city_icon_material : town_icon_material;
        const iconMesh = new THREE.Mesh(geometry, material);
        iconMesh.position.set(x, y, z);

        icons.push(iconMesh);
        scene.add(iconMesh);

        const points = [new THREE.Vector3(x, y, z), new THREE.Vector3(x, y + options.line, z)];
        const line_geometry = new THREE.BufferGeometry().setFromPoints(points);
        const line = new THREE.Line(line_geometry, line_material);

        lines.push(line);
        scene.add(line);
      }
    }

    // state labels
    if (layerIsOn("toggleLabels")) {
      for (let i = 1; i < pack.states.length; i++) {
        const state = pack.states[i];
        if (state.removed) continue;

        const [x, y, z] = get3dCoords(state.pole[0], state.pole[1]);
        const text = states.select("#stateLabel" + state.i)?.text() || state.name;
        const stateSprite = await createTextLabel({text, ...stateOptions});

        stateSprite.position.set(x, y + stateOptions.elevation, z);
        stateSprite.size = stateOptions.size;

        labels.push(stateSprite);
        scene.add(stateSprite);
      }
    }

    // apply visibility setting
    doWorkOnRender();
  }

  function deleteLabels() {
    raycaster = undefined;

    for (const mesh of labels) {
      scene.remove(mesh);
      mesh.material.map.dispose();
      mesh.material.dispose();
      mesh.geometry.dispose();
    }
    labels = [];

    for (const mesh of icons) {
      scene.remove(mesh);
      mesh.material.dispose();
      mesh.geometry.dispose();
    }
    icons = [];

    for (const line of lines) {
      scene.remove(line);
      line.material.dispose();
      line.geometry.dispose();
    }
    lines = [];
  }

  // create a mesh from pixel data
  async function createMesh(width, height, segmentsX, segmentsY) {
    const mapOptions = {
      noLabels: options.labels3d,
      noWater: options.extendedWater
    };
    const url = await getMapURL("mesh", mapOptions);
    window.setTimeout(() => window.URL.revokeObjectURL(url), 5000);

    if (texture) texture.dispose();
    texture = new THREE.TextureLoader().load(url, render);
    texture.needsUpdate = true;

    if (material) material.dispose();
    material = new THREE.MeshLambertMaterial();
    material.map = texture;
    material.transparent = true;

    if (geometry) geometry.dispose();
    geometry = new THREE.PlaneGeometry(width, height, segmentsX - 1, segmentsY - 1);
    geometry.vertices.forEach((v, i) => (v.z = getMeshHeight(i)));
    geometry.computeVertexNormals();

    if (mesh) scene.remove(mesh);
    mesh = new THREE.Mesh(geometry, material);
    mesh.rotation.x = -Math.PI / 2;
    mesh.castShadow = true;
    mesh.receiveShadow = true;
    scene.add(mesh);

    if (options.labels3d) {
      render();
      await createLabels();
    }
  }

  function getMeshHeight(i) {
    const h = grid.cells.h[i];
    return h < 20 ? 0 : ((h - 18) / 82) * options.scale;
  }

  function extendWater(width, height) {
    scene.background = new THREE.Color(options.skyColor);

    waterPlane = new THREE.PlaneGeometry(width * 10, height * 10, 1);
    waterMaterial = new THREE.MeshBasicMaterial({color: options.waterColor});
    scene.fog = new THREE.Fog(scene.background, 500, 3000);

    waterMesh = new THREE.Mesh(waterPlane, waterMaterial);
    waterMesh.rotation.x = -Math.PI / 2;
    waterMesh.position.y -= 3;
    scene.add(waterMesh);
  }

  async function update3dTexture() {
    if (texture) texture.dispose();
    const mapOptions = {
      noLabels: options.labels3d,
      noWater: options.extendedWater
    };
    const url = await getMapURL("mesh", mapOptions);
    window.setTimeout(() => window.URL.revokeObjectURL(url), 4000);
    texture = new THREE.TextureLoader().load(url, render);
    material.map = texture;
  }

  async function newGlobe(canvas) {
    const loaded = await loadTHREE();
    if (!loaded) {
      tip("Cannot load 3d library", false, "error", 4000);
      return false;
    }

    // scene
    scene = new THREE.Scene();
    scene.background = new THREE.TextureLoader().load("https://i0.wp.com/azgaar.files.wordpress.com/2019/10/stars-1.png", render);

    // Renderer
    Renderer = new THREE.WebGLRenderer({canvas, antialias: true, preserveDrawingBuffer: true});
    Renderer.setSize(canvas.width, canvas.height);

    // material
    if (material) material.dispose();
    material = new THREE.MeshBasicMaterial();
    updateGlobeTexure(true);

    // camera
    camera = new THREE.PerspectiveCamera(45, canvas.width / canvas.height, 0.1, 1000).translateZ(5);

    // controls
    controls = await OrbitControls(camera, Renderer.domElement);
    controls.enableKeys = false;
    controls.minDistance = 1.8;
    controls.maxDistance = 10;
    controls.autoRotate = Boolean(options.rotateGlobe);
    controls.autoRotateSpeed = options.rotateGlobe;
    controls.addEventListener("change", render);

    return true;
  }

  async function updateGlobeTexure(addMesh) {
    const world = mapCoordinates.latT > 179; // define if map covers whole world

    // texture size
    const scale = options.resolution;
    const height = 512 * scale;
    const width = 1024 * scale;

    // calculate map size and offset position
    const mapHeight = rn((mapCoordinates.latT / 180) * height);
    const mapWidth = world ? mapHeight * 2 : rn((graphWidth / graphHeight) * mapHeight);
    const dy = world ? 0 : ((90 - mapCoordinates.latN) / 180) * height;
    const dx = world ? 0 : mapWidth / 4;

    // draw map on canvas
    const ctx = document.createElement("canvas").getContext("2d");
    ctx.canvas.width = width;
    ctx.canvas.height = height;

    // add cloud texture if map does not cover all the globe
    if (!world) {
      const img = new Image();
      img.onload = function () {
        ctx.drawImage(img, 0, 0, width, height);
      };
      img.src =
        "data:image/png;base64,[encoded data, 30756 characters]";
    }

    // fill canvas segment with map texture
    const img2 = new Image();
    img2.onload = function () {
      ctx.drawImage(img2, dx, dy, mapWidth, mapHeight);
      if (texture) texture.dispose();
      texture = new THREE.CanvasTexture(ctx.canvas, render);
      material.map = texture;
      if (addMesh) addGlobe3dMesh();
    };
    img2.src = await getMapURL("mesh", {globe: true});
  }

  async function getOBJ() {
    const objexporter = await OBJExporter();
    const data = await objexporter.parse(mesh);
    return data;
  }

  function addGlobe3dMesh() {
    geometry = new THREE.SphereBufferGeometry(1, 64, 64);
    mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    if (controls.autoRotate) animate();
    else render();
  }

  // render 3d scene and camera, do only on controls change
  const renderThrottled = throttle(doWorkOnRender, 200);
  function render() {
    Renderer.render(scene, camera);
    renderThrottled();
  }

  function doWorkOnRender() {
    for (const [i, label] of labels.entries()) {
      const dist = label.position.distanceTo(camera.position);
      const isVisible = dist < 100 * label.size && dist > label.size * 6;
      label.visible = isVisible;
      if (lines[i]) lines[i].visible = isVisible;
    }
  }

  // animate 3d scene and camera
  function animate() {
    animationFrame = requestAnimationFrame(animate);
    controls.update();
  }

  function loadTHREE() {
    if (window.THREE) return Promise.resolve(true);

    return new Promise(resolve => {
      const script = document.createElement("script");
      script.src = "libs/three.min.js";
      document.head.append(script);
      script.onload = () => resolve(true);
      script.onerror = () => resolve(false);
    });
  }

  function OrbitControls(camera, domElement) {
    if (THREE.OrbitControls) return new THREE.OrbitControls(camera, domElement);

    return new Promise(resolve => {
      const script = document.createElement("script");
      script.src = "libs/orbitControls.min.js";
      document.head.append(script);
      script.onload = () => resolve(new THREE.OrbitControls(camera, domElement));
      script.onerror = () => resolve(false);
    });
  }

  function OBJExporter() {
    if (THREE.OBJExporter) return new THREE.OBJExporter();

    return new Promise(resolve => {
      const script = document.createElement("script");
      script.src = "libs/objexporter.min.js";
      document.head.append(script);
      script.onload = () => resolve(new THREE.OBJExporter());
      script.onerror = () => resolve(false);
    });
  }

  return {create, redraw, update, stop, options, setScale, setLightness, setSun, setRotation, toggleLabels, toggleSky, setResolution, setColors, saveScreenshot, saveOBJ};
});
