diff --git a/gui/package.json b/gui/package.json index ebf13f73..9626c7d6 100644 --- a/gui/package.json +++ b/gui/package.json @@ -50,5 +50,6 @@ "typescript": "^5.0.2", "vite": "^5.2.12", "vitest": "^1.6.0" - } + }, + "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" } diff --git a/gui/src/app/ApplicationBar.tsx b/gui/src/app/ApplicationBar.tsx deleted file mode 100644 index 2c5d7a80..00000000 --- a/gui/src/app/ApplicationBar.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { SmallIconButton } from "@fi-sci/misc"; -import { QuestionMark } from "@mui/icons-material"; -import { AppBar, Toolbar } from "@mui/material"; -import { FunctionComponent, useCallback } from "react"; -import useRoute from "./useRoute"; -import CompilationServerConnectionControl from "./CompilationServerConnectionControl/CompilationServerConnectionControl"; - -export const applicationBarColor = '#bac' -export const applicationBarColorDarkened = '#546' - -type Props = { - // none -} - -export const applicationBarHeight = 35 - -const logoUrl = `/stan-playground-logo.png` - -const ApplicationBar: FunctionComponent = () => { - const {setRoute} = useRoute() - - const onHome = useCallback(() => { - setRoute({page: 'home'}) - }, [setRoute]) - - // light greenish background color for app bar - // const barColor = '#e0ffe0' - - const barColor = '#656565' - - // const bannerColor = '#00a000' - const titleColor = 'white' - // const bannerColor = titleColor - - // const star = - - return ( - - - - logo -
   Stan Playground
- - - - } - onClick={() => setRoute({page: 'about'})} - title={`About Stan Playground`} - /> - -
-
-
- ) -} - -export default ApplicationBar \ No newline at end of file diff --git a/gui/src/app/MainWindow.tsx b/gui/src/app/MainWindow.tsx index 1d1003d0..9ec0639a 100644 --- a/gui/src/app/MainWindow.tsx +++ b/gui/src/app/MainWindow.tsx @@ -1,6 +1,5 @@ import { useWindowDimensions } from "@fi-sci/misc"; import { FunctionComponent } from "react"; -import ApplicationBar, { applicationBarHeight } from "./ApplicationBar"; import StatusBar, { statusBarHeight } from "./StatusBar"; import HomePage from "./pages/HomePage/HomePage"; import useRoute from "./useRoute"; @@ -12,13 +11,10 @@ type Props = { const MainWindow: FunctionComponent = () => { const {route} = useRoute() const {width, height} = useWindowDimensions() - const H = height - applicationBarHeight - statusBarHeight + const H = height - statusBarHeight return (
-
- -
-
+
{ route.page === 'home' ? ( diff --git a/gui/src/app/SPAnalysis/SPAnalysisContextProvider.tsx b/gui/src/app/SPAnalysis/SPAnalysisContextProvider.tsx new file mode 100644 index 00000000..53ad21f5 --- /dev/null +++ b/gui/src/app/SPAnalysis/SPAnalysisContextProvider.tsx @@ -0,0 +1,57 @@ +import { createContext, FunctionComponent, PropsWithChildren, useEffect, useReducer } from "react" +import { deserializeAnalysis, initialDataModel, serializeAnalysis, SPAnalysisDataModel } from "./SPAnalysisDataModel" +import { SPAnalysisReducer, SPAnalysisReducerAction, SPAnalysisReducerType } from "./SPAnalysisReducer" + +type SPAnalysisContextType = { + data: SPAnalysisDataModel + update: React.Dispatch +} + +type SPAnalysisContextProviderProps = { + // may be used in the future when we allow parameters to be passed through the string + sourceDataUri: string +} + +export const SPAnalysisContext = createContext({ + data: initialDataModel, + update: () => {} +}) + +const SPAnalysisContextProvider: FunctionComponent> = ({children}) => { + const [data, update] = useReducer(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. + useEffect(() => { + // as user reloads the page or closes the tab, save state to local storage + const handleBeforeUnload = () => { + const state = serializeAnalysis(data) + localStorage.setItem('stan-playground-saved-state', state) + }; + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [data]) + + useEffect(() => { + // load the saved state on first load + const savedState = localStorage.getItem('stan-playground-saved-state') + if (!savedState) return + const parsedData = deserializeAnalysis(savedState) + update({ type: 'loadLocalStorage', state: parsedData }) + }, []) + //////////////////////////////////////////////////////////////////////////////////////// + + return ( + + {children} + + ) +} + +export default SPAnalysisContextProvider + diff --git a/gui/src/app/SPAnalysis/SPAnalysisDataModel.ts b/gui/src/app/SPAnalysis/SPAnalysisDataModel.ts new file mode 100644 index 00000000..d3f7cb45 --- /dev/null +++ b/gui/src/app/SPAnalysis/SPAnalysisDataModel.ts @@ -0,0 +1,58 @@ +import { SamplingOpts, defaultSamplingOpts } from "../StanSampler/StanSampler" + +export enum SPAnalysisKnownFiles { + STANFILE = 'stanFileContent', + DATAFILE = 'dataFileContent', +} + +type SPAnalysisFiles = { + [filetype in SPAnalysisKnownFiles]: string +} + +type SPAnalysisBase = SPAnalysisFiles & +{ + samplingOpts: SamplingOpts +} + +type SPAnalysisMetadata = { + title: string +} + +type SPAnalysisEphemeralData = SPAnalysisFiles & { + // possible future things to track include the compilation status + // of the current stan src file(s) + // not implemented in this PR, but we need some content for the type + server?: string +} + +export type SPAnalysisDataModel = SPAnalysisBase & +{ + meta: SPAnalysisMetadata, + ephemera: SPAnalysisEphemeralData +} + +export const initialDataModel: SPAnalysisDataModel = { + meta: { title: "Undefined" }, + ephemera: { + stanFileContent: "", + dataFileContent: "", + }, + stanFileContent: "", + dataFileContent: "", + samplingOpts: defaultSamplingOpts +} + +export const serializeAnalysis = (data: SPAnalysisDataModel): string => { + const intermediary = { + ...data, ephemera: undefined } + return JSON.stringify(intermediary) +} + +export const deserializeAnalysis = (serialized: string): SPAnalysisDataModel => { + const intermediary = JSON.parse(serialized) + // Not sure if this is strictly necessary + intermediary.ephemera = {} + const stringFileKeys = Object.values(SPAnalysisKnownFiles).filter((v) => isNaN(Number(v))); + stringFileKeys.forEach((k) => intermediary.ephemera[k] = intermediary[k]); + return intermediary as SPAnalysisDataModel +} diff --git a/gui/src/app/SPAnalysis/SPAnalysisReducer.ts b/gui/src/app/SPAnalysis/SPAnalysisReducer.ts new file mode 100644 index 00000000..0c195413 --- /dev/null +++ b/gui/src/app/SPAnalysis/SPAnalysisReducer.ts @@ -0,0 +1,75 @@ +import { Reducer } from "react" +import { Stanie } from "../exampleStanies/exampleStanies" +import { defaultSamplingOpts, SamplingOpts } from '../StanSampler/StanSampler' +import { initialDataModel, SPAnalysisDataModel, SPAnalysisKnownFiles } from "./SPAnalysisDataModel" + + +export type SPAnalysisReducerType = Reducer + +export type SPAnalysisReducerAction = { + type: 'loadStanie', + stanie: Stanie +} | { + type: 'retitle', + title: string +} | { + type: 'editFile', + content: string, + filename: SPAnalysisKnownFiles +} | { + type: 'commitFile', + filename: SPAnalysisKnownFiles +} | { + type: 'setSamplingOpts', + opts: Partial +} | { + type: 'loadLocalStorage', + state: SPAnalysisDataModel +} | { + type: 'clear' +} + +export const SPAnalysisReducer: SPAnalysisReducerType = (s: SPAnalysisDataModel, a: SPAnalysisReducerAction) => { + switch (a.type) { + case "loadStanie": { + return { + ...s, + stanFileContent: a.stanie.stan, + dataFileContent: JSON.stringify(a.stanie.data), + samplingOpts: defaultSamplingOpts, + meta: { ...s.meta, title: a.stanie.meta.title ?? 'Untitled' }, + ephemera: { + ...s.ephemera, + stanFileContent: a.stanie.stan, + dataFileContent: JSON.stringify(a.stanie.data) + } + } + } + case "retitle": { + return { + ...s, + meta: { ...s.meta, title: a.title } + } + } + case "editFile": { + const newEphemera = { ...s.ephemera } + newEphemera[a.filename] = a.content + return { ...s, ephemera: newEphemera } + } + case "commitFile": { + const newState = { ...s } + newState[a.filename] = s.ephemera[a.filename] + return newState + } + case "setSamplingOpts": { + return { ...s, samplingOpts: { ...s.samplingOpts, ...a.opts }} + } + case "loadLocalStorage": { + return a.state; + } + case "clear": { + return initialDataModel + } + } +} + diff --git a/gui/src/app/pages/HomePage/HomePage.tsx b/gui/src/app/pages/HomePage/HomePage.tsx index b2f2abb7..3164e709 100644 --- a/gui/src/app/pages/HomePage/HomePage.tsx +++ b/gui/src/app/pages/HomePage/HomePage.tsx @@ -1,128 +1,85 @@ -import { Hyperlink } from "@fi-sci/misc"; import { Splitter } from "@fi-sci/splitter"; -import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FunctionComponent, useCallback, useContext, useEffect, useMemo, useState } from "react"; import DataFileEditor from "../../FileEditor/DataFileEditor"; import StanFileEditor from "../../FileEditor/StanFileEditor"; import RunPanel from "../../RunPanel/RunPanel"; import SamplerOutputView from "../../SamplerOutputView/SamplerOutputView"; -import useStanSampler, { useSamplerStatus } from "../../StanSampler/useStanSampler"; -import examplesStanies, { Stanie, StanieMetaData } from "../../exampleStanies/exampleStanies"; import SamplingOptsPanel from "../../SamplingOptsPanel/SamplingOptsPanel"; -import { SamplingOpts, defaultSamplingOpts } from "../../StanSampler/StanSampler"; +import SPAnalysisContextProvider, { SPAnalysisContext } from '../../SPAnalysis/SPAnalysisContextProvider'; +import { SPAnalysisKnownFiles } from "../../SPAnalysis/SPAnalysisDataModel"; +import { SamplingOpts } from "../../StanSampler/StanSampler"; +import useStanSampler, { useSamplerStatus } from "../../StanSampler/useStanSampler"; +import useRoute from "../../useRoute"; +import LeftPanel from "./LeftPanel"; +import TopBar from "./TopBar"; type Props = { width: number height: number } -const defaultStanContent = '' -const defaultDataContent = '' -const defaultMetaContent = '{"title": "Untitled"}' -const defaultSamplingOptsContent = JSON.stringify(defaultSamplingOpts) - -const initialFileContent = localStorage.getItem('main.stan') || defaultStanContent - -const initialDataFileContent = localStorage.getItem('data.json') || defaultDataContent - -const initialMetaContent = localStorage.getItem('meta.json') || defaultMetaContent - -const initialSamplingOptsContent = localStorage.getItem('samplingOpts.json') || defaultSamplingOptsContent - const HomePage: FunctionComponent = ({ width, height }) => { - const [fileContent, saveFileContent] = useState(initialFileContent) - const [editedFileContent, setEditedFileContent] = useState('') - useEffect(() => { - setEditedFileContent(fileContent) - }, [fileContent]) - useEffect(() => { - localStorage.setItem('main.stan', fileContent) - }, [fileContent]) - - const [dataFileContent, saveDataFileContent] = useState(initialDataFileContent) - const [editedDataFileContent, setEditedDataFileContent] = useState('') - useEffect(() => { - setEditedDataFileContent(dataFileContent) - }, [dataFileContent]) - useEffect(() => { - localStorage.setItem('data.json', dataFileContent) - }, [dataFileContent]) - - const [samplingOptsContent, setSamplingOptsContent] = useState(initialSamplingOptsContent) - useEffect(() => { - localStorage.setItem('samplingOpts.json', samplingOptsContent) - }, [samplingOptsContent]) - const samplingOpts = useMemo(() => ( - {...defaultSamplingOpts, ...JSON.parse(samplingOptsContent)} - ), [samplingOptsContent]) + const { route } = useRoute() + if (route.page !== 'home') { + throw Error('Unexpected route') + } + // NOTE: We should probably move the SPAnalysisContextProvider up to the App or MainWindow + // component; however this will wait on routing refactor since I don't want to add the `route` + // item in those contexts in this PR + return ( + + + + ) +} +const HomePageChild: FunctionComponent = ({ width, height }) => { + const { route, setRoute } = useRoute() + if (route.page !== 'home') { + throw Error('Unexpected route') + } + const { data, update } = useContext(SPAnalysisContext) const setSamplingOpts = useCallback((opts: SamplingOpts) => { - setSamplingOptsContent(JSON.stringify(opts, null, 2)) - }, [setSamplingOptsContent]) - - const [metaContent, setMetaContent] = useState(initialMetaContent) - useEffect(() => { - localStorage.setItem('meta.json', metaContent) - }, [metaContent]) - - const doNotSaveOnUnload = useRef(false) - useEffect(() => { - // if the user reloads the page, then we want - // to save the current state of the editor in local storage - // because this may have been overwritten in a different - // tab, and the user would expect to see the same content - // upon reload - const handleBeforeUnload = () => { - if (doNotSaveOnUnload.current) { - return - } - localStorage.setItem('main.stan', fileContent); - localStorage.setItem('data.json', dataFileContent); - localStorage.setItem('meta.json', metaContent); - }; - - window.addEventListener('beforeunload', handleBeforeUnload); - - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload); - }; - }, [fileContent, dataFileContent, metaContent, doNotSaveOnUnload]); + update({type: 'setSamplingOpts', opts}) + }, [update]) const [compiledMainJsUrl, setCompiledMainJsUrl] = useState('') - const leftPanelWidth = width > 400 ? 200 : 0 + const leftPanelWidth = Math.max(250, Math.min(340, width * 0.2)) + const topBarHeight = 25 - const handleLoadStanie = useCallback((stanie: Stanie) => { - saveFileContent(stanie.stan) - saveDataFileContent(stringifyData(stanie.data)) - setMetaContent(JSON.stringify(stanie.meta, null, 2)) - }, [saveFileContent, saveDataFileContent, setMetaContent]) + useEffect(() => { + // update the title in the route + const newRoute = { ...route, title: data.meta.title } + setRoute(newRoute, true) + }, [data.meta.title, route, setRoute]) - const handleClearBrowserData = useCallback(() => { - const confirmed = window.confirm('Are you sure you want to clear all browser data?') - if (!confirmed) { - return - } - localStorage.clear() - doNotSaveOnUnload.current = true - window.location.reload() - }, []) + useEffect(() => { + // update the document title based on the route + document.title = route?.title ?? 'stan-playground' + }, [route.title]) return ( -
-
+
+
+ +
+
-
+
@@ -130,22 +87,24 @@ const HomePage: FunctionComponent = ({ width, height }) => { width={0} height={0} fileName="main.stan" - fileContent={fileContent} - onSaveContent={saveFileContent} - editedFileContent={editedFileContent} - setEditedFileContent={setEditedFileContent} + fileContent={data.stanFileContent} + // this could be made more ergonomic? + onSaveContent={() => update({ type: 'commitFile', filename: SPAnalysisKnownFiles.STANFILE })} + editedFileContent={data.ephemera.stanFileContent} + setEditedFileContent={(content: string) => update({ type: 'editFile', content, filename: SPAnalysisKnownFiles.STANFILE })} readOnly={false} setCompiledUrl={setCompiledMainJsUrl} /> update({ type: 'commitFile', filename: SPAnalysisKnownFiles.DATAFILE })} + editedDataFileContent={data.ephemera.dataFileContent} + setEditedDataFileContent={(content: string) => update({ type: 'editFile', content, filename: SPAnalysisKnownFiles.DATAFILE })} compiledMainJsUrl={compiledMainJsUrl} - samplingOpts={samplingOpts} + samplingOpts={data.samplingOpts} setSamplingOpts={setSamplingOpts} /> @@ -219,18 +178,18 @@ const LowerRightView: FunctionComponent = ({ width, height, const samplingOptsPanelHeight = 160 const samplingOptsPanelWidth = Math.min(180, width / 2) - const {sampler} = useStanSampler(compiledMainJsUrl) - const {status: samplerStatus} = useSamplerStatus(sampler) + const { sampler } = useStanSampler(compiledMainJsUrl) + const { status: samplerStatus } = useSamplerStatus(sampler) const isSampling = samplerStatus === 'sampling' return ( -
-
+
+
-
+
= ({ width, height, samplingOpts={samplingOpts} />
-
+
{sampler && = ({ width, height, ) } -type LeftPanelProps = { - width: number - height: number - metaContent: string - setMetaContent: (text: string) => void - onLoadStanie: (stanie: Stanie) => void - onClearBrowserData: () => void -} - -const LeftPanel: FunctionComponent = ({ width, height, metaContent, setMetaContent, onLoadStanie, onClearBrowserData }) => { - const metaData = useMemo(() => { - try { - const x = JSON.parse(metaContent) as StanieMetaData - return { - title: x.title || undefined - } - } - catch (e) { - return {} - } - }, [metaContent]) - - const updateTitle = useCallback((title: string) => { - setMetaContent(JSON.stringify({ - ...metaData, - title - })) - }, [metaData, setMetaContent]) - return ( -
-
 
-
- Title: -
-
 
-
- updateTitle(e.target.value)} - /> -
-
-
- Examples -
 
- { - examplesStanies.map((stanie, i) => { - return ( -
- onLoadStanie(stanie)}> - {stanie.meta.title} - -
- ) - }) - } -
-
-
-
- - clear all browser data - -
-
- ) -} - -const stringifyData = (data: { [key: string]: any }) => { - const replacer = (_key: string, value: any) => Array.isArray(value) ? JSON.stringify(value) : value; - - return JSON.stringify(data, replacer, 2) - .replace(/"\[/g, '[') - .replace(/\]"/g, ']') -} - export default HomePage diff --git a/gui/src/app/pages/HomePage/LeftPanel.tsx b/gui/src/app/pages/HomePage/LeftPanel.tsx new file mode 100644 index 00000000..dcd0d71e --- /dev/null +++ b/gui/src/app/pages/HomePage/LeftPanel.tsx @@ -0,0 +1,49 @@ +import { Hyperlink } from "@fi-sci/misc" +import { FunctionComponent, useCallback, useContext } from "react" +import examplesStanies, { Stanie } from "../../exampleStanies/exampleStanies" +import { SPAnalysisContext } from "../../SPAnalysis/SPAnalysisContextProvider" + +type LeftPanelProps = { + width: number + height: number +} + +const LeftPanel: FunctionComponent = ({ width, height }) => { + // note: this is close enough to pass in directly if we wish + const { update } = useContext(SPAnalysisContext) + + const handleOpenExample = useCallback((stanie: Stanie) => () => { + update({ type: 'loadStanie', stanie }) + }, [update]) + return ( +
+
+

Examples

+ { + examplesStanies.map((stanie, i) => ( +
+ + {stanie.meta.title} + +
+ )) + } +
+ {/* This will probably be removed or replaced in the future. It's just for convenience during development. */} + +
+
+

+ This panel will have controls for loading/saving data from cloud +

+
+
+
+ ) +} + +export default LeftPanel \ No newline at end of file diff --git a/gui/src/app/pages/HomePage/TopBar.tsx b/gui/src/app/pages/HomePage/TopBar.tsx new file mode 100644 index 00000000..8245ec8a --- /dev/null +++ b/gui/src/app/pages/HomePage/TopBar.tsx @@ -0,0 +1,38 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { FunctionComponent } from "react"; +import CompilationServerConnectionControl from "../../CompilationServerConnectionControl/CompilationServerConnectionControl"; +import { SmallIconButton } from "@fi-sci/misc"; +import { QuestionMark } from "@mui/icons-material"; +import useRoute from "../../useRoute"; +import { Toolbar } from "@mui/material"; + + +type TopBarProps = { + width: number + height: number +} + +const TopBar: FunctionComponent = () => { + const { route, setRoute } = useRoute() + if (route.page !== 'home') { + throw Error('Unexpected route') + } + return ( +
+ + Stan Playground - {route.title} + + + + } + onClick={() => setRoute({page: 'about'})} + title={`About Stan Playground`} + /> + + +
+ ) +} + +export default TopBar; diff --git a/gui/src/app/useRoute.ts b/gui/src/app/useRoute.ts index a1c1bf3f..5a6db133 100644 --- a/gui/src/app/useRoute.ts +++ b/gui/src/app/useRoute.ts @@ -3,6 +3,8 @@ import { useLocation, useNavigate } from "react-router-dom" export type Route = { page: 'home' + sourceDataUri: string + title: string } | { page: 'about' } @@ -17,7 +19,9 @@ const useRoute = () => { if (typeof p !== 'string') { console.warn('Unexpected type for p', typeof p) return { - page: 'home' + page: 'home', + sourceDataUri: '', + title: '' } } if (p === '/about') { @@ -27,15 +31,17 @@ const useRoute = () => { } else { return { - page: 'home' + page: 'home', + sourceDataUri: p, + title: decodeURI((query.t || '') as string) } } - }, [p]) + }, [p, query]) const setRoute = useCallback((r: Route, replaceHistory?: boolean) => { let newQuery = {...query} if (r.page === 'home') { - newQuery = {p: '/'} + newQuery = {p: '/', t: encodeURI(r.title)} } else if (r.page === 'about') { newQuery = {p: '/about'} diff --git a/gui/src/index.css b/gui/src/index.css index 048cea08..95206181 100644 --- a/gui/src/index.css +++ b/gui/src/index.css @@ -1,4 +1,23 @@ body { margin: 0; font-family: 'Arial', sans-serif; +} + +.top-bar { + background: #555; + color: white; + padding-top: 2px; + padding-left: 10px; + font-size: 16px; + font-family: Verdana, Geneva, Tahoma, sans-serif; +} + +.left-panel { + background-image: repeating-linear-gradient( + 45deg, + #f0f0f0 1px, + #e0e0e0 6px + ); + margin: 0px; + font-size: 14px; } \ No newline at end of file