Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions frontend/src2/charts/components/FunnelChartConfigForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,7 @@ const discrete_dimensions = computed(() =>
v-model="config.value_column"
:column-options="props.columnOptions"
/>
<FormControl
v-model="config.label_position"
label="Label Position"
type="select"
:options="[
{ label: __('Left'), value: 'left' },
{ label: __('Right'), value: 'right' },
{ label: __('Alternate'), value: 'alternate' },
]"
/>
<Toggle v-model="config.show_percentage" :label="__('Show Percentage')" />
</div>
</CollapsibleSection>
</template>
237 changes: 160 additions & 77 deletions frontend/src2/charts/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 `
<div class="flex items-center justify-between gap-5">
<div>${params.name}</div>
<div class="font-bold">${value}${pct}</div>
</div>`
},
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',
},
},
})),
},
],
}
Expand Down
10 changes: 5 additions & 5 deletions frontend/src2/components/DataTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src2/types/chart.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading