Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CC-219/CC-241 Add first version of hash decoding/encoding and super state handling #6

Draft
wants to merge 7 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions applications/cryoet-neuroglancer/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"format": "prettier --cache -w -l ."
},
"dependencies": {
"pako": "^2.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
Expand All @@ -27,9 +28,9 @@
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.12.0",
"prettier": "3.4.2",
"typescript": "~5.6.2",
"typescript-eslint": "^8.15.0",
"vite": "^6.0.1",
"prettier": "3.4.2"
"vite": "^6.0.1"
}
}
71 changes: 20 additions & 51 deletions applications/cryoet-neuroglancer/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,84 +1,53 @@
import { useEffect, useRef } from "react";
import { encodeState, parseState } from "./utils";
import { toggleLayersVisibility } from "./services/layers";
import { useCallback, useState } from "react";
import "./App.css";
import NeuroglancerWrapper from "./NeuroglancerWrapper";
import { toggleLayersVisibility } from "./services/layers";
import { type ResolvedSuperState, updateState } from "./utils";

const Main = () => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const neuroglancerUrl = import.meta.env.VITE_NEUROGLANCER_URL;
const cryoetUrl = import.meta.env.VITE_CRYOET_PORTAL_DOC_URL;
const exampleHash = import.meta.env.VITE_EXAMPLE_CRYOET_HASH;

// Add event listeners for hash changes and iframe messages
useEffect(() => {
const iframe = iframeRef.current;

if (iframe) {
const handleHashChange = () => {
iframe.contentWindow?.postMessage(
{ type: "hashchange", hash: window.location.hash },
"*",
);
};

const handleMessage = (event: MessageEvent) => {
if (event.origin !== neuroglancerUrl) {
return;
}
const { type, hash } = event.data;
if (type === "synchash" && window.location.hash !== hash) {
history.replaceState(null, "", hash);
}
};

window.addEventListener("hashchange", handleHashChange);
window.addEventListener("message", handleMessage);

return () => {
window.removeEventListener("hashchange", handleHashChange);
window.removeEventListener("message", handleMessage);
};
}

return () => {};
}, [neuroglancerUrl]);
const Main = () => {
const cryoetUrl = import.meta.env.VITE_CRYOET_PORTAL_DOC_URL;
const exampleHash = import.meta.env.VITE_EXAMPLE_CRYOET_HASH;
const [layers, setLayers] = useState<Array<any>>([])

// Button action for toggling layers visibility
const toggleButton = () => {
const currentHash = window.location.hash;
const newState = toggleLayersVisibility(parseState(currentHash));
const newHash = encodeState(newState);
window.location.hash = newHash; // This triggers the hashchange listener
updateState((state) => {
return toggleLayersVisibility(state)
})
};

const callback = useCallback((state: ResolvedSuperState) => {
const neuroglancer = state.neuroglancer;
setLayers([...neuroglancer.layers.filter(l => (l.visible === undefined || l.visible))])
}, [setLayers])

return (
<div className="main-container">
<header className="main-header">
<a href={cryoetUrl} target="_blank" rel="noopener noreferrer">
<button className="cryoet-doc-button">View documentation</button>
</a>
<p className="portal-title">CryoET data portal neuroglancer</p>
{layers?.map(l => <p className="portal-title" key={l.name}>{l.name}</p>)}
<div className="button-group">
<button
className="toggle-button"
onClick={() => {
window.location.hash = exampleHash;
}}
>
Load example data
Load example data
</button>
<button className="toggle-button" onClick={toggleButton}>
Toggle layers visibility
Toggle layers visibility
</button>
</div>
</header>
<div className="iframe-container">
<iframe
className="neuroglancer-iframe"
ref={iframeRef}
src={`${neuroglancerUrl}/${window.location.hash}`}
title="Neuroglancer"
/>
<NeuroglancerWrapper onStateChange={callback}/>
</div>
</div>
);
Expand Down
100 changes: 100 additions & 0 deletions applications/cryoet-neuroglancer/frontend/src/NeuroglancerWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { useEffect, useRef } from "react";
import {
hashIsUncompressed,
parseSuperState,
encodeState,
type SuperState,
newSuperState,
updateNeuroglancerConfigInSuperstate,
parseState,
type ResolvedSuperState,
} from "./utils";
import "./App.css";

interface NeuroglancerWrapperProps {
baseUrl?: string,
onStateChange?: (state: ResolvedSuperState) => void,
}

const NeuroglancerWrapper = ({ baseUrl: neuroglancerUrl = import.meta.env.VITE_NEUROGLANCER_URL, onStateChange }: NeuroglancerWrapperProps) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const superState = useRef<SuperState>(newSuperState(window.location.hash))

// Add event listeners for hash changes and iframe messages
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe) {
return () => {};
}

// main window hash -> iFrame hash sync
const handleHashChange = () => {
const hash = window.location.hash;
// First we parse the super state (if there is no super state, one is created empty)
superState.current = parseSuperState(hash, superState.current);
const state = superState.current
if (hashIsUncompressed(hash)) {
// In case we receive a uncompressed hash
state.neuroglancer = hash
const newState = encodeState(state)
history.replaceState(null, "", newState); // We replace main window hash with the compressed one
iframe.contentWindow?.postMessage(
// We propagate the hash we received to the iframe
{ type: "hashchange", hash: hash },
"*",
);
return;
}
// If the hash is compressed, we should have already a super state, we just decompress it
iframe.contentWindow?.postMessage(
{ type: "hashchange", hash: state.neuroglancer },
"*",
);
};

