diff --git a/frontend/src/Components/Charts/ChartAccessories/AttributePlots/GeneratorAttributePlot.tsx b/frontend/src/Components/Charts/ChartAccessories/AttributePlots/GeneratorAttributePlot.tsx
index 149162f6b..361df4173 100644
--- a/frontend/src/Components/Charts/ChartAccessories/AttributePlots/GeneratorAttributePlot.tsx
+++ b/frontend/src/Components/Charts/ChartAccessories/AttributePlots/GeneratorAttributePlot.tsx
@@ -2,7 +2,7 @@
import { observer } from 'mobx-react';
import styled from '@emotion/styled';
import { Tooltip } from '@mui/material';
-import { Fragment, useContext } from 'react';
+import { Fragment, useContext, useState } from 'react';
import { format } from 'd3';
import { AttributePlotData } from '../../../../Interfaces/Types/DataTypes';
import {
@@ -49,6 +49,9 @@ const attributePlotTextGenerator = (
chartId: string,
dimensionHeight: number,
currentOffset: Offset,
+ isDescending: boolean,
+ onSortClick: () => void,
+ activeSortIdx: number,
) => {
// Defines the explanation for the tooltip
let explanation = '';
@@ -81,6 +84,10 @@ const attributePlotTextGenerator = (
// Calculates the x value for the text, based on the width of the previous extra pairs.
const labelX: number = allAttributePlots.slice(0, idx + 1).map((attributePlot) => AttributePlotWidth[attributePlot.type] + AttributePlotPadding).reduce((partialSum, a) => partialSum + a, 0);
+ const tooltipTitle = isDescending ? 'Sort Descending' : 'Sort Ascending';
+
+ const ARROW_SIZE = 24; // px
+ const arrowColor = activeSortIdx === idx ? '#000' : '#bbb';
return (
<>
{/* Generates the Violin Axis */}
@@ -91,6 +98,29 @@ const attributePlotTextGenerator = (
xPos={labelX - AttributePlotWidth.Violin}
/>
)}
+ {/* Absolutely positioned IconButton overlay */}
+
+
+ {/* Plain SVG arrow */}
+ {isDescending ? (
+
+ )
+ : (
+
+ )}
+
+
{/* Generates the Attribute Label */}
{labelInput}
-
- x
-
+ {/* 'x' below the label */}
+ {
+ store.chartStore.removeAttributePlot(chartId, nameInput);
+ }}
+ style={{ cursor: 'pointer' }}
+ >
+ x
+
>
);
};
@@ -118,67 +156,54 @@ function GeneratorAttributePlot({
aggregationScaleDomain,
aggregationScaleRange,
}: {
- attributePlotData: AttributePlotData<'Violin' | 'BarChart' | 'Basic'>[];
- secondaryAttributePlotData?: AttributePlotData<'Violin' | 'BarChart' | 'Basic'>[];
- aggregationScaleDomain: string;
- aggregationScaleRange: string;
+ attributePlotData: AttributePlotData<'Violin' | 'BarChart' | 'Basic'>[];
+ secondaryAttributePlotData?: AttributePlotData<'Violin' | 'BarChart' | 'Basic'>[];
+ aggregationScaleDomain: string;
+ aggregationScaleRange: string;
}) {
let transferedDistance = 0;
const returningComponents: JSX.Element[] = [];
- // For each extra pair, generate the appropriate plot.
attributePlotData.forEach((plotData, idx) => {
let temporarySecondary = secondaryAttributePlotData;
if (secondaryAttributePlotData) {
temporarySecondary = secondaryAttributePlotData.length > 0 ? temporarySecondary : undefined;
}
- if (plotData.type === 'Violin') {
- const violinPlotData = plotData as AttributePlotData<'Violin'>;
- const secondaryViolinPlotData = (temporarySecondary ? temporarySecondary[idx] : undefined) as AttributePlotData<'Violin'> | undefined;
-
- transferedDistance += (AttributePlotWidth.Violin + AttributePlotPadding);
- returningComponents.push(
-
-
- ,
- );
- } else if (plotData.type === 'BarChart') {
- const barPlotData = plotData as AttributePlotData<'BarChart'>;
- const secondaryBarPlotData = (temporarySecondary ? temporarySecondary[idx] : undefined) as AttributePlotData<'BarChart'> | undefined;
-
- transferedDistance += (AttributePlotWidth.BarChart + AttributePlotPadding);
- returningComponents.push(
-
-
- ,
- );
- } else {
- const basicPlotData = plotData as AttributePlotData<'Basic'>;
- const secondaryBasicPlotData = (temporarySecondary ? temporarySecondary[idx] : undefined) as AttributePlotData<'Basic'> | undefined;
+ // Calculate the X position for centering the plot
+ const plotWidth = AttributePlotWidth[plotData.type];
+ transferedDistance += (plotWidth + AttributePlotPadding);
+ const plotX = transferedDistance - plotWidth;
- transferedDistance += (AttributePlotWidth.Basic + AttributePlotPadding);
- returningComponents.push(
-
-
- ,
- );
- }
+ returningComponents.push(
+
+ {/* The plot itself */}
+
+ {plotData.type === 'Violin' ? (
+ }
+ secondaryPlotData={temporarySecondary ? (temporarySecondary[idx] as AttributePlotData<'Violin'>) : undefined}
+ aggregationScaleDomain={aggregationScaleDomain}
+ aggregationScaleRange={aggregationScaleRange}
+ />
+ ) : plotData.type === 'BarChart' ? (
+ }
+ secondaryPlotData={temporarySecondary ? (temporarySecondary[idx] as AttributePlotData<'BarChart'>) : undefined}
+ aggregationScaleDomain={aggregationScaleDomain}
+ aggregationScaleRange={aggregationScaleRange}
+ />
+ ) : (
+ }
+ secondaryPlotData={temporarySecondary ? (temporarySecondary[idx] as AttributePlotData<'Basic'>) : undefined}
+ aggregationScaleDomain={aggregationScaleDomain}
+ aggregationScaleRange={aggregationScaleRange}
+ />
+ )}
+
+ ,
+ );
});
return (
@@ -195,13 +220,27 @@ export function AttributePlotLabels({
chartId,
dimensionHeight,
currentOffset,
+ onSortClick,
}: {
attributePlotData: AttributePlotData<'Violin' | 'BarChart' | 'Basic'>[];
chartId: string;
dimensionHeight: number;
currentOffset: Offset;
+ onSortClick: (attributeName: typeof EXTRA_PAIR_OPTIONS[number]) => void;
}): JSX.Element {
const store = useContext(Store);
+ const [sortDirections, setSortDirections] = useState(() => attributePlotData.map(() => false));
+ const [activeSortIdx, setActiveSortIdx] = useState(null);
+
+ const handleSortClick = (attributeName: typeof EXTRA_PAIR_OPTIONS[number], idx: number) => {
+ setSortDirections((prev) => {
+ const next = [...prev];
+ next[idx] = !next[idx];
+ setActiveSortIdx(idx);
+ return next;
+ });
+ onSortClick(attributeName);
+ };
return (
<>
{attributePlotData.map((plotData, idx) => (
@@ -217,6 +256,9 @@ export function AttributePlotLabels({
chartId,
dimensionHeight,
currentOffset,
+ sortDirections[idx],
+ () => handleSortClick(plotData.attributeName, idx),
+ activeSortIdx ?? -1,
)}
))}
diff --git a/frontend/src/Components/Charts/CostBarChart.tsx b/frontend/src/Components/Charts/CostBarChart.tsx
index 2e5f31234..8815eb7b3 100644
--- a/frontend/src/Components/Charts/CostBarChart.tsx
+++ b/frontend/src/Components/Charts/CostBarChart.tsx
@@ -4,6 +4,7 @@ import {
} from 'react';
import { VegaLite } from 'react-vega';
import HelpIcon from '@mui/icons-material/Help';
+import SortIcon from '@mui/icons-material/Sort';
import MonetizationOnIcon from '@mui/icons-material/MonetizationOn';
import {
FormControl, Tooltip, Switch, IconButton, Menu, MenuItem, Stack,
@@ -62,6 +63,10 @@ function WrapperCostBar({ layout }: { layout: CostLayoutElement }) {
rawDateRange,
} = store.provenanceState;
+ // Sorting state for cost
+ const [sortDescending, setSortDescending] = useState(true);
+ const [sortByCost, setSortByCost] = useState(false);
+
const chartDiv = useRef(null);
const [vegaHeight, setVegaHeight] = useState(0);
const [dimensionHeight, setDimensionHeight] = useState(0);
@@ -111,8 +116,6 @@ function WrapperCostBar({ layout }: { layout: CostLayoutElement }) {
// Gets the provider name depending on the private mode setting
const getLabel = usePrivateProvLabel();
- // Separate data for the cost savings chart, and the extra attribute plot(s) ------------------------------------------------------------
-
// Cost Savings Chart Data
const [costSavingsData, setCostSavingsData] = useState([]);
const [secondaryCostSavingsData, setSecondaryCostSavingsData] = useState([]);
@@ -188,10 +191,7 @@ function WrapperCostBar({ layout }: { layout: CostLayoutElement }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [attributePlots]);
- // useDeepCompareEffect COPIED FROM WrapperHeatMap.tsx, which correctly generates the extra attribute data for the extra pair plot ------------------------------------------------------------
-
- // Creating the Extra Attribute Data (NOT the cost-savings data), to be used for the extra pair plot (GeneratorAttributePlot) -----------------------------------------------------------------------------------
- // Default xAxisVar is PRBC_UNITS (because cost savings chart doesn't have different x-Axes). (So additional attributes like Total Transfused, Per Case, etc. are currently in terms of 'PRBC_UNITS').
+ // Creating the Extra Attribute Data (NOT the cost-savings data), to be used for the extra pair plot (GeneratorAttributePlot)
const xAxisVar = 'PRBC_UNITS';
useDeepCompareEffect(() => {
const [tempCaseCount, secondaryTempCaseCount, outputData, secondaryOutputData] = generateExtraAttributeData(filteredCases, yAxisVar, outcomeComparison, interventionDate, store.provenanceState.showZero, xAxisVar);
@@ -332,6 +332,46 @@ function WrapperCostBar({ layout }: { layout: CostLayoutElement }) {
stateUpdateWrapperUseJSON(costSavingsData, outputData, setCostSavingsData);
}, [proceduresSelection, rawDateRange, yAxisVar, currentOutputFilterSet, BloodProductCost, filteredCases, outcomeComparison, interventionDate, store.configStore.privateMode]);
+ // Sort xVals by total cost if sortByCost is true, otherwise use default xVals
+ const sortedXVals = useMemo(() => {
+ if (!sortByCost) return xVals;
+ // Calculate total cost for each row
+ const costMap: Record = {};
+ costSavingsData.forEach((d) => {
+ const label = getLabel(d.aggregateAttribute, yAxisVar);
+ if (!costMap[label]) costMap[label] = 0;
+ costMap[label] +=
+ (d.PRBC_UNITS || 0) +
+ (d.FFP_UNITS || 0) +
+ (d.CRYO_UNITS || 0) +
+ (d.PLT_UNITS || 0) +
+ (d.CELL_SAVER_ML || 0);
+ });
+ // If secondary data is present, include it as well
+ secondaryCostSavingsData.forEach((d) => {
+ const label = getLabel(d.aggregateAttribute, yAxisVar);
+ if (!costMap[label]) costMap[label] = 0;
+ costMap[label] +=
+ (d.PRBC_UNITS || 0) +
+ (d.FFP_UNITS || 0) +
+ (d.CRYO_UNITS || 0) +
+ (d.PLT_UNITS || 0) +
+ (d.CELL_SAVER_ML || 0);
+ });
+ // Sort labels by total cost
+ const labels = Object.keys(costMap);
+ labels.sort((a, b) =>
+ sortDescending ? costMap[b] - costMap[a] : costMap[a] - costMap[b]
+ );
+ return labels;
+ }, [costSavingsData, secondaryCostSavingsData, getLabel, yAxisVar, sortDescending, sortByCost, xVals]);
+
+ // Toggle sort direction and enable cost sort on icon click
+ const handleSortClick = useCallback(() => {
+ setSortByCost(true);
+ setSortDescending((prev) => !prev);
+ }, []);
+
const spec = useMemo(() => ({
$schema: 'https://vega.github.io/schema/vega-lite/v5.json',
data: { name: 'values' },
@@ -348,7 +388,7 @@ function WrapperCostBar({ layout }: { layout: CostLayoutElement }) {
aggregate: 'sum',
axis: { grid: false, title: 'Cost' },
},
- y: { field: 'rowLabel', type: 'ordinal', sort: xVals.toReversed() },
+ y: { field: 'rowLabel', type: 'ordinal', sort: sortedXVals.toReversed() },
yOffset: outcomeComparison || interventionDate ? { field: 'type', type: 'ordinal', scale: { paddingOuter: -8, domain: ['true', 'false'] } } : undefined,
color: { field: 'bloodProduct', type: 'nominal', legend: null },
fillOpacity: {
@@ -380,7 +420,7 @@ function WrapperCostBar({ layout }: { layout: CostLayoutElement }) {
},
x: { value: -20 },
x2: { value: -5 },
- opacity: { value: 0.2 }, // 0.2 because there are 5 copies stacked on top of each other
+ opacity: { value: 0.2 },
},
},
] : []),
@@ -389,7 +429,7 @@ function WrapperCostBar({ layout }: { layout: CostLayoutElement }) {
type: 'fit',
contains: 'padding',
},
- height: { step: Math.max(MIN_HEATMAP_BANDWIDTH(secondaryCostSavingsData), (dimensionHeight - 50) / xVals.length) },
+ height: { step: Math.max(MIN_HEATMAP_BANDWIDTH(secondaryCostSavingsData), (dimensionHeight - 50) / sortedXVals.length) },
config: {
axisY: {
title: null,
@@ -399,7 +439,7 @@ function WrapperCostBar({ layout }: { layout: CostLayoutElement }) {
stroke: 'transparent',
},
},
- }), [xVals, outcomeComparison, interventionDate, secondaryCostSavingsData, dimensionHeight]);
+ }), [sortedXVals, outcomeComparison, interventionDate, secondaryCostSavingsData, dimensionHeight]);
const axisSpec = useMemo(() => ({
...spec,
@@ -424,11 +464,13 @@ function WrapperCostBar({ layout }: { layout: CostLayoutElement }) {
Cost/Saving Chart
- {/* Potential cost */}
{ setShowPotential(e.target.checked); }} />
+
+
+
@@ -471,7 +513,6 @@ function WrapperCostBar({ layout }: { layout: CostLayoutElement }) {
secondTotal={secondaryCostSavingsData.reduce((a, b) => a + b.caseCount, 0)}
outcomeComparison={outcomeComparison}
/>
-
)}
-
- {/* Permanent x axis overlay with white background */}
- {/* Bottom additional attribute labels */}
@@ -548,4 +588,4 @@ function WrapperCostBar({ layout }: { layout: CostLayoutElement }) {
);
}
-export default observer(WrapperCostBar);
+export default observer(WrapperCostBar);
\ No newline at end of file
diff --git a/frontend/src/Components/Charts/HeatMap/HeatMap.tsx b/frontend/src/Components/Charts/HeatMap/HeatMap.tsx
index 1bef88986..1e1797890 100644
--- a/frontend/src/Components/Charts/HeatMap/HeatMap.tsx
+++ b/frontend/src/Components/Charts/HeatMap/HeatMap.tsx
@@ -31,21 +31,21 @@ const outputGradientLegend = (showZero: boolean, dimensionWidth: number) => {
};
type Props = {
- dimensionWidth: number;
- dimensionHeight: number;
- xAxisVar: BloodComponent;
- yAxisVar: Aggregation;
- chartId: string;
- data: HeatMapDataPoint[];
- svg: React.RefObject;
- attributePlotData: AttributePlotData<'Violin' | 'BarChart' | 'Basic'>[];
- attributePlotTotalWidth: number;
- interventionDate?: number;
- secondaryData?: HeatMapDataPoint[];
- secondaryAttributePlotData?: AttributePlotData<'Violin' | 'BarChart' | 'Basic'>[];
- firstTotal: number;
- secondTotal: number;
- outcomeComparison?: Outcome;
+ dimensionWidth: number;
+ dimensionHeight: number;
+ xAxisVar: BloodComponent;
+ yAxisVar: Aggregation;
+ chartId: string;
+ data: HeatMapDataPoint[];
+ svg: React.RefObject;
+ attributePlotData: AttributePlotData<'Violin' | 'BarChart' | 'Basic'>[];
+ attributePlotTotalWidth: number;
+ interventionDate?: number;
+ secondaryData?: HeatMapDataPoint[];
+ secondaryAttributePlotData?: AttributePlotData<'Violin' | 'BarChart' | 'Basic'>[];
+ firstTotal: number;
+ secondTotal: number;
+ outcomeComparison?: Outcome;
};
function HeatMap({
@@ -56,6 +56,8 @@ function HeatMap({
const currentOffset = OffsetDict.regular;
const [xVals, setXVals] = useState<[]>([]);
const [caseMax, setCaseMax] = useState(0);
+ const [sortAttribute, setSortAttribute] = useState(null);
+ const [sortDescending, setSortDescending] = useState(true);
useDeepCompareEffect(() => {
const [tempxVals, newCaseMax] = sortHelper(data, yAxisVar, store.provenanceState.showZero, secondaryData);
@@ -64,12 +66,70 @@ function HeatMap({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, store.provenanceState.showZero, yAxisVar, secondaryData]);
+ const sortedData = useMemo(() => {
+ // If not sorting by attribute, return original data
+ if (!sortAttribute) return data;
+
+ // Find the plot data for the attribute
+ const plot = attributePlotData.find((p) => p.attributeName === sortAttribute);
+ if (!plot) return data;
+
+ // A map of values of the attribute to sort by
+ const sortAttributeMap = plot.attributeData;
+ const medianSet = plot.type === 'Violin' ? plot.medianSet : undefined;
+
+ // Helper to get the value for sorting
+ function getSortValue(val: any, rowKey?: string): number {
+ if (plot?.type === 'Violin' && medianSet && rowKey && medianSet[rowKey] !== undefined) {
+ return medianSet[rowKey];
+ }
+ // If it's an object with attributeCaseCount and rowCaseCount, use the percentage
+ if (
+ val
+ && typeof val === 'object'
+ && typeof val.attributeCaseCount === 'number'
+ && typeof val.rowCaseCount === 'number'
+ && val.rowCaseCount > 0
+ ) {
+ return val.attributeCaseCount / val.rowCaseCount;
+ }
+ // If it's a number, use it directly
+ if (typeof val === 'number') return val;
+ // If it's an object with a value property, use that
+ if (val && typeof val === 'object') {
+ if ('value' in val && typeof val.value === 'number') return val.value;
+ if ('median' in val && typeof val.median === 'number') return val.median;
+ const numVal = Object.values(val).find((v) => typeof v === 'number');
+ if (typeof numVal === 'number') return numVal;
+ }
+ // Fallback to 0
+ return 0;
+ }
+
+ // Sort 'data' based on the sortAttribute, and return.
+ const attributeSortedData = [...data].sort((a, b) => {
+ const aVal = getSortValue(sortAttributeMap[a.aggregateAttribute], a.aggregateAttribute);
+ const bVal = getSortValue(sortAttributeMap[b.aggregateAttribute], b.aggregateAttribute);
+ return sortDescending ? bVal - aVal : aVal - bVal;
+ });
+ return attributeSortedData;
+ }, [data, sortAttribute, sortDescending, attributePlotData]);
+
+ // Use sortedData to generate xVals for the aggregation scale
+ const sortedXVals = useMemo(
+ () => sortedData.map((d) => d.aggregateAttribute),
+ [sortedData],
+ );
+
const chartHeight = useMemo(() => Math.max(
dimensionHeight,
data.length * MIN_HEATMAP_BANDWIDTH(secondaryData) + OffsetDict.regular.top + OffsetDict.regular.bottom,
), [data.length, dimensionHeight, secondaryData]);
- const aggregationScale = useCallback(() => AggregationScaleGenerator(xVals, chartHeight, currentOffset), [xVals, currentOffset, chartHeight]);
+ const aggregationScale = useCallback(
+ () => AggregationScaleGenerator(sortedXVals, chartHeight, currentOffset),
+ [sortedXVals, chartHeight, currentOffset],
+ );
const valueScale = useCallback(() => {
let outputRange;
@@ -92,7 +152,7 @@ function HeatMap({
function rowSelected(attribute: string, value: string) {
return interactionStore.selectedAttributes
?.some(([attrName, attrValue]) => attrName === attribute && attrValue === value)
- ?? false;
+ ?? false;
}
// Sets the selected attribute in the store.
@@ -115,6 +175,20 @@ function HeatMap({
interactionStore.hoveredAttribute = undefined;
}
+ const handleSortClick = useCallback((attributeName: string) => {
+ console.log(`Sorting by ${attributeName} in ${sortDescending ? 'descending' : 'ascending'} order`);
+ setSortAttribute((prevAttr) => {
+ // If we've previously sorted by this attribute, toggle the sort direction.
+ if (prevAttr === attributeName) {
+ console.log(`Toggling sort direction for ${attributeName}`);
+ setSortDescending((prevDesc) => !prevDesc);
+ return prevAttr;
+ }
+ setSortDescending(true); // default to descending on new attribute
+ return attributeName;
+ });
+ }, []);
+
// Calculates the height of each row based on whether secondary data is present.
const rowHeight = useMemo(() => (secondaryData ? aggregationScale().bandwidth() * 0.5 : aggregationScale().bandwidth()), [secondaryData, aggregationScale]);
@@ -128,10 +202,10 @@ function HeatMap({
>