Skip to content

Commit

Permalink
feat: Using JSURL for data serialization in links
Browse files Browse the repository at this point in the history
  • Loading branch information
Danko Kozar committed Apr 1, 2020
1 parent f8e4291 commit 0539129
Show file tree
Hide file tree
Showing 11 changed files with 363 additions and 61 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"i18next-browser-languagedetector": "4.0.2",
"immer": "5.3.6",
"immutable": "3.8.2",
"jsurl": "0.1.5",
"jszip": "3.2.2",
"katex": "0.11.1",
"lodash": "4.17.15",
Expand Down
7 changes: 2 additions & 5 deletions src/algorithms/types/Param.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,8 @@ export interface ScenarioData {
simulation: SimulationData
}

export interface AllParams {
population: PopulationData
epidemiological: EpidemiologicalData
simulation: SimulationData
containment: ContainmentData
export interface AllParams extends ScenarioData {
current: string
}

export type AllParamsFlat = PopulationData & EpidemiologicalData & SimulationData
Expand Down
25 changes: 21 additions & 4 deletions src/components/Main/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { schema } from './validation/schema'
import { setContainmentData, setPopulationData, setEpidemiologicalData, setSimulationData } from './state/actions'
import { scenarioReducer } from './state/reducer'
import { defaultScenarioState, State } from './state/state'
import { serializeScenarioToURL, deserializeScenarioFromURL } from './state/URLSerializer'
import { serialize, deserialize } from './state/URLSerializer'

import { ResultsCard } from './Results/ResultsCard'
import { ScenarioCard } from './Scenario/ScenarioCard'
Expand Down Expand Up @@ -60,7 +60,7 @@ async function runSimulation(
return
}

if (params.population.cases !== "none" && !isRegion(params.population.cases)) {
if (params.population.cases !== 'none' && !isRegion(params.population.cases)) {
console.error(`The given confirmed cases region is invalid: ${params.population.cases}`)
return
}
Expand All @@ -70,7 +70,8 @@ async function runSimulation(

const containmentData = params.containment.reduction

serializeScenarioToURL(scenarioState, params)
const queryString = serialize(scenarioState, params)
window.history.pushState('', '', `?${queryString}`)

const newResult = await run(paramsFlat, severity, ageDistribution, containmentData)
setResult(newResult)
Expand All @@ -88,13 +89,29 @@ const isRegion = (region: string): region is keyof typeof countryCaseCountData =
return Object.prototype.hasOwnProperty.call(countryCaseCountData, region)
}

const deserializeScenarioFromUrl = (state: State): State => {
const { search } = window.location

if (search) {
const queryString = search.slice(1) // removing first char ('?')

try {
return deserialize(state, queryString)
} catch (error) {
console.error('Error while parsing URL')
}
}

return state
}

function Main() {
const [result, setResult] = useState<AlgorithmResult | undefined>()
const [autorunSimulation, setAutorunSimulation] = useState(false)
const [scenarioState, scenarioDispatch] = useReducer(
scenarioReducer,
defaultScenarioState,
deserializeScenarioFromURL,
deserializeScenarioFromUrl,
)

// TODO: Can this complex state be handled by formik too?
Expand Down
6 changes: 3 additions & 3 deletions src/components/Main/Results/ResponsiveTooltipContent.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import React from 'react'
import React, { ReactNode } from 'react'

import './ResponsiveTooltipContent.scss'

interface TooltipItem {
name: string
value: string | number
value: string | number | ReactNode
color: string
}

interface TooltipContentProps {
active: boolean
label: string | number
label?: string | number
payload: TooltipItem[]
formatter?: Function
labelFormatter?: Function
Expand Down
120 changes: 120 additions & 0 deletions src/components/Main/state/URLSerializer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { serialize, deserialize } from './URLSerializer'
import { State } from './state'
import { AllParams } from '../../../algorithms/types/Param.types'

const SCENARIOS = ['CHE-Basel-Landschaft', 'CHE-Basel-Stadt']

const INITIAL_STATE: State = {
scenarios: SCENARIOS,
current: 'CHE-Basel-Stadt',
data: {
population: {
ICUBeds: 80,
cases: 'CHE-Basel-Stadt',
country: 'Switzerland',
hospitalBeds: 698,
importsPerDay: 0.1,
populationServed: 195000,
suspectedCasesToday: 10,
},
epidemiological: {
infectiousPeriod: 3,
latencyTime: 5,
lengthHospitalStay: 4,
lengthICUStay: 14,
overflowSeverity: 2,
peakMonth: 0,
r0: 2,
seasonalForcing: 0.2,
},
containment: {
reduction: [
{ t: new Date('2020-01-31T00:00:00.000Z'), y: 1 },
{ t: new Date('2020-02-23T18:40:00.000Z'), y: 1 },
{ t: new Date('2020-03-18T13:20:00.000Z'), y: 1 },
{ t: new Date('2020-04-11T08:00:00.000Z'), y: 1 },
{ t: new Date('2020-05-05T02:40:00.000Z'), y: 1 },
{ t: new Date('2020-05-28T21:20:00.000Z'), y: 1 },
{ t: new Date('2020-06-21T16:00:00.000Z'), y: 1 },
{ t: new Date('2020-07-15T10:40:00.000Z'), y: 1 },
{ t: new Date('2020-08-08T05:20:00.000Z'), y: 1 },
{ t: new Date('2020-09-01T00:00:00.000Z'), y: 1 },
],
numberPoints: 10,
},
simulation: {
simulationTimeRange: { tMin: new Date('2020-01-31T00:00:00.000Z'), tMax: new Date('2020-09-01T00:00:00.000Z') },
numberStochasticRuns: 0,
},
},
}

const PARAMS: AllParams = {
population: {
ICUBeds: 80,
cases: 'CHE-Basel-Stadt',
country: 'Switzerland',
hospitalBeds: 698,
importsPerDay: 0.1,
populationServed: 195000,
suspectedCasesToday: 10,
},
epidemiological: {
infectiousPeriod: 3,
latencyTime: 5,
lengthHospitalStay: 4,
lengthICUStay: 14,
overflowSeverity: 2,
peakMonth: 0,
r0: 2,
seasonalForcing: 0.2,
},
simulation: {
simulationTimeRange: { tMax: new Date('2020-09-01T00:00:00.000Z'), tMin: new Date('2020-01-31T00:00:00.000Z') },
numberStochasticRuns: 0,
},
containment: {
reduction: [
{ y: 1, t: new Date('2020-01-31T00:00:00.000Z') },
{ y: 1, t: new Date('2020-02-23T18:40:00.000Z') },
{ y: 1, t: new Date('2020-03-18T13:20:00.000Z') },
{ y: 1, t: new Date('2020-04-11T08:00:00.000Z') },
{ y: 1, t: new Date('2020-05-05T02:40:00.000Z') },
{ y: 1, t: new Date('2020-05-28T21:20:00.000Z') },
{ y: 1, t: new Date('2020-06-21T16:00:00.000Z') },
{ y: 1, t: new Date('2020-07-15T10:40:00.000Z') },
{ y: 1, t: new Date('2020-08-08T05:20:00.000Z') },
{ y: 0.5, t: new Date('2020-09-01T00:00:00.000Z') }, // last slider moved to 0.5
],
numberPoints: 10,
},
current: 'CHE-Basel-Stadt',
}

const SERIALIZED_STRING =
"~(population~(ICUBeds~80~cases~'CHE-Basel-Stadt~country~'Switzerland~hospitalBeds~698~importsPerDay~0.1~populationServed~195000~suspectedCasesToday~10)~epidemiological~(infectiousPeriod~3~latencyTime~5~lengthHospitalStay~4~lengthICUStay~14~overflowSeverity~2~peakMonth~0~r0~2~seasonalForcing~0.2)~simulation~(simulationTimeRange~(tMin~1580428800000~tMax~1598918400000)~numberStochasticRuns~0)~containment~(reduction~(~(y~1~t~1580428800000)~(y~1~t~1582483200000)~(y~1~t~1584537600000)~(y~1~t~1586592000000)~(y~1~t~1588646400000)~(y~1~t~1590700800000)~(y~1~t~1592755200000)~(y~1~t~1594809600000)~(y~1~t~1596864000000)~(y~0.5~t~1598918400000))~numberPoints~10)~current~'CHE-Basel-Stadt)"

describe('URLSerializer', () => {
it('serialize', () => {
expect(serialize(INITIAL_STATE, PARAMS)).toBe(SERIALIZED_STRING)
})

it('deserialize', () => {
expect(deserialize(INITIAL_STATE, SERIALIZED_STRING)).toEqual({
current: 'CHE-Basel-Stadt',
scenarios: INITIAL_STATE.scenarios,
data: {
containment: PARAMS.containment,
epidemiological: PARAMS.epidemiological,
population: PARAMS.population,
simulation: PARAMS.simulation,
},
})
})

it('throws', () => {
expect(() => {
deserialize(INITIAL_STATE, 'some random string')
}).toThrow('URLSerializer: Error while parsing URL')
})
})
86 changes: 37 additions & 49 deletions src/components/Main/state/URLSerializer.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,53 @@
import type { AllParams } from '../../../algorithms/types/Param.types'

import { State } from './state'

/*
Quick and dirty helper to serialize/deserialize parameters within the URL,
so people can share/save it and keep their parameters
This could have been done inside a redux middleware, but with some refacto.
We use JSON.stringify to serialize things out. It's not the most optimized way at all,
but it works, and it's simple enough. Note that Date object is serialized as a string,
so some extra work is needed during deserialization.
*/

export async function serializeScenarioToURL(scenarioState: State, params: AllParams) {
const toSave = {
import jsUrl from './serialization/jsUrl'
import encodeURIComponent from './serialization/encodeURIComponent'

/**
* Serializes using JSURL (new format)
*/
export function serialize(state: State, params: AllParams) {
return jsUrl.serialize({
...params,
current: scenarioState.current,
containment: scenarioState.data.containment.reduction,
}

window.history.pushState('', '', `?${encodeURIComponent(JSON.stringify(toSave))}`)
current: state.current,
})
}

export function deserializeScenarioFromURL(initState: State): State {
if (window.location.search) {
try {
/*
We deserialise the URL by removing the first char ('?'), and applying JSON.parse
*/
const obj = JSON.parse(decodeURIComponent(window.location.search.slice(1)))

// Be careful of dates object that have been serialized to string

// safe to mutate here
obj.simulation.simulationTimeRange.tMin = new Date(obj.simulation.simulationTimeRange.tMin)
obj.simulation.simulationTimeRange.tMax = new Date(obj.simulation.simulationTimeRange.tMax)
/**
* Deserializes using:
* 1. JSURL (new format)
* 2. encodeURIComponent (old format for backward compatibility)
*/
export function deserialize(state: State, queryString: string): State {
let obj: AllParams

const containmentDataReduction = obj.containment.map((c: { t: string; y: number }) => ({
y: c.y,
t: new Date(c.t),
}))
if (queryString) {
try {
// 1. Trying to deserialize as JSURL
obj = jsUrl.deserialize(queryString)
} catch (error) {
try {
// 2. This is not JSURL, the old (URL encoded) format
obj = encodeURIComponent.deserialize(queryString)
} catch (error) {
// 3. None of the two formats
throw new Error('URLSerializer: Error while parsing URL')
}
}

if (obj) {
return {
...initState,
...state,
current: obj.current,
data: {
population: initState.data.population,
containment: {
reduction: containmentDataReduction,
numberPoints: containmentDataReduction.length,
},
epidemiological: initState.data.epidemiological,
containment: obj.containment,
epidemiological: obj.epidemiological,
population: obj.population,
simulation: obj.simulation,
},
}
} catch (error) {
console.error('Error while parsing URL :', error.message)
}
}
return initState

return state
}
44 changes: 44 additions & 0 deletions src/components/Main/state/serialization/encodeURIComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { AllParams } from '../../../../algorithms/types/Param.types'

const serialize = (params: AllParams): string => {
return encodeURIComponent(JSON.stringify(params))
}

const deserialize = (queryString: string): AllParams => {
try {
const obj = JSON.parse(decodeURIComponent(queryString))

const simulationTimeRangeOriginal = obj.simulation.simulationTimeRange
const simulationTimeRangeConverted = {
tMin: new Date(simulationTimeRangeOriginal.tMin),
tMax: new Date(simulationTimeRangeOriginal.tMax),
}

// NOTE: Error in original parser. It should be: obj.containment
const containmentOriginal = obj.containment
const containmentConverted = {
...containmentOriginal,
// NOTE: Error in original parser. It should be: containmentOriginal.reduction.map
reduction: containmentOriginal.map((c: { t: number; y: number }) => ({
y: c.y,
t: new Date(c.t),
})),
}

return {
...obj,
containment: containmentConverted,
simulation: {
...obj.simulation,
simulationTimeRange: simulationTimeRangeConverted,
},
}
} catch (error) {
throw new Error('encodeURIComponent: Error while parsing URL')
}
}

export default {
serialize,
deserialize,
}
Loading

0 comments on commit 0539129

Please sign in to comment.