diff --git a/app/scripts/components/exploration/atoms/embed.ts b/app/scripts/components/exploration/atoms/embed.ts deleted file mode 100644 index b1873c366..000000000 --- a/app/scripts/components/exploration/atoms/embed.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { atomWithUrlValueStability } from '$utils/params-location-atom/atom-with-url-value-stability'; - -const initialParams = new URLSearchParams(window.location.search); - -const hydrateBoolean = (serialized: string | null) => { - return serialized === 'true'; -}; - -const dehydrateBoolean = (value: boolean) => { - // Only add the param to the URL if it's true. - return value ? 'true' : ''; -}; - -export const isEmbeddedAtom = atomWithUrlValueStability({ - initialValue: hydrateBoolean(initialParams.get('embed')), - urlParam: 'embed', - hydrate: hydrateBoolean, - dehydrate: dehydrateBoolean, - areEqual: (prev, next) => prev === next -}); diff --git a/app/scripts/components/exploration/atoms/viewMode.ts b/app/scripts/components/exploration/atoms/viewMode.ts new file mode 100644 index 000000000..6c49c0230 --- /dev/null +++ b/app/scripts/components/exploration/atoms/viewMode.ts @@ -0,0 +1,32 @@ +import { ViewMode } from '$components/exploration/types.d.ts'; +import { atomWithUrlValueStability } from '$utils/params-location-atom/atom-with-url-value-stability'; + +const initialParams = new URLSearchParams(window.location.search); + +const hydrateViewMode = (serialized: string | null) => { + if (serialized === 'simple') return serialized; + return 'default'; +}; + +const dehydrateViewMode = (value: ViewMode) => { + return value ?? 'default'; +}; + +/** + * Atom that manages the exploration view mode via URL parameter. + * + * - 'simple': Minimal view for used primarily for embedding (no navigation/header/footer/dataset selection UI or time series visualization) + * - 'default': Full exploration and analysis interface + * + * URL parameter: `?viewMode=simple` (defaults to 'default') + * + * @example + * const [viewMode] = useAtom(viewModeAtom); + */ +export const viewModeAtom = atomWithUrlValueStability({ + initialValue: hydrateViewMode(initialParams.get('viewMode')), + urlParam: 'viewMode', + hydrate: hydrateViewMode, + dehydrate: dehydrateViewMode, + areEqual: (prev, next) => prev === next +}); diff --git a/app/scripts/components/exploration/container.tsx b/app/scripts/components/exploration/container.tsx deleted file mode 100644 index b3df56f19..000000000 --- a/app/scripts/components/exploration/container.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React, { useState } from 'react'; -import { Link } from 'react-router-dom'; -import { DevTools } from 'jotai-devtools'; -import { useAtom, useSetAtom } from 'jotai'; - -import { DatasetSelectorModal } from './components/dataset-selector-modal'; -import useTimelineDatasetAtom from './hooks/use-timeline-dataset-atom'; -import { externalDatasetsAtom } from './atoms/datasetLayers'; -import { isEmbeddedAtom } from './atoms/embed'; -import EmbeddedExploration from './embed-exploration'; -import ExplorationAndAnalysis from '.'; -import { allExploreDatasets } from '$data-layer/datasets'; -import { urlAtom } from '$utils/params-location-atom/url'; -import { PageMainContent } from '$styles/page'; -import { LayoutProps } from '$components/common/layout-root'; -import PageHero from '$components/common/page-hero'; -import { DATASETS_PATH, EXPLORATION_PATH } from '$utils/routes'; - -/** - * @LEGACY-SUPPORT - * - * @NOTE: This container component serves as a wrapper for the purpose of data management, this is ONLY to support current instances. - * veda2 instances can just use the direct component, 'ExplorationAndAnalysis', and manage data directly in their page views - */ - -export default function ExplorationAndAnalysisContainer() { - const setExternalDatasets = useSetAtom(externalDatasetsAtom); - setExternalDatasets(allExploreDatasets); - const [timelineDatasets, setTimelineDatasets] = useTimelineDatasetAtom(); - const [datasetModalRevealed, setDatasetModalRevealed] = useState( - !timelineDatasets.length - ); - // @NOTE: When Exploration page is preloaded (ex. Linked with react-router) - // atomWithLocation gets initialized outside of Exploration page and returns the previous page's value - // We check if url Atom actually returns the values for exploration page here. - const [currentUrl] = useAtom(urlAtom); - const [isEmbedded] = useAtom(isEmbeddedAtom); - - if (!currentUrl.pathname?.includes(EXPLORATION_PATH)) return null; - - if (isEmbedded) { - return ; - } - const openModal = () => setDatasetModalRevealed(true); - const closeModal = () => setDatasetModalRevealed(false); - - return ( - <> - - - - - - -

There are no datasets to show with the selected filters.

-

- This tool allows the exploration and analysis of time-series - datasets in raster format. For a comprehensive list of available - datasets, please visit the{' '} - Data Catalog. -

- - } - /> -
- - ); -} diff --git a/app/scripts/components/exploration/index.tsx b/app/scripts/components/exploration/index.tsx index 7c4de93bb..fa8d16f24 100644 --- a/app/scripts/components/exploration/index.tsx +++ b/app/scripts/components/exploration/index.tsx @@ -1,142 +1,97 @@ -import React, { useEffect, useState } from 'react'; -import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; -import styled from 'styled-components'; -import { themeVal } from '@devseed-ui/theme-provider'; -import { TourProvider } from '@reactour/tour'; - +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { DevTools } from 'jotai-devtools'; import { useAtom, useSetAtom } from 'jotai'; -import Timeline from './components/timeline/timeline'; -import { ExplorationMap } from './components/map'; -import { useAnalysisController } from './hooks/use-analysis-data-request'; -import { PopoverTourComponent, TourManager } from './tour-manager'; -import { TimelineDataset } from './types.d.ts'; -import { selectedCompareDateAtom, selectedDateAtom } from './atoms/dates'; -import { CLEAR_LOCATION, urlAtom } from '$utils/params-location-atom/url'; -import { legacyGlobalStyleCSSBlock } from '$styles/legacy-global-styles'; - -// @TODO: "height: 100%" Added for exploration container to show correctly in NextJs instance but investigate why this is needed and possibly work to remove -const Container = styled.div` - display: flex; - flex-flow: column; - flex-grow: 1; - height: 100%; - - .panel-wrapper { - flex-grow: 1; - } - - .panel { - display: flex; - flex-direction: column; - position: relative; - } - * { - ${legacyGlobalStyleCSSBlock} - } - .panel-timeline { - box-shadow: 0 -1px 0 0 ${themeVal('color.base-100')}; - } - - .resize-handle { - flex: 0; - position: relative; - outline: none; - display: flex; - align-items: center; - justify-content: center; - width: 5rem; - margin: 0 auto -1.25rem auto; - padding: 0rem 0 0.25rem; - z-index: 1; - - ::before { - content: ''; - display: block; - width: 2rem; - background: ${themeVal('color.base-200')}; - height: 0.25rem; - border-radius: ${themeVal('shape.ellipsoid')}; - } - } -`; -const tourProviderStyles = { - popover: (base) => ({ - ...base, - padding: '0', - background: 'none' - }) -}; +import { DatasetSelectorModal } from '$components/exploration/components/dataset-selector-modal'; +import useTimelineDatasetAtom from '$components/exploration/hooks/use-timeline-dataset-atom'; +import { externalDatasetsAtom } from '$components/exploration/atoms/datasetLayers'; +import { viewModeAtom } from '$components/exploration/atoms/viewMode'; +import ExplorationAndAnalysisSimpleView from '$components/exploration/views/simple'; +import ExplorationAndAnalysisDefaultView from '$components/exploration/views/default'; +import { allExploreDatasets } from '$data-layer/datasets'; +import { urlAtom } from '$utils/params-location-atom/url'; +import { PageMainContent } from '$styles/page'; +import { LayoutProps } from '$components/common/layout-root'; +import PageHero from '$components/common/page-hero'; +import { DATASETS_PATH, EXPLORATION_PATH } from '$utils/routes'; -interface ExplorationAndAnalysisProps { - datasets: TimelineDataset[]; - setDatasets: (datasets: TimelineDataset[]) => void; - openDatasetsSelectionModal?: () => void; -} - -export default function ExplorationAndAnalysis( - props: ExplorationAndAnalysisProps -) { - const { datasets, setDatasets, openDatasetsSelectionModal } = props; - - const [selectedDay, setSelectedDay] = useAtom(selectedDateAtom); - - const [selectedCompareDay, setSelectedCompareDay] = useAtom( - selectedCompareDateAtom +/** + * Container component that manages exploration view routing and data state. + * + * Routes between two view modes based on URL parameter: + * - Simple view (`?viewMode=simple`): Minimal interface used primarily for embedding in viewport constrained external apps + * - Default view: Full exploration and analysis interface + * + * @LEGACY-SUPPORT + * + * @NOTE: This container component serves as a wrapper for the purpose of data management, + * this is ONLY to support current instances. veda2 instances can just use the direct + * component, 'ExplorationAndAnalysisDefaultView', and manage data directly in their page views + * + * @returns {JSX.Element | null} Renders the appropriate view or null if not on exploration path + */ +export default function ExplorationAndAnalysisContainer() { + const setExternalDatasets = useSetAtom(externalDatasetsAtom); + setExternalDatasets(allExploreDatasets); + const [timelineDatasets, setTimelineDatasets] = useTimelineDatasetAtom(); + const [datasetModalRevealed, setDatasetModalRevealed] = useState( + !timelineDatasets.length ); + // @NOTE: When Exploration page is preloaded (ex. Linked with react-router) + // atomWithLocation gets initialized outside of Exploration page and returns the previous page's value + // We check if url Atom actually returns the values for exploration page here. + const [currentUrl] = useAtom(urlAtom); + const [viewMode] = useAtom(viewModeAtom); - // @TECH-DEBT: panelHeight needs to be passed to work around Safari CSS - const [panelHeight, setPanelHeight] = useState(0); - - const setUrl = useSetAtom(urlAtom); - const { reset: resetAnalysisController } = useAnalysisController(); + if (!currentUrl.pathname?.includes(EXPLORATION_PATH)) return null; - // Reset atoms when leaving the page. - useEffect(() => { - return () => { - resetAnalysisController(); - setUrl(CLEAR_LOCATION); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + const openModal = () => setDatasetModalRevealed(true); + const closeModal = () => setDatasetModalRevealed(false); return ( - - - - - { - setPanelHeight(size); - }} - > - + + + + + {viewMode === 'simple' ? ( + + ) : ( + <> + - - - - +

