Skip to content

Commit

Permalink
SAM mega study details (#834)
Browse files Browse the repository at this point in the history
Co-authored-by: Bob MacCallum <uncoolbob@gmail.com>
  • Loading branch information
dmfalke and bobular authored Feb 13, 2024
1 parent 5d9c768 commit 84a62aa
Show file tree
Hide file tree
Showing 15 changed files with 678 additions and 135 deletions.
7 changes: 5 additions & 2 deletions packages/libs/components/src/map/BoundsDriftMarker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,11 @@ export default function BoundsDriftMarker({
[setSelectedMarkers, selectedMarkers, props.id]
);

const handleDoubleClick = () => {
if (map) {
const handleDoubleClick = (e: LeafletMouseEvent) => {
// If SHIFT is pressed, ignore double-click event
// so users can quickly select multiple markers without
// triggering a zoom
if (map && !e.originalEvent.shiftKey) {
map.fitBounds(boundingBox);
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,22 +109,33 @@ export default function DraggablePanel(props: DraggablePanelProps) {
}
}

const { ref, height, width } = useResizeObserver();

useEffect(
function invokeOnPanelResize() {
const { ref } = useResizeObserver({
box: 'border-box',
onResize: ({ height, width }) => {
if (!onPanelResize || !height || !width) return;

onPanelResize({
height: height,
width: width,
});
},
[height, width]
);
});

const {
ref: containerRef,
height: conainerHeight,
width: containerWidth,
} = useResizeObserver({
box: 'border-box',
});

const finalPosition = confineToParentContainer
? constrainPositionOnScreen(panelPosition, width, height, window)
? constrainPositionOnScreen(
panelPosition,
containerWidth,
conainerHeight,
window
)
: panelPosition;

// set maximum text length for the panel title
Expand All @@ -141,7 +152,7 @@ export default function DraggablePanel(props: DraggablePanelProps) {
position={finalPosition}
>
<div
ref={ref}
ref={containerRef}
// As the attribute's name suggests, this helps with automated testing.
// At the moment, jsdom and dragging is a bad combo for testing.
data-testid={`${panelTitle} ${wasDragged ? 'dragged' : 'not dragged'}`}
Expand All @@ -155,13 +166,7 @@ export default function DraggablePanel(props: DraggablePanelProps) {
top: 0;
visibility: ${isOpen === false ? 'hidden' : 'visible'};
z-index: ${styleOverrides?.zIndex ?? 'auto'};
margin: ${styleOverrides?.margin ?? 'margin'};
// If resize is set, you can consider these two values as
// initial heights and widths.
height: ${styleOverrides?.height ?? 'fit-content'};
width: ${styleOverrides?.width ?? 'fit-content'};
min-height: ${styleOverrides?.minHeight ?? 0};
min-width: ${styleOverrides?.minWidth ?? 0};
margin: ${cssLengthToString(styleOverrides?.margin) ?? 'margin'};
`}
>
<div
Expand Down Expand Up @@ -226,6 +231,7 @@ export default function DraggablePanel(props: DraggablePanelProps) {
{HeaderButtons != null && <HeaderButtons />}
</div>
<div
ref={ref}
css={css`
// Hey, so you need to explicitly set overflow wherever
// you plan to use resize.
Expand All @@ -238,6 +244,13 @@ export default function DraggablePanel(props: DraggablePanelProps) {
// and the content's container a z-index of 1.
position: relative;
z-index: 1;
// If resize is set, you can consider these two values as
// initial heights and widths.
height: ${cssLengthToString(styleOverrides?.height) ??
'fit-content'};
width: ${cssLengthToString(styleOverrides?.width) ?? 'fit-content'};
min-height: ${cssLengthToString(styleOverrides?.minHeight) ?? 0};
min-width: ${cssLengthToString(styleOverrides?.minWidth) ?? 0};
`}
>
{children}
Expand Down Expand Up @@ -305,3 +318,12 @@ export const truncateWithEllipsis = (label: string, maxLabelLength: number) => {
? (label || '').substring(0, maxLabelLength - 2) + '...'
: label;
};

function cssLengthToString(value?: string | number): string | undefined {
switch (typeof value) {
case 'number':
return value + 'px';
default:
return value;
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
VariableTreeNode,
FeaturePrefilterThresholds,
VariableTreeNode,
useFindEntityAndVariableCollection,
} from '../../..';
import { VariableCollectionDescriptor } from '../../../types/variable';
Expand Down
36 changes: 35 additions & 1 deletion packages/libs/eda/src/lib/core/hooks/debouncing.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { debounce } from 'lodash';
import { useEffect, useRef, useState } from 'react';

// Courtesy of https://usehooks.com/useDebounce/
export function useDebounce<T>(value: T, delay: number) {
Expand All @@ -21,3 +22,36 @@ export function useDebounce<T>(value: T, delay: number) {
);
return debouncedValue;
}

/**
* Returns a stable function that calls the input function after `delayMs` time in milliseconds.
* If the returned function is called multiple times with the `delayMs` time window, previoius
* calls will be cancelled. Furthermore, when the component is unmounted, any queued function
* calls will be cancelled.
*
* Note that this hook does not require any dependencies, and does not support cancellation
* based on dependency values changing.
*/
export function useDebouncedCallback<T extends any[]>(
fn: (...args: T) => void,
delayMs: number
) {
// TODO Consider supporting cancellation based on dependency values changing.
// This could be done using a "queryKey", similar to `useQuery`.
// We would need a good use case for this behavior, before implementing.
const fnRef = useRef(fn);
fnRef.current = fn;
const debouncedFn = useRef(
debounce(function (...args: T) {
fnRef.current(...args);
}, delayMs)
).current;

useEffect(() => {
return function cancel() {
debouncedFn.cancel();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return debouncedFn;
}
2 changes: 2 additions & 0 deletions packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ function MapAnalysisImpl(props: ImplProps) {
setIsSidePanelExpanded,
setMarkerConfigurations,
setActiveMarkerConfigurationType,
setStudyDetailsPanelConfig,
geoConfigs,
setTimeSliderConfig,
showLinkToEda = false,
Expand Down Expand Up @@ -764,6 +765,7 @@ function MapAnalysisImpl(props: ImplProps) {
filteredCounts,
hideVizInputsAndControls,
setHideVizInputsAndControls,
setStudyDetailsPanelConfig,
headerButtons: HeaderButtons,
};

Expand Down
143 changes: 143 additions & 0 deletions packages/libs/eda/src/lib/map/analysis/SubStudies.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { useQuery } from '@tanstack/react-query';
import { Filter, useSubsettingClient } from '../../core';
import { usePermissions } from '@veupathdb/study-data-access/lib/data-restriction/permissionsHooks';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { DraggablePanel } from '@veupathdb/coreui/lib/components/containers';
import { PanelConfig } from './appState';
import { useDebouncedCallback } from '../../core/hooks/debouncing';
import Spinner from '@veupathdb/components/lib/components/Spinner';
import Banner from '@veupathdb/coreui/lib/components/banners/Banner';

interface Props {
studyId: string;
/** ID for Studies entity */
entityId: string;
/** ID for StudyID variable */
variableId: string;
filters?: Filter[];
panelConfig: PanelConfig;
updatePanelConfig: (config: PanelConfig) => void;
hasSelectedMarkers: boolean;
}

export function SubStudies(props: Props) {
// get tabular data for studies
const {
entityId,
studyId,
variableId,
filters = [],
panelConfig,
updatePanelConfig,
hasSelectedMarkers,
} = props;
const subsettingClient = useSubsettingClient();
const permissions = usePermissions();
const result = useQuery({
queryKey: ['map', 'studies', entityId, filters],
queryFn: async () => {
return await subsettingClient.getTabularData(studyId, entityId, {
filters,
outputVariableIds: [variableId],
reportConfig: {
headerFormat: 'standard',
},
});
},
});

const datasetIdByStudyId = useMemo(() => {
if (permissions.loading) return {};
return Object.fromEntries(
Object.entries(permissions.permissions.perDataset)
.map(([datasetId, value]) => [value?.studyId, datasetId])
.filter((entry): entry is [string, string] => entry[0] != null)
);
}, [permissions]);

const updatePosition = useDebouncedCallback(
(position: PanelConfig['position']) => {
updatePanelConfig({ ...panelConfig, position });
},
250
);

const updateDimensions = useDebouncedCallback(
(dimensions: PanelConfig['dimensions']) => {
updatePanelConfig({ ...panelConfig, dimensions });
},
250
);

return (
<DraggablePanel
isOpen
confineToParentContainer
showPanelTitle
panelTitle="Studies"
defaultPosition={panelConfig.position}
onDragComplete={updatePosition}
onPanelResize={updateDimensions}
styleOverrides={{
zIndex: 10,
height: panelConfig.dimensions.height,
width: panelConfig.dimensions.width,
resize: 'both',
overflow: 'auto',
}}
onPanelDismiss={() =>
updatePanelConfig({ ...panelConfig, isVisble: false })
}
>
<div
css={{
padding: '1em',
}}
>
{result.error ? (
<Banner
banner={{
type: 'error',
message: String(result.error),
}}
/>
) : result.data == null || result.isFetching ? (
<Spinner />
) : (
<div>
<p>
There {studyCountPhrase(result.data.length - 1)} for the{' '}
{hasSelectedMarkers ? 'selected' : 'visible'} markers on the map.
</p>
<ul>
{result.data.slice(1).map(([id, display]) => (
<li>
<Link
target="_blank"
to={{
pathname: `/record/dataset/${datasetIdByStudyId[id]}`,
}}
>
{display}
</Link>
</li>
))}
</ul>
</div>
)}
</div>
</DraggablePanel>
);
}

function studyCountPhrase(numStudies: number) {
switch (numStudies) {
case 0:
return 'are no studies';
case 1:
return 'is 1 study';
default:
return `are ${numStudies} studies`;
}
}
Loading

0 comments on commit 84a62aa

Please # to comment.