shorter share URLs: deflate-compressed hash, integer-rounded coords, flat-array pin storage

This commit is contained in:
2026-04-25 22:52:25 +02:00
parent 18c7792935
commit 920408f7d3
+107 -30
View File
@@ -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");
}