shorter share URLs: deflate-compressed hash, integer-rounded coords, flat-array pin storage
This commit is contained in:
+107
-30
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user