Wplace chunk downloader

Easily download chunk images from wplace.live using multi-point selection and highlighting

// ==UserScript==
// @name         Wplace chunk downloader
// @namespace    http://tampermonkey.net/
// @version      2.1.1
// @description  Easily download chunk images from wplace.live using multi-point selection and highlighting
// @author       NotNotWaldo
// @match        https://wplace.live/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=wplace.live
// @license      MIT
// @run-at       document-end
// @grant        none
// ==/UserScript==

// Code below, feel free to read in horror

(() => {
  // Global vals because I'm way too lazy
  // These variables are for handling the highlighting feature
  let isHightlightOn = false;
  let downloadingState = false;
  // the downloading state ensures that the highlight wont be also printed when downloading the images of chunk

  // variables for the currently selected chunks
  let chunkX = null;
  let chunkY = null;
  let chunkUrl = null;

  // for the dragging mechanic
  let isPointing = false;

  // just a template for chunk img
  const chunkTemplateUrl = `https://backend.wplace.live/files/s0/tiles/`;

  // array for highlighting chunks
  let highlightedChunksLinksArr = [];

  // variables for the download bar
  let currImgsDownloaded = null;
  let totalImgsToBeDownloaded = null;

  // the coords of chunks that are selected by the points you've set
  let mlChunkCoords = {
    firstChunk: { x: null, y: null },
    secondChunk: { x: null, y: null },
  };

  let multipleChunksDownloaderElem = document.createElement("div");
  multipleChunksDownloaderElem.className = "mulChunksDownloader";

  multipleChunksDownloaderElem.innerHTML = `
  <div class="chunk-downloader">

    <!-- Multiple Chunk Downloader -->

    <div class="mainHead section-header">
      <span>Wplace chunks downloader</span>
      <button class="collapse-btn">–</button>
    </div>

    <div class="mainCollapsible expanded">

      <div class="infoSection section">
        <div class="section-header coords">
          <span class="chunkSelectedInfo">Chunk selected: X: null, Y: null</span>
        </div>
        <div class="btn-row">
            <button class="downloadChunkBtn btn btn-primary">Download chunk</button>
            <button class="viewChunkBtn btn">View chunk image</button>
          </div>
      </div>

      <div class="mulChunkSection section">
        <div class="section-header">
          <span>Multiple chunks downloader</span>
          <button class="collapse-btn">+</button>
        </div>

        <div class="collapsible collapsed">
          <div class="coords">
            <span>1st X: null, Y: null</span>
            <span>2nd X: null, Y: null</span>
          </div>

          <div class="btn-row">
            <button class="setPointBtn btn btn-primary">Set point</button>
            <button class="removePointBtn btn">Remove points</button>
          </div>

          <div class="btn-row">
            <button class="downloadBtn btn btn-primary">Download chunks</button>
            <button class="highlightBtn btn">Highlight chunks</button>
          </div>
        </div>
      </div>

      <!-- Manual Chunk Download -->
      <div class="manualChunkSection section">
        <div class="section-header">
          <span>Manual chunk downloader</span>
          <button class="collapse-btn">+</button>
        </div>

        <div class="collapsible collapsed">
          <input class="coordsInput input-box" type="text" name="chunksCoords" placeholder="firstX, firstY, secX, secY, safety">
          </input>

          <div class="btn-row" style="grid-template-columns: 1fr;">
            <button class="manualDownloadBtn btn btn-primary">Download</button>
          </div>
        </div>
      </div>

      <div class="downloadBarCon">
        <div class="download-bar">
          <div class="download-progress"></div>
          <span class="download-text">0 / 0</span>
        </div>
      </div>
    </div>
  </div>
`;

  let style = document.createElement("style");
  style.textContent = `
.mulChunksDownloader {
  position: fixed;
  top: auto;
  bottom: 12px;
  left: 12px;
  z-index: 49;
}

.mulChunksDownloader .chunk-downloader {
  background: #fff;
  border-radius: 16px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.15);
  border: 1px solid #e5e7eb;
  font-family: sans-serif;
  padding: 16px;
  width: 360px;
  font-size: 14px;
  color: #111827;
}

.mulChunksDownloader .section {
  margin-bottom: 16px;
}

.mulChunksDownloader .mainHead {
  margin: 0px;
}

.mulChunksDownloader .infoSection {
  margin-top: 16px;
}

.mulChunksDownloader .section-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  font-weight: 600;
  margin-bottom: 3px;
}

.mulChunksDownloader .mainHead {
  cursor: move;
}

.mulChunksDownloader .collapse-btn {
  background: #f3f4f6;
  border: 1px solid #d1d5db;
  border-radius: 6px;
  width: 24px;
  height: 24px;
  font-size: 14px;
  color: #4b5563;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  }
.mulChunksDownloader .collapse-btn:hover {
  background: #e5e7eb;
}

.mulChunksDownloader .coords {
  display: flex;
  justify-content: space-around;
  align-items: center;
  gap: 8px;
  font-size: 14px;

  padding: 4px 12px;
  border-radius: 9999px;
  background: #f3f4f6;
}

.mulChunksDownloader .btn-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
  margin-top: 8px;
}

.mulChunksDownloader .btn {
  border-radius: 9999px;
  padding: 6px 12px;
  cursor: pointer;
  border: 1px solid #d1d5db;
  background: #f3f4f6;
  color: #374151;
  text-align: center;
  font-size: 13px;
}
.mulChunksDownloader .btn:hover {
  background: #e5e7eb;
}

.mulChunksDownloader .btn-primary {
  background: #2563eb;
  border: none;
  color: white;
}

.mulChunksDownloader .btn-primary:hover {
  background: #1d4ed8;
}

.mulChunksDownloader .input-box {
  width: 100%;
  border-radius: 9999px;
  background: #f3f4f6;
  border: 1px solid #d1d5db;
  padding: 6px 12px;
  font-size: 0.875rem;
  margin: 8px 0;
  outline: none;
  transition: border 0.2s, box-shadow 0.2s;
}
.mulChunksDownloader .input-box:focus {
  border-color: #2563eb;
  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
  background: white;
}

.mulChunksDownloader .collapsible {
  overflow: hidden;
  transition: max-height 0.3s ease;
}
.mulChunksDownloader .collapsible.collapsed {
  max-height: 0;
}

.mulChunksDownloader .collapsible.expanded {
  max-height: 1000px;
}

.mulChunksDownloader button:disabled {
  background-color: #4b5563; /* darken */
  color: #9ca3af;
  cursor: not-allowed;
  opacity: 0.7;
}

.mulChunksDownloader .download-bar {
  position: relative;
  width: 100%;
  height: 24px;
  background-color: #e0e0e0; /* light gray background */
  border-radius: 6px;
  overflow: hidden;
  margin-top: 10px;
}

.mulChunksDownloader .download-progress {
  height: 100%;
  width: 0%;
  background-color: #007bff; /* bootstrap blue */
  transition: width 0.3s ease;
}

.mulChunksDownloader .download-text {
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  font-size: 12px;
  font-weight: bold;
  color: white;
  line-height: 24px;
}

.mulChunksDownloader .mainCollapsible {
  overflow: hidden;
  transition: max-height 0.3s ease;
}

.mulChunksDownloader .mainCollapsible.collapsed {
  max-height: 0;
}

.mulChunksDownloader .mainCollapsible.expanded {
  max-height: 2000px; /* large enough to fit all content */
}

`;
  document.head.appendChild(style);
  document.body.appendChild(multipleChunksDownloaderElem);

  // for the collapsing mechanism
  let collapseSection = (btn, target) => {
    let isCollapsed = btn.dataset.collapsed === "true";

    if (!isCollapsed) {
      target.classList.remove("expanded");
      target.classList.add("collapsed");
      btn.dataset.collapsed = "true";
      btn.textContent = "+";
    } else {
      target.classList.remove("collapsed");
      target.classList.add("expanded");
      btn.dataset.collapsed = "false";
      btn.textContent = "–";
    }
  };

  // Main collapse
  let mainHead = multipleChunksDownloaderElem.querySelector(".mainHead");
  const mainCollapsible =
    multipleChunksDownloaderElem.querySelector(".mainCollapsible");
  const mainCollapseBtn = multipleChunksDownloaderElem.querySelector(
    ".mainHead .collapse-btn"
  );
  mainCollapseBtn.dataset.collapsed = "false";

  mainCollapseBtn.addEventListener("click", () => {
    collapseSection(mainCollapseBtn, mainCollapsible);
  });

  // Multiple Chunks Section collapse
  let mulChunkSection =
    multipleChunksDownloaderElem.querySelector(".mulChunkSection");
  let mulChunkCollapseBtn = mulChunkSection.querySelector(".collapse-btn");
  let mulChunkCollapsible = mulChunkSection.querySelector(".collapsible");
  mulChunkCollapseBtn.dataset.collapsed = "true";

  mulChunkCollapseBtn.addEventListener("click", () => {
    collapseSection(mulChunkCollapseBtn, mulChunkCollapsible);
  });

  // Manual Chunks Section collapse
  let manualChunkSection = multipleChunksDownloaderElem.querySelector(
    ".manualChunkSection"
  );
  let manualCollapseBtn = manualChunkSection.querySelector(".collapse-btn");
  let manualCollapsible = manualChunkSection.querySelector(".collapsible");
  manualCollapseBtn.dataset.collapsed = "true"; // starts collapsed

  manualCollapseBtn.addEventListener("click", () => {
    collapseSection(manualCollapseBtn, manualCollapsible);
  });

  // for the dragging mechanism
  let isDragging = false;
  let offsetX = 0;
  let offsetY = 0;

  mainHead.addEventListener("mousedown", (e) => {
    isDragging = true;

    // Calculates click offset inside the box
    const rect = multipleChunksDownloaderElem.getBoundingClientRect();
    offsetX = e.clientX - rect.left;
    offsetY = e.clientY - rect.top;

    // Prevent accidental text selection
    e.preventDefault();
  });

  document.addEventListener("mousemove", (e) => {
    if (!isDragging) return;

    multipleChunksDownloaderElem.style.top = `${e.clientY - offsetY}px`;
    multipleChunksDownloaderElem.style.left = `${e.clientX - offsetX}px`;
    multipleChunksDownloaderElem.style.bottom = "auto"; // stop sticking to bottom
    multipleChunksDownloaderElem.style.right = "auto"; // stop sticking to left
    multipleChunksDownloaderElem.style.position = "fixed";
  });

  document.addEventListener("mouseup", () => {
    isDragging = false;
  });

  // for displaying info about points and currently selected chunk
  let infoSection = multipleChunksDownloaderElem.querySelector(".infoSection");

  let downloadChunkBtn = infoSection.querySelector(".downloadChunkBtn");

  downloadChunkBtn.addEventListener("click", async () => {
    if (chunkX == null) return;
    multipleChunksDlUrl(chunkX, chunkY, chunkX, chunkY);
  });

  let viewChunkBtn = infoSection.querySelector(".viewChunkBtn");
  viewChunkBtn.addEventListener("click", (event) => {
    if (chunkX == null) return;
    window.open(chunkUrl, "_blank");
  });

  // to update the infos
  const refreshSetPointsInfo = () => {
    let coordsCon = mulChunkSection.querySelector(".coords");
    let currentCoords = infoSection.querySelector("span");
    currentCoords.textContent = `Chunk selected: X: ${chunkX}, Y: ${chunkY}`;
    let infoChildren = coordsCon.querySelectorAll("span");
    infoChildren[0].textContent = `1st X: ${mlChunkCoords.firstChunk.x}, Y: ${mlChunkCoords.firstChunk.y}`;
    infoChildren[1].textContent = `2nd X: ${mlChunkCoords.secondChunk.x}, Y: ${mlChunkCoords.secondChunk.y}`;
  };

  let setPointBtn = mulChunkSection.querySelector(".setPointBtn");

  setPointBtn.addEventListener("click", async () => {
    if (chunkX == null) return;
    if (mlChunkCoords.firstChunk.x == null) {
      // yes I'm lazy
      mlChunkCoords.firstChunk = { x: chunkX, y: chunkY };
    } else if (mlChunkCoords.secondChunk.x == null) {
      mlChunkCoords.secondChunk = { x: chunkX, y: chunkY };
    } else {
      mlChunkCoords.firstChunk = mlChunkCoords.secondChunk;
      mlChunkCoords.secondChunk = { x: chunkX, y: chunkY };
    }

    if (isHightlightOn) {
      highlightedChunksLinksArr.length = 0;
      let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
      highlightedChunksLinksArr.push(
        ...getLinksFromChunkCoords(organizedCoords)
      );
    }

    refreshSetPointsInfo();
    updateButtons();
  });

  let removePointsBtn = mulChunkSection.querySelector(".removePointBtn");
  removePointsBtn.addEventListener("click", async () => {
    mlChunkCoords = {
      firstChunk: { x: null, y: null },
      secondChunk: { x: null, y: null },
    };

    highlightedChunksLinksArr.length = 0;
    isHightlightOn = false;
    let highlightBtn = mulChunkSection.querySelector(".highlightBtn");
    highlightBtn.textContent = "Highlight chunks";

    refreshSetPointsInfo();
    updateButtons();
  });

  let highlightBtn = mulChunkSection.querySelector(".highlightBtn");
  highlightBtn.addEventListener("click", async () => {
    console.log("Trying to hightlight chunks");
    if (mlChunkCoords.firstChunk.x == null) return;
    if (!isHightlightOn) {
      let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
      console.log(Object.keys(organizedCoords));
      highlightedChunksLinksArr.push(
        ...getLinksFromChunkCoords(organizedCoords)
      );
      console.log(`Turned on hightlight`);
      isHightlightOn = !isHightlightOn;
      highlightBtn.textContent = "Unhighlight chunks";
    } else {
      highlightedChunksLinksArr.length = 0;
      console.log(`Turned off highlight`);
      isHightlightOn = !isHightlightOn;
      highlightBtn.textContent = "Highlight chunks";
    }
    // updateButtons();
  });

  let downloadBtn = mulChunkSection.querySelector(".downloadBtn");
  downloadBtn.addEventListener("click", async () => {
    let tempCoords = structuredClone(mlChunkCoords);
    if (
      mlChunkCoords.firstChunk.x == null &&
      mlChunkCoords.secondChunk.x == null
    ) {
      return;
    }
    if (mlChunkCoords.secondChunk.x == null) {
      tempCoords.secondChunk.x = mlChunkCoords.firstChunk.x;
      tempCoords.secondChunk.y = mlChunkCoords.firstChunk.y;
    }
    console.log(
      `downloading chunks: ${tempCoords.firstChunk.x}, ${tempCoords.firstChunk.y} | ${tempCoords.secondChunk.x}, ${tempCoords.secondChunk.y}`
    );

    // making sure the coords are appropriate before passing it
    let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
    multipleChunksDlUrl(
      organizedCoords.firstChunk.x,
      organizedCoords.firstChunk.y,
      organizedCoords.secondChunk.x,
      organizedCoords.secondChunk.y
    );
  });

  let coordsInput = manualChunkSection.querySelector(".coordsInput");
  coordsInput.addEventListener("keydown", (event) => {
    if (event.key === "Enter" && !event.repeat) {
      manualDownload();
    }
  });

  let manualDownloadBtn =
    manualChunkSection.querySelector(".manualDownloadBtn");
  manualDownloadBtn.addEventListener("click", () => {
    manualDownload();
  });

  let manualDownload = () => {
    let coordsText = coordsInput.value;
    // Split and trim whitespace from each value
    let splitUpVal = coordsText.split(",").map((v) => v.trim());

    // Helper: convert string to boolean or null if invalid
    const toBoolean = (str) => {
      if (str.toLowerCase() === "true") return true;
      if (str.toLowerCase() === "false") return false;
      return null;
    };

    if (splitUpVal.length !== 4 && splitUpVal.length !== 5) {
      console.log("You must input 4 or 5 arguments (comma-separated).");
      return;
    }

    // Validate first 4 args as integers
    for (let i = 0; i < 4; i++) {
      if (!Number.isInteger(Number(splitUpVal[i]))) {
        console.log(
          "First 4 arguments must all be integers. Also make sure that there is no space in between numbers. Example of what not to do: ..., 34 6, ..."
        );
        return;
      }
    }

    // With 5th arg (boolean)
    if (splitUpVal.length === 5) {
      let inputBool = toBoolean(splitUpVal[4]);
      if (inputBool === null) {
        console.log("The fifth argument only accepts 'true' or 'false'.");
        return;
      }
      multipleChunksDlUrl(
        Number(splitUpVal[0]),
        Number(splitUpVal[1]),
        Number(splitUpVal[2]),
        Number(splitUpVal[3]),
        inputBool
      );
    } else {
      // Only 4 args
      multipleChunksDlUrl(
        Number(splitUpVal[0]),
        Number(splitUpVal[1]),
        Number(splitUpVal[2]),
        Number(splitUpVal[3])
      );
    }
  };

  let updateButtons = () => {
    let marker = document.querySelector(".maplibregl-marker");
    if (!marker) {
      isPointing = false;
      chunkX = null;
      chunkY = null;
      chunkUrl = null;
      refreshSetPointsInfo();
    }
    setPointBtn.disabled = !isPointing;
    downloadChunkBtn.disabled = !isPointing;
    viewChunkBtn.disabled = !isPointing;
    downloadBtn.disabled =
      downloadingState || mlChunkCoords.firstChunk.x == null;
    manualDownloadBtn.disabled = downloadingState;

    let noFirstChunk = mlChunkCoords.firstChunk.x == null;
    highlightBtn.disabled = noFirstChunk;
    removePointsBtn.disabled = noFirstChunk;
  };

  let updateDownloadBar = () => {
    const progressElem = document.querySelector(".download-progress");
    const textElem = document.querySelector(".download-text");

    if (!progressElem || !textElem || totalImgsToBeDownloaded === 0) return;

    const percent = Math.min(
      100,
      (currImgsDownloaded / totalImgsToBeDownloaded) * 100
    );

    progressElem.style.width = percent + "%";
    textElem.textContent = `${currImgsDownloaded} / ${totalImgsToBeDownloaded}`;
  };

  updateButtons();

  const originalFetch = window.fetch;

  const mlCoordsOrganizer = async (mlCoords) => {
    let tempMlChunkCoords = structuredClone(mlChunkCoords);

    // checks if the second point is empty. If yes, it copies the val of first point onto the second
    if (mlCoords.secondChunk.x == null) {
      tempMlChunkCoords.secondChunk.x = mlChunkCoords.firstChunk.x;
      tempMlChunkCoords.secondChunk.y = mlChunkCoords.firstChunk.y;
      return tempMlChunkCoords;
    }

    // making sure that the coords that will be sent would be appropriate
    // turns the first point to be the topleft corner and the second the bottom right
    tempMlChunkCoords.firstChunk.x = Math.min(
      mlCoords.firstChunk.x,
      mlCoords.secondChunk.x
    );
    tempMlChunkCoords.secondChunk.x = Math.max(
      mlCoords.firstChunk.x,
      mlCoords.secondChunk.x
    );
    tempMlChunkCoords.firstChunk.y = Math.min(
      mlCoords.firstChunk.y,
      mlCoords.secondChunk.y
    );
    tempMlChunkCoords.secondChunk.y = Math.max(
      mlCoords.firstChunk.y,
      mlCoords.secondChunk.y
    );

    return tempMlChunkCoords;
  };

  window.fetch = async (resource, init) => {
    const url = new URL(
      typeof resource === "string" ? resource : resource.url || ""
    );
    const res = await originalFetch(resource, init);

    const x = url.searchParams.get("x");
    const y = url.searchParams.get("y");

    // This part is for the wonky feature that highlights a chunk, the reason it is wonky is becuase
    // it has to wait for the app to reload/refetch the img so we can apply effects
    // Why wait? because the images are flattened in the <canvas> object, and I have no access
    // (or I dont know how to get access) to the functions that sets the <canvas> obj
    // leaving me unable to modify or even force to reload/fetch the image

    // Detect tile fetches
    if (url.pathname.endsWith(".png")) {
      const pathParts = url.pathname.split("/");
      const chunkX = pathParts[pathParts.length - 2];
      const chunkY = pathParts[pathParts.length - 1].replace(".png", "");

      // If this chunk matches our selected one
      if (
        isHightlightOn &&
        highlightedChunksLinksArr.includes(
          chunkTemplateUrl + chunkX + "/" + chunkY + ".png"
        ) &&
        !downloadingState
      ) {
        // Clone so we don't consume the original stream
        const cloned = res.clone();
        const blob = await cloned.blob();
        const imgBitmap = await createImageBitmap(blob);

        // Draw onto an offscreen canvas
        const canvas = document.createElement("canvas");
        canvas.width = imgBitmap.width;
        canvas.height = imgBitmap.height;
        const ctx = canvas.getContext("2d");

        // Draw original
        ctx.drawImage(imgBitmap, 0, 0);

        // Add blue overlay
        ctx.fillStyle = "rgba(0, 0, 255, 0.2)";
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        const modifiedBlob = await new Promise((r) =>
          canvas.toBlob(r, "image/png")
        );

        return new Response(modifiedBlob, {
          status: res.status,
          statusText: res.statusText,
          headers: res.headers,
        });
      }
    }

    if (x && y) {
      // Setting important infos
      const pathnames = url.pathname.split("/");
      chunkX = pathnames[pathnames.length - 2];
      chunkY = pathnames[pathnames.length - 1];
      chunkUrl = `https://backend.wplace.live/files/s0/tiles/${chunkX}/${chunkY}.png`;

      isPointing = true;
      updateButtons();
      refreshSetPointsInfo();

      const parent = document
        .querySelector(".rounded-t-box")
        ?.querySelector("div");

      if (parent) {
        const pixelBtns = parent.querySelector(".hide-scrollbar");

        let exitBtn = parent.querySelector(
          "div.px-3:nth-child(1) > button:nth-child(2)"
        );

        exitBtn.addEventListener("click", (event) => {
          isPointing = false;
          chunkX = null;
          chunkY = null;
          chunkUrl = null;
          updateButtons();
          refreshSetPointsInfo();
        });
      } else {
        console.error("Parent element not found");
      }
    }
    return res;
  };
  const multipleChunksDlUrl = async (
    topleftX,
    topleftY,
    botRightX,
    botRightY,
    safety = true
  ) => {
    let linksResultArr = getLinksFromChunkCoords({
      firstChunk: { x: topleftX, y: topleftY },
      secondChunk: { x: botRightX, y: botRightY },
    });
    downloadingState = true;
    updateButtons();

    let safetyThreshold = 70;

    let chunkWidth = 1 + Number(botRightX - topleftX);

    let imgsAmount = linksResultArr.length;

    if (linksResultArr.length > safetyThreshold) {
      if (safety) {
        console.warn(
          `You were about to download ${linksResultArr.length} images but was prevented by this precaution. If you intentionally wanted to download that much, you can type '${topLeftX}, ${topLeftY}, ${botRightX}, ${botRightY}, false' onto the manual chunk downloader. Good luck.`
        );
        return;
      } else {
        console.log("Better pray to God...");
      }
    }

    totalImgsToBeDownloaded = imgsAmount;
    currImgsDownloaded = 0;
    updateDownloadBar();

    stitchAndDownload(
      linksResultArr,
      chunkWidth,
      `ch(${topleftX}, ${topleftY}, ${botRightX}, ${botRightY})` // the name of the stitched file png
    );
  };

  let getLinksFromChunkCoords = (chunkCoords) => {
    console.log("getting the links from chunk coords.");
    console.log(
      "tempChunkCoords: " +
        `First chunk {x: ${chunkCoords.firstChunk.x}, y: ${chunkCoords.firstChunk.y}}, Second chunk {x: ${chunkCoords.secondChunk.x}, y: ${chunkCoords.secondChunk.y}}`
    );
    let topleftX = chunkCoords.firstChunk.x,
      topleftY = chunkCoords.firstChunk.y,
      botRightX = chunkCoords.secondChunk.x,
      botRightY = chunkCoords.secondChunk.y;

    if (botRightX == null) {
      botRightX = topleftX;
      botRightY = topleftY;
    }

    let chunkWidth = 1 + Number(botRightX - topleftX);
    let chunkHeight = 1 + Number(botRightY - topleftY);

    console.log("chunkWidth: " + chunkWidth);
    console.log("chunkHeight: " + chunkHeight);

    let linksArr = [];
    for (let j = 0; j < chunkHeight; j++) {
      for (let i = 0; i < chunkWidth; i++) {
        // I F*CKING HATE JAVASCRIPT. TF YOU MEAN THAT YOU THINK A F*CKING NUMBER IS A STRING!
        linksArr.push(
          chunkTemplateUrl +
            (Number(i) + Number(topleftX)) +
            "/" +
            (Number(j) + Number(topleftY)) +
            ".png"
        );
      }
    }
    return linksArr;
  };

  async function stitchAndDownload(images, width, name) {
    // Creates a temp 1000x1000 image

    const createBlank = () => {
      const c = document.createElement("canvas");
      c.width = 1000;
      c.height = 1000;
      const ctx = c.getContext("2d");
      ctx.fillStyle = "rgba(0,0,0,0)"; // or transparent
      ctx.fillRect(0, 0, c.width, c.height);
      return new Promise((resolve) => {
        c.toBlob((blob) => {
          const img = new Image();
          img.src = URL.createObjectURL(blob);
          img.onload = () => resolve(img);
        });
      });
    };

    // loads all images with delay
    const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

    async function loadImagesWithDelay(images, delay = 100) {
      const loadedImages = [];

      for (const src of images) {
        let img;

        while (true) {
          // keep retrying until successful
          try {
            const res = await fetch(src, { mode: "cors" });

            if (res.status === 429) {
              console.warn("Rate limited! Cooling down for 10s...");
              await sleep(10000); // 10 second cooldown, i dont know if this should be lessened to 5s
              continue; // retry this same image
            }

            if (!res.ok) {
              throw new Error(`HTTP ${res.status}`);
            }

            // Convert response to blob and load into Image
            const blob = await res.blob();
            img = await new Promise((resolve) => {
              const image = new Image();
              image.crossOrigin = "anonymous";
              image.onload = () => resolve(image);
              image.onerror = async () => {
                console.warn("Failed to decode image, using blank:", src);
                const blank = await createBlank();
                resolve(blank);
              };
              image.src = URL.createObjectURL(blob);
            });

            break; // success, exit retry loop
          } catch (err) {
            console.warn("Fetch failed, using blank:", src, err);
            img = await createBlank();
            break;
          }
        }

        loadedImages.push(img);
        currImgsDownloaded++;
        updateDownloadBar();

        // cooldown between normal downloads
        await sleep(delay);
      }

      return loadedImages;
    }

    // usage
    const loadedImages = await loadImagesWithDelay(images, 150);

    // # of columns/rows
    const columns = width;
    const rows = Math.ceil(loadedImages.length / columns);

    const imgWidth = loadedImages[0].width;
    const imgHeight = loadedImages[0].height;

    const canvas = document.createElement("canvas");
    canvas.width = imgWidth * columns;
    canvas.height = imgHeight * rows;
    const ctx = canvas.getContext("2d");

    // this draw images in order: left to right, top to bottom
    loadedImages.forEach((img, index) => {
      const x = (index % columns) * imgWidth;
      const y = Math.floor(index / columns) * imgHeight;
      ctx.drawImage(img, x, y);
    });

    // Download stitched image
    canvas.toBlob((blob) => {
      const link = document.createElement("a");
      link.href = URL.createObjectURL(blob);
      link.download = `${name}_${Date.now()}.png`;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      URL.revokeObjectURL(link.href);

      // to enable the highlight to stay after downloading
      downloadingState = false;
      updateButtons();
    }, "image/png");
  }
})();

// Sry for horrible coding lmao
// No one just can win against Javascript that easily...