+ There are no datasets to show with the selected filters. +

+

+ This tool allows the exploration and analysis of time-series + datasets in raster format. For a comprehensive list of + available datasets, please visit the{' '} + Data Catalog. +

+ + } /> -
-
-
-
+ + )} + + ); } diff --git a/app/scripts/components/exploration/types.d.ts.ts b/app/scripts/components/exploration/types.d.ts.ts index 5aacb23f3..65f78f0d5 100644 --- a/app/scripts/components/exploration/types.d.ts.ts +++ b/app/scripts/components/exploration/types.d.ts.ts @@ -194,3 +194,11 @@ export interface ZoomTransformPlain { y: number; k: number; } + +/** + * Exploration view mode. + * + * - 'simple': Minimal view for embedding + * - 'default': Full exploration interface + */ +export type ViewMode = 'simple' | 'default'; diff --git a/app/scripts/components/exploration/views/default/index.tsx b/app/scripts/components/exploration/views/default/index.tsx new file mode 100644 index 000000000..318eb3f30 --- /dev/null +++ b/app/scripts/components/exploration/views/default/index.tsx @@ -0,0 +1,158 @@ +import React, { useEffect, useState } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import styled from 'styled-components'; +import { themeVal } from '@devseed-ui/theme-provider'; +import { TourProvider } from '@reactour/tour'; + +import { useAtom, useSetAtom } from 'jotai'; +import Timeline from '$components/exploration/components/timeline/timeline'; +import { ExplorationMap } from '$components/exploration/components/map'; +import { useAnalysisController } from '$components/exploration/hooks/use-analysis-data-request'; +import { + PopoverTourComponent, + TourManager +} from '$components/exploration/tour-manager'; +import { TimelineDataset } from '$components/exploration/types.d.ts'; +import { + selectedCompareDateAtom, + selectedDateAtom +} from '$components/exploration/atoms/dates'; +import { CLEAR_LOCATION, urlAtom } from '$utils/params-location-atom/url'; +import { legacyGlobalStyleCSSBlock } from '$styles/legacy-global-styles'; + +// @TODO: "height: 100%" Added for exploration container to show correctly in NextJs instance but investigate why this is needed and possibly work to remove +const Container = styled.div` + display: flex; + flex-flow: column; + flex-grow: 1; + height: 100%; + + .panel-wrapper { + flex-grow: 1; + } + + .panel { + display: flex; + flex-direction: column; + position: relative; + } + * { + ${legacyGlobalStyleCSSBlock} + } + .panel-timeline { + box-shadow: 0 -1px 0 0 ${themeVal('color.base-100')}; + } + + .resize-handle { + flex: 0; + position: relative; + outline: none; + display: flex; + align-items: center; + justify-content: center; + width: 5rem; + margin: 0 auto -1.25rem auto; + padding: 0rem 0 0.25rem; + z-index: 1; + + ::before { + content: ''; + display: block; + width: 2rem; + background: ${themeVal('color.base-200')}; + height: 0.25rem; + border-radius: ${themeVal('shape.ellipsoid')}; + } + } +`; + +const tourProviderStyles = { + popover: (base) => ({ + ...base, + padding: '0', + background: 'none' + }) +}; + +interface ExplorationAndAnalysisDefaultViewProps { + datasets: TimelineDataset[]; + setDatasets: (datasets: TimelineDataset[]) => void; + openDatasetsSelectionModal?: () => void; +} + +/** + * Full exploration and analysis view with all features. + * + * Includes interactive map, resizable timeline panel, dataset management, + * and analysis tools. Exported as `ExplorationAndAnalysis` in the public API. + * + * @param props.datasets - Timeline datasets to display and analyze + * @param props.setDatasets - Callback to update the dataset list + * @param props.openDatasetsSelectionModal - Optional callback to open dataset selector + */ +export default function ExplorationAndAnalysisDefaultView( + props: ExplorationAndAnalysisDefaultViewProps +) { + const { datasets, setDatasets, openDatasetsSelectionModal } = props; + + const [selectedDay, setSelectedDay] = useAtom(selectedDateAtom); + + const [selectedCompareDay, setSelectedCompareDay] = useAtom( + selectedCompareDateAtom + ); + + // @TECH-DEBT: panelHeight needs to be passed to work around Safari CSS + const [panelHeight, setPanelHeight] = useState(0); + + const setUrl = useSetAtom(urlAtom); + const { reset: resetAnalysisController } = useAnalysisController(); + + // Reset atoms when leaving the page. + useEffect(() => { + return () => { + resetAnalysisController(); + setUrl(CLEAR_LOCATION); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + + { + setPanelHeight(size); + }} + > + + + + + + + + + + ); +} diff --git a/app/scripts/components/exploration/embed-exploration.tsx b/app/scripts/components/exploration/views/simple/index.tsx similarity index 69% rename from app/scripts/components/exploration/embed-exploration.tsx rename to app/scripts/components/exploration/views/simple/index.tsx index 6be3b1705..58d1c7007 100644 --- a/app/scripts/components/exploration/embed-exploration.tsx +++ b/app/scripts/components/exploration/views/simple/index.tsx @@ -1,15 +1,18 @@ import React, { useMemo, useState, useEffect } from 'react'; import styled from 'styled-components'; import { useAtom } from 'jotai'; +import { + selectedCompareDateAtom, + selectedDateAtom +} from '$components/exploration/atoms/dates'; +import { zoomAtom } from '$components/exploration/atoms/zoom'; +import { centerAtom } from '$components/exploration/atoms/center'; +import TimelineSimpleView from '$components/exploration/views/simple/timeline-simple-view'; +import { BasemapId } from '$components/common/map/controls/map-options/basemap'; import { convertProjectionToMapbox, projectionDefault -} from '../common/map/controls/map-options/projections'; -import { BasemapId } from '../common/map/controls/map-options/basemap'; -import { selectedCompareDateAtom, selectedDateAtom } from './atoms/dates'; -import { zoomAtom } from './atoms/zoom'; -import { centerAtom } from './atoms/center'; -import EmbedTimeline from './components/embed-exploration/embed-timeline'; +} from '$components/common/map/controls/map-options/projections'; import MapBlock from '$components/common/blocks/block-map'; import { VizDataset, @@ -21,31 +24,24 @@ import { useReconcileWithStacMetadata } from '$components/exploration/hooks/use- import { ProjectionOptions, TimeDensity } from '$types/veda'; import { useVedaUI } from '$context/veda-ui-provider'; -const Carto = styled.div` - position: relative; - flex-grow: 1; - height: 100vh; - display: flex; -`; -const BaseTimelineContainer = styled.div<{ isCompareMode?: boolean }>` - position: absolute; - bottom: 2rem; - left: ${({ isCompareMode }) => (isCompareMode ? '25%' : '50%')}; - transform: translateX(-50%); - z-index: 10; -`; -const CompareTimelineContainer = styled.div` - position: absolute; - bottom: 2rem; - left: 75%; - transform: translateX(-50%); - z-index: 10; -`; - -interface EmbeddedExplorationProps { +interface ExplorationAndAnalysisSimpleViewProps { datasets: TimelineDataset[]; } -export default function EmbeddedExploration(props: EmbeddedExplorationProps) { + +/** + * Simplified exploration view optimized for embedding. + * + * Renders only the map visualization and timeline controls, + * without navigation, header, footer, dataset selection UI and time series visualization. + * + * @param props.datasets - Timeline datasets to display + * + * @example + * + */ +export default function ExplorationAndAnalysisSimpleView( + props: ExplorationAndAnalysisSimpleViewProps +) { const { datasets } = props; const [selectedDay, setSelectedDay] = useAtom(selectedDateAtom); const [selectedCompareDay, setSelectedComparedDay] = useAtom( @@ -54,21 +50,20 @@ export default function EmbeddedExploration(props: EmbeddedExplorationProps) { const [zoom] = useAtom(zoomAtom); const [center] = useAtom(centerAtom); return ( - <> - - + // eslint-disable-next-line react/jsx-pascal-case + ); } -interface EmbeddedLayersExplorationProps { +interface ExplorationAndAnalysisSimpleViewContentProps { datasets: TimelineDataset[]; setSelectedDay: (x: Date) => void; setSelectedComparedDay: (x: Date) => void; @@ -99,7 +94,9 @@ const getDataLayer = ( }; }; -function EmbeddedLayersExploration(props: EmbeddedLayersExplorationProps) { +function ExplorationAndAnalysisSimpleViewContent( + props: ExplorationAndAnalysisSimpleViewContentProps +) { const { datasets, selectedDay, @@ -164,7 +161,7 @@ function EmbeddedLayersExploration(props: EmbeddedLayersExplorationProps) { }, [basemapId]); return ( - + - + {selectedDay && ( - )} - - + + {selectedCompareDay && ( - )} - - + + ); } + +const StyledContainer = styled.div` + position: relative; + flex-grow: 1; + height: 100vh; + display: flex; +`; +const StyledTimelineContainer = styled.div<{ isCompareMode?: boolean }>` + position: absolute; + bottom: 2rem; + left: ${({ isCompareMode }) => (isCompareMode ? '25%' : '50%')}; + transform: translateX(-50%); + z-index: 10; +`; +const StyledCompareTimelineContainer = styled.div` + position: absolute; + bottom: 2rem; + left: 75%; + transform: translateX(-50%); + z-index: 10; +`; diff --git a/app/scripts/components/exploration/components/embed-exploration/embed-timeline.tsx b/app/scripts/components/exploration/views/simple/timeline-simple-view.tsx similarity index 72% rename from app/scripts/components/exploration/components/embed-exploration/embed-timeline.tsx rename to app/scripts/components/exploration/views/simple/timeline-simple-view.tsx index 59831bd78..d157ba70f 100644 --- a/app/scripts/components/exploration/components/embed-exploration/embed-timeline.tsx +++ b/app/scripts/components/exploration/views/simple/timeline-simple-view.tsx @@ -1,12 +1,15 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; import { View } from 'react-calendar/dist/cjs/shared/types'; -import { getLabelFormat, getTemporalExtent } from '../timeline/timeline-utils'; -import { TimelineDatePicker } from '../timeline/timeline-datepicker'; -import { TimeDensity } from '../../types.d.ts'; +import { TimelineDatePicker } from '$components/exploration/components/timeline/timeline-datepicker'; +import { + getLabelFormat, + getTemporalExtent +} from '$components/exploration/components/timeline/timeline-utils'; import { getLowestCommonTimeDensity } from '$components/exploration/data-utils'; import { + TimeDensity, TimelineDataset, DatasetStatus, TimelineDatasetSuccess @@ -22,16 +25,28 @@ const TimelineWrapper = styled.div` background-color: white; border-radius: 2px; `; -interface EmbedTimelineProps { +interface TimelineSimpleViewProps { date: Date | null; setDate: (date: Date | null) => void; timeDensity: TimeDensity; datasets: TimelineDataset[]; label: string; + tipContent?: string; } -function EmbedTimeline(props: EmbedTimelineProps) { - const { date, setDate, timeDensity, datasets, label } = props; +/** + * Timeline date picker for the simple exploration view. + * + * Adjusts calendar view (month/year/day) based on dataset time density + * and calculates temporal extent from provided datasets. + * + * @param props.date - Currently selected date + * @param props.setDate - Callback to update the selected date + * @param props.timeDensity - Dataset time density (determines calendar view) + * @param props.datasets - Datasets used to calculate temporal extent + */ +function TimelineSimpleView(props: TimelineSimpleViewProps) { + const { date, setDate, timeDensity, datasets, label, tipContent } = props; const lowestCommonTimeDensity = useMemo( () => @@ -85,6 +100,7 @@ function EmbedTimeline(props: EmbedTimelineProps) { minDate={minMaxTemporalExtent[0]} maxDate={minMaxTemporalExtent[1]} selectedDay={date} + tipContent={tipContent} onConfirm={(d) => { if (!d) return; setDate(new Date(d)); @@ -98,4 +114,4 @@ function EmbedTimeline(props: EmbedTimelineProps) { ); } -export default EmbedTimeline; +export default TimelineSimpleView; diff --git a/app/scripts/libs/index.ts b/app/scripts/libs/index.ts index 71bba238b..844352d14 100644 --- a/app/scripts/libs/index.ts +++ b/app/scripts/libs/index.ts @@ -56,7 +56,7 @@ export { export { CatalogContent, - EmbeddedExploration, + ExplorationAndAnalysisSimpleView, ExplorationAndAnalysis, StoriesHubContent, DatasetSelectorModal diff --git a/app/scripts/libs/page-components.ts b/app/scripts/libs/page-components.ts index 33b71199a..802a5115f 100644 --- a/app/scripts/libs/page-components.ts +++ b/app/scripts/libs/page-components.ts @@ -1,6 +1,6 @@ export { default as CatalogContent } from '$components/common/catalog/catalog-content'; -export { default as ExplorationAndAnalysis } from '$components/exploration'; -export { default as EmbeddedExploration } from '$components/exploration/embed-exploration'; +export { default as ExplorationAndAnalysis } from '$components/exploration/views/default'; +export { default as ExplorationAndAnalysisSimpleView } from '$components/exploration/views/simple'; // DataSelectorModal needs to be paried with E&A, so putting this for now. export { DatasetSelectorModal } from '$components/exploration/components/dataset-selector-modal'; export { default as StoriesHubContent } from '$components/stories/hub/hub-content'; diff --git a/app/scripts/main.tsx b/app/scripts/main.tsx index 5da3a471c..db18fba89 100644 --- a/app/scripts/main.tsx +++ b/app/scripts/main.tsx @@ -47,9 +47,7 @@ const DataCatalog = lazy(() => import('$components/data-catalog/container')); const DatasetsOverview = lazy(() => import('$components/datasets/s-overview')); -const ExplorationAndAnalysis = lazy( - () => import('$components/exploration/container') -); +const ExplorationAndAnalysis = lazy(() => import('$components/exploration')); const Sandbox = lazy(() => import('$components/sandbox'));