clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat/atlas-next-horizon
atlas / internal / workbench / assets / workbench.js
workbench.js 7.7K
1document.addEventListener("dragstart", (event) => {2 const handoff = event.target.closest("[data-artifact-handoff]");3 if (!handoff || !event.dataTransfer) return;45 const endpoint = new URL("/project/artifact", window.location.href).href;6 const name = handoff.dataset.artifactName || "volume.atlas";7 event.dataTransfer.effectAllowed = "copy";8 event.dataTransfer.setData("DownloadURL", `application/octet-stream:${name}:${endpoint}`);9 event.dataTransfer.setData("text/uri-list", endpoint);10 event.dataTransfer.setData("text/plain", handoff.dataset.artifactPath || name);11});1213const tileX = (longitude, count, upper = false) => {14 const value = ((longitude + 180) / 360) * count;15 return upper ? Math.ceil(value) - 1 : Math.floor(value);16};1718const tileY = (latitude, count) => {19 const radians = latitude * Math.PI / 180;20 return Math.floor((1 - Math.asinh(Math.tan(radians)) / Math.PI) / 2 * count);21};2223const latitudeAtTile = (tile, count) => Math.atan(Math.sinh(Math.PI * (1 - 2 * tile / count))) * 180 / Math.PI;2425const mapRegions = {26 world: {27 extent: [-180, -85.051128, 180, 85.051128], initial: [-105.1, 39.6, -104.9, 39.8], zoom: 12,28 outline: [],29 },30 conus: {31 extent: [-125, 24, -66, 50], initial: [-105.1, 39.6, -104.9, 39.8], zoom: 12,32 outline: [[-124, 48], [-124, 42], [-122, 38], [-117, 33], [-111, 32], [-106, 31], [-103, 29], [-97, 26], [-82, 25], [-80, 27], [-81, 31], [-75, 35], [-67, 45], [-75, 45], [-83, 46], [-95, 49], [-112, 49]],33 },34 alaska: {35 extent: [-180, 50, -129, 72], initial: [-151.0, 60.9, -150.7, 61.2], zoom: 11,36 outline: [[-168, 54], [-160, 55], [-153, 57], [-147, 59], [-141, 60], [-141, 69], [-151, 71], [-162, 68], [-168, 64], [-179, 52]],37 },38 hawaii: {39 extent: [-161, 18, -154, 23], initial: [-157.95, 21.25, -157.7, 21.45], zoom: 12,40 outline: [[-160.3, 22.1], [-159.2, 21.8], [-158.2, 21.7], [-157.7, 21.3], [-156.3, 20.8], [-155.5, 19.4]],41 },42 "puerto-rico": {43 extent: [-68, 17, -65, 19], initial: [-66.7, 18.1, -66.3, 18.4], zoom: 13,44 outline: [[-67.3, 18.5], [-65.6, 18.5], [-65.2, 18.2], [-66.0, 17.9], [-67.2, 18.0]],45 },46};4748const alignedSelection = (form) => {49 const value = (name) => Number(form.elements.namedItem(name).value);50 const zoom = value("detail");51 const count = 2 ** zoom;52 const minX = tileX(value("west"), count);53 const maxX = tileX(value("east"), count, true);54 const minY = tileY(value("north"), count);55 const maxY = tileY(value("south"), count);56 let side = 1;57 while (Math.floor(minX / side) * side + side - 1 < maxX || Math.floor(minY / side) * side + side - 1 < maxY) side *= 2;58 const originX = Math.floor(minX / side) * side;59 const originY = Math.floor(minY / side) * side;60 return {61 zoom, side, pixels: side * 256, localZoom: Math.log2(side),62 bounds: [originX / count * 360 - 180, latitudeAtTile(originY + side, count),63 (originX + side) / count * 360 - 180, latitudeAtTile(originY, count)],64 };65};6667const mapPoint = (bounds, canvas, longitude, latitude) => [68 (longitude - bounds[0]) / (bounds[2] - bounds[0]) * canvas.width,69 (bounds[3] - latitude) / (bounds[3] - bounds[1]) * canvas.height,70];7172const drawBounds = (context, canvas, region, bounds, color, fill) => {73 const start = mapPoint(region.extent, canvas, bounds[0], bounds[3]);74 const end = mapPoint(region.extent, canvas, bounds[2], bounds[1]);75 context.strokeStyle = color;76 context.fillStyle = fill;77 context.lineWidth = 3;78 context.setLineDash(color === "#7fc5de" ? [] : [10, 6]);79 context.fillRect(start[0], start[1], end[0] - start[0], end[1] - start[1]);80 context.strokeRect(start[0], start[1], end[0] - start[0], end[1] - start[1]);81};8283const renderAreaMap = (form, aligned) => {84 const canvas = form.querySelector("[data-area-map]");85 const region = mapRegions[form.querySelector("[data-map-region]").value];86 const context = canvas.getContext("2d");87 context.clearRect(0, 0, canvas.width, canvas.height);88 context.fillStyle = "#0d1626";89 context.fillRect(0, 0, canvas.width, canvas.height);90 context.beginPath();91 region.outline.forEach(([longitude, latitude], index) => {92 const point = mapPoint(region.extent, canvas, longitude, latitude);93 if (index === 0) context.moveTo(point[0], point[1]); else context.lineTo(point[0], point[1]);94 });95 if (region.outline.length > 4) context.closePath();96 context.fillStyle = "rgba(138, 106, 68, .35)";97 context.strokeStyle = "#8a6a44";98 context.lineWidth = 2;99 context.fill();100 context.stroke();101 const value = (name) => Number(form.elements.namedItem(name).value);102 drawBounds(context, canvas, region, aligned.bounds, "#8e897c", "rgba(0,0,0,0)");103 drawBounds(context, canvas, region, [value("west"), value("south"), value("east"), value("north")], "#7fc5de", "rgba(32, 138, 174, .22)");104};105106const updateAreaBudget = (form) => {107 const budget = form.querySelector("[data-budget-preview]");108 const detail = form.querySelector("[data-detail-output]");109 const selected = alignedSelection(form);110 const rasterTiles = (4 ** (selected.localZoom + 1) - 1) / 3;111 const captures = (form.elements.namedItem("topo").checked ? 1 : 0) +112 (form.elements.namedItem("roads").checked ? 3 : 0) +113 (form.elements.namedItem("hydro").checked ? 2 : 0) +114 (form.elements.namedItem("counties").checked ? 1 : 0);115 detail.value = String(selected.zoom);116 budget.querySelector("[data-budget-tiles]").value = rasterTiles.toLocaleString();117 budget.querySelector("[data-budget-size]").value = `${selected.pixels.toLocaleString()} × ${selected.pixels.toLocaleString()}`;118 const over = selected.pixels > 4096 || captures === 0;119 budget.classList.toggle("over", over);120 budget.querySelector("[data-budget-state]").value = captures === 0 ? "Select at least one source." :121 over ? `${captures} captures · lower detail or narrow the area.` : `${captures} capture requests · within default gate.`;122 renderAreaMap(form, selected);123};124125const installAreaMap = (form) => {126 const canvas = form.querySelector("[data-area-map]");127 const regionPicker = form.querySelector("[data-map-region]");128 let start = null;129 const coordinates = (event) => {130 const rect = canvas.getBoundingClientRect();131 const x = Math.max(0, Math.min(canvas.width, (event.clientX - rect.left) / rect.width * canvas.width));132 const y = Math.max(0, Math.min(canvas.height, (event.clientY - rect.top) / rect.height * canvas.height));133 const region = mapRegions[regionPicker.value];134 return [region.extent[0] + x / canvas.width * (region.extent[2] - region.extent[0]),135 region.extent[3] - y / canvas.height * (region.extent[3] - region.extent[1])];136 };137 canvas.addEventListener("pointerdown", (event) => {138 start = coordinates(event);139 canvas.setPointerCapture(event.pointerId);140 });141 canvas.addEventListener("pointermove", (event) => {142 if (!start) return;143 const end = coordinates(event);144 form.elements.namedItem("west").value = Math.min(start[0], end[0]).toFixed(6);145 form.elements.namedItem("east").value = Math.max(start[0], end[0]).toFixed(6);146 form.elements.namedItem("south").value = Math.min(start[1], end[1]).toFixed(6);147 form.elements.namedItem("north").value = Math.max(start[1], end[1]).toFixed(6);148 updateAreaBudget(form);149 });150 canvas.addEventListener("pointerup", (event) => {151 if (start) canvas.releasePointerCapture(event.pointerId);152 start = null;153 });154 regionPicker.addEventListener("change", () => {155 const region = mapRegions[regionPicker.value];156 ["west", "south", "east", "north"].forEach((name, index) => { form.elements.namedItem(name).value = region.initial[index]; });157 form.elements.namedItem("detail").value = region.zoom;158 updateAreaBudget(form);159 });160};161162document.querySelectorAll("[data-us-area-form]").forEach((form) => {163 installAreaMap(form);164 updateAreaBudget(form);165 form.addEventListener("input", () => updateAreaBudget(form));166});