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({ > - {data.map((dataPoint, idx) => { - // Calculate vertical placement and height for each primary row + {sortedData.map((dataPoint, idx) => { + // Calculate vertical placement and height for each primary row const rowY = (aggregationScale()(dataPoint.aggregateAttribute) || 0) - + (secondaryData ? aggregationScale().bandwidth() * 0.5 : 0); + + (secondaryData ? aggregationScale().bandwidth() * 0.5 : 0); // For this row, is the row selected or hovered? const isSelected = rowSelected(yAxisVar, dataPoint.aggregateAttribute); @@ -160,7 +234,7 @@ function HeatMap({ valueScaleDomain={JSON.stringify(valueScale().domain())} valueScaleRange={JSON.stringify(valueScale().range())} dataPoint={dataPoint} - // Now rendered at y=0 within this transformed group + // Now rendered at y=0 within this transformed group howToTransform="translate(0,0)" /> @@ -180,7 +254,7 @@ function HeatMap({ {secondaryData ? secondaryData.map((dataPoint, idx) => { // Calculate vertical placement and height for each primary row const rowY = (aggregationScale()(dataPoint.aggregateAttribute) || 0) - + (aggregationScale().bandwidth() * 0.5); + + (aggregationScale().bandwidth() * 0.5); // For this secondary row, is the row selected or hovered? const isSelected = rowSelected(yAxisVar, dataPoint.aggregateAttribute); @@ -221,7 +295,7 @@ function HeatMap({ - + {/* Render after chart to render on top */}