diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 8844511145..a68bdcb2f2 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -17,3 +17,4 @@ jobs: - uses: nrwl/nx-set-shas@v3 - run: yarn - run: yarn nx affected --target=build-npm-modules --parallel=3 + - run: yarn nx affected --target=compile:check diff --git a/.gitignore b/.gitignore index 71456e7b66..529cfe1c88 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,9 @@ dist # TernJS port file .tern-port +# VSCode config files +.vscode + .editorconfig .pnp.* diff --git a/Dockerfile b/Dockerfile index d191c41ecf..2c7ff4212d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,12 +33,12 @@ COPY package.json package.json COPY yarn.lock yarn.lock COPY packages packages +ARG NODE_OPTIONS=--max-old-space-size=4096 + # Build the client bundles +# RUN echo "Building with NODE_OPTIONS=$NODE_OPTIONS" RUN yarn \ - && yarn nx bundle:npm @veupathdb/clinepi-site \ - && yarn nx bundle:npm @veupathdb/genomics-site \ - && yarn nx bundle:npm @veupathdb/mbio-site \ - && yarn nx bundle:npm @veupathdb/ortho-site + && yarn nx run-many --target=bundle:npm --parallel=1 # # # # # # # # # # # # # # # # # diff --git a/docker-compose.yml b/docker-compose.yml index fe0b0d19d6..13c97ce4c9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,5 @@ version: "3.5" -name: "web-client" - networks: traefik: external: true diff --git a/packages/libs/components/package.json b/packages/libs/components/package.json index 039a1f8448..dd20ef2f9b 100755 --- a/packages/libs/components/package.json +++ b/packages/libs/components/package.json @@ -19,7 +19,7 @@ "@visx/text": "^1.3.0", "@visx/tooltip": "^1.3.0", "@visx/visx": "^1.1.0", - "@visx/xychart": "^3.1.0", + "@visx/xychart": "https://github.com/jernestmyers/visx.git#visx-xychart", "bootstrap": "^4.5.2", "color-math": "^1.1.3", "d3": "^7.1.1", diff --git a/packages/libs/components/src/components/plotControls/AxisRangeControl.tsx b/packages/libs/components/src/components/plotControls/AxisRangeControl.tsx index 0f52658666..ccffa18c62 100755 --- a/packages/libs/components/src/components/plotControls/AxisRangeControl.tsx +++ b/packages/libs/components/src/components/plotControls/AxisRangeControl.tsx @@ -22,6 +22,8 @@ export interface AxisRangeControlProps disabled?: boolean; /** is this for a log scale axis? If so, we'll validate the min value to be > 0 */ logScale?: boolean; + /** specify step for increment/decrement buttons in MUI number inputs; MUI's default is 1 */ + step?: number; } export default function AxisRangeControl({ @@ -33,6 +35,7 @@ export default function AxisRangeControl({ // add disabled prop to disable input fields: default is false disabled = false, logScale = false, + step = undefined, }: AxisRangeControlProps) { const validator = useCallback( ( @@ -87,6 +90,7 @@ export default function AxisRangeControl({ validator={validator} // add disabled prop to disable input fields disabled={disabled} + step={step} /> ) ) : null; diff --git a/packages/libs/components/src/components/plotControls/PlotBubbleLegend.tsx b/packages/libs/components/src/components/plotControls/PlotBubbleLegend.tsx new file mode 100644 index 0000000000..d52a84e216 --- /dev/null +++ b/packages/libs/components/src/components/plotControls/PlotBubbleLegend.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { range } from 'd3'; +import _ from 'lodash'; + +// set props for custom legend function +export interface PlotLegendBubbleProps { + legendMax: number; + valueToDiameterMapper: ((value: number) => number) | undefined; +} + +// legend ellipsis function for legend title and legend items (from custom legend work) +// const legendEllipsis = (label: string, ellipsisLength: number) => { +// return (label || '').length > ellipsisLength +// ? (label || '').substring(0, ellipsisLength) + '...' +// : label; +// }; + +export default function PlotBubbleLegend({ + legendMax, + valueToDiameterMapper, +}: PlotLegendBubbleProps) { + if (valueToDiameterMapper) { + // Declare constants + const tickFontSize = '0.8em'; + // const legendTextSize = '1.0em'; + const circleStrokeWidth = 3; + const padding = 5; + const numCircles = 3; + + // The largest circle's value will be the first number that's larger than + // legendMax and has only one significant digit. Each smaller circle will + // be half the size of the last (rounded and >= 1) + const roundedOneSigFig = Number(legendMax.toPrecision(1)); + const largestCircleValue = + legendMax <= 10 + ? legendMax + : roundedOneSigFig < legendMax + ? roundedOneSigFig + 10 ** Math.floor(Math.log10(legendMax)) // effectively rounding up + : roundedOneSigFig; // no need to adjust - already rounded up + const circleValues = _.uniq( + range(numCircles) + .map((i) => Math.round(largestCircleValue / 2 ** i)) + .filter((value) => value >= 1) + ); + + const largestCircleDiameter = valueToDiameterMapper(largestCircleValue); + const largestCircleRadius = largestCircleDiameter / 2; + + const tickLength = largestCircleRadius + 5; + + return ( + + {circleValues.map((value, i) => { + const circleDiameter = valueToDiameterMapper(value); + const circleRadius = circleDiameter / 2; + const tickY = + padding + + largestCircleDiameter + + circleStrokeWidth - + circleDiameter; + + return ( + <> + + + + + {value} + + + + ); + })} + + ); + } else { + return null; + } + + // for display, convert large value with k (e.g., 12345 -> 12k): return original value if less than a criterion + // const sumLabel = props.markerLabel ?? String(fullPieValue); +} diff --git a/packages/libs/components/src/components/plotControls/PlotGradientLegend.tsx b/packages/libs/components/src/components/plotControls/PlotGradientLegend.tsx index 97af9b84ab..ef84ace1b7 100755 --- a/packages/libs/components/src/components/plotControls/PlotGradientLegend.tsx +++ b/packages/libs/components/src/components/plotControls/PlotGradientLegend.tsx @@ -81,7 +81,11 @@ export default function PlotGradientLegend({ return (
- + {stopPoints} diff --git a/packages/libs/components/src/components/plotControls/PlotLegend.tsx b/packages/libs/components/src/components/plotControls/PlotLegend.tsx index e847e49068..c61447293a 100755 --- a/packages/libs/components/src/components/plotControls/PlotLegend.tsx +++ b/packages/libs/components/src/components/plotControls/PlotLegend.tsx @@ -4,6 +4,7 @@ import PlotListLegend, { PlotListLegendProps } from './PlotListLegend'; import PlotGradientLegend, { PlotLegendGradientProps, } from './PlotGradientLegend'; +import PlotBubbleLegend, { PlotLegendBubbleProps } from './PlotBubbleLegend'; interface PlotLegendBaseProps extends ContainerStylesAddon { legendTitle?: string; @@ -13,6 +14,7 @@ export type PlotLegendProps = PlotLegendBaseProps & ( | ({ type: 'list' } & PlotListLegendProps) | ({ type: 'colorscale' } & PlotLegendGradientProps) + | ({ type: 'bubble' } & PlotLegendBubbleProps) ); export default function PlotLegend({ @@ -29,7 +31,8 @@ export default function PlotLegend({ {((type === 'list' && ((otherProps as PlotListLegendProps).legendItems.length > 1 || (otherProps as PlotListLegendProps).showOverlayLegend)) || - type === 'colorscale') && ( + type === 'colorscale' || + type === 'bubble') && (
)} + {type === 'bubble' && ( + + )}
)} diff --git a/packages/libs/components/src/components/widgets/NumberAndDateRangeInputs.tsx b/packages/libs/components/src/components/widgets/NumberAndDateRangeInputs.tsx index 50f1558b52..cdc625cb07 100755 --- a/packages/libs/components/src/components/widgets/NumberAndDateRangeInputs.tsx +++ b/packages/libs/components/src/components/widgets/NumberAndDateRangeInputs.tsx @@ -6,6 +6,7 @@ import { NumberInput, DateInput } from './NumberAndDateInputs'; import Button from './Button'; import Notification from './Notification'; import { NumberRange, DateRange, NumberOrDateRange } from '../../types/general'; +import { propTypes } from 'react-bootstrap/esm/Image'; export type BaseProps = { /** Externally controlled range. */ @@ -44,7 +45,7 @@ export type BaseProps = { disabled?: boolean; }; -export type NumberRangeInputProps = BaseProps; +export type NumberRangeInputProps = BaseProps & { step?: number }; export function NumberRangeInput(props: NumberRangeInputProps) { return ; @@ -85,6 +86,7 @@ function BaseInput({ clearButtonLabel = 'Clear', // add disabled prop to disable input fields disabled = false, + ...props }: BaseInputProps) { if (validator && required) console.log( @@ -161,6 +163,7 @@ function BaseInput({ ]); const { min, max } = localRange ?? {}; + const step = 'step' in props ? props.step : undefined; return (
@@ -188,6 +191,7 @@ function BaseInput({ }} // add disabled prop to disable input fields disabled={disabled} + step={step} /> ) : ( ) : ( handleMouseOut(e), dblclick: handleDoubleClick, }} + zIndexOffset={zIndexOffset} {...optionalIconProp} > {showPopup && popup} diff --git a/packages/libs/components/src/map/BubbleMarker.tsx b/packages/libs/components/src/map/BubbleMarker.tsx new file mode 100755 index 0000000000..15776da395 --- /dev/null +++ b/packages/libs/components/src/map/BubbleMarker.tsx @@ -0,0 +1,173 @@ +// import React from 'react'; +import L from 'leaflet'; +import BoundsDriftMarker, { BoundsDriftMarkerProps } from './BoundsDriftMarker'; + +import { ContainerStylesAddon } from '../types/plots'; + +export interface BubbleMarkerProps extends BoundsDriftMarkerProps { + data: { + /* The size value */ + value: number; + diameter: number; + /* The color value (shown in the popup) */ + colorValue?: number; + /* Label shown next to the color value in the popup */ + colorLabel?: string; + color?: string; + }; + // isAtomic: add a special thumbtack icon if this is true + isAtomic?: boolean; + onClick?: (event: L.LeafletMouseEvent) => void | undefined; +} + +/** + * this is a SVG bubble marker icon + */ +export default function BubbleMarker(props: BubbleMarkerProps) { + const { html: svgHTML, diameter: size } = bubbleMarkerSVGIcon(props); + + // set icon as divIcon + const SVGBubbleIcon = L.divIcon({ + className: 'leaflet-canvas-icon', // may need to change this className but just leave it as it for now + iconSize: new L.Point(size, size), // this will make icon to cover up SVG area! + iconAnchor: new L.Point(size / 2, size / 2), // location of topleft corner: this is used for centering of the icon like transform/translate in CSS + html: svgHTML, // divIcon HTML svg code generated above + }); + + // anim check duration exists or not + const duration: number = props.duration ? props.duration : 300; + + const popupContent = ( +
+
+ Count {props.data.value} +
+ {props.data.colorValue && ( +
+ {props.data.colorLabel}{' '} + {props.data.colorValue} +
+ )} +
+ ); + + return ( + + ); +} + +type BubbleMarkerStandaloneProps = Omit< + BubbleMarkerProps, + | 'id' + | 'position' + | 'bounds' + | 'onClick' + | 'duration' + | 'showPopup' + | 'popupClass' + | 'popupContent' +> & + ContainerStylesAddon; + +export function BubbleMarkerStandalone(props: BubbleMarkerStandaloneProps) { + const { html, diameter } = bubbleMarkerSVGIcon(props); + // NOTE: the font size and line height would normally come from the .leaflet-container class + // but we won't be using that. You can override these with `containerStyles` if you like. + return ( +
+ ); +} + +function bubbleMarkerSVGIcon(props: BubbleMarkerStandaloneProps): { + html: string; + diameter: number; +} { + // const scale = props.markerScale ?? MarkerScaleDefault; + const diameter = props.data.diameter; + const radius = diameter / 2; + // set outer white circle size to describe white boundary + const outlineWidth = 2; + const outlineRadius = radius + outlineWidth; + + let svgHTML: string = ''; + + // set drawing area + svgHTML += + ''; // initiate svg marker icon + + // for display, convert large value with k (e.g., 12345 -> 12k): return original value if less than a criterion + // const sumLabel = props.markerLabel ?? String(fullPieValue); + + // draw a larger white-filled circle + svgHTML += + ''; + + // create bubble + svgHTML += + ''; + + //TODO: do we need to show total number for bubble marker? + // adding total number text/label and centering it + // svgHTML += + // '' + + // props.data.value + + // ''; + + // check isAtomic: draw pushpin if true + if (props.isAtomic) { + let pushPinCode = '🖈'; + svgHTML += + '' + + pushPinCode + + ''; + } + + svgHTML += ''; + + return { html: svgHTML, diameter: diameter }; +} diff --git a/packages/libs/components/src/map/Types.ts b/packages/libs/components/src/map/Types.ts index 18e91ff321..146f0912c2 100644 --- a/packages/libs/components/src/map/Types.ts +++ b/packages/libs/components/src/map/Types.ts @@ -35,6 +35,8 @@ export interface MarkerProps { height: number; }; }; + /* This offset gets added to the default zIndex */ + zIndexOffset?: number; } export type AnimationFunction = ({ diff --git a/packages/libs/components/src/plots/Network.tsx b/packages/libs/components/src/plots/Network.tsx new file mode 100755 index 0000000000..c9e2702f18 --- /dev/null +++ b/packages/libs/components/src/plots/Network.tsx @@ -0,0 +1,106 @@ +import { DefaultNode } from '@visx/network'; +import { Text } from '@visx/text'; +import { LinkData, NodeData } from '../types/plots/network'; + +interface NodeWithLabelProps { + /** Network node */ + node: NodeData; + /** Function to run when a user clicks either the node or label */ + onClick?: () => void; + /** Should the label be drawn to the left or right of the node? */ + labelPosition?: 'right' | 'left'; + /** Font size for the label. Ex. "1em" */ + fontSize?: string; + /** Font weight for the label */ + fontWeight?: number; + /** Color for the label */ + labelColor?: string; +} + +// NodeWithLabel draws one node and an optional label for the node. Both the node and +// label can be styled. +export function NodeWithLabel(props: NodeWithLabelProps) { + const DEFAULT_NODE_RADIUS = 4; + const DEFAULT_NODE_COLOR = '#aaa'; + const DEFAULT_STROKE_WIDTH = 1; + + const { + node, + onClick, + labelPosition = 'right', + fontSize = '1em', + fontWeight = 200, + labelColor = '#000', + } = props; + + const { color, label, stroke, strokeWidth } = node; + + const nodeRadius = node.r ?? DEFAULT_NODE_RADIUS; + + // Calculate where the label should be posiitoned based on + // total size of the node. + let textXOffset: number; + let textAnchor: 'start' | 'end'; + + if (labelPosition === 'right') { + textXOffset = 4 + nodeRadius; + if (strokeWidth) textXOffset = textXOffset + strokeWidth; + textAnchor = 'start'; + } else { + textXOffset = -4 - nodeRadius; + if (strokeWidth) textXOffset = textXOffset - strokeWidth; + textAnchor = 'end'; + } + + return ( + <> + + {/* Note that Text becomes a tspan */} + + {label} + + + ); +} + +export interface LinkProps { + link: LinkData; + // onClick?: () => void; To add in the future, maybe also some hover action +} + +// Link component draws a linear edge between two nodes. +// Eventually can grow into drawing directed edges (edges with arrows) when the time comes. +export function Link(props: LinkProps) { + const DEFAULT_LINK_WIDTH = 1; + const DEFAULT_COLOR = '#222'; + const DEFAULT_OPACITY = 0.95; + + const { link } = props; + + return ( + + ); +} diff --git a/packages/libs/components/src/plots/VolcanoPlot.css b/packages/libs/components/src/plots/VolcanoPlot.css new file mode 100644 index 0000000000..3316c0cb7f --- /dev/null +++ b/packages/libs/components/src/plots/VolcanoPlot.css @@ -0,0 +1,28 @@ +.visx-tooltip { + z-index: 1; +} + +.VolcanoPlotTooltip { + padding: 5px 10px; + font-size: 12px; + border-radius: 2px; + box-shadow: 2px 2px 7px rgba(0, 0, 0, 0.5); +} + +.VolcanoPlotTooltip > .pseudo-hr { + margin: 5px auto; + height: 1px; + width: 100%; +} + +.VolcanoPlotTooltip > ul { + margin: 0; + padding: 0; + list-style: none; + line-height: 1.5em; + font-weight: normal; +} + +.VolcanoPlotTooltip > ul > li > span { + font-weight: bold; +} diff --git a/packages/libs/components/src/plots/VolcanoPlot.tsx b/packages/libs/components/src/plots/VolcanoPlot.tsx index 32fbf4ae8c..c117697b2e 100755 --- a/packages/libs/components/src/plots/VolcanoPlot.tsx +++ b/packages/libs/components/src/plots/VolcanoPlot.tsx @@ -5,12 +5,12 @@ import { useImperativeHandle, useRef, } from 'react'; -import { significanceColors } from '../types/plots'; import { VolcanoPlotData, VolcanoPlotDataPoint, } from '../types/plots/volcanoplot'; import { NumberRange } from '../types/general'; +import { SignificanceColors, significanceColors } from '../types/plots'; import { XYChart, Axis, @@ -20,9 +20,10 @@ import { AnnotationLineSubject, DataContext, AnnotationLabel, + Tooltip, } from '@visx/xychart'; +import findNearestDatumXY from '@visx/xychart/lib/utils/findNearestDatumXY'; import { Group } from '@visx/group'; -import { max, min } from 'lodash'; import { gridStyles, thresholdLineStyles, @@ -37,6 +38,12 @@ import Spinner from '../components/Spinner'; import { ToImgopts } from 'plotly.js'; import { DEFAULT_CONTAINER_HEIGHT } from './PlotlyPlot'; import domToImage from 'dom-to-image'; +import './VolcanoPlot.css'; + +export interface RawDataMinMaxValues { + x: NumberRange; + y: NumberRange; +} export interface VolcanoPlotProps { /** Data for the plot. An array of VolcanoPlotDataPoints */ @@ -61,7 +68,7 @@ export interface VolcanoPlotProps { /** Title of the plot */ plotTitle?: string; /** marker fill opacity: range from 0 to 1 */ - markerBodyOpacity?: number; + markerBodyOpacity: number; /** Truncation bar fill color. If no color provided, truncation bars will be filled with a black and white pattern */ truncationBarFill?: string; /** container name */ @@ -70,6 +77,8 @@ export interface VolcanoPlotProps { containerStyles?: CSSProperties; /** shall we show the loading spinner? */ showSpinner?: boolean; + /** used to determine truncation logic */ + rawDataMinMaxValues: RawDataMinMaxValues; } const EmptyVolcanoPlotData: VolcanoPlotData = [ @@ -114,8 +123,8 @@ function TruncationRectangle(props: TruncationRectangleProps) { function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { const { data = EmptyVolcanoPlotData, - independentAxisRange, // not yet implemented - expect this to be set by user - dependentAxisRange, // not yet implemented - expect this to be set by user + independentAxisRange, + dependentAxisRange, significanceThreshold, log2FoldChangeThreshold, markerBodyOpacity, @@ -124,6 +133,7 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { comparisonLabels, truncationBarFill, showSpinner = false, + rawDataMinMaxValues, } = props; // Use ref forwarding to enable screenshotting of the plot for thumbnail versions. @@ -140,87 +150,24 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { [] ); - /** - * Find mins and maxes of the data and for the plot. - * The standard x axis is the log2 fold change. The standard - * y axis is -log10 raw p value. - */ - - // Find maxes and mins of the data itself - const dataXMin = min(data.map((d) => Number(d.log2foldChange))) ?? 0; - const dataXMax = max(data.map((d) => Number(d.log2foldChange))) ?? 0; - const dataYMin = min(data.map((d) => Number(d.pValue))) ?? 0; - const dataYMax = max(data.map((d) => Number(d.pValue))) ?? 0; + // Set maxes and mins of the data itself from rawDataMinMaxValues prop + const { min: dataXMin, max: dataXMax } = rawDataMinMaxValues.x; + const { min: dataYMin, max: dataYMax } = rawDataMinMaxValues.y; - // Determine mins, maxes of axes in the plot. - // These are different than the data mins/maxes because - // of the log transform and the little bit of padding, or because axis ranges - // are supplied. - let xAxisMin: number; - let xAxisMax: number; - let yAxisMin: number; - let yAxisMax: number; - const AXIS_PADDING_FACTOR = 0.05; // The padding ensures we don't clip off part of the glyphs that represent - // the most extreme points. We could have also used d3.scale.nice but then we dont have precise control of where - // the extremes are, which is important for user-defined ranges and truncation bars. - - // X axis - if (independentAxisRange) { - xAxisMin = independentAxisRange.min; - xAxisMax = independentAxisRange.max; - } else { - if (dataXMin && dataXMax) { - // We can use the dataMin and dataMax here because we don't have a further transform - xAxisMin = dataXMin; - xAxisMax = dataXMax; - // Add a little padding to prevent clipping the glyph representing the extreme points - xAxisMin = xAxisMin - (xAxisMax - xAxisMin) * AXIS_PADDING_FACTOR; - xAxisMax = xAxisMax + (xAxisMax - xAxisMin) * AXIS_PADDING_FACTOR; - } else { - xAxisMin = 0; - xAxisMax = 0; - } - } - - // Y axis - if (dependentAxisRange) { - yAxisMin = dependentAxisRange.min; - yAxisMax = dependentAxisRange.max; - } else { - if (dataYMin && dataYMax) { - // Standard volcano plots have -log10(raw p value) as the y axis - yAxisMin = -Math.log10(dataYMax); - yAxisMax = -Math.log10(dataYMin); - // Add a little padding to prevent clipping the glyph representing the extreme points - yAxisMin = yAxisMin - (yAxisMax - yAxisMin) * AXIS_PADDING_FACTOR; - yAxisMax = yAxisMax + (yAxisMax - yAxisMin) * AXIS_PADDING_FACTOR; - } else { - yAxisMin = 0; - yAxisMax = 0; - } - } + // Set mins, maxes of axes in the plot using axis range props + const xAxisMin = independentAxisRange?.min ?? 0; + const xAxisMax = independentAxisRange?.max ?? 0; + const yAxisMin = dependentAxisRange?.min ?? 0; + const yAxisMax = dependentAxisRange?.max ?? 0; /** * Accessors - tell visx which value of the data point we should use and where. */ // For the actual volcano plot data - // Only return data if the points fall within the specified range! Otherwise they'll show up on the plot. const dataAccessors = { - xAccessor: (d: VolcanoPlotDataPoint) => { - const log2foldChange = Number(d?.log2foldChange); - - return log2foldChange <= xAxisMax && log2foldChange >= xAxisMin - ? log2foldChange - : null; - }, - yAccessor: (d: VolcanoPlotDataPoint) => { - const transformedPValue = -Math.log10(Number(d?.pValue)); - - return transformedPValue <= yAxisMax && transformedPValue >= yAxisMin - ? transformedPValue - : null; - }, + xAccessor: (d: VolcanoPlotDataPoint) => Number(d?.log2foldChange), + yAccessor: (d: VolcanoPlotDataPoint) => -Math.log10(Number(d?.pValue)), }; // For all other situations where we need to access point values. For example @@ -246,6 +193,18 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { const showYMaxTruncationBar = Number(-Math.log10(dataYMin) > yAxisMax); const yTruncationBarHeight = 0.02 * (yAxisMax - yAxisMin); + /** + * Check whether each threshold line is within the graph's axis ranges so we can + * prevent the line from rendering outside the graph. + */ + const showNegativeFoldChangeThresholdLine = + -log2FoldChangeThreshold > xAxisMin; + const showPositiveFoldChangeThresholdLine = + log2FoldChangeThreshold < xAxisMax; + const showSignificanceThresholdLine = + -Math.log10(Number(significanceThreshold)) > yAxisMin && + -Math.log10(Number(significanceThreshold)) < yAxisMax; + return ( // Relative positioning so that tooltips are positioned correctly (tooltips are positioned absolutely)
) { style={{ ...containerStyles, position: 'relative' }} >
{/* The XYChart takes care of laying out the chart elements (children) appropriately. It uses modularized React.context layers for data, events, etc. The following all becomes an svg, so use caution when ordering the children (ex. draw axes before data). */} - ) { ], zero: false, }} + findNearestDatumOverride={findNearestDatumXY} > {/* Set up the axes and grid lines. XYChart magically lays them out correctly */} @@ -317,7 +276,7 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { is on the points instead of the line connecting them. */} {/* Draw horizontal significance threshold */} - {significanceThreshold && ( + {significanceThreshold && showSignificanceThresholdLine && ( ) { {/* Draw both vertical log2 fold change threshold lines */} {log2FoldChangeThreshold && ( <> - - - - - - + {showNegativeFoldChangeThresholdLine && ( + + + + )} + {showPositiveFoldChangeThresholdLine && ( + + + + )} )} @@ -359,22 +322,79 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { {/* Wrapping in a group in order to change the opacity of points. The GlyphSeries is somehow a bunch of glyphs which are so there should be a way to pass opacity down to those elements, but I haven't found it yet */} - + { - return assignSignificanceColor( - Number(d.log2foldChange), - Number(d.pValue), - significanceThreshold, - log2FoldChangeThreshold, - significanceColors - ); - }} + colorAccessor={(d: VolcanoPlotDataPoint) => d.significanceColor} + findNearestDatumOverride={findNearestDatumXY} /> + + snapTooltipToDatumX + snapTooltipToDatumY + showVerticalCrosshair + showHorizontalCrosshair + horizontalCrosshairStyle={{ stroke: 'red' }} + verticalCrosshairStyle={{ stroke: 'red' }} + unstyled + applyPositionStyle + renderTooltip={(d) => { + const data = d.tooltipData?.nearestDatum?.datum; + /** + * Notes regarding colors in the tooltips: + * 1. We use the data point's significanceColor property for background color + * 2. For color contrast reasons, color for text and hr's border is set conditionally: + * - if significanceColor matches the 'inconclusive' color (grey), we use black + * - else, we use white + * (white font meets contrast ratio threshold (min 3:1 for UI-y things) w/ #AC3B4E (red) and #0E8FAB (blue)) + */ + const color = + data?.significanceColor === significanceColors['inconclusive'] + ? 'black' + : 'white'; + return ( +
+
    + {data?.displayLabels + ? data.displayLabels.map((label) => ( +
  • + {label} +
  • + )) + : data?.pointIDs?.map((id) => ( +
  • + {id} +
  • + ))} +
+
+
    +
  • + log2 Fold Change: {data?.log2foldChange} +
  • +
  • + P Value: {data?.pValue} +
  • +
  • + Adjusted P Value:{' '} + {data?.adjustedPValue ?? 'n/a'} +
  • +
+
+ ); + }} + /> {/* Truncation indicators */} {/* Example from https://airbnb.io/visx/docs/pattern */} @@ -435,35 +455,30 @@ function VolcanoPlot(props: VolcanoPlotProps, ref: Ref) { /** * Assign color to point based on significance and magnitude change thresholds */ -function assignSignificanceColor( +export function assignSignificanceColor( log2foldChange: number, pValue: number, significanceThreshold: number, log2FoldChangeThreshold: number, - significanceColors: string[] // Assuming the order is [insignificant, high (up regulated), low (down regulated)] + significanceColors: SignificanceColors ) { - // Name indices of the significanceColors array for easier accessing. - const INSIGNIFICANT = 0; - const HIGH = 1; - const LOW = 2; - // Test 1. If the y value is higher than the significance threshold, just return not significant if (pValue >= significanceThreshold) { - return significanceColors[INSIGNIFICANT]; + return significanceColors['inconclusive']; } // Test 2. So the y is significant. Is the x larger than the positive foldChange threshold? if (log2foldChange >= log2FoldChangeThreshold) { - return significanceColors[HIGH]; + return significanceColors['high']; } // Test 3. Is the x value lower than the negative foldChange threshold? if (log2foldChange <= -log2FoldChangeThreshold) { - return significanceColors[LOW]; + return significanceColors['low']; } // If we're still here, it must be a non significant point. - return significanceColors[INSIGNIFICANT]; + return significanceColors['inconclusive']; } export default forwardRef(VolcanoPlot); diff --git a/packages/libs/components/src/stories/BubbleMarkers.stories.tsx b/packages/libs/components/src/stories/BubbleMarkers.stories.tsx new file mode 100644 index 0000000000..c4359164b6 --- /dev/null +++ b/packages/libs/components/src/stories/BubbleMarkers.stories.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Story, Meta } from '@storybook/react/types-6-0'; + +import { MapVEuMapProps } from '../map/MapVEuMap'; + +import { BubbleMarkerStandalone } from '../map/BubbleMarker'; + +export default { + title: 'Map/Bubble Markers', +} as Meta; + +const valueToDiameterMapper = (value: number) => { + // Area scales directly with value + const constant = 100; + const area = value * constant; + const radius = Math.sqrt(area / Math.PI); + + // Radius scales with log_10 of value + // const constant = 20; + // const radius = Math.log10(value) * constant; + + // Radius scales directly with value + // const largestCircleSize = 150; + // const constant = maxValue / largestCircleSize; + // const radius = value * constant; + + return 2 * radius; +}; + +export const Standalone: Story = () => { + return ( +
+ + + +
+ ); +}; diff --git a/packages/libs/components/src/stories/plotControls/PlotLegend.stories.tsx b/packages/libs/components/src/stories/plotControls/PlotLegend.stories.tsx index 9fc754a19d..fa0f372f08 100755 --- a/packages/libs/components/src/stories/plotControls/PlotLegend.stories.tsx +++ b/packages/libs/components/src/stories/plotControls/PlotLegend.stories.tsx @@ -527,6 +527,42 @@ export const GradientPlotLegend = () => { ); }; +export const BubbleMarkerLegend = () => { + const maxValue = 100; + // const scale = 1; + + const valueToDiameterMapper = (value: number) => { + // const largestCircleArea = 9000; + const largestCircleDiameter = 150; + + // Area scales directly with value + // const constant = largestCircleArea / maxOverlayCount; + // const area = value * constant; + // const radius = Math.sqrt(area / Math.PI); + + // Radius scales with log_10 of value + // const constant = 20; + // const radius = Math.log10(value) * constant; + + // Radius scales directly with value + const constant = maxValue / largestCircleDiameter; + const diameter = value * constant; + + // return 2 * radius; + return diameter; + }; + + return ( +
+ +
+ ); +}; + // custom legend with histogram export const TestLongLegendItems = () => { // long legend test diff --git a/packages/libs/components/src/stories/plots/Network.stories.tsx b/packages/libs/components/src/stories/plots/Network.stories.tsx new file mode 100755 index 0000000000..fa4d41c9b8 --- /dev/null +++ b/packages/libs/components/src/stories/plots/Network.stories.tsx @@ -0,0 +1,90 @@ +import { Story, Meta } from '@storybook/react/types-6-0'; +import { Graph } from '@visx/network'; +import { NodeData, LinkData, NetworkData } from '../../types/plots/network'; +import { Link, NodeWithLabel } from '../../plots/Network'; + +export default { + title: 'Plots/Network', + component: NodeWithLabel, +} as Meta; + +// For simplicity, make square svgs with the following height and width +const DEFAULT_PLOT_SIZE = 500; + +interface TemplateProps { + data: NetworkData; +} + +// This template is a simple network that highlights our NodeWithLabel and Link components. +const Template: Story = (args) => { + return ( + + } + // The node components are already transformed using x and y. + // So inside the node component all coords should be relative to this + // initial transform. + nodeComponent={({ node }) => { + const nodeWithLabelProps = { + node: node, + }; + return ; + }} + /> + + ); +}; + +/** + * Stories + */ + +// A simple network with node labels +const simpleData = genNetwork(20, true, DEFAULT_PLOT_SIZE, DEFAULT_PLOT_SIZE); +export const Simple = Template.bind({}); +Simple.args = { + data: simpleData, +}; + +// A network with lots and lots of points! +const manyPointsData = genNetwork( + 100, + false, + DEFAULT_PLOT_SIZE, + DEFAULT_PLOT_SIZE +); +export const ManyPoints = Template.bind({}); +ManyPoints.args = { + data: manyPointsData, +}; + +// Gerenate a network with a given number of nodes and random edges +function genNetwork( + nNodes: number, + addNodeLabel: boolean, + height: number, + width: number +) { + // Create nodes with random positioning, an id, and optionally a label + const nodes: NodeData[] = [...Array(nNodes).keys()].map((i) => { + return { + x: Math.floor(Math.random() * width), + y: Math.floor(Math.random() * height), + id: String(i), + label: addNodeLabel ? 'Node ' + String(i) : undefined, + }; + }); + + // Create {nNodes} links. Just basic links no weighting or colors for now. + const links: LinkData[] = [...Array(nNodes).keys()].map(() => { + return { + source: nodes[Math.floor(Math.random() * nNodes)], + target: nodes[Math.floor(Math.random() * nNodes)], + }; + }); + + return { nodes, links } as NetworkData; +} diff --git a/packages/libs/components/src/stories/plots/NodeWithLabel.stories.tsx b/packages/libs/components/src/stories/plots/NodeWithLabel.stories.tsx new file mode 100755 index 0000000000..f69368b550 --- /dev/null +++ b/packages/libs/components/src/stories/plots/NodeWithLabel.stories.tsx @@ -0,0 +1,86 @@ +import { Story, Meta } from '@storybook/react/types-6-0'; +import { NodeData } from '../../types/plots/network'; +import { NodeWithLabel } from '../../plots/Network'; +import { Group } from '@visx/group'; + +export default { + title: 'Plots/Network', + component: NodeWithLabel, +} as Meta; + +interface TemplateProps { + data: NodeData; + onClick: () => void; + labelPosition?: 'right' | 'left'; + fontWeight?: number; + labelColor?: string; +} + +// Simply draw a node! +const Template: Story = (args) => { + const { data, labelPosition, fontWeight, labelColor, onClick } = args; + + const nodeWithLabelProps = { + node: data, + onClick: onClick, + labelPosition: labelPosition, + fontWeight: fontWeight, + labelColor: labelColor, + }; + + return ( + + + + + + ); +}; + +/** + * Stories + */ + +// Basic node with a label +const myNode = { + x: 100, + y: 100, + id: 'id', + label: 'label', +}; + +export const NodeWithALabel = Template.bind({}); +NodeWithALabel.args = { + data: myNode, + labelPosition: 'left', +}; + +const myFancyNode = { + x: 100, + y: 100, + id: 'id', + label: 'a fancy long label', + r: 9, + color: '#118899', + stroke: '#000', + strokeWidth: 3, +}; + +export const FancyNodeWithLabel = Template.bind({}); +FancyNodeWithLabel.args = { + data: myFancyNode, + labelPosition: 'right', + labelColor: '#008822', + fontWeight: 600, +}; + +export const ClickNodeOrLabel = Template.bind({}); +ClickNodeOrLabel.args = { + data: myNode, + labelPosition: 'right', + labelColor: '#008822', + fontWeight: 600, + onClick: () => { + console.log('clicked!'); + }, +}; diff --git a/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx b/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx index 595a2d6bb2..5eca64288d 100755 --- a/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx +++ b/packages/libs/components/src/stories/plots/VolcanoPlot.stories.tsx @@ -5,6 +5,8 @@ import { getNormallyDistributedRandomNumber } from './ScatterPlot.storyData'; import { VolcanoPlotData } from '../../types/plots/volcanoplot'; import { NumberRange } from '../../types/general'; import { yellow } from '@veupathdb/coreui/lib/definitions/colors'; +import { assignSignificanceColor } from '../../plots/VolcanoPlot'; +import { significanceColors } from '../../types/plots'; export default { title: 'Plots/VolcanoPlot', @@ -99,14 +101,53 @@ const Template: Story = (args) => { // Process input data. Take the object of arrays and turn it into // an array of data points. Note the backend will do this for us! const volcanoDataPoints: VolcanoPlotData | undefined = - args.data?.volcanoplot.log2foldChange.map((l2fc, index) => { - return { - log2foldChange: l2fc, - pValue: args.data?.volcanoplot.pValue[index], - adjustedPValue: args.data?.volcanoplot.adjustedPValue[index], - pointID: args.data?.volcanoplot.pointID[index], - }; - }); + args.data?.volcanoplot.log2foldChange + .map((l2fc, index) => { + return { + log2foldChange: l2fc, + pValue: args.data?.volcanoplot.pValue[index], + adjustedPValue: args.data?.volcanoplot.adjustedPValue[index], + pointID: args.data?.volcanoplot.pointID[index], + }; + }) + .map((d) => ({ + ...d, + pointID: d.pointID ? [d.pointID] : undefined, + significanceColor: assignSignificanceColor( + Number(d.log2foldChange), + Number(d.pValue), + args.significanceThreshold, + args.log2FoldChangeThreshold, + significanceColors + ), + })); + + const rawDataMinMaxValues = { + x: { + min: + (volcanoDataPoints && + Math.min( + ...volcanoDataPoints.map((d) => Number(d.log2foldChange)) + )) ?? + 0, + max: + (volcanoDataPoints && + Math.max( + ...volcanoDataPoints.map((d) => Number(d.log2foldChange)) + )) ?? + 0, + }, + y: { + min: + (volcanoDataPoints && + Math.min(...volcanoDataPoints.map((d) => Number(d.pValue)))) ?? + 1, + max: + (volcanoDataPoints && + Math.max(...volcanoDataPoints.map((d) => Number(d.pValue)))) ?? + 1, + }, + }; const volcanoPlotProps: VolcanoPlotProps = { data: volcanoDataPoints, @@ -118,6 +159,7 @@ const Template: Story = (args) => { dependentAxisRange: args.dependentAxisRange, truncationBarFill: args.truncationBarFill, showSpinner: args.showSpinner, + rawDataMinMaxValues, }; return ( diff --git a/packages/libs/components/src/stories/plots/VolcanoPlotRef.stories.tsx b/packages/libs/components/src/stories/plots/VolcanoPlotRef.stories.tsx index 8070890015..65bd5e9de8 100644 --- a/packages/libs/components/src/stories/plots/VolcanoPlotRef.stories.tsx +++ b/packages/libs/components/src/stories/plots/VolcanoPlotRef.stories.tsx @@ -1,11 +1,13 @@ import { useEffect } from 'react'; import { useState } from 'react'; import { useRef } from 'react'; -import { Story, Meta } from '@storybook/react/types-6-0'; +import { Story } from '@storybook/react/types-6-0'; import VolcanoPlot, { VolcanoPlotProps } from '../../plots/VolcanoPlot'; import { range } from 'lodash'; import { VolcanoPlotData } from '../../types/plots/volcanoplot'; import { getNormallyDistributedRandomNumber } from './ScatterPlot.storyData'; +import { assignSignificanceColor } from '../../plots/VolcanoPlot'; +import { significanceColors } from '../../types/plots'; export default { title: 'Plots/VolcanoPlot', @@ -63,15 +65,41 @@ const Template: Story = (args) => { }, []); // Wrangle data to get it into the nice form for plot component. - const volcanoDataPoints: VolcanoPlotData = - data.volcanoplot.log2foldChange.map((l2fc, index) => { + const volcanoDataPoints: VolcanoPlotData = data.volcanoplot.log2foldChange + .map((l2fc, index) => { return { log2foldChange: l2fc, pValue: data.volcanoplot.pValue[index], adjustedPValue: data.volcanoplot.adjustedPValue[index], pointID: data.volcanoplot.pointID[index], }; - }); + }) + .map((d) => ({ + ...d, + pointID: d.pointID ? [d.pointID] : undefined, + significanceColor: assignSignificanceColor( + Number(d.log2foldChange), + Number(d.pValue), + args.significanceThreshold, + args.log2FoldChangeThreshold, + significanceColors + ), + })); + + const rawDataMinMaxValues = { + x: { + min: + Math.min(...volcanoDataPoints.map((d) => Number(d.log2foldChange))) ?? + 0, + max: + Math.max(...volcanoDataPoints.map((d) => Number(d.log2foldChange))) ?? + 0, + }, + y: { + min: Math.min(...volcanoDataPoints.map((d) => Number(d.pValue))) ?? 0, + max: Math.max(...volcanoDataPoints.map((d) => Number(d.pValue))) ?? 0, + }, + }; const volcanoPlotProps: VolcanoPlotProps = { data: volcanoDataPoints, @@ -79,6 +107,9 @@ const Template: Story = (args) => { log2FoldChangeThreshold: args.log2FoldChangeThreshold, markerBodyOpacity: args.markerBodyOpacity, comparisonLabels: args.comparisonLabels, + rawDataMinMaxValues, + independentAxisRange: { min: -9, max: 9 }, + dependentAxisRange: { min: 0, max: 9 }, }; return ( diff --git a/packages/libs/components/src/types/plots/addOns.ts b/packages/libs/components/src/types/plots/addOns.ts index 4c40d47ac0..68131409d8 100644 --- a/packages/libs/components/src/types/plots/addOns.ts +++ b/packages/libs/components/src/types/plots/addOns.ts @@ -1,11 +1,10 @@ /** * Additional reusable modules to extend PlotProps and PlotData props */ - import { CSSProperties } from 'react'; import { BarLayoutOptions, OrientationOptions } from '.'; import { scaleLinear } from 'd3-scale'; -import { interpolateLab, extent, range } from 'd3'; +import { interpolateLab, range } from 'd3'; import { rgb, lab } from 'd3-color'; /** PlotProps addons */ @@ -242,6 +241,52 @@ const Berlin = [ 'rgb(229, 149, 144)', 'rgb(255, 173, 173)', ]; + +export const getValueToGradientColorMapper = ( + minValue: number, + maxValue: number +): ((value: number) => string) | undefined => { + const gradientColorscaleType = + minValue != null && maxValue != null + ? minValue >= 0 && maxValue >= 0 + ? 'sequential' + : minValue <= 0 && maxValue <= 0 + ? 'sequential reversed' + : 'divergent' + : undefined; + + if (gradientColorscaleType == null) { + return undefined; + } + + // Initialize normalization function. + const normalize = scaleLinear(); + + if (gradientColorscaleType === 'divergent') { + // Diverging colorscale, assume 0 is midpoint. Colorscale must be symmetric around the midpoint + const maxAbsOverlay = + Math.abs(minValue) > maxValue ? Math.abs(minValue) : maxValue; + // For each point, normalize the data to [-1, 1] + normalize.domain([-maxAbsOverlay, maxAbsOverlay]).range([-1, 1]); + + return (value) => gradientDivergingColorscaleMap(normalize(value)); + } else { + normalize.domain([minValue, maxValue]); + + if (gradientColorscaleType === 'sequential reversed') { + // Normalize data to [1, 0], so that the colorscale goes in reverse. + // NOTE: can remove once we add the ability for users to set colorscale range. + normalize.range([1, 0]); + } else { + // Then we use the sequential (from 0 to inf) colorscale. + // For each point, normalize the data to [0, 1] + normalize.range([0, 1]); + } + + return (value) => gradientSequentialColorscaleMap(normalize(value)); + } +}; + // Lighten in LAB space, then convert to RGB for plotting. export const ConvergingGradientColorscale = Berlin.map((color) => rgb(lab(color).darker(-1)).toString() @@ -283,8 +328,16 @@ export const gradientConvergingColorscaleMap = scaleLinear() .range(ConvergingGradientColorscale) .interpolate(interpolateLab); -// Significance colors (not significant, high, low) -export const significanceColors = ['#B5B8B4', '#AC3B4E', '#0E8FAB']; +export type SignificanceColors = { + inconclusive: string; + high: string; + low: string; +}; +export const significanceColors: SignificanceColors = { + inconclusive: '#B5B8B4', + high: '#AC3B4E', + low: '#0E8FAB', +}; /** truncated axis flags */ export type AxisTruncationAddon = { diff --git a/packages/libs/components/src/types/plots/network.ts b/packages/libs/components/src/types/plots/network.ts new file mode 100755 index 0000000000..fadf279582 --- /dev/null +++ b/packages/libs/components/src/types/plots/network.ts @@ -0,0 +1,49 @@ +// Types required for creating networks +export type NodeData = { + /** For now x and y are required. Eventually the network should have a default layout so that + * these become unnecessary in certain situations. + */ + /** The x coordinate of the node */ + x: number; + /** The y coordinate of the node */ + y: number; + /** Node ID. Must be unique in the network! */ + id: string; + /** Node color */ + color?: string; + /** Node radius */ + r?: number; + /** User-friendly node label */ + label?: string; + /** Color for the stroke of the node */ + stroke?: string; + /** Width of node stroke */ + strokeWidth?: number; +}; + +export type LinkData = { + /** The beginning node of the link */ + source: NodeData; + /** The ending node of the link */ + target: NodeData; + /** Link stroke width */ + strokeWidth?: number; + /** Link color */ + color?: string; + /** Link opacity. Must be between 0 and 1 */ + opacity?: number; +}; + +/** NetworkData is the same format accepted by visx's Graph component. */ +export type NetworkData = { + nodes: NodeData[]; + links: LinkData[]; +}; + +/** Bipartite network data is a regular network with addiitonal declarations of + * nodes in each of the two columns. IDs in columnXNodeIDs must match node ids exactly. + */ +export type BipartiteNetworkData = { + column1NodeIDs: string[]; + column2NodeIDs: string[]; +} & NetworkData; diff --git a/packages/libs/components/src/types/plots/volcanoplot.ts b/packages/libs/components/src/types/plots/volcanoplot.ts index e235abcb01..51a9fe163a 100755 --- a/packages/libs/components/src/types/plots/volcanoplot.ts +++ b/packages/libs/components/src/types/plots/volcanoplot.ts @@ -8,7 +8,11 @@ export type VolcanoPlotDataPoint = { // Used for thresholding and tooltip adjustedPValue?: string; // Used for tooltip - pointID?: string; + pointIDs?: string[]; + // Used to determine color of data point in the plot + significanceColor?: string; + // Optional user-friendly label. One for each pointID + displayLabels?: string[]; }; export type VolcanoPlotData = Array; diff --git a/packages/libs/eda/src/lib/core/api/DataClient/index.ts b/packages/libs/eda/src/lib/core/api/DataClient/index.ts index 733cc0b161..a69b4b797a 100644 --- a/packages/libs/eda/src/lib/core/api/DataClient/index.ts +++ b/packages/libs/eda/src/lib/core/api/DataClient/index.ts @@ -27,8 +27,12 @@ import { MapMarkersOverlayResponse, StandaloneMapMarkersResponse, StandaloneMapMarkersRequestParams, + StandaloneMapBubblesResponse, + StandaloneMapBubblesRequestParams, ContinousVariableMetadataRequestParams, ContinousVariableMetadataResponse, + StandaloneMapBubblesLegendRequestParams, + StandaloneMapBubblesLegendResponse, } from './types'; export default class DataClient extends FetchClientWithCredentials { @@ -186,6 +190,31 @@ export default class DataClient extends FetchClientWithCredentials { ); } + // standalone bubble markers + getStandaloneBubbles( + computationName: string, + params: StandaloneMapBubblesRequestParams + ): Promise { + return this.getVisualizationData( + computationName, + 'map-markers/bubbles', + params, + StandaloneMapBubblesResponse + ); + } + + getStandaloneBubblesLegend( + computationName: string, + params: StandaloneMapBubblesLegendRequestParams + ): Promise { + return this.getVisualizationData( + computationName, + 'map-markers/bubbles/legend', + params, + StandaloneMapBubblesLegendResponse + ); + } + // filter-aware continuous overlay variable metadata getContinousVariableMetadata( params: ContinousVariableMetadataRequestParams diff --git a/packages/libs/eda/src/lib/core/api/DataClient/types.ts b/packages/libs/eda/src/lib/core/api/DataClient/types.ts index ffdcd33684..4333667f77 100755 --- a/packages/libs/eda/src/lib/core/api/DataClient/types.ts +++ b/packages/libs/eda/src/lib/core/api/DataClient/types.ts @@ -148,6 +148,13 @@ const plotConfig = intersection([ }), ]); +// to be distinguished from geo-viewports +export type NumericViewport = TypeOf; +const numericViewport = type({ + xMin: string, + xMax: string, +}); + export interface HistogramRequestParams { studyId: string; filters: Filter[]; @@ -164,10 +171,7 @@ export interface HistogramRequestParams { value?: number; units?: TimeUnit; }; - viewport?: { - xMin: string; - xMax: string; - }; + viewport?: NumericViewport; showMissingness?: 'TRUE' | 'FALSE'; }; } @@ -182,13 +186,6 @@ const histogramSummary = type({ max: string, }); -// to be distinguised from geo-viewports -export type NumericViewport = TypeOf; -const numericViewport = type({ - xMin: string, - xMax: string, -}); - export type HistogramConfig = TypeOf; const histogramConfig = intersection([ plotConfig, @@ -408,10 +405,7 @@ export interface LineplotRequestParams { overlayVariable?: VariableDescriptor; facetVariable?: ZeroToTwoVariables; binSpec: BinSpec; - viewport?: { - xMin: string; - xMax: string; - }; + viewport?: NumericViewport; showMissingness?: 'TRUE' | 'FALSE'; valueSpec: 'mean' | 'median' | 'geometricMean' | 'proportion'; errorBars: 'TRUE' | 'FALSE'; @@ -666,41 +660,47 @@ export const BoxplotResponse = intersection([ }), ]); +export type LatLonViewport = TypeOf; +const latLonViewport = type({ + latitude: type({ + xMin: number, + xMax: number, + }), + longitude: type({ + left: number, + right: number, + }), +}); + +interface MapMarkersConfig { + outputEntityId: string; + geoAggregateVariable: VariableDescriptor; + latitudeVariable: VariableDescriptor; + longitudeVariable: VariableDescriptor; + viewport: LatLonViewport; +} + export interface MapMarkersRequestParams { studyId: string; filters: Filter[]; - config: { - outputEntityId: string; - geoAggregateVariable: VariableDescriptor; - latitudeVariable: VariableDescriptor; - longitudeVariable: VariableDescriptor; - viewport: { - latitude: { - xMin: number; - xMax: number; - }; - longitude: { - left: number; - right: number; - }; - }; - }; + config: MapMarkersConfig; } +type MapElement = TypeOf; +const MapElement = type({ + geoAggregateValue: string, + entityCount: number, + avgLat: number, + avgLon: number, + minLat: number, + minLon: number, + maxLat: number, + maxLon: number, +}); + export type MapMarkersResponse = TypeOf; export const MapMarkersResponse = type({ - mapElements: array( - type({ - geoAggregateValue: string, - entityCount: number, - avgLat: number, - avgLon: number, - minLat: number, - minLon: number, - maxLat: number, - maxLon: number, - }) - ), + mapElements: array(MapElement), config: type({ completeCasesGeoVar: number, }), @@ -709,8 +709,7 @@ export const MapMarkersResponse = type({ export interface MapMarkersOverlayRequestParams { studyId: string; filters: Filter[]; - config: { - outputEntityId: string; + config: MapMarkersConfig & { showMissingness: | 'TRUE' | 'FALSE' @@ -718,20 +717,7 @@ export interface MapMarkersOverlayRequestParams { | 'allVariables' | 'strataVariables'; xAxisVariable: VariableDescriptor; - latitudeVariable: VariableDescriptor; - longitudeVariable: VariableDescriptor; - geoAggregateVariable: VariableDescriptor; valueSpec: 'count' | 'proportion'; - viewport: { - latitude: { - xMin: number; - xMax: number; - }; - longitude: { - left: number; - right: number; - }; - }; }; } @@ -739,16 +725,7 @@ export type MapMarkersOverlayConfig = TypeOf; const mapMarkersOverlayConfig = intersection([ plotConfig, type({ - viewport: type({ - latitude: type({ - xMin: number, - xMax: number, - }), - longitude: type({ - left: number, - right: number, - }), - }), + viewport: latLonViewport, }), partial({ binSpec: BinSpec, @@ -796,7 +773,6 @@ export const AllValuesDefinition = type({ export type OverlayConfig = TypeOf; export const OverlayConfig = intersection([ type({ - overlayType: keyof({ categorical: null, continuous: null }), overlayVariable: VariableDescriptor, }), union([ @@ -811,26 +787,28 @@ export const OverlayConfig = intersection([ ]), ]); +export type BubbleOverlayConfig = TypeOf; +export const BubbleOverlayConfig = type({ + overlayVariable: VariableDescriptor, + aggregationConfig: union([ + type({ + overlayType: literal('categorical'), + numeratorValues: array(string), + denominatorValues: array(string), + }), + type({ + overlayType: literal('continuous'), + aggregator: keyof({ mean: null, median: null }), + }), + ]), +}); + export interface StandaloneMapMarkersRequestParams { studyId: string; filters: Filter[]; - config: { - outputEntityId: string; - geoAggregateVariable: VariableDescriptor; - latitudeVariable: VariableDescriptor; - longitudeVariable: VariableDescriptor; + config: MapMarkersConfig & { overlayConfig?: Omit; valueSpec: 'count' | 'proportion'; - viewport: { - latitude: { - xMin: number; - xMax: number; - }; - longitude: { - left: number; - right: number; - }; - }; }; } @@ -839,32 +817,75 @@ export type StandaloneMapMarkersResponse = TypeOf< >; export const StandaloneMapMarkersResponse = type({ mapElements: array( - type({ - geoAggregateValue: string, - entityCount: number, - overlayValues: array( - intersection([ - type({ - binLabel: string, - value: number, - count: number, - }), - partial({ - binStart: string, - binEnd: string, - }), - ]) - ), - avgLat: number, - avgLon: number, - minLat: number, - minLon: number, - maxLat: number, - maxLon: number, - }) + intersection([ + MapElement, + type({ + overlayValues: array( + intersection([ + type({ + binLabel: string, + value: number, + count: number, + }), + partial({ + binStart: string, + binEnd: string, + }), + ]) + ), + }), + ]) + ), +}); + +export interface StandaloneMapBubblesRequestParams { + studyId: string; + filters: Filter[]; + config: MapMarkersConfig & { + overlayConfig?: BubbleOverlayConfig; + valueSpec: 'count'; + }; +} + +export type StandaloneMapBubblesResponse = TypeOf< + typeof StandaloneMapBubblesResponse +>; +export const StandaloneMapBubblesResponse = type({ + mapElements: array( + intersection([ + MapElement, + type({ + overlayValue: number, + }), + ]) ), }); +export interface StandaloneMapBubblesLegendRequestParams { + studyId: string; + filters: Filter[]; + config: { + outputEntityId: string; + colorLegendConfig: { + geoAggregateVariable: VariableDescriptor; + quantitativeOverlayConfig: BubbleOverlayConfig; + }; + sizeConfig: { + geoAggregateVariable: VariableDescriptor; + }; + }; +} + +export type StandaloneMapBubblesLegendResponse = TypeOf< + typeof StandaloneMapBubblesLegendResponse +>; +export const StandaloneMapBubblesLegendResponse = type({ + minColorValue: number, + maxColorValue: number, + minSizeValue: number, + maxSizeValue: number, +}); + export interface ContinousVariableMetadataRequestParams { studyId: string; filters: Filter[]; diff --git a/packages/libs/eda/src/lib/core/api/SubsettingClient/types.ts b/packages/libs/eda/src/lib/core/api/SubsettingClient/types.ts index c7ce822e3d..9b45c730e5 100644 --- a/packages/libs/eda/src/lib/core/api/SubsettingClient/types.ts +++ b/packages/libs/eda/src/lib/core/api/SubsettingClient/types.ts @@ -21,7 +21,7 @@ export const StudyResponse = type({ }); export interface DistributionRequestParams { - filters: Filter[]; + filters?: Filter[]; binSpec?: { displayRangeMin: number | string; displayRangeMax: number | string; diff --git a/packages/libs/eda/src/lib/core/components/filter/HistogramFilter.tsx b/packages/libs/eda/src/lib/core/components/filter/HistogramFilter.tsx index fd195ec5ab..2136ee74b0 100755 --- a/packages/libs/eda/src/lib/core/components/filter/HistogramFilter.tsx +++ b/packages/libs/eda/src/lib/core/components/filter/HistogramFilter.tsx @@ -129,6 +129,7 @@ export function HistogramFilter(props: Props) { }, [variableUISettings, uiStateKey, defaultUIState]); const uiStateForData = useDebounce(uiState, 1000); const subsettingClient = useSubsettingClient(); + const getData = useCallback( async ( dataParams: UIState @@ -181,6 +182,7 @@ export function HistogramFilter(props: Props) { variable.type ), ]; + const binWidth: NumberOrTimeDelta = NumberVariable.is(variable) ? dataParams.binWidth : { @@ -796,7 +798,7 @@ function HistogramPlotWithControls({ ); } -function distributionResponseToDataSeries( +export function distributionResponseToDataSeries( name: string, response: DistributionResponse, color: string, diff --git a/packages/libs/eda/src/lib/core/components/filter/TableFilter.tsx b/packages/libs/eda/src/lib/core/components/filter/TableFilter.tsx index b8025aa81a..ec349af50f 100644 --- a/packages/libs/eda/src/lib/core/components/filter/TableFilter.tsx +++ b/packages/libs/eda/src/lib/core/components/filter/TableFilter.tsx @@ -240,12 +240,21 @@ export function TableFilter({ ); const handleSearch = useCallback( - (_: unknown, searchTerm: string) => { + /** + * shouldResetPaging is true when the number of filtered rows is no longer enough to render + * rows on the currentPage + * + * Example: + * We are on page 3 and each page has 50 rows. If our search returns 100 or less rows, page 3 + * would no longer have any rows to display. Thus, we reset the currentPage to 1. + */ + (_: unknown, searchTerm: string, shouldResetPaging: boolean = false) => { analysisState.setVariableUISettings((currentState) => ({ ...currentState, [uiStateKey]: { ...uiState, searchTerm, + ...(shouldResetPaging ? { currentPage: 1 } : {}), }, })); }, diff --git a/packages/libs/eda/src/lib/core/components/visualizations/InputVariables.tsx b/packages/libs/eda/src/lib/core/components/visualizations/InputVariables.tsx index 060cb468ba..e167b1ff75 100644 --- a/packages/libs/eda/src/lib/core/components/visualizations/InputVariables.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/InputVariables.tsx @@ -16,6 +16,7 @@ import { Tooltip } from '@veupathdb/components/lib/components/widgets/Tooltip'; import RadioButtonGroup from '@veupathdb/components/lib/components/widgets/RadioButtonGroup'; import { isEqual } from 'lodash'; import { red } from '@veupathdb/coreui/lib/definitions/colors'; +import { CSSProperties } from '@material-ui/core/styles/withStyles'; export interface InputSpec { name: string; @@ -143,6 +144,7 @@ export interface Props { onShowMissingnessChange?: (newState: boolean) => void; /** output entity, required for toggle switch label */ outputEntity?: StudyEntity; + flexDirection?: CSSProperties['flexDirection']; } export function InputVariables(props: Props) { @@ -161,8 +163,9 @@ export function InputVariables(props: Props) { onShowMissingnessChange, outputEntity, customSections, + flexDirection, } = props; - const classes = useInputStyles(); + const classes = useInputStyles(flexDirection); const handleChange = ( inputName: string, selectedVariable?: VariableDescriptor diff --git a/packages/libs/eda/src/lib/core/components/visualizations/implementations/HistogramVisualization.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/HistogramVisualization.tsx index 5272a0afca..079adf7c18 100755 --- a/packages/libs/eda/src/lib/core/components/visualizations/implementations/HistogramVisualization.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/implementations/HistogramVisualization.tsx @@ -99,7 +99,10 @@ import { truncationConfig } from '../../../utils/truncation-config-utils'; // use Notification for truncation warning message import Notification from '@veupathdb/components/lib/components/widgets//Notification'; import AxisRangeControl from '@veupathdb/components/lib/components/plotControls/AxisRangeControl'; -import { UIState } from '../../filter/HistogramFilter'; +import { + UIState, + distributionResponseToDataSeries, +} from '../../filter/HistogramFilter'; // change defaultIndependentAxisRange to hook import { useDefaultAxisRange } from '../../../hooks/computeDefaultAxisRange'; import { @@ -124,6 +127,12 @@ import { ResetButtonCoreUI } from '../../ResetButton'; import { FloatingHistogramExtraProps } from '../../../../map/analysis/hooks/plugins/histogram'; import { useFindOutputEntity } from '../../../hooks/findOutputEntity'; +import { getDistribution } from '../../filter/util'; +import { DistributionResponse } from '../../../api/SubsettingClient'; +import { useSubsettingClient } from '../../../hooks/workspace'; +import { red } from '../../filter/colors'; +import { min, max } from 'lodash'; + export type HistogramDataWithCoverageStatistics = ( | HistogramData | FacetedData @@ -442,6 +451,78 @@ function HistogramViz(props: VisualizationProps) { [options, providedOverlayVariable, providedOverlayVariableDescriptor] ); + // get distribution data + const subsettingClient = useSubsettingClient(); + + const getDistributionData = useCallback(async () => { + if (vizConfig.xAxisVariable != null && xAxisVariable != null) { + const [displayRangeMin, displayRangeMax, binWidth, binUnits] = + NumberVariable.is(xAxisVariable) + ? [ + xAxisVariable.distributionDefaults.displayRangeMin ?? + xAxisVariable.distributionDefaults.rangeMin, + xAxisVariable.distributionDefaults.displayRangeMax ?? + xAxisVariable.distributionDefaults.rangeMax, + xAxisVariable.distributionDefaults.binWidth, + undefined, + ] + : [ + (xAxisVariable as DateVariable).distributionDefaults + .displayRangeMin ?? + (xAxisVariable as DateVariable).distributionDefaults.rangeMin, + (xAxisVariable as DateVariable).distributionDefaults + .displayRangeMax ?? + (xAxisVariable as DateVariable).distributionDefaults.rangeMax, + (xAxisVariable as DateVariable).distributionDefaults.binWidth, + (xAxisVariable as DateVariable).distributionDefaults.binUnits, + ]; + + // try to call once + const distribution = await subsettingClient.getDistribution( + studyMetadata.id, + vizConfig.xAxisVariable?.entityId ?? '', + vizConfig.xAxisVariable?.variableId ?? '', + { + valueSpec: 'count', + filters, + binSpec: { + // Note: technically any arbitrary values can be used here for displayRangeMin/Max + // but used more accurate value anyway + displayRangeMin: DateVariable.is(xAxisVariable) + ? displayRangeMin + 'T00:00:00Z' + : displayRangeMin, + displayRangeMax: DateVariable.is(xAxisVariable) + ? displayRangeMax + 'T00:00:00Z' + : displayRangeMax, + binWidth: binWidth ?? 1, + binUnits: binUnits, + }, + } + ); + + // return series using foreground response + const series = { + series: [ + distributionResponseToDataSeries( + 'Subset', + distribution, + red, + NumberVariable.is(xAxisVariable) ? 'number' : 'date' + ), + ], + }; + + return series; + } + + return undefined; + }, [filters, xAxisVariable, vizConfig.xAxisVariable, subsettingClient]); + + // need useCallback to avoid infinite loop + const distributionDataPromise = usePromise( + useCallback(() => getDistributionData(), [getDistributionData]) + ); + const dataRequestConfig: DataRequestConfig = useDeepValue( pick(vizConfig, [ 'valueSpec', @@ -470,6 +551,13 @@ function HistogramViz(props: VisualizationProps) { ) return undefined; + // wait till distributionDataPromise is ready + if ( + distributionDataPromise.pending || + distributionDataPromise.value == null + ) + return undefined; + if ( !variablesAreUnique([ xAxisVariable, @@ -565,14 +653,59 @@ function HistogramViz(props: VisualizationProps) { computation.descriptor.type, overlayEntity, facetEntity, + distributionDataPromise.pending, + distributionDataPromise.value, ]) ); - const independentAxisMinMax = useMemo( - () => histogramDefaultIndependentAxisMinMax(data), - [data] - ); + // Note: Histogram distribution data contains statistical values such as summary.min/max, + // however, it does not fully respect multiple filters. + // Similarly, distribution data also partially reflect filtered data. + // A solution is to compute both min/max values from data-based and summary-based ones, + // then take max of min values and min of max values, + // which will result in correct min/max value for multiple filters + // More specifically, data-based min and summary-based max are correct values + const dataBasedIndependentAxisMinMax = useMemo(() => { + return histogramDefaultIndependentAxisMinMax(distributionDataPromise); + }, [distributionDataPromise]); + + const summaryBasedIndependentAxisMinMax = useMemo(() => { + if ( + !distributionDataPromise.pending && + distributionDataPromise.value != null + ) + return { + min: DateVariable.is(xAxisVariable) + ? ( + (distributionDataPromise?.value?.series[0]?.summary + ?.min as string) ?? '' + ).split('T')[0] + : distributionDataPromise?.value?.series[0]?.summary?.min, + max: DateVariable.is(xAxisVariable) + ? ( + (distributionDataPromise?.value?.series[0]?.summary + ?.max as string) ?? '' + ).split('T')[0] + : distributionDataPromise?.value?.series[0]?.summary?.max, + }; + }, [distributionDataPromise]); + const independentAxisMinMax = useMemo(() => { + return { + min: max([ + dataBasedIndependentAxisMinMax?.min, + summaryBasedIndependentAxisMinMax?.min, + ]), + max: min([ + dataBasedIndependentAxisMinMax?.max, + summaryBasedIndependentAxisMinMax?.max, + ]), + }; + }, [distributionDataPromise]); + + // Note: defaultIndependentRange in the Histogram Viz should keep its initial range + // regardless of the change of the data to ensure the truncation behavior + // Thus, pass an additional prop to useDefaultAxisRange() if Histogram Viz const defaultIndependentRange = useDefaultAxisRange( xAxisVariable, vizConfig.independentAxisValueSpec === 'Full' @@ -742,16 +875,29 @@ function HistogramViz(props: VisualizationProps) { truncationConfigIndependentAxisMax, truncationConfigDependentAxisMin, truncationConfigDependentAxisMax, - } = truncationConfig( - { - ...defaultUIState, // using annotated range, NOT the actual data - ...(minPosMax != null && minPosMax.min != null && minPosMax.max != null - ? { dependentAxisRange: minPosMax } - : {}), - }, - vizConfig, - {}, // no overrides - true // use inclusive less than equal for the range min + } = useMemo( + () => + truncationConfig( + { + ...defaultUIState, // using annotated range, NOT the actual data + ...(minPosMax != null && + minPosMax.min != null && + minPosMax.max != null + ? { dependentAxisRange: minPosMax } + : {}), + }, + vizConfig, + {}, // no overrides + true // use inclusive less than equal for the range min + ), + [ + defaultUIState, + dependentMinPosMax, + vizConfig.independentAxisRange, + vizConfig.dependentAxisRange, + vizConfig.independentAxisValueSpec, + vizConfig.dependentAxisValueSpec, + ] ); // axis range control diff --git a/packages/libs/eda/src/lib/core/components/visualizations/implementations/LineplotVisualization.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/LineplotVisualization.tsx index de879714b2..9e1925e924 100755 --- a/packages/libs/eda/src/lib/core/components/visualizations/implementations/LineplotVisualization.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/implementations/LineplotVisualization.tsx @@ -768,10 +768,15 @@ function LineplotViz(props: VisualizationProps) { response.completeCasesTable ); - const xAxisVocabulary = fixLabelsForNumberVariables( - xAxisVariable?.vocabulary, - xAxisVariable - ); + // This is used for reordering series data. + // We don't want to do this for non-continuous variables. + const xAxisVocabulary = + xAxisVariable.dataShape === 'continuous' + ? [] + : fixLabelsForNumberVariables( + xAxisVariable?.vocabulary, + xAxisVariable + ); const overlayVocabulary = (overlayVariable && options?.getOverlayVocabulary?.()) ?? fixLabelsForNumberVariables( @@ -1727,115 +1732,26 @@ function LineplotViz(props: VisualizationProps) { ); - const classes = useInputStyles(); - - const aggregationHelp = ( -
-

- “Mean” and “Median” are y-axis aggregation functions that can only be - used when continuous variables{' '} - are selected for the - y-axis. -

-
    -
  • - Mean = Sum of values for all data points / Number of all data points -
  • -
  • - Median = The middle number in a sorted list of numbers. The median is - a better measure of central tendency than the mean when data are not - normally distributed. -
  • -
-

- “Proportion” is the only y-axis aggregation function that can be used - when categorical variables are - selected for the y-axis. -

-
    -
  • Proportion = Numerator count / Denominator count
  • -
-

- The y-axis variable's values that count towards numerator and - denominator must be selected in the two drop-downs. -

-
- ); - const aggregationInputs = ( -
- {vizConfig.valueSpecConfig !== 'Proportion' ? ( -
-
Function
- option !== 'Proportion') - .map((option) => ({ value: option, display: option }))} - /> -
- ) : ( -
- -
- Proportion* = -
-
-
- -
-
-
-
-
- -
-
- )} -
+ option !== 'Proportion' + ), + aggregationFunction: vizConfig.valueSpecConfig, + onFunctionChange: onValueSpecChange, + } + : { + aggregationType: 'proportion', + options: yAxisVariable?.vocabulary ?? [], + numeratorValues: vizConfig.numeratorValues ?? [], + onNumeratorChange: onNumeratorValuesChange, + denominatorValues: vizConfig.denominatorValues ?? [], + onDenominatorChange: onDenominatorValuesChange, + })} + /> ); const LayoutComponent = options?.layoutComponent ?? PlotLayout; @@ -2791,3 +2707,133 @@ function useDefaultDependentAxisRangeProportion( return defaultDependentAxisRange; } + +type AggregationConfig> = + | { + aggregationType: 'function'; + aggregationFunction: F; + onFunctionChange: (value: F) => void; + options: Array; + } + | { + aggregationType: 'proportion'; + numeratorValues: Array; + onNumeratorChange: (value: Array) => void; + denominatorValues: Array; + onDenominatorChange: (value: Array) => void; + options: P; + }; + +export function AggregationInputs>( + props: AggregationConfig +) { + const classes = useInputStyles(); + + return ( +
+ {props.aggregationType === 'function' ? ( +
+
Function
+ ({ + value: option, + display: option, + }))} + /> +
+ ) : ( +
+ +
+ Proportion* = +
+
+
+ +
+
+
+
+
+ +
+
+ )} +
+ ); +} + +export const aggregationHelp = ( +
+

+ “Mean” and “Median” are y-axis aggregation functions that can only be used + when continuous variables {' '} + are selected for the y-axis. +

+
    +
  • + Mean = Sum of values for all data points / Number of all data points +
  • +
  • + Median = The middle number in a sorted list of numbers. The median is a + better measure of central tendency than the mean when data are not + normally distributed. +
  • +
+

+ “Proportion” is the only y-axis aggregation function that can be used when + categorical variables are + selected for the y-axis. +

+
    +
  • Proportion = Numerator count / Denominator count
  • +
+

+ The y-axis variable's values that count towards numerator and denominator + must be selected in the two drop-downs. +

+
+); diff --git a/packages/libs/eda/src/lib/core/components/visualizations/implementations/ScatterplotVisualization.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/ScatterplotVisualization.tsx index 2f55d1cb65..f45617e6f9 100755 --- a/packages/libs/eda/src/lib/core/components/visualizations/implementations/ScatterplotVisualization.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/implementations/ScatterplotVisualization.tsx @@ -4,7 +4,6 @@ import ScatterPlot, { } from '@veupathdb/components/lib/plots/ScatterPlot'; import * as t from 'io-ts'; -import { scaleLinear } from 'd3-scale'; import { useCallback, useMemo, useState, useEffect } from 'react'; // need to set for Scatterplot @@ -84,9 +83,8 @@ import { gray } from '../colors'; import { ColorPaletteDefault, ColorPaletteDark, - gradientSequentialColorscaleMap, - gradientDivergingColorscaleMap, SequentialGradientColorscale, + getValueToGradientColorMapper, } from '@veupathdb/components/lib/types/plots/addOns'; import { VariablesByInputName } from '../../../utils/data-element-constraints'; import { useRouteMatch } from 'react-router'; @@ -138,7 +136,7 @@ import { ResetButtonCoreUI } from '../../ResetButton'; // add Slider and SliderWidgetProps import SliderWidget, { - SliderWidgetProps, + plotsSliderOpacityGradientColorSpec, } from '@veupathdb/components/lib/components/widgets/Slider'; import { FloatingScatterplotExtraProps } from '../../../../map/analysis/hooks/plugins/scatterplot'; @@ -166,6 +164,14 @@ const modalPlotContainerStyles = { margin: 'auto', }; +// slider settings +const markerBodyOpacityContainerStyles = { + height: '4em', + width: '20em', + marginLeft: '1em', + marginBottom: '0.5em', +}; + // define ScatterPlotDataWithCoverage and export export interface ScatterPlotDataWithCoverage extends CoverageStatistics { dataSetProcess: ScatterPlotData | FacetedData; @@ -572,22 +578,6 @@ function ScatterplotViz(props: VisualizationProps) { ? overlayVariable?.distributionDefaults?.rangeMax : 0; - // Diverging colorscale, assume 0 is midpoint. Colorscale must be symmetric around the midpoint - const maxAbsOverlay = - Math.abs(overlayMin) > overlayMax ? Math.abs(overlayMin) : overlayMax; - const gradientColorscaleType: - | 'sequential' - | 'sequential reversed' - | 'divergent' - | undefined = - overlayMin != null && overlayMax != null - ? overlayMin >= 0 && overlayMax >= 0 - ? 'sequential' - : overlayMin <= 0 && overlayMax <= 0 - ? 'sequential reversed' - : 'divergent' - : undefined; - const inputsForValidation = useMemo( (): InputSpec[] => [ { @@ -720,37 +710,14 @@ function ScatterplotViz(props: VisualizationProps) { response.completeCasesTable ); - let overlayValueToColorMapper: ((a: number) => string) | undefined; - - if ( + const overlayValueToColorMapper: ((a: number) => string) | undefined = response.scatterplot.data.every( (series) => 'seriesGradientColorscale' in series ) && (overlayVariable?.type === 'integer' || overlayVariable?.type === 'number') - ) { - // create the value to color mapper (continuous overlay) - // Initialize normalization function. - const normalize = scaleLinear(); - - if (gradientColorscaleType === 'divergent') { - // For each point, normalize the data to [-1, 1], then retrieve the corresponding color - normalize.domain([-maxAbsOverlay, maxAbsOverlay]).range([-1, 1]); - overlayValueToColorMapper = (a) => - gradientDivergingColorscaleMap(normalize(a)); - } else if (gradientColorscaleType === 'sequential reversed') { - // Normalize data to [1, 0], so that the colorscale goes in reverse. NOTE: can remove once we add the ability for users to set colorscale range. - normalize.domain([overlayMin, overlayMax]).range([1, 0]); - overlayValueToColorMapper = (a) => - gradientSequentialColorscaleMap(normalize(a)); - } else { - // Then we use the sequential (from 0 to inf) colorscale. - // For each point, normalize the data to [0, 1], then retrieve the corresponding color - normalize.domain([overlayMin, overlayMax]).range([0, 1]); - overlayValueToColorMapper = (a) => - gradientSequentialColorscaleMap(normalize(a)); - } - } + ? getValueToGradientColorMapper(overlayMin, overlayMax) + : undefined; const overlayVocabulary = computedOverlayVariableDescriptor ? response.scatterplot.config.variables.find( @@ -822,8 +789,6 @@ function ScatterplotViz(props: VisualizationProps) { facetEntity, computedOverlayVariableDescriptor, neutralPaletteProps.colorPalette, - gradientColorscaleType, - maxAbsOverlay, overlayMin, overlayMax, ]) @@ -1300,24 +1265,6 @@ function ScatterplotViz(props: VisualizationProps) { setTruncatedDependentAxisWarning, ]); - // slider settings - const markerBodyOpacityContainerStyles = { - height: '4em', - width: '20em', - marginLeft: '1em', - marginBottom: '0.5em', - }; - - // implement gradient color for slider opacity - const colorSpecProps: SliderWidgetProps['colorSpec'] = { - type: 'gradient', - tooltip: '#aaa', - knobColor: '#aaa', - // normal slider color: e.g., from 0 to 1 - trackGradientStart: '#fff', - trackGradientEnd: '#000', - }; - const scatterplotProps: ScatterPlotProps = { interactive: !isFaceted(data.value?.dataSetProcess) ? true : false, showSpinner: filteredCounts.pending || data.pending, @@ -1626,7 +1573,7 @@ function ScatterplotViz(props: VisualizationProps) { containerStyles={markerBodyOpacityContainerStyles} showLimits={true} label={'Marker opacity'} - colorSpec={colorSpecProps} + colorSpec={plotsSliderOpacityGradientColorSpec} /> {/* axis range control UIs */} diff --git a/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx index 84dda8e9ad..808de911b2 100755 --- a/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx +++ b/packages/libs/eda/src/lib/core/components/visualizations/implementations/VolcanoPlotVisualization.tsx @@ -1,10 +1,12 @@ // load scatter plot component import VolcanoPlot, { VolcanoPlotProps, + assignSignificanceColor, + RawDataMinMaxValues, } from '@veupathdb/components/lib/plots/VolcanoPlot'; import * as t from 'io-ts'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { usePromise } from '../../../hooks/promise'; import { useUpdateThumbnailEffect } from '../../../hooks/thumbnails'; @@ -31,15 +33,37 @@ import DataClient, { VolcanoPlotRequestParams, VolcanoPlotResponse, } from '../../../api/DataClient'; +import { + VolcanoPlotData, + VolcanoPlotDataPoint, +} from '@veupathdb/components/lib/types/plots/volcanoplot'; import VolcanoSVG from './selectorIcons/VolcanoSVG'; import { NumberOrDate } from '@veupathdb/components/lib/types/general'; import { DifferentialAbundanceConfig } from '../../computations/plugins/differentialabundance'; import { yellow } from '@material-ui/core/colors'; +import PlotLegend from '@veupathdb/components/lib/components/plotControls/PlotLegend'; +import { significanceColors } from '@veupathdb/components/lib/types/plots'; +import { NumberOrDateRange, NumberRange } from '../../../types/general'; +import { max, min } from 'lodash'; +// plot controls +import SliderWidget, { + plotsSliderOpacityGradientColorSpec, +} from '@veupathdb/components/lib/components/widgets/Slider'; +import { ResetButtonCoreUI } from '../../ResetButton'; +import AxisRangeControl from '@veupathdb/components/lib/components/plotControls/AxisRangeControl'; +import { fixVarIdLabel } from '../../../utils/visualization'; // end imports const DEFAULT_SIG_THRESHOLD = 0.05; const DEFAULT_FC_THRESHOLD = 2; +const DEFAULT_MARKER_OPACITY = 0.7; +/** + * The padding ensures we don't clip off part of the glyphs that represent the most extreme points. + * We could have also used d3.scale.nice but then we dont have precise control of where the extremes + * are, which is important for user-defined ranges and truncation bars. + */ +const AXIS_PADDING_FACTOR = 0.05; const EMPTY_VIZ_AXIS_RANGES = { independentAxisRange: { min: -9, max: 9 }, dependentAxisRange: { min: -1, max: 9 }, @@ -63,16 +87,20 @@ function createDefaultConfig(): VolcanoPlotConfig { return { log2FoldChangeThreshold: DEFAULT_FC_THRESHOLD, significanceThreshold: DEFAULT_SIG_THRESHOLD, - markerBodyOpacity: 0.5, + markerBodyOpacity: DEFAULT_MARKER_OPACITY, + independentAxisRange: undefined, + dependentAxisRange: undefined, }; } export type VolcanoPlotConfig = t.TypeOf; - +// eslint-disable-next-line @typescript-eslint/no-redeclare export const VolcanoPlotConfig = t.partial({ log2FoldChangeThreshold: t.number, significanceThreshold: t.number, markerBodyOpacity: t.number, + independentAxisRange: NumberRange, + dependentAxisRange: NumberRange, }); interface Options @@ -82,7 +110,7 @@ interface Options // Volcano Plot Visualization // The volcano plot visualization takes no input variables. The received data populates all parts of the plot. // The user can control the threshold lines, which affect the marker colors. Additional controls -// will include axis ranges. +// include axis ranges and marker opacity slider. function VolcanoPlotViz(props: VisualizationProps) { const { options, @@ -140,9 +168,6 @@ function VolcanoPlotViz(props: VisualizationProps) { computeJobStatus, filteredCounts.pending, filteredCounts.value, - entities, - dataElementConstraints, - dataElementDependencyOrder, filters, studyId, computationConfiguration, @@ -152,14 +177,199 @@ function VolcanoPlotViz(props: VisualizationProps) { ]) ); + /** + * Find mins and maxes of the data and for the plot. + * The standard x axis is the log2 fold change. The standard + * y axis is -log10 raw p value. + */ + + // Find maxes and mins of the data itself + const rawDataMinMaxValues: RawDataMinMaxValues = useMemo(() => { + if (!data.value) + return { + x: { min: 0, max: 0 }, + y: { min: 1, max: 1 }, + }; + const dataXMin = min(data.value.map((d) => Number(d.log2foldChange))) ?? 0; + const dataXMax = max(data.value.map((d) => Number(d.log2foldChange))) ?? 0; + const dataYMin = min(data.value.map((d) => Number(d.pValue))) ?? 0; + const dataYMax = max(data.value.map((d) => Number(d.pValue))) ?? 0; + return { + x: { min: dataXMin, max: dataXMax }, + y: { min: dataYMin, max: dataYMax }, + }; + }, [data.value]); + + // Determine mins, maxes of axes in the plot. These are different than the data mins/maxes because + // of the log transform and the little bit of padding, or because axis ranges are supplied. + const independentAxisRange = useMemo(() => { + if (!data.value) return undefined; + if (vizConfig.independentAxisRange) { + return vizConfig.independentAxisRange; + } else { + const { + x: { min: dataXMin, max: dataXMax }, + } = rawDataMinMaxValues; + // We can use the dataMin and dataMax here because we don't have a further transform + // Add a little padding to prevent clipping the glyph representing the extreme points + return { + min: Math.floor(dataXMin - (dataXMax - dataXMin) * AXIS_PADDING_FACTOR), + max: Math.ceil(dataXMax + (dataXMax - dataXMin) * AXIS_PADDING_FACTOR), + }; + } + }, [data.value, vizConfig.independentAxisRange, rawDataMinMaxValues]); + + const dependentAxisRange = useMemo(() => { + if (!data.value) return undefined; + if (vizConfig.dependentAxisRange) { + return vizConfig.dependentAxisRange; + } else { + const { + y: { min: dataYMin, max: dataYMax }, + } = rawDataMinMaxValues; + // Standard volcano plots have -log10(raw p value) as the y axis + const yAxisMin = -Math.log10(dataYMax); + const yAxisMax = -Math.log10(dataYMin); + // Add a little padding to prevent clipping the glyph representing the extreme points + return { + min: Math.floor(yAxisMin - (yAxisMax - yAxisMin) * AXIS_PADDING_FACTOR), + max: Math.ceil(yAxisMax + (yAxisMax - yAxisMin) * AXIS_PADDING_FACTOR), + }; + } + }, [data.value, vizConfig.dependentAxisRange, rawDataMinMaxValues]); + + const significanceThreshold = + vizConfig.significanceThreshold ?? DEFAULT_SIG_THRESHOLD; + const log2FoldChangeThreshold = + vizConfig.log2FoldChangeThreshold ?? DEFAULT_FC_THRESHOLD; + + /** + * This version of the data will get passed to the VolcanoPlot component + */ + const finalData = useMemo(() => { + if (data.value && independentAxisRange && dependentAxisRange) { + const cleanedData = data.value + // Only return data if the points fall within the specified range! Otherwise they'll show up on the plot. + .filter((d) => { + const log2foldChange = Number(d?.log2foldChange); + const transformedPValue = -Math.log10(Number(d?.pValue)); + return ( + log2foldChange <= independentAxisRange.max && + log2foldChange >= independentAxisRange.min && + transformedPValue <= dependentAxisRange.max && + transformedPValue >= dependentAxisRange.min + ); + }) + /** + * Okay, this map function is doing a number of things. + * 1. We're going to remove the pointID property and replace it with a pointIDs property that is an array of strings. + * Some data share coordinates but correspond to a different pointID. By converting pointID to pointIDs, we can + * later aggregate data that share coordinates and then render one tooltip that lists all pointIDs corresponding + * to the point on the plot + * 2. We also add a significanceColor property that is assigned a value that gets used in VolcanoPlot when rendering + * the data point and the data point's tooltip. The property is also used in the countsData logic. + */ + .map((d) => { + const { pointID, ...remainingProperties } = d; + // Try to find a user-friendly label for the point. Note that pointIDs are in entityID.variableID format. + const displayLabel = + pointID && + fixVarIdLabel( + pointID.split('.')[1], + pointID.split('.')[0], + entities + ); + return { + ...remainingProperties, + pointIDs: pointID ? [pointID] : undefined, + displayLabels: displayLabel ? [displayLabel] : undefined, + significanceColor: assignSignificanceColor( + Number(d.log2foldChange), + Number(d.pValue), + significanceThreshold, + log2FoldChangeThreshold, + significanceColors + ), + }; + }) + // Sort data in ascending order for tooltips to work most effectively + .sort((a, b) => Number(a.log2foldChange) - Number(b.log2foldChange)); + + // Here we're going to loop through the cleanedData to aggregate any data with shared coordinates. + // For each entry, we'll check if our aggregatedData includes an item with the same coordinates: + // Yes? => update the matched aggregatedData element's pointID array to include the pointID of the matching entry + // No? => just push the entry onto the aggregatedData array since no match was found + const aggregatedData: VolcanoPlotData = []; + for (const entry of cleanedData) { + const foundIndex = aggregatedData.findIndex( + (d: VolcanoPlotDataPoint) => + d.log2foldChange === entry.log2foldChange && + d.pValue === entry.pValue + ); + if (foundIndex === -1) { + aggregatedData.push(entry); + } else { + const { pointIDs, displayLabels } = aggregatedData[foundIndex]; + if (pointIDs) { + aggregatedData[foundIndex] = { + ...aggregatedData[foundIndex], + pointIDs: [ + ...pointIDs, + ...(entry.pointIDs ? entry.pointIDs : []), + ], + displayLabels: displayLabels && [ + ...displayLabels, + ...(entry.displayLabels ? entry.displayLabels : []), + ], + }; + } else { + aggregatedData[foundIndex] = { + ...aggregatedData[foundIndex], + pointIDs: entry.pointIDs, + displayLabels: entry.displayLabels, + }; + } + } + } + return aggregatedData; + } + }, [ + data.value, + independentAxisRange, + dependentAxisRange, + significanceThreshold, + log2FoldChangeThreshold, + ]); + + // For the legend, we need the counts of the data + const countsData = useMemo(() => { + if (!finalData) return; + const counts = { + [significanceColors['inconclusive']]: 0, + [significanceColors['high']]: 0, + [significanceColors['low']]: 0, + }; + for (const entry of finalData) { + if (entry.significanceColor) { + // Recall that finalData combines data with shared coords into one point in order to display a + // single tooltip that lists all the pointIDs for that shared point. This means we need to use + // the length of the pointID array to accurately reflect the counts of unique data (not unique coords). + const addend = entry.pointIDs?.length ?? 1; + counts[entry.significanceColor] = + addend + counts[entry.significanceColor]; + } + } + return counts; + }, [finalData]); + const plotRef = useUpdateThumbnailEffect( updateThumbnail, plotContainerStyles, [ - data, + finalData, // vizConfig.checkedLegendItems, TODO - // vizConfig.independentAxisRange, TODO - // vizConfig.dependentAxisRange, TODO + vizConfig.independentAxisRange, + vizConfig.dependentAxisRange, vizConfig.markerBodyOpacity, ] ); @@ -182,14 +392,16 @@ function VolcanoPlotViz(props: VisualizationProps) { * In order to display an empty viz, EmptyVolcanoPlotData is defined as: * const EmptyVolcanoPlotData: VolcanoPlotData = [{log2foldChange: '0', pValue: '1'}]; */ - data: data.value ? Object.values(data.value) : undefined, + data: finalData ? Object.values(finalData) : undefined, + significanceThreshold, + log2FoldChangeThreshold, /** * Since we are rendering a single point in order to display an empty viz, let's hide the data point * by setting the marker opacity to 0 when data.value doesn't exist */ - markerBodyOpacity: data.value ? vizConfig.markerBodyOpacity ?? 0.5 : 0, - significanceThreshold: vizConfig.significanceThreshold ?? 0.05, - log2FoldChangeThreshold: vizConfig.log2FoldChangeThreshold ?? 3, + markerBodyOpacity: data.value + ? vizConfig.markerBodyOpacity ?? DEFAULT_MARKER_OPACITY + : 0, containerStyles: plotContainerStyles, /** * Let's not display comparisonLabels before we have data for the viz. This prevents what may be @@ -198,6 +410,9 @@ function VolcanoPlotViz(props: VisualizationProps) { comparisonLabels: data.value ? comparisonLabels : [], showSpinner: data.pending, truncationBarFill: yellow[300], + independentAxisRange, + dependentAxisRange, + rawDataMinMaxValues, /** * As sophisticated aesthetes, let's specify axis ranges for the empty viz placeholder */ @@ -207,11 +422,164 @@ function VolcanoPlotViz(props: VisualizationProps) { // @ts-ignore const plotNode = ; - // TODO - const controlsNode = <> ; + const controlsNode = ( +
+ + { + updateVizConfig({ markerBodyOpacity: newValue }); + }} + containerStyles={{ width: '20em', marginTop: '0.5em' }} + showLimits={true} + label={'Marker opacity'} + colorSpec={plotsSliderOpacityGradientColorSpec} + /> + +
+
+
+ } + containerStyles={{ + marginRight: 0, + paddingLeft: 0, + }} + /> + + updateVizConfig({ independentAxisRange: undefined }) + } + /> +
+ { + const typeCheckedNewRange = + typeof newRange?.min === 'number' && + typeof newRange?.max === 'number' + ? { + min: newRange.min, + max: newRange.max, + } + : undefined; + updateVizConfig({ + independentAxisRange: typeCheckedNewRange, + }); + }} + step={0.01} + /> +
+ {/** vertical line to separate x from y range controls */} +
+
+
+ } + containerStyles={{ + marginRight: 0, + paddingLeft: 0, + }} + /> + updateVizConfig({ dependentAxisRange: undefined })} + /> +
+ { + const typeCheckedNewRange = + typeof newRange?.min === 'number' && + typeof newRange?.max === 'number' + ? { + min: newRange.min, + max: newRange.max, + } + : undefined; + updateVizConfig({ + dependentAxisRange: typeCheckedNewRange, + }); + }} + step={0.01} + /> +
+
+
+ ); - // TODO - const legendNode = {}; + const legendNode = finalData && countsData && ( + + ); // TODO const tableGroupNode = <> ; @@ -251,7 +619,7 @@ function VolcanoPlotViz(props: VisualizationProps) { /> */} + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/libs/eda/src/lib/core/components/visualizations/inputStyles.ts b/packages/libs/eda/src/lib/core/components/visualizations/inputStyles.ts index efae9e58a4..3686d7cbda 100644 --- a/packages/libs/eda/src/lib/core/components/visualizations/inputStyles.ts +++ b/packages/libs/eda/src/lib/core/components/visualizations/inputStyles.ts @@ -1,39 +1,44 @@ import { makeStyles } from '@material-ui/core'; +import { CSSProperties } from '@material-ui/core/styles/withStyles'; -export const useInputStyles = makeStyles({ - inputs: { - display: 'flex', - flexWrap: 'wrap', - marginLeft: '0.5em', // this indent is only needed because the wdk-SaveableTextEditor above it is indented - alignItems: 'flex-start', - columnGap: '5em', - rowGap: '1em', - }, - inputGroup: { - display: 'flex', - flexDirection: 'column', - }, - input: { - display: 'flex', - alignItems: 'center', - marginBottom: '0.5em', // in case they end up stacked vertically on a narrow screen - }, - label: { - marginRight: '1ex', - cursor: 'default', - }, - dataLabel: { - textAlign: 'right', - marginTop: '2em', - fontSize: '1.35em', - fontWeight: 500, - }, - fullRow: { - flexBasis: '100%', - }, - showMissingness: { - minHeight: '32px', // this is the height of the neighbouring input variable selector (20 + 2*6px padding) - display: 'flex', - alignItems: 'center', - }, -}); +export const useInputStyles = ( + flexDirection?: CSSProperties['flexDirection'] +) => + makeStyles({ + inputs: { + display: 'flex', + flexDirection, + flexWrap: 'wrap', + marginLeft: '0.5em', // this indent is only needed because the wdk-SaveableTextEditor above it is indented + alignItems: 'flex-start', + columnGap: '5em', + rowGap: '1em', + }, + inputGroup: { + display: 'flex', + flexDirection: 'column', + }, + input: { + display: 'flex', + alignItems: 'center', + marginBottom: '0.5em', // in case they end up stacked vertically on a narrow screen + }, + label: { + marginRight: '1ex', + cursor: 'default', + }, + dataLabel: { + textAlign: 'right', + marginTop: '2em', + fontSize: '1.35em', + fontWeight: 500, + }, + fullRow: { + flexBasis: '100%', + }, + showMissingness: { + minHeight: '32px', // this is the height of the neighbouring input variable selector (20 + 2*6px padding) + display: 'flex', + alignItems: 'center', + }, + })(); diff --git a/packages/libs/eda/src/lib/core/hooks/study.ts b/packages/libs/eda/src/lib/core/hooks/study.ts index cd71db1403..3f2c6dabd3 100644 --- a/packages/libs/eda/src/lib/core/hooks/study.ts +++ b/packages/libs/eda/src/lib/core/hooks/study.ts @@ -27,6 +27,10 @@ import SubsettingClient from '../api/SubsettingClient'; // Hooks import { useStudyRecord } from '..'; +import { useStudyAccessApi } from '@veupathdb/study-data-access/lib/study-access/studyAccessHooks'; +import { getWdkStudyRecords } from '../utils/study-records'; +import { useDeepValue } from './immutability'; +import { usePermissions } from '@veupathdb/study-data-access/lib/data-restriction/permissionsHooks'; const STUDY_RECORD_CLASS_NAME = 'dataset'; @@ -61,7 +65,12 @@ export function useWdkStudyRecord(datasetId: string): HookValue | undefined { ) .map(getNodeId) .toArray() - .concat(['bulk_download_url', 'request_needs_approval', 'is_public']) + .concat([ + 'dataset_id', + 'bulk_download_url', + 'request_needs_approval', + 'is_public', + ]) .filter((attribute) => attribute in studyRecordClass.attributesMap); const studyRecord = await wdkService .getRecord( @@ -99,44 +108,30 @@ export function useWdkStudyRecord(datasetId: string): HookValue | undefined { ); } -const DEFAULT_STUDY_ATTRIBUTES = ['dataset_id', 'eda_study_id']; -const DEFAULT_STUDY_TABLES: string[] = []; -const EMPTY_ARRAY: string[] = []; +interface WdkStudyRecordsOptions { + attributes?: AnswerJsonFormatConfig['attributes']; + tables?: AnswerJsonFormatConfig['tables']; + searchName?: string; +} export function useWdkStudyRecords( - attributes: AnswerJsonFormatConfig['attributes'] = EMPTY_ARRAY, - tables: AnswerJsonFormatConfig['tables'] = EMPTY_ARRAY + subsettingClient: SubsettingClient, + options?: WdkStudyRecordsOptions ): StudyRecord[] | undefined { + const studyAccessApi = useStudyAccessApi(); + const stableOptions = useDeepValue(options); return useWdkService( - async (wdkService) => { - const recordClass = await wdkService.findRecordClass('dataset'); - const finalAttributes = DEFAULT_STUDY_ATTRIBUTES.concat( - attributes - ).filter((attribute) => attribute in recordClass.attributesMap); - const finalTables = DEFAULT_STUDY_TABLES.concat(tables).filter( - (table) => table in recordClass.tablesMap - ); - return wdkService.getAnswerJson( + (wdkService) => + getWdkStudyRecords( { - searchName: 'Studies', - searchConfig: { - parameters: {}, - }, + studyAccessApi, + subsettingClient, + wdkService, }, - { - attributes: finalAttributes, - tables: finalTables, - sorting: [ - { - attributeName: 'display_name', - direction: 'ASC', - }, - ], - } - ); - }, - [attributes, tables] - )?.records.filter((record) => record.attributes.eda_study_id != null); + stableOptions + ), + [studyAccessApi, subsettingClient, stableOptions] + ); } /** @@ -191,10 +186,13 @@ export function isStubEntity(entity: StudyEntity) { } export function useStudyMetadata(datasetId: string, client: SubsettingClient) { + const permissionsResponse = usePermissions(); return useWdkServiceWithRefresh( async (wdkService) => { + if (permissionsResponse.loading) return; + const { permissions } = permissionsResponse; const recordClass = await wdkService.findRecordClass('dataset'); - const attributes = ['dataset_id', 'eda_study_id', 'study_access'].filter( + const attributes = ['dataset_id', 'study_access'].filter( (attribute) => attribute in recordClass.attributesMap ); const studyRecord = await wdkService @@ -224,22 +222,20 @@ export function useStudyMetadata(datasetId: string, client: SubsettingClient) { tableErrors: [], } as RecordInstance; }); - if (typeof studyRecord.attributes.eda_study_id !== 'string') - throw new Error( - 'Could not find study with associated dataset id `' + datasetId + '`.' - ); + const studyId = + permissions.perDataset[studyRecord.attributes.dataset_id as string] + ?.studyId; + if (studyId == null) throw new Error('Not an eda study'); try { - return await client.getStudyMetadata( - studyRecord.attributes.eda_study_id - ); + return await client.getStudyMetadata(studyId); } catch (error) { console.error(error); return { - id: studyRecord.attributes.eda_study_id, + id: studyId, rootEntity: STUB_ENTITY, }; } }, - [datasetId, client] + [datasetId, client, permissionsResponse] ); } diff --git a/packages/libs/eda/src/lib/core/utils/axis-range-calculations.ts b/packages/libs/eda/src/lib/core/utils/axis-range-calculations.ts index 42c41036fa..b2789cd045 100755 --- a/packages/libs/eda/src/lib/core/utils/axis-range-calculations.ts +++ b/packages/libs/eda/src/lib/core/utils/axis-range-calculations.ts @@ -14,7 +14,8 @@ import { VariableMapping } from '../api/DataClient/types'; // calculate min/max of default independent axis range export function histogramDefaultIndependentAxisMinMax( - data: PromiseHookState + // use distribution data + data: PromiseHookState ) { if (isFaceted(data.value)) { const facetMinMaxes = diff --git a/packages/libs/eda/src/lib/core/utils/default-axis-range.ts b/packages/libs/eda/src/lib/core/utils/default-axis-range.ts index bb1e61823d..8c5e6a44f8 100755 --- a/packages/libs/eda/src/lib/core/utils/default-axis-range.ts +++ b/packages/libs/eda/src/lib/core/utils/default-axis-range.ts @@ -18,7 +18,7 @@ export function numberDateDefaultAxisRange( if (variable.type === 'number' || variable.type === 'integer') { const defaults = variable.distributionDefaults; if (logScale && observedMinPos == null) return undefined; // return nothing - there will be no plottable data anyway - // set default range of Custom to be Auto-zoom + // set default range of Custom to be Auto-zoom and check Histogram Viz return axisRangeSpec === 'Full' ? { min: @@ -39,7 +39,7 @@ export function numberDateDefaultAxisRange( (min([ defaults.displayRangeMin ?? 0, defaults.rangeMin, - observedMin as number, + observedMin, ]) as number), max: max([ defaults.displayRangeMax, diff --git a/packages/libs/eda/src/lib/core/utils/study-records.ts b/packages/libs/eda/src/lib/core/utils/study-records.ts new file mode 100644 index 0000000000..f9c878a61b --- /dev/null +++ b/packages/libs/eda/src/lib/core/utils/study-records.ts @@ -0,0 +1,76 @@ +// utils for getting study records + +import { cachedPermissionCheck } from '@veupathdb/study-data-access/lib/data-restriction/permissionsHooks'; +import { getStudyId } from '@veupathdb/study-data-access/lib/shared/studies'; +import { StudyAccessApi } from '@veupathdb/study-data-access/lib/study-access/api'; +import { WdkService } from '@veupathdb/wdk-client/lib/Core'; +import { AnswerJsonFormatConfig } from '@veupathdb/wdk-client/lib/Utils/WdkModel'; +import { SubsettingClient } from '../api'; +import { StudyRecord } from '../types/study'; + +interface WdkStudyRecordsDeps { + wdkService: WdkService; + subsettingClient: SubsettingClient; + studyAccessApi: StudyAccessApi; +} + +interface WdkStudyRecordsOptions { + attributes?: AnswerJsonFormatConfig['attributes']; + tables?: AnswerJsonFormatConfig['tables']; + searchName?: string; +} + +const DEFAULT_STUDY_ATTRIBUTES = ['dataset_id']; +const DEFAULT_STUDY_TABLES: string[] = []; +const EMPTY_ARRAY: string[] = []; + +export async function getWdkStudyRecords( + deps: WdkStudyRecordsDeps, + options?: WdkStudyRecordsOptions +): Promise { + const { wdkService, subsettingClient, studyAccessApi } = deps; + const attributes = options?.attributes ?? EMPTY_ARRAY; + const tables = options?.tables ?? EMPTY_ARRAY; + const searchName = options?.searchName ?? 'Studies'; + + const [permissions, recordClass] = await Promise.all([ + cachedPermissionCheck(await wdkService.getCurrentUser(), studyAccessApi), + wdkService.findRecordClass('dataset'), + ]); + const finalAttributes = DEFAULT_STUDY_ATTRIBUTES.concat(attributes).filter( + (attribute) => attribute in recordClass.attributesMap + ); + const finalTables = DEFAULT_STUDY_TABLES.concat(tables).filter( + (table) => table in recordClass.tablesMap + ); + const [edaStudies, answer] = await Promise.all([ + subsettingClient.getStudies(), + wdkService.getAnswerJson( + { + searchName, + searchConfig: { + parameters: {}, + }, + }, + { + attributes: finalAttributes, + tables: finalTables, + sorting: [ + { + attributeName: 'display_name', + direction: 'ASC', + }, + ], + } + ), + ]); + const studyIds = new Set(edaStudies.map((s) => s.id)); + return answer.records.filter((record) => { + const datasetId = getStudyId(record); + if (datasetId == null) { + return false; + } + const studyId = permissions.perDataset[datasetId]?.studyId; + return studyId && studyIds.has(studyId); + }); +} diff --git a/packages/libs/eda/src/lib/map/MapVeuContainer.tsx b/packages/libs/eda/src/lib/map/MapVeuContainer.tsx index cee77499ee..04244bad58 100644 --- a/packages/libs/eda/src/lib/map/MapVeuContainer.tsx +++ b/packages/libs/eda/src/lib/map/MapVeuContainer.tsx @@ -64,10 +64,19 @@ export function MapVeuContainer(mapVeuContainerProps: Props) { /> )} /> - } /> + } + /> } + render={() => ( + + )} /> Loading...
; return (
diff --git a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx index 9e38ca7ea0..c750a27a1a 100644 --- a/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapAnalysis.tsx @@ -3,6 +3,7 @@ import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; import { AllValuesDefinition, AnalysisState, + BubbleOverlayConfig, CategoricalVariableDataShape, DEFAULT_ANALYSIS_NAME, EntityDiagram, @@ -72,8 +73,13 @@ import { RecordController } from '@veupathdb/wdk-client/lib/Controllers'; import { BarPlotMarkerConfigurationMenu, PieMarkerConfigurationMenu, + BubbleMarkerConfigurationMenu, } from './MarkerConfiguration'; -import { BarPlotMarker, DonutMarker } from './MarkerConfiguration/icons'; +import { + BarPlotMarker, + DonutMarker, + BubbleMarker, +} from './MarkerConfiguration/icons'; import { leastAncestralEntity } from '../../core/utils/data-element-constraints'; import { getDefaultOverlayConfig } from './utils/defaultOverlayConfig'; import { AllAnalyses } from '../../workspace/AllAnalyses'; @@ -87,6 +93,9 @@ import { DraggablePanel } from '@veupathdb/coreui/lib/components/containers'; import { TabbedDisplayProps } from '@veupathdb/coreui/lib/components/grids/TabbedDisplay'; import { GeoConfig } from '../../core/types/geoConfig'; import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; +import BubbleMarkerComponent, { + BubbleMarkerProps, +} from '@veupathdb/components/lib/map/BubbleMarker'; import DonutMarkerComponent, { DonutMarkerProps, DonutMarkerStandalone, @@ -98,6 +107,8 @@ import ChartMarkerComponent, { import { sharedStandaloneMarkerProperties } from './MarkerConfiguration/CategoricalMarkerPreview'; import { mFormatter, kFormatter } from '../../core/utils/big-number-formatters'; import { getCategoricalValues } from './utils/categoricalValues'; +import { DraggablePanelCoordinatePair } from '@veupathdb/coreui/lib/components/containers/DraggablePanel'; +import _ from 'lodash'; enum MapSideNavItemLabels { Download = 'Download', @@ -113,6 +124,7 @@ enum MapSideNavItemLabels { enum MarkerTypeLabels { pie = 'Donuts', barplot = 'Bar plots', + bubble = 'Bubbles', } type SideNavigationItemConfigurationObject = { @@ -338,7 +350,9 @@ function MapAnalysisImpl(props: ImplProps) { if ( !overlayVariable || !CategoricalVariableDataShape.is(overlayVariable.dataShape) || - activeMarkerConfiguration?.selectedCountsOption !== 'visible' + (activeMarkerConfiguration && + 'selectedCountsOption' in activeMarkerConfiguration && + activeMarkerConfiguration.selectedCountsOption !== 'visible') ) return; @@ -350,22 +364,26 @@ function MapAnalysisImpl(props: ImplProps) { filters: filtersIncludingViewport, }); }, [ - overlayEntity, overlayVariable, + activeMarkerConfiguration, + overlayEntity, subsettingClient, studyId, filtersIncludingViewport, - activeMarkerConfiguration?.selectedCountsOption, ]) ); // If the variable or filters have changed on the active marker config // get the default overlay config. const activeOverlayConfig = usePromise( - useCallback(async (): Promise => { + useCallback(async (): Promise< + OverlayConfig | BubbleOverlayConfig | undefined + > => { // Use `selectedValues` to generate the overlay config for categorical variables if ( - activeMarkerConfiguration?.selectedValues && + activeMarkerConfiguration && + 'selectedValues' in activeMarkerConfiguration && + activeMarkerConfiguration.selectedValues && CategoricalVariableDataShape.is(overlayVariable?.dataShape) ) { return { @@ -385,17 +403,23 @@ function MapAnalysisImpl(props: ImplProps) { overlayEntity, dataClient, subsettingClient, - binningMethod: activeMarkerConfiguration?.binningMethod, + markerType: activeMarkerConfiguration?.type, + binningMethod: _.get(activeMarkerConfiguration, 'binningMethod'), + aggregator: _.get(activeMarkerConfiguration, 'aggregator'), + numeratorValues: _.get(activeMarkerConfiguration, 'numeratorValues'), + denominatorValues: _.get( + activeMarkerConfiguration, + 'denominatorValues' + ), }); }, [ - dataClient, - filters, - overlayEntity, + activeMarkerConfiguration, overlayVariable, studyId, + filters, + overlayEntity, + dataClient, subsettingClient, - activeMarkerConfiguration?.selectedValues, - activeMarkerConfiguration?.binningMethod, ]) ); @@ -405,6 +429,8 @@ function MapAnalysisImpl(props: ImplProps) { case 'barplot': { return activeMarkerConfiguration?.selectedPlotMode; // count or proportion } + case 'bubble': + return 'bubble'; case 'pie': default: return 'pie'; @@ -416,6 +442,9 @@ function MapAnalysisImpl(props: ImplProps) { pending, error, legendItems, + bubbleLegendData, + bubbleValueToDiameterMapper, + bubbleValueToColorMapper, totalVisibleEntityCount, totalVisibleWithOverlayEntityCount, } = useStandaloneMapMarkers({ @@ -446,7 +475,12 @@ function MapAnalysisImpl(props: ImplProps) { }); const continuousMarkerPreview = useMemo(() => { - if (!previewMarkerData || !previewMarkerData.length) return; + if ( + !previewMarkerData || + !previewMarkerData.length || + !Array.isArray(previewMarkerData[0].data) + ) + return; const initialDataObject = previewMarkerData[0].data.map((data) => ({ label: data.label, value: 0, @@ -492,15 +526,17 @@ function MapAnalysisImpl(props: ImplProps) { /> ); } - }, [previewMarkerData]); + }, [activeMarkerConfiguration, markerType, previewMarkerData]); const markers = useMemo( () => markersData?.map((markerProps) => markerType === 'pie' ? ( - + + ) : markerType === 'bubble' ? ( + ) : ( - + ) ) || [], [markersData, markerType] @@ -645,6 +681,14 @@ function MapAnalysisImpl(props: ImplProps) { onClick: () => setActiveMarkerConfigurationType('barplot'), isActive: activeMarkerConfigurationType === 'barplot', }, + { + // concatenating the parent and subMenu labels creates a unique ID + id: MapSideNavItemLabels.ConfigureMap + MarkerTypeLabels.bubble, + labelText: MarkerTypeLabels.bubble, + icon: , + onClick: () => setActiveMarkerConfigurationType('bubble'), + isActive: activeMarkerConfigurationType === 'bubble', + }, ], renderSideNavigationPanel: (apps) => { const markerVariableConstraints = apps @@ -674,7 +718,9 @@ function MapAnalysisImpl(props: ImplProps) { } toggleStarredVariable={toggleStarredVariable} constraints={markerVariableConstraints} - overlayConfiguration={activeOverlayConfig.value} + overlayConfiguration={ + activeOverlayConfig.value as OverlayConfig + } overlayVariable={overlayVariable} subsettingClient={subsettingClient} studyId={studyId} @@ -711,7 +757,9 @@ function MapAnalysisImpl(props: ImplProps) { toggleStarredVariable={toggleStarredVariable} configuration={activeMarkerConfiguration} constraints={markerVariableConstraints} - overlayConfiguration={activeOverlayConfig.value} + overlayConfiguration={ + activeOverlayConfig.value as OverlayConfig + } overlayVariable={overlayVariable} subsettingClient={subsettingClient} studyId={studyId} @@ -728,6 +776,36 @@ function MapAnalysisImpl(props: ImplProps) { <> ), }, + { + type: 'bubble', + displayName: MarkerTypeLabels.bubble, + icon: ( + + ), + configurationMenu: + activeMarkerConfiguration?.type === 'bubble' ? ( + + ) : ( + <> + ), + }, ]; const mapTypeConfigurationMenuTabs: TabbedDisplayProps< @@ -1166,25 +1244,57 @@ function MapAnalysisImpl(props: ImplProps) { />
- -
- -
-
+ {markerType !== 'bubble' ? ( + +
+ +
+
+ ) : ( + <> + +
+ +
+
+ +
+ 'white'), + }} + /> +
+
+ + )} {/* ); } + +const DraggableLegendPanel = (props: { + zIndex: number; + panelTitle?: string; + defaultPosition?: DraggablePanelCoordinatePair; + children: React.ReactNode; +}) => ( + + {props.children} + +); diff --git a/packages/libs/eda/src/lib/map/analysis/MapLegend.tsx b/packages/libs/eda/src/lib/map/analysis/MapLegend.tsx index 3edcbda737..cc92b68d18 100644 --- a/packages/libs/eda/src/lib/map/analysis/MapLegend.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MapLegend.tsx @@ -1,15 +1,16 @@ import Spinner from '@veupathdb/components/lib/components/Spinner'; -import PlotLegend from '@veupathdb/components/lib/components/plotControls/PlotLegend'; -import { LegendItemsProps } from '@veupathdb/components/lib/components/plotControls/PlotListLegend'; +import PlotLegend, { + PlotLegendProps, +} from '@veupathdb/components/lib/components/plotControls/PlotLegend'; interface Props { - legendItems: LegendItemsProps[]; + plotLegendProps: PlotLegendProps; isLoading: boolean; showCheckbox?: boolean; } export function MapLegend(props: Props) { - const { legendItems, isLoading, showCheckbox } = props; + const { plotLegendProps, isLoading, showCheckbox } = props; return isLoading ? (
@@ -17,9 +18,9 @@ export function MapLegend(props: Props) {
) : ( ); } diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx index e77e2d4ee9..ae67024c10 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BarPlotMarkerConfigurationMenu.tsx @@ -20,6 +20,11 @@ import LabelledGroup from '@veupathdb/components/lib/components/widgets/Labelled import { Toggle } from '@veupathdb/coreui'; import { SharedMarkerConfigurations } from './PieMarkerConfigurationMenu'; import { useUncontrolledSelections } from '../hooks/uncontrolledSelections'; +import { + BinningMethod, + SelectedCountsOption, + SelectedValues, +} from '../appState'; interface MarkerConfiguration { type: T; @@ -30,6 +35,9 @@ export interface BarPlotMarkerConfiguration SharedMarkerConfigurations { selectedPlotMode: 'count' | 'proportion'; dependentAxisLogScale: boolean; + binningMethod: BinningMethod; + selectedValues: SelectedValues; + selectedCountsOption: SelectedCountsOption; } interface Props diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx new file mode 100644 index 0000000000..99bacf8cad --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/BubbleMarkerConfigurationMenu.tsx @@ -0,0 +1,221 @@ +import { + InputVariables, + Props as InputVariablesProps, +} from '../../../core/components/visualizations/InputVariables'; +import { VariableTreeNode } from '../../../core/types/study'; +import { VariablesByInputName } from '../../../core/utils/data-element-constraints'; +import { findEntityAndVariable } from '../../../core/utils/study-metadata'; +import { SharedMarkerConfigurations } from './PieMarkerConfigurationMenu'; +import HelpIcon from '@veupathdb/wdk-client/lib/Components/Icon/HelpIcon'; +import { BubbleOverlayConfig } from '../../../core'; +import PluginError from '../../../core/components/visualizations/PluginError'; +import { + aggregationHelp, + AggregationInputs, +} from '../../../core/components/visualizations/implementations/LineplotVisualization'; + +type AggregatorOption = typeof aggregatorOptions[number]; +const aggregatorOptions = ['mean', 'median'] as const; + +interface MarkerConfiguration { + type: T; +} + +export interface BubbleMarkerConfiguration + extends MarkerConfiguration<'bubble'>, + SharedMarkerConfigurations { + aggregator?: AggregatorOption; + numeratorValues?: string[]; + denominatorValues?: string[]; +} + +interface Props + extends Omit< + InputVariablesProps, + | 'inputs' + | 'onChange' + | 'selectedVariables' + | 'selectedPlotMode' + | 'onPlotSelected' + > { + onChange: (configuration: BubbleMarkerConfiguration) => void; + configuration: BubbleMarkerConfiguration; + overlayConfiguration: BubbleOverlayConfig | undefined; +} + +export function BubbleMarkerConfigurationMenu({ + entities, + configuration, + overlayConfiguration, + onChange, + starredVariables, + toggleStarredVariable, + constraints, +}: Props) { + function handleInputVariablesOnChange(selection: VariablesByInputName) { + if (!selection.overlayVariable) { + console.error( + `Expected overlayVariable to be defined but got ${typeof selection.overlayVariable}` + ); + return; + } + + onChange({ + ...configuration, + selectedVariable: selection.overlayVariable, + numeratorValues: undefined, + denominatorValues: undefined, + }); + } + + const selectedVariable = findEntityAndVariable( + entities, + configuration.selectedVariable + )?.variable; + + const categoricalMode = isSuitableCategoricalVariable(selectedVariable); + + const aggregationConfig = overlayConfiguration?.aggregationConfig; + const numeratorValues = + aggregationConfig && 'numeratorValues' in aggregationConfig + ? aggregationConfig.numeratorValues + : undefined; + const denominatorValues = + aggregationConfig && 'denominatorValues' in aggregationConfig + ? aggregationConfig.denominatorValues + : undefined; + const aggregator = + aggregationConfig && 'aggregator' in aggregationConfig + ? aggregationConfig.aggregator + : undefined; + const vocabulary = + selectedVariable && 'vocabulary' in selectedVariable + ? selectedVariable.vocabulary + : undefined; + + const proportionIsValid = validateProportionValues( + numeratorValues, + denominatorValues + ); + + const aggregationInputs = ( +
+ + onChange({ + ...configuration, + aggregator: value, + }), + } + : { + aggregationType: 'proportion', + options: vocabulary ?? [], + numeratorValues: numeratorValues ?? [], + onNumeratorChange: (value) => + onChange({ + ...configuration, + numeratorValues: value, + }), + denominatorValues: denominatorValues ?? [], + onDenominatorChange: (value) => + onChange({ + ...configuration, + denominatorValues: value, + }), + })} + /> + {!proportionIsValid && ( +
+
+ +
+
+ )} +
+ ); + + return ( +
+

+ Color: +

+ + + {selectedVariable + ? categoricalMode + ? 'Proportion (categorical variable)' + : 'Aggregation (continuous variable)' + : ''} + + + + ), + order: 75, + content: selectedVariable ? ( + aggregationInputs + ) : ( + + First choose a Y-axis variable. + + ), + }, + ]} + entities={entities} + selectedVariables={{ overlayVariable: configuration.selectedVariable }} + onChange={handleInputVariablesOnChange} + starredVariables={starredVariables} + toggleStarredVariable={toggleStarredVariable} + constraints={constraints} + flexDirection="column" + /> +
+ ); +} + +/** + * determine if we are dealing with a categorical variable + */ +function isSuitableCategoricalVariable(variable?: VariableTreeNode): boolean { + return ( + variable != null && + 'dataShape' in variable && + variable.dataShape !== 'continuous' && + variable.vocabulary != null && + variable.distinctValuesCount != null + ); +} + +// We currently call this function twice per value change. +// If the number of values becomes vary large, we may want to optimize this? +// Maybe O(n^2) isn't that bad though. +export const validateProportionValues = ( + numeratorValues: string[] | undefined, + denominatorValues: string[] | undefined +) => + numeratorValues === undefined || + denominatorValues === undefined || + numeratorValues.every((value) => denominatorValues.includes(value)); diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx index 378e92a6d5..9059f8d10f 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/CategoricalMarkerConfigurationTable.tsx @@ -6,8 +6,8 @@ import { Tooltip } from '@veupathdb/components/lib/components/widgets/Tooltip'; import { ColorPaletteDefault } from '@veupathdb/components/lib/types/plots'; import RadioButtonGroup from '@veupathdb/components/lib/components/widgets/RadioButtonGroup'; import { UNSELECTED_TOKEN } from '../../'; -import { SharedMarkerConfigurations } from './PieMarkerConfigurationMenu'; import { orderBy } from 'lodash'; +import { SelectedCountsOption } from '../appState'; type Props = { overlayValues: string[]; @@ -16,7 +16,7 @@ type Props = { uncontrolledSelections: Set; setUncontrolledSelections: (v: Set) => void; allCategoricalValues: AllValuesDefinition[] | undefined; - selectedCountsOption: SharedMarkerConfigurations['selectedCountsOption']; + selectedCountsOption: SelectedCountsOption; }; const DEFAULT_SORTING: MesaSortObject = { diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx index 0d44c6497b..29e30a9994 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/PieMarkerConfigurationMenu.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useCallback } from 'react'; import { InputVariables, Props as InputVariablesProps, @@ -19,6 +19,11 @@ import { SubsettingClient } from '../../../core/api'; import RadioButtonGroup from '@veupathdb/components/lib/components/widgets/RadioButtonGroup'; import LabelledGroup from '@veupathdb/components/lib/components/widgets/LabelledGroup'; import { useUncontrolledSelections } from '../hooks/uncontrolledSelections'; +import { + BinningMethod, + SelectedCountsOption, + SelectedValues, +} from '../appState'; interface MarkerConfiguration { type: T; @@ -26,13 +31,14 @@ interface MarkerConfiguration { export interface SharedMarkerConfigurations { selectedVariable: VariableDescriptor; - binningMethod: 'equalInterval' | 'quantile' | 'standardDeviation' | undefined; - selectedCountsOption: 'filtered' | 'visible' | undefined; - selectedValues: string[] | undefined; } export interface PieMarkerConfiguration extends MarkerConfiguration<'pie'>, - SharedMarkerConfigurations {} + SharedMarkerConfigurations { + binningMethod: BinningMethod; + selectedValues: SelectedValues; + selectedCountsOption: SelectedCountsOption; +} interface Props extends Omit< diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/icons/BubbleMarker.tsx b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/icons/BubbleMarker.tsx new file mode 100644 index 0000000000..9243778ad8 --- /dev/null +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/icons/BubbleMarker.tsx @@ -0,0 +1,29 @@ +import { SVGProps } from 'react'; + +export function BubbleMarker(props: SVGProps) { + return ( + // + + + + + + + + + ); +} diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/icons/index.ts b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/icons/index.ts index 48e8b56bf2..8d42af56ba 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/icons/index.ts +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/icons/index.ts @@ -2,5 +2,12 @@ import { DonutMarker } from './DonutMarker'; import { DonutMarkers } from './DonutMarkers'; import { BarPlotMarker } from './BarPlotMarker'; import { BarPlotMarkers } from './BarPlotMarkers'; +import { BubbleMarker } from './BubbleMarker'; -export { DonutMarker, DonutMarkers, BarPlotMarker, BarPlotMarkers }; +export { + DonutMarker, + DonutMarkers, + BarPlotMarker, + BarPlotMarkers, + BubbleMarker, +}; diff --git a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/index.ts b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/index.ts index d6102c524a..3b67e0b8c1 100644 --- a/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/index.ts +++ b/packages/libs/eda/src/lib/map/analysis/MarkerConfiguration/index.ts @@ -1,9 +1,11 @@ import { BarPlotMarkerConfigurationMenu } from './BarPlotMarkerConfigurationMenu'; import { PieMarkerConfigurationMenu } from './PieMarkerConfigurationMenu'; import { MarkerConfigurationSelector } from './MarkerConfigurationSelector'; +import { BubbleMarkerConfigurationMenu } from './BubbleMarkerConfigurationMenu'; export { MarkerConfigurationSelector, PieMarkerConfigurationMenu, BarPlotMarkerConfigurationMenu, + BubbleMarkerConfigurationMenu, }; diff --git a/packages/libs/eda/src/lib/map/analysis/appState.ts b/packages/libs/eda/src/lib/map/analysis/appState.ts index e1b2047e34..f27e62a375 100644 --- a/packages/libs/eda/src/lib/map/analysis/appState.ts +++ b/packages/libs/eda/src/lib/map/analysis/appState.ts @@ -2,7 +2,7 @@ import { getOrElseW } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/function'; import * as t from 'io-ts'; import { isEqual } from 'lodash'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { AnalysisState, useGetDefaultVariableDescriptor, @@ -15,8 +15,31 @@ const LatLngLiteral = t.type({ lat: t.number, lng: t.number }); const MarkerType = t.keyof({ barplot: null, pie: null, + bubble: null, }); +// user-specified selection +export type SelectedValues = t.TypeOf; +// eslint-disable-next-line @typescript-eslint/no-redeclare +const SelectedValues = t.union([t.array(t.string), t.undefined]); + +export type BinningMethod = t.TypeOf; +// eslint-disable-next-line @typescript-eslint/no-redeclare +const BinningMethod = t.union([ + t.literal('equalInterval'), + t.literal('quantile'), + t.literal('standardDeviation'), + t.undefined, +]); + +export type SelectedCountsOption = t.TypeOf; +// eslint-disable-next-line @typescript-eslint/no-redeclare +const SelectedCountsOption = t.union([ + t.literal('filtered'), + t.literal('visible'), + t.undefined, +]); + export type MarkerConfiguration = t.TypeOf; // eslint-disable-next-line @typescript-eslint/no-redeclare export const MarkerConfiguration = t.intersection([ @@ -27,36 +50,28 @@ export const MarkerConfiguration = t.intersection([ t.union([ t.type({ type: t.literal('barplot'), - selectedValues: t.union([t.array(t.string), t.undefined]), // user-specified selection + selectedValues: SelectedValues, selectedPlotMode: t.union([t.literal('count'), t.literal('proportion')]), - binningMethod: t.union([ - t.literal('equalInterval'), - t.literal('quantile'), - t.literal('standardDeviation'), - t.undefined, - ]), + binningMethod: BinningMethod, dependentAxisLogScale: t.boolean, - selectedCountsOption: t.union([ - t.literal('filtered'), - t.literal('visible'), - t.undefined, - ]), + selectedCountsOption: SelectedCountsOption, }), t.type({ type: t.literal('pie'), - selectedValues: t.union([t.array(t.string), t.undefined]), // user-specified selection - binningMethod: t.union([ - t.literal('equalInterval'), - t.literal('quantile'), - t.literal('standardDeviation'), - t.undefined, - ]), - selectedCountsOption: t.union([ - t.literal('filtered'), - t.literal('visible'), - t.undefined, - ]), + selectedValues: SelectedValues, + binningMethod: BinningMethod, + selectedCountsOption: SelectedCountsOption, }), + t.intersection([ + t.type({ + type: t.literal('bubble'), + }), + t.partial({ + aggregator: t.union([t.literal('mean'), t.literal('median')]), + numeratorValues: t.union([t.array(t.string), t.undefined]), + denominatorValues: t.union([t.array(t.string), t.undefined]), + }), + ]), ]), ]); @@ -110,36 +125,72 @@ export function useAppState(uiStateKey: string, analysisState: AnalysisState) { studyMetadata.rootEntity.id ); + const defaultAppState: AppState = useMemo( + () => ({ + viewport: defaultViewport, + mouseMode: 'default', + activeMarkerConfigurationType: 'pie', + markerConfigurations: [ + { + type: 'pie', + selectedVariable: defaultVariable, + selectedValues: undefined, + binningMethod: undefined, + selectedCountsOption: 'filtered', + }, + { + type: 'barplot', + selectedPlotMode: 'count', + selectedVariable: defaultVariable, + selectedValues: undefined, + binningMethod: undefined, + dependentAxisLogScale: false, + selectedCountsOption: 'filtered', + }, + { + type: 'bubble', + selectedVariable: defaultVariable, + aggregator: 'mean', + numeratorValues: undefined, + denominatorValues: undefined, + }, + ], + }), + [defaultVariable] + ); + useEffect(() => { - if (analysis && !appState) { - const defaultAppState: AppState = { - viewport: defaultViewport, - activeMarkerConfigurationType: 'pie', - markerConfigurations: [ - { - type: 'pie', - selectedVariable: defaultVariable, - selectedValues: undefined, - binningMethod: undefined, - selectedCountsOption: 'filtered', - }, - { - type: 'barplot', - selectedPlotMode: 'count', - selectedVariable: defaultVariable, - selectedValues: undefined, - binningMethod: undefined, - dependentAxisLogScale: false, - selectedCountsOption: 'filtered', - }, - ], - }; - setVariableUISettings((prev) => ({ - ...prev, - [uiStateKey]: defaultAppState, - })); + if (analysis) { + if (!appState) { + setVariableUISettings((prev) => ({ + ...prev, + [uiStateKey]: defaultAppState, + })); + } else { + // Ensures forward compatibility of analyses with new marker types + const missingMarkerConfigs = + defaultAppState.markerConfigurations.filter( + (defaultConfig) => + !appState.markerConfigurations.some( + (config) => config.type === defaultConfig.type + ) + ); + + if (missingMarkerConfigs.length > 0) { + setVariableUISettings((prev) => ({ + ...prev, + [uiStateKey]: { + ...appState, + markerConfigurations: [ + ...appState.markerConfigurations, + ...missingMarkerConfigs, + ], + }, + })); + } + } } - }, [analysis, appState, defaultVariable, setVariableUISettings, uiStateKey]); + }, [analysis, appState, setVariableUISettings, uiStateKey, defaultAppState]); function useSetter(key: T) { return useCallback( diff --git a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx index 42967d9fc7..935b6160a1 100644 --- a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx +++ b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneMapMarkers.tsx @@ -3,7 +3,12 @@ import { usePromise } from '../../../core/hooks/promise'; import { BoundsViewport } from '@veupathdb/components/lib/map/Types'; import { GeoConfig } from '../../../core/types/geoConfig'; import DataClient, { + BubbleOverlayConfig, OverlayConfig, + StandaloneMapBubblesLegendRequestParams, + StandaloneMapBubblesLegendResponse, + StandaloneMapBubblesRequestParams, + StandaloneMapBubblesResponse, StandaloneMapMarkersRequestParams, StandaloneMapMarkersResponse, } from '../../../core/api/DataClient'; @@ -14,6 +19,7 @@ import { useDefaultAxisRange } from '../../../core/hooks/computeDefaultAxisRange import { isEqual, some } from 'lodash'; import { ColorPaletteDefault, + getValueToGradientColorMapper, gradientSequentialColorscaleMap, } from '@veupathdb/components/lib/types/plots'; import { @@ -27,6 +33,9 @@ import { useDeepValue } from '../../../core/hooks/immutability'; import { UNSELECTED_DISPLAY_TEXT, UNSELECTED_TOKEN } from '../..'; import { DonutMarkerProps } from '@veupathdb/components/lib/map/DonutMarker'; import { ChartMarkerProps } from '@veupathdb/components/lib/map/ChartMarker'; +import { BubbleMarkerProps } from '@veupathdb/components/lib/map/BubbleMarker'; +import { validateProportionValues } from '../MarkerConfiguration/BubbleMarkerConfigurationMenu'; +import _ from 'lodash'; /** * We can use this viewport to request all available data @@ -65,16 +74,20 @@ export interface StandaloneMapMarkersProps { /** What is the full configuration for that overlay? * This is (sometimes) determined asynchronously from back end requests. */ - overlayConfig: OverlayConfig | undefined; + overlayConfig: OverlayConfig | BubbleOverlayConfig | undefined; outputEntityId: string | undefined; - markerType: 'count' | 'proportion' | 'pie'; + markerType: 'count' | 'proportion' | 'pie' | 'bubble'; dependentAxisLogScale?: boolean; } // what this hook returns interface MapMarkers { /** the markers */ - markersData: DonutMarkerProps[] | ChartMarkerProps[] | undefined; + markersData: + | DonutMarkerProps[] + | ChartMarkerProps[] + | BubbleMarkerProps[] + | undefined; /** `totalVisibleEntityCount` tells you how many entities are visible at a given viewport. But not necessarily with data for the overlay variable. */ totalVisibleEntityCount: number | undefined; /** This tells you how many entities are on screen that also have data for the overlay variable @@ -85,6 +98,9 @@ interface MapMarkers { // vocabulary: string[] | undefined; /** data for creating a legend */ legendItems: LegendItemsProps[]; + bubbleLegendData?: StandaloneMapBubblesLegendResponse; + bubbleValueToDiameterMapper?: (value: number) => number; + bubbleValueToColorMapper?: (value: number) => string; /** is the request pending? */ pending: boolean; /** any error returned from the data request */ @@ -110,7 +126,11 @@ export function useStandaloneMapMarkers( // when switching between pie and bar markers when using the same variable const selectedOverlayVariable = useDeepValue(sov); const overlayConfig = useDeepValue(oc); - const overlayType = overlayConfig?.overlayType; + const overlayType = overlayConfig + ? 'overlayType' in overlayConfig + ? overlayConfig.overlayType + : overlayConfig.aggregationConfig.overlayType + : undefined; const dataClient: DataClient = useDataClient(); @@ -157,8 +177,16 @@ export function useStandaloneMapMarkers( const rawPromise = usePromise< | { - rawMarkersData: StandaloneMapMarkersResponse; + rawMarkersData: + | StandaloneMapMarkersResponse + | StandaloneMapBubblesResponse; vocabulary: string[] | undefined; + bubbleLegendData?: { + minColorValue: number; + maxColorValue: number; + minSizeValue: number; + maxSizeValue: number; + }; } | undefined >( @@ -196,34 +224,113 @@ export function useStandaloneMapMarkers( } : GLOBAL_VIEWPORT; - // now prepare the rest of the request params - const requestParams: StandaloneMapMarkersRequestParams = { - studyId, - filters: filters || [], - config: { - geoAggregateVariable, - latitudeVariable, - longitudeVariable, - overlayConfig, - outputEntityId, - valueSpec: markerType === 'pie' ? 'count' : markerType, - viewport, - }, - }; + if (markerType === 'bubble') { + const bubbleOverlayConfig = overlayConfig as + | BubbleOverlayConfig + | undefined; + const aggregationConfig = bubbleOverlayConfig?.aggregationConfig; + const numeratorValues = + aggregationConfig && 'numeratorValues' in aggregationConfig + ? aggregationConfig.numeratorValues + : undefined; + const denominatorValues = + aggregationConfig && 'denominatorValues' in aggregationConfig + ? aggregationConfig.denominatorValues + : undefined; - // now get and return the data - return { - rawMarkersData: await dataClient.getStandaloneMapMarkers( - 'standalone-map', - requestParams - ), - vocabulary: - overlayType === 'categorical' // switch statement style guide time!! - ? overlayConfig?.overlayValues - : overlayType === 'continuous' - ? overlayConfig?.overlayValues.map((ov) => ov.binLabel) - : undefined, - }; + if ( + !aggregationConfig || + numeratorValues?.length === 0 || + denominatorValues?.length === 0 || + !validateProportionValues(numeratorValues, denominatorValues) + ) { + return undefined; + } + + const markerRequestParams: StandaloneMapBubblesRequestParams = { + studyId, + filters: filters || [], + config: { + geoAggregateVariable, + latitudeVariable, + longitudeVariable, + overlayConfig: bubbleOverlayConfig, + outputEntityId, + valueSpec: 'count', + viewport, + }, + }; + + const legendRequestParams: StandaloneMapBubblesLegendRequestParams = { + studyId, + filters: filters || [], + config: { + outputEntityId, + colorLegendConfig: { + geoAggregateVariable: { + entityId: geoConfig.entity.id, + variableId: geoConfig.aggregationVariableIds.at(-1) as string, + }, + quantitativeOverlayConfig: bubbleOverlayConfig, + }, + sizeConfig: { + geoAggregateVariable: { + entityId: geoConfig.entity.id, + variableId: geoConfig.aggregationVariableIds[0], + }, + }, + }, + }; + + const [rawMarkersData, bubbleLegendData] = await Promise.all([ + dataClient.getStandaloneBubbles( + 'standalone-map', + markerRequestParams + ), + dataClient.getStandaloneBubblesLegend( + 'standalone-map', + legendRequestParams + ), + ]); + + return { + rawMarkersData, + bubbleLegendData, + vocabulary: undefined, + }; + } else { + const standardOverlayConfig = overlayConfig as + | OverlayConfig + | undefined; + const requestParams: StandaloneMapMarkersRequestParams = { + studyId, + filters: filters || [], + config: { + geoAggregateVariable, + latitudeVariable, + longitudeVariable, + overlayConfig: standardOverlayConfig, + outputEntityId, + valueSpec: markerType === 'pie' ? 'count' : markerType, + viewport, + }, + }; + + return { + rawMarkersData: await dataClient.getStandaloneMapMarkers( + 'standalone-map', + requestParams + ), + vocabulary: + overlayType === 'categorical' // switch statement style guide time!! + ? (standardOverlayConfig?.overlayValues as string[]) + : overlayType === 'continuous' + ? standardOverlayConfig?.overlayValues.map((ov) => + typeof ov === 'object' ? ov.binLabel : '' + ) + : undefined, + }; + } }, [ studyId, filters, @@ -241,18 +348,24 @@ export function useStandaloneMapMarkers( ]) ); - const totalVisibleEntityCount: number | undefined = - rawPromise.value?.rawMarkersData.mapElements.reduce((acc, curr) => { - return acc + curr.entityCount; - }, 0); + const totalVisibleEntityCount: number | undefined = rawPromise.value + ? ( + rawPromise.value.rawMarkersData.mapElements as Array<{ + entityCount: number; + }> + ).reduce((acc, curr) => { + return acc + curr.entityCount; + }, 0) + : undefined; // calculate minPos, max and sum for chart marker dependent axis // assumes the value is a count! (so never negative) const { valueMax, valueMinPos, countSum } = useMemo( () => - rawPromise.value?.rawMarkersData + (markerType === 'count' || markerType === 'proportion') && + rawPromise.value ? rawPromise.value.rawMarkersData.mapElements - .flatMap((el) => el.overlayValues) + .flatMap((el) => ('overlayValues' in el ? el.overlayValues : [])) .reduce( ({ valueMax, valueMinPos, countSum }, elem) => ({ valueMax: Math.max(elem.value, valueMax), @@ -270,7 +383,7 @@ export function useStandaloneMapMarkers( } ) : { valueMax: undefined, valueMinPos: undefined, countSum: undefined }, - [rawPromise.value?.rawMarkersData] + [markerType, rawPromise.value] ); const defaultDependentAxisRange = useDefaultAxisRange( @@ -281,111 +394,129 @@ export function useStandaloneMapMarkers( dependentAxisLogScale ) as NumberRange; + const vocabulary = rawPromise.value?.vocabulary; + const bubbleLegendData = rawPromise.value?.bubbleLegendData; + + const adjustedSizeData = useMemo( + () => + bubbleLegendData && + bubbleLegendData.minSizeValue === bubbleLegendData.maxSizeValue + ? { + minSizeValue: 0, + maxSizeValue: bubbleLegendData.maxSizeValue || 1, + } + : undefined, + [bubbleLegendData] + ); + const adjustedColorData = useMemo( + () => + bubbleLegendData && + bubbleLegendData.minColorValue === bubbleLegendData.maxColorValue + ? bubbleLegendData.maxColorValue >= 0 + ? { + minColorValue: 0, + maxColorValue: bubbleLegendData.maxColorValue || 1, + } + : { + minColorValue: bubbleLegendData.minColorValue, + maxColorValue: 0, + } + : undefined, + [bubbleLegendData] + ); + const adjustedBubbleLegendData = useMemo( + () => + bubbleLegendData + ? { + ...bubbleLegendData, + ...adjustedSizeData, + ...adjustedColorData, + } + : undefined, + [adjustedColorData, adjustedSizeData, bubbleLegendData] + ); + + const bubbleValueToDiameterMapper = useMemo( + () => + markerType === 'bubble' && adjustedBubbleLegendData + ? (value: number) => { + // const largestCircleArea = 9000; + const largestCircleDiameter = 90; + const smallestCircleDiameter = 10; + + // Area scales directly with value + // const constant = largestCircleArea / maxOverlayCount; + // const area = value * constant; + // const radius = Math.sqrt(area / Math.PI); + + // Radius scales with log_10 of value + // const constant = 20; + // const radius = Math.log10(value) * constant; + + // Radius scales directly with value + // y = mx + b, m = (y2 - y1) / (x2 - x1), b = y1 - m * x1 + const m = + (largestCircleDiameter - smallestCircleDiameter) / + (adjustedBubbleLegendData.maxSizeValue - + adjustedBubbleLegendData.minSizeValue); + const b = + smallestCircleDiameter - + m * adjustedBubbleLegendData.minSizeValue; + const diameter = m * value + b; + + // return 2 * radius; + return diameter; + } + : undefined, + [adjustedBubbleLegendData, markerType] + ); + + const bubbleValueToColorMapper = useMemo( + () => + markerType === 'bubble' && adjustedBubbleLegendData + ? getValueToGradientColorMapper( + adjustedBubbleLegendData.minColorValue, + adjustedBubbleLegendData.maxColorValue + ) + : undefined, + [adjustedBubbleLegendData, markerType] + ); + /** * Merge the overlay data into the basicMarkerData, if available, * and create markers. */ const finalMarkersData = useMemo(() => { - const vocabulary = rawPromise.value?.vocabulary; - return rawPromise.value?.rawMarkersData.mapElements.map( - ({ - geoAggregateValue, - entityCount, - avgLat, - avgLon, - minLat, - minLon, - maxLat, - maxLon, - overlayValues, - }) => { - const bounds = { - southWest: { lat: minLat, lng: minLon }, - northEast: { lat: maxLat, lng: maxLon }, - }; - const position = { lat: avgLat, lng: avgLon }; - - const donutData = - vocabulary && overlayValues && overlayValues.length - ? overlayValues.map(({ binLabel, value }) => ({ - label: binLabel, - value: value, - color: - overlayType === 'categorical' - ? ColorPaletteDefault[vocabulary.indexOf(binLabel)] - : gradientSequentialColorscaleMap( - vocabulary.length > 1 - ? vocabulary.indexOf(binLabel) / - (vocabulary.length - 1) - : 0.5 - ), - })) - : []; - - // TO DO: address diverging colorscale (especially if there are use-cases) - - // now reorder the data, adding zeroes if necessary. - const reorderedData = - vocabulary != null - ? vocabulary.map( - ( - overlayLabel // overlay label can be 'female' or a bin label '(0,100]' - ) => - donutData.find(({ label }) => label === overlayLabel) ?? { - label: fixLabelForOtherValues(overlayLabel), - value: 0, - } - ) - : // however, if there is no overlay data - // provide a simple entity count marker in the palette's first colour - [ - { - label: 'unknown', - value: entityCount, - color: '#333', - }, - ]; - - const count = - vocabulary != null // if there's an overlay (all expected use cases) - ? overlayValues - .filter(({ binLabel }) => vocabulary.includes(binLabel)) - .reduce((sum, { count }) => (sum = sum + count), 0) - : entityCount; // fallback if not - - const commonMarkerProps = { - id: geoAggregateValue, - key: geoAggregateValue, - bounds: bounds, - position: position, - data: reorderedData, - duration: defaultAnimationDuration, - }; + if (rawPromise.value == null) return undefined; - switch (markerType) { - case 'pie': { - return { - ...commonMarkerProps, - markerLabel: kFormatter(count), - } as DonutMarkerProps; - } - default: { - return { - ...commonMarkerProps, - markerLabel: mFormatter(count), - dependentAxisRange: defaultDependentAxisRange, - dependentAxisLogScale, - } as ChartMarkerProps; - } - } - } - ); + return markerType === 'bubble' + ? processRawBubblesData( + (rawPromise.value.rawMarkersData as StandaloneMapBubblesResponse) + .mapElements, + (props.overlayConfig as BubbleOverlayConfig | undefined) + ?.aggregationConfig, + bubbleValueToDiameterMapper, + bubbleValueToColorMapper + ) + : processRawMarkersData( + (rawPromise.value.rawMarkersData as StandaloneMapMarkersResponse) + .mapElements, + markerType, + defaultDependentAxisRange, + dependentAxisLogScale, + vocabulary, + overlayType + ); }, [ - rawPromise, - markerType, - overlayType, + bubbleValueToColorMapper, + bubbleValueToDiameterMapper, defaultDependentAxisRange, dependentAxisLogScale, + markerType, + overlayType, + props.overlayConfig, + rawPromise.value, + vocabulary, ]); /** @@ -393,7 +524,7 @@ export function useStandaloneMapMarkers( */ const legendItems: LegendItemsProps[] = useMemo(() => { const vocabulary = rawPromise?.value?.vocabulary; - if (vocabulary == null) return []; + if (vocabulary == null || markerType === 'bubble') return []; return vocabulary.map((label) => ({ label: fixLabelForOtherValues(label), @@ -411,25 +542,207 @@ export function useStandaloneMapMarkers( // has any geo-facet got an array of overlay data // containing at least one element that satisfies label==label hasData: rawPromise.value?.rawMarkersData - ? some(rawPromise.value.rawMarkersData.mapElements, (el) => - el.overlayValues.some((ov) => ov.binLabel === label) + ? some( + rawPromise.value.rawMarkersData.mapElements, + (el) => + // TS says el could potentially be a number, and I don't know why + typeof el === 'object' && + 'overlayValues' in el && + el.overlayValues.some((ov) => ov.binLabel === label) ) : false, group: 1, rank: 1, })); - }, [rawPromise, overlayType]); + }, [markerType, overlayType, rawPromise]); return { - markersData: finalMarkersData, + markersData: finalMarkersData as MapMarkers['markersData'], totalVisibleWithOverlayEntityCount: countSum, totalVisibleEntityCount, legendItems, + bubbleLegendData: adjustedBubbleLegendData, + bubbleValueToDiameterMapper, + bubbleValueToColorMapper, pending: rawPromise.pending, error: rawPromise.error, }; } +const processRawMarkersData = ( + mapElements: StandaloneMapMarkersResponse['mapElements'], + markerType: 'count' | 'proportion' | 'pie', + defaultDependentAxisRange: NumberRange, + dependentAxisLogScale: boolean, + vocabulary?: string[], + overlayType?: 'categorical' | 'continuous' +) => { + return mapElements.map( + ({ + geoAggregateValue, + entityCount, + avgLat, + avgLon, + minLat, + minLon, + maxLat, + maxLon, + overlayValues, + }) => { + const { bounds, position } = getBoundsAndPosition( + minLat, + minLon, + maxLat, + maxLon, + avgLat, + avgLon + ); + + const donutData = + vocabulary && overlayValues && overlayValues.length + ? overlayValues.map(({ binLabel, value }) => ({ + label: binLabel, + value: value, + color: + overlayType === 'categorical' + ? ColorPaletteDefault[vocabulary.indexOf(binLabel)] + : gradientSequentialColorscaleMap( + vocabulary.length > 1 + ? vocabulary.indexOf(binLabel) / (vocabulary.length - 1) + : 0.5 + ), + })) + : []; + + // TO DO: address diverging colorscale (especially if there are use-cases) + + // now reorder the data, adding zeroes if necessary. + const reorderedData = + vocabulary != null + ? vocabulary.map( + ( + overlayLabel // overlay label can be 'female' or a bin label '(0,100]' + ) => + donutData.find(({ label }) => label === overlayLabel) ?? { + label: fixLabelForOtherValues(overlayLabel), + value: 0, + } + ) + : // however, if there is no overlay data + // provide a simple entity count marker in the palette's first colour + [ + { + label: 'unknown', + value: entityCount, + color: '#333', + }, + ]; + + const count = + vocabulary != null && overlayValues // if there's an overlay (all expected use cases) + ? overlayValues + .filter(({ binLabel }) => vocabulary.includes(binLabel)) + .reduce((sum, { count }) => (sum = sum + count), 0) + : entityCount; // fallback if not + + const commonMarkerProps = { + data: reorderedData, + id: geoAggregateValue, + key: geoAggregateValue, + bounds, + position, + duration: defaultAnimationDuration, + }; + + switch (markerType) { + case 'pie': { + return { + ...commonMarkerProps, + markerLabel: kFormatter(count), + } as DonutMarkerProps; + } + default: { + return { + ...commonMarkerProps, + markerLabel: mFormatter(count), + dependentAxisRange: defaultDependentAxisRange, + dependentAxisLogScale, + } as ChartMarkerProps; + } + } + } + ); +}; + +const processRawBubblesData = ( + mapElements: StandaloneMapBubblesResponse['mapElements'], + aggregationConfig?: BubbleOverlayConfig['aggregationConfig'], + bubbleValueToDiameterMapper?: (value: number) => number, + bubbleValueToColorMapper?: (value: number) => string +) => { + return mapElements.map( + ({ + geoAggregateValue, + entityCount, + avgLat, + avgLon, + minLat, + minLon, + maxLat, + maxLon, + overlayValue, + }) => { + const { bounds, position } = getBoundsAndPosition( + minLat, + minLon, + maxLat, + maxLon, + avgLat, + avgLon + ); + + // TO DO: address diverging colorscale (especially if there are use-cases) + + const bubbleData = { + value: entityCount, + diameter: bubbleValueToDiameterMapper?.(entityCount) ?? 0, + colorValue: overlayValue, + colorLabel: aggregationConfig + ? aggregationConfig.overlayType === 'continuous' + ? _.capitalize(aggregationConfig.aggregator) + : 'Proportion' + : undefined, + color: bubbleValueToColorMapper?.(overlayValue), + }; + + return { + id: geoAggregateValue, + key: geoAggregateValue, + bounds, + position, + duration: defaultAnimationDuration, + data: bubbleData, + markerLabel: String(entityCount), + } as BubbleMarkerProps; + } + ); +}; + +const getBoundsAndPosition = ( + minLat: number, + minLon: number, + maxLat: number, + maxLon: number, + avgLat: number, + avgLon: number +) => ({ + bounds: { + southWest: { lat: minLat, lng: minLon }, + northEast: { lat: maxLat, lng: maxLon }, + }, + position: { lat: avgLat, lng: avgLon }, +}); + function fixLabelForOtherValues(input: string): string { return input === UNSELECTED_TOKEN ? UNSELECTED_DISPLAY_TEXT : input; } diff --git a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts index 891d829fa2..2abe58a43d 100644 --- a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts +++ b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts @@ -17,17 +17,22 @@ import { scatterplotVisualization } from '../../../core/components/visualization import { lineplotVisualization } from '../../../core/components/visualizations/implementations/LineplotVisualization'; import { barplotVisualization } from '../../../core/components/visualizations/implementations/BarplotVisualization'; import { boxplotVisualization } from '../../../core/components/visualizations/implementations/BoxplotVisualization'; -import { BinDefinitions, OverlayConfig } from '../../../core'; +import { + BinDefinitions, + OverlayConfig, + BubbleOverlayConfig, +} from '../../../core'; import { boxplotRequest } from './plugins/boxplot'; import { barplotRequest } from './plugins/barplot'; import { lineplotRequest } from './plugins/lineplot'; import { histogramRequest } from './plugins/histogram'; import { scatterplotRequest } from './plugins/scatterplot'; -//TO DO import timeline SVGIcon -import LineSVG from '../../../core/components/visualizations/implementations/selectorIcons/LineSVG'; + +import TimeSeriesSVG from '../../../core/components/visualizations/implementations/selectorIcons/TimeSeriesSVG'; +import _ from 'lodash'; interface Props { - selectedOverlayConfig?: OverlayConfig; + selectedOverlayConfig?: OverlayConfig | BubbleOverlayConfig; } type StandaloneVizOptions = LayoutOptions & OverlayOptions; @@ -47,9 +52,14 @@ export function useStandaloneVizPlugins({ // one object? Because in the pre-SAM world, getOverlayVariable was already // part of this interface. getOverlayVariable: (_) => selectedOverlayConfig?.overlayVariable, - getOverlayType: () => selectedOverlayConfig?.overlayType, + getOverlayType: () => + _.get(selectedOverlayConfig, 'overlayType') ?? + _.get(selectedOverlayConfig, 'aggregationConfig.overlayType'), getOverlayVocabulary: () => { - const overlayValues = selectedOverlayConfig?.overlayValues; + const overlayValues = + selectedOverlayConfig && 'overlayValues' in selectedOverlayConfig + ? selectedOverlayConfig.overlayValues + : undefined; if (overlayValues == null) return undefined; if (BinDefinitions.is(overlayValues)) { return overlayValues.map((bin) => bin.binLabel); @@ -74,7 +84,7 @@ export function useStandaloneVizPlugins({ requestFunction: ( props: RequestOptionProps & ExtraProps & { - overlayConfig: OverlayConfig | undefined; + overlayConfig: OverlayConfig | BubbleOverlayConfig | undefined; } ) => RequestParamsType ) { @@ -113,7 +123,7 @@ export function useStandaloneVizPlugins({ .withOptions({ showMarginalHistogram: true, }) - .withSelectorIcon(LineSVG) + .withSelectorIcon(TimeSeriesSVG) ), lineplotRequest ), diff --git a/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts b/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts index aa26974d5b..b60f795c2b 100644 --- a/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts +++ b/packages/libs/eda/src/lib/map/analysis/utils/defaultOverlayConfig.ts @@ -2,6 +2,7 @@ import { ColorPaletteDefault } from '@veupathdb/components/lib/types/plots'; import { UNSELECTED_TOKEN } from '../..'; import { BinRange, + BubbleOverlayConfig, CategoricalVariableDataShape, ContinuousVariableDataShape, Filter, @@ -10,7 +11,8 @@ import { Variable, } from '../../../core'; import { DataClient, SubsettingClient } from '../../../core/api'; -import { MarkerConfiguration } from '../appState'; +import { BinningMethod, MarkerConfiguration } from '../appState'; +import { BubbleMarkerConfiguration } from '../MarkerConfiguration/BubbleMarkerConfigurationMenu'; // This async function fetches the default overlay config. // For continuous variables, this involves calling the filter-aware-metadata/continuous-variable @@ -26,12 +28,16 @@ export interface DefaultOverlayConfigProps { overlayEntity: StudyEntity | undefined; dataClient: DataClient; subsettingClient: SubsettingClient; - binningMethod?: MarkerConfiguration['binningMethod']; + markerType?: MarkerConfiguration['type']; + binningMethod?: BinningMethod; + aggregator?: BubbleMarkerConfiguration['aggregator']; + numeratorValues?: BubbleMarkerConfiguration['numeratorValues']; + denominatorValues?: BubbleMarkerConfiguration['denominatorValues']; } export async function getDefaultOverlayConfig( props: DefaultOverlayConfigProps -): Promise { +): Promise { const { studyId, filters, @@ -39,7 +45,11 @@ export async function getDefaultOverlayConfig( overlayEntity, dataClient, subsettingClient, + markerType, binningMethod = 'equalInterval', + aggregator = 'mean', + numeratorValues, + denominatorValues, } = props; if (overlayVariable != null && overlayEntity != null) { @@ -50,34 +60,57 @@ export async function getDefaultOverlayConfig( if (CategoricalVariableDataShape.is(overlayVariable.dataShape)) { // categorical - const overlayValues = await getMostFrequentValues({ - studyId: studyId, - ...overlayVariableDescriptor, - filters: filters ?? [], - numValues: ColorPaletteDefault.length - 1, - subsettingClient, - }); + if (markerType === 'bubble') { + return { + overlayVariable: overlayVariableDescriptor, + aggregationConfig: { + overlayType: 'categorical', + numeratorValues: + numeratorValues ?? overlayVariable.vocabulary ?? [], + denominatorValues: + denominatorValues ?? overlayVariable.vocabulary ?? [], + }, + }; + } else { + const overlayValues = await getMostFrequentValues({ + studyId: studyId, + ...overlayVariableDescriptor, + filters: filters ?? [], + numValues: ColorPaletteDefault.length - 1, + subsettingClient, + }); - return { - overlayType: 'categorical', - overlayVariable: overlayVariableDescriptor, - overlayValues, - }; + return { + overlayType: 'categorical', + overlayVariable: overlayVariableDescriptor, + overlayValues, + }; + } } else if (ContinuousVariableDataShape.is(overlayVariable.dataShape)) { // continuous - const overlayBins = await getBinRanges({ - studyId, - ...overlayVariableDescriptor, - filters: filters ?? [], - dataClient, - binningMethod, - }); + if (markerType === 'bubble') { + return { + overlayVariable: overlayVariableDescriptor, + aggregationConfig: { + overlayType: 'continuous', + aggregator, + }, + }; + } else { + const overlayBins = await getBinRanges({ + studyId, + ...overlayVariableDescriptor, + filters: filters ?? [], + dataClient, + binningMethod, + }); - return { - overlayType: 'continuous', - overlayValues: overlayBins, - overlayVariable: overlayVariableDescriptor, - }; + return { + overlayType: 'continuous', + overlayValues: overlayBins, + overlayVariable: overlayVariableDescriptor, + }; + } } else { return; } @@ -127,7 +160,7 @@ export type GetBinRangesProps = { entityId: string; dataClient: DataClient; filters: Filter[]; - binningMethod: MarkerConfiguration['binningMethod']; + binningMethod: BinningMethod; }; // get the equal spaced bin definitions (for now at least) diff --git a/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx b/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx index d55c85c1ba..17f605ee68 100644 --- a/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx +++ b/packages/libs/eda/src/lib/workspace/AllAnalyses.tsx @@ -38,6 +38,7 @@ import { AnalysisSummary, useAnalysisList, usePinnedAnalyses, + useSubsettingClient, } from '../core'; import SubsettingClient from '../core/api/SubsettingClient'; import { useDebounce } from '../core/hooks/debouncing'; @@ -169,7 +170,10 @@ export function AllAnalyses(props: Props) { removePinnedAnalysis, } = usePinnedAnalyses(analysisClient); - const datasets = useWdkStudyRecords(WDK_STUDY_RECORD_ATTRIBUTES); + const subsettingClient = useSubsettingClient(); + const datasets = useWdkStudyRecords(subsettingClient, { + attributes: WDK_STUDY_RECORD_ATTRIBUTES, + }); const { analyses, deleteAnalyses, updateAnalysis, loading, error } = useAnalysisList(analysisClient); diff --git a/packages/libs/eda/src/lib/workspace/PublicAnalysesRoute.tsx b/packages/libs/eda/src/lib/workspace/PublicAnalysesRoute.tsx index 9cd421b38b..6c5a30b91b 100644 --- a/packages/libs/eda/src/lib/workspace/PublicAnalysesRoute.tsx +++ b/packages/libs/eda/src/lib/workspace/PublicAnalysesRoute.tsx @@ -9,18 +9,21 @@ import { AnalysisClient, usePublicAnalysisList } from '../core'; import { useWdkStudyRecords } from '../core/hooks/study'; import { PublicAnalyses } from './PublicAnalyses'; +import SubsettingClient from '../core/api/SubsettingClient'; export interface Props { analysisClient: AnalysisClient; + subsettingClient: SubsettingClient; exampleAnalysesAuthor?: number; } export function PublicAnalysesRoute({ analysisClient, + subsettingClient, exampleAnalysesAuthor, }: Props) { const publicAnalysisListState = usePublicAnalysisList(analysisClient); - const studyRecords = useWdkStudyRecords(); + const studyRecords = useWdkStudyRecords(subsettingClient); const location = useLocation(); const makeAnalysisLink = useCallback( diff --git a/packages/libs/eda/src/lib/workspace/StudyList.tsx b/packages/libs/eda/src/lib/workspace/StudyList.tsx index cd8ddf9598..5da035c592 100644 --- a/packages/libs/eda/src/lib/workspace/StudyList.tsx +++ b/packages/libs/eda/src/lib/workspace/StudyList.tsx @@ -10,19 +10,23 @@ import { usePermissions } from '@veupathdb/study-data-access/lib/data-restrictio import { useWdkStudyRecords } from '../core/hooks/study'; import { getStudyAccess } from '@veupathdb/study-data-access/lib/shared/studies'; +import { SubsettingClient } from '../core/api'; interface StudyListProps { baseUrl: string; + subsettingClient: SubsettingClient; } /** * Displays a list of links to various available studies. */ export function StudyList(props: StudyListProps) { - const { baseUrl } = props; + const { baseUrl, subsettingClient } = props; const studyRecordAttributes = useMemo(() => ['study_access'], []); - const datasets = useWdkStudyRecords(studyRecordAttributes); + const datasets = useWdkStudyRecords(subsettingClient, { + attributes: studyRecordAttributes, + }); const permissions = usePermissions(); @@ -34,18 +38,16 @@ export function StudyList(props: StudyListProps) {

EDA Workspace

Choose a study

    - {datasets - .filter((dataset) => dataset.attributes.eda_study_id != null) - .map((dataset) => { - return ( -
  • - - {safeHtml(dataset.displayName)} [ - {getStudyAccess(dataset)}] - -
  • - ); - })} + {datasets.map((dataset) => { + return ( +
  • + + {safeHtml(dataset.displayName)} [ + {getStudyAccess(dataset)}] + +
  • + ); + })}
); diff --git a/packages/libs/eda/src/lib/workspace/WorkspaceRouter.tsx b/packages/libs/eda/src/lib/workspace/WorkspaceRouter.tsx index 4115654bdb..ad93da0128 100644 --- a/packages/libs/eda/src/lib/workspace/WorkspaceRouter.tsx +++ b/packages/libs/eda/src/lib/workspace/WorkspaceRouter.tsx @@ -183,13 +183,16 @@ export function WorkspaceRouter({ } + render={() => ( + + )} /> ( )} diff --git a/packages/libs/http-utils/src/FetchClient.ts b/packages/libs/http-utils/src/FetchClient.ts index 779a41a77e..a5c7091558 100644 --- a/packages/libs/http-utils/src/FetchClient.ts +++ b/packages/libs/http-utils/src/FetchClient.ts @@ -37,12 +37,25 @@ export interface FetchApiOptions { init?: RequestInit; /** Implementation of `fetch` function. Defaults to `window.fetch`. */ fetchApi?: Window['fetch']; + /** + * Callback that can be used for reporting errors. A Promise rejection will + * still occur. + */ + onNonSuccessResponse?: (error: Error) => void; +} + +class FetchClientError extends Error { + name = 'FetchClientError'; } export abstract class FetchClient { + /** Default callback used, if none is specified to constructor. */ + private static onNonSuccessResponse: FetchApiOptions['onNonSuccessResponse']; + protected readonly baseUrl: string; protected readonly init: RequestInit; protected readonly fetchApi: Window['fetch']; + protected readonly onNonSuccessResponse: FetchApiOptions['onNonSuccessResponse']; // Subclasses can set this to false to disable including a traceparent header with all requests. protected readonly includeTraceidHeader: boolean = true; @@ -50,6 +63,23 @@ export abstract class FetchClient { this.baseUrl = options.baseUrl; this.init = options.init ?? {}; this.fetchApi = options.fetchApi ?? window.fetch; + this.onNonSuccessResponse = + options.onNonSuccessResponse ?? FetchClient.onNonSuccessResponse; + } + + /** + * Set a default callback for all instances. Should only be called once. + */ + public static setOnNonSuccessResponse( + callback: FetchApiOptions['onNonSuccessResponse'] + ) { + if (this.onNonSuccessResponse) { + console.warn( + 'FetchClient.setOnNonSuccessResponse() should only be called once.' + ); + return; + } + this.onNonSuccessResponse = callback; } protected async fetch(apiRequest: ApiRequest): Promise { @@ -74,9 +104,14 @@ export abstract class FetchClient { return await transformResponse(responseBody); } - throw new Error( - `${response.status} ${response.statusText}${'\n'}${await response.text()}` + const fetchError = new FetchClientError( + `${response.status} ${ + response.statusText + }: ${request.method.toUpperCase()} ${request.url} + ${'\n'}${await response.text()}` ); + this.onNonSuccessResponse?.(fetchError); + throw fetchError; } } diff --git a/packages/libs/study-data-access/src/data-restriction/DataRestrictionDaemon.jsx b/packages/libs/study-data-access/src/data-restriction/DataRestrictionDaemon.jsx index 050d4ac59f..490951d846 100644 --- a/packages/libs/study-data-access/src/data-restriction/DataRestrictionDaemon.jsx +++ b/packages/libs/study-data-access/src/data-restriction/DataRestrictionDaemon.jsx @@ -27,9 +27,9 @@ function DataRestrictionDaemon(props) { useEffect(() => { clearRestrictions(); - }, [location.pathname]); + }, [location.pathname, clearRestrictions]); - const permissionsValue = usePermissions(); + const permissionsValue = usePermissions({ force: true }); if (dataRestriction == null || user == null || permissionsValue.loading) return null; diff --git a/packages/libs/study-data-access/src/data-restriction/permissionsHooks.ts b/packages/libs/study-data-access/src/data-restriction/permissionsHooks.ts index 258733cf09..a7975b5a09 100644 --- a/packages/libs/study-data-access/src/data-restriction/permissionsHooks.ts +++ b/packages/libs/study-data-access/src/data-restriction/permissionsHooks.ts @@ -1,7 +1,5 @@ import { useMemo } from 'react'; -import { defaultMemoize } from 'reselect'; - import { useWdkService } from '@veupathdb/wdk-client/lib/Hooks/WdkServiceHook'; import { User } from '@veupathdb/wdk-client/lib/Utils/WdkUser'; @@ -13,22 +11,28 @@ export type AsyncUserPermissions = | { loading: true } | { loading: false; permissions: UserPermissions }; -const memoizedPermissionsCheck = defaultMemoize(function ( - user: User, - studyAccessApi: StudyAccessApi -) { - return checkPermissions(user, studyAccessApi); -}); +// Caches permissions until the location changes +export const cachedPermissionCheck = (function () { + let result: Promise; + let lastLocation = window.location.href; + return function cachedPermissionCheck( + user: User, + studyAccessApi: StudyAccessApi + ): Promise { + if (result == null || lastLocation !== window.location.href) { + lastLocation = window.location.href; + result = checkPermissions(user, studyAccessApi); + } + return result; + }; +})(); export function usePermissions(): AsyncUserPermissions { const studyAccessApi = useStudyAccessApi(); const permissions = useWdkService( async (wdkService) => - memoizedPermissionsCheck( - await wdkService.getCurrentUser({ force: true }), - studyAccessApi - ), + cachedPermissionCheck(await wdkService.getCurrentUser(), studyAccessApi), [studyAccessApi] ); diff --git a/packages/libs/user-datasets/src/lib/Components/Detail/UserDatasetDetail.jsx b/packages/libs/user-datasets/src/lib/Components/Detail/UserDatasetDetail.jsx index 2dafec8c72..b9fef4bc0d 100644 --- a/packages/libs/user-datasets/src/lib/Components/Detail/UserDatasetDetail.jsx +++ b/packages/libs/user-datasets/src/lib/Components/Detail/UserDatasetDetail.jsx @@ -519,6 +519,11 @@ class UserDatasetDetail extends React.Component { -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-= */ + // This is needed to resolve downstream typescript errors. + // TypeScript infers that this method returns JSX.Element[]. + // Some classes extending this will return (JSX.Element | null)[]. + // The ReactNode type is better suited, here, since it allows for null values. + /** @return {import("react").ReactNode[]} */ getPageSections() { return [ this.renderHeaderSection, diff --git a/packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.jsx b/packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.jsx index f47567d44b..220722c6e4 100644 --- a/packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.jsx +++ b/packages/libs/wdk-client/src/Components/AttributeFilter/MembershipField.jsx @@ -390,6 +390,23 @@ class MembershipTable extends React.PureComponent { } handleSearchTermChange(searchTerm) { + // When we are not on page 1, we need to determine if our currentPage position remains viable + // or if it should get reset to page 1 (see note in TableFilter.tsx's handleSearch callback definition) + if (this.props.activeFieldState.currentPage !== 1) { + const numberOfFilteredRows = filterBySearchTerm( + this.getRows(), + searchTerm + ).length; + const shouldResetPaging = + numberOfFilteredRows <= + this.props.activeFieldState.rowsPerPage * + (this.props.activeFieldState.currentPage - 1); + this.props.onMemberSearch( + this.props.activeField, + searchTerm, + shouldResetPaging + ); + } this.props.onMemberSearch(this.props.activeField, searchTerm); } diff --git a/packages/libs/wdk-client/src/Components/DataTable/DataTable.css b/packages/libs/wdk-client/src/Components/DataTable/DataTable.css index d85a3d3d0f..d3e902b5cc 100644 --- a/packages/libs/wdk-client/src/Components/DataTable/DataTable.css +++ b/packages/libs/wdk-client/src/Components/DataTable/DataTable.css @@ -136,3 +136,8 @@ top: -3em; margin-left: 310px; } + +.wdk-DataTableCountsContainer { + margin: auto 0 auto 1em; + font-size: 0.9em; +} diff --git a/packages/libs/wdk-client/src/Components/DataTable/DataTable.tsx b/packages/libs/wdk-client/src/Components/DataTable/DataTable.tsx index d37f251754..ad7abecb55 100644 --- a/packages/libs/wdk-client/src/Components/DataTable/DataTable.tsx +++ b/packages/libs/wdk-client/src/Components/DataTable/DataTable.tsx @@ -124,6 +124,8 @@ interface State { childRows: [HTMLElement, ChildRowProps][]; selectedColumnFilters: string[]; showFieldSelector: boolean; + /** Used to display row counts to user */ + numberOfRowsVisible: number | null; } /** @@ -158,6 +160,7 @@ class DataTable extends PureComponent { childRows: [], selectedColumnFilters: [], showFieldSelector: false, + numberOfRowsVisible: null, }; _childRowContainers: Map = new Map(); @@ -430,6 +433,11 @@ class DataTable extends PureComponent { .search(searchTermRegex, true, false, true) .draw(); } + /** set row count after .draw() for correct count */ + this.setState((state) => ({ + ...state, + numberOfRowsVisible: dataTable.page.info().recordsDisplay, + })); } _updateSorting(dataTable: DataTables.Api) { @@ -662,6 +670,21 @@ class DataTable extends PureComponent {
+
+ + + {this.state.numberOfRowsVisible ?? this.props.data.length} + {' '} + rows + {' '} + {(this.state.numberOfRowsVisible === 0 || + this.state.numberOfRowsVisible) && + this.state.numberOfRowsVisible < this.props.data.length && ( + + (filtered from a total of {this.props.data.length}) + + )} +
{this.state.showFieldSelector && ( ) => - (props: Exclude) => + (options: Pick) => + (props: Exclude) => , }); diff --git a/packages/libs/wdk-client/src/StoreModules/QuestionStoreModule.ts b/packages/libs/wdk-client/src/StoreModules/QuestionStoreModule.ts index 418bae05ff..ec875f85fa 100644 --- a/packages/libs/wdk-client/src/StoreModules/QuestionStoreModule.ts +++ b/packages/libs/wdk-client/src/StoreModules/QuestionStoreModule.ts @@ -1,4 +1,4 @@ -import { keyBy, mapValues, pick, toString } from 'lodash'; +import { isEmpty, keyBy, mapValues, pick, toString } from 'lodash'; import { Seq } from '../Utils/IterableUtils'; import { combineEpics, @@ -535,34 +535,6 @@ const observeLoadQuestionSuccess: QuestionEpic = (action$) => ) ); -const observeStoreUpdatedParams: QuestionEpic = ( - action$, - state$, - { paramValueStore } -) => - action$.pipe( - ofType(UPDATE_PARAM_VALUE), - mergeMap(async (action: UpdateParamValueAction) => { - const searchName = action.payload.searchName; - const questionState = state$.value.question.questions[searchName]; - - if (questionState == null) { - return EMPTY; - } - - const { globalParamMapping, paramValues: newParamValues } = questionState; - - await updateLastParamValues( - paramValueStore, - searchName, - newParamValues, - globalParamMapping - ); - return EMPTY; - }), - mergeAll() - ); - type ActionAffectingGroupCount = | ChangeGroupVisibilityAction | UpdateParamValueAction; @@ -757,13 +729,6 @@ const observeQuestionSubmit: QuestionEpic = (action$, state$, services) => wdkWeight: Number.isNaN(weight) ? DEFAULT_STEP_WEIGHT : weight, }; - updateLastParamValues( - services.paramValueStore, - searchName, - paramValues, - globalParamMapping - ); - if (submissionMetadata.type === 'edit-step') { return of( requestReviseStep( @@ -957,6 +922,16 @@ const observeQuestionSubmit: QuestionEpic = (action$, state$, services) => ) ); }) + .then((nextAction) => { + const { paramValues, globalParamMapping, question } = questionState; + updateLastParamValues( + services.paramValueStore, + question.urlSegment, + paramValues, + globalParamMapping + ); + return nextAction; + }) ).pipe( mergeAll(), catchError((error: any) => @@ -989,7 +964,6 @@ export const observeQuestion: QuestionEpic = combineEpics( observeLoadQuestion, observeLoadQuestionSuccess, observeAutoRun, - observeStoreUpdatedParams, observeUpdateDependentParams, observeLoadGroupCount, observeQuestionSubmit, @@ -1063,13 +1037,6 @@ async function loadQuestion( const wdkWeight = step == null ? undefined : step.searchConfig.wdkWeight; - await updateLastParamValues( - paramValueStore, - searchName, - paramValues, - globalParamMapping - ); - return questionLoaded({ autoRun, prepopulateWithLastParamValues, @@ -1101,7 +1068,7 @@ async function fetchInitialParams( ) { if (step != null) { return initialParamDataFromStep(step); - } else if (initialParamData != null) { + } else if (!isEmpty(initialParamData)) { return extracParamValues(initialParamData, question.paramNames); } else if (prepopulateWithLastParamValues) { return ( @@ -1137,7 +1104,7 @@ function extracParamValues( return pick(initialParamData, paramNames); } -function updateLastParamValues( +export function updateLastParamValues( paramValueStore: ParamValueStore, searchName: string, newParamValues: ParameterValues, diff --git a/packages/libs/wdk-client/src/Utils/ComponentUtils.tsx b/packages/libs/wdk-client/src/Utils/ComponentUtils.tsx index cef9e517fd..8f93c9ac94 100644 --- a/packages/libs/wdk-client/src/Utils/ComponentUtils.tsx +++ b/packages/libs/wdk-client/src/Utils/ComponentUtils.tsx @@ -261,7 +261,12 @@ export function safeHtml

( Component: any = 'span' ): JSX.Element { str = str ?? ''; - if (str.indexOf('<') === -1) { + /** + * To improve performance, let's skip the element creation and innerHTML magic + * when we detect neither HTML nor an HTML entity in the string + */ + const isHtmlEntityFound = /(\&(.+?);)/.test(str); + if (str.indexOf('<') === -1 && !isHtmlEntityFound) { return {str}; } // Use innerHTML to auto close tags diff --git a/packages/libs/wdk-client/src/Utils/UserPreferencesUtils.ts b/packages/libs/wdk-client/src/Utils/UserPreferencesUtils.ts index 996425c9c3..77a3baf996 100644 --- a/packages/libs/wdk-client/src/Utils/UserPreferencesUtils.ts +++ b/packages/libs/wdk-client/src/Utils/UserPreferencesUtils.ts @@ -65,7 +65,9 @@ export async function getResultTableColumnsPref( const [knownColumns, unknownColumns] = partition( columns, - (columnName) => columnName in recordClass.attributesMap + (columnName) => + recordClass.attributes.some((a) => a.name === columnName) || + question.dynamicAttributes.some((a) => a.name === columnName) ); if (unknownColumns.length > 0) { diff --git a/packages/libs/wdk-client/src/Views/Question/Params/EnumParam.tsx b/packages/libs/wdk-client/src/Views/Question/Params/EnumParam.tsx index 6aa380ccb9..98cbb17391 100644 --- a/packages/libs/wdk-client/src/Views/Question/Params/EnumParam.tsx +++ b/packages/libs/wdk-client/src/Views/Question/Params/EnumParam.tsx @@ -25,6 +25,7 @@ import { isMultiPick, toMultiValueString, toMultiValueArray, + countInBounds, } from '../../../Views/Question/Params/EnumParamUtils'; // TODO: Move TreeBox state into TreeBoxEnumParam component @@ -39,11 +40,16 @@ export default createParamModule({ function isParamValueValid(context: Context) { let value = context.paramValues[context.parameter.name]; - return ( - typeof value === 'string' && - (context.parameter.type !== 'multi-pick-vocabulary' || - isValidEnumJson(value)) - ); + if (context.parameter.type === 'multi-pick-vocabulary') { + if (!isValidEnumJson(value)) return false; + const typedValue = toMultiValueArray(value); + return countInBounds( + typedValue.length, + context.parameter.minSelectedCount, + context.parameter.maxSelectedCount + ); + } + return typeof value === 'string'; } function isType(parameter: Parameter): parameter is EnumParam { diff --git a/packages/libs/wdk-client/src/Views/Question/Params/EnumParamUtils.ts b/packages/libs/wdk-client/src/Views/Question/Params/EnumParamUtils.ts index c380c1da38..cf9a7ccc46 100644 --- a/packages/libs/wdk-client/src/Views/Question/Params/EnumParamUtils.ts +++ b/packages/libs/wdk-client/src/Views/Question/Params/EnumParamUtils.ts @@ -37,9 +37,14 @@ export function isMultiPick(parameter: Parameter): boolean { } export function isValidEnumJson(value: string): boolean { - const validationResult = enumJsonDecoder(value); - - return validationResult.status === 'ok'; + try { + const parsedValue = JSON.parse(value); + const validationResult = enumJsonDecoder(parsedValue); + + return validationResult.status === 'ok'; + } catch { + return false; + } } const enumJsonDecoder = arrayOf(string); diff --git a/packages/libs/wdk-client/src/Views/Question/Params/SelectionInfo.tsx b/packages/libs/wdk-client/src/Views/Question/Params/SelectionInfo.tsx index 98ffb25bcd..ebe9cf5c30 100644 --- a/packages/libs/wdk-client/src/Views/Question/Params/SelectionInfo.tsx +++ b/packages/libs/wdk-client/src/Views/Question/Params/SelectionInfo.tsx @@ -22,7 +22,7 @@ export default function SelectionInfo(props: Props) { ? `${ isSingleSelect ? '' : 'between ' + minSelectedCount + ' and ' }${maxSelectedCount} ${valueDescription(maxSelectedCount)} required` - : hasMin && selectedCount > 0 + : hasMin && (selectedCount > 0 || minSelectedCount === 1) ? `at least ${minSelectedCount} ${valueDescription( minSelectedCount )} required` diff --git a/packages/libs/wdk-client/src/Views/ResultTableSummaryView/ResultTable.tsx b/packages/libs/wdk-client/src/Views/ResultTableSummaryView/ResultTable.tsx index 06a5dce46b..a7227eef52 100644 --- a/packages/libs/wdk-client/src/Views/ResultTableSummaryView/ResultTable.tsx +++ b/packages/libs/wdk-client/src/Views/ResultTableSummaryView/ResultTable.tsx @@ -36,6 +36,7 @@ export interface Props { resultType: ResultType; viewId: string; actions?: Action[]; + showCount?: boolean; selectedIds?: string[]; showIdAttributeColumn: boolean; activeAttributeAnalysisName: string | undefined; @@ -72,6 +73,7 @@ function ResultTable(props: Props) { showHideAddColumnsDialog, requestAddStepToBasket, actions, + showCount, selectedIds, userIsGuest, showLoginWarning, @@ -106,6 +108,7 @@ function ResultTable(props: Props) { recordInstance.attributes[recordClass.recordIdAttributeName] as string ); }, + showCount, }; const tableState = MesaState.create({ options, diff --git a/packages/libs/wdk-client/src/Views/ResultTableSummaryView/ResultTableSummaryView.tsx b/packages/libs/wdk-client/src/Views/ResultTableSummaryView/ResultTableSummaryView.tsx index 280cf7db13..0d8e46adf2 100644 --- a/packages/libs/wdk-client/src/Views/ResultTableSummaryView/ResultTableSummaryView.tsx +++ b/packages/libs/wdk-client/src/Views/ResultTableSummaryView/ResultTableSummaryView.tsx @@ -48,6 +48,7 @@ interface Props { columnsDialogSearchString?: string; columnsDialogExpandedNodes?: string[]; columnsTree?: CategoryTreeNode; + showCount?: boolean; requestSortingUpdate: RequestSortingUpdate; requestColumnsChoiceUpdate: RequestColumnsChoiceUpdate; requestUpdateBasket: RequestUpdateBasket; @@ -80,6 +81,7 @@ export default function ResultTableSummaryView({ question, userIsGuest, basketStatusArray, + showCount, requestColumnsChoiceUpdate, requestSortingUpdate, requestUpdateBasket, @@ -130,6 +132,7 @@ export default function ResultTableSummaryView({ answer={answer} viewId={viewId} actions={actions} + showCount={showCount} selectedIds={selectedIds} showIdAttributeColumn={showIdAttributeColumn} activeAttributeAnalysisName={activeAttributeAnalysisName} diff --git a/packages/libs/web-common/src/bootstrap.js b/packages/libs/web-common/src/bootstrap.js index 1a038707d6..5ebdeb64ab 100644 --- a/packages/libs/web-common/src/bootstrap.js +++ b/packages/libs/web-common/src/bootstrap.js @@ -16,6 +16,7 @@ import { debounce, identity, uniq, flow } from 'lodash'; // TODO Remove auth_tkt from url before proceeding +import { FetchClient } from '@veupathdb/http-utils'; import { initialize as initializeWdk_ } from '@veupathdb/wdk-client/lib/Core/main'; import * as WdkComponents from '@veupathdb/wdk-client/lib/Components'; import * as WdkControllers from '@veupathdb/wdk-client/lib/Controllers'; @@ -94,6 +95,11 @@ export function initialize(options = {}) { context.store.dispatch(loadSiteConfig(siteConfig)); + // Add non-success response handler for FetchClient instances + FetchClient.setOnNonSuccessResponse((error) => { + context.wdkService.submitError(error); + }); + return context; } diff --git a/packages/libs/web-common/src/components/SiteSearch/SiteSearch.tsx b/packages/libs/web-common/src/components/SiteSearch/SiteSearch.tsx index 933f041f76..2cd26cec9a 100644 --- a/packages/libs/web-common/src/components/SiteSearch/SiteSearch.tsx +++ b/packages/libs/web-common/src/components/SiteSearch/SiteSearch.tsx @@ -77,6 +77,7 @@ import { Link, useHistory } from 'react-router-dom'; import { CellProps, Column } from 'react-table'; import { CommonModal } from '@veupathdb/wdk-client/lib/Components'; import './SiteSearch.scss'; +import { usePermissions } from '@veupathdb/study-data-access/lib/data-restriction/permissionsHooks'; interface Props { loading: boolean; @@ -1416,14 +1417,14 @@ function VariableValueStudyTable(props: { summaryField: SiteSearchDocumentTypeField; }) { const { document, summaryField } = props; - const datasets = useDatasets(); + const permissionsResult = usePermissions(); function makeLink(studyId: string, entityId?: string, variableId?: string) { - if (datasets == null) return ''; - const dataset = datasets?.records.find( - (d) => d.attributes.eda_study_id === studyId - ); - // if (dataset == null) throw new Error("Cannot find dataset with eda_study_id = '" + studyId + "'."); - const base = makeEdaRoute(dataset?.id[0].value) + '/new'; + if (permissionsResult.loading) return ''; + const datasetId = + Object.entries(permissionsResult.permissions.perDataset).find( + ([_datasetId, entry]) => entry?.studyId === studyId + )?.[0] ?? studyId; + const base = makeEdaRoute(datasetId) + '/new'; if (entityId == null) return base; if (variableId == null) return base + `/variables/${entityId}`; return base + `/variables/${entityId}/${variableId}`; @@ -1616,9 +1617,7 @@ const getDatasetsOnce = memoize((wdkService: WdkService) => parameters: {}, }, }, - { - attributes: ['eda_study_id'], - } + {} ) ); @@ -1627,14 +1626,12 @@ function useDatasets() { } function useDatasetId(edaStudyId: string) { - const datasets = useDatasets(); - if (datasets == null) return; - const dataset = datasets.records.find( - (d) => d.attributes.eda_study_id === edaStudyId - ); - // if (dataset == null) throw new Error("Could not find a dataset with eda_study_id = " + edaStudyId); - if (dataset == null) return edaStudyId; - return dataset.id[0].value; + const permissionsResult = usePermissions(); + if (permissionsResult.loading) return; + const datasetId = Object.entries( + permissionsResult.permissions.perDataset + ).find(([_datasetId, entry]) => entry?.studyId === edaStudyId)?.[0]; + return datasetId ?? edaStudyId; } interface ColumnDef { diff --git a/packages/libs/web-common/src/styles/AllSites.scss b/packages/libs/web-common/src/styles/AllSites.scss index 694b37ad8c..2997708bd3 100644 --- a/packages/libs/web-common/src/styles/AllSites.scss +++ b/packages/libs/web-common/src/styles/AllSites.scss @@ -58,7 +58,7 @@ html { .main-stack footer { position: relative; - z-index: 1; + z-index: 0; } ._BodyLayer { diff --git a/packages/libs/web-common/src/util/api.ts b/packages/libs/web-common/src/util/api.ts deleted file mode 100644 index ea054617d2..0000000000 --- a/packages/libs/web-common/src/util/api.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { mapValues, compose } from 'lodash/fp'; -import { - Decoder, - standardErrorReport, -} from '@veupathdb/wdk-client/lib/Utils/Json'; - -/* - * An "Api" is an abstraction for interacting with resources. - * - * There are two primary interfaces: `ApiRequest` and `ApiRequestHandler`. - * - * An `ApiRequest` represents a HTTP-like request for a resource. - * - * An `ApiRequestHandler` represents an implentation that can handle a request. - * Typically this will be based on the `fetch` API. - */ - -/** - * Represents an HTTP-like request for a resource. - */ -export interface ApiRequest { - /** Path to resource, relative to a fixed base url. */ - path: string; - /** Request method for resource. */ - method: string; - /** Body of request */ - body?: any; - /** Headers to add to the request. */ - headers?: Record; - /** Transform response body. This is a good place to do validation. */ - transformResponse: (body: unknown) => Promise; -} - -export interface ApiRequestCreator { - (...args: U): ApiRequest; -} - -export interface ApiRequestsObject { - [Key: string]: ApiRequestCreator; -} - -type ApiRequestToBound> = - R extends ApiRequestCreator - ? (...args: U) => Promise - : never; - -export type BoundApiRequestsObject = { - [P in keyof T]: T[P] extends ApiRequestCreator - ? (...args: B) => Promise - : never; -}; - -export function bindApiRequestCreators( - requestCreators: T, - handler: ApiRequestHandler -): BoundApiRequestsObject { - return mapValues( - (requestCreator) => compose(handler, requestCreator), - requestCreators - ) as BoundApiRequestsObject; -} - -// XXX Not sure if these belong here, since they are specific to an ApiRequestHandler - -/** Helper to create a request with a JSON body. */ -export function createJsonRequest(init: ApiRequest): ApiRequest { - return { - ...init, - body: JSON.stringify(init.body), - headers: { - ...init.headers, - 'Content-Type': 'application/json', - }, - }; -} - -/** Helper to create a request with a plain text body. */ -export function createPlainTextRequest(init: ApiRequest): ApiRequest { - return { - ...init, - headers: { - ...init.headers, - 'Content-Type': 'text/plain', - }, - }; -} - -/** Standard transformer that uses a `Json.ts` `decoder` type. */ -export function standardTransformer(decoder: Decoder) { - return async function transform(body: unknown): Promise { - const result = decoder(body); - if (result.status === 'ok') return result.value; - const report = `Expected ${result.expected}${ - result.context ? 'at _' + result.context : '' - }, but got ${JSON.stringify(result.value)}.`; - throw new Error('Could not decode response.\n' + report); - }; -} - -/** - * A function that takes an `ApiRequest` and returns a `Promise`. - */ -export interface ApiRequestHandler { - (request: ApiRequest): Promise; -} - -/** - * Options for a `fetch`-based request handler. - */ -export interface FetchApiOptions { - /** Base url for service endpoint. */ - baseUrl: string; - /** Global optoins for all requests. */ - init?: RequestInit; - /** Implementation of `fetch` function. Defaults to `window.fetch`. */ - fetchApi?: Window['fetch']; -} - -/** - * A `fetch`-based implentation of an `ApiRequestHandler`. - */ -export function createFetchApiRequestHandler( - options: FetchApiOptions -): ApiRequestHandler { - const { baseUrl, init = {}, fetchApi = window.fetch } = options; - return async function fetchApiRequestHandler( - apiRequest: ApiRequest - ): Promise { - const { transformResponse, path, body, ...restReq } = apiRequest; - const request = new Request(baseUrl + path, { - ...init, - ...restReq, - body: body, - headers: { - ...restReq.headers, - ...init.headers, - }, - }); - const response = await fetchApi(request); - // TODO Make this behavior configurable - if (response.ok) { - const responseBody = await fetchResponseBody(response); - - return await transformResponse(responseBody); - } - throw new Error( - `${response.status} ${response.statusText}${'\n'}${await response.text()}` - ); - }; -} - -export abstract class FetchClient { - protected readonly baseUrl: string; - protected readonly init: RequestInit; - protected readonly fetchApi: Window['fetch']; - - constructor(options: FetchApiOptions) { - this.baseUrl = options.baseUrl; - this.init = options.init ?? {}; - this.fetchApi = options.fetchApi ?? window.fetch; - } - - protected async fetch(apiRequest: ApiRequest): Promise { - const { baseUrl, init, fetchApi } = this; - const { transformResponse, path, body, ...restReq } = apiRequest; - const request = new Request(baseUrl + path, { - ...init, - ...restReq, - body: body, - headers: { - ...restReq.headers, - ...init.headers, - }, - }); - const response = await fetchApi(request); - // TODO Make this behavior configurable - if (response.ok) { - const responseBody = await fetchResponseBody(response); - - return await transformResponse(responseBody); - } - throw new Error( - `${response.status} ${response.statusText}${'\n'}${await response.text()}` - ); - } -} - -async function fetchResponseBody(response: Response) { - const contentType = response.headers.get('Content-Type'); - - return contentType == null - ? undefined - : contentType.startsWith('application/json') - ? response.json() - : response.text(); -} diff --git a/packages/libs/web-common/src/wrapWdkService.js b/packages/libs/web-common/src/wrapWdkService.js index 81207d6946..6b5a33ff6d 100644 --- a/packages/libs/web-common/src/wrapWdkService.js +++ b/packages/libs/web-common/src/wrapWdkService.js @@ -1,10 +1,9 @@ -import { useEda } from './config'; import { ok } from '@veupathdb/wdk-client/lib/Utils/Json'; export default (wdkService) => ({ ...wdkService, - async getStudies(attributes, tables = []) { - const datasets = await wdkService.sendRequest(ok, { + getStudies(attributes, tables = []) { + return wdkService.sendRequest(ok, { useCache: true, cacheId: 'studies', method: 'post', @@ -24,15 +23,6 @@ export default (wdkService) => ({ }, }), }); - - if (useEda) { - // TODO Mark non-eda studies as prerelease, instead of removing - datasets.records = datasets.records.filter( - (record) => record.attributes.eda_study_id != null - ); - } - - return datasets; }, getSiteMessages: () => wdkService.sendRequest(ok, { diff --git a/packages/sites/clinepi-site/package.json b/packages/sites/clinepi-site/package.json index b25056f5a0..d7ddfb01a6 100644 --- a/packages/sites/clinepi-site/package.json +++ b/packages/sites/clinepi-site/package.json @@ -8,6 +8,7 @@ "clean": "rm -rf dist && mkdir dist", "copy:webapp": "cp -r webapp/* dist", "copy:images": "cp -r ../../../node_modules/@veupathdb/web-common/images dist", + "compile:check": "tsc --noEmit", "bundle:dev": "npm-run-all clean copy:webapp copy:images && BROWSERSLIST_ENV=modern webpack --mode=development", "bundle:npm": "npm-run-all clean copy:webapp copy:images && BROWSERSLIST_ENV=modern webpack --mode=production && BROWSERSLIST_ENV=legacy webpack --mode=production" }, diff --git a/packages/sites/genomics-site/package.json b/packages/sites/genomics-site/package.json index a811a8e1ae..376369bc26 100644 --- a/packages/sites/genomics-site/package.json +++ b/packages/sites/genomics-site/package.json @@ -8,6 +8,7 @@ "clean": "rm -rf dist && mkdir dist", "copy:webapp": "cp -r webapp/* dist", "copy:images": "cp -r ../../../node_modules/@veupathdb/web-common/images dist", + "compile:check": "tsc --noEmit", "bundle:dev": "npm-run-all clean copy:webapp copy:images && BROWSERSLIST_ENV=modern webpack --mode=development", "bundle:npm": "npm-run-all clean copy:webapp copy:images && BROWSERSLIST_ENV=modern webpack --mode=production && BROWSERSLIST_ENV=legacy webpack --mode=production" }, diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/Downloads.scss b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/Downloads.scss index ff4d2384ea..bdb2477c50 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/Downloads.scss +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/Downloads.scss @@ -10,13 +10,26 @@ } &-Filter-Container { - outline: 2px solid #acacac; - margin: 2em auto 3em; - width: 60vw; - border-radius: 8px; - // height: 25em; + .wdk-QuestionFormParameterList { + width: 60vw; + margin: auto; + } + + .wdk-QuestionFormParameterHeading h2 { + font-size: 1.3em; + } + + .OrganismParam { + max-width: 47em; + } .filter-param { + outline: 2px solid #acacac; + // margin: 2em auto 3em; + // width: 60vw; + border-radius: 8px; + // height: 25em; + h3 { padding: 0.25em 0; } @@ -64,6 +77,10 @@ } .TableToolbar { + order: -3; + } + + .TableToolbar-Children { display: none; } diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/Downloads.tsx b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/Downloads.tsx index 57684001a9..80466313b0 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/Downloads.tsx +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/Downloads.tsx @@ -1,7 +1,14 @@ -import React, { useMemo, useState } from 'react'; -import { useHistory, useLocation, useRouteMatch } from 'react-router'; +import Banner from '@veupathdb/coreui/lib/components/banners/Banner'; import { parseQueryString } from '@veupathdb/wdk-client/lib/Core/RouteEntry'; +import { RootState } from '@veupathdb/wdk-client/lib/Core/State/Types'; +import { useWdkDependenciesEffect } from '@veupathdb/wdk-client/lib/Hooks/WdkDependenciesEffect'; +import { updateLastParamValues } from '@veupathdb/wdk-client/lib/StoreModules/QuestionStoreModule'; import { SearchConfig } from '@veupathdb/wdk-client/lib/Utils/WdkModel'; +import { isParamValueValid } from '@veupathdb/wdk-client/lib/Views/Question/Params'; +import { isEqual } from 'lodash'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { useHistory, useLocation, useRouteMatch } from 'react-router'; import { DownloadsFilter } from './DownloadsFilter'; import { DownloadsTable } from './DownloadsTable'; import './Downloads.scss'; @@ -11,7 +18,6 @@ const TABLE_QUESTION_NAME = 'GetAllFileRecords'; const BULK_QUESTION_NAME = 'GetFileRecordsByID'; export function Downloads() { - const [searchConfig, setSearchConfig] = useState(); const location = useLocation(); const history = useHistory(); const match = useRouteMatch(); @@ -20,6 +26,43 @@ export function Downloads() { [history, location, match] ); + const { searchConfig, isValid } = useSelector( + (state: RootState) => { + const questionState = state.question.questions[TABLE_QUESTION_NAME]; + const searchConfig: SearchConfig | undefined = + questionState?.paramValues && { + parameters: questionState.paramValues, + }; + const isValid = questionState?.paramValues + ? questionState.question.parameters.every((parameter) => + isParamValueValid( + { + searchName: TABLE_QUESTION_NAME, + paramValues: questionState?.paramValues, + parameter, + }, + questionState.paramUIState[parameter.name] + ) + ) + : true; + return { searchConfig, isValid }; + }, + (left, right) => isEqual(left, right) + ); + + useWdkDependenciesEffect( + ({ paramValueStore }) => { + if (searchConfig == null || !isValid) return; + updateLastParamValues( + paramValueStore, + TABLE_QUESTION_NAME, + searchConfig?.parameters, + undefined + ); + }, + [searchConfig, isValid] + ); + return (

- {searchConfig && ( + {!isValid ? ( + + ) : searchConfig ? ( - )} + ) : null}
); } diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/DownloadsFilter.tsx b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/DownloadsFilter.tsx index 9602b6c152..621253c53c 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/DownloadsFilter.tsx +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/DownloadsFilter.tsx @@ -1,16 +1,16 @@ +import { mapValues } from 'lodash'; +import React, { useMemo } from 'react'; import { SubmissionMetadata } from '@veupathdb/wdk-client/lib/Actions/QuestionActions'; import { QuestionController } from '@veupathdb/wdk-client/lib/Controllers'; -import { RootState } from '@veupathdb/wdk-client/lib/Core/State/Types'; -import { SearchConfig } from '@veupathdb/wdk-client/lib/Utils/WdkModel'; -import { Props as FormProps } from '@veupathdb/wdk-client/lib/Views/Question/DefaultQuestionForm'; -import React, { useEffect } from 'react'; -import { useSelector } from 'react-redux'; +import { + Props as FormProps, + renderDefaultParamGroup, +} from '@veupathdb/wdk-client/lib/Views/Question/DefaultQuestionForm'; interface Props { recordName: string; questionName: string; initialParamData: Record; - onChange: (searchConfig: SearchConfig) => void; } const submissionMetadata: SubmissionMetadata = { @@ -19,17 +19,7 @@ const submissionMetadata: SubmissionMetadata = { }; export function DownloadsFilter(props: Props) { - const { recordName, questionName, initialParamData, onChange } = props; - const paramValues = useSelector( - (state: RootState) => state.question.questions[questionName]?.paramValues - ); - useEffect(() => { - if (paramValues) { - onChange({ - parameters: paramValues, - }); - } - }, [paramValues, onChange]); + const { recordName, questionName, initialParamData } = props; return ( ); } function FormComponent(props: FormProps) { - const { parameterElements } = props; - return <>{Object.values(parameterElements)}; + const { state } = props; + // Need to add `isSearchPage` prop so that organism prefs are used + const parameterElements = useMemo( + () => + mapValues(props.parameterElements, (parameterElement) => { + return React.isValidElement(parameterElement) + ? React.cloneElement( + parameterElement, + { + pluginProps: { + ...parameterElement.props.pluginProps, + isSearchPage: true, + }, + } as any, + parameterElement.props.chilren + ) + : parameterElement; + }), + [props.parameterElements] + ); + + const updatedProps = useMemo( + () => ({ ...props, parameterElements }), + [props, parameterElements] + ); + + return ( + <> + {state.question.groups + .filter((group) => group.displayType !== 'hidden') + .map((group) => renderDefaultParamGroup(group, updatedProps))} + + ); } diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/DownloadsTable.tsx b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/DownloadsTable.tsx index d561252369..3057342bca 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/DownloadsTable.tsx +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/Downloads/DownloadsTable.tsx @@ -18,6 +18,8 @@ interface Props { bulkSearchName: string; } +const R = ResultTableSummaryViewController.withOptions({ showCount: true }); + export function DownloadsTable(props: Props) { const { searchConfig, tableSearchName, bulkSearchName } = props; const { wdkService } = useNonNullableContext(WdkDependenciesContext); @@ -78,11 +80,13 @@ export function DownloadsTable(props: Props) { }, [bulkSearchName, wdkService]); return ( - + <> + + ); } diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/homepage/VEuPathDBHomePage.tsx b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/homepage/VEuPathDBHomePage.tsx index 36b94b295e..6f7e40b0a8 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/homepage/VEuPathDBHomePage.tsx +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/components/homepage/VEuPathDBHomePage.tsx @@ -6,7 +6,7 @@ import React, { useState, useMemo, } from 'react'; -import { connect } from 'react-redux'; +import { connect, useSelector } from 'react-redux'; import { get, memoize } from 'lodash'; @@ -24,6 +24,7 @@ import { RootState } from '@veupathdb/wdk-client/lib/Core/State/Types'; import { useSessionBackedState } from '@veupathdb/wdk-client/lib/Hooks/SessionBackedState'; import { CategoryTreeNode } from '@veupathdb/wdk-client/lib/Utils/CategoryUtils'; import { arrayOf, decode, string } from '@veupathdb/wdk-client/lib/Utils/Json'; +import { useStudyAccessApi } from '@veupathdb/study-data-access/lib/study-access/studyAccessHooks'; import Announcements from '@veupathdb/web-common/lib/components/Announcements'; import CookieBanner from '@veupathdb/web-common/lib/components/CookieBanner'; @@ -45,11 +46,14 @@ import { import { useUserDatasetsWorkspace, useEda, + edaServiceUrl, } from '@veupathdb/web-common/lib/config'; import { useAnnouncementsState } from '@veupathdb/web-common/lib/hooks/announcements'; import { useCommunitySiteRootUrl } from '@veupathdb/web-common/lib/hooks/staticData'; import { STATIC_ROUTE_PATH } from '@veupathdb/web-common/lib/routes'; import { formatReleaseDate } from '@veupathdb/web-common/lib/util/formatters'; +import { useWdkStudyRecords } from '@veupathdb/eda/lib/core/hooks/study'; +import { getWdkStudyRecords } from '@veupathdb/eda/lib/core/utils/study-records'; import { PreferredOrganismsSummary } from '@veupathdb/preferred-organisms/lib/components/PreferredOrganismsSummary'; @@ -60,7 +64,12 @@ import { makeVpdbClassNameHelper } from './Utils'; import './VEuPathDBHomePage.scss'; import { makeClassNameHelper } from '@veupathdb/wdk-client/lib/Utils/ComponentUtils'; +import SubsettingClient from '@veupathdb/eda/lib/core/api/SubsettingClient'; +import { WdkDependenciesContext } from '@veupathdb/wdk-client/lib/Hooks/WdkDependenciesEffect'; +import { useNonNullableContext } from '@veupathdb/wdk-client/lib/Hooks/NonNullableContext'; import { useWdkService } from '@veupathdb/wdk-client/lib/Hooks/WdkServiceHook'; +import { Question } from '@veupathdb/wdk-client/lib/Utils/WdkModel'; +import { StudyRecord } from '@veupathdb/eda/lib/core/types/study'; import { Warning } from '@veupathdb/coreui'; const vpdbCx = makeVpdbClassNameHelper(''); @@ -321,6 +330,10 @@ const VEuPathDB = 'VEuPathDB'; const UniDB = 'UniDB'; const DB = 'DB'; +// TODO Update this const once we know the question name to use. +// const QUESTION_FOR_MAP_DATASETS = 'DatasetsForMapMenu'; +const QUESTION_FOR_MAP_DATASETS = 'AllDatasets'; + function makeStaticPageRoute(subPath: string) { return `${STATIC_ROUTE_PATH}${subPath}`; } @@ -350,49 +363,13 @@ const useHeaderMenuItems = ( const alphabetizedSearchTree = useAlphabetizedSearchTree(searchTree); const communitySite = useCommunitySiteRootUrl(); - const showInteractiveMaps = Boolean(useEda && projectId === 'VectorBase'); - - const mapMenuItems = useWdkService( - async (wdkService): Promise => { - if (!showInteractiveMaps) return []; - try { - const anwser = await wdkService.getAnswerJson( - { - searchName: 'AllDatasets', - searchConfig: { - parameters: {}, - }, - }, - { - attributes: ['eda_study_id'], - } - ); - return anwser.records - .filter((record) => record.attributes.eda_study_id != null) - .map((record) => ({ - key: `map-${record.id[0].value}`, - display: record.displayName, - type: 'reactRoute', - url: `/workspace/maps/${record.id[0].value}/new`, - target: '_blank', - })); - } catch (error) { - console.error(error); - return [ - { - key: 'maps-error', - display: ( - <> - Could not load map data - - ), - type: 'custom', - }, - ]; - } - }, - [showInteractiveMaps] + const mapMenuItemsQuestion = useSelector((state: RootState) => + state.globalData.questions?.find( + (q) => q.urlSegment === QUESTION_FOR_MAP_DATASETS + ) ); + const showInteractiveMaps = mapMenuItemsQuestion != null; + const mapMenuItems = useMapMenuItems(mapMenuItemsQuestion); // type: reactRoute, webAppRoute, externalLink, subMenu, custom const fullMenuItemEntries: HeaderMenuItemEntry[] = [ @@ -1192,3 +1169,47 @@ const VEuPathDBSnackbarProvider = makeSnackbarProvider( export const VEuPathDBHomePage = connect(mapStateToProps)( VEuPathDBHomePageView ); + +function useMapMenuItems(question?: Question) { + const { wdkService } = useNonNullableContext(WdkDependenciesContext); + const studyAccessApi = useStudyAccessApi(); + const subsettingClient = useMemo( + () => new SubsettingClient({ baseUrl: edaServiceUrl }, wdkService), + [wdkService] + ); + const [mapMenuItems, setMapMenuItems] = useState(); + useEffect(() => { + if (question == null) return; + getWdkStudyRecords( + { studyAccessApi, subsettingClient, wdkService }, + { searchName: question.urlSegment } + ).then( + (records) => { + const menuItems = records.map( + (record): HeaderMenuItem => ({ + key: `map-${record.id[0].value}`, + display: record.displayName, + type: 'reactRoute', + url: `/workspace/maps/${record.id[0].value}/new`, + }) + ); + setMapMenuItems(menuItems); + }, + (error) => { + console.error(error); + setMapMenuItems([ + { + key: 'map-error', + type: 'custom', + display: ( + <> + Unable to load map datasets. + + ), + }, + ]); + } + ); + }, [question, studyAccessApi, subsettingClient, wdkService]); + return mapMenuItems; +} diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/record-page-new-feature.scss b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/record-page-new-feature.scss index a22ca1e786..3cd2d530d3 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/record-page-new-feature.scss +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/record-page-new-feature.scss @@ -1,6 +1,4 @@ // This CSS adds the NEW icon next to the attribute name on record pages -#alphafold_url, -#AlphaFoldLinkouts, #Cellxgene { .wdk-CollapsibleSectionHeader:after { content: url('~@veupathdb/wdk-client/lib/Core/Style/images/new-feature.png'); diff --git a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/styles/home-page-layout.scss b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/styles/home-page-layout.scss index bf1ed39d0a..53f3ae55ee 100644 --- a/packages/sites/genomics-site/webapp/wdkCustomization/js/client/styles/home-page-layout.scss +++ b/packages/sites/genomics-site/webapp/wdkCustomization/js/client/styles/home-page-layout.scss @@ -55,6 +55,7 @@ body.vpdb-Body .vpdb-RootContainer { position: fixed; bottom: 0; width: 100%; + z-index: 1; } .vpdb-Footer__thin { diff --git a/packages/sites/mbio-site/package.json b/packages/sites/mbio-site/package.json index 17d044c693..47a898421c 100644 --- a/packages/sites/mbio-site/package.json +++ b/packages/sites/mbio-site/package.json @@ -8,6 +8,7 @@ "clean": "rm -rf dist && mkdir dist", "copy:webapp": "cp -r webapp/* dist", "copy:images": "cp -r ../../../node_modules/@veupathdb/web-common/images dist", + "compile:check": "tsc --noEmit", "bundle:dev": "npm-run-all clean copy:webapp copy:images && BROWSERSLIST_ENV=modern webpack --mode=development", "bundle:npm": "npm-run-all clean copy:webapp copy:images && BROWSERSLIST_ENV=modern webpack --mode=production && BROWSERSLIST_ENV=legacy webpack --mode=production" }, diff --git a/packages/sites/ortho-site/package.json b/packages/sites/ortho-site/package.json index f1c3b6c283..ad20423e94 100644 --- a/packages/sites/ortho-site/package.json +++ b/packages/sites/ortho-site/package.json @@ -9,6 +9,7 @@ "clean": "rm -rf dist && mkdir dist", "copy:webapp": "cp -r webapp/* dist", "copy:images": "cp -r ../../../node_modules/@veupathdb/web-common/images dist", + "compile:check": "tsc --noEmit", "bundle:dev": "npm-run-all clean copy:webapp copy:images && BROWSERSLIST_ENV=modern webpack --mode=development", "bundle:npm": "npm-run-all clean copy:webapp copy:images && BROWSERSLIST_ENV=modern webpack --mode=production && BROWSERSLIST_ENV=legacy webpack --mode=production" }, diff --git a/yarn.lock b/yarn.lock index e2ce4e4e63..bff2e0e8ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6272,6 +6272,13 @@ __metadata: languageName: node linkType: hard +"@types/d3-array@npm:3.0.3": + version: 3.0.3 + resolution: "@types/d3-array@npm:3.0.3" + checksum: 972c8ff009c97c9093883d7597aac7185d4e8c2832d7f25420d8312aa1ebd60182f85fe768615cbc9d73d8819add52eea7ce640e57f5b1668ef389f1dc9569bc + languageName: node + linkType: hard + "@types/d3-axis@npm:*": version: 3.0.2 resolution: "@types/d3-axis@npm:3.0.2" @@ -6297,7 +6304,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-color@npm:*": +"@types/d3-color@npm:*, @types/d3-color@npm:3.1.0": version: 3.1.0 resolution: "@types/d3-color@npm:3.1.0" checksum: b1856f17d6366559a68eaba0164f30727e9dc5eaf1b3a6c8844354da228860240423d19fa4de65bff9da26b4ead8843eab14b1566962665412e8fd82c3810554 @@ -6321,7 +6328,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-delaunay@npm:*": +"@types/d3-delaunay@npm:*, @types/d3-delaunay@npm:6.0.1": version: 6.0.1 resolution: "@types/d3-delaunay@npm:6.0.1" checksum: c46fd6f399ed604e9f40841851c432c936c4408af9e61b235a7f6030e93faa9b5c4f6c33a62be221e1d33f48a9162e9c4bbfa173746c0e4787483310da9a03b2 @@ -6374,7 +6381,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-format@npm:*": +"@types/d3-format@npm:*, @types/d3-format@npm:3.0.1": version: 3.0.1 resolution: "@types/d3-format@npm:3.0.1" checksum: 6819fae7e7c3fce1e44cd56e9b6d8ea5508f708cb7a42793edf82ff914b120bf6cddc35208b1b33ec82615ab82b843c284709f9cea5c3fe1ea5f012106b3d32c @@ -6413,7 +6420,7 @@ __metadata: languageName: node linkType: hard -"@types/d3-interpolate@npm:*, @types/d3-interpolate@npm:^3.0.1": +"@types/d3-interpolate@npm:*, @types/d3-interpolate@npm:3.0.1, @types/d3-interpolate@npm:^3.0.1": version: 3.0.1 resolution: "@types/d3-interpolate@npm:3.0.1" dependencies: @@ -6489,6 +6496,15 @@ __metadata: languageName: node linkType: hard +"@types/d3-scale@npm:4.0.2": + version: 4.0.2 + resolution: "@types/d3-scale@npm:4.0.2" + dependencies: + "@types/d3-time": "*" + checksum: 6b3c0337f38f82b582d9f3190fde82edfce7ffafb371e5b2464c443137c2660bac644099d259f1f5cc829085eb5688bed0bea2b336a957d0845433bd07bf2ddd + languageName: node + linkType: hard + "@types/d3-scale@npm:^3.3.0": version: 3.3.2 resolution: "@types/d3-scale@npm:3.3.2" @@ -6530,7 +6546,14 @@ __metadata: languageName: node linkType: hard -"@types/d3-time@npm:*": +"@types/d3-time-format@npm:2.1.0": + version: 2.1.0 + resolution: "@types/d3-time-format@npm:2.1.0" + checksum: 8493cc4676d7656becf0c1195abb1213827e4cf42a85849c5e0159fa89b1b80c5ff6ad8af54e54d0e8c76815b866adf68a948da9b966405bd12c2070f57a7efe + languageName: node + linkType: hard + +"@types/d3-time@npm:*, @types/d3-time@npm:3.0.0": version: 3.0.0 resolution: "@types/d3-time@npm:3.0.0" checksum: e76adb056daccf80107f4db190ac6deb77e8774f00362bb6c76f178e67f2f217422fe502b654edbc9ac6451f6619045b9f6f5fe0db1ec5520e2ada377af7c72e @@ -8066,7 +8089,7 @@ __metadata: "@visx/text": ^1.3.0 "@visx/tooltip": ^1.3.0 "@visx/visx": ^1.1.0 - "@visx/xychart": ^3.1.0 + "@visx/xychart": "https://github.com/jernestmyers/visx.git#visx-xychart" babel-loader: ^8.3.0 bootstrap: ^4.5.2 color-math: ^1.1.3 @@ -8955,20 +8978,20 @@ __metadata: languageName: node linkType: hard -"@visx/annotation@npm:3.0.1": - version: 3.0.1 - resolution: "@visx/annotation@npm:3.0.1" +"@visx/annotation@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/annotation@npm:3.3.0" dependencies: "@types/react": "*" - "@visx/drag": 3.0.1 - "@visx/group": 3.0.0 - "@visx/text": 3.0.0 + "@visx/drag": 3.3.0 + "@visx/group": 3.3.0 + "@visx/text": 3.3.0 classnames: ^2.3.1 prop-types: ^15.5.10 react-use-measure: ^2.0.4 peerDependencies: react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 - checksum: e1a3016b8b00c843b56a41c0b07c5509ea195948408c856f09fd2db3268fbc4641f1e6adf0022e60a042cd25d7f0334e7ac6128a367c92a27e42770573eef5f6 + checksum: 1b073c7e4608c03f3200eb17dee325ad4ced21292171c7b774001e94f61e02237fbbbda96abf24de453230e4cb2209571e8fffe7bd9270cda58511389efc9ada languageName: node linkType: hard @@ -8990,7 +9013,25 @@ __metadata: languageName: node linkType: hard -"@visx/axis@npm:3.1.0, @visx/axis@npm:^3.1.0": +"@visx/axis@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/axis@npm:3.3.0" + dependencies: + "@types/react": "*" + "@visx/group": 3.3.0 + "@visx/point": 3.3.0 + "@visx/scale": 3.3.0 + "@visx/shape": 3.3.0 + "@visx/text": 3.3.0 + classnames: ^2.3.1 + prop-types: ^15.6.0 + peerDependencies: + react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 + checksum: b82d5017ca01c23b0f5c3dbf23d99ea28dcfd37bac8f15d0397d6ba0ac3d569dd935f7cc90588a6c1c1bee185af444539ecde1d653484c0922131f7ab43c5928 + languageName: node + linkType: hard + +"@visx/axis@npm:^3.1.0": version: 3.1.0 resolution: "@visx/axis@npm:3.1.0" dependencies: @@ -9022,9 +9063,9 @@ __metadata: languageName: node linkType: hard -"@visx/bounds@npm:3.0.0": - version: 3.0.0 - resolution: "@visx/bounds@npm:3.0.0" +"@visx/bounds@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/bounds@npm:3.3.0" dependencies: "@types/react": "*" "@types/react-dom": "*" @@ -9032,7 +9073,7 @@ __metadata: peerDependencies: react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 react-dom: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 - checksum: 3c50dad1c78b526bebe20a690360ae5ad3dd2a31c86150553071671e49309a9fcf3bca3303eab7f7ac2e28be8d7987ac9ce5daa28b8df32c647b0aa97f118fa9 + checksum: e682b343735f53d7eed04cc9e4bc40308583724faa0577f752449d59daea57030f05cb72a9711d7e7f46db932691fea6bb7a545b204b4df24b80ffff0ed04e78 languageName: node linkType: hard @@ -9084,6 +9125,16 @@ __metadata: languageName: node linkType: hard +"@visx/curve@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/curve@npm:3.3.0" + dependencies: + "@types/d3-shape": ^1.3.1 + d3-shape: ^1.0.6 + checksum: 36db95911c567408de78851eee6d98b60065c9d07b5abc7652cef072eaf5040444d8d84e73255f91bca0cfb50837f3b47db7209183211c20bc4bc1adcf13794d + languageName: node + linkType: hard + "@visx/drag@npm:1.18.1": version: 1.18.1 resolution: "@visx/drag@npm:1.18.1" @@ -9097,17 +9148,17 @@ __metadata: languageName: node linkType: hard -"@visx/drag@npm:3.0.1": - version: 3.0.1 - resolution: "@visx/drag@npm:3.0.1" +"@visx/drag@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/drag@npm:3.3.0" dependencies: "@types/react": "*" - "@visx/event": 3.0.1 - "@visx/point": 3.0.1 + "@visx/event": 3.3.0 + "@visx/point": 3.3.0 prop-types: ^15.5.10 peerDependencies: react: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 - checksum: 256e4a7b649e29b2112f77a57a657cd3aedb76b605ebc8b49e6c3d4b152fea5d81de7820060dc569dda0c81a0e7b6668ce9fe071536198b26cf2ebd655a6e6a9 + checksum: 98a352249e4009e6de83bb229d3093706766d205eaf21b3d330a087d4b310e603b25759836645a605f1b7ba462cc3fd7e2d94c65ea40752d3d4f72fa4da27d9c languageName: node linkType: hard @@ -9121,13 +9172,13 @@ __metadata: languageName: node linkType: hard -"@visx/event@npm:3.0.1": - version: 3.0.1 - resolution: "@visx/event@npm:3.0.1" +"@visx/event@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/event@npm:3.3.0" dependencies: "@types/react": "*" - "@visx/point": 3.0.1 - checksum: 0cb4dd578bbe54bd428a0cfae9e64db141b039c2c6c1412d1cd1d04e1613d193212b031124948ca3b7eed877cdea87d161cc3a008b973c6f217e21a8a2dd27b0 + "@visx/point": 3.3.0 + checksum: 3cdb86848d9ae7cc8b6a14a0cc4efd7fc2186fca582ee374bb1f2dfe104ec5cd04dbdbbfc41ccb8c94e1885e2b6e6060a488f3a0a0554ad9e831ecc1b28339de languageName: node linkType: hard @@ -9164,19 +9215,19 @@ __metadata: languageName: node linkType: hard -"@visx/glyph@npm:3.0.0": - version: 3.0.0 - resolution: "@visx/glyph@npm:3.0.0" +"@visx/glyph@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/glyph@npm:3.3.0" dependencies: "@types/d3-shape": ^1.3.1 "@types/react": "*" - "@visx/group": 3.0.0 + "@visx/group": 3.3.0 classnames: ^2.3.1 d3-shape: ^1.2.0 prop-types: ^15.6.2 peerDependencies: react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 - checksum: 0a944f2594c519e66e66dbc806e9ff2e53bbc64bfd8c2106420d97f0c47824d22b6e733f7bed3591dd94fb00b32ead68c34fd3d342fc42968182d511ac9996c5 + checksum: d9218f3a9ea410c43c7a6f3831deda456e7c746f7b3ed883ec6cf4e2ebe7a20d7f89b617c4782564d88c5c5e05e33184132735dafc7ae19ab8f974fc9a90dd50 languageName: node linkType: hard @@ -9210,21 +9261,21 @@ __metadata: languageName: node linkType: hard -"@visx/grid@npm:3.0.1": - version: 3.0.1 - resolution: "@visx/grid@npm:3.0.1" +"@visx/grid@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/grid@npm:3.3.0" dependencies: "@types/react": "*" - "@visx/curve": 3.0.0 - "@visx/group": 3.0.0 - "@visx/point": 3.0.1 - "@visx/scale": 3.0.0 - "@visx/shape": 3.0.0 + "@visx/curve": 3.3.0 + "@visx/group": 3.3.0 + "@visx/point": 3.3.0 + "@visx/scale": 3.3.0 + "@visx/shape": 3.3.0 classnames: ^2.3.1 prop-types: ^15.6.2 peerDependencies: react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 - checksum: 2ff960b180f0ac1960170f3628a49667f3da58751242638af7844eb49a30273e52bba380103c14bb5b4829ca4bef0c072a8a78696abf2b41d4a49fcdbc02a263 + checksum: 9265ab7c0c9ca9caddbe2c4413ef8e2641a181f6e3a72492d940c3c4e62d28be8e3da52c5f0d723f9921ac455e653414cb8b660af7014af0371bf0d60387fb6e languageName: node linkType: hard @@ -9254,6 +9305,19 @@ __metadata: languageName: node linkType: hard +"@visx/group@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/group@npm:3.3.0" + dependencies: + "@types/react": "*" + classnames: ^2.3.1 + prop-types: ^15.6.2 + peerDependencies: + react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + checksum: a2f950664f43f1f1700a329f3a66bc3921ce8be35a93095a9ea28334ae4f9943c1082f21ba0f57f2bda913d2a40da8b7124235c2ae666c0e5d58a900c7148a8b + languageName: node + linkType: hard + "@visx/heatmap@npm:1.17.1": version: 1.17.1 resolution: "@visx/heatmap@npm:1.17.1" @@ -9365,6 +9429,13 @@ __metadata: languageName: node linkType: hard +"@visx/point@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/point@npm:3.3.0" + checksum: 79950f49828d12c67c856ac0b345292df03a154b50f739aab3dbab37abb73ba40f9cbdaa46d698b50691984e81b94afdafe64dd4fa18c6d2895298cf25f1a450 + languageName: node + linkType: hard + "@visx/react-spring@npm:1.17.1": version: 1.17.1 resolution: "@visx/react-spring@npm:1.17.1" @@ -9383,21 +9454,21 @@ __metadata: languageName: node linkType: hard -"@visx/react-spring@npm:3.1.0": - version: 3.1.0 - resolution: "@visx/react-spring@npm:3.1.0" +"@visx/react-spring@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/react-spring@npm:3.3.0" dependencies: "@types/react": "*" - "@visx/axis": 3.1.0 - "@visx/grid": 3.0.1 - "@visx/scale": 3.0.0 - "@visx/text": 3.0.0 + "@visx/axis": 3.3.0 + "@visx/grid": 3.3.0 + "@visx/scale": 3.3.0 + "@visx/text": 3.3.0 classnames: ^2.3.1 prop-types: ^15.6.2 peerDependencies: "@react-spring/web": ^9.4.5 react: ^16.3.0-0 || ^17.0.0 || ^18.0.0 - checksum: 5d727dd8053c101d6473580feba5b8c05d70fa6311a0dc2f9c1d420c6fce4ce497f7f191b89bb10c53eedd986aa3006cd7a7f389035c02a2a847f8c3c38fcafb + checksum: 07693c055ed6de780e002f9060cad416017030a784078edd3ccbb6c377a671b2cbc18688861abcd6b41984df4721975c5df9c5a8a6dcf561c7110e6e4faebf34 languageName: node linkType: hard @@ -9416,9 +9487,9 @@ __metadata: languageName: node linkType: hard -"@visx/responsive@npm:3.0.0": - version: 3.0.0 - resolution: "@visx/responsive@npm:3.0.0" +"@visx/responsive@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/responsive@npm:3.3.0" dependencies: "@types/lodash": ^4.14.172 "@types/react": "*" @@ -9426,7 +9497,7 @@ __metadata: prop-types: ^15.6.1 peerDependencies: react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 - checksum: 0a547c7cec9f2f61931d1de6ee8955b76904ea0bcc1c55d18d991067f63579c6ca1d1b976a0ada21d3b3818551b3d5749d8b11ab50dc472f38bd94f21b56b8ec + checksum: 8f23d09bdb66d1deb35a393ff731438e8f9329a0dba89cffe00cc7aadd7ffd6af021eed1452f91a8cc0566b1f025b59d32f060f9070ef2bc66c7a41f8a06c9fb languageName: node linkType: hard @@ -9458,6 +9529,15 @@ __metadata: languageName: node linkType: hard +"@visx/scale@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/scale@npm:3.3.0" + dependencies: + "@visx/vendor": 3.3.0 + checksum: a6a5589fc86c72df84d8d11bab255fa3bf0481ca13ff15c66228368afae60937bb0553d942202b007a5d50b93dfc62359b06fd53c00da0eeb472db09d662440d + languageName: node + linkType: hard + "@visx/shape@npm:1.17.1, @visx/shape@npm:^1.4.0": version: 1.17.1 resolution: "@visx/shape@npm:1.17.1" @@ -9502,6 +9582,28 @@ __metadata: languageName: node linkType: hard +"@visx/shape@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/shape@npm:3.3.0" + dependencies: + "@types/d3-path": ^1.0.8 + "@types/d3-shape": ^1.3.1 + "@types/lodash": ^4.14.172 + "@types/react": "*" + "@visx/curve": 3.3.0 + "@visx/group": 3.3.0 + "@visx/scale": 3.3.0 + classnames: ^2.3.1 + d3-path: ^1.0.5 + d3-shape: ^1.2.0 + lodash: ^4.17.21 + prop-types: ^15.5.10 + peerDependencies: + react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 + checksum: ab53910f6a2b5c2a36bfceed92ebafaf3bfa0c20272e3b86e7ec7e782628021fca4fb377edfe86b76d38df4f5ad9b846056ba358f63112988ff7346f23bad739 + languageName: node + linkType: hard + "@visx/text@npm:1.17.1, @visx/text@npm:^1.3.0": version: 1.17.1 resolution: "@visx/text@npm:1.17.1" @@ -9534,6 +9636,22 @@ __metadata: languageName: node linkType: hard +"@visx/text@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/text@npm:3.3.0" + dependencies: + "@types/lodash": ^4.14.172 + "@types/react": "*" + classnames: ^2.3.1 + lodash: ^4.17.21 + prop-types: ^15.7.2 + reduce-css-calc: ^1.3.0 + peerDependencies: + react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 + checksum: 22e2b10a22fa708f59a8a93c9e3610162c4bb2aed8fc4be624f3277ceead858c83a46f72f9940ce9b28d9b6847e561a6aa977a35d9363c08dd7ea5d5a62748b3 + languageName: node + linkType: hard + "@visx/tooltip@npm:1.17.1, @visx/tooltip@npm:^1.3.0": version: 1.17.1 resolution: "@visx/tooltip@npm:1.17.1" @@ -9550,19 +9668,44 @@ __metadata: languageName: node linkType: hard -"@visx/tooltip@npm:3.1.2": - version: 3.1.2 - resolution: "@visx/tooltip@npm:3.1.2" +"@visx/tooltip@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/tooltip@npm:3.3.0" dependencies: "@types/react": "*" - "@visx/bounds": 3.0.0 + "@visx/bounds": 3.3.0 classnames: ^2.3.1 prop-types: ^15.5.10 react-use-measure: ^2.0.4 peerDependencies: react: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 react-dom: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 - checksum: 4c2127068f22766e984ed3ed1452dcece919bacb0297b17ba862a6815bb7f04c4fc462a100d3934e0cb56a9c7962b82f7c4184748ef4b2dbef3b4b0d5b717fdd + checksum: eb525afd34bf108f9343e7998a8752e0002190afa76d2e9e7f47ec83f89139f611def8fe59ba55af4ab2d15bb7d31b836d372ef338e8aa4ca2e7e4cd68567c7f + languageName: node + linkType: hard + +"@visx/vendor@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/vendor@npm:3.3.0" + dependencies: + "@types/d3-array": 3.0.3 + "@types/d3-color": 3.1.0 + "@types/d3-delaunay": 6.0.1 + "@types/d3-format": 3.0.1 + "@types/d3-interpolate": 3.0.1 + "@types/d3-scale": 4.0.2 + "@types/d3-time": 3.0.0 + "@types/d3-time-format": 2.1.0 + d3-array: 3.2.1 + d3-color: 3.1.0 + d3-delaunay: 6.0.2 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + internmap: 2.0.3 + checksum: eaab876b3898d44930e75a95ea1f6f1db490822fd9c93f2806f5673875f8b63bde08f41f43ab0c2c291301796b52d612e671d3f81dfaa20c986adc90d95d09cf languageName: node linkType: hard @@ -9620,9 +9763,9 @@ __metadata: languageName: node linkType: hard -"@visx/voronoi@npm:3.0.0": - version: 3.0.0 - resolution: "@visx/voronoi@npm:3.0.0" +"@visx/voronoi@npm:3.3.0": + version: 3.3.0 + resolution: "@visx/voronoi@npm:3.3.0" dependencies: "@types/d3-voronoi": ^1.1.9 "@types/react": "*" @@ -9631,7 +9774,39 @@ __metadata: prop-types: ^15.6.1 peerDependencies: react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 - checksum: 7c0f4ff846401bae337bf3e961e3807a1ae7b6e50da3817dd7bfba0c8b40017079ef81baa6145bab61b29368d27d2852b5fcf17ee52b372ab17ff2d81a087b9a + checksum: 9640a77f270f940aed0e2a44fcb8768083abaae18dbc343edcd7d7ea9b40aca775d080b7cabbe72cdf9ada425f567e05aca6867b29b3407593b14f789110cf61 + languageName: node + linkType: hard + +"@visx/xychart@https://github.com/jernestmyers/visx.git#visx-xychart": + version: 3.3.0 + resolution: "@visx/xychart@https://github.com/jernestmyers/visx.git#commit=fb1fa95a48e35a27a54ec9ad5de149ad8cd1e0c5" + dependencies: + "@types/lodash": ^4.14.172 + "@types/react": "*" + "@visx/annotation": 3.3.0 + "@visx/axis": 3.3.0 + "@visx/event": 3.3.0 + "@visx/glyph": 3.3.0 + "@visx/grid": 3.3.0 + "@visx/react-spring": 3.3.0 + "@visx/responsive": 3.3.0 + "@visx/scale": 3.3.0 + "@visx/shape": 3.3.0 + "@visx/text": 3.3.0 + "@visx/tooltip": 3.3.0 + "@visx/vendor": 3.3.0 + "@visx/voronoi": 3.3.0 + classnames: ^2.3.1 + d3-interpolate-path: 2.2.1 + d3-shape: ^2.0.0 + lodash: ^4.17.21 + mitt: ^2.1.0 + prop-types: ^15.6.2 + peerDependencies: + "@react-spring/web": ^9.4.5 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 491f82103ceb4ebfc75cb82b8606e8130d22b6e6ef3132664f69b847e4131444d6ccc8014ab7a521619e14fdf489b47fb65c4adee747ef2423d9fe3d926ad46c languageName: node linkType: hard @@ -9667,38 +9842,6 @@ __metadata: languageName: node linkType: hard -"@visx/xychart@npm:^3.1.0": - version: 3.1.2 - resolution: "@visx/xychart@npm:3.1.2" - dependencies: - "@types/lodash": ^4.14.172 - "@types/react": "*" - "@visx/annotation": 3.0.1 - "@visx/axis": 3.1.0 - "@visx/event": 3.0.1 - "@visx/glyph": 3.0.0 - "@visx/grid": 3.0.1 - "@visx/react-spring": 3.1.0 - "@visx/responsive": 3.0.0 - "@visx/scale": 3.0.0 - "@visx/shape": 3.0.0 - "@visx/text": 3.0.0 - "@visx/tooltip": 3.1.2 - "@visx/voronoi": 3.0.0 - classnames: ^2.3.1 - d3-array: ^2.6.0 - d3-interpolate-path: 2.2.1 - d3-shape: ^2.0.0 - lodash: ^4.17.21 - mitt: ^2.1.0 - prop-types: ^15.6.2 - peerDependencies: - "@react-spring/web": ^9.4.5 - react: ^16.8.0 || ^17.0.0 || ^ 18.0.0 - checksum: f6776a4da8000811c7f71e2285610dfe26b159f43904ba8e124225219a6791a89b9ded2f71c4f04f38da03f10b59e5f0d63a6b2b7f8c688aebc9107be4ec2e57 - languageName: node - linkType: hard - "@visx/zoom@npm:1.14.1": version: 1.14.1 resolution: "@visx/zoom@npm:1.14.1" @@ -15338,6 +15481,15 @@ __metadata: languageName: node linkType: hard +"d3-array@npm:3.2.1": + version: 3.2.1 + resolution: "d3-array@npm:3.2.1" + dependencies: + internmap: 1 - 2 + checksum: 0bed33cc33b70f9d48ccef3e7a5956e134862e09179bf14df0bf9c8fc0ec02b8f847d4f5e1d32729cd5e02032af1d0a32bcc968ff1333795028455a749994623 + languageName: node + linkType: hard + "d3-axis@npm:3": version: 3.0.0 resolution: "d3-axis@npm:3.0.0" @@ -15388,7 +15540,7 @@ __metadata: languageName: node linkType: hard -"d3-color@npm:1 - 3, d3-color@npm:3": +"d3-color@npm:1 - 3, d3-color@npm:3, d3-color@npm:3.1.0": version: 3.1.0 resolution: "d3-color@npm:3.1.0" checksum: 4931fbfda5d7c4b5cfa283a13c91a954f86e3b69d75ce588d06cde6c3628cebfc3af2069ccf225e982e8987c612aa7948b3932163ce15eb3c11cd7c003f3ee3b @@ -15404,7 +15556,7 @@ __metadata: languageName: node linkType: hard -"d3-delaunay@npm:6": +"d3-delaunay@npm:6, d3-delaunay@npm:6.0.2": version: 6.0.2 resolution: "d3-delaunay@npm:6.0.2" dependencies: @@ -15514,7 +15666,7 @@ __metadata: languageName: node linkType: hard -"d3-format@npm:1 - 3, d3-format@npm:3": +"d3-format@npm:1 - 3, d3-format@npm:3, d3-format@npm:3.1.0": version: 3.1.0 resolution: "d3-format@npm:3.1.0" checksum: f345ec3b8ad3cab19bff5dead395bd9f5590628eb97a389b1dd89f0b204c7c4fc1d9520f13231c2c7cf14b7c9a8cf10f8ef15bde2befbab41454a569bd706ca2 @@ -15585,7 +15737,7 @@ __metadata: languageName: node linkType: hard -"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:3, d3-interpolate@npm:^3.0.1": +"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:3, d3-interpolate@npm:3.0.1, d3-interpolate@npm:^3.0.1": version: 3.0.1 resolution: "d3-interpolate@npm:3.0.1" dependencies: @@ -15678,7 +15830,7 @@ __metadata: languageName: node linkType: hard -"d3-scale@npm:4, d3-scale@npm:^4.0.2": +"d3-scale@npm:4, d3-scale@npm:4.0.2, d3-scale@npm:^4.0.2": version: 4.0.2 resolution: "d3-scale@npm:4.0.2" dependencies: @@ -15754,7 +15906,7 @@ __metadata: languageName: node linkType: hard -"d3-time-format@npm:2 - 4, d3-time-format@npm:4": +"d3-time-format@npm:2 - 4, d3-time-format@npm:4, d3-time-format@npm:4.1.0": version: 4.1.0 resolution: "d3-time-format@npm:4.1.0" dependencies: @@ -15781,7 +15933,7 @@ __metadata: languageName: node linkType: hard -"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:3": +"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:3, d3-time@npm:3.1.0": version: 3.1.0 resolution: "d3-time@npm:3.1.0" dependencies: @@ -21130,7 +21282,7 @@ __metadata: languageName: node linkType: hard -"internmap@npm:1 - 2": +"internmap@npm:1 - 2, internmap@npm:2.0.3": version: 2.0.3 resolution: "internmap@npm:2.0.3" checksum: 7ca41ec6aba8f0072fc32fa8a023450a9f44503e2d8e403583c55714b25efd6390c38a87161ec456bf42d7bc83aab62eb28f5aef34876b1ac4e60693d5e1d241