aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/js/exportpanel.js
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-07 00:14:38 +0200
committergodosa <godosa@godosa.eu>2026-10-07 00:14:38 +0200
commit3443c1c65e9f1753e1e656b35d08416c1fa298f2 (patch)
tree4e43236f460145a4d75d1b4616dcb7aa6ef08f51 /viewer/js/exportpanel.js
downloadworldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.tar.gz
worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.zip
worldmap-viewer: initial public history
Diffstat (limited to 'viewer/js/exportpanel.js')
-rw-r--r--viewer/js/exportpanel.js103
1 files changed, 103 insertions, 0 deletions
diff --git a/viewer/js/exportpanel.js b/viewer/js/exportpanel.js
new file mode 100644
index 0000000..9af9526
--- /dev/null
+++ b/viewer/js/exportpanel.js
@@ -0,0 +1,103 @@
+// exportpanel.js — the Export tab: freeze the square around the map centre into game-engine files (map/exports/)
+import { api, el, toast } from './ui.js';
+import { exportSizeMB, exportStatusText, samplesFor, squareOutline } from './exports.js';
+import { fmtLatLon } from './units.js';
+
+const SIZES = [5, 10, 20, 40, 80, 160]; // km
+const RESOLUTIONS = [1, 2, 5, 10, 20, 50, 100]; // m per sample
+const MAX_SAMPLES = 8193; // as the server (serve.EXPORT_MAX_SAMPLES)
+
+export class ExportPanel {
+ constructor(box, { getCenter, onSquare, getEra = () => undefined }) {
+ Object.assign(this, { box, getCenter, onSquare, getEra });
+ this.size = 20;
+ this.res = 10;
+ this.list = [];
+ this.status = null;
+ this.visible = false;
+ this.timer = null;
+ }
+
+ async load() {
+ try {
+ const r = await api('api/exports');
+ this.list = r.exports;
+ this.status = r.status;
+ } catch (e) {
+ toast(`Exports: ${e.message}`);
+ }
+ clearTimeout(this.timer);
+ if (this.status?.state === 'running') this.timer = setTimeout(() => this.load(), 700);
+ if (this.statusEl) this.refresh(); // a poll: never rebuild what is being typed or picked
+ else this.render();
+ }
+
+ refresh() { // the parts that change while an export runs: status line, button, list
+ const busy = this.status?.state === 'running', tooMany = samplesFor(this.size, this.res) > MAX_SAMPLES;
+ this.statusEl.textContent = exportStatusText(this.status);
+ this.statusEl.setAttribute('class', this.status?.state === 'failed' ? 'warn' : 'muted');
+ if (busy || tooMany) this.button.setAttribute('disabled', 'true');
+ else this.button.removeAttribute?.('disabled');
+ this.listEl.replaceChildren(...(this.list.length ? this.list.map(e => this.#item(e)) : [el('p', { class: 'muted' }, 'No exports yet.')]));
+ }
+
+ #item(e) {
+ return el('div', { class: 'pin-item' }, el('b', {}, e.name), el('br'),
+ el('span', { class: 'muted' }, `${(e.size_m / 1000).toLocaleString()} km · ${e.res_m} m · ${e.samples} × ${e.samples}`), el('br'),
+ el('span', { class: 'muted small' }, e.path));
+ }
+
+ show() {
+ this.visible = true;
+ this.render();
+ this.update();
+ this.load();
+ }
+
+ hide() {
+ this.visible = false;
+ this.onSquare(null);
+ }
+
+ update() { // the view moved or the size changed: the square and the centre line follow
+ if (!this.visible) return;
+ const c = this.getCenter();
+ this.onSquare(squareOutline(c.lat, c.lon, this.size));
+ if (this.centre) this.centre.textContent = `Centre (map centre): ${fmtLatLon(c.lat, c.lon)}`;
+ }
+
+ async start(name) {
+ const c = this.getCenter();
+ try {
+ await api('api/exports', { method: 'POST', body: { lat: c.lat, lon: c.lon, size_km: this.size, res_m: this.res,
+ era: this.getEra() || undefined, ...(name.trim() ? { name: name.trim() } : {}) } });
+ } catch (e) {
+ return toast(`Could not export: ${e.message}`);
+ }
+ await this.load();
+ }
+
+ render() {
+ const n = samplesFor(this.size, this.res), tooMany = n > MAX_SAMPLES;
+ const select = (key, values, unit) => {
+ const s = el('select', { name: key }, ...values.map(v => el('option', { value: String(v), selected: v === this[key] ? true : null }, `${v} ${unit}`)));
+ s.addEventListener('change', () => { this[key] = Number(s.value); this.render(); this.update(); });
+ return s;
+ };
+ const name = el('input', { maxlength: 64, placeholder: 'name (optional: a–z 0–9 . _ -)' });
+ name.addEventListener('input', () => { const v = name.value.toLowerCase().replace(/[^a-z0-9._-]/g, '-'); if (v !== name.value) name.value = v; });
+ this.centre = el('p', { class: 'muted' }, '');
+ this.button = el('button', { onclick: () => this.start(name.value) }, 'Export here (map centre)');
+ this.statusEl = el('p', { class: 'muted' }, '');
+ this.listEl = el('div', {});
+ this.box.replaceChildren(
+ el('p', { class: 'muted' }, 'Freeze the square around the map centre into files for a game engine: heights, water, '
+ + 'biomes, rivers, pins (map/exports/, see its README.txt). Generated terrain: idea.'),
+ el('label', { class: 'row' }, 'Size ', select('size', SIZES, 'km'), ' Resolution ', select('res', RESOLUTIONS, 'm')),
+ el('p', { class: tooMany ? 'warn' : 'muted' }, `${n} × ${n} samples · ≈ ${Math.round(exportSizeMB(n))} MB`
+ + (tooMany ? ' — too many: choose a coarser resolution or a smaller square' : '')),
+ this.centre, el('label', { class: 'row' }, name), el('div', {}, this.button), this.statusEl, this.listEl);
+ this.refresh();
+ this.update();
+ }
+}