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 (
+
+ );
+ } 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 (
-
)}
>
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 = (
+
- “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.
-
+ “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) {
/>
-
-