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 / render / mint.ts
mint.ts 7.6K
1// Earth authoring enhancement.2//3// The application renders the complete form and owns every route. This module4// contributes only the two facts a browser camera can answer: which local5// world rectangle is visible, and which rectangle a pointer drew. Both are6// converted into the WGS84 fields the server already rendered.78import type { TileGrid } from "./data/payload.ts";9import type { AtlasChart } from "./chart/element.ts";10import type { AtlasViewport } from "./viewport.ts";1112export type Extent = readonly [number, number, number, number];1314// Six decimal places are the authored form contract. Keeping the clamp on15// that same precision prevents a whole-Earth camera from rounding one unit16// past the native Web Mercator boundary.17const MAX_MERCATOR_LATITUDE = 85.051128;1819/** Convert local y-up Atlas pixels into a WGS84 west/south/east/north box. */20export function worldExtentToWGS84(extent: Extent, grid: TileGrid): [number, number, number, number] | null {21 const zoom = grid.sourceZoom;22 const tileSize = grid.tileSize;23 const originX = grid.originX ?? grid.firstTile;24 const originY = grid.originY ?? grid.firstTile;25 const scale = tileSize * 2 ** zoom;26 if (![...extent, zoom, tileSize, originX, originY, scale].every(Number.isFinite) || tileSize <= 0 || scale <= 0) return null;27 const west = longitude(originX * tileSize + extent[0], scale);28 const east = longitude(originX * tileSize + extent[2], scale);29 const north = latitude(originY * tileSize - extent[3], scale);30 const south = latitude(originY * tileSize - extent[1], scale);31 const bounds = [west, south, east, north] as const;32 if (!bounds.every(Number.isFinite) || west >= east || south >= north) return null;33 return bounds.map((value) => rounded(value)) as [number, number, number, number];34}3536function longitude(pixel: number, scale: number): number {37 return Math.max(-180, Math.min(180, pixel / scale * 360 - 180));38}3940function latitude(pixel: number, scale: number): number {41 const mercator = Math.PI - 2 * Math.PI * pixel / scale;42 const degrees = Math.atan(Math.sinh(mercator)) * 180 / Math.PI;43 return Math.max(-MAX_MERCATOR_LATITUDE, Math.min(MAX_MERCATOR_LATITUDE, degrees));44}4546function rounded(value: number): number { return Number(value.toFixed(6)); }4748let drawing = false;49let start: { x: number; y: number } | null = null;50const wired = new WeakSet<EventTarget>();5152/** Wire the enhancement onto whichever mint controls are currently rendered. */53export function wireMinting(): void {54 const create = document.querySelector<HTMLElement>("#atlas-create");55 if (create && !wired.has(create)) {56 wired.add(create);57 create.addEventListener("click", () => window.setTimeout(useCurrentView, 220));58 }59 const current = document.querySelector<HTMLButtonElement>("[data-mint-current-view]");60 if (current && !wired.has(current)) {61 wired.add(current);62 current.addEventListener("click", useCurrentView);63 }64 const draw = document.querySelector<HTMLButtonElement>("[data-mint-draw]");65 if (draw && !wired.has(draw)) {66 wired.add(draw);67 draw.addEventListener("click", () => setDrawing(!drawing));68 }69 const detail = document.querySelector<HTMLInputElement>('input[name="detail"]');70 if (detail && !wired.has(detail)) {71 wired.add(detail);72 detail.addEventListener("input", () => writeDetail(detail));73 }74 const chart = document.querySelector<AtlasChart>("atlas-chart");75 if (chart && !wired.has(chart)) {76 wired.add(chart);77 chart.addEventListener("pointerdown", beginZone, { capture: true });78 }79}8081function useCurrentView(): void {82 const chart = document.querySelector<AtlasChart>("atlas-chart");83 const panel = document.querySelector<HTMLElement>("#atlas-mint");84 if (!chart) return;85 const chartBox = chart.getBoundingClientRect();86 const panelBox = panel?.getBoundingClientRect();87 const visibleWidth = panelBox && panelBox.left > chartBox.left88 ? Math.min(chartBox.width, panelBox.left - chartBox.left)89 : chartBox.width;90 const extent = chart.extentForPixels([0, 0, visibleWidth, chartBox.height]) ?? chart.visibleExtent();91 if (!extent) return;92 fillBounds(extent);93 showZone({ left: chartBox.left, top: chartBox.top, right: chartBox.left + visibleWidth, bottom: chartBox.bottom });94}9596function beginZone(event: PointerEvent): void {97 if (!drawing || event.button !== 0) return;98 event.preventDefault();99 event.stopImmediatePropagation();100 start = { x: event.clientX, y: event.clientY };101 showZone({ left: event.clientX, top: event.clientY, right: event.clientX, bottom: event.clientY });102 window.addEventListener("pointermove", moveZone, { capture: true });103 window.addEventListener("pointerup", finishZone, { capture: true, once: true });104 window.addEventListener("pointercancel", cancelZone, { capture: true, once: true });105}106107function moveZone(event: PointerEvent): void {108 if (!start) return;109 event.preventDefault();110 showZone({ left: start.x, top: start.y, right: event.clientX, bottom: event.clientY });111}112113function finishZone(event: PointerEvent): void {114 window.removeEventListener("pointermove", moveZone, { capture: true });115 window.removeEventListener("pointercancel", cancelZone, { capture: true });116 if (!start) return;117 event.preventDefault();118 const chart = document.querySelector<AtlasChart>("atlas-chart");119 const box = chart?.getBoundingClientRect();120 const width = Math.abs(event.clientX - start.x);121 const height = Math.abs(event.clientY - start.y);122 if (chart && box && width >= 12 && height >= 12) {123 const extent = chart.extentForPixels([124 Math.min(start.x, event.clientX) - box.left,125 Math.min(start.y, event.clientY) - box.top,126 Math.max(start.x, event.clientX) - box.left,127 Math.max(start.y, event.clientY) - box.top,128 ]);129 if (extent) fillBounds(extent);130 }131 start = null;132 setDrawing(false);133}134135function cancelZone(): void {136 window.removeEventListener("pointermove", moveZone, { capture: true });137 window.removeEventListener("pointerup", finishZone, { capture: true });138 start = null;139 setDrawing(false);140}141142function fillBounds(extent: Extent): void {143 const viewport = document.querySelector<AtlasViewport>("atlas-viewport");144 const bounds = viewport?.current ? worldExtentToWGS84(extent, viewport.current.grid) : null;145 if (!bounds) return;146 ["west", "south", "east", "north"].forEach((name, index) => {147 const field = document.querySelector<HTMLInputElement>(`#atlas-mint input[name="${name}"]`);148 if (field) field.value = String(bounds[index] ?? "");149 });150 window.dispatchEvent(new CustomEvent("atlas:mint-area"));151}152153function showZone(rect: { left: number; top: number; right: number; bottom: number }): void {154 const zone = document.querySelector<HTMLElement>("#atlas-mint-zone");155 const panel = document.querySelector<HTMLElement>(".map-panel");156 if (!zone || !panel) return;157 const base = panel.getBoundingClientRect();158 zone.hidden = false;159 zone.dataset.visible = "true";160 zone.style.left = `${Math.min(rect.left, rect.right) - base.left}px`;161 zone.style.top = `${Math.min(rect.top, rect.bottom) - base.top}px`;162 zone.style.width = `${Math.abs(rect.right - rect.left)}px`;163 zone.style.height = `${Math.abs(rect.bottom - rect.top)}px`;164 zone.style.right = "auto";165 zone.style.bottom = "auto";166}167168function setDrawing(value: boolean): void {169 drawing = value;170 const button = document.querySelector<HTMLButtonElement>("[data-mint-draw]");171 button?.setAttribute("aria-pressed", String(value));172 document.querySelector(".map-panel")?.classList.toggle("mint-drawing", value);173}174175function writeDetail(field: HTMLInputElement): void {176 const output = document.querySelector<HTMLOutputElement>("[data-mint-detail]");177 if (output) output.value = `z${field.value}`;178}