const UUID_RE =
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
function isUuid(s) {
return typeof s === "string" && UUID_RE.test(s);
}
function redirectToLogin() {
const next = encodeURIComponent(
window.location.pathname + window.location.search,
);
window.location.assign(`/login?next=${next}`);
}
const _originalFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const resp = await _originalFetch(...args);
if (resp.status === 401) {
redirectToLogin();
}
return resp;
};
async function loadCurrentUser() {
try {
const resp = await _originalFetch("/api/auth/me");
if (resp.status === 401) {
redirectToLogin();
return;
}
if (!resp.ok) return;
const me = await resp.json();
const label = document.getElementById("logged-in-as");
if (label) label.textContent = `Logged in as ${me.legacy_name}`;
const logout = document.getElementById("logout-button");
if (logout) logout.classList.remove("hidden");
const routeColor = document.getElementById("route_color");
if (
routeColor &&
typeof me.route_color === "string" &&
/^#[0-9a-fA-F]{6}$/.test(me.route_color)
) {
routeColor.value = me.route_color;
}
} catch (_err) {
}
}
document.addEventListener("DOMContentLoaded", () => {
loadCurrentUser();
const logout = document.getElementById("logout-button");
if (logout) {
logout.addEventListener("click", async () => {
try {
await _originalFetch("/api/auth/logout", { method: "POST" });
} catch (_err) {
}
window.location.assign("/login");
});
}
decorateInvitationsLink();
});
async function decorateInvitationsLink() {
const link = document.getElementById("invitations-link");
if (!link) return;
try {
const resp = await _originalFetch("/api/invitations");
if (!resp.ok) return;
const data = await resp.json();
const n = (data.invitations || []).length;
if (n > 0) {
const badge = document.createElement("span");
badge.className = "badge";
badge.textContent = String(n);
link.appendChild(document.createTextNode(" "));
link.appendChild(badge);
}
} catch (_err) {
}
}
const TILE_URL = (z, x, y) =>
`https://secondlife-maps-cdn.akamaized.net/map-${z}-${x}-${y}-objects.jpg`;
const tileSize = (z) => 1 << (z - 1);
const pixelsPerRegion = (z) => 1 << (9 - z);
const TILE_PX = 256;
const PREVIEW_BUDGET_PX = 1024;
function pickPreviewZoom(sizeX, sizeY) {
for (let z = 1; z <= 8; z++) {
if (
sizeX * pixelsPerRegion(z) <= PREVIEW_BUDGET_PX &&
sizeY * pixelsPerRegion(z) <= PREVIEW_BUDGET_PX
) {
return z;
}
}
return 8;
}
const MIN_PX_PER_REGION_FOR_NAMES = 64;
const MAX_REGIONS_FOR_NAMES = 1024;
function finalFitZoom(sizeX, sizeY, maxW, maxH) {
const pprX = Math.ceil(maxW / Math.max(1, sizeX));
const pprY = Math.ceil(maxH / Math.max(1, sizeY));
const zx = 9 - Math.min(8, Math.floor(Math.log2(Math.max(1, pprX))));
const zy = 9 - Math.min(8, Math.floor(Math.log2(Math.max(1, pprY))));
return Math.max(1, Math.min(8, Math.max(zx, zy)));
}
function regionNamesShownInPreview(sizeX, sizeY, maxW, maxH) {
const finalPpr = pixelsPerRegion(finalFitZoom(sizeX, sizeY, maxW, maxH));
return (
finalPpr >= MIN_PX_PER_REGION_FOR_NAMES &&
sizeX * sizeY <= MAX_REGIONS_FOR_NAMES
);
}
function $(id) {
return document.getElementById(id);
}
function show(el) {
el.classList.remove("hidden");
}
function hide(el) {
el.classList.add("hidden");
}
const ON_RENDER_PAGE = Boolean($("missing_map_tile_enabled"));
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((t) => {
t.classList.remove("active");
});
document.querySelectorAll(".tab-panel").forEach((p) => {
p.classList.remove("active");
});
tab.classList.add("active");
const panel = document.getElementById(`tab-${tab.dataset.tab}`);
if (panel) panel.classList.add("active");
});
});
function activateSubtab(name) {
document.querySelectorAll(".subtab").forEach((t) => {
t.classList.toggle("active", t.dataset.subtab === name);
});
document.querySelectorAll(".subtab-panel").forEach((p) => {
p.classList.toggle("active", p.id === `subtab-${name}`);
});
}
document.querySelectorAll(".subtab").forEach((tab) => {
tab.addEventListener("click", () => activateSubtab(tab.dataset.subtab));
});
function activeSubtab() {
const t = document.querySelector(".subtab.active");
return t ? t.dataset.subtab : "file";
}
function activeTab() {
const t = document.querySelector(".tab.active");
return t ? t.dataset.tab : "grid";
}
if (ON_RENDER_PAGE)
$("missing_map_tile_enabled").addEventListener("change", (e) => {
$("missing_map_tile_color").disabled = !e.target.checked;
});
if (ON_RENDER_PAGE)
$("missing_region_enabled").addEventListener("change", (e) => {
$("missing_region_color").disabled = !e.target.checked;
refreshRegionOverlay();
updateFillHint();
});
const ROUTE_COLOR_RE = /^#[0-9a-fA-F]{6}$/;
if (ON_RENDER_PAGE)
$("route_color").addEventListener("change", async (e) => {
const value = e.target.value;
if (!ROUTE_COLOR_RE.test(value)) return;
try {
await fetch("/api/users/me/preferences", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ route_color: value }),
});
} catch (_err) {
}
});
function renderSavedColors(colors) {
const datalist = $("saved_colors");
if (datalist) {
datalist.replaceChildren(
...colors.map((c) => {
const opt = document.createElement("option");
opt.value = c;
return opt;
}),
);
}
const list = $("saved_colors_list");
if (list) {
list.replaceChildren(
...colors.map((c) => {
const swatch = document.createElement("span");
swatch.className = "saved-color";
const chip = document.createElement("span");
chip.className = "saved-color-chip";
chip.style.backgroundColor = c;
const label = document.createElement("code");
label.textContent = c;
const remove = document.createElement("button");
remove.type = "button";
remove.className = "saved-color-remove";
remove.dataset.color = c;
remove.title = `Remove ${c}`;
remove.setAttribute("aria-label", `Remove ${c}`);
remove.textContent = "×";
swatch.append(chip, label, remove);
return swatch;
}),
);
}
}
async function loadSavedColors() {
try {
const resp = await fetch("/api/users/me/colors");
if (!resp.ok) return;
const data = await resp.json();
if (Array.isArray(data.colors)) renderSavedColors(data.colors);
} catch (_err) {
}
}
if (ON_RENDER_PAGE) {
$("saved_color_add").addEventListener("click", async () => {
const value = $("saved_color_new").value;
if (!ROUTE_COLOR_RE.test(value)) return;
try {
const resp = await fetch("/api/users/me/colors", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ color: value }),
});
if (resp.ok) await loadSavedColors();
} catch (_err) {
}
});
$("saved_colors_list").addEventListener("click", async (e) => {
const btn = e.target.closest(".saved-color-remove");
if (!btn) return;
const value = btn.dataset.color;
if (!ROUTE_COLOR_RE.test(value)) return;
try {
const resp = await fetch(`/api/users/me/colors/${value.slice(1)}`, {
method: "DELETE",
});
if (resp.ok) await loadSavedColors();
} catch (_err) {
}
});
document.addEventListener("DOMContentLoaded", () => {
loadSavedColors().catch(() => {});
});
}
function readThemeSettings() {
const overlay = readRegionOverlay();
const glwStyle = { margin_band: $("glw_margin_band").checked };
for (const { key, id } of GLW_COLOR_FIELDS) {
const el = $(id);
if (el) glwStyle[key] = el.value;
}
return {
version: 1,
missing_map_tile_enabled: $("missing_map_tile_enabled").checked,
missing_map_tile_color: $("missing_map_tile_color").value,
missing_region_enabled: $("missing_region_enabled").checked,
missing_region_color: $("missing_region_color").value,
draw_region_rectangles: overlay.draw_region_rectangles,
draw_region_names: overlay.draw_region_names,
draw_region_coordinates: overlay.draw_region_coordinates,
region_label_font_id: overlay.region_label_font_id,
glw_style: glwStyle,
glw_font_id: $("glw_font_id").value || null,
route_color: $("route_color").value || null,
};
}
function applyTheme(s) {
if (!s) return;
const mtEnabled = !!s.missing_map_tile_enabled;
$("missing_map_tile_enabled").checked = mtEnabled;
$("missing_map_tile_color").disabled = !mtEnabled;
if (s.missing_map_tile_color)
$("missing_map_tile_color").value = s.missing_map_tile_color;
const mrEnabled = !!s.missing_region_enabled;
$("missing_region_enabled").checked = mrEnabled;
$("missing_region_color").disabled = !mrEnabled;
if (s.missing_region_color)
$("missing_region_color").value = s.missing_region_color;
applyRegionOverlaySettings(s);
if (s.glw_style) {
if ("margin_band" in s.glw_style)
$("glw_margin_band").checked = !!s.glw_style.margin_band;
for (const { key, id } of GLW_COLOR_FIELDS) {
const el = $(id);
if (el && s.glw_style[key]) el.value = s.glw_style[key];
}
}
if (s.glw_font_id) populateFontSelect($("glw_font_id"), s.glw_font_id);
if (s.route_color && ROUTE_COLOR_RE.test(s.route_color))
$("route_color").value = s.route_color;
}
function selectedThemeScope() {
return $("theme_scope").value;
}
function selectedThemeId() {
return $("theme_select").value;
}
function themeScopeIsWritable() {
const opt = $("theme_scope").selectedOptions[0];
return Boolean(opt && opt.dataset.writable === "true");
}
function setThemeStatus(msg) {
const el = $("theme_status");
if (el) el.textContent = msg;
}
function refreshThemeButtons() {
const writable = themeScopeIsWritable();
const hasSelection = Boolean(selectedThemeId());
$("theme_apply").disabled = !hasSelection;
$("theme_save_new").disabled = !writable;
$("theme_overwrite").disabled = !writable || !hasSelection;
}
async function loadThemesForScope(scope) {
const sel = $("theme_select");
if (!sel) return;
sel.replaceChildren();
try {
const r = await fetch(`/api/themes?scope=${encodeURIComponent(scope)}`);
if (r.ok) {
const data = await r.json();
for (const t of data.themes || []) {
const o = document.createElement("option");
o.value = t.theme_id;
o.textContent = t.name;
sel.appendChild(o);
}
}
} catch (_err) {
}
refreshThemeButtons();
}
async function loadThemeScopes() {
const scopeSel = $("theme_scope");
if (!scopeSel) return;
const personal = document.createElement("option");
personal.value = "personal";
personal.textContent = "Personal";
personal.dataset.writable = "true";
scopeSel.appendChild(personal);
let groups = { groups: [] };
try {
const r = await fetch("/api/groups");
if (r.ok) groups = await r.json();
} catch (_err) {
}
for (const g of groups.groups || []) {
const o = document.createElement("option");
o.value = `group:${g.group_id}`;
o.textContent = `Group: ${g.name}`;
o.dataset.writable = g.my_role === "owner" ? "true" : "false";
scopeSel.appendChild(o);
}
scopeSel.addEventListener("change", () => {
loadThemesForScope(scopeSel.value).catch(() => {});
});
await loadThemesForScope(scopeSel.value);
}
if (ON_RENDER_PAGE) {
$("theme_select").addEventListener("change", refreshThemeButtons);
$("theme_apply").addEventListener("click", async () => {
const id = selectedThemeId();
if (!id) return;
setThemeStatus("Applying…");
try {
const r = await fetch(`/api/themes/${id}`);
if (!r.ok) {
await showError(r);
setThemeStatus("");
return;
}
const data = await r.json();
applyTheme(data.theme.settings);
setThemeStatus(`Applied "${data.theme.name}".`);
} catch (_err) {
setThemeStatus("Could not apply the theme.");
}
});
$("theme_save_new").addEventListener("click", async () => {
const name = $("theme_name").value.trim();
if (!name) {
setThemeStatus("Enter a name for the new theme.");
return;
}
const scope = selectedThemeScope();
setThemeStatus("Saving…");
try {
const r = await fetch("/api/themes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope, name, settings: readThemeSettings() }),
});
if (!r.ok) {
await showError(r);
setThemeStatus("");
return;
}
const data = await r.json();
$("theme_name").value = "";
await loadThemesForScope(scope);
$("theme_select").value = data.theme.theme_id;
refreshThemeButtons();
setThemeStatus(`Saved "${data.theme.name}".`);
} catch (_err) {
setThemeStatus("Could not save the theme.");
}
});
$("theme_overwrite").addEventListener("click", async () => {
const id = selectedThemeId();
if (!id) return;
const ok = await confirmModal({
title: "Overwrite theme",
message:
"Replace the selected theme's saved settings with the current settings?",
okText: "Overwrite",
});
if (!ok) return;
setThemeStatus("Saving…");
try {
const r = await fetch(`/api/themes/${id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ settings: readThemeSettings() }),
});
if (!r.ok) {
await showError(r);
setThemeStatus("");
return;
}
setThemeStatus("Theme updated.");
} catch (_err) {
setThemeStatus("Could not update the theme.");
}
});
document.addEventListener("DOMContentLoaded", () => {
loadThemeScopes().catch(() => {});
});
}
async function loadGroupsAndNotecards() {
let groups = { groups: [] };
try {
const r = await fetch("/api/groups");
if (r.ok) groups = await r.json();
} catch (_err) {
}
const saveTo = $("save_to");
if (saveTo) {
for (const g of groups.groups || []) {
if (g.my_role !== "owner") continue;
const o = document.createElement("option");
o.value = `group:${g.group_id}`;
o.textContent = `Group: ${g.name}`;
saveTo.appendChild(o);
}
}
const scopeSel = $("reuse_scope");
if (scopeSel) {
for (const g of groups.groups || []) {
const o = document.createElement("option");
o.value = `group:${g.group_id}`;
o.textContent = `Group: ${g.name}`;
scopeSel.appendChild(o);
}
scopeSel.addEventListener("change", () => {
loadNotecardsForScope(scopeSel.value).catch(() => {});
});
await loadNotecardsForScope(scopeSel.value);
}
}
async function loadNotecardsForScope(scope) {
const sel = $("reuse_notecard_id");
if (!sel) return;
const previous = sel.value;
sel.replaceChildren();
let notecards = [];
try {
const r = await fetch(`/api/notecards?scope=${encodeURIComponent(scope)}`);
if (r.ok) {
const data = await r.json();
notecards = data.notecards || [];
}
} catch (_err) {
}
if (notecards.length === 0) {
const o = document.createElement("option");
o.value = "";
o.textContent = "(no saved notecards in this scope)";
sel.appendChild(o);
return;
}
for (const n of notecards) {
const o = document.createElement("option");
o.value = n.notecard_id;
o.textContent = n.name;
sel.appendChild(o);
}
if (previous) {
const match = Array.from(sel.options).find((o) => o.value === previous);
if (match) sel.value = previous;
}
}
if (ON_RENDER_PAGE)
document.addEventListener("DOMContentLoaded", loadGroupsAndNotecards);
function readSharedParams() {
return {
max_width: parseInt($("max_width").value, 10),
max_height: parseInt($("max_height").value, 10),
missing_map_tile_color: $("missing_map_tile_enabled").checked
? $("missing_map_tile_color").value
: null,
missing_region_color: $("missing_region_enabled").checked
? $("missing_region_color").value
: null,
format: $("format").value,
};
}
function readRegionOverlay() {
return {
draw_region_rectangles: $("draw_region_rectangles").checked,
draw_region_names: $("draw_region_names").checked,
draw_region_coordinates: $("draw_region_coordinates").checked,
region_label_font_id: $("region_label_font_id").value || null,
};
}
function readBorders() {
const get = (id) => {
const v = $(id).value.trim();
return v === "" ? null : parseInt(v, 10);
};
return {
border_regions: get("border_regions"),
border_north: get("border_north"),
border_south: get("border_south"),
border_east: get("border_east"),
border_west: get("border_west"),
};
}
function appendBordersToForm(fd) {
const b = readBorders();
Object.entries(b).forEach(([k, v]) => {
if (v !== null) fd.append(k, String(v));
});
}
function appendRegionOverlayToForm(fd) {
const o = readRegionOverlay();
if (o.draw_region_rectangles) fd.append("draw_region_rectangles", "true");
if (o.draw_region_names) fd.append("draw_region_names", "true");
if (o.draw_region_coordinates) fd.append("draw_region_coordinates", "true");
if (
(o.draw_region_names || o.draw_region_coordinates) &&
o.region_label_font_id
) {
fd.append("region_label_font_id", o.region_label_font_id);
}
}
async function fetchGlwOverlay(rect, z) {
const glw = readGlwOptions();
if (!glw) return null;
const resp = await fetch("/api/render/glw-preview", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
lower_left_x: rect.lower_left_x,
lower_left_y: rect.lower_left_y,
upper_right_x: rect.upper_right_x,
upper_right_y: rect.upper_right_y,
zoom: z,
glw,
}),
});
if (!resp.ok) throw new Error(await resp.text());
return URL.createObjectURL(await resp.blob());
}
async function fetchGlwLegendOverlay(rect) {
const glw = readGlwOptions();
if (!glw) return null;
if (!glw.legend_slot || glw.legend_slot === "none") return null;
const shared = readSharedParams();
const resp = await fetch("/api/render/glw-legend-preview", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
lower_left_x: rect.lower_left_x,
lower_left_y: rect.lower_left_y,
upper_right_x: rect.upper_right_x,
upper_right_y: rect.upper_right_y,
max_width: shared.max_width,
max_height: shared.max_height,
glw,
}),
});
if (!resp.ok) throw new Error(await resp.text());
return URL.createObjectURL(await resp.blob());
}
async function fetchRoutePreview(rect, waypoints) {
if (!waypoints || waypoints.length <= 1) return null;
const shared = readSharedParams();
const resp = await fetch("/api/render/route-preview", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
lower_left_x: rect.lower_left_x,
lower_left_y: rect.lower_left_y,
upper_right_x: rect.upper_right_x,
upper_right_y: rect.upper_right_y,
max_width: shared.max_width,
max_height: shared.max_height,
color: $("route_color").value,
waypoints: waypoints.map((w) => ({
region_x: w.region_x,
region_y: w.region_y,
x: w.x,
y: w.y,
})),
}),
});
if (!resp.ok) throw new Error(await resp.text());
return URL.createObjectURL(await resp.blob());
}
async function fetchPlacementOverlay(rect) {
const labels = readLabels(false);
const logos = readLogos(false);
if (!labels.length && !logos.length) return null;
const activeTab = document.querySelector(".tab.active");
const which = activeTab ? activeTab.dataset.tab : "grid";
let resp;
if (which === "grid") {
const glw = readGlwOptions();
const body = {
lower_left_x: rect.lower_left_x,
lower_left_y: rect.lower_left_y,
upper_right_x: rect.upper_right_x,
upper_right_y: rect.upper_right_y,
...readSharedParams(),
labels,
logos,
};
if (glw) body.glw = glw;
resp = await fetch("/api/render/placement-preview/grid-rectangle", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
} else {
const fd = new FormData();
appendNotecardSourceToForm(fd);
appendBordersToForm(fd);
const shared = readSharedParams();
fd.append("max_width", String(shared.max_width));
fd.append("max_height", String(shared.max_height));
fd.append("format", shared.format);
if (shared.missing_map_tile_color)
fd.append("missing_map_tile_color", shared.missing_map_tile_color);
if (shared.missing_region_color)
fd.append("missing_region_color", shared.missing_region_color);
fd.append("color", $("route_color").value);
const glw = readGlwOptions();
if (glw) fd.append("glw_json", JSON.stringify(glw));
fd.append("labels_json", JSON.stringify(labels));
fd.append("logos_json", JSON.stringify(logos));
resp = await fetch("/api/render/placement-preview/usb-notecard", {
method: "POST",
body: fd,
});
}
if (!resp.ok) throw new Error(await resp.text());
return URL.createObjectURL(await resp.blob());
}
function setRegionOverlayStatus(text) {
const el = $("preview-overlay-status");
if (el) el.textContent = text;
}
function updateFillHint(rect) {
const el = $("preview-fill-hint");
if (!el) return;
const r = rect || lastPreviewRect;
if (!$("missing_region_enabled").checked || !r) {
el.textContent = "";
return;
}
const sizeX = r.upper_right_x - r.lower_left_x + 1;
const sizeY = r.upper_right_y - r.lower_left_y + 1;
if (sizeX <= 0 || sizeY <= 0) {
el.textContent = "";
return;
}
const shared = readSharedParams();
const shown =
$("draw_region_names").checked &&
regionNamesShownInPreview(
sizeX,
sizeY,
shared.max_width,
shared.max_height,
);
el.textContent = shown
? "Fill missing regions: shown in this preview."
: "Fill missing regions: applied in the final render only — enable Draw region names on a small enough area (regions ≥ 64 px, ≤ 1024 regions) to preview it.";
}
let regionOverlayAbort = null;
async function fetchRegionOverlay(rect) {
const overlay = readRegionOverlay();
if (
!overlay.draw_region_rectangles &&
!overlay.draw_region_names &&
!overlay.draw_region_coordinates
) {
setRegionOverlayStatus("");
return null;
}
if (regionOverlayAbort) regionOverlayAbort.abort();
const controller = new AbortController();
regionOverlayAbort = controller;
const shared = readSharedParams();
let resp;
try {
resp = await fetch("/api/render/region-overlay-preview", {
method: "POST",
headers: { "content-type": "application/json" },
signal: controller.signal,
body: JSON.stringify({
lower_left_x: rect.lower_left_x,
lower_left_y: rect.lower_left_y,
upper_right_x: rect.upper_right_x,
upper_right_y: rect.upper_right_y,
max_width: shared.max_width,
max_height: shared.max_height,
missing_region_color: shared.missing_region_color,
...overlay,
}),
});
} catch (err) {
if (err.name === "AbortError") return null;
throw err;
}
if (!resp.ok) throw new Error(await resp.text());
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let imageData = null;
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
let msg;
try {
msg = JSON.parse(line);
} catch (_err) {
continue;
}
if (msg.type === "region_names_planned") {
setRegionOverlayStatus(`region names: 0 / ${msg.total_regions}`);
} else if (msg.type === "region_name_resolved") {
const n = msg.index + 1;
if (n === msg.total || (n & 0x1f) === 0) {
setRegionOverlayStatus(`region names: ${n} / ${msg.total}`);
}
} else if (msg.type === "image") {
imageData = msg.data;
} else if (msg.type === "error") {
setRegionOverlayStatus("");
throw new Error(msg.message);
}
}
}
} catch (err) {
if (err.name === "AbortError") return null;
throw err;
}
if (regionOverlayAbort === controller) {
regionOverlayAbort = null;
setRegionOverlayStatus("");
}
if (!imageData) return null;
return URL.createObjectURL(base64PngToBlob(imageData));
}
function base64PngToBlob(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new Blob([bytes], { type: "image/png" });
}
function drawRegionOverlay(viewport, rect) {
if (!viewport) return;
const old = viewport.querySelector("img.region-overlay");
if (old) old.remove();
const bx = parseFloat(viewport.dataset.boundsX);
const by = parseFloat(viewport.dataset.boundsY);
const bw = parseFloat(viewport.dataset.boundsW);
const bh = parseFloat(viewport.dataset.boundsH);
if (![bx, by, bw, bh].every(Number.isFinite)) return;
const img = document.createElement("img");
img.className = "region-overlay";
img.style.left = `${bx.toFixed(1)}px`;
img.style.top = `${by.toFixed(1)}px`;
img.style.width = `${bw.toFixed(1)}px`;
img.style.height = `${bh.toFixed(1)}px`;
const tiles = viewport.querySelector(".tiles");
if (tiles && tiles.nextSibling) viewport.insertBefore(img, tiles.nextSibling);
else viewport.appendChild(img);
fetchRegionOverlay(rect)
.then((url) => {
clearOverlayError("Region overlay failed");
if (!url) {
img.remove();
return;
}
img.src = url;
img.addEventListener("load", () => URL.revokeObjectURL(url), {
once: true,
});
})
.catch((err) => {
img.remove();
$("preview-status").textContent = `Region overlay failed: ${err.message}`;
});
}
function renderPreview(rect, waypoints) {
const container = $("preview-container");
container.replaceChildren();
const sizeX = rect.upper_right_x - rect.lower_left_x + 1;
const sizeY = rect.upper_right_y - rect.lower_left_y + 1;
if (sizeX <= 0 || sizeY <= 0) {
$("preview-status").textContent =
"Invalid rectangle: corners must be ordered.";
$("preview-region-info").textContent = "";
$("preview-fill-hint").textContent = "";
return;
}
const z = pickPreviewZoom(sizeX, sizeY);
const ts = tileSize(z);
const firstX = rect.lower_left_x - (rect.lower_left_x % ts);
const firstY = rect.lower_left_y - (rect.lower_left_y % ts);
const lastX = rect.upper_right_x - (rect.upper_right_x % ts);
const lastY = rect.upper_right_y - (rect.upper_right_y % ts);
const tilesX = (lastX - firstX) / ts + 1;
const tilesY = (lastY - firstY) / ts + 1;
const widthPx = tilesX * TILE_PX;
const heightPx = tilesY * TILE_PX;
const viewport = document.createElement("div");
viewport.className = "viewport";
viewport.style.width = `${widthPx}px`;
viewport.style.height = `${heightPx}px`;
viewport.dataset.intrinsicWidth = String(widthPx);
viewport.dataset.intrinsicHeight = String(heightPx);
const missingTileColor = $("missing_map_tile_enabled").checked
? $("missing_map_tile_color").value
: null;
const tiles = document.createElement("div");
tiles.className = "tiles";
tiles.style.width = `${widthPx}px`;
tiles.style.height = `${heightPx}px`;
for (let tx = 0; tx < tilesX; tx++) {
for (let ty = 0; ty < tilesY; ty++) {
const cornerX = firstX + tx * ts;
const cornerY = firstY + ty * ts;
const img = document.createElement("img");
img.className = "tile";
img.loading = "lazy";
img.alt = `tile ${z}-${cornerX}-${cornerY}`;
const leftPx = `${tx * TILE_PX}px`;
const topPx = `${(tilesY - 1 - ty) * TILE_PX}px`;
img.style.left = leftPx;
img.style.top = topPx;
if (missingTileColor) {
img.addEventListener(
"error",
() => {
const cell = document.createElement("div");
cell.className = "missing-tile";
cell.style.left = leftPx;
cell.style.top = topPx;
cell.style.backgroundColor = missingTileColor;
img.replaceWith(cell);
},
{ once: true },
);
}
img.src = TILE_URL(z, cornerX, cornerY);
tiles.appendChild(img);
}
}
viewport.appendChild(tiles);
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("route-overlay");
svg.setAttribute("viewBox", `0 0 ${widthPx} ${heightPx}`);
svg.setAttribute("width", widthPx);
svg.setAttribute("height", heightPx);
const ppRegion = pixelsPerRegion(z);
const boundsX = (rect.lower_left_x - firstX) * ppRegion;
const boundsY = heightPx - (rect.upper_right_y + 1 - firstY) * ppRegion;
const boundsW = sizeX * ppRegion;
const boundsH = sizeY * ppRegion;
const dim = document.createElementNS("http://www.w3.org/2000/svg", "path");
dim.setAttribute(
"d",
`M0 0 H${widthPx} V${heightPx} H0 Z ` +
`M${boundsX.toFixed(1)} ${boundsY.toFixed(1)} ` +
`h${boundsW.toFixed(1)} v${boundsH.toFixed(1)} ` +
`h${(-boundsW).toFixed(1)} Z`,
);
dim.setAttribute("fill-rule", "evenodd");
dim.setAttribute("fill", "#000");
dim.setAttribute("fill-opacity", "0.5");
svg.appendChild(dim);
const boundsRect = document.createElementNS(
"http://www.w3.org/2000/svg",
"rect",
);
boundsRect.setAttribute("x", boundsX.toFixed(1));
boundsRect.setAttribute("y", boundsY.toFixed(1));
boundsRect.setAttribute("width", boundsW.toFixed(1));
boundsRect.setAttribute("height", boundsH.toFixed(1));
boundsRect.setAttribute("fill", "none");
boundsRect.setAttribute("stroke", "#ff2d2d");
boundsRect.setAttribute("stroke-width", "2");
boundsRect.setAttribute("stroke-dasharray", "6 4");
boundsRect.setAttribute("vector-effect", "non-scaling-stroke");
svg.appendChild(boundsRect);
viewport.appendChild(svg);
let routeImg = null;
if (waypoints && waypoints.length > 1) {
routeImg = document.createElement("img");
routeImg.className = "route-overlay";
routeImg.style.left = `${boundsX.toFixed(1)}px`;
routeImg.style.top = `${boundsY.toFixed(1)}px`;
routeImg.style.width = `${boundsW.toFixed(1)}px`;
routeImg.style.height = `${boundsH.toFixed(1)}px`;
viewport.insertBefore(routeImg, svg);
fetchRoutePreview(rect, waypoints)
.then((url) => {
if (!url) {
routeImg.remove();
return;
}
routeImg.src = url;
routeImg.addEventListener("load", () => URL.revokeObjectURL(url), {
once: true,
});
})
.catch((err) => {
routeImg.remove();
$("preview-status").textContent =
`Route overlay failed: ${err.message}`;
});
}
if ($("glw_enabled") && $("glw_enabled").checked) {
const glwImg = document.createElement("img");
glwImg.className = "glw-overlay";
glwImg.style.left = `${boundsX.toFixed(1)}px`;
glwImg.style.top = `${boundsY.toFixed(1)}px`;
glwImg.style.width = `${boundsW.toFixed(1)}px`;
glwImg.style.height = `${boundsH.toFixed(1)}px`;
viewport.insertBefore(glwImg, routeImg || svg);
fetchGlwOverlay(rect, z)
.then((url) => {
if (!url) {
glwImg.remove();
return;
}
glwImg.src = url;
glwImg.addEventListener("load", () => URL.revokeObjectURL(url), {
once: true,
});
})
.catch((err) => {
glwImg.remove();
$("preview-status").textContent = `GLW overlay failed: ${err.message}`;
});
}
viewport.dataset.boundsX = String(boundsX);
viewport.dataset.boundsY = String(boundsY);
viewport.dataset.boundsW = String(boundsW);
viewport.dataset.boundsH = String(boundsH);
drawRegionOverlay(viewport, rect);
drawLegendOverlay(viewport, rect);
container.appendChild(viewport);
fitViewport(container, viewport, widthPx, heightPx);
drawSlotsOverlay(viewport);
lastPreviewRect = rect;
const wrap = $("preview-viewport-wrap");
if (wrap) wrap.classList.remove("hidden");
const dragHint = $("dragzoom-hint");
if (dragHint) dragHint.style.display = activeTab() === "grid" ? "" : "none";
$("preview-status").textContent =
`Preview at zoom ${z} (${pixelsPerRegion(z)} px/region) — ` +
`${tilesX * tilesY} tile${tilesX * tilesY === 1 ? "" : "s"}, ` +
`${widthPx}×${heightPx} px.`;
$("preview-region-info").textContent =
`Selected area: ${sizeX} × ${sizeY} regions (${sizeX * sizeY} total).`;
updateFillHint(rect);
findFreeSlots();
}
function clearOverlayError(prefix) {
const el = $("preview-status");
if (el && el.textContent.startsWith(prefix)) el.textContent = "";
}
function drawLegendOverlay(viewport, rect) {
if (!viewport) return;
const old = viewport.querySelector("img.glw-legend");
if (old) old.remove();
if (!($("glw_enabled") && $("glw_enabled").checked)) {
clearOverlayError("GLW legend failed");
return;
}
const bx = parseFloat(viewport.dataset.boundsX);
const by = parseFloat(viewport.dataset.boundsY);
const bw = parseFloat(viewport.dataset.boundsW);
const bh = parseFloat(viewport.dataset.boundsH);
if (![bx, by, bw, bh].every(Number.isFinite)) return;
const img = document.createElement("img");
img.className = "glw-legend";
img.style.left = `${bx.toFixed(1)}px`;
img.style.top = `${by.toFixed(1)}px`;
img.style.width = `${bw.toFixed(1)}px`;
img.style.height = `${bh.toFixed(1)}px`;
viewport.appendChild(img);
fetchGlwLegendOverlay(rect)
.then((url) => {
clearOverlayError("GLW legend failed");
if (!url) {
img.remove();
return;
}
img.src = url;
img.addEventListener("load", () => URL.revokeObjectURL(url), {
once: true,
});
})
.catch((err) => {
img.remove();
$("preview-status").textContent = `GLW legend failed: ${err.message}`;
});
}
function drawPlacementOverlay(viewport, rect) {
if (!viewport) return;
const old = viewport.querySelector("img.placement-overlay");
if (old) old.remove();
const bx = parseFloat(viewport.dataset.boundsX);
const by = parseFloat(viewport.dataset.boundsY);
const bw = parseFloat(viewport.dataset.boundsW);
const bh = parseFloat(viewport.dataset.boundsH);
if (![bx, by, bw, bh].every(Number.isFinite)) return;
const img = document.createElement("img");
img.className = "placement-overlay";
img.style.left = `${bx.toFixed(1)}px`;
img.style.top = `${by.toFixed(1)}px`;
img.style.width = `${bw.toFixed(1)}px`;
img.style.height = `${bh.toFixed(1)}px`;
viewport.appendChild(img);
fetchPlacementOverlay(rect)
.then((url) => {
clearOverlayError("Placement preview failed");
if (!url) {
img.remove();
return;
}
img.src = url;
img.addEventListener("load", () => URL.revokeObjectURL(url), {
once: true,
});
})
.catch((err) => {
img.remove();
$("preview-status").textContent =
`Placement preview failed: ${err.message}`;
});
}
function fitViewport(container, viewport, widthPx, heightPx) {
const availWidth = container.clientWidth || widthPx;
const maxHeight = Math.max(window.innerHeight * 0.7, 400);
const scale = Math.min(availWidth / widthPx, maxHeight / heightPx, 1);
viewport.style.transformOrigin = "0 0";
viewport.style.transform = `scale(${scale})`;
container.style.height = `${heightPx * scale}px`;
}
if (ON_RENDER_PAGE)
window.addEventListener("resize", () => {
document.querySelectorAll(".map-container").forEach((container) => {
const vp = container.querySelector(".viewport");
if (!vp) return;
const w = parseFloat(vp.dataset.intrinsicWidth);
const h = parseFloat(vp.dataset.intrinsicHeight);
if (Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0) {
fitViewport(container, vp, w, h);
}
});
});
function previewGrid() {
const rect = {
lower_left_x: parseInt($("ll_x").value, 10),
lower_left_y: parseInt($("ll_y").value, 10),
upper_right_x: parseInt($("ur_x").value, 10),
upper_right_y: parseInt($("ur_y").value, 10),
};
renderPreview(rect, null);
}
async function lookupRegion() {
const name = $("region_search").value.trim();
const statusEl = $("region_search_status");
if (name === "") {
statusEl.textContent = "";
return;
}
statusEl.textContent = "Looking up…";
try {
const resp = await fetch("/api/region/lookup", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ region_name: name }),
});
if (!resp.ok) throw new Error(await errorText(resp));
const { x, y } = await resp.json();
$("ll_x").value = String(x);
$("ll_y").value = String(y);
$("ur_x").value = String(x);
$("ur_y").value = String(y);
statusEl.textContent = `${name}: ${x}, ${y}`;
previewGrid();
} catch (err) {
statusEl.textContent = `Lookup failed: ${err.message}`;
}
}
function appendNotecardSourceToForm(fd) {
switch (activeSubtab()) {
case "file": {
const file = $("notecard_file").files[0];
if (!file) throw new Error("choose a notecard file");
fd.append("notecard", file);
const ncName = $("notecard_name_file").value.trim();
if (ncName !== "") fd.append("notecard_name", ncName);
break;
}
case "clipboard": {
const text = $("notecard_text").value;
if (text.trim() === "") throw new Error("paste a notecard");
fd.append("notecard_text", text);
const ncName = $("notecard_name_paste").value.trim();
if (ncName !== "") fd.append("notecard_name", ncName);
break;
}
case "reuse": {
const id = $("reuse_notecard_id").value.trim();
if (!isUuid(id)) throw new Error("choose a saved notecard");
fd.append("notecard_id", id);
break;
}
default:
throw new Error("unknown notecard source");
}
}
async function buildNotecardForm() {
const fd = new FormData();
appendNotecardSourceToForm(fd);
appendBordersToForm(fd);
return fd;
}
async function previewNotecard() {
$("preview-status").textContent = "Resolving notecard…";
try {
const fd = await buildNotecardForm();
const resp = await fetch("/api/notecard/derive-rectangle", {
method: "POST",
body: fd,
});
if (!resp.ok) throw new Error(await resp.text());
const data = await resp.json();
renderPreview(data, data.waypoints);
} catch (err) {
$("preview-status").textContent = `Preview failed: ${err.message}`;
}
}
if (ON_RENDER_PAGE)
$("preview_btn").addEventListener("click", () => {
if (activeTab() === "notecard") previewNotecard();
else previewGrid();
});
const GRID_MIN = 0;
const GRID_MAX = 65535;
function clampInt(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function adjustGridRect(dir, delta) {
let llx = parseInt($("ll_x").value, 10);
let lly = parseInt($("ll_y").value, 10);
let urx = parseInt($("ur_x").value, 10);
let ury = parseInt($("ur_y").value, 10);
if (![llx, lly, urx, ury].every(Number.isFinite)) return;
const sides = dir === "all" ? ["north", "south", "east", "west"] : [dir];
for (const s of sides) {
if (s === "north") ury = clampInt(ury + delta, lly, GRID_MAX);
else if (s === "south") lly = clampInt(lly - delta, GRID_MIN, ury);
else if (s === "east") urx = clampInt(urx + delta, llx, GRID_MAX);
else if (s === "west") llx = clampInt(llx - delta, GRID_MIN, urx);
}
$("ll_x").value = String(llx);
$("ll_y").value = String(lly);
$("ur_x").value = String(urx);
$("ur_y").value = String(ury);
previewGrid();
}
function adjustNotecardBorders(dir, delta) {
const b = readBorders();
const uniform = b.border_regions;
const eff = (perSide) => (uniform !== null ? uniform : (perSide ?? 0));
let north = eff(b.border_north);
let south = eff(b.border_south);
let east = eff(b.border_east);
let west = eff(b.border_west);
const bump = (v) => Math.max(0, v + delta);
const sides = dir === "all" ? ["north", "south", "east", "west"] : [dir];
for (const s of sides) {
if (s === "north") north = bump(north);
else if (s === "south") south = bump(south);
else if (s === "east") east = bump(east);
else if (s === "west") west = bump(west);
}
$("border_regions").value = "";
$("border_north").value = String(north);
$("border_south").value = String(south);
$("border_east").value = String(east);
$("border_west").value = String(west);
previewNotecard();
}
function adjustArea(dir, delta) {
if (activeTab() === "notecard") adjustNotecardBorders(dir, delta);
else adjustGridRect(dir, delta);
}
[
["extend_n", "north", 1],
["shrink_n", "north", -1],
["extend_s", "south", 1],
["shrink_s", "south", -1],
["extend_e", "east", 1],
["shrink_e", "east", -1],
["extend_w", "west", 1],
["shrink_w", "west", -1],
["extend_all", "all", 1],
["shrink_all", "all", -1],
].forEach(([id, dir, delta]) => {
const el = $(id);
if (el) el.addEventListener("click", () => adjustArea(dir, delta));
});
if (ON_RENDER_PAGE) {
$("region_search_btn").addEventListener("click", lookupRegion);
$("region_search").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
lookupRegion();
}
});
}
function eventToRegion(viewport, ev) {
const rect = lastPreviewRect;
if (!rect) return null;
const boundsX = parseFloat(viewport.dataset.boundsX);
const boundsY = parseFloat(viewport.dataset.boundsY);
const boundsW = parseFloat(viewport.dataset.boundsW);
const boundsH = parseFloat(viewport.dataset.boundsH);
if (![boundsX, boundsY, boundsW, boundsH].every(Number.isFinite)) return null;
const regionsX = rect.upper_right_x - rect.lower_left_x + 1;
const regionsY = rect.upper_right_y - rect.lower_left_y + 1;
const ppRegion = boundsW / regionsX;
if (!(ppRegion > 0)) return null;
const vpRect = viewport.getBoundingClientRect();
const scale = vpRect.width / parseFloat(viewport.dataset.intrinsicWidth);
const pixelX = (ev.clientX - vpRect.left) / scale;
const pixelY = (ev.clientY - vpRect.top) / scale;
let rx = rect.lower_left_x + Math.floor((pixelX - boundsX) / ppRegion);
let ry = rect.upper_right_y - Math.floor((pixelY - boundsY) / ppRegion);
rx = clampInt(rx, rect.lower_left_x, rect.upper_right_x);
ry = clampInt(ry, rect.lower_left_y, rect.upper_right_y);
return { x: rx, y: ry };
}
(function wireDragZoom() {
const container = $("preview-container");
if (!container) return;
let dragging = false;
let startRegion = null;
let selEl = null;
function viewport() {
return container.querySelector(".viewport");
}
function drawSelection(vp, a, b) {
const boundsX = parseFloat(vp.dataset.boundsX);
const boundsY = parseFloat(vp.dataset.boundsY);
const boundsW = parseFloat(vp.dataset.boundsW);
const rect = lastPreviewRect;
const regionsX = rect.upper_right_x - rect.lower_left_x + 1;
const ppRegion = boundsW / regionsX;
const minX = Math.min(a.x, b.x);
const maxX = Math.max(a.x, b.x);
const minY = Math.min(a.y, b.y);
const maxY = Math.max(a.y, b.y);
const left = boundsX + (minX - rect.lower_left_x) * ppRegion;
const top = boundsY + (rect.upper_right_y - maxY) * ppRegion;
const w = (maxX - minX + 1) * ppRegion;
const h = (maxY - minY + 1) * ppRegion;
if (!selEl) {
selEl = document.createElement("div");
selEl.className = "selection-rect";
vp.appendChild(selEl);
}
selEl.style.left = `${left.toFixed(1)}px`;
selEl.style.top = `${top.toFixed(1)}px`;
selEl.style.width = `${w.toFixed(1)}px`;
selEl.style.height = `${h.toFixed(1)}px`;
}
function cleanup() {
dragging = false;
startRegion = null;
if (selEl) {
selEl.remove();
selEl = null;
}
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
}
function onMove(ev) {
if (!dragging) return;
const vp = viewport();
if (!vp) return;
const cur = eventToRegion(vp, ev);
if (!cur) return;
drawSelection(vp, startRegion, cur);
}
function onUp(ev) {
if (!dragging) return;
const vp = viewport();
const cur = vp ? eventToRegion(vp, ev) : null;
const start = startRegion;
cleanup();
if (!cur || !start) return;
const llx = Math.min(start.x, cur.x);
const urx = Math.max(start.x, cur.x);
const lly = Math.min(start.y, cur.y);
const ury = Math.max(start.y, cur.y);
if (start.x === cur.x && start.y === cur.y) return;
$("ll_x").value = String(llx);
$("ll_y").value = String(lly);
$("ur_x").value = String(urx);
$("ur_y").value = String(ury);
previewGrid();
}
container.addEventListener("mousedown", (ev) => {
if (ev.button !== 0) return;
if (activeTab() !== "grid") return;
if (ev.target.closest("button, input, .slot-buttons")) return;
const vp = viewport();
if (!vp) return;
const r = eventToRegion(vp, ev);
if (!r) return;
ev.preventDefault(); dragging = true;
startRegion = r;
drawSelection(vp, r, r);
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
});
})();
const tileGridEl = $("tile-grid");
const renderProgressEl = $("render-progress");
const renderResultEl = $("render-result");
const renderStatusEl = $("render-status");
async function errorText(resp) {
const raw = await resp.text();
try {
const body = JSON.parse(raw);
if (body && typeof body.error === "string") return body.error;
} catch (_e) {
}
return raw;
}
function startRenderUI() {
hide(renderResultEl);
show(renderProgressEl);
tileGridEl.replaceChildren();
totalTiles = 0;
finishedTiles = 0;
totalRegions = 0;
checkedRegions = 0;
totalWaypoints = 0;
resolvedWaypoints = 0;
totalRegionNames = 0;
resolvedRegionNames = 0;
renderStatusEl.textContent = "Starting render…";
}
const tileCells = new Map();
function tileKey(z, x, y) {
return `${z}-${x}-${y}`;
}
function ensureTileCell(z, x, y) {
const key = tileKey(z, x, y);
let cell = tileCells.get(key);
if (!cell) {
cell = document.createElement("span");
cell.className = "tile-cell";
cell.title = key;
tileGridEl.appendChild(cell);
tileCells.set(key, cell);
}
return cell;
}
let totalTiles = 0;
let finishedTiles = 0;
let totalRegions = 0;
let checkedRegions = 0;
let totalWaypoints = 0;
let resolvedWaypoints = 0;
let totalRegionNames = 0;
let resolvedRegionNames = 0;
function updateStatus() {
const parts = [];
if (totalTiles > 0) {
parts.push(`tiles: ${finishedTiles} / ${totalTiles}`);
}
if (totalRegions > 0) {
parts.push(`region checks: ${checkedRegions} / ${totalRegions}`);
}
if (totalWaypoints > 0) {
parts.push(`waypoints: ${resolvedWaypoints} / ${totalWaypoints}`);
}
if (totalRegionNames > 0) {
parts.push(`region names: ${resolvedRegionNames} / ${totalRegionNames}`);
}
renderStatusEl.textContent = parts.join(" · ");
}
function handleProgress(evt) {
switch (evt.type) {
case "plan_computed":
totalTiles = evt.total_tiles;
finishedTiles = 0;
tileCells.clear();
tileGridEl.replaceChildren();
updateStatus();
break;
case "tile_started": {
const cell = ensureTileCell(evt.zoom, evt.x, evt.y);
cell.classList.add("active");
break;
}
case "tile_finished": {
const cell = ensureTileCell(evt.zoom, evt.x, evt.y);
cell.classList.remove("active");
cell.classList.add(evt.outcome);
finishedTiles += 1;
updateStatus();
break;
}
case "region_check_planned":
totalRegions = evt.total_regions;
checkedRegions = 0;
updateStatus();
break;
case "region_checked":
checkedRegions += 1;
if (checkedRegions === totalRegions || (checkedRegions & 0x1f) === 0) {
updateStatus();
}
break;
case "route_planned":
totalWaypoints = evt.total_waypoints;
resolvedWaypoints = 0;
updateStatus();
break;
case "route_waypoint_resolved":
resolvedWaypoints = evt.index + 1;
updateStatus();
break;
case "region_names_planned":
totalRegionNames = evt.total_regions;
resolvedRegionNames = 0;
updateStatus();
break;
case "region_name_resolved":
resolvedRegionNames = evt.index + 1;
if (
resolvedRegionNames === totalRegionNames ||
(resolvedRegionNames & 0x1f) === 0
) {
updateStatus();
}
break;
case "done":
renderStatusEl.textContent = "Render complete.";
break;
case "error":
renderStatusEl.textContent = `Render failed: ${evt.message}`;
break;
default:
break;
}
}
async function followJob(jobId, withWithoutRoute) {
return new Promise((resolve, reject) => {
const source = new EventSource(`/api/render/${jobId}/events`);
let failedMessage = null;
source.onmessage = (ev) => {
try {
const evt = JSON.parse(ev.data);
handleProgress(evt);
if (evt.type === "error") {
failedMessage = evt.message;
}
if (evt.type === "done" || evt.type === "error") {
source.close();
if (failedMessage) reject(new Error(failedMessage));
else resolve();
}
} catch (_err) {
}
};
source.onerror = () => {
source.close();
resolve();
};
}).then(async () => {
const metaResp = await fetch(`/api/render/${jobId}/metadata`);
if (!metaResp.ok) throw new Error(await metaResp.text());
const meta = await metaResp.json();
showResult(jobId, meta, withWithoutRoute);
});
}
function showResult(jobId, meta, withWithoutRoute) {
hide(renderProgressEl);
show(renderResultEl);
const ratioStr = (meta.aspect_ratio || 0).toFixed(4);
$("render-metadata").textContent =
`Aspect ratio: ${meta.aspect_x}:${meta.aspect_y} (${ratioStr}). ` +
`PPS HUD config: ${meta.pps_hud_config}`;
const img = $("result-image");
img.src = `/api/render/${jobId}/image`;
const dl = $("download-image");
dl.href = img.src;
dl.download = `sl-map-${jobId}.${$("format").value === "jpeg" ? "jpg" : "png"}`;
const dlNoRoute = $("download-without-route");
if (withWithoutRoute) {
dlNoRoute.href = `/api/render/${jobId}/image-without-route`;
dlNoRoute.download = `sl-map-no-route-${jobId}.${$("format").value === "jpeg" ? "jpg" : "png"}`;
show(dlNoRoute);
} else {
hide(dlNoRoute);
}
const metaBtn = $("result-metadata-btn");
if (metaBtn) metaBtn.onclick = () => metadataModal(meta);
}
async function renderGrid() {
startRenderUI();
try {
const glw = readGlwOptions();
const body = {
lower_left_x: parseInt($("ll_x").value, 10),
lower_left_y: parseInt($("ll_y").value, 10),
upper_right_x: parseInt($("ur_x").value, 10),
upper_right_y: parseInt($("ur_y").value, 10),
...readSharedParams(),
...readRegionOverlay(),
save_to: $("save_to").value,
};
if (glw) body.glw = glw;
const labels = readLabels();
if (labels.length) body.labels = labels;
const logos = readLogos();
if (logos.length) body.logos = logos;
const resp = await fetch("/api/render/grid-rectangle", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error(await errorText(resp));
const { job_id } = await resp.json();
await followJob(job_id, false);
} catch (err) {
renderStatusEl.textContent = `Render failed: ${err.message}`;
}
}
async function renderNotecard() {
startRenderUI();
try {
const fd = new FormData();
appendNotecardSourceToForm(fd);
appendBordersToForm(fd);
const shared = readSharedParams();
fd.append("max_width", String(shared.max_width));
fd.append("max_height", String(shared.max_height));
fd.append("format", shared.format);
if (shared.missing_map_tile_color) {
fd.append("missing_map_tile_color", shared.missing_map_tile_color);
}
if (shared.missing_region_color) {
fd.append("missing_region_color", shared.missing_region_color);
}
fd.append("color", $("route_color").value);
fd.append("save_to", $("save_to").value);
const withWithoutRoute = $("save_without_route").checked;
if (withWithoutRoute) fd.append("save_without_route", "true");
appendRegionOverlayToForm(fd);
const glw = readGlwOptions();
if (glw) fd.append("glw_json", JSON.stringify(glw));
const labels = readLabels();
if (labels.length) fd.append("labels_json", JSON.stringify(labels));
const logos = readLogos();
if (logos.length) fd.append("logos_json", JSON.stringify(logos));
const resp = await fetch("/api/render/usb-notecard", {
method: "POST",
body: fd,
});
if (!resp.ok) throw new Error(await errorText(resp));
const { job_id, notecard } = await resp.json();
if (notecard) addNotecardOptionIfNew(notecard);
await followJob(job_id, withWithoutRoute);
} catch (err) {
renderStatusEl.textContent = `Render failed: ${err.message}`;
}
}
if (ON_RENDER_PAGE)
$("generate_btn").addEventListener("click", () => {
if (activeTab() === "notecard") renderNotecard();
else renderGrid();
});
function addNotecardOptionIfNew({ notecard_id, name, scope }) {
const scopeSel = $("reuse_scope");
const ncSel = $("reuse_notecard_id");
if (!scopeSel || !ncSel) return;
if (scopeSel.value !== scope) return;
for (const o of ncSel.options) {
if (o.value === notecard_id) return;
}
if (ncSel.options.length === 1 && ncSel.options[0].value === "") {
ncSel.replaceChildren();
}
const o = document.createElement("option");
o.value = notecard_id;
o.textContent = name;
ncSel.appendChild(o);
}
async function applyPrefillFromQuery() {
const params = new URLSearchParams(window.location.search);
const reuse = params.get("reuse_notecard");
const regen = params.get("regenerate");
if (isUuid(reuse)) {
selectReuseNotecard(reuse).catch(() => {});
}
if (isUuid(regen)) {
try {
const resp = await fetch(`/api/renders/${regen}/settings`);
if (!resp.ok) throw new Error(await resp.text());
const settings = await resp.json();
applySettings(settings);
} catch (err) {
console.error("regenerate prefill failed:", err);
}
}
}
function applyRegionOverlaySettings(s) {
$("draw_region_rectangles").checked = !!s.draw_region_rectangles;
$("draw_region_names").checked = !!s.draw_region_names;
$("draw_region_coordinates").checked = !!s.draw_region_coordinates;
if (s.region_label_font_id) {
populateFontSelect($("region_label_font_id"), s.region_label_font_id);
}
}
function applySettings(s) {
initSlotGroups();
if (s.kind === "grid_rectangle") {
$("ll_x").value = s.lower_left_x;
$("ll_y").value = s.lower_left_y;
$("ur_x").value = s.upper_right_x;
$("ur_y").value = s.upper_right_y;
$("max_width").value = s.max_width;
$("max_height").value = s.max_height;
$("format").value = s.format;
if (s.missing_map_tile_color) {
$("missing_map_tile_enabled").checked = true;
$("missing_map_tile_color").disabled = false;
$("missing_map_tile_color").value = s.missing_map_tile_color;
}
if (s.missing_region_color) {
$("missing_region_enabled").checked = true;
$("missing_region_color").disabled = false;
$("missing_region_color").value = s.missing_region_color;
}
applyRegionOverlaySettings(s);
applyGlwSettings(s.glw);
applyLabels(s.labels);
applyLogos(s.logos);
const tab = document.querySelector('.tab[data-tab="grid"]');
if (tab) tab.click();
} else if (s.kind === "usb_notecard") {
$("max_width").value = s.max_width;
$("max_height").value = s.max_height;
$("format").value = s.format;
if (s.missing_map_tile_color) {
$("missing_map_tile_enabled").checked = true;
$("missing_map_tile_color").disabled = false;
$("missing_map_tile_color").value = s.missing_map_tile_color;
}
if (s.missing_region_color) {
$("missing_region_enabled").checked = true;
$("missing_region_color").disabled = false;
$("missing_region_color").value = s.missing_region_color;
}
$("border_north").value = s.border_north || "";
$("border_south").value = s.border_south || "";
$("border_east").value = s.border_east || "";
$("border_west").value = s.border_west || "";
if (s.color) $("route_color").value = s.color;
$("save_without_route").checked = !!s.save_without_route;
applyRegionOverlaySettings(s);
if (s.notecard_id) {
selectReuseNotecard(s.notecard_id).catch(() => {});
}
applyGlwSettings(s.glw);
applyLabels(s.labels);
applyLogos(s.logos);
const tab = document.querySelector('.tab[data-tab="notecard"]');
if (tab) tab.click();
}
}
async function selectReuseNotecard(notecardId) {
const tab = document.querySelector('.tab[data-tab="notecard"]');
if (tab) tab.click();
activateSubtab("reuse");
let scopeValue = "personal";
try {
const r = await fetch(`/api/notecards/${notecardId}`);
if (r.ok) {
const data = await r.json();
const dest = data.notecard && data.notecard.destination;
if (dest && dest.kind === "group" && isUuid(dest.group_id)) {
scopeValue = `group:${dest.group_id}`;
} else {
scopeValue = "personal";
}
}
} catch (_err) {
}
const scopeSel = $("reuse_scope");
if (!scopeSel) return;
for (let i = 0; i < 10; i++) {
if (Array.from(scopeSel.options).some((o) => o.value === scopeValue)) {
break;
}
await new Promise((r) => setTimeout(r, 50));
}
scopeSel.value = scopeValue;
await loadNotecardsForScope(scopeValue);
const ncSel = $("reuse_notecard_id");
if (ncSel) ncSel.value = notecardId;
}
if (ON_RENDER_PAGE)
document.addEventListener("DOMContentLoaded", applyPrefillFromQuery);
function refreshGlwPanelVisibility() {
const enabled = $("glw_enabled").checked;
const body = $("glw-body");
if (enabled) body.removeAttribute("hidden");
else body.setAttribute("hidden", "");
const source = activeGlwSource();
for (const el of document.querySelectorAll("[data-glw-source-not]")) {
el.style.display = el.dataset.glwSourceNot === source ? "none" : "";
}
}
function activateGlwSource(name) {
document.querySelectorAll(".glw-tab").forEach((t) => {
t.classList.toggle("active", t.dataset.glwTab === name);
});
document.querySelectorAll(".glw-tab-panel").forEach((p) => {
p.classList.toggle("active", p.id === `glw-source-${name}`);
});
refreshGlwPanelVisibility();
if (name === "saved") loadSavedGlw().catch(() => {});
}
function activeGlwSource() {
const t = document.querySelector(".glw-tab.active");
return t ? t.dataset.glwTab : "event_id";
}
async function loadFonts() {
const sel = $("glw_font_id");
if (!sel) return;
try {
const resp = await fetch("/api/fonts");
if (!resp.ok) return;
const { fonts } = await resp.json();
loadedFonts = fonts;
sel.replaceChildren();
for (const f of fonts) {
const opt = document.createElement("option");
opt.value = f.id;
opt.textContent = f.name;
sel.appendChild(opt);
}
if (fonts.length === 1) sel.value = fonts[0].id;
document
.querySelectorAll(".label-font")
.forEach((labelSel) => populateFontSelect(labelSel));
populateFontSelect($("region_label_font_id"));
} catch (err) {
console.error("font list failed:", err);
}
}
async function loadSavedGlw() {
const sel = $("glw_saved_id");
if (!sel) return;
const scope = $("save_to").value || "personal";
try {
const resp = await fetch(`/api/glw?scope=${encodeURIComponent(scope)}`);
if (!resp.ok) {
sel.replaceChildren();
return;
}
const { glw_data } = await resp.json();
sel.replaceChildren();
for (const g of glw_data) {
const opt = document.createElement("option");
opt.value = g.glw_data_id;
opt.textContent = g.name;
sel.appendChild(opt);
}
if (glw_data.length === 0) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "(none yet — pick another source)";
sel.appendChild(opt);
}
} catch (err) {
console.error("saved glw list failed:", err);
}
}
const GLW_COLOR_FIELDS = [
{ key: "area_outline_color", id: "glw_area_outline_color" },
{ key: "circle_outline_color", id: "glw_circle_outline_color" },
{ key: "margin_outline_color", id: "glw_margin_outline_color" },
{ key: "wind_color", id: "glw_wind_color" },
{ key: "current_color", id: "glw_current_color" },
{ key: "wave_color", id: "glw_wave_color" },
{ key: "label_color", id: "glw_label_color" },
];
let glwStyleDefaults = null;
async function loadGlwStyleDefaults() {
try {
const resp = await fetch("/api/glw/style-defaults");
if (!resp.ok) return;
glwStyleDefaults = await resp.json();
} catch (err) {
console.error("glw style defaults failed:", err);
return;
}
applyGlwStyleDefaults();
}
function applyGlwStyleDefaults() {
if (!glwStyleDefaults) return;
const mb = $("glw_margin_band");
if (mb) mb.checked = !!glwStyleDefaults.margin_band;
for (const { key, id } of GLW_COLOR_FIELDS) {
const el = $(id);
const def = glwStyleDefaults[key];
if (el && def) el.value = def;
}
}
function readGlwOptions() {
if (!$("glw_enabled").checked) return null;
const source = readGlwSource();
const fontId = $("glw_font_id").value;
if (!fontId) {
throw new Error("Pick a font for the GLW labels.");
}
const style = {
margin_band: $("glw_margin_band").checked,
area_outline_color: optionalColor(
"area_outline_color",
"glw_area_outline_color",
),
circle_outline_color: optionalColor(
"circle_outline_color",
"glw_circle_outline_color",
),
margin_outline_color: optionalColor(
"margin_outline_color",
"glw_margin_outline_color",
),
wind_color: optionalColor("wind_color", "glw_wind_color"),
current_color: optionalColor("current_color", "glw_current_color"),
wave_color: optionalColor("wave_color", "glw_wave_color"),
label_color: optionalColor("label_color", "glw_label_color"),
};
const opts = { source, font_id: fontId, style };
opts.legend_slot = legendSlotFromState();
if (activeGlwSource() !== "saved") {
const saveAs = $("glw_save_as").value.trim();
if (saveAs) opts.save_as = saveAs;
}
return opts;
}
function readGlwSource() {
switch (activeGlwSource()) {
case "event_id": {
const raw = $("glw_event_id").value.trim();
if (!raw) throw new Error("Enter the GLW event id.");
const event_id = parseInt(raw, 10);
if (!Number.isFinite(event_id) || event_id < 0) {
throw new Error("GLW event id must be a non-negative integer.");
}
return { type: "event_id", event_id };
}
case "event_key": {
const event_key = $("glw_event_key").value.trim();
if (!event_key) throw new Error("Enter the GLW event key.");
return { type: "event_key", event_key };
}
case "saved": {
const glw_data_id = $("glw_saved_id").value.trim();
if (!glw_data_id) throw new Error("Pick a saved GLW row.");
return { type: "saved_id", glw_data_id };
}
case "pasted": {
const payload = $("glw_pasted").value.trim();
if (!payload) throw new Error("Paste the GLW event JSON.");
return { type: "pasted_json", payload };
}
default:
return null;
}
}
function optionalColor(key, id) {
const el = $(id);
if (!el) return null;
const v = el.value;
if (!v) return null;
const def = glwStyleDefaults ? glwStyleDefaults[key] : null;
if (def) return v.toLowerCase() === def.toLowerCase() ? null : v;
return v === "#000000" ? null : v;
}
function applyGlwSettings(glw) {
if (!glw) return;
$("glw_enabled").checked = true;
refreshGlwPanelVisibility();
if (glw.font_id) $("glw_font_id").value = glw.font_id;
if (glw.legend_slot && glw.legend_slot !== "none") {
const g = groupOf(glw.legend_slot);
if (g) {
g.type = "legend";
g.config = null;
}
}
if (glw.save_as) $("glw_save_as").value = glw.save_as;
if (glw.source && glw.source.type === "saved_id") {
activateGlwSource("saved");
loadSavedGlw().then(() => {
const sel = $("glw_saved_id");
if (sel) sel.value = glw.source.glw_data_id;
});
}
if (glw.style) {
if ("margin_band" in glw.style)
$("glw_margin_band").checked = !!glw.style.margin_band;
for (const { key, id } of GLW_COLOR_FIELDS) {
const el = $(id);
if (!el) continue;
if (glw.style[key]) el.value = glw.style[key];
else if (glwStyleDefaults && glwStyleDefaults[key])
el.value = glwStyleDefaults[key];
}
}
}
document.addEventListener("DOMContentLoaded", () => {
const enabled = $("glw_enabled");
if (!enabled) return;
enabled.addEventListener("change", refreshGlwPanelVisibility);
document.querySelectorAll(".glw-tab").forEach((tab) => {
tab.addEventListener("click", () => activateGlwSource(tab.dataset.glwTab));
});
$("save_to").addEventListener("change", () => {
if (activeGlwSource() === "saved") loadSavedGlw().catch(() => {});
loadLogosForScope().catch(() => {});
});
refreshGlwPanelVisibility();
loadFonts().catch(() => {});
loadGlwStyleDefaults().catch(() => {});
});
const SLOT_ANCHORS = [
"top_left",
"top_center",
"top_right",
"middle_left",
"center",
"middle_right",
"bottom_left",
"bottom_center",
"bottom_right",
];
const SLOT_LABELS = {
top_left: "Top left",
top_center: "Top centre",
top_right: "Top right",
middle_left: "Middle left",
center: "Centre",
middle_right: "Middle right",
bottom_left: "Bottom left",
bottom_center: "Bottom centre",
bottom_right: "Bottom right",
};
const SLOT_CELL = {
top_left: [0, 0],
top_center: [1, 0],
top_right: [2, 0],
middle_left: [0, 1],
center: [1, 1],
middle_right: [2, 1],
bottom_left: [0, 2],
bottom_center: [1, 2],
bottom_right: [2, 2],
};
let loadedFonts = [];
let lastPlacementSlots = null;
let lastGroupRects = {};
let lastPlacementImageSize = null;
let lastPreviewRect = null;
function populateFontSelect(sel, selected) {
if (!sel) return;
if (selected) sel.dataset.want = selected;
sel.replaceChildren();
for (const f of loadedFonts) {
const opt = document.createElement("option");
opt.value = f.id;
opt.textContent = f.name;
sel.appendChild(opt);
}
if (sel.dataset.want) sel.value = sel.dataset.want;
else if (loadedFonts.length === 1) sel.value = loadedFonts[0].id;
}
function slotDefaultAlign(anchor) {
if (anchor === "center") return { h: "center", v: "center" };
const [vertical, horizontal] = anchor.split("_");
const v =
vertical === "top" ? "top" : vertical === "bottom" ? "bottom" : "center";
const h =
horizontal === "left"
? "left"
: horizontal === "right"
? "right"
: "center";
return { h, v };
}
function setGenerateEnabled(ok) {
const btn = $("generate_btn");
if (btn) btn.disabled = !ok;
}
let slotGroups = [];
let slotToGroup = new Map();
let nextGroupId = 1;
function initSlotGroups() {
slotGroups = [];
slotToGroup = new Map();
nextGroupId = 1;
for (const a of SLOT_ANCHORS) addGroup([a], "none", null);
}
function sortAnchors(slots) {
return [...new Set(slots)].sort(
(a, b) => SLOT_ANCHORS.indexOf(a) - SLOT_ANCHORS.indexOf(b),
);
}
function addGroup(slots, type, config) {
const g = {
id: nextGroupId++,
slots: sortAnchors(slots),
type,
config,
error: null,
};
slotGroups.push(g);
for (const a of g.slots) slotToGroup.set(a, g);
return g;
}
function removeGroupObj(g) {
slotGroups = slotGroups.filter((x) => x !== g);
for (const a of g.slots) if (slotToGroup.get(a) === g) slotToGroup.delete(a);
}
function groupOf(anchor) {
return slotToGroup.get(anchor) || null;
}
function primaryAnchor(g) {
return g.slots[0];
}
function groupName(g) {
return g.slots.map((a) => SLOT_LABELS[a]).join(" + ");
}
function describe(g) {
return g.type === "label"
? "the text label"
: g.type === "logo"
? "the logo"
: g.type === "legend"
? "the GLW legend"
: "nothing";
}
function legendSlotFromState() {
const g = slotGroups.find((x) => x.type === "legend");
return g ? primaryAnchor(g) : "none";
}
function assignGroup(g, type, config) {
if (type === "legend") {
for (const o of slotGroups)
if (o !== g && o.type === "legend") {
o.type = "none";
o.config = null;
}
}
g.type = type;
g.config = config;
refreshPlacement();
}
function setLegend(g) {
assignGroup(g, "legend", null);
}
async function clearGroup(g) {
if (g.type === "none") return;
g.type = "none";
g.config = null;
refreshPlacement();
}
function clearLegendWhenGlwDisabled() {
if ($("glw_enabled") && $("glw_enabled").checked) return false;
let cleared = false;
for (const g of slotGroups) {
if (g.type === "legend") {
g.type = "none";
g.config = null;
cleared = true;
}
}
return cleared;
}
function ensureGroupForSlots(slots) {
const set = sortAnchors(slots);
for (const a of set) {
const g = groupOf(a);
if (g) removeGroupObj(g);
}
return addGroup(set, "none", null);
}
function refreshPlacement() {
validateLabels();
refreshPlacementPreview();
}
function refreshPlacementPreview() {
const vp = $("preview-container").querySelector(".viewport");
if (!vp || !lastPreviewRect) return;
drawLegendOverlay(vp, lastPreviewRect);
drawPlacementOverlay(vp, lastPreviewRect);
}
function refreshRegionOverlay() {
const vp = $("preview-container").querySelector(".viewport");
if (!vp || !lastPreviewRect) return;
drawRegionOverlay(vp, lastPreviewRect);
}
async function measureText(fontId, fontPx, lines) {
const resp = await fetch("/api/text/measure", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ font_id: fontId, font_px: fontPx, lines }),
});
if (!resp.ok) throw new Error(await resp.text());
return resp.json();
}
async function remeasureAllLabels() {
for (const g of slotGroups) {
if (g.type !== "label") continue;
try {
const m = await measureText(
g.config.font_id,
g.config.font_px,
g.config.lines,
);
g.config.mw = m.width;
g.config.mh = m.height;
} catch (_err) {
}
}
validateLabels();
}
function groupKey(slots) {
return slots.join(",");
}
function rectForGroup(g) {
if (!lastPlacementSlots) return null;
if (g.slots.length === 1) {
const s = lastPlacementSlots[g.slots[0]];
if (!s) return null;
return {
available: s.available,
free_rect: s.free_rect || null,
free_width: s.free_width,
free_height: s.free_height,
};
}
const gr = lastGroupRects[groupKey(g.slots)];
if (!gr) return null;
return {
available: gr.available,
free_rect: gr.free_rect || null,
free_width: gr.free_width,
free_height: gr.free_height,
};
}
function readLabels(includeErrored = true) {
const out = [];
for (const g of slotGroups) {
if (g.type !== "label") continue;
if (!includeErrored && g.error) continue;
const c = g.config;
out.push({
slot: primaryAnchor(g),
slots: g.slots.slice(),
lines: c.lines,
font_id: c.font_id,
font_px: c.font_px,
color: c.color,
h_align: c.h_align,
v_align: c.v_align,
});
}
return out;
}
function validateLabels() {
let ok = true;
for (const g of slotGroups) {
g.error = null;
if (g.type === "none") continue;
const rect = rectForGroup(g);
if (!lastPlacementSlots) {
g.error = "Preview to check fit";
ok = false;
continue;
}
if (!rect || !rect.available) {
g.error = "Covered by route / GLW";
ok = false;
continue;
}
if (g.type === "label") {
const c = g.config;
if (c.mw != null && c.mh != null) {
if (c.mw > rect.free_width || c.mh > rect.free_height) {
g.error = `Text ${c.mw}×${c.mh} too big for ${rect.free_width}×${rect.free_height}`;
ok = false;
}
}
} else if (g.type === "logo") {
const c = g.config;
const w = c.w * c.scale;
const h = c.h * c.scale;
if (w > rect.free_width || h > rect.free_height) {
g.error = `Logo ${w}×${h} too big for ${rect.free_width}×${rect.free_height}`;
ok = false;
}
}
}
setGenerateEnabled(ok);
redrawSlotsOverlay();
}
function applyLabels(labels) {
if (Array.isArray(labels)) {
for (const l of labels) {
const g = ensureGroupForSlots(
l.slots && l.slots.length ? l.slots : [l.slot],
);
g.type = "label";
g.config = {
lines: Array.isArray(l.lines) ? l.lines : [],
font_id: l.font_id,
font_px: l.font_px,
color: l.color || "#ffffff",
h_align: l.h_align || slotDefaultAlign(primaryAnchor(g)).h,
v_align: l.v_align || slotDefaultAlign(primaryAnchor(g)).v,
mw: null,
mh: null,
};
}
}
remeasureAllLabels().catch(() => {});
}
async function editLabel(g) {
const anchor = primaryAnchor(g);
const existing = g.type === "label" ? g.config : null;
const def = slotDefaultAlign(anchor);
const value = await formModal({
title: existing ? "Edit text label" : "Add text label",
okText: existing ? "Save" : "Add",
build: (dialog) => {
const form = $(
"label-modal-template",
).content.firstElementChild.cloneNode(true);
dialog.appendChild(form);
const lines = form.querySelector(".lm-lines");
const font = form.querySelector(".lm-font");
const size = form.querySelector(".lm-size");
const color = form.querySelector(".lm-color");
const ha = form.querySelector(".lm-halign");
const va = form.querySelector(".lm-valign");
const fit = form.querySelector(".lm-fit");
populateFontSelect(font, existing ? existing.font_id : undefined);
if (existing) {
lines.value = existing.lines.join("\n");
size.value = existing.font_px;
color.value = existing.color;
ha.value = existing.h_align;
va.value = existing.v_align;
} else {
ha.value = def.h;
va.value = def.v;
}
const rect = rectForGroup(g);
const refresh = debounce(async () => {
const txt = lines.value.split("\n");
const px = parseFloat(size.value);
if (txt.every((l) => l.trim() === "") || !font.value || !(px > 0)) {
fit.textContent = "";
return;
}
try {
const m = await measureText(font.value, px, txt);
let s = `Text ${m.width}×${m.height}px`;
if (rect && rect.available) {
s += ` — slot free ${rect.free_width}×${rect.free_height}px`;
if (m.width > rect.free_width || m.height > rect.free_height)
s += " (too large)";
}
fit.textContent = s;
} catch (err) {
fit.textContent = `Could not measure: ${err.message}`;
}
}, 250);
lines.addEventListener("input", refresh);
size.addEventListener("input", refresh);
font.addEventListener("change", refresh);
refresh();
return async () => {
const txt = lines.value.split("\n");
if (txt.every((l) => l.trim() === "")) {
fit.textContent = "Enter some text.";
return null;
}
const px = parseFloat(size.value);
if (!(px > 0)) {
fit.textContent = "Enter a positive size.";
return null;
}
if (!font.value) {
fit.textContent = "Pick a font.";
return null;
}
let m;
try {
m = await measureText(font.value, px, txt);
} catch (err) {
fit.textContent = `Could not measure: ${err.message}`;
return null;
}
return {
lines: txt,
font_id: font.value,
font_px: px,
color: color.value,
h_align: ha.value,
v_align: va.value,
mw: m.width,
mh: m.height,
};
};
},
});
if (value === null) return;
assignGroup(g, "label", value);
}
let loadedLogos = [];
function populateLogoSelect(sel, selected) {
if (!sel) return;
if (selected) sel.dataset.want = selected;
const want = sel.dataset.want || sel.value;
sel.replaceChildren();
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = loadedLogos.length
? "(choose a logo)"
: "(no logos in this scope)";
sel.appendChild(placeholder);
for (const l of loadedLogos) {
const opt = document.createElement("option");
opt.value = l.logo_id;
opt.textContent = `${l.name} (${l.width}×${l.height})`;
opt.dataset.w = String(l.width);
opt.dataset.h = String(l.height);
sel.appendChild(opt);
}
if (want && loadedLogos.some((l) => l.logo_id === want)) sel.value = want;
}
async function loadLogosForScope() {
const scope = $("save_to") ? $("save_to").value || "personal" : "personal";
try {
const resp = await fetch(`/api/logos?scope=${encodeURIComponent(scope)}`);
loadedLogos = resp.ok ? (await resp.json()).logos || [] : [];
} catch (_err) {
loadedLogos = [];
}
validateLabels();
}
function readLogos(includeErrored = true) {
const out = [];
for (const g of slotGroups) {
if (g.type !== "logo") continue;
if (!includeErrored && g.error) continue;
const c = g.config;
out.push({
slot: primaryAnchor(g),
slots: g.slots.slice(),
logo_id: c.logo_id,
scale: c.scale,
h_align: c.h_align,
v_align: c.v_align,
});
}
return out;
}
function logoSizeOf(logoId) {
const l = loadedLogos.find((x) => x.logo_id === logoId);
return l ? { w: l.width, h: l.height } : null;
}
function applyLogos(logos) {
if (Array.isArray(logos)) {
for (const l of logos) {
const g = ensureGroupForSlots(
l.slots && l.slots.length ? l.slots : [l.slot],
);
const size = logoSizeOf(l.logo_id) || { w: 0, h: 0 };
g.type = "logo";
g.config = {
logo_id: l.logo_id,
scale: l.scale || 1,
h_align: l.h_align || slotDefaultAlign(primaryAnchor(g)).h,
v_align: l.v_align || slotDefaultAlign(primaryAnchor(g)).v,
w: size.w,
h: size.h,
};
}
}
validateLabels();
}
async function editLogo(g) {
const anchor = primaryAnchor(g);
const existing = g.type === "logo" ? g.config : null;
const def = slotDefaultAlign(anchor);
let uploadUrl = null;
const value = await formModal({
title: existing ? "Edit logo" : "Add logo",
okText: existing ? "Save" : "Add",
build: (dialog) => {
const form = $("logo-modal-template").content.firstElementChild.cloneNode(
true,
);
dialog.appendChild(form);
const pick = form.querySelector(".gm-pick");
const scale = form.querySelector(".gm-scale");
const ha = form.querySelector(".gm-halign");
const va = form.querySelector(".gm-valign");
const preview = form.querySelector(".gm-preview");
const fit = form.querySelector(".gm-fit");
const uploadName = form.querySelector(".gm-upload-name");
const uploadFile = form.querySelector(".gm-upload-file");
const uploadStatus = form.querySelector(".gm-upload-status");
const sourceTabs = form.querySelectorAll(".gm-source-tab");
const sourcePanels = form.querySelectorAll(".gm-source-panel");
populateLogoSelect(pick, existing ? existing.logo_id : undefined);
if (existing) {
scale.value = String(existing.scale);
ha.value = existing.h_align;
va.value = existing.v_align;
} else {
ha.value = def.h;
va.value = def.v;
}
const rect = rectForGroup(g);
let source = !existing && loadedLogos.length === 0 ? "upload" : "reuse";
let uploadDims = null;
const setPreview = (src) => {
if (src) {
preview.src = src;
preview.classList.remove("hidden");
} else {
preview.removeAttribute("src");
preview.classList.add("hidden");
}
};
const refresh = () => {
let dims = null;
if (source === "upload") {
setPreview(uploadUrl);
dims = uploadDims;
} else {
setPreview(pick.value ? `/api/logos/${pick.value}/image` : null);
const opt = pick.selectedOptions[0];
if (pick.value && opt)
dims = {
w: parseInt(opt.dataset.w, 10) || 0,
h: parseInt(opt.dataset.h, 10) || 0,
};
}
const s = parseInt(scale.value, 10) || 1;
const w = (dims ? dims.w : 0) * s;
const h = (dims ? dims.h : 0) * s;
if (w && h) {
let t = `Logo ${w}×${h}px`;
if (rect && rect.available) {
t += ` — slot free ${rect.free_width}×${rect.free_height}px`;
if (w > rect.free_width || h > rect.free_height)
t += " (too large)";
}
fit.textContent = t;
} else {
fit.textContent = "";
}
};
const activateSource = (name) => {
source = name;
sourceTabs.forEach((t) =>
t.classList.toggle("active", t.dataset.logoSource === name),
);
sourcePanels.forEach((p) =>
p.classList.toggle("active", p.dataset.logoSource === name),
);
refresh();
};
sourceTabs.forEach((t) =>
t.addEventListener("click", () => activateSource(t.dataset.logoSource)),
);
pick.addEventListener("change", refresh);
scale.addEventListener("change", refresh);
uploadFile.addEventListener("change", () => {
if (uploadUrl) URL.revokeObjectURL(uploadUrl);
uploadUrl = null;
uploadDims = null;
uploadStatus.textContent = "";
const file = uploadFile.files && uploadFile.files[0];
if (file) {
uploadUrl = URL.createObjectURL(file);
const probe = new Image();
probe.onload = () => {
uploadDims = { w: probe.naturalWidth, h: probe.naturalHeight };
refresh();
};
probe.src = uploadUrl;
}
refresh();
});
activateSource(source);
return async () => {
if (source === "upload") {
const name = uploadName.value.trim();
const file = uploadFile.files && uploadFile.files[0];
if (!name) {
uploadStatus.textContent = "Enter a name for the new logo.";
return null;
}
if (!file) {
uploadStatus.textContent = "Choose an image file to upload.";
return null;
}
const scope = $("save_to")
? $("save_to").value || "personal"
: "personal";
const fd = new FormData();
fd.append("scope", scope);
fd.append("name", name);
fd.append("file", file);
uploadStatus.textContent = "Uploading…";
let resp;
try {
resp = await fetch("/api/logos", { method: "POST", body: fd });
} catch (_err) {
uploadStatus.textContent = "Upload failed; check your connection.";
return null;
}
if (!resp.ok) {
uploadStatus.textContent = "";
await showError(resp);
return null;
}
const body = await resp.json().catch(() => ({}));
const logo = body.logo || {};
await loadLogosForScope();
return {
logo_id: logo.logo_id,
scale: parseInt(scale.value, 10) || 1,
h_align: ha.value,
v_align: va.value,
w: logo.width || 0,
h: logo.height || 0,
};
}
if (!pick.value) {
fit.textContent = "Choose a logo.";
return null;
}
const opt = pick.selectedOptions[0];
return {
logo_id: pick.value,
scale: parseInt(scale.value, 10) || 1,
h_align: ha.value,
v_align: va.value,
w: opt ? parseInt(opt.dataset.w, 10) || 0 : 0,
h: opt ? parseInt(opt.dataset.h, 10) || 0 : 0,
};
};
},
});
if (uploadUrl) URL.revokeObjectURL(uploadUrl);
if (value === null) return;
assignGroup(g, "logo", value);
}
function svgIcon(name) {
const NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("viewBox", "0 0 16 16");
svg.setAttribute("width", "14");
svg.setAttribute("height", "14");
svg.setAttribute("aria-hidden", "true");
const add = (tag, attrs) => {
const el = document.createElementNS(NS, tag);
for (const k of Object.keys(attrs)) el.setAttribute(k, String(attrs[k]));
el.setAttribute("fill", "none");
el.setAttribute("stroke", "currentColor");
el.setAttribute("stroke-width", "1.5");
el.setAttribute("stroke-linecap", "round");
el.setAttribute("stroke-linejoin", "round");
svg.appendChild(el);
};
switch (name) {
case "none":
add("circle", { cx: 8, cy: 8, r: 6 });
add("line", { x1: 4, y1: 4, x2: 12, y2: 12 });
break;
case "label":
add("path", { d: "M4 4h8" });
add("path", { d: "M8 4v8" });
break;
case "logo":
add("rect", { x: 2.5, y: 3, width: 11, height: 10, rx: 1 });
add("path", { d: "M3 11l3-3 2 2 2.5-2.5L13 11" });
add("circle", { cx: 6, cy: 6, r: 1 });
break;
case "legend":
add("path", { d: "M3 4.5h10" });
add("path", { d: "M3 8h10" });
add("path", { d: "M3 11.5h7" });
break;
case "combine":
add("path", { d: "M3 8h6" });
add("path", { d: "M9 5l3 3-3 3" });
break;
case "split":
add("path", { d: "M13 8H7" });
add("path", { d: "M7 5L4 8l3 3" });
break;
case "copy":
add("rect", { x: 5.5, y: 5.5, width: 7.5, height: 7.5, rx: 1 });
add("path", { d: "M3 10.5V3h7.5" });
break;
case "swap":
add("path", { d: "M4 6h8l-2.5-2.5" });
add("path", { d: "M12 10H4l2.5 2.5" });
break;
default:
break;
}
return svg;
}
function iconButton(name, title, active, onClick) {
const b = document.createElement("button");
b.type = "button";
b.className = "icon-btn" + (active ? " active" : "");
b.title = title;
b.setAttribute("aria-label", title);
b.appendChild(svgIcon(name));
b.addEventListener("click", (e) => {
e.stopPropagation();
onClick();
});
return b;
}
function onSlotButton(g, type) {
if (type === "none") clearGroup(g);
else if (type === "legend") setLegend(g);
else if (type === "label") editLabel(g);
else if (type === "logo") editLogo(g);
}
function redrawSlotsOverlay() {
const vp = $("preview-container").querySelector(".viewport");
if (vp) drawSlotsOverlay(vp);
}
function rectsTouch(a, b) {
const T = 1;
const xGap = Math.max(a.x, b.x) - Math.min(a.x + a.width, b.x + b.width);
const yGap = Math.max(a.y, b.y) - Math.min(a.y + a.height, b.y + b.height);
if (xGap > T || yGap > T) return false; return -xGap > T || -yGap > T;
}
function groupsFormRectangle(ga, gb) {
const cells = [...ga.slots, ...gb.slots].map((s) => SLOT_CELL[s]);
const cols = cells.map((c) => c[0]);
const rows = cells.map((c) => c[1]);
const c0 = Math.min(...cols);
const c1 = Math.max(...cols);
const r0 = Math.min(...rows);
const r1 = Math.max(...rows);
if (cells.length !== (c1 - c0 + 1) * (r1 - r0 + 1)) return false;
const present = new Set(cells.map(([c, r]) => `${c},${r}`));
for (let c = c0; c <= c1; c++) {
for (let r = r0; r <= r1; r++) {
if (!present.has(`${c},${r}`)) return false;
}
}
return true;
}
function drawCombineButtons(layer, bx, by, bw, bh) {
const seen = new Set();
for (let i = 0; i < SLOT_ANCHORS.length; i++) {
for (let j = i + 1; j < SLOT_ANCHORS.length; j++) {
const a = SLOT_ANCHORS[i];
const b = SLOT_ANCHORS[j];
const [ca, ra] = SLOT_CELL[a];
const [cb, rb] = SLOT_CELL[b];
if (Math.abs(ca - cb) + Math.abs(ra - rb) !== 1) continue; const ga = groupOf(a);
const gb = groupOf(b);
if (!ga || !gb || ga === gb) continue; const key = ga.id < gb.id ? `${ga.id}-${gb.id}` : `${gb.id}-${ga.id}`;
if (seen.has(key)) continue; const ra2 = rectForGroup(ga);
const rb2 = rectForGroup(gb);
if (!ra2 || !ra2.available || !ra2.free_rect) continue;
if (!rb2 || !rb2.available || !rb2.free_rect) continue;
if (!rectsTouch(ra2.free_rect, rb2.free_rect)) continue;
if (!groupsFormRectangle(ga, gb)) continue;
seen.add(key);
const cx = bx + ((ca + cb) / 2 + 0.5) * (bw / 3);
const cy = by + ((ra + rb) / 2 + 0.5) * (bh / 3);
const btn = iconButton(
"combine",
`Combine ${groupName(ga)} + ${groupName(gb)}`,
false,
() => combineSlots(a, b),
);
btn.classList.add("slot-combine");
btn.style.left = `${cx.toFixed(1)}px`;
btn.style.top = `${cy.toFixed(1)}px`;
layer.appendChild(btn);
}
}
}
function drawSlotsOverlay(viewport) {
if (!viewport) return;
const existing = viewport.querySelector(".slots-overlay");
if (existing) existing.remove();
const toggle = $("show_slots");
if (!toggle || !toggle.checked) return;
if (!lastPlacementSlots || !lastPlacementImageSize) return;
clearLegendWhenGlwDisabled();
const bx = parseFloat(viewport.dataset.boundsX);
const by = parseFloat(viewport.dataset.boundsY);
const bw = parseFloat(viewport.dataset.boundsW);
const bh = parseFloat(viewport.dataset.boundsH);
const imgW = lastPlacementImageSize.width;
const imgH = lastPlacementImageSize.height;
if (![bx, by, bw, bh].every(Number.isFinite) || !(imgW > 0) || !(imgH > 0)) {
return;
}
const sx = bw / imgW;
const sy = bh / imgH;
const layer = document.createElement("div");
layer.className = "slots-overlay";
for (const g of slotGroups) {
const rect = rectForGroup(g);
if (!rect || !rect.available || !rect.free_rect) {
if (g.slots.length === 1) {
const [col, row] = SLOT_CELL[g.slots[0]];
const marker = document.createElement("span");
marker.className = "slot-marker occupied";
marker.textContent = `${SLOT_LABELS[g.slots[0]]} occupied`;
marker.style.left = `${(bx + (col + 0.5) * (bw / 3)).toFixed(1)}px`;
marker.style.top = `${(by + (row + 0.5) * (bh / 3)).toFixed(1)}px`;
layer.appendChild(marker);
}
continue;
}
const fr = rect.free_rect;
const box = document.createElement("div");
box.className = "slot-box" + (g.error ? " error" : "");
box.style.left = `${(bx + fr.x * sx).toFixed(1)}px`;
box.style.top = `${(by + fr.y * sy).toFixed(1)}px`;
box.style.width = `${(fr.width * sx).toFixed(1)}px`;
box.style.height = `${(fr.height * sy).toFixed(1)}px`;
const label = document.createElement("span");
label.className = "slot-label";
label.textContent =
`${groupName(g)} · ${rect.free_width}×${rect.free_height}` +
(g.error ? ` — ${g.error}` : "");
label.title = label.textContent;
box.appendChild(label);
const btns = document.createElement("div");
btns.className = "slot-buttons";
const radios = [
["none", "None", "none"],
["label", "Text label", "label"],
["logo", "Logo", "logo"],
];
if ($("glw_enabled") && $("glw_enabled").checked) {
radios.push(["legend", "GLW legend", "legend"]);
}
for (const [type, title, icon] of radios) {
const b = iconButton(icon, title, g.type === type, () =>
onSlotButton(g, type),
);
b.classList.add("slot-radio");
btns.appendChild(b);
}
if (g.type !== "none") {
btns.appendChild(
iconButton("copy", "Copy these settings to another slot", false, () =>
startSlotAction("copy", g),
),
);
}
btns.appendChild(
iconButton("swap", "Swap settings with another slot", false, () =>
startSlotAction("swap", g),
),
);
if (g.slots.length > 1) {
const sp = iconButton("split", "Split combined slot", false, () =>
splitGroup(g),
);
sp.classList.add("slot-split");
btns.appendChild(sp);
}
box.appendChild(btns);
if (pendingSlotAction && pendingSlotAction.from !== g) {
box.classList.add("pick-target");
box.addEventListener("click", () => completeSlotAction(g));
}
layer.appendChild(box);
}
drawCombineButtons(layer, bx, by, bw, bh);
if (pendingSlotAction) layer.classList.add("picking");
viewport.appendChild(layer);
}
let pendingSlotAction = null;
function startSlotAction(kind, g) {
pendingSlotAction = { kind, from: g };
const st = $("placement-status");
if (st)
st.textContent =
kind === "copy"
? "Click another slot to copy these settings into (Esc to cancel)."
: "Click another slot to swap settings with (Esc to cancel).";
redrawSlotsOverlay();
}
function cancelSlotAction() {
if (!pendingSlotAction) return;
pendingSlotAction = null;
const st = $("placement-status");
if (st) st.textContent = "";
redrawSlotsOverlay();
}
function completeSlotAction(target) {
if (!pendingSlotAction) return;
const { kind, from } = pendingSlotAction;
pendingSlotAction = null;
const st = $("placement-status");
if (st) st.textContent = "";
if (target === from) {
redrawSlotsOverlay();
return;
}
if (kind === "copy") {
const config = from.config ? JSON.parse(JSON.stringify(from.config)) : null;
assignGroup(target, from.type, config);
} else {
const tType = target.type;
const tConfig = target.config;
target.type = from.type;
target.config = from.config;
from.type = tType;
from.config = tConfig;
refreshPlacement();
}
}
async function combineSlots(a, b) {
const ga = groupOf(a);
const gb = groupOf(b);
if (!ga || !gb || ga === gb) return;
let type = "none";
let config = null;
const aHas = ga.type !== "none";
const bHas = gb.type !== "none";
if (aHas && bHas) {
const keep = await choiceModal({
title: "Combine slots",
message: "Both slots have content. Which should the combined slot keep?",
choices: [
{ label: `Keep ${describe(ga)}`, value: "a" },
{ label: `Keep ${describe(gb)}`, value: "b" },
],
});
if (keep === null) return;
[type, config] = keep === "a" ? [ga.type, ga.config] : [gb.type, gb.config];
} else if (aHas) {
[type, config] = [ga.type, ga.config];
} else if (bHas) {
[type, config] = [gb.type, gb.config];
}
const slots = sortAnchors([...ga.slots, ...gb.slots]);
removeGroupObj(ga);
removeGroupObj(gb);
addGroup(slots, type, config);
await findFreeSlots();
refreshPlacementPreview();
}
async function splitGroup(g) {
const { type, config } = g;
const primary = primaryAnchor(g);
const slots = g.slots.slice();
removeGroupObj(g);
let primaryGroup = null;
for (const slot of slots) {
const ng = addGroup([slot], "none", null);
if (slot === primary) primaryGroup = ng;
}
if (type !== "none" && primaryGroup) {
primaryGroup.type = type;
primaryGroup.config = config;
}
await findFreeSlots();
refreshPlacementPreview();
}
async function findFreeSlots() {
const statusEl = $("placement-status");
statusEl.textContent = "Computing free slots…";
try {
const activeTab = document.querySelector(".tab.active");
const which = activeTab ? activeTab.dataset.tab : "grid";
const groupSlots = slotGroups
.filter((g) => g.slots.length > 1)
.map((g) => g.slots);
let resp;
if (which === "grid") {
const glw = readGlwOptions();
const body = {
lower_left_x: parseInt($("ll_x").value, 10),
lower_left_y: parseInt($("ll_y").value, 10),
upper_right_x: parseInt($("ur_x").value, 10),
upper_right_y: parseInt($("ur_y").value, 10),
...readSharedParams(),
};
if (glw) body.glw = glw;
if (groupSlots.length) body.groups = groupSlots;
resp = await fetch("/api/render/placement-slots/grid-rectangle", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
} else {
const fd = new FormData();
appendNotecardSourceToForm(fd);
appendBordersToForm(fd);
const shared = readSharedParams();
fd.append("max_width", String(shared.max_width));
fd.append("max_height", String(shared.max_height));
fd.append("format", shared.format);
if (shared.missing_map_tile_color)
fd.append("missing_map_tile_color", shared.missing_map_tile_color);
if (shared.missing_region_color)
fd.append("missing_region_color", shared.missing_region_color);
fd.append("color", $("route_color").value);
const glw = readGlwOptions();
if (glw) fd.append("glw_json", JSON.stringify(glw));
if (groupSlots.length)
fd.append("groups_json", JSON.stringify(groupSlots));
resp = await fetch("/api/render/placement-slots/usb-notecard", {
method: "POST",
body: fd,
});
}
if (!resp.ok) throw new Error(await resp.text());
const data = await resp.json();
lastPlacementSlots = {};
for (const s of data.slots) lastPlacementSlots[s.slot] = s;
lastGroupRects = {};
for (const gr of data.groups || []) lastGroupRects[groupKey(gr.slots)] = gr;
lastPlacementImageSize = {
width: data.image_width,
height: data.image_height,
};
statusEl.textContent = `Free slots for a ${data.image_width}×${data.image_height}px render.`;
validateLabels();
const vp = $("preview-container").querySelector(".viewport");
if (vp && lastPreviewRect) drawPlacementOverlay(vp, lastPreviewRect);
} catch (err) {
statusEl.textContent = `Could not compute slots: ${err.message}`;
}
}
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
if (ON_RENDER_PAGE)
document.addEventListener("DOMContentLoaded", () => {
initSlotGroups();
loadLogosForScope().catch(() => {});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && pendingSlotAction) cancelSlotAction();
});
const showSlots = $("show_slots");
if (showSlots)
showSlots.addEventListener("change", () => redrawSlotsOverlay());
const glwEnabled = $("glw_enabled");
if (glwEnabled)
glwEnabled.addEventListener("change", () => {
clearLegendWhenGlwDisabled();
validateLabels();
refreshPlacementPreview();
redrawSlotsOverlay();
});
[
"draw_region_rectangles",
"draw_region_names",
"draw_region_coordinates",
"region_label_font_id",
].forEach((id) => {
const el = $(id);
if (el)
el.addEventListener("change", () => {
refreshRegionOverlay();
updateFillHint();
});
});
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
lastPlacementSlots = null;
lastGroupRects = {};
lastPlacementImageSize = null;
const st = $("placement-status");
if (st) st.textContent = "";
validateLabels();
});
});
validateLabels();
});