Skip to content

Commit

Permalink
Merge pull request #84 from flatironinstitute/query-url-loading
Browse files Browse the repository at this point in the history
Implements loading live state from URL query parameters.
  • Loading branch information
jsoules authored Jun 26, 2024
2 parents 6e9f7ad + 01ced77 commit 0013c9d
Show file tree
Hide file tree
Showing 3 changed files with 163 additions and 20 deletions.
41 changes: 25 additions & 16 deletions gui/src/app/SPAnalysis/SPAnalysisContextProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { createContext, FunctionComponent, PropsWithChildren, useEffect, useReducer } from "react"
import { initialDataModel, SPAnalysisDataModel } from "./SPAnalysisDataModel"
import { createContext, FunctionComponent, PropsWithChildren, useEffect, useReducer } from "react"
import { SPAnalysisReducer, SPAnalysisReducerAction, SPAnalysisReducerType } from "./SPAnalysisReducer"
import { deserializeAnalysisFromLocalStorage, serializeAnalysisToLocalStorage } from "./SPAnalysisSerialization"
import { fetchRemoteAnalysis, queryStringHasParameters, useQueryParams } from "./SPAnalysisQueryLoading"

type SPAnalysisContextType = {
data: SPAnalysisDataModel
Expand All @@ -13,16 +14,16 @@ type SPAnalysisContextProviderProps = {

export const SPAnalysisContext = createContext<SPAnalysisContextType>({
data: initialDataModel,
update: () => {}
update: () => { }
})

const SPAnalysisContextProvider: FunctionComponent<PropsWithChildren<SPAnalysisContextProviderProps>> = ({children}) => {
const [data, update] = useReducer<SPAnalysisReducerType>(SPAnalysisReducer, initialDataModel)

////////////////////////////////////////////////////////////////////////////////////////
// For convenience, we save the state to local storage so it is available on
// reload of the page But this will be revised in the future to use a more
// sophisticated storage mechanism.
const SPAnalysisContextProvider: FunctionComponent<PropsWithChildren<SPAnalysisContextProviderProps>> = ({ children }) => {

const { queries, clearSearchParams } = useQueryParams();

const [data, update] = useReducer<SPAnalysisReducerType>(SPAnalysisReducer(clearSearchParams), initialDataModel)

useEffect(() => {
// as user reloads the page or closes the tab, save state to local storage
const handleBeforeUnload = () => {
Expand All @@ -37,16 +38,24 @@ const SPAnalysisContextProvider: FunctionComponent<PropsWithChildren<SPAnalysisC
}, [data])

useEffect(() => {
// load the saved state on first load
const savedState = localStorage.getItem('stan-playground-saved-state')
if (!savedState) return
const parsedData = deserializeAnalysisFromLocalStorage(savedState)
update({ type: 'loadLocalStorage', state: parsedData })
}, [])
////////////////////////////////////////////////////////////////////////////////////////
if (data != initialDataModel) return;

if (queryStringHasParameters(queries)) {
fetchRemoteAnalysis(queries).then((data) => {
update({ type: 'loadInitialData', state: data })
})
} else {
// load the saved state on first load
const savedState = localStorage.getItem('stan-playground-saved-state')
if (!savedState) return
const parsedData = deserializeAnalysisFromLocalStorage(savedState)
update({ type: 'loadInitialData', state: parsedData })
}

}, [data, queries])

return (
<SPAnalysisContext.Provider value={{data, update}}>
<SPAnalysisContext.Provider value={{ data, update }}>
{children}
</SPAnalysisContext.Provider>
)
Expand Down
127 changes: 127 additions & 0 deletions gui/src/app/SPAnalysis/SPAnalysisQueryLoading.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { useSearchParams } from "react-router-dom";
import { SPAnalysisDataModel, initialDataModel, persistStateToEphemera } from "./SPAnalysisDataModel";
import { useCallback } from "react";


enum QueryParamKeys {
StanFile = "stan",
DataFile = "data",
SamplingOpts = "sampling_opts",
Title = "title",
SONumChains = 'num_chains',
SONumWarmup = 'num_warmup',
SONumSamples = 'num_samples',
SOInitRadius = 'init_radius',
SOSeed = 'seed'
}

type QueryParams = {
[key in QueryParamKeys]: string | null
}

export const useQueryParams = () => {
const [searchParams, setSearchParams] = useSearchParams();

const clearSearchParams = useCallback(() => {
// whenever the data state is 'dirty', we want to
// clear the URL bar as to indiciate that the viewed content is
// no longer what the link would point to
if (searchParams.size !== 0)
setSearchParams(new URLSearchParams())
}, [searchParams, setSearchParams]);

for (const key of searchParams.keys()) {
// warn on unknown keys
if (!Object.values(QueryParamKeys).includes(key as QueryParamKeys)) {
console.warn('Unknown query parameter', key)
}
}

const queries: QueryParams = {
stan: searchParams.get(QueryParamKeys.StanFile),
data: searchParams.get(QueryParamKeys.DataFile),
sampling_opts: searchParams.get(QueryParamKeys.SamplingOpts),
title: searchParams.get(QueryParamKeys.Title),
num_chains: searchParams.get(QueryParamKeys.SONumChains),
num_warmup: searchParams.get(QueryParamKeys.SONumWarmup),
num_samples: searchParams.get(QueryParamKeys.SONumSamples),
init_radius: searchParams.get(QueryParamKeys.SOInitRadius),
seed: searchParams.get(QueryParamKeys.SOSeed),
}

return { queries, clearSearchParams }
}

export const queryStringHasParameters = (query: QueryParams) => {
return Object.values(query).some(v => v !== null)
}

const tryFetch = async (url: string) => {
console.log('Fetching content from', url)
try {
const req = await fetch(url)
if (!req.ok) {
console.error('Failed to fetch from url', url, req.status, req.statusText)
return undefined
}
return await req.text()
} catch (err) {
console.error('Failed to fetch from url', url, err)
return undefined
}
}

const deepCopy = (obj: any) => {
return JSON.parse(JSON.stringify(obj))
}

export const fetchRemoteAnalysis = async (query: QueryParams) => {
// any special 'project' query could be loaded here at the top
const data: SPAnalysisDataModel = deepCopy(initialDataModel)

const stanFilePromise = query.stan ? tryFetch(query.stan) : Promise.resolve(undefined);
const dataFilePromise = query.data ? tryFetch(query.data) : Promise.resolve(undefined);
const sampling_optsPromise = query.sampling_opts ? tryFetch(query.sampling_opts) : Promise.resolve(undefined);

const stanFileContent = await stanFilePromise;
if (stanFileContent) {
data.stanFileContent = stanFileContent;
}
const dataFileContent = await dataFilePromise;
if (dataFileContent) {
data.dataFileContent = dataFileContent;
}

const sampling_opts = await sampling_optsPromise;
if (sampling_opts) {
try {
const parsed = JSON.parse(sampling_opts)
// TODO validate the parsed object
data.samplingOpts = parsed
} catch (err) {
console.error('Failed to parse sampling_opts', err)
}
} else {
if (query.num_chains) {
data.samplingOpts.num_chains = parseInt(query.num_chains)
}
if (query.num_warmup) {
data.samplingOpts.num_warmup = parseInt(query.num_warmup)
}
if (query.num_samples) {
data.samplingOpts.num_samples = parseInt(query.num_samples)
}
if (query.init_radius) {
data.samplingOpts.init_radius = parseFloat(query.init_radius)
}
if (query.seed) {
data.samplingOpts.seed = parseInt(query.seed)
}
}

if (query.title) {
data.meta.title = query.title
}

return persistStateToEphemera(data);
}
15 changes: 11 additions & 4 deletions gui/src/app/SPAnalysis/SPAnalysisReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,20 @@ export type SPAnalysisReducerAction = {
type: 'setSamplingOpts',
opts: Partial<SamplingOpts>
} | {
type: 'loadLocalStorage',
type: 'loadInitialData',
state: SPAnalysisDataModel
} | {
type: 'clear'
}

export const SPAnalysisReducer: SPAnalysisReducerType = (s: SPAnalysisDataModel, a: SPAnalysisReducerAction) => {
export const SPAnalysisReducer = (onDirty: () => void) => (s: SPAnalysisDataModel, a: SPAnalysisReducerAction) => {
if (a.type !== "loadInitialData") {
// TextEditor seems to trigger occasional spurious edits where nothing changes
if (a.type !== "editFile" || s[a.filename] != a.content) {
onDirty();
}
}

switch (a.type) {
case "loadStanie": {
const dataFileContent = JSON.stringify(a.stanie.data, null, 2);
Expand Down Expand Up @@ -71,9 +78,9 @@ export const SPAnalysisReducer: SPAnalysisReducerType = (s: SPAnalysisDataModel,
return newState
}
case "setSamplingOpts": {
return { ...s, samplingOpts: { ...s.samplingOpts, ...a.opts }}
return { ...s, samplingOpts: { ...s.samplingOpts, ...a.opts } }
}
case "loadLocalStorage": {
case "loadInitialData": {
return a.state;
}
case "clear": {
Expand Down

0 comments on commit 0013c9d

Please sign in to comment.