diff --git a/frontend/src2/charts/components/FunnelChartConfigForm.vue b/frontend/src2/charts/components/FunnelChartConfigForm.vue
index 433cdf55d..e1a4eb0b4 100644
--- a/frontend/src2/charts/components/FunnelChartConfigForm.vue
+++ b/frontend/src2/charts/components/FunnelChartConfigForm.vue
@@ -48,16 +48,7 @@ const discrete_dimensions = computed(() =>
v-model="config.value_column"
:column-options="props.columnOptions"
/>
-
+
diff --git a/frontend/src2/charts/helpers.ts b/frontend/src2/charts/helpers.ts
index ec9c86847..d1fc3b361 100644
--- a/frontend/src2/charts/helpers.ts
+++ b/frontend/src2/charts/helpers.ts
@@ -17,7 +17,7 @@ import {
XAxis,
} from '../types/chart.types'
import { QueryResult, QueryResultColumn, QueryResultRow } from '../types/query.types'
-import { getColors, getGradientColors } from './colors'
+import { getColors } from './colors'
interface GeoJSONFeature {
type: string
@@ -545,92 +545,175 @@ export function getFunnelChartOptions(config: FunnelChartConfig, result: QueryRe
const labelColumn = config.label_column.dimension_name
const valueColumn = config.value_column.measure_name
- const labelPosition = config.label_position || 'left'
+ const show_percentage = config.show_percentage ?? true
- const labels = rows.map((r) => r[labelColumn])
- const values = rows.map((r) => r[valueColumn])
+ const categories = rows.map((r) => r[labelColumn] as string)
+ const dataValues = rows.map((r) => r[valueColumn] as number)
- let colors = getGradientColors('blue')
+ const count = dataValues.length
+ const colors = Array.from({ length: count }, (_, i) => {
+ const ratio = count === 1 ? 0 : i / (count - 1)
+ const l = 52 + (82 - 52) * ratio
+ return `hsl(208 67.9% ${l.toFixed(1)}%)`
+ })
+
+ const maxDataValue = Math.max(...dataValues)
+ const maxValue = maxDataValue * 1.05
+ // Square-root scaling: compresses large values and preserves visual gap between small ones
+ const visualValues = dataValues.map((v) =>
+ maxDataValue * Math.sqrt((v as number) / maxDataValue),
+ )
return {
animation: true,
animationDuration: 300,
- color: colors,
+ grid: {
+ left: 16,
+ right: 16,
+ top: 66,
+ bottom: 16,
+ },
+ tooltip: {
+ show: true,
+ trigger: 'item',
+ confine: true,
+ appendToBody: false,
+ formatter: (params: any) => {
+ const value = formatNumber(params.value)
+ const pct =
+ show_percentage && dataValues[0] > 0
+ ? ` (${((params.value / dataValues[0]) * 100).toFixed(0)}%)`
+ : ''
+ return `
+
+
${params.name}
+
${value}${pct}
+
`
+ },
+ backgroundColor: '#fff',
+ borderColor: '#E5E7EB',
+ borderWidth: 1,
+ padding: [8, 12],
+ textStyle: {
+ color: '#111827',
+ fontSize: 13,
+ },
+ extraCssText:
+ 'box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); border-radius: 8px;',
+ },
+ xAxis: {
+ type: 'category',
+ data: categories,
+ boundaryGap: true,
+ show: false,
+ },
+ yAxis: {
+ type: 'value',
+ show: false,
+ min: 0,
+ max: maxValue,
+ },
series: [
{
- name: 'Funnel',
- type: 'funnel',
- orient: 'vertical',
- funnelAlign: 'center',
- top: 'center',
- left: 'center',
- width: '55%',
- height: '75%',
- minSize: '10px',
- maxSize: '100%',
- sort: 'descending',
- label: {
- show: true,
- // position doesn't have any effect
- // it is mapped here to re-render when the label position changes
- // because the label layout function is not changing when the label position changes
- // and so the chart doesn't re-render
- position: labelPosition,
- color: '#565656',
- lineHeight: 16,
- padding: [0, 5, 0, 0],
- formatter: (params: any) => {
- const index = labels.indexOf(params.name)
- const percentage = Number((values[index] / values[0]) * 100).toFixed(0)
- const value = getShortNumber(values[index], 2)
- return `${params.name}\n${value} (${percentage}%)`
- },
- },
- labelLine: { show: false },
- labelLayout(params: any) {
- const leftPos = params.rect.x - 15
- const rightPos = params.rect.x + params.rect.width + 15
-
- if (labelPosition === 'left') {
- return {
- x: leftPos,
- align: 'right',
- }
- }
- if (labelPosition === 'right') {
- return {
- x: rightPos,
- align: 'left',
- }
- }
- if (labelPosition === 'alternate') {
- return {
- x: params.dataIndex % 2 === 0 ? leftPos : rightPos,
- align: params.dataIndex % 2 === 0 ? 'right' : 'left',
- }
+ type: 'custom',
+ emphasis: { disabled: true },
+ data: dataValues.map((val, i) => ({
+ name: categories[i],
+ value: val,
+ itemStyle: { color: colors[i % colors.length] },
+ })),
+ renderItem: (params: any, api: any) => {
+ const i = params.dataIndex
+ const val = dataValues[i] as number
+ const visualVal = visualValues[i]
+ // slope target: top of next bar, or taper last bar slightly
+ const nextVisual =
+ i < visualValues.length - 1
+ ? visualValues[i + 1]
+ : Math.max(visualVal - maxDataValue * 0.06, 0)
+
+ const width = api.size([1, 0])[0]
+ const cx = api.coord([params.dataIndex, 0])[0]
+ const x = cx - width / 2
+ const nextX = cx + width / 2
+
+ const y1 = api.coord([0, visualVal])[1]
+ const y2 = api.coord([0, nextVisual])[1]
+ const yBottom = api.coord([0, 0])[1]
+
+ const r = 8
+ const m = (y2 - y1) / (nextX - x)
+
+ const pctText =
+ show_percentage && dataValues[0] > 0
+ ? ` (${((val / dataValues[0]) * 100).toFixed(0)}%)`
+ : ''
+ const valueText = `${getShortNumber(val, 2)}${pctText}`
+
+ return {
+ type: 'group',
+ children: [
+ {
+ type: 'path',
+ shape: {
+ pathData: `M ${x} ${yBottom} L ${x} ${y1 + r} Q ${x} ${y1} ${x + r} ${y1 + m * r} L ${nextX - r} ${y2 - m * r} Q ${nextX} ${y2} ${nextX} ${y2 + r} L ${nextX} ${yBottom} Z`,
+ },
+ style: {
+ fill: colors[params.dataIndex % colors.length],
+ },
+ emphasis: {
+ style: {
+ fill: colors[params.dataIndex % colors.length],
+ },
+ },
+ },
+ {
+ type: 'text',
+ x: params.dataIndex === 0 ? x : x + 16,
+ y: 8,
+ style: {
+ text: valueText,
+ fill: '#111827',
+ fontSize: 16,
+ fontWeight: 500,
+ textVerticalAlign: 'top',
+ width: width - 32,
+ overflow: 'truncate',
+ },
+ },
+ {
+ type: 'text',
+ x: params.dataIndex === 0 ? x : x + 16,
+ y: 32,
+ style: {
+ text: categories[params.dataIndex] || '',
+ fill: '#6b7280',
+ fontSize: 12,
+ textVerticalAlign: 'top',
+ width: width - 32,
+ overflow: 'truncate',
+ },
+ },
+ ...(params.dataIndex < dataValues.length - 1
+ ? [
+ {
+ type: 'line',
+ shape: {
+ x1: nextX,
+ y1: 0,
+ x2: nextX,
+ y2: api.getHeight(),
+ },
+ style: {
+ stroke: '#E5E7EB',
+ lineWidth: 1,
+ },
+ },
+ ]
+ : []),
+ ],
}
},
- gap: 6,
- data: values.map((value, index) => ({
- name: labels[index],
- value: value,
- itemStyle: {
- color: colors[index],
- borderColor: colors[index],
- borderWidth: 4,
- borderCap: 'round',
- borderJoin: 'round',
- },
- emphasis: {
- itemStyle: {
- color: colors[index],
- borderColor: colors[index],
- borderWidth: 6,
- borderCap: 'round',
- borderJoin: 'round',
- },
- },
- })),
},
],
}
diff --git a/frontend/src2/components/DataTable.vue b/frontend/src2/components/DataTable.vue
index f020a887e..2de14c8e8 100644
--- a/frontend/src2/components/DataTable.vue
+++ b/frontend/src2/components/DataTable.vue
@@ -225,11 +225,11 @@ const pagination = usePagination({
const colorByPercentage = {
0: 'bg-white text-gray-900',
- 10: 'bg-blue-100 text-blue-900',
- 30: 'bg-blue-200 text-blue-900',
- 60: 'bg-blue-300 text-blue-900',
- 90: 'bg-blue-400 text-blue-900',
- 100: 'bg-blue-500 text-white',
+ 10: 'bg-[#338AD8]/10 text-gray-900',
+ 30: 'bg-[#338AD8]/30 text-gray-900',
+ 60: 'bg-[#338AD8]/60 text-gray-900',
+ 90: 'bg-[#338AD8]/90 text-white',
+ 100: 'bg-[#338AD8] text-white',
}
const colorByValues = computed(() => {
diff --git a/frontend/src2/types/chart.types.ts b/frontend/src2/types/chart.types.ts
index 2a6d59b7a..7baa59542 100644
--- a/frontend/src2/types/chart.types.ts
+++ b/frontend/src2/types/chart.types.ts
@@ -105,7 +105,7 @@ export type DonutChartConfig = {
export type FunnelChartConfig = {
label_column: Dimension
value_column: Measure
- label_position?: 'left' | 'right' | 'alternate'
+ show_percentage?: boolean
}
export type TableChartConfig = {