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/packages/libs/components/src/components/plotControls/PlotBubbleLegend.tsx b/packages/libs/components/src/components/plotControls/PlotBubbleLegend.tsx new file mode 100644 index 0000000000..d52ba6414d --- /dev/null +++ b/packages/libs/components/src/components/plotControls/PlotBubbleLegend.tsx @@ -0,0 +1,118 @@ +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 legendMaxLog10 = Math.floor(Math.log10(legendMax)); + const largestCircleValue = + legendMax <= 10 + ? legendMax + : (Number(legendMax.toPrecision(1)[0]) + 1) * 10 ** legendMaxLog10; + 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/map/BoundsDriftMarker.tsx b/packages/libs/components/src/map/BoundsDriftMarker.tsx index c12c2c19ee..7fa19c4c52 100644 --- a/packages/libs/components/src/map/BoundsDriftMarker.tsx +++ b/packages/libs/components/src/map/BoundsDriftMarker.tsx @@ -38,6 +38,7 @@ export default function BoundsDriftMarker({ showPopup, popupContent, popupClass, + zIndexOffset, }: BoundsDriftMarkerProps) { const map = useMap(); const boundingBox = new LatLngBounds([ @@ -300,6 +301,7 @@ export default function BoundsDriftMarker({ mouseout: (e: LeafletMouseEvent) => 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/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/types/plots/addOns.ts b/packages/libs/components/src/types/plots/addOns.ts index f57965365c..68131409d8 100644 --- a/packages/libs/components/src/types/plots/addOns.ts +++ b/packages/libs/components/src/types/plots/addOns.ts @@ -241,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() 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/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/LineplotVisualization.tsx b/packages/libs/eda/src/lib/core/components/visualizations/implementations/LineplotVisualization.tsx index de879714b2..3dd09a3af2 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 @@ -1727,115 +1727,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 +2702,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..d2d2d0e102 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'; @@ -572,22 +570,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 +702,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 +781,6 @@ function ScatterplotViz(props: VisualizationProps) { facetEntity, computedOverlayVariableDescriptor, neutralPaletteProps.colorPalette, - gradientColorscaleType, - maxAbsOverlay, overlayMin, overlayMax, ]) 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/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..78dcad9c78 100644 --- a/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts +++ b/packages/libs/eda/src/lib/map/analysis/hooks/standaloneVizPlugins.ts @@ -17,7 +17,11 @@ 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'; @@ -25,9 +29,10 @@ 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 _ 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 ) { 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 c2618be7da..4a52be0971 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,56 @@ 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 ?? [], + 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 +159,7 @@ type GetBinRangesProps = { entityId: string; dataClient: DataClient; filters: Filter[]; - binningMethod: MarkerConfiguration['binningMethod']; + binningMethod: BinningMethod; }; // get the equal spaced bin definitions (for now at least)