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

Add URL-safe Base64 Encoding/Decoding for Dynamic Sample Redirection #141

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
6 changes: 3 additions & 3 deletions docs/docs/loading-data/data-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This page describes file formats used in Chromoscope. To find a list of required
## Structural Variants (BEDPE)
<!-- https://bedtools.readthedocs.io/en/latest/content/general-usage.html#bedpe-format -->

The structural variants are stored in a BEDPE file. The following columns are used in the browser:
The structural variants are stored in a headed BEDPE file. The order of the columns does not need to be in the exact same order. This is a The following columns are used in the browser:

| Property | Type | Note |
|---|---|---|
Expand Down Expand Up @@ -43,7 +43,7 @@ In Chromosope, strands are mapped with the following types of SVs.
## CNV (TSV)
<!-- https://bedtools.readthedocs.io/en/latest/content/general-usage.html#bedpe-format -->

The CNV is stored in a tab-delimited file that is visualized as three tracks: CNV, Gain, and LOH.
The CNV is stored in a headed tab-delimited file that is visualized as three tracks: CNV, Gain, and LOH. The order of the columns does not need to be in the exact same order.

| Property | Type | Note |
|---|---|---|
Expand All @@ -63,7 +63,7 @@ https://s3.amazonaws.com/gosling-lang.org/data/SV/7a921087-8e62-4a93-a757-fd8cdb
## Drivers (TSV or JSON)
<!-- https://bedtools.readthedocs.io/en/latest/content/general-usage.html#bedpe-format -->

The drivers are stored in a tab-delimited file. When this file is present, the browser will show drivers that are included in the file only.
The drivers are stored in a headed tab-delimited file. When this file is present, the browser will show drivers that are included in the file only.

The order of the columns does not need to be in the exact same order.

Expand Down
50 changes: 34 additions & 16 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import HorizontalLine from './ui/horizontal-line';
import SampleConfigForm from './ui/sample-config-form';
import { BrowserDatabase } from './browser-log';
import legend from './legend.png';
import UrlsafeCodec from './lib/urlsafe-codec';

const db = new Database();
const log = new BrowserDatabase();
Expand Down Expand Up @@ -167,24 +168,39 @@ function App(props: RouteComponentProps) {
rightReads.current = [];
}, [demo]);

function isWebAddress(url) {
return url.startsWith('http://') || url.startsWith('https://');
}

useEffect(() => {
const fetchData = async (url) => {
let responseText;
let externalDemo;
if (isWebAddress(url)) {
responseText = await fetch(url).then(response => response.text());
externalDemo = JSON.parse(responseText);
} else {
externalDemo = await UrlsafeCodec.decode(url);
}
processDemoData(externalDemo);
};

function processDemoData(demoData){
if (Array.isArray(demoData) && demoData.length >= 0) {
setFilteredSamples(demoData);
demoData = demoData[demoIndex.current < demoData.length ? demoIndex.current : 0];
} else {
setFilteredSamples([demoData]);
}
if (demoData) {
setDemo(demoData);
}
setShowSmallMultiples(true);
setReady(true);
};

if (externalUrl) {
fetch(externalUrl).then(response =>
response.text().then(d => {
let externalDemo = JSON.parse(d);
if (Array.isArray(externalDemo) && externalDemo.length >= 0) {
setFilteredSamples(externalDemo);
externalDemo = externalDemo[demoIndex.current < externalDemo.length ? demoIndex.current : 0];
} else {
setFilteredSamples([externalDemo]);
}
if (externalDemo) {
setDemo(externalDemo);
}
setShowSmallMultiples(true);
setReady(true);
})
);
fetchData(externalUrl);
}
}, []);

