diff --git a/src/Viewer.tsx b/src/Viewer.tsx index 1b1bb3a2a..17b04ad4a 100644 --- a/src/Viewer.tsx +++ b/src/Viewer.tsx @@ -97,8 +97,21 @@ function Viewer(): ReactElement { const [featureKey, setFeatureKey] = useState(""); const [selectedTrack, setSelectedTrack] = useState(null); const [currentFrame, setCurrentFrame] = useState(0); + /** Backdrop key is null if the dataset has no backdrops, or during initialization. */ const [selectedBackdropKey, setSelectedBackdropKey] = useState(null); + useEffect(() => { + // Switch to default backdrop if the dataset has one and none is currently selected. + // If the dataset has no backdrops, hide the backdrop. + if (dataset && (selectedBackdropKey === null || !dataset.hasBackdrop(selectedBackdropKey))) { + const defaultBackdropKey = dataset.getDefaultBackdropKey(); + setSelectedBackdropKey(defaultBackdropKey); + if (!defaultBackdropKey) { + updateConfig({ backdropVisible: false }); + } + } + }, [dataset, selectedBackdropKey]); + // TODO: Save these settings in local storage // Use reducer here in case multiple updates happen simultaneously const [config, updateConfig] = useReducer( @@ -462,9 +475,16 @@ function Viewer(): ReactElement { await setFrame(newFrame); setFindTrackInput(""); - if (selectedBackdropKey && !newDataset.hasBackdrop(selectedBackdropKey)) { - setSelectedBackdropKey(null); + + // Switch to the new dataset's default backdrop if the current one is not in the + // new dataset. `selectedBackdropKey` is null only if the current dataset has no backdrops. + if ( + selectedBackdropKey === null || + (selectedBackdropKey !== null && !newDataset.hasBackdrop(selectedBackdropKey)) + ) { + setSelectedBackdropKey(newDataset.getDefaultBackdropKey()); } + setSelectedTrack(null); setDatasetOpen(true); setFeatureThresholds(validateThresholds(newDataset, featureThresholds)); @@ -1061,6 +1081,7 @@ function Viewer(): ReactElement { categoricalColors={categoricalPalette} selectedTrack={selectedTrack} config={config} + updateConfig={updateConfig} onTrackClicked={(track) => { setFindTrackInput(track?.trackId.toString() || ""); setSelectedTrack(track); diff --git a/src/assets/images/icon-images-slash.svg b/src/assets/images/icon-images-slash.svg new file mode 100644 index 000000000..24158ba50 --- /dev/null +++ b/src/assets/images/icon-images-slash.svg @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/assets/images/icon-images.svg b/src/assets/images/icon-images.svg new file mode 100644 index 000000000..db2b29656 --- /dev/null +++ b/src/assets/images/icon-images.svg @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/assets/images/index.ts b/src/assets/images/index.ts index 66427b90b..ae0f00816 100644 --- a/src/assets/images/index.ts +++ b/src/assets/images/index.ts @@ -2,6 +2,8 @@ import AicsLogoSVGAsset from "./AICS-logo.svg?react"; import AicsLogoAndNameSVGAsset from "./AICS-logo-and-name.svg?react"; import DropdownSVGAsset from "./dropdown-arrow.svg?react"; import ExportIconSVGAsset from "./icon-export.svg?react"; +import ImagesIconSVGAsset from "./icon-images.svg?react"; +import ImagesSlashIconSVGAsset from "./icon-images-slash.svg?react"; import SwitchIconSVGAsset from "./icon-switch-arrows.svg?react"; import NoImageSVGAsset from "./no-image.svg?react"; import SpinBoxHandleDownSVGAsset from "./spin_box-handle-down.svg?react"; @@ -12,6 +14,8 @@ export const AicsLogoSVG = AicsLogoSVGAsset; export const DropdownSVG = DropdownSVGAsset; export const SwitchIconSVG = SwitchIconSVGAsset; export const NoImageSVG = NoImageSVGAsset; +export const ImagesIconSVG = ImagesIconSVGAsset; +export const ImagesSlashIconSVG = ImagesSlashIconSVGAsset; export const ExportIconSVG = ExportIconSVGAsset; export const SpinBoxHandleDownSVG = SpinBoxHandleDownSVGAsset; export const SpinBoxHandleUpSVG = SpinBoxHandleUpSVGAsset; diff --git a/src/colorizer/Dataset.ts b/src/colorizer/Dataset.ts index ab39c08a6..06682d70b 100644 --- a/src/colorizer/Dataset.ts +++ b/src/colorizer/Dataset.ts @@ -330,6 +330,10 @@ export default class Dataset { return loadedFrame; } + public getDefaultBackdropKey(): string | null { + return this.backdropData.keys().next().value ?? null; + } + public hasBackdrop(key: string): boolean { return this.backdropData.has(key); } diff --git a/src/colorizer/constants.ts b/src/colorizer/constants.ts index dc7c75051..da114be3f 100644 --- a/src/colorizer/constants.ts +++ b/src/colorizer/constants.ts @@ -33,12 +33,13 @@ export const getDefaultViewerConfig = (): ViewerConfig => ({ showLegendDuringExport: true, showHeaderDuringExport: true, keepRangeBetweenDatasets: false, + backdropVisible: false, /** Brightness, as an integer percentage. */ backdropBrightness: 100, /** Saturation, as an integer percentage. */ backdropSaturation: 100, - /** Opacity, as an integer percentage. */ - objectOpacity: 100, + /** Opacity when backdrops are visible, as an integer percentage. */ + objectOpacity: 50, outOfRangeDrawSettings: { mode: DrawMode.USE_COLOR, color: new Color(OUT_OF_RANGE_COLOR_DEFAULT) }, outlierDrawSettings: { mode: DrawMode.USE_COLOR, color: new Color(OUTLIER_COLOR_DEFAULT) }, outlineColor: new Color(OUTLINE_COLOR_DEFAULT), diff --git a/src/colorizer/types.ts b/src/colorizer/types.ts index e8780d066..942d345a3 100644 --- a/src/colorizer/types.ts +++ b/src/colorizer/types.ts @@ -169,11 +169,12 @@ export type ViewerConfig = { showLegendDuringExport: boolean; showHeaderDuringExport: boolean; keepRangeBetweenDatasets: boolean; + backdropVisible: boolean; /** Brightness, as an integer percentage. */ backdropBrightness: number; /** Saturation, as an integer percentage. */ backdropSaturation: number; - /** Opacity, as an integer percentage. */ + /** Object opacity when backdrop is visible, as an integer percentage. */ objectOpacity: number; outOfRangeDrawSettings: DrawSettings; outlierDrawSettings: DrawSettings; diff --git a/src/colorizer/utils/url_utils.ts b/src/colorizer/utils/url_utils.ts index a87d3034b..bf03659e0 100644 --- a/src/colorizer/utils/url_utils.ts +++ b/src/colorizer/utils/url_utils.ts @@ -38,6 +38,7 @@ enum UrlParam { COLOR_RAMP_REVERSED_SUFFIX = "!", PALETTE = "palette", PALETTE_KEY = "palette-key", + SHOW_BACKDROP = "bg", BACKDROP_KEY = "bg-key", BACKDROP_BRIGHTNESS = "bg-brightness", BACKDROP_SATURATION = "bg-sat", @@ -301,6 +302,7 @@ function serializeViewerConfig(config: Partial): string[] { parameters.push(`${UrlParam.OPEN_TAB}=${config.openTab}`); } + tryAddBooleanParam(parameters, config.backdropVisible, UrlParam.SHOW_BACKDROP); tryAddBooleanParam(parameters, config.showScaleBar, UrlParam.SHOW_SCALEBAR); tryAddBooleanParam(parameters, config.showTimestamp, UrlParam.SHOW_TIMESTAMP); tryAddBooleanParam(parameters, config.showTrackPath, UrlParam.SHOW_PATH); @@ -366,6 +368,7 @@ function deserializeViewerConfig(params: URLSearchParams): Partial newConfig.openTab = openTab; } + newConfig.backdropVisible = getBooleanParam(params.get(UrlParam.SHOW_BACKDROP)); newConfig.showScaleBar = getBooleanParam(params.get(UrlParam.SHOW_SCALEBAR)); newConfig.showTimestamp = getBooleanParam(params.get(UrlParam.SHOW_TIMESTAMP)); newConfig.showTrackPath = getBooleanParam(params.get(UrlParam.SHOW_PATH)); diff --git a/src/components/AppStyle.tsx b/src/components/AppStyle.tsx index ac5b0284c..6a8e7f899 100644 --- a/src/components/AppStyle.tsx +++ b/src/components/AppStyle.tsx @@ -59,6 +59,8 @@ const theme = { theme: palette.theme, link: palette.link, linkHover: palette.linkDark, + darkLink: palette.gray20, + darkLinkHover: palette.gray10, }, layout: { background: palette.gray0, diff --git a/src/components/Buttons/LinkStyleButton.tsx b/src/components/Buttons/LinkStyleButton.tsx new file mode 100644 index 000000000..c4dbc56f1 --- /dev/null +++ b/src/components/Buttons/LinkStyleButton.tsx @@ -0,0 +1,36 @@ +import styled, { css } from "styled-components"; + +/** + * A button styled to remove default styles and visually resemble a link. + * This ensures the button still follows accessibility semantics. + */ +export const LinkStyleButton = styled.button<{ + $color?: string; + $hoverColor?: string; +}>` + margin: 0; + padding: 0; + width: auto; + overflow: visible; + background: transparent; + line-height: normal; + font: inherit; + font-size: inherit; + border: none; + + text-decoration: underline; + + ${(props) => { + return css` + color: ${props.$color || "var(--color-text-link)"}; + + &:hover { + color: ${props.$hoverColor || "var(--color-text-link-hover)"}; + } + + &:focus-visible { + box-shadow: 0 0 0 3px ${props.$color || "var(--color-text-link)"}; + } + `; + }} +`; diff --git a/src/components/CanvasWrapper.tsx b/src/components/CanvasWrapper.tsx index d77e8586d..d9b0e27f4 100644 --- a/src/components/CanvasWrapper.tsx +++ b/src/components/CanvasWrapper.tsx @@ -5,9 +5,9 @@ import styled from "styled-components"; import { Color, ColorRepresentation, Vector2 } from "three"; import { clamp } from "three/src/math/MathUtils"; -import { NoImageSVG } from "../assets"; +import { ImagesIconSVG, ImagesSlashIconSVG, NoImageSVG } from "../assets"; import { ColorRamp, Dataset, Track } from "../colorizer"; -import { LoadTroubleshooting, ViewerConfig } from "../colorizer/types"; +import { LoadTroubleshooting, TabType, ViewerConfig } from "../colorizer/types"; import * as mathUtils from "../colorizer/utils/math_utils"; import { FlexColumn, FlexColumnAlignCenter, VisuallyHidden } from "../styles/utils"; @@ -15,6 +15,7 @@ import CanvasUIOverlay from "../colorizer/CanvasWithOverlay"; import Collection from "../colorizer/Collection"; import { AppThemeContext } from "./AppStyle"; import { AlertBannerProps } from "./Banner"; +import { LinkStyleButton } from "./Buttons/LinkStyleButton"; import IconButton from "./IconButton"; import LoadingSpinner from "./LoadingSpinner"; @@ -30,19 +31,32 @@ const RIGHT_CLICK_BUTTON = 2; const MAX_INVERSE_ZOOM = 2; // 0.5x zoom const MIN_INVERSE_ZOOM = 0.1; // 10x zoom -function TooltipWithSubtext(props: TooltipProps & { title: ReactNode; subtext: ReactNode }): ReactElement { +function TooltipWithSubtext( + props: TooltipProps & { title: ReactNode; subtitle?: ReactNode; subtitleList?: ReactNode[] } +): ReactElement { + const divRef = useRef(null); return ( - -

{props.title}

-

{props.subtext}

- - } - > - {props.children} -
+
+ +

{props.title}

+ {props.subtitle &&

{props.subtitle}

} + {props.subtitleList && + props.subtitleList.map((text, i) => ( +

+ {text} +

+ ))} + + } + getPopupContainer={() => divRef.current ?? document.body} + > + {props.children} +
+
); } @@ -84,6 +98,7 @@ type CanvasWrapperProps = { /** Pan and zoom will be reset on collection change. */ collection: Collection | null; config: ViewerConfig; + updateConfig: (settings: Partial) => void; vectorData: Float32Array | null; loading: boolean; @@ -225,10 +240,22 @@ export default function CanvasWrapper(inputProps: CanvasWrapperProps): ReactElem // Update backdrops useMemo(() => { - canv.setBackdropKey(props.selectedBackdropKey); - canv.setBackdropBrightness(props.config.backdropBrightness); - canv.setBackdropSaturation(props.config.backdropSaturation); - }, [props.selectedBackdropKey, props.config.backdropBrightness, props.config.backdropSaturation]); + if (props.selectedBackdropKey !== null && props.config.backdropVisible) { + canv.setBackdropKey(props.selectedBackdropKey); + canv.setBackdropBrightness(props.config.backdropBrightness); + canv.setBackdropSaturation(props.config.backdropSaturation); + canv.setObjectOpacity(props.config.objectOpacity); + } else { + canv.setBackdropKey(null); + canv.setObjectOpacity(100); + } + }, [ + props.selectedBackdropKey, + props.config.backdropVisible, + props.config.backdropBrightness, + props.config.backdropSaturation, + props.config.objectOpacity, + ]); // Update categorical colors useMemo(() => { @@ -246,10 +273,6 @@ export default function CanvasWrapper(inputProps: CanvasWrapperProps): ReactElem canv.setOutlierDrawMode(settings.mode, settings.color); }, [props.config.outlierDrawSettings]); - useMemo(() => { - canv.setObjectOpacity(props.config.objectOpacity); - }, [props.config.objectOpacity]); - useMemo(() => { canv.setInRangeLUT(props.inRangeLUT); }, [props.inRangeLUT]); @@ -574,7 +597,33 @@ export default function CanvasWrapper(inputProps: CanvasWrapperProps): ReactElem }, [props.dataset, canv]); // RENDERING ///////////////////////////////////////////////// + canv.render(); + + const onViewerSettingsLinkClicked = (): void => { + props.updateConfig({ openTab: TabType.SETTINGS }); + }; + + const backdropTooltipContents: ReactNode[] = []; + backdropTooltipContents.push( + + {props.selectedBackdropKey === null + ? "(No backdrops available)" + : props.dataset?.getBackdropData().get(props.selectedBackdropKey)?.name} + + ); + // Link to viewer settings + backdropTooltipContents.push( + + {"Viewer settings > Backdrop"} (opens settings tab) + + ); + return ( Reset view - + { @@ -618,7 +667,7 @@ export default function CanvasWrapper(inputProps: CanvasWrapperProps): ReactElem Zoom in - + { @@ -632,6 +681,23 @@ export default function CanvasWrapper(inputProps: CanvasWrapperProps): ReactElem Zoom out + + { + props.updateConfig({ backdropVisible: !props.config.backdropVisible }); + }} + disabled={props.selectedBackdropKey === null} + > + {props.config.backdropVisible ? : } + {props.config.backdropVisible ? "Hide backdrop" : "Show backdrop"} + + ); diff --git a/src/components/Tabs/SettingsTab.tsx b/src/components/Tabs/SettingsTab.tsx index 5cda9c4c4..edf1aca4e 100644 --- a/src/components/Tabs/SettingsTab.tsx +++ b/src/components/Tabs/SettingsTab.tsx @@ -41,20 +41,35 @@ export default function SettingsTab(props: SettingsTabProps): ReactElement { return { key, label: data.name }; }) : []; - backdropOptions.unshift(NO_BACKDROP); - const isBackdropDisabled = backdropOptions.length === 1 || !props.selectedBackdropKey; + const isBackdropDisabled = backdropOptions.length === 0 || props.selectedBackdropKey === null; + const isBackdropOptionsDisabled = isBackdropDisabled || !props.config.backdropVisible; + let selectedBackdropKey = props.selectedBackdropKey ?? NO_BACKDROP.key; + if (isBackdropDisabled) { + backdropOptions.push(NO_BACKDROP); + selectedBackdropKey = NO_BACKDROP.key; + } return ( - + + { + props.updateConfig({ backdropVisible: event.target.checked }); + }} + /> + + @@ -69,7 +84,7 @@ export default function SettingsTab(props: SettingsTabProps): ReactElement { onChange={(brightness: number) => props.updateConfig({ backdropBrightness: brightness })} marks={[100]} numberFormatter={(value?: number) => `${value}%`} - disabled={isBackdropDisabled} + disabled={isBackdropOptionsDisabled} /> @@ -86,7 +101,23 @@ export default function SettingsTab(props: SettingsTabProps): ReactElement { onChange={(saturation: number) => props.updateConfig({ backdropSaturation: saturation })} marks={[100]} numberFormatter={(value?: number) => `${value}%`} - disabled={isBackdropDisabled} + disabled={isBackdropOptionsDisabled} + /> + + + +
+ props.updateConfig({ objectOpacity: objectOpacity })} + marks={[100]} + numberFormatter={(value?: number) => `${value}%`} />
@@ -128,21 +159,6 @@ export default function SettingsTab(props: SettingsTabProps): ReactElement { }} /> - -
- props.updateConfig({ objectOpacity: objectOpacity })} - marks={[100]} - numberFormatter={(value?: number) => `${value}%`} - /> -
-
{ showScaleBar: true, showTimestamp: false, keepRangeBetweenDatasets: true, + backdropVisible: true, backdropBrightness: 75, backdropSaturation: 50, objectOpacity: 25, @@ -161,7 +162,7 @@ describe("Loading + saving from URL query strings", () => { }; const queryString = paramsToUrlQueryString(originalParams); const expectedQueryString = - "?collection=collection&dataset=dataset&feature=feature&track=25&t=14&filters=f1%3Am%3A0%3A0%2Cf2%3Aum%3ANaN%3ANaN%2Cf3%3Akm%3A0%3A1%2Cf4%3Amm%3A0.501%3A1000.485%2Cf5%3A%3Afff%2Cf6%3A%3A11&range=21.433%2C89.400&color=myMap-1!&palette-key=adobe&bg-sat=50&bg-brightness=75&fg-alpha=25&outlier-color=00ff00&outlier-mode=1&filter-color=ff0000&filter-mode=0&tab=filters&scalebar=1×tamp=0&path=1&keep-range=1&bg-key=some_backdrop&scatter-range=all&scatter-x=x%20axis%20name&scatter-y=y%20axis%20name"; + "?collection=collection&dataset=dataset&feature=feature&track=25&t=14&filters=f1%3Am%3A0%3A0%2Cf2%3Aum%3ANaN%3ANaN%2Cf3%3Akm%3A0%3A1%2Cf4%3Amm%3A0.501%3A1000.485%2Cf5%3A%3Afff%2Cf6%3A%3A11&range=21.433%2C89.400&color=myMap-1!&palette-key=adobe&bg-sat=50&bg-brightness=75&fg-alpha=25&outlier-color=00ff00&outlier-mode=1&filter-color=ff0000&filter-mode=0&tab=filters&bg=1&scalebar=1×tamp=0&path=1&keep-range=1&bg-key=some_backdrop&scatter-range=all&scatter-x=x%20axis%20name&scatter-y=y%20axis%20name"; expect(queryString).equals(expectedQueryString); const parsedParams = loadFromUrlSearchParams(new URLSearchParams(queryString));