Skip to content

Commit d9b5206

Browse files
committed
Improve bubble plot
1 parent 2d57dc9 commit d9b5206

File tree

8 files changed

+152
-38
lines changed

8 files changed

+152
-38
lines changed

smoosense-gui/src/components/common/NumericalColumnDropdown.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { useAppSelector, useAppDispatch } from '@/lib/hooks'
66
import { useIsCategoricalBulk } from '@/lib/hooks/useIsCategorical'
77
import { useRenderType } from '@/lib/hooks/useRenderType'
88
import { RenderType } from '@/lib/utils/agGridCellRenderers'
9-
import { setHistogramColumn, setBubblePlotXColumn, setBubblePlotYColumn } from '@/lib/features/ui/uiSlice'
9+
import { setHistogramColumn, setBubblePlotXColumn, setBubblePlotYColumn, setBubblePlotColorColumn } from '@/lib/features/ui/uiSlice'
1010
import {
1111
Select,
1212
SelectContent,
@@ -25,12 +25,14 @@ type UIState = {
2525
histogramColumn: string
2626
bubblePlotXColumn: string
2727
bubblePlotYColumn: string
28+
bubblePlotColorColumn: string
2829
}
2930

3031
const actionMap = {
3132
histogramColumn: setHistogramColumn,
3233
bubblePlotXColumn: setBubblePlotXColumn,
3334
bubblePlotYColumn: setBubblePlotYColumn,
35+
bubblePlotColorColumn: setBubblePlotColorColumn,
3436
} as const
3537

3638
export default function NumericalColumnDropdown({
@@ -58,7 +60,7 @@ export default function NumericalColumnDropdown({
5860
const handleValueChange = (value: string) => {
5961
const action = actionMap[settingKey]
6062
if (action) {
61-
// Convert "-" back to empty string (numerical columns don't accept null)
63+
// Convert "-" back to empty string
6264
dispatch(action(value === "-" ? "" : value))
6365
}
6466
}

smoosense-gui/src/lib/features/bubblePlot/BubblePlotControls.tsx

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use client'
22

33
import NumericalColumnDropdown from '@/components/common/NumericalColumnDropdown'
4-
import CategoricalColumnDropdown from '@/components/common/CategoricalColumnDropdown'
54
import { Button } from '@/components/ui/button'
65
import { ArrowRightLeft } from 'lucide-react'
76
import { useAppSelector, useAppDispatch } from '@/lib/hooks'
@@ -47,13 +46,6 @@ export default function BubblePlotControls() {
4746
/>
4847
</div>
4948

50-
<div className="flex-1">
51-
<CategoricalColumnDropdown
52-
settingKey="bubblePlotBreakdownColumn"
53-
label="Breakdown Column"
54-
/>
55-
</div>
56-
5749
<div className="flex-none">
5850
<BubblePlotMoreControls />
5951
</div>

smoosense-gui/src/lib/features/bubblePlot/BubblePlotMoreControls.tsx

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,74 @@
33
import { SlidersHorizontal } from 'lucide-react'
44
import { Slider } from '@/components/ui/slider'
55
import IconPopover from '@/components/common/IconPopover'
6+
import CategoricalColumnDropdown from '@/components/common/CategoricalColumnDropdown'
7+
import NumericalColumnDropdown from '@/components/common/NumericalColumnDropdown'
8+
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
69
import { useAppSelector, useAppDispatch } from '@/lib/hooks'
7-
import { setBubblePlotMaxMarkerSize, setBubblePlotOpacity, setBubblePlotMarkerSizeContrastRatio } from '@/lib/features/ui/uiSlice'
10+
import { setBubblePlotMaxMarkerSize, setBubblePlotOpacity, setBubblePlotMarkerSizeContrastRatio, setBubblePlotColorScale } from '@/lib/features/ui/uiSlice'
11+
12+
// Plotly color scales with their CSS gradient representations
13+
const COLOR_SCALES: Record<string, string> = {
14+
'Jet': 'linear-gradient(to right, #000080, #0000ff, #00ffff, #00ff00, #ffff00, #ff0000, #800000)',
15+
'Hot': 'linear-gradient(to right, #000000, #e60000, #ffd200, #ffffff)',
16+
'Reds': 'linear-gradient(to right, #fff5f0, #fee0d2, #fcbba1, #fc9272, #fb6a4a, #ef3b2c, #cb181d, #a50f15, #67000d)',
17+
'Picnic': 'linear-gradient(to right, #0000ff, #3399ff, #66ccff, #99ccff, #ccccff, #ffffff, #ffcccc, #ff9999, #ff6666, #ff3333, #ff0000)',
18+
}
19+
20+
function ColorScaleBar({ scale }: { scale: string }) {
21+
const gradient = COLOR_SCALES[scale] || 'linear-gradient(to right, #ccc, #666)'
22+
return (
23+
<div
24+
className="h-3 w-full rounded-sm"
25+
style={{ background: gradient }}
26+
/>
27+
)
28+
}
829

930
function BubblePlotMoreControlsContent() {
1031
const dispatch = useAppDispatch()
1132
const bubblePlotMaxMarkerSize = useAppSelector((state) => state.ui.bubblePlotMaxMarkerSize)
1233
const bubblePlotOpacity = useAppSelector((state) => state.ui.bubblePlotOpacity)
1334
const bubblePlotMarkerSizeContrastRatio = useAppSelector((state) => state.ui.bubblePlotMarkerSizeContrastRatio)
35+
const bubblePlotColorScale = useAppSelector((state) => state.ui.bubblePlotColorScale)
1436

1537
return (
1638
<div className="space-y-4 w-full max-w-sm">
39+
<CategoricalColumnDropdown
40+
settingKey="bubblePlotBreakdownColumn"
41+
label="Breakdown"
42+
/>
43+
44+
<NumericalColumnDropdown
45+
settingKey="bubblePlotColorColumn"
46+
label="Color by"
47+
shouldInitialize={false}
48+
/>
49+
50+
<div className="flex items-center gap-3">
51+
<label className="text-sm font-medium text-foreground">Color Scale</label>
52+
<div className="flex-1">
53+
<Select
54+
value={bubblePlotColorScale}
55+
onValueChange={(value) => dispatch(setBubblePlotColorScale(value))}
56+
>
57+
<SelectTrigger className="w-full">
58+
<span className="text-sm">{bubblePlotColorScale}</span>
59+
</SelectTrigger>
60+
<SelectContent>
61+
{Object.keys(COLOR_SCALES).map((scale) => (
62+
<SelectItem key={scale} value={scale}>
63+
<div className="flex items-center gap-2 w-48">
64+
<span className="text-sm w-16">{scale}</span>
65+
<ColorScaleBar scale={scale} />
66+
</div>
67+
</SelectItem>
68+
))}
69+
</SelectContent>
70+
</Select>
71+
</div>
72+
</div>
73+
1774
<div>
1875
<label className="text-sm font-medium mb-2 block">
1976
Max Marker Size: {bubblePlotMaxMarkerSize}
@@ -64,8 +121,9 @@ export default function BubblePlotMoreControls() {
64121
<IconPopover
65122
icon={<SlidersHorizontal />}
66123
tooltip="More Controls"
67-
contentClassName="p-4"
68-
align="end"
124+
contentClassName="p-4 w-96"
125+
side="right"
126+
align="start"
69127
>
70128
<BubblePlotMoreControlsContent />
71129
</IconPopover>

smoosense-gui/src/lib/features/bubblePlot/PlotlyBubblePlot.tsx

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const PlotlyBubblePlot = React.memo(function PlotlyBubblePlot({ data }: PlotlyBu
3636
const maxMarkerSize = useAppSelector((state) => state.ui.bubblePlotMaxMarkerSize)
3737
const opacity = useAppSelector((state) => state.ui.bubblePlotOpacity)
3838
const markerSizeContrastRatio = useAppSelector((state) => state.ui.bubblePlotMarkerSizeContrastRatio)
39+
const colorColumn = useAppSelector((state) => state.ui.bubblePlotColorColumn)
40+
const colorScale = useAppSelector((state) => state.ui.bubblePlotColorScale)
3941

4042
// Get theme colors for marker styling
4143
const colors = usePlotlyColors()
@@ -51,34 +53,66 @@ const PlotlyBubblePlot = React.memo(function PlotlyBubblePlot({ data }: PlotlyBu
5153
}
5254

5355
try {
56+
// Check if we have color values and should use color scale
57+
const hasColorValues = colorColumn && data.some(item =>
58+
item.customdata.some(c => c.colorValue !== null)
59+
)
60+
5461
const plotlyData = data.map((item) => {
62+
const markerSizes = item.customdata.map(c => {
63+
// Shifted logistic function: markerSize = 2 * maxMarkerSize * (1 / (1 + exp(-markerSizeContrastRatio * count)) - 0.5)
64+
const k = Math.exp(-markerSizeContrastRatio)
65+
const logisticValue = 1 / (1 + Math.exp(-k * c.count))
66+
return Math.max(5, 2 * maxMarkerSize * (logisticValue - 0.5))
67+
})
68+
69+
// Build marker config
70+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
71+
const marker: any = {
72+
size: markerSizes,
73+
opacity: opacity,
74+
}
75+
76+
// Add color mapping if color column is selected
77+
if (hasColorValues) {
78+
const colorValues = item.customdata.map(c => c.colorValue ?? 0)
79+
marker.color = colorValues
80+
marker.colorscale = colorScale
81+
marker.showscale = true
82+
marker.colorbar = {
83+
title: colorColumn,
84+
thickness: 15,
85+
len: 0.5
86+
}
87+
// Use same color for border (no separate border color)
88+
marker.line = {
89+
width: 1,
90+
color: colorValues,
91+
colorscale: colorScale
92+
}
93+
} else {
94+
// Default border color when no color column
95+
marker.line = {
96+
width: 1,
97+
color: colors.foreground
98+
}
99+
}
55100

56101
const trace: Partial<PlotData> = {
57102
x: item.x,
58103
y: item.y,
59104
name: item.name,
60105
mode: 'markers',
61106
type: 'scatter',
62-
marker: {
63-
size: item.customdata.map(c => {
64-
// Shifted logistic function: markerSize = 2 * maxMarkerSize * (1 / (1 + exp(-markerSizeContrastRatio * count)) - 0.5)
65-
const k = Math.exp(-markerSizeContrastRatio)
66-
const logisticValue = 1 / (1 + Math.exp(-k * c.count))
67-
return Math.max(1, 2 * maxMarkerSize * (logisticValue - 0.5))
68-
}),
69-
opacity: opacity,
70-
line: {
71-
width: 1,
72-
color: colors.foreground
73-
}
74-
},
107+
marker,
75108
// eslint-disable-next-line @typescript-eslint/no-explicit-any
76109
customdata: item.customdata as any,
77110
hovertemplate:
78111
`<b>X ${xColumn}:</b> ~%{x}<br>` +
79112
`<b>Y ${yColumn}:</b> ~%{y}<br>` +
80113
(breakdownColumn ? `<b>${breakdownColumn}:</b> ${item.name}<br>` : '') +
81114
`<b>Count:</b> %{customdata.count}<br>` +
115+
(hasColorValues ? `<b>${colorColumn}:</b> %{customdata.colorValue:.2f}<br>` : '') +
82116
'<extra></extra>',
83117
}
84118
return trace
@@ -89,12 +123,12 @@ const PlotlyBubblePlot = React.memo(function PlotlyBubblePlot({ data }: PlotlyBu
89123
setPlotlyError((error as Error).message)
90124
return []
91125
}
92-
}, [data, xColumn, yColumn, breakdownColumn, opacity, maxMarkerSize, markerSizeContrastRatio, colors.foreground])
126+
}, [data, xColumn, yColumn, breakdownColumn, colorColumn, colorScale, opacity, maxMarkerSize, markerSizeContrastRatio, colors.foreground])
93127

94-
const baseLayout = usePlotlyLayout({
95-
xTitle: `X: ${xColumn}`,
128+
const baseLayout = usePlotlyLayout({
129+
xTitle: `X: ${xColumn}`,
96130
yTitle: `Y: ${yColumn}`,
97-
showLegend: true
131+
showLegend: breakdownColumn !== null
98132
})
99133

100134
const layout = useMemo((): Partial<Layout> => ({

smoosense-gui/src/lib/features/bubblePlot/bubblePlotSlice.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ export interface BubblePlotDataPoint {
1212
x: number
1313
y: number
1414
count: number
15+
color_value: number | null
1516
}
1617

1718
export interface BubblePlotGroup {
1819
name: string
1920
x: number[]
2021
y: number[]
21-
customdata: Array<{ condExpr: string; count: number }>
22+
customdata: Array<{ condExpr: string; count: number; colorValue: number | null }>
2223
}
2324

2425
export type BubblePlotState = BaseAsyncDataState<BubblePlotGroup[]>
@@ -27,6 +28,7 @@ interface FetchBubblePlotParams {
2728
bubblePlotXColumn: string
2829
bubblePlotYColumn: string
2930
bubblePlotBreakdownColumn: string | null // Optional - BubblePlot can work without breakdown column
31+
bubblePlotColorColumn: string // Optional - column to compute AVG for coloring (empty string = not set)
3032
tablePath: string
3133
queryEngine: string
3234
filterCondition: string | null
@@ -52,6 +54,7 @@ const fetchBubblePlotFunction = async (
5254
bubblePlotXColumn,
5355
bubblePlotYColumn,
5456
bubblePlotBreakdownColumn,
57+
bubblePlotColorColumn,
5558
tablePath,
5659
queryEngine,
5760
filterCondition,
@@ -66,13 +69,21 @@ const fetchBubblePlotFunction = async (
6669
// Use lance_table when queryEngine is lance, otherwise use tablePath
6770
const tableRef = queryEngine === 'lance' ? 'lance_table' : `'${tablePath}'`
6871

69-
// Build query
72+
// Build query with optional color column
73+
const colorColumnSelect = !bubblePlotColorColumn
74+
? 'NULL AS color_col'
75+
: `${sanitizeName(bubblePlotColorColumn)} AS color_col`
76+
const colorColumnAgg = !bubblePlotColorColumn
77+
? 'NULL AS color_value'
78+
: 'AVG(color_col) AS color_value'
79+
7080
const query = `
7181
WITH filtered AS (
7282
SELECT
7383
${sanitizeName(bubblePlotXColumn)} AS x,
7484
${sanitizeName(bubblePlotYColumn)} AS y,
75-
${isNil(bubblePlotBreakdownColumn) ? 'NULL' : sanitizeName(bubblePlotBreakdownColumn)} AS breakdown
85+
${isNil(bubblePlotBreakdownColumn) ? 'NULL' : sanitizeName(bubblePlotBreakdownColumn)} AS breakdown,
86+
${colorColumnSelect}
7687
FROM ${tableRef}
7788
${additionalWhere} x IS NOT NULL AND y IS NOT NULL
7889
), binned AS (
@@ -86,7 +97,8 @@ const fetchBubblePlotFunction = async (
8697
-- Compute the "bubble" center as the average x and y within that bin
8798
AVG(x) AS x,
8899
AVG(y) AS y,
89-
COUNT(*) AS count
100+
COUNT(*) AS count,
101+
${colorColumnAgg}
90102
FROM binned
91103
GROUP BY 1, 2, 3
92104
ORDER BY 1, 2, 3
@@ -109,16 +121,16 @@ const fetchBubblePlotFunction = async (
109121
const xCol = sanitizeName(bubblePlotXColumn)
110122
const yCol = sanitizeName(bubblePlotYColumn)
111123
const breakdownCol = sanitizeName(bubblePlotBreakdownColumn)
112-
124+
113125
const condExpr = [
114126
`${xCol} >= ${xMin}`,
115127
`${xCol} < ${xMax}`,
116128
`${yCol} >= ${yMin}`,
117129
`${yCol} < ${yMax}`,
118130
...(isNil(bubblePlotBreakdownColumn) ? [] : [`${breakdownCol} = '${breakdown}'`])
119131
].join(' AND ')
120-
121-
return { condExpr, count: item.count }
132+
133+
return { condExpr, count: item.count, colorValue: item.color_value }
122134
})
123135
return { name: breakdown || 'All', x, y, customdata }
124136
})

smoosense-gui/src/lib/features/ui/uiSlice.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ interface UiState {
3838
bubblePlotMaxMarkerSize: number
3939
bubblePlotOpacity: number
4040
bubblePlotMarkerSizeContrastRatio: number
41+
bubblePlotColorColumn: string
42+
bubblePlotColorScale: string
4143
heatmapXColumn: string | null
4244
heatmapYColumn: string | null
4345
boxPlotBreakdownColumn: string | null
@@ -83,6 +85,8 @@ const initialState: UiState = {
8385
bubblePlotMaxMarkerSize: 20,
8486
bubblePlotOpacity: 0.7,
8587
bubblePlotMarkerSizeContrastRatio: 4.2,
88+
bubblePlotColorColumn: '',
89+
bubblePlotColorScale: 'Jet',
8690
heatmapXColumn: null,
8791
heatmapYColumn: null,
8892
boxPlotBreakdownColumn: null,
@@ -211,6 +215,12 @@ export const uiSlice = createSlice({
211215
setBubblePlotMarkerSizeContrastRatio: (state, action: PayloadAction<number>) => {
212216
state.bubblePlotMarkerSizeContrastRatio = Math.max(-7, Math.min(7, action.payload))
213217
},
218+
setBubblePlotColorColumn: (state, action: PayloadAction<string>) => {
219+
state.bubblePlotColorColumn = action.payload
220+
},
221+
setBubblePlotColorScale: (state, action: PayloadAction<string>) => {
222+
state.bubblePlotColorScale = action.payload
223+
},
214224
setHeatmapXColumn: (state, action: PayloadAction<string | null>) => {
215225
state.heatmapXColumn = action.payload
216226
},
@@ -304,6 +314,8 @@ export const {
304314
setBubblePlotMaxMarkerSize,
305315
setBubblePlotOpacity,
306316
setBubblePlotMarkerSizeContrastRatio,
317+
setBubblePlotColorColumn,
318+
setBubblePlotColorScale,
307319
setHeatmapXColumn,
308320
setHeatmapYColumn,
309321
setBoxPlotBreakdownColumn,

smoosense-gui/src/lib/hooks/useBubblePlot.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export function useBubblePlot(): UseBubblePlotResult {
2222
const bubblePlotXColumn = useAppSelector((state) => state.ui.bubblePlotXColumn)
2323
const bubblePlotYColumn = useAppSelector((state) => state.ui.bubblePlotYColumn)
2424
const bubblePlotBreakdownColumn = useAppSelector((state) => state.ui.bubblePlotBreakdownColumn)
25+
const bubblePlotColorColumn = useAppSelector((state) => state.ui.bubblePlotColorColumn)
2526
const filterCondition = useAppSelector((state) => extractSqlFilterFromState(state))
2627

2728
// Get stats for X and Y columns to determine bins
@@ -47,13 +48,14 @@ export function useBubblePlot(): UseBubblePlotResult {
4748
bubblePlotXColumn,
4849
bubblePlotYColumn,
4950
bubblePlotBreakdownColumn,
51+
bubblePlotColorColumn,
5052
tablePath,
5153
queryEngine,
5254
filterCondition,
5355
xBin,
5456
yBin
5557
}
56-
}, [tablePath, queryEngine, bubblePlotXColumn, bubblePlotYColumn, bubblePlotBreakdownColumn, filterCondition, xStatsData, yStatsData])
58+
}, [tablePath, queryEngine, bubblePlotXColumn, bubblePlotYColumn, bubblePlotBreakdownColumn, bubblePlotColorColumn, filterCondition, xStatsData, yStatsData])
5759

5860
const { data, loading, error, setNeedRefresh } = useAsyncData({
5961
stateSelector: (state) => state.bubblePlot,

0 commit comments

Comments
 (0)