Skip to content

Commit

Permalink
fix scroll find and implemented a clever compression algorithm to red…
Browse files Browse the repository at this point in the history
…uce size of shareurl
  • Loading branch information
GitPaulo authored Jan 24, 2025
1 parent 2684fba commit da8a78f
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 76 deletions.
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
},
"type": "module",
"dependencies": {
"@msgpack/msgpack": "^3.0.0-beta2",
"lodash": "^4.17.21",
"lz-string": "^1.5.0"
}
Expand Down
4 changes: 4 additions & 0 deletions src/lib/components/Search.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
}
}
export function clear() {
searchInput.value = "";
}
onMount(() => {
window.addEventListener("keydown", handleKeydown);
});
Expand Down
249 changes: 176 additions & 73 deletions src/lib/stores/progressStore.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { writable, get } from "svelte/store";
import {
compressToEncodedURIComponent,
decompressFromEncodedURIComponent,
} from "lz-string";
import {
quests,
completedQuests,
currentExpansion,
} from "$lib/stores/questsStore";
import { openModal } from "$lib/stores/modalManager";
import { encode, decode } from "@msgpack/msgpack";

export type ExpansionProgress = {
percent: number;
Expand All @@ -22,7 +19,139 @@ export type QuestGroupProgress = {
total: number;
};

// Store for overall progress
export type SharableState = {
completedQuests: string[];
currentExpansion: string;
};

// Compress and encode state into a sharable string
export async function compressAndEncode(state: SharableState): Promise<{
base64Encoded: string;
basePath: string;
}> {
// Step 1: Optimize "completedQuests" using Delta Encoding
const optimizedCompletedQuests = convertToDeltas(state.completedQuests);

// Create optimized state
const optimizedState = {
cq: optimizedCompletedQuests, // Delta encoded completed quests
ce: state.currentExpansion, // Expansion name remains a string
};

// Step 2: Serialize using MessagePack
const binaryState = encode(optimizedState);

// Step 3: Compress using the native Gzip CompressionStream
const stream = new CompressionStream("gzip");
const writer = stream.writable.getWriter();
writer.write(binaryState);
writer.close();

const compressed = await new Response(stream.readable).arrayBuffer();

// Step 4: Convert to Base64
const base64Encoded = btoa(
String.fromCharCode(...new Uint8Array(compressed))
);

// Create the base URL
const basePath =
window.location.origin + window.location.pathname.replace(/\/$/, "");

return { base64Encoded, basePath };
}

// Decode and decompress a shared progress string into the original state
export async function decodeAndDecompress(
base64Encoded: string
): Promise<SharableState> {
// Step 1: Decode Base64 back to a Uint8Array
const binaryString = atob(base64Encoded);
const compressed = Uint8Array.from(binaryString, (char) =>
char.charCodeAt(0)
);

// Step 2: Decompress using the native Gzip DecompressionStream
const stream = new DecompressionStream("gzip");
const writer = stream.writable.getWriter();
writer.write(compressed);
writer.close();

const decompressed = await new Response(stream.readable).arrayBuffer();

// Step 3: Deserialize using MessagePack
const optimizedState = decode(new Uint8Array(decompressed)) as {
cq: number[]; // Delta-encoded quest IDs
ce: string; // Expansion name
};

// Step 4: Reconstruct original "completedQuests" using Delta Decoding
const completedQuests = convertFromDeltas(optimizedState.cq);

return {
completedQuests,
currentExpansion: optimizedState.ce,
};
}

// Convert array of IDs to delta encoding
function convertToDeltas(ids: string[]): number[] {
const numbers = ids.map(Number).sort((a, b) => a - b);

if (numbers.length === 0) return [];
const deltas: number[] = [numbers[0]];

// Calculate the difference (delta) between consecutive IDs
for (let i = 1; i < numbers.length; i++) {
deltas.push(numbers[i] - numbers[i - 1]);
}

/*
Example:
Input IDs (sorted): [65564, 65565, 65567, 65570]
ASCII Representation:
IDs: 65564 65565 65567 65570
Deltas: +0 +1 +2 +3
Output Deltas: [65564, 1, 2, 3]
- First ID is stored as is: 65564
- 65565 - 65564 = 1 (delta from previous)
- 65567 - 65565 = 2
- 65570 - 65567 = 3
*/

return deltas;
}

// Utility: Convert delta-encoded array back to original IDs
function convertFromDeltas(deltas: number[]): string[] {
if (deltas.length === 0) return [];

const ids: number[] = [deltas[0]];
for (let i = 1; i < deltas.length; i++) {
ids.push(ids[i - 1] + deltas[i]);
}

/*
Example:
Input Deltas: [65564, 1, 2, 3]
ASCII Representation:
Base: 65564 +1 +2 +3
IDs: 65564 65565 65567 65570
Output IDs: [65564, 65565, 65567, 65570]
- Start with the base ID: 65564
- Add 1: 65564 + 1 = 65565
- Add 2: 65565 + 2 = 65567
- Add 3: 65567 + 3 = 65570
*/

return ids.map(String);
}

// Svelte store for overall progress
export const progress = writable<Record<string, ExpansionProgress>>({});
export const groupProgress = writable<
Record<string, Record<string, QuestGroupProgress>>
Expand Down Expand Up @@ -83,7 +212,7 @@ export function calculateQuestGroupProgress(
}

// Initialize all expansion and quest group progress
export function initAllExpansionProgress() {
export function initAllExpansionProgress(): void {
const allQuests = get(quests);
if (!allQuests.length) return;

Expand All @@ -94,15 +223,12 @@ export function initAllExpansionProgress() {
> = {};

allQuests.forEach((expansion) => {
// Calculate expansion progress
updatedProgress[expansion.name] = calculateExpansionProgress(
expansion.name
);

// Initialize nested object for this expansion
updatedGroupProgress[expansion.name] = {};

// Calculate progress for each quest group
Object.keys(expansion.quests).forEach((questGroup) => {
updatedGroupProgress[expansion.name][questGroup] =
calculateQuestGroupProgress(expansion.name, questGroup);
Expand All @@ -113,7 +239,7 @@ export function initAllExpansionProgress() {
groupProgress.set(updatedGroupProgress);
}

// Get shared progress from URL
// Parse shared progress from URL
export function getSharedProgress(): string {
try {
const urlParams = new URLSearchParams(window.location.search);
Expand All @@ -129,86 +255,63 @@ export function hasSharedProgress(): boolean {
return !!getSharedProgress();
}

// Load shared progress from URL
export function loadSharedProgress() {
// Load shared progress from URL and apply it
export async function loadSharedProgress(): Promise<void> {
const compressedState = getSharedProgress();
if (!compressedState) return;

if (compressedState) {
openModal(
"Shared Progress",
"You are viewing a shared progress link. Your progress won't be overwritten without prompt.",
() => {},
() => {
window.location.href =
window.location.origin + window.location.pathname;
},
"Understood",
"",
false
);
openModal(
"Shared Progress",
"You are viewing a shared progress link. Your progress won't be overwritten without prompt.",
() => {},
() => {
window.location.href = window.location.origin + window.location.pathname;
},
"Understood",
"",
false
);

try {
const state = JSON.parse(
decompressFromEncodedURIComponent(compressedState) || "{}"
);

const reconstructedCompleted: Record<number, boolean> = {};
state.completedQuests.forEach((id: string) => {
reconstructedCompleted[Number(id)] = true;
});

completedQuests.set(reconstructedCompleted);
currentExpansion.set(state.currentExpansion);
} catch (error) {
console.error("Failed to decode shared progress:", error);
}
try {
const state = await decodeAndDecompress(compressedState);
const reconstructedCompleted: Record<number, boolean> = {};
state.completedQuests.forEach((id: string) => {
reconstructedCompleted[Number(id)] = true;
});

completedQuests.set(reconstructedCompleted);
currentExpansion.set(state.currentExpansion);
} catch (error) {
console.error("Failed to decode shared progress:", error);
}
}

// Generate a shareable link for progress
export function generateShareableLink() {
// Generate a shareable progress link
export async function generateShareableLink(): Promise<void> {
const completed = get(completedQuests);
const completedIds = Object.keys(completed).filter(
(id) => completed[Number(id)]
);

const state = {
const state: SharableState = {
completedQuests: completedIds,
currentExpansion: get(currentExpansion),
};

try {
const compressedState = compressToEncodedURIComponent(
JSON.stringify(state)
const { base64Encoded, basePath } = await compressAndEncode(state);
const shareableLink = `${basePath}?progress=${base64Encoded}`;

await navigator.clipboard.writeText(shareableLink);
openModal(
"Shareable Link",
"The progress link has been copied to your clipboard. Share it with your friends!",
() => {},
() => {},
"Understood",
"",
false
);
const basePath =
window.location.origin + window.location.pathname.replace(/\/$/, "");
const shareableLink = `${basePath}?progress=${compressedState}`;

navigator.clipboard
.writeText(shareableLink)
.then(() =>
openModal(
"Shareable Link",
"The progress link has been copied to your clipboard. Share it with your friends!",
() => {},
() => {},
"Understood",
"",
false
)
)
.catch(() =>
openModal(
"Shareable Link",
"Failed to copy the progress link to your clipboard. Please try again.",
() => {},
() => {},
"Understood",
"",
false
)
);
} catch (error) {
console.error("Failed to generate shareable link:", error);
openModal(
Expand Down
7 changes: 4 additions & 3 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts">
// Dependencies imports
import { onMount, onDestroy, tick } from "svelte";
import { onMount, onDestroy, tick, SvelteComponent } from "svelte";
import { fade } from "svelte/transition";
import { get } from "svelte/store";
import { debounce } from "lodash";
Expand Down Expand Up @@ -67,7 +67,7 @@
let lastCheckedQuestNumber: number | null = null;
let highlightedQuestNumber: number | null = null;
let tooltipTarget: HTMLElement | null = null;
let searchInput: HTMLInputElement;
let searchInput: SvelteComponent<Search>;
function filterQuests(): void {
const allQuests = get(quests);
Expand Down Expand Up @@ -176,7 +176,7 @@
// Clear search if it exists
if (searchQuery) {
searchQuery = "";
searchInput.value = "";
searchInput.clear();
filterQuests();
}
Expand Down Expand Up @@ -389,6 +389,7 @@
<Progress />
<Search
placeholder="Search quest name, description and unlocks..."
bind:this={searchInput}
bind:value={searchQuery}
on:input={handleSearchInput}
/>
Expand Down

0 comments on commit da8a78f

Please sign in to comment.