From 920408f7d3f74fab28b21b9fcc0564a4bdfb0ae5 Mon Sep 17 00:00:00 2001 From: Florian Berthold Date: Sat, 25 Apr 2026 22:52:25 +0200 Subject: [PATCH] shorter share URLs: deflate-compressed hash, integer-rounded coords, flat-array pin storage --- web/app.js | 137 +++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 107 insertions(+), 30 deletions(-) diff --git a/web/app.js b/web/app.js index e6c8a85..d6fa35d 100644 --- a/web/app.js +++ b/web/app.js @@ -344,13 +344,14 @@ function onCanvasClick(e) { function svgPointFromEvent(e) { // Convert client coords → image-pixel coords (the SVG's own coord system, - // which matches the underlying webp's natural dimensions). + // which matches the underlying webp's natural dimensions). Integer + // pixels are plenty for routing — sub-pixel precision just bloats URLs. const m = currentMap(); const svg = $("overlay"); const rect = svg.getBoundingClientRect(); - const x = ((e.clientX - rect.left) / rect.width) * m.width; - const y = ((e.clientY - rect.top) / rect.height) * m.height; - return { x: +x.toFixed(1), y: +y.toFixed(1) }; + const x = Math.round(((e.clientX - rect.left) / rect.width) * m.width); + const y = Math.round(((e.clientY - rect.top) / rect.height) * m.height); + return { x, y }; } function removePoint(kind, idx) { @@ -521,46 +522,122 @@ function clearCurrent() { updateHash(); } -function updateHash() { +/* ----- URL hash share format ----- + * v1 (legacy): base64url of JSON with full {x,y} objects, no prefix. + * v2 (current): "~" + base64url of deflate-raw'd JSON. The waypoint + * arrays are flattened to [x1,y1,x2,y2,...] (ints) before compression to + * shave another ~30% before deflate runs. + * + * On a typical 30-pull RFK route, v1 produced ~3500 char URLs; v2 lands + * around 400-700 chars. + */ + +const HASH_PREFIX = "~"; + +function flattenPins(map) { + const out = {}; + for (const k of Object.keys(map || {})) { + const flat = []; + for (const p of map[k]) flat.push(Math.round(p.x), Math.round(p.y)); + out[k] = flat; + } + return out; +} + +function inflatePins(map) { + const out = {}; + for (const k of Object.keys(map || {})) { + const flat = map[k]; + const arr = []; + if (Array.isArray(flat) && flat.length && typeof flat[0] === "object") { + // Backward compat: legacy {x,y} objects, just round into ints. + for (const p of flat) arr.push({ x: Math.round(p.x), y: Math.round(p.y) }); + } else { + for (let i = 0; i + 1 < flat.length; i += 2) arr.push({ x: flat[i], y: flat[i + 1] }); + } + out[k] = arr; + } + return out; +} + +async function encodePayload(p) { + const json = JSON.stringify(p); + if (typeof CompressionStream === "undefined") { + return base64UrlEncodeStr(json); + } + const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("deflate-raw")); + const buf = new Uint8Array(await new Response(stream).arrayBuffer()); + return HASH_PREFIX + base64UrlEncodeBytes(buf); +} + +async function decodePayload(s) { + if (s.startsWith(HASH_PREFIX) && typeof DecompressionStream !== "undefined") { + const buf = base64UrlDecodeBytes(s.slice(1)); + const stream = new Blob([buf]).stream().pipeThrough(new DecompressionStream("deflate-raw")); + return JSON.parse(await new Response(stream).text()); + } + // Legacy: plain base64url JSON + return JSON.parse(base64UrlDecodeStr(s)); +} + +function base64UrlEncodeStr(s) { + return btoa(unescape(encodeURIComponent(s))) + .replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); +} +function base64UrlDecodeStr(s) { + s = s.replace(/-/g, "+").replace(/_/g, "/"); + while (s.length % 4) s += "="; + return decodeURIComponent(escape(atob(s))); +} +function base64UrlEncodeBytes(bytes) { + let bin = ""; + for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]); + return btoa(bin).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); +} +function base64UrlDecodeBytes(s) { + s = s.replace(/-/g, "+").replace(/_/g, "/"); + while (s.length % 4) s += "="; + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +async function updateHash() { if (!state.current) return; - const payload = { d: state.current.id, f: state.floorIndex, r: state.routes, p: state.pulls }; - const hash = "#" + compress(JSON.stringify(payload)); + const payload = { + d: state.current.id, + f: state.floorIndex, + r: flattenPins(state.routes), + p: flattenPins(state.pulls), + }; + const encoded = await encodePayload(payload); + const hash = "#" + encoded; if (location.hash !== hash) { history.replaceState(null, "", location.pathname + location.search + hash); } } -function loadFromHash() { +async function loadFromHash() { const h = location.hash.slice(1); if (!h) return; try { - const p = JSON.parse(decompress(h)); - if (p.d) { - const d = state.dungeons.find((x) => x.id === p.d); - if (d) { - state.current = d; - state.floorIndex = p.f || 0; - state.routes = p.r || {}; - state.pulls = p.p || {}; - renderViewer(); - } - } + const p = await decodePayload(h); + if (!p.d) return; + const d = state.dungeons.find((x) => x.id === p.d); + if (!d) return; + state.current = d; + state.floorIndex = p.f || 0; + state.routes = inflatePins(p.r); + state.pulls = inflatePins(p.p); + renderViewer(); } catch (e) { console.warn("hash parse failed", e); } } -function compress(s) { - return btoa(unescape(encodeURIComponent(s))).replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); -} -function decompress(s) { - s = s.replace(/-/g, "+").replace(/_/g, "/"); - while (s.length % 4) s += "="; - return decodeURIComponent(escape(atob(s))); -} - -function shareUrl() { - updateHash(); +async function shareUrl() { + await updateHash(); navigator.clipboard?.writeText(location.href); toast("Link copied"); }