Expand Down Expand Up @@ -493,6 +509,7 @@ function App(props: RouteComponentProps) {
currentSpec.current = JSON.stringify(spec);
// console.log('spec', spec);
return (
<div>
<GoslingComponent
ref={gosRef}
spec={spec}
Expand All @@ -501,6 +518,7 @@ function App(props: RouteComponentProps) {
experimental={{ reactive: true }}
theme={THEME}
/>
</div>
);
// !! Removed `demo` not to update twice since `drivers` are updated right after a demo update.
}, [ready, xDomain, visPanelWidth, drivers, showOverview, showPutativeDriver, selectedSvId, breakpoints, svReads]);
Expand Down
18 changes: 9 additions & 9 deletions src/data/driver.custom.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[
{
"chr": "chr2",
"pos": 47806320,
"pos": 47795017,
"ref": "G",
"alt": "A",
"gene": "MSH6",
Expand All @@ -13,7 +13,7 @@
},
{
"chr": "chr6",
"pos": 157133136,
"pos": 156993402,
"ref": "G",
"alt": "C",
"gene": "ARID1B",
Expand All @@ -26,7 +26,7 @@
},
{
"chr": "chr8",
"pos": 38428420,
"pos": 38439986,
"ref": "G",
"alt": "A",
"gene": "FGFR1",
Expand All @@ -38,7 +38,7 @@
},
{
"chr": "chr13",
"pos": 32339132,
"pos": 32357888,
"ref": "G",
"alt": "T",
"gene": "BRCA2",
Expand All @@ -51,7 +51,7 @@
},
{
"chr": "chr17",
"pos": 7675088,
"pos": 7677976,
"ref": "C",
"alt": "T",
"gene": "TP53",
Expand All @@ -64,7 +64,7 @@
},
{
"chr": "chrX",
"pos": 77681733,
"pos": 77645546,
"ref": "T",
"alt": "C",
"gene": "ATRX",
Expand All @@ -78,19 +78,19 @@
{
"gene": "CDKN2A",
"chr": "chr9",
"pos": 21981527,
"pos": 21981538,
"category": "deletion"
},
{
"gene": "MET",
"chr": "chr7",
"pos": 116735291,
"pos": 116735286,
"category": "amplification"
},
{
"gene": "PTEN",
"chr": "chr10",
"pos": 89677278,
"pos": 87917777,
"category": "deletion",
"biallelic": "yes"
}
Expand Down
2 changes: 1 addition & 1 deletion src/data/samples.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Assembly } from 'gosling.js/dist/src/core/gosling.schema';
import { Assembly } from 'gosling.js/dist/src/gosling-schema';
import _7a921087 from '../script/img/7a921087-8e62-4a93-a757-fd8cdbe1eb8f.jpeg';
import _84ca6ab0 from '../script/img/84ca6ab0-9edc-4636-9d27-55cdba334d7d.jpeg';
import _7d332cb1 from '../script/img/7d332cb1-ba25-47e4-8bf8-d25e14f40d59.jpeg';
Expand Down
72 changes: 72 additions & 0 deletions src/lib/urlsafe-codec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import pako from 'pako';
import base64Js from 'base64-js';

/**
* UrlsafeCodec provides static methods to encode and decode samples
* to and from a URL-safe base64 encoded string. It uses JSON for serialization,
* pako for compression, and base64-js for handling base64 encoding.
*/
class UrlsafeCodec {
/**
* Encodes a sample object into a URL-safe base64 string.
*
* The method serializes the sample to a JSON string, compresses it using pako,
* By default, the header check is turned off. If headerCheck is set to true, the method
* will perform zlib/gzip header and checksum verification during decompression using pako.
* converts the compressed data to a base64 string, and then modifies the base64 string
* to make it URL-safe by replacing '+' with '.', '/' with '_', and '=' with '-'.
*
* @param {Object} sample - The sample object to encode.
* @param {boolean} headerCheck - Optional parameter to enable header and checksum verification.
* @returns {string} A URL-safe base64 encoded string representing the sample.
*/
static encode(sample, headerCheck = false) {
try {
const string = JSON.stringify(sample);
const encoder = new TextEncoder();
const stringAsUint8Array = encoder.encode(string);
// Set 'raw' to true if headerCheck is false
const compressedUint8Array = pako.deflate(stringAsUint8Array, {raw: !headerCheck });
const base64Bytes = base64Js.fromByteArray(compressedUint8Array);
const base64Blob = base64Bytes.toString();
const base64UrlsafeBlob = base64Blob.replace(/\+/g, '.').replace(/\//g, '_').replace(/=/g, '-');
return base64UrlsafeBlob;
} catch (error) {
console.error('Error encoding sample:', error);
// Handle the error or rethrow, depending on your needs
throw error;
}
}

/**
* Decodes a URL-safe base64 string back into a sample object.
*
* The method reverses the URL-safe transformation by replacing '.', '_', and '-'
* with '+', '/', and '=' respectively. It then converts the base64 string back to bytes.
* By default, the header check is turned off. If headerCheck is set to true, the method
* will perform zlib/gzip header and checksum verification during decompression using pako.
* Finally, it parses the JSON string to reconstruct the original sample object.
*
* @param {string} encodedString - The URL-safe base64 encoded string to decode.
* @param {boolean} headerCheck - Optional parameter to enable header and checksum verification.
* @returns {Object} The original sample object.
*/
static decode(encodedString, headerCheck = false) {
try {
const base64Blob = encodedString.replace(/\./g, '+').replace(/_/g, '/').replace(/-/g, '=');
const compressedUint8Array = base64Js.toByteArray(base64Blob);
// Set 'raw' to true if headerCheck is false
const bytes = pako.inflate(compressedUint8Array, {raw: !headerCheck });
const decoder = new TextDecoder();
const string = decoder.decode(bytes);
const sample = JSON.parse(string);
return sample;
} catch (error) {
console.error('Error decoding string:', error);
// Handle the error or rethrow, depending on your needs
throw error;
}
}
}

export default UrlsafeCodec;
4 changes: 3 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter, Route } from 'react-router-dom';
import App from './App';
import JsonBase64Converter from './ui/json-base64-converter';

ReactDOM.render(
<BrowserRouter>
<Route component={App} />
<Route path="/app" component={App} />
<Route path="/dev/codec" component={JsonBase64Converter} />
</BrowserRouter>,
document.getElementById('root')
);
12 changes: 0 additions & 12 deletions src/track/sv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,6 @@ export default function sv(
url,
type: 'csv',
separator: '\t',
headerNames: [
'chrom1',
'start1',
'end1',
'chrom2',
'start2',
'end2',
'sv_id',
'pe_support',
'strand1',
'strand2'
],
genomicFieldsToConvert: [
{
chromosomeField: 'chrom1',
Expand Down
60 changes: 60 additions & 0 deletions src/ui/json-base64-converter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import React, { useState } from 'react';
import UrlsafeCodec from '../lib/urlsafe-codec';

const JsonBase64Converter = () => {
const [jsonText, setJsonText] = useState('');
const [base64Text, setBase64Text] = useState('');
const [error, setError] = useState('');

const handleEncode = () => {
try {
const jsonObject = JSON.parse(jsonText);
const encoded = UrlsafeCodec.encode(jsonObject);
setBase64Text(encoded);
setError('');
} catch (error) {
setError('Error parsing JSON. Please enter valid JSON.');
}
};

const handleDecode = () => {
try {
const decoded = UrlsafeCodec.decode(base64Text);
const jsonString = JSON.stringify(decoded, null, 4);
setJsonText(jsonString);
setError('');
} catch (error) {
setError('Error decoding base64 string. Please enter a valid base64-encoded string.');
}
};

return (
<div style={{ display: 'flex', flexDirection: 'row' }}>
<div style={{ flex: 1, marginRight: '8px', marginLeft: '8px' }}>
<h2>JSON Text</h2>
<textarea
value={jsonText}
onChange={(e) => setJsonText(e.target.value)}
placeholder="Enter JSON text"
rows={48}
style={{ width: '99%', fontSize: 'large' }}
/>
<button onClick={handleEncode}>Encode</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
</div>
<div style={{ flex: 1, marginRight: '8px', marginLeft: '8px' }}>
<h2>Base64 Encoded Text</h2>
<textarea
value={base64Text}
onChange={(e) => setBase64Text(e.target.value)}
placeholder="Base64 url safe encoded string"
rows={48}
style={{ width: '99%', fontSize: 'large' }}
/>
<button onClick={handleDecode}>Decode</button>
</div>
</div>
);
};

export default JsonBase64Converter;
2 changes: 1 addition & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import reactRefresh from '@vitejs/plugin-react-refresh';

// https://vitejs.dev/config/
export default defineConfig({
base: '/app/',
base: '/',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized this change makes the demo of the chromoscope inaccessible via chromoscope.bio/app. I tested this by running the following commands.

# build both the documentation and demo and create a "\build" folder to organize files
yarn predeploy

# enables browsing the built project (i.e., doc and demo) by running a local server
http-server ./build

How the document and demo are organized under \build:

build
   - index.html // ← documentation (i.e., "chromoscope.bio")
   - ...
   - app
      - index.html // ← demo (i.e., "chromoscope.bio/app")
      - ...

By setting base: '/app/', the index.html file for the demo (i.e., app/index.html) created by vite has proper relative paths (e.g., app/index.js instead of index.js). So, without setting the base option, chromoscope.bio/app (app/index.html) ends up having the wrong relative paths, making the demo not work.

I did not find a simple way to walk around this and would need to experiment more for a better alternative, but we can simply create an inlined HTML file for the webpage that you created (instead of using React) and put that under a /dev/codec/ folder.

build
   - index.html // ← documentation (i.e., "chromoscope.bio")
   - ...
   - app
      - index.html // ← demo (i.e., "chromoscope.bio/app")
      - ...
   - dev
      - codec
         - index.html // ← simple page to convert JSON to str

In this way, users should be able to access the webpage via "chromoscope.bio/dev/codec/'.

build: { target: 'esnext' },
optimizeDeps: {
include: ['gosling.js']
Expand Down
Loading