// iFrame hash -> main window hash sync
const handleMessage = (event: MessageEvent) => {
const url = neuroglancerUrl.endsWith("/") ? neuroglancerUrl.slice(0, -1) : neuroglancerUrl
if (event.origin !== url) {
return;
}
const { type, hash } = event.data;
// When we receive a sync from neuroglancer (iFrame), we know it's uncompressed
if (type === "synchash" && window.location.hash !== hash) {
const originalLength = JSON.stringify(superState.current).length
updateNeuroglancerConfigInSuperstate(superState.current, hash)
const newHash = encodeState(superState.current);
console.debug(
"Hash gain, original",
originalLength,
"newHash",
newHash.length,
"gain",
((hash.length - newHash.length) / hash.length) * 100,
"%",
);
history.replaceState(null, "", newHash);
onStateChange?.({ ...superState.current, neuroglancer: parseState(superState.current.neuroglancer) })
}
};

window.addEventListener("hashchange", handleHashChange);
window.addEventListener("message", handleMessage);

return () => {
window.removeEventListener("hashchange", handleHashChange);
window.removeEventListener("message", handleMessage);
};
}, [neuroglancerUrl]);

return (
<iframe
className="neuroglancer-iframe"
ref={iframeRef}
src={`${neuroglancerUrl}/${superState.current.neuroglancer}`} // We need to give an uncompress hash initially
title="Neuroglancer"
/>
);
};

export default NeuroglancerWrapper;
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export const toggleLayersVisibility = (state: any) => {
state.layers.forEach((layer: any) => {
for (const layer of state.neuroglancer.layers) {
layer.visible = !(layer.visible === undefined || layer.visible);
});
}
return state;
};
134 changes: 131 additions & 3 deletions applications/cryoet-neuroglancer/frontend/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,139 @@
import pako from "pako";

export interface SuperState extends Record<string, any> {
neuroglancer: string;
}

export interface ResolvedSuperState extends Record<string, any> {
neuroglancer: Record<string, any>;
}

const emptySuperState = (config: string): SuperState => {
return {
extra: 44,
neuroglancer: config.length > 0 ? decompressHash(config) : "",
};
};

export const updateState = (onStateChange: (state: ResolvedSuperState) => ResolvedSuperState) => {
const state = currentState();
const newState = onStateChange(state);
commitState(newState);
};

export const newSuperState = (config: string): SuperState => {
const state = parseSuperState(config);
if (state.neuroglancer) {
return state;
}
return emptySuperState(config);
};

export const updateNeuroglancerConfigInSuperstate = (
superstate: SuperState,
neuroglancerHash: string
): SuperState => {
superstate.neuroglancer = neuroglancerHash;
return superstate;
};

export const parseSuperState = (
hash: string,
previous?: SuperState
): SuperState => {
if (!hash || hash.length === 0) {
return emptySuperState("");
}
const superState = parseState(hash);
if (!superState.neuroglancer) {
if (previous) {
previous.neuroglancer = decompressHash(hash);
return previous;
}
return emptySuperState(hash);
}
return superState;
};

export const extractConfigFromSuperState = (hash: string): string => {
const superstate = parseState(hash);
return superstate.neuroglancer || "";
};

function hash2jsonString(hash: string): string {
if (hash.startsWith("#!")) {
return hash.slice(2);
}
return hash;
}

// Helper functions for parsing and encoding state
export const currentState = (hash = window.location.hash) => {
const superState = parseState(hash);
if (superState.neuroglancer) {
superState.neuroglancer = parseState(superState.neuroglancer);
}
return superState;
};

export const commitState = (state: SuperState | ResolvedSuperState) => {
state.neuroglancer = encodeState(state.neuroglancer, /* compress = */ false);
const newHash = encodeState(state);
window.location.hash = newHash; // This triggers the hashchange listener
};

export const parseState = (hashState: string) => {
const decodedHash = decodeURIComponent(hashState.slice(2));
return JSON.parse(decodedHash);
const hash = decompressHash(hash2jsonString(hashState));
const decodedHash = decodeURIComponent(hash);
return JSON.parse(hash2jsonString(decodedHash));
};

export const encodeState = (jsonObject: any) => {
export const encodeState = (jsonObject: any, compress: boolean = true) => {
const jsonString = JSON.stringify(jsonObject);
const encodedString = encodeURIComponent(jsonString);
if (compress) {
return compressHash(encodedString);
}
return `#!${encodedString}`;
};

// Helper functions for parsing, compressing and decompressing hash
function b64Tob64Url(buffer: string): string {
return buffer.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

function b64UrlTo64(value: string): string {
const m = value.length % 4;
return value
.replace(/-/g, "+")
.replace(/_/g, "/")
.padEnd(value.length + (m === 0 ? 0 : 4 - m), "=");
}

export function hashIsUncompressed(hash: string): boolean {
return hash.includes("%");
}

export function hashIsCompressed(hash: string): boolean {
return !hashIsUncompressed(hash);
}

export function compressHash(hash: string): string {
if (hashIsCompressed(hash)) {
return hash;
}
const gzipHash = pako.gzip(hash2jsonString(hash));
const base64UrlFragment = btoa(String.fromCharCode.apply(null, gzipHash));
const newHash = `#!${b64Tob64Url(base64UrlFragment)}`;
return newHash;
}

export function decompressHash(hash: string): string {
if (hashIsUncompressed(hash)) {
return hash;
}
const base64Hash = b64UrlTo64(hash2jsonString(hash));
const gzipedHash = Uint8Array.from(atob(base64Hash), (c) => c.charCodeAt(0));
const hashFragment = new TextDecoder().decode(pako.ungzip(gzipedHash));
return `#!${hashFragment}`;
}
5 changes: 5 additions & 0 deletions applications/cryoet-neuroglancer/frontend/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,11 @@ p-locate@^5.0.0:
dependencies:
p-limit "^3.0.2"

pako@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86"
integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==

parent-module@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
Expand Down