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