diff --git a/R/widget.R b/R/widget.R index e33677c..d16357e 100644 --- a/R/widget.R +++ b/R/widget.R @@ -1,18 +1,209 @@ +DEFAULT_PLUGIN_ESM <- " +function createPlugins(utilsForPlugins) { + const { + React, + PluginFileType, + PluginViewType, + PluginCoordinationType, + PluginJointFileType, + z, + useCoordination, + } = utilsForPlugins; + return { + pluginViewTypes: undefined, + pluginFileTypes: undefined, + pluginCoordinationTypes: undefined, + pluginJointFileTypes: undefined, + }; +} +export default { createPlugins }; +" + ESM <- " -function render({ el, model }) { - console.log(model.get('config')); - let count = () => model.get('count'); - let btn = document.createElement('button'); - btn.innerHTML = `count button ${count()}`; - btn.addEventListener('click', () => { - model.set('count', count() + 1); - model.save_changes(); - }); - model.on('change:count', () => { - btn.innerHTML = `count is ${count()}`; - }); - el.appendChild(btn); +import { importWithMap } from 'https://unpkg.com/dynamic-importmap@0.1.0'; +const importMap = { + imports: { + 'react': 'https://esm.sh/react@18.2.0?dev', + 'react-dom': 'https://esm.sh/react-dom@18.2.0?dev', + 'react-dom/client': 'https://esm.sh/react-dom@18.2.0/client?dev', + }, +}; + +const React = await importWithMap('react', importMap); +const { createRoot } = await importWithMap('react-dom/client', importMap); + +const e = React.createElement; + +const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + +function prependBaseUrl(config, proxy, hasHostName) { + return config; +} + +async function render(view) { + + const cssUid = view.model.get('uid'); + const jsDevMode = view.model.get('js_dev_mode'); + const jsPackageVersion = view.model.get('js_package_version'); + const customJsUrl = view.model.get('custom_js_url'); + const pluginEsm = view.model.get('plugin_esm'); + const remountOnUidChange = view.model.get('remount_on_uid_change'); + const storeUrls = view.model.get('store_urls'); + + const pkgName = (jsDevMode ? '@vitessce/dev' : 'vitessce'); + + importMap.imports.vitessce = (customJsUrl.length > 0 + ? customJsUrl + : `https://unpkg.com/${pkgName}@${jsPackageVersion}` + ); + + const { + Vitessce, + PluginFileType, + PluginViewType, + PluginCoordinationType, + PluginJointFileType, + z, + useCoordination, + } = await importWithMap('vitessce', importMap); + + let pluginViewTypes; + let pluginCoordinationTypes; + let pluginFileTypes; + let pluginJointFileTypes; + + const stores = {}; + /* + const stores = Object.fromEntries( + storeUrls.map(storeUrl => ([ + storeUrl, + { + async get(key) { + const [data, buffers] = await view.experimental.invoke('_zarr_get', [storeUrl, key]); + if (!data.success) return undefined; + return buffers[0].buffer; + }, + } + ])), + ); + */ + + try { + const pluginEsmUrl = URL.createObjectURL(new Blob([pluginEsm], { type: 'text/javascript' })); + const pluginModule = (await import(pluginEsmUrl)).default; + URL.revokeObjectURL(pluginEsmUrl); + + const pluginsObj = await pluginModule.createPlugins({ + React, + PluginFileType, + PluginViewType, + PluginCoordinationType, + PluginJointFileType, + z, + useCoordination, + }); + pluginViewTypes = pluginsObj.pluginViewTypes; + pluginCoordinationTypes = pluginsObj.pluginCoordinationTypes; + pluginFileTypes = pluginsObj.pluginFileTypes; + pluginJointFileTypes = pluginsObj.pluginJointFileTypes; + } catch(e) { + console.error(e); + } + + + function VitessceWidget(props) { + const { model } = props; + + const [config, setConfig] = React.useState(prependBaseUrl(model.get('config'), model.get('proxy'), model.get('has_host_name'))); + const [validateConfig, setValidateConfig] = React.useState(true); + const height = model.get('height'); + const theme = model.get('theme') === 'auto' ? (prefersDark ? 'dark' : 'light') : model.get('theme'); + + const divRef = React.useRef(); + + React.useEffect(() => { + if(!divRef.current) { + return () => {}; + } + + function handleMouseEnter() { + const jpn = divRef.current.closest('.jp-Notebook'); + if(jpn) { + jpn.style.overflow = 'hidden'; + } + } + function handleMouseLeave(event) { + if(event.relatedTarget === null || (event.relatedTarget && event.relatedTarget.closest('.jp-Notebook')?.length)) return; + const jpn = divRef.current.closest('.jp-Notebook'); + if(jpn) { + jpn.style.overflow = 'auto'; + } + } + divRef.current.addEventListener('mouseenter', handleMouseEnter); + divRef.current.addEventListener('mouseleave', handleMouseLeave); + + return () => { + if(divRef.current) { + divRef.current.removeEventListener('mouseenter', handleMouseEnter); + divRef.current.removeEventListener('mouseleave', handleMouseLeave); + } + }; + }, [divRef]); + + // Config changed on JS side (from within ), + // send updated config to Python side. + const onConfigChange = React.useCallback((config) => { + model.set('config', config); + setValidateConfig(false); + model.save_changes(); + }, [model]); + + // Config changed on Python side, + // pass to component to it is updated on JS side. + React.useEffect(() => { + model.on('change:config', () => { + const newConfig = prependBaseUrl(model.get('config'), model.get('proxy'), model.get('has_host_name')); + + // Force a re-render and re-validation by setting a new config.uid value. + // TODO: make this conditional on a parameter from Python. + //newConfig.uid = `random-${Math.random()}`; + //console.log('newConfig', newConfig); + setConfig(newConfig); + }); + }, []); + + const vitessceProps = { + height, theme, config, onConfigChange, validateConfig, + pluginViewTypes, pluginCoordinationTypes, pluginFileTypes, pluginJointFileTypes, + remountOnUidChange, stores, + }; + + return e('div', { ref: divRef, style: { height: height + 'px' } }, + e(React.Suspense, { fallback: e('div', {}, 'Loading...') }, + e(React.StrictMode, {}, + e(Vitessce, vitessceProps) + ), + ), + ); + } + + const root = createRoot(view.el); + root.render(e(VitessceWidget, { model: view.model })); + + return () => { + // Re-enable scrolling. + const jpn = view.el.closest('.jp-Notebook'); + if(jpn) { + jpn.style.overflow = 'auto'; + } + + // Clean up React and DOM state. + root.unmount(); + if(view._isFromDisplay) { + view.el.remove(); + } + }; } export default { render }; " @@ -86,8 +277,16 @@ vitessce_widget <- function(config, theme = "dark", width = NA, height = NA, por .mode = "static", .width = width, .height = height, + height = 600, count = 1, config = config_list, - theme = theme + theme = theme, + uid = 'todo-uuid-here', + proxy = FALSE, + js_package_version = '3.4.6', + js_dev_mode = FALSE, + custom_js_url = '', + plugin_esm = DEFAULT_PLUGIN_ESM, + remount_on_uid_change = TRUE ) } diff --git a/vignettes/seurat_basic.Rmd b/vignettes/seurat_basic.Rmd index 6986dbc..a4fc5fb 100644 --- a/vignettes/seurat_basic.Rmd +++ b/vignettes/seurat_basic.Rmd @@ -30,8 +30,10 @@ library(Seurat) adata_path <- file.path("data", "seurat", "pbmcsca.h5ad.zarr") seu <- get_seurat_v5_obj() +# TODO: add a dimensionality reduction sce <- seurat_to_sce(seu) -sce_to_anndata_zarr(sce, adata_path) +sce <- sce[1:3000,1:20000] +sce_to_anndata_zarr(sce, adata_path, to_dense = TRUE) # Create Vitessce view config vc <- VitessceConfig$new(schema_version = "1.0.16", name = "My config")