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 = {