diff --git a/frontend/src2/charts/components/BaseChart.vue b/frontend/src2/charts/components/BaseChart.vue index b55109753..cfff5cd2e 100644 --- a/frontend/src2/charts/components/BaseChart.vue +++ b/frontend/src2/charts/components/BaseChart.vue @@ -5,10 +5,10 @@ import { wheneverChanges } from '../../helpers' import ChartTitle from './ChartTitle.vue' const props = defineProps({ - title: { type: String, required: false }, - subtitle: { type: String, required: false }, - options: { type: Object, required: true }, - onClick: { type: Function, required: false }, + title: { type: String, required: false }, + subtitle: { type: String, required: false }, + options: { type: Object, required: true }, + onClick: { type: Function, required: false }, }) let eChart = null @@ -16,69 +16,64 @@ const chartRef = ref(null) let resizeObserver = null onMounted(async () => { - const series = props.options?.series?.find((s) => s.type === 'map') - const isMap = series && series.type === 'map' - const renderer = isMap ? 'canvas' : 'svg' - eChart = echarts.init(chartRef.value, 'light', { renderer }) + // Choose renderer (map requires canvas) + const isMap = props.options?.series?.some(s => s.type === 'map') + const renderer = isMap ? 'canvas' : 'svg' - await setChartOptions() - props.onClick && eChart.on('click', props.onClick) + eChart = echarts.init(chartRef.value, 'light', { renderer }) - resizeObserver = new ResizeObserver(() => eChart.resize()) - setTimeout( - () => chartRef.value && resizeObserver && resizeObserver.observe(chartRef.value), - 1000, - ) + if (Object.keys(props.options).length) { + eChart.setOption(props.options) + } + + if (props.onClick) { + eChart.on('click', props.onClick) + } + + // Auto-resize chart + resizeObserver = new ResizeObserver(() => { + try { + eChart?.resize() + } catch (_) {} + }) + + setTimeout(() => { + chartRef.value && resizeObserver.observe(chartRef.value) + }, 500) }) onBeforeUnmount(() => { - if (chartRef.value && resizeObserver) resizeObserver.unobserve(chartRef.value) + chartRef.value && resizeObserver?.unobserve(chartRef.value) }) wheneverChanges(() => props.options, setChartOptions, { deep: true }) async function setChartOptions() { - if (!eChart) return - const series = props.options?.series?.find((s) => s.type === 'map') - const isMap = series && series.type === 'map' - if (isMap) { - await registerMap(series.map) - } - eChart.setOption({ ...props.options }) -} + if (!eChart) return -async function registerMap(mapName) { - if (!mapName) return - if (mapName === 'india') { - const mapJson = await import('../../assets/maps_json/india.json') - echarts.registerMap('india', mapJson.default) - } else if (mapName === 'world') { - const mapJson = await import('../../assets/maps_json/world_map.json') - echarts.registerMap('world', mapJson.default) - } + const mapSeries = props.options?.series?.find(s => s.type === 'map') + if (mapSeries) { + await registerMap(mapSeries.map) + } + + eChart.setOption({ ...props.options }) } -defineExpose({ downloadChart }) -function downloadChart() { - const image = new Image() - const type = 'png' - image.src = eChart.getDataURL({ - type, - pixelRatio: 2, - backgroundColor: '#fff', - }) - const link = document.createElement('a') - link.href = image.src - link.download = `${props.title}.${type}` - link.click() +// Load map JSON file when required +async function registerMap(mapName) { + try { + const res = await fetch(`/assets/insights/maps/${mapName}.json`) + const geoJson = await res.json() + echarts.registerMap(mapName, geoJson) + } catch (e) { + console.warn("Map load failed:", mapName) + } } - - - - - - + + + + diff --git a/frontend/src2/charts/components/YAxisConfig.vue b/frontend/src2/charts/components/YAxisConfig.vue index 81f879532..299f3fb8a 100644 --- a/frontend/src2/charts/components/YAxisConfig.vue +++ b/frontend/src2/charts/components/YAxisConfig.vue @@ -2,7 +2,6 @@ import ColorInput from '@/components/Controls/ColorInput.vue' import { debounce } from 'frappe-ui' import { watchEffect } from 'vue' -import Checkbox from '../../components/Checkbox.vue' import DraggableList from '../../components/DraggableList.vue' import InlineFormControlLabel from '../../components/InlineFormControlLabel.vue' import { copy } from '../../helpers' @@ -12,13 +11,14 @@ import CollapsibleSection from './CollapsibleSection.vue' import MeasurePicker from './MeasurePicker.vue' const props = defineProps<{ columnOptions: ColumnOption[] }>() + +// y-axis config const y_axis = defineModel({ required: true, - default: () => ({ - series: [], - }), + default: () => ({ series: [] }), }) +// Ensure at least one series exists const emptySeries = { measure: {} as MeasureOption } watchEffect(() => { if (!y_axis.value?.series?.length) { @@ -30,73 +30,83 @@ function addSeries() { y_axis.value.series.push(copy(emptySeries)) } +// Color update handler const updateColor = debounce((color: string, idx: number) => { - if (!y_axis.value.series[idx].color) { - y_axis.value.series[idx].color = [] - } y_axis.value.series[idx].color = color ? [color] : [] }, 500) + +// Label position options +const labelPositionOptions = [ + { label: 'Top', value: 'top' }, + { label: 'Center', value: 'inside' }, + { label: 'Bottom', value: 'bottom' }, +] + + Series - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - - - - + Add series - - + + + + + + + + + Add series + + + + + + + + + + + { + diff --git a/frontend/src2/charts/helpers.ts b/frontend/src2/charts/helpers.ts index a8cbfefc9..32d4aa328 100644 --- a/frontend/src2/charts/helpers.ts +++ b/frontend/src2/charts/helpers.ts @@ -166,11 +166,15 @@ function getDataZoom(show: boolean, swapAxes = false) { } } -export function getBarChartOptions(config: BarChartConfig, result: QueryResult, swapAxes = false) { +export function getBarChartOptions( + config: BarChartConfig, + result: QueryResult, + swapAxes = false +) { const _columns = result.columns const _rows = result.rows - const number_columns = _columns.filter((c) => FIELDTYPES.NUMBER.includes(c.type)) + const number_columns = _columns.filter(c => FIELDTYPES.NUMBER.includes(c.type)) const show_legend = number_columns.length > 1 const show_scrollbar = config.y_axis.show_scrollbar || false @@ -185,9 +189,10 @@ export function getBarChartOptions(config: BarChartConfig, result: QueryResult, min: config.y_axis.min, max: config.y_axis.max, }) + const rightYAxis = getYAxis({ normalized: config.y_axis.normalize }) - const hasRightAxis = config.y_axis.series.some((s) => s.align === 'Right') - const yAxis = !hasRightAxis ? [leftYAxis] : [leftYAxis, rightYAxis] + const hasRightAxis = config.y_axis.series.some(s => s.align === 'Right') + const yAxis = hasRightAxis ? [leftYAxis, rightYAxis] : [leftYAxis] const sortedRows = xAxisIsDate ? _rows.sort((a, b) => { @@ -198,30 +203,32 @@ export function getBarChartOptions(config: BarChartConfig, result: QueryResult, : _rows const total_per_x_value = _rows.reduce((acc, row) => { - const x_value = row[config.x_axis.dimension.dimension_name] - if (!acc[x_value]) acc[x_value] = 0 - number_columns.forEach((m) => (acc[x_value] += row[m.name])) + const x_val = row[config.x_axis.dimension.dimension_name] + acc[x_val] ||= 0 + number_columns.forEach(m => (acc[x_val] += row[m.name])) return acc }, {} as Record) const getSeriesData = (column: string) => sortedRows - .map((r) => { - const x_value = r[config.x_axis.dimension.dimension_name] - const y_value = r[column] - const normalize = config.y_axis.normalize - if (!normalize) { - return [x_value, y_value] - } - - const total = total_per_x_value[x_value] - const normalized_value = total ? (y_value / total) * 100 : 0 - return [x_value, normalized_value] + .map(r => { + const x_val = r[config.x_axis.dimension.dimension_name] + const y_val = r[column] + if (!config.y_axis.normalize) return [x_val, y_val] + const total = total_per_x_value[x_val] + return [x_val, total ? (y_val / total) * 100 : 0] }) - .map((d) => (swapAxes ? [d[1], d[0]] : d)) + .map(d => (swapAxes ? [d[1], d[0]] : d)) const colors = getColors() + // Global label position + const globalLabelPosition = + config.y_axis.label_position && + ['top', 'inside', 'bottom'].includes(config.y_axis.label_position) + ? config.y_axis.label_position + : 'top' + return { animation: true, animationDuration: 700, @@ -230,49 +237,46 @@ export function getBarChartOptions(config: BarChartConfig, result: QueryResult, xAxis: swapAxes ? yAxis : xAxis, yAxis: swapAxes ? xAxis : yAxis, dataZoom: getDataZoom(show_scrollbar, swapAxes), + series: number_columns.map((c, idx) => { const serie = getSerie(config, c.name) - const is_right_axis = serie.align === 'Right' - - const color = serie.color?.[0] || colors[idx] const type = serie.type?.toLowerCase() || 'bar' - const stack = type === 'bar' && config.y_axis.stack ? 'stack' : undefined - const show_data_labels = serie.show_data_labels ?? config.y_axis.show_data_labels - const data = getSeriesData(c.name) - const name = config.split_by?.dimension?.column_name ? c.name : serie.measure.measure_name || c.name + const isRight = serie.align === 'Right' - const roundedCorners = swapAxes ? [0, 2, 2, 0] : [2, 2, 0, 0] - const isLast = idx === number_columns.length - 1 + const data = getSeriesData(c.name) + const color = serie.color?.[0] || colors[idx] + const show_labels = serie.show_data_labels ?? config.y_axis.show_data_labels - let labelPosition = 'inside' - if (type == 'line') { - labelPosition = 'top' - } + let labelPosition = globalLabelPosition + if (type === 'line') labelPosition = 'top' return { type, - stack: config.y_axis.overlap ? undefined : stack, - name, + name: config.split_by?.dimension?.column_name + ? c.name + : serie.measure.measure_name || c.name, data: swapAxes ? data.reverse() : data, - color: color, + color, + stack: type === 'bar' && config.y_axis.stack ? 'stack' : undefined, label: { - show: show_data_labels, + show: show_labels, position: labelPosition, - formatter: (params: any) => { - const _val = swapAxes ? params.value?.[0] : params.value?.[1] - return getShortNumber(_val, 1) + formatter: params => { + const val = swapAxes ? params.value?.[0] : params.value?.[1] + return getShortNumber(val, 1) }, fontSize: 11, }, barGap: config.y_axis.overlap ? '-100%' : undefined, labelLayout: { hideOverlap: true }, - yAxisIndex: is_right_axis ? 1 : 0, + yAxisIndex: isRight ? 1 : 0, itemStyle: { - color: color, - borderRadius: roundedCorners, + color, + borderRadius: swapAxes ? [0, 2, 2, 0] : [2, 2, 0, 0], }, } }), + tooltip: getTooltip({ xAxisIsDate, granularity, diff --git a/frontend/src2/types/chart.types.ts b/frontend/src2/types/chart.types.ts index 275b14ba2..c6576830e 100644 --- a/frontend/src2/types/chart.types.ts +++ b/frontend/src2/types/chart.types.ts @@ -31,6 +31,9 @@ export type YAxis = { show_axis_label?: boolean show_data_labels?: boolean show_scrollbar?: boolean + + // 🆕 NEW FIELD — global label position for bar chart labels + label_position?: 'top' | 'inside' | 'bottom' } export type Series = { name?: string
Series