From c6cb367d2f81715ec1f163975b3dfed371e1afed Mon Sep 17 00:00:00 2001 From: "Marceline (matho)" Date: Mon, 1 Jun 2026 14:05:28 +0200 Subject: [PATCH 1/3] WIP --- src/actions/edit_actions.ts | 8 + .../bottom_bar_statistic.css | 21 +- .../bottom_bar_statistic.ts | 5 + .../bottom_bar_statistic.xml | 25 +- src/components/icons/icons.xml | 11 +- .../data_analysis/data_analysis_panel.css | 26 ++ .../data_analysis/data_analysis_panel.ts | 333 ++++++++++++++++++ .../data_analysis/data_analysis_panel.xml | 65 ++++ .../data_analysis/data_analysis_store.ts | 273 ++++++++++++++ .../figures/charts/smart_chart_engine.ts | 2 +- src/registries/menus/cell_menu_registry.ts | 5 + src/registries/side_panel_registry.ts | 6 + .../context_menu_component.test.ts.snap | 38 ++ 13 files changed, 800 insertions(+), 18 deletions(-) create mode 100644 src/components/side_panel/data_analysis/data_analysis_panel.css create mode 100644 src/components/side_panel/data_analysis/data_analysis_panel.ts create mode 100644 src/components/side_panel/data_analysis/data_analysis_panel.xml create mode 100644 src/components/side_panel/data_analysis/data_analysis_store.ts diff --git a/src/actions/edit_actions.ts b/src/actions/edit_actions.ts index 6a3c2f376e..ff98114619 100644 --- a/src/actions/edit_actions.ts +++ b/src/actions/edit_actions.ts @@ -73,6 +73,14 @@ export const pasteSpecialFormat: ActionSpec = { execute: ACTIONS.PASTE_FORMAT_ACTION, }; +export const dataAnalysis: ActionSpec = { + name: _t("Data analysis"), + execute: (env) => + env.openSidePanel("DataAnalysisPanel", { zones: env.model.getters.getSelectedZones() }), + icon: "o-spreadsheet-Icon.COLUMN_STATS", + isEnabled: (env) => !env.isSmall, +}; + export const findAndReplace: ActionSpec = { name: _t("Find and replace"), shortcut: "Ctrl+H", diff --git a/src/components/bottom_bar/bottom_bar_statistic/bottom_bar_statistic.css b/src/components/bottom_bar/bottom_bar_statistic/bottom_bar_statistic.css index 4f5bf55d0e..3e99b2b708 100644 --- a/src/components/bottom_bar/bottom_bar_statistic/bottom_bar_statistic.css +++ b/src/components/bottom_bar/bottom_bar_statistic/bottom_bar_statistic.css @@ -1,10 +1,19 @@ .o-spreadsheet { - .o-selection-statistic { - margin-right: 20px; - padding: 4px 4px 4px 8px; - cursor: pointer; - &:hover { - background-color: var(--os-background-gray-color-hover) !important; + .o-bottom-bar-statistic { + .o-selection-statistic { + margin-right: 10px; + padding: 4px 4px 4px 8px; + cursor: pointer; + &:hover { + background-color: var(--os-background-gray-color-hover) !important; + } + } + .o-icon { + width: 30px; + height: 30px; + } + .o-data-analysis-button { + cursor: pointer; } } } diff --git a/src/components/bottom_bar/bottom_bar_statistic/bottom_bar_statistic.ts b/src/components/bottom_bar/bottom_bar_statistic/bottom_bar_statistic.ts index 4187969cbc..5fd0e78677 100644 --- a/src/components/bottom_bar/bottom_bar_statistic/bottom_bar_statistic.ts +++ b/src/components/bottom_bar/bottom_bar_statistic/bottom_bar_statistic.ts @@ -80,4 +80,9 @@ export class BottomBarStatistic extends Component { (fnValue?.value !== undefined ? formatValue(fnValue.value(), { locale }) : "__") ); } + + showDataAnalysis() { + const zones = this.env.model.getters.getSelectedZones(); + this.env.openSidePanel("DataAnalysisPanel", { zones }); + } } diff --git a/src/components/bottom_bar/bottom_bar_statistic/bottom_bar_statistic.xml b/src/components/bottom_bar/bottom_bar_statistic/bottom_bar_statistic.xml index e41bb59073..0b0cbe4482 100644 --- a/src/components/bottom_bar/bottom_bar_statistic/bottom_bar_statistic.xml +++ b/src/components/bottom_bar/bottom_bar_statistic/bottom_bar_statistic.xml @@ -1,15 +1,20 @@ - -
- - - - -
-
+
+ +
+ + + + +
+
+ + + +
diff --git a/src/components/icons/icons.xml b/src/components/icons/icons.xml index 5298a1f9a0..3f1e5b8f14 100644 --- a/src/components/icons/icons.xml +++ b/src/components/icons/icons.xml @@ -1145,7 +1145,7 @@ - + + + + + + + diff --git a/src/components/side_panel/data_analysis/data_analysis_panel.css b/src/components/side_panel/data_analysis/data_analysis_panel.css new file mode 100644 index 0000000000..e394337dea --- /dev/null +++ b/src/components/side_panel/data_analysis/data_analysis_panel.css @@ -0,0 +1,26 @@ +.o-spreadsheet { + .o-data-analysis-title { + font-size: 16px; + } + .o-row-switcher { + cursor: pointer; + } + .o-data-analysis-row { + padding: 1px 0; + } + .o-data-analysis-row:nth-child(even) { + background: var(--os-gray-100); + } + .o-column-frequencies-row { + cursor: pointer; + } + .o-column-frequencies-row:hover span { + font-weight: bold; + } + + .o-data-analysis-chart { + height: 200px; + cursor: pointer; + border: 1px solid var(--os-border-color); + } +} diff --git a/src/components/side_panel/data_analysis/data_analysis_panel.ts b/src/components/side_panel/data_analysis/data_analysis_panel.ts new file mode 100644 index 0000000000..238650a0ca --- /dev/null +++ b/src/components/side_panel/data_analysis/data_analysis_panel.ts @@ -0,0 +1,333 @@ +import { onWillUnmount, proxy, signal } from "@odoo/owl"; +import { Chart, ChartConfiguration } from "chart.js/auto"; +import { DEFAULT_FIGURE_HEIGHT, DEFAULT_FIGURE_WIDTH, HIGHLIGHT_COLOR } from "../../../constants"; +import { lightenColor } from "../../../helpers/color"; +import { SpreadsheetChart } from "../../../helpers/figures/chart"; +import { + categorizeColumns, + getSmartChartDefinition, +} from "../../../helpers/figures/charts/smart_chart_engine"; +import { clipTextWithEllipsis } from "../../../helpers/text_helper"; +import { UuidGenerator } from "../../../helpers/uuid"; +import { toZone, zoneToXc } from "../../../helpers/zones"; +import { Component, useLayoutEffect } from "../../../owl3_compatibility_layer"; +import { useLocalStore } from "../../../store_engine/store_hooks"; +import { _t } from "../../../translation"; +import { Highlight, UID, Zone } from "../../../types/misc"; +import { SpreadsheetChildEnv } from "../../../types/spreadsheet_env"; +import { Store } from "../../../types/store_engine"; +import { useHighlights } from "../../helpers/highlight_hook"; +import { NumberInput } from "../../number_input/number_input"; +import { SelectionInput } from "../../selection_input/selection_input"; +import { BadgeSelection } from "../components/badge_selection/badge_selection"; +import { SidePanelCollapsible } from "../components/collapsible/side_panel_collapsible"; +import { Section } from "../components/section/section"; +import { DataAnalysisStore } from "./data_analysis_store"; + +interface Props { + onCloseSidePanel: () => void; + zones: Zone[]; +} + +const CURRENT_SELECTION_COLOR = lightenColor(HIGHLIGHT_COLOR, 0.25); + +export class DataAnalysisPanel extends Component { + static template = "o-spreadsheet-DataAnalysisPanel"; + static props = { onCloseSidePanel: Function, zones: Array }; + static components = { + NumberInput, + SidePanelCollapsible, + BadgeSelection, + Section, + SelectionInput, + }; + + state = proxy({ + currentChart: "count", + currentFrequencyOrder: "descending", + highlightPositions: [] as { row: number; col: number }[], + pendingRanges: [] as string[], + }); + + store!: Store; + private chartCanvasRef = signal(null); + private chartDivRef = signal(null); + private chart?: Chart; + private sheetId?: UID; + + setup() { + this.sheetId = this.env.model.getters.getActiveSheetId(); + const initialRanges = this.props.zones.map(zoneToXc); + this.state.pendingRanges = initialRanges; + this.store = useLocalStore(DataAnalysisStore, initialRanges); + useHighlights(this); + onWillUnmount(() => this.destroyChart()); + useLayoutEffect( + () => { + this.updateChart(); + }, + () => [this.store.countChartData, this.state.currentChart] + ); + } + + get charts() { + return [ + { value: "count", label: _t("Count"), icon: "o-spreadsheet-Icon.COUNT_CHART" }, + { value: "histogram", label: _t("Distribution"), icon: "o-spreadsheet-Icon.COUNT_CHART" }, + ]; + } + + get frequencyOrders() { + return [ + { value: "descending", label: _t("Descending"), icon: "o-spreadsheet-Icon.DESCENDING_SORT" }, + { value: "ascending", label: _t("Ascending"), icon: "o-spreadsheet-Icon.ASCENDING_SORT" }, + ]; + } + + get shouldShowChart(): boolean { + if (this.state.currentChart === "histogram") { + return this.store.numericValues.length > 0; + } else if (this.state.currentChart === "count") { + return this.store.values.length > 0; + } + return false; + } + + get chartErrorMessage(): string | null { + if (this.state.currentChart === "histogram" && this.store.numericValues.length === 0) { + return _t("No numeric values to display."); + } + if (this.state.currentChart === "count" && this.store.values.length === 0) { + return _t("No values to display."); + } + return null; + } + + get zonesType() { + return categorizeColumns( + this.store.ranges?.map((range) => toZone(range)) ?? [], + this.env.model.getters + ); + } + + get smartChartDefinition() { + return getSmartChartDefinition( + this.store.ranges?.map((range) => toZone(range)) ?? [], + this.env.model.getters + ); + } + + private getChartConfiguration(): ChartConfiguration | null { + const getters = this.env.model.getters; + const activeSheetId = getters.getActiveSheetId(); + const chart = SpreadsheetChart.fromStrDefinition( + getters, + activeSheetId, + this.smartChartDefinition + ); + const runtime = chart.getRuntime(getters, "myChart"); + if (!("chartJsConfig" in runtime)) { + return null; + } + let config = runtime.chartJsConfig; + const canvas = this.chartCanvasRef(); + const ctx2d = canvas?.getContext("2d") ?? null; + config = { + ...config, + options: { + ...config.options, + plugins: { + ...config.options?.plugins, + legend: { + ...config.options?.plugins?.legend, + labels: { + ...(config.options?.plugins?.legend as any)?.labels, + font: { size: 9 }, + }, + }, + tooltip: { enabled: false }, + }, + events: [], + animation: false, + scales: { + ...config.options?.scales, + x: { + ...config.options?.scales?.x, + ticks: { + ...(config.options?.scales?.x as any)?.ticks, + font: { size: 9 }, + callback: function (value: any) { + if (Math.floor(value) !== value) { + return ""; + } + const label = (this as any).getLabelForValue(value) as string | undefined; + if (!label) { + return ""; + } + if (!ctx2d) { + return label; + } + return clipTextWithEllipsis(ctx2d, label, 50); + }, + //maxRotation: 90, + //minRotation: 90, + }, + }, + y: { + ...config.options?.scales?.y, + ticks: { + ...(config.options?.scales?.y as any)?.ticks, + font: { size: 9 }, + }, + }, + }, + }, + }; + return config; + } + + startDragAndDrop(ev: MouseEvent) { + const canvas = this.chartCanvasRef(); + if (!canvas) { + return; + } + const div = this.chartDivRef(); + if (!div) { + return; + } + const rect = canvas.getBoundingClientRect(); + const { position, left, top } = getComputedStyle(div); + const offsetX = ev.clientX - rect.left; + const offsetY = ev.clientY - rect.top; + const onMouseMove = (moveEvent: MouseEvent) => { + div.style.position = "absolute"; + div.style.left = `${moveEvent.clientX - offsetX}px`; + div.style.top = `${moveEvent.clientY - offsetY}px`; + }; + const onMouseUp = (mouseEvent: MouseEvent) => { + //get grid-overlay dimensions + const gridOverlay = document.querySelector(".o-grid-overlay") as HTMLElement | null; + if (!gridOverlay) { + return; + } + const gridRect = gridOverlay.getBoundingClientRect(); + if ( + mouseEvent.clientX > gridRect.left && + mouseEvent.clientX < gridRect.right && + mouseEvent.clientY > gridRect.top && + mouseEvent.clientY < gridRect.bottom + ) { + const { scrollX, scrollY } = this.env.model.getters.getActiveSheetScrollInfo(); + const x = mouseEvent.clientX - gridRect.left - offsetX + scrollX; + const y = mouseEvent.clientY - gridRect.top - offsetY + scrollY; + + const { col, row, offset } = this.env.model.getters.getPositionAnchorOffset({ x, y }); + + this.env.model.dispatch("CREATE_CHART", { + chartId: UuidGenerator.smallUuid(), + figureId: UuidGenerator.smallUuid(), + sheetId: this.env.model.getters.getActiveSheetId(), + size: { width: DEFAULT_FIGURE_WIDTH, height: DEFAULT_FIGURE_HEIGHT }, + definition: this.smartChartDefinition, + col, + row, + offset, + }); + } + div.style.position = position; + div.style.left = left; + div.style.top = top; + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + } + + private createChart() { + if (!globalThis.Chart) { + throw new Error("Chart.js library is not loaded"); + } + const canvas = this.chartCanvasRef(); + if (!canvas) { + return; + } + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + const config = this.getChartConfiguration(); + if (!config) { + return; + } + //@ts-ignore + this.chart = new globalThis.Chart!(ctx, config); + } + + private updateChart() { + const config = this.getChartConfiguration(); + if (!config) { + this.destroyChart(); + return; + } + if (!this.chart) { + this.createChart(); + return; + } + this.chart.config.options = config.options; + this.chart.data = config.data; + this.chart.update(); + } + + private destroyChart() { + this.chart?.destroy(); + this.chart = undefined; + } + + switchChart(chartType: string) { + this.state.currentChart = chartType; + } + + onRangeUpdate(ranges: string[]) { + this.state.pendingRanges = ranges; + } + + onRangeConfirmed() { + this.store.updateRanges(this.state.pendingRanges); + this.updateChart(); + } + + switchFrequencyOrder(order: string) { + this.state.currentFrequencyOrder = order; + } + + get valueFrequencies(): { value: string; count: number }[] { + const orderingCriterion = + this.state.currentFrequencyOrder === "ascending" + ? (a: { count: number }, b: { count: number }) => a.count - b.count + : (a: { count: number }, b: { count: number }) => b.count - a.count; + return this.store.valueFrequencies.sort(orderingCriterion).slice(0, 5); + } + + get highlights(): Highlight[] { + const sheetId = this.env.model.getters.getActiveSheetId(); + if (sheetId !== this.sheetId) { + return []; + } + return ( + this.store.ranges?.map((range) => ({ + range: this.env.model.getters.getRangeFromSheetXC(sheetId, range), + color: CURRENT_SELECTION_COLOR, + interactive: false, + })) ?? [] + ); + } + + highlightFrequencyPositions(positions: { row: number; col: number }[]) { + this.state.highlightPositions = positions; + } + + clearHighlights() { + this.state.highlightPositions = []; + } +} diff --git a/src/components/side_panel/data_analysis/data_analysis_panel.xml b/src/components/side_panel/data_analysis/data_analysis_panel.xml new file mode 100644 index 0000000000..77893ae822 --- /dev/null +++ b/src/components/side_panel/data_analysis/data_analysis_panel.xml @@ -0,0 +1,65 @@ + + +
+
+
+ +
+
+ + +
+
+ +
+
+
+
+ + +
+ +
+ + +
+
+
+
+
+ + +
+ +
+
+ +
+ + +
+
+
+
+
+
+
+
diff --git a/src/components/side_panel/data_analysis/data_analysis_store.ts b/src/components/side_panel/data_analysis/data_analysis_store.ts new file mode 100644 index 0000000000..a95f047d49 --- /dev/null +++ b/src/components/side_panel/data_analysis/data_analysis_store.ts @@ -0,0 +1,273 @@ +import { sum } from "../../../functions/helper_math"; +import { average, max, median, min } from "../../../functions/helper_statistical"; +import { formatValue } from "../../../helpers/format/format"; +import { isDefined } from "../../../helpers/misc"; +import { splitReference } from "../../../helpers/references"; +import { + computeStatisticFnResults, + SelectionStatisticFunction, + StatisticFnResults, +} from "../../../helpers/selection_statistic_functions"; +import { toZone } from "../../../helpers/zones"; +import { SpreadsheetStore } from "../../../stores/spreadsheet_store"; +import { _t } from "../../../translation"; +import { CellValue, CellValueType, EvaluatedCell } from "../../../types/cells"; +import { Command, invalidateEvaluationCommands } from "../../../types/commands"; +import { LocaleFormat } from "../../../types/format"; +import { Position } from "../../../types/misc"; +import { Get } from "../../../types/store_engine"; + +const columnStatisticFunctions: SelectionStatisticFunction[] = [ + { + name: _t("Total rows"), + types: Object.values(CellValueType), + compute: (values, locale) => values.length, + format: "0", + }, + { + name: _t("Unique values"), + types: [CellValueType.number, CellValueType.text, CellValueType.boolean, CellValueType.error], + compute: (values, locale) => { + const uniqueValues = new Set(); + for (const cell of values) { + uniqueValues.add(cell.value as string | number | boolean); + } + return uniqueValues.size; + }, + format: "0", + }, + { + name: _t("Sum"), + types: [CellValueType.number], + compute: (values, locale) => sum([[values]], locale), + }, + { + name: _t("Average"), + types: [CellValueType.number], + compute: (values, locale) => average([[values]], locale), + }, + { + name: _t("Median"), + types: [CellValueType.number], + compute: (values, locale) => median([[values]], locale) ?? "", + }, + { + name: _t("Minimum value"), + types: [CellValueType.number], + compute: (values, locale) => min([[values]], locale).value, + }, + { + name: _t("Maximum value"), + types: [CellValueType.number], + compute: (values, locale) => max([[values]], locale).value, + }, +]; + +function buildEmptyStatisticFnResults( + selectionStatisticFunctions: SelectionStatisticFunction[] +): StatisticFnResults { + const statisticFnResults: StatisticFnResults = {}; + for (const fn of selectionStatisticFunctions) { + statisticFnResults[fn.name] = undefined; + } + return statisticFnResults; +} + +interface CountChartData { + data: number[]; + labels: string[]; + positions: Position[][]; +} + +interface PositionedValue extends Position { + value: T; +} + +export class DataAnalysisStore extends SpreadsheetStore { + mutators = ["updateRanges"] as const; + statisticFnResults: StatisticFnResults = buildEmptyStatisticFnResults(columnStatisticFunctions); + numericValues: PositionedValue[] = []; + values: PositionedValue[] = []; + dataFormat?: string; + countChartData?: CountChartData; + private isDirty = false; + ranges?: string[]; + + constructor(get: Get, initialRanges: string[]) { + super(get); + this.model.selection.observe(this, { + handleEvent: this.refreshStatistics.bind(this), + }); + this.onDispose(() => { + this.model.selection.unobserve(this); + }); + this.ranges = initialRanges; + this.refreshStatistics(); + } + + handle(cmd: Command) { + if ( + invalidateEvaluationCommands.has(cmd.type) || + (cmd.type === "UPDATE_CELL" && ("content" in cmd || "format" in cmd)) + ) { + this.isDirty = true; + } + switch (cmd.type) { + case "HIDE_COLUMNS_ROWS": + case "UNHIDE_COLUMNS_ROWS": + case "GROUP_HEADERS": + case "UNGROUP_HEADERS": + case "ACTIVATE_SHEET": + case "ACTIVATE_NEXT_SHEET": + case "ACTIVATE_PREVIOUS_SHEET": + case "EVALUATE_CELLS": + case "UNDO": + case "REDO": + this.isDirty = true; + } + } + + finalize() { + if (this.isDirty) { + this.isDirty = false; + this.refreshStatistics(); + } + } + + private computeCountChartData(): CountChartData | undefined { + if (this.ranges === undefined) { + return undefined; + } + const values = this.numericValues.length ? this.numericValues : this.values; + if (!values.length) { + return undefined; + } + const countMap = new Map(); + for (const val of values) { + if (val.value === null || val.value === undefined) { + continue; + } + const formattedValue = + typeof val.value === "number" + ? formatValue(val.value, this.localeFormat) + : val.value.toString(); + if (!countMap.has(formattedValue)) { + countMap.set(formattedValue, { positions: [], count: 0, value: val.value }); + } + countMap.get(formattedValue)!.positions.push({ row: val.row, col: val.col }); + countMap.get(formattedValue)!.count += 1; + } + const data: number[] = []; + const positions: Position[][] = []; + const labels: string[] = []; + Array.from(countMap.entries()) + .sort((a, b) => b[1].count - a[1].count) + .forEach(([val, count]) => { + labels.push(val); + data.push(count.count); + positions.push(count.positions); + }); + + return { data, labels, positions }; + } + + get valueFrequencies(): { + value: string; + count: number; + positions: Position[]; + }[] { + const count = this.countChartData; + if (!count) { + return []; + } + return count.labels.map((value, index) => ({ + value, + count: count.data[index], + positions: count.positions[index], + })); + } + + updateRanges(ranges: string[]) { + this.ranges = ranges; + this.refreshStatistics(); + } + + private refreshStatistics() { + const getters = this.getters; + const { col } = getters.getActivePosition(); + const formatsInDataset = this.ranges + ?.map((range) => { + const { sheetName, xc } = splitReference(range); + const sheetId = getters.getSheetIdByName(sheetName) ?? getters.getActiveSheetId(); + const zone = toZone(xc); + getters + .getEvaluatedCellsInZone(sheetId, { + top: zone.top, + left: col, + bottom: getters.getNumberRows(sheetId) - 1, + right: col, + }) + .map((cell) => cell.format); + }) + .flat(); + this.dataFormat = formatsInDataset?.find(isDefined) ?? "0.00"; + + const cells: EvaluatedCell[] = []; + const numericValues: { row: number; col: number; value: number }[] = []; + const values: { row: number; col: number; value: EvaluatedCell["value"] }[] = []; + + for (const range of this.ranges ?? []) { + const { sheetName, xc } = splitReference(range); + const zone = toZone(xc); + const sheetId = getters.getSheetIdByName(sheetName) ?? getters.getActiveSheetId(); + for (let col = zone.left; col <= zone.right; col++) { + for (let row = zone.top; row <= zone.bottom; row++) { + if (getters.isRowHidden(sheetId, row) || getters.isColHidden(sheetId, col)) { + continue; + } + + const evaluatedCell = getters.getEvaluatedCell({ sheetId, col: col, row }); + cells.push(evaluatedCell); + if ( + evaluatedCell.type !== CellValueType.empty && + evaluatedCell.type !== CellValueType.error + ) { + values.push({ row, col, value: evaluatedCell.value }); + if (evaluatedCell.type === CellValueType.number) { + numericValues.push({ row, col, value: evaluatedCell.value }); + } + } + } + } + } + + const locale = getters.getLocale(); + this.statisticFnResults = computeStatisticFnResults(columnStatisticFunctions, cells, locale); + this.numericValues = numericValues; + this.values = values; + this.countChartData = this.computeCountChartData(); + } + + private get localeFormat(): LocaleFormat { + return { locale: this.getters.getLocale(), format: this.dataFormat }; + } + + get statItems(): { + name: string; + value: string; + }[] { + const localeFormat = this.localeFormat; + return Object.entries(this.statisticFnResults).map(([name, fnValue]) => { + if (fnValue?.value === undefined) { + return { name, value: "—" }; + } + return { + name, + value: formatValue(fnValue.value(), { + locale: localeFormat.locale, + format: fnValue.format ?? localeFormat.format, + }), + }; + }); + } +} diff --git a/src/helpers/figures/charts/smart_chart_engine.ts b/src/helpers/figures/charts/smart_chart_engine.ts index 38b76a908a..1e13ee849b 100644 --- a/src/helpers/figures/charts/smart_chart_engine.ts +++ b/src/helpers/figures/charts/smart_chart_engine.ts @@ -76,7 +76,7 @@ function detectColumnType(cells: EvaluatedCell[]): ColumnType { return detectedType; } -function categorizeColumns(zones: Zone[], getters: Getters): ColumnInfo[] { +export function categorizeColumns(zones: Zone[], getters: Getters): ColumnInfo[] { const columns: ColumnInfo[] = []; for (const zone of getZonesByColumns(zones)) { const cells = getters.getEvaluatedCellsInZone(getters.getActiveSheetId(), zone); diff --git a/src/registries/menus/cell_menu_registry.ts b/src/registries/menus/cell_menu_registry.ts index 30d6613840..e3b9500083 100644 --- a/src/registries/menus/cell_menu_registry.ts +++ b/src/registries/menus/cell_menu_registry.ts @@ -38,6 +38,11 @@ cellMenuRegistry ...ACTION_EDIT.pasteSpecialFormat, sequence: 20, }) + .add("data_analysis", { + ...ACTION_EDIT.dataAnalysis, + sequence: 50, + separator: true, + }) .add("add_row_before", { ...ACTION_INSERT.cellInsertRowsBefore, sequence: 70, diff --git a/src/registries/side_panel_registry.ts b/src/registries/side_panel_registry.ts index ed68fc91d9..975267cc0e 100644 --- a/src/registries/side_panel_registry.ts +++ b/src/registries/side_panel_registry.ts @@ -3,6 +3,7 @@ import { ChartPanel } from "../components/side_panel/chart/main_chart_panel/main import { ColumnStatsPanel } from "../components/side_panel/column_stats/column_stats_panel"; import { ConditionalFormattingEditor } from "../components/side_panel/conditional_formatting/cf_editor/cf_editor"; import { ConditionalFormatPreviewList } from "../components/side_panel/conditional_formatting/cf_preview_list/cf_preview_list"; +import { DataAnalysisPanel } from "../components/side_panel/data_analysis/data_analysis_panel"; import { DataValidationPanel } from "../components/side_panel/data_validation/data_validation_panel"; import { DataValidationEditor } from "../components/side_panel/data_validation/dv_editor/dv_editor"; import { FindAndReplacePanel } from "../components/side_panel/find_and_replace/find_and_replace"; @@ -126,6 +127,11 @@ sidePanelRegistry.add("ColumnStats", { Body: ColumnStatsPanel, }); +sidePanelRegistry.add("DataAnalysisPanel", { + title: _t("Data analysis"), + Body: DataAnalysisPanel, +}); + sidePanelRegistry.add("TableSidePanel", { title: _t("Edit table"), Body: TablePanel, diff --git a/tests/menus/__snapshots__/context_menu_component.test.ts.snap b/tests/menus/__snapshots__/context_menu_component.test.ts.snap index f8ec47303e..e2a698cd72 100644 --- a/tests/menus/__snapshots__/context_menu_component.test.ts.snap +++ b/tests/menus/__snapshots__/context_menu_component.test.ts.snap @@ -171,6 +171,44 @@ exports[`Context MenuPopover integration tests context menu simple rendering 1`] /> +
+
+
+ + + +
+ +
+ Data analysis +
+ + +
+
+
+ +
Date: Wed, 3 Jun 2026 14:36:00 +0200 Subject: [PATCH 2/3] wip2 --- .../data_analysis/chart_suggestion_preview.ts | 226 +++ .../chart_suggestion_preview.xml | 19 + .../data_analysis/data_analysis_panel.css | 53 + .../data_analysis/data_analysis_panel.ts | 246 +--- .../data_analysis/data_analysis_panel.xml | 28 +- .../figures/charts/chart_suggestion_engine.ts | 1252 +++++++++++++++++ .../figures/charts/smart_chart_engine.ts | 2 +- 7 files changed, 1592 insertions(+), 234 deletions(-) create mode 100644 src/components/side_panel/data_analysis/chart_suggestion_preview.ts create mode 100644 src/components/side_panel/data_analysis/chart_suggestion_preview.xml create mode 100644 src/helpers/figures/charts/chart_suggestion_engine.ts diff --git a/src/components/side_panel/data_analysis/chart_suggestion_preview.ts b/src/components/side_panel/data_analysis/chart_suggestion_preview.ts new file mode 100644 index 0000000000..3a4c4a3d81 --- /dev/null +++ b/src/components/side_panel/data_analysis/chart_suggestion_preview.ts @@ -0,0 +1,226 @@ +import { onWillUnmount, signal } from "@odoo/owl"; +import { Chart, ChartConfiguration } from "chart.js/auto"; +import { DEFAULT_FIGURE_HEIGHT, DEFAULT_FIGURE_WIDTH } from "../../../constants"; +import { SpreadsheetChart } from "../../../helpers/figures/chart"; +import { drawGaugeChart } from "../../../helpers/figures/charts/gauge_chart_rendering"; +import { drawScoreChart } from "../../../helpers/figures/charts/scorecard_chart"; +import { getScorecardConfiguration } from "../../../helpers/figures/charts/scorecard_chart_config_builder"; +import { UuidGenerator } from "../../../helpers/uuid"; +import { Component, useLayoutEffect } from "../../../owl3_compatibility_layer"; +import { ChartDefinition } from "../../../types/chart/chart"; +import { GaugeChartRuntime } from "../../../types/chart/gauge_chart"; +import { ScorecardChartRuntime } from "../../../types/chart/scorecard_chart"; +import { SpreadsheetChildEnv } from "../../../types/spreadsheet_env"; + +interface Props { + definition: ChartDefinition; + title: string; + rationale: string; + isRecommended: boolean; +} + +export class ChartSuggestionPreview extends Component { + static template = "o-spreadsheet-ChartSuggestionPreview"; + static props = { + definition: Object, + title: String, + rationale: String, + isRecommended: Boolean, + }; + + private chartCanvasRef = signal(null); + private chartDivRef = signal(null); + private chart?: Chart; + private renderedChartType?: string; + + setup() { + onWillUnmount(() => this.destroyChart()); + useLayoutEffect( + () => { + this.updateChart(); + }, + () => [this.props.definition] + ); + } + + private getChartConfiguration(): ChartConfiguration | null { + const getters = this.env.model.getters; + const activeSheetId = getters.getActiveSheetId(); + const chart = SpreadsheetChart.fromStrDefinition(getters, activeSheetId, this.props.definition); + const runtime = chart.getRuntime(getters, "myChart"); + if (!("chartJsConfig" in runtime)) { + return null; + } + let config = runtime.chartJsConfig; + const existingScales = config.options?.scales ?? {}; + config = { + ...config, + options: { + ...config.options, + plugins: { + ...config.options?.plugins, + legend: { display: false }, + title: { display: false }, + tooltip: { enabled: false }, + }, + events: [], + animation: false, + scales: { + ...existingScales, + ...("x" in existingScales + ? { + x: { + ...existingScales.x, + ticks: { display: false }, + border: { display: false }, + }, + } + : {}), + ...("y" in existingScales + ? { + y: { + ...existingScales.y, + ticks: { display: false }, + border: { display: false }, + }, + } + : {}), + ...("r" in existingScales + ? { + r: { + ...(existingScales as any).r, + ticks: { display: false }, + pointLabels: { display: false }, + }, + } + : {}), + }, + }, + }; + return config; + } + + startDragAndDrop(ev: MouseEvent) { + const canvas = this.chartCanvasRef(); + const div = this.chartDivRef(); + if (!div) { + return; + } + const target = canvas ?? div; + const rect = target.getBoundingClientRect(); + const { position, left, top } = getComputedStyle(div); + const offsetX = ev.clientX - rect.left; + const offsetY = ev.clientY - rect.top; + const onMouseMove = (moveEvent: MouseEvent) => { + div.style.position = "absolute"; + div.style.left = `${moveEvent.clientX - offsetX}px`; + div.style.top = `${moveEvent.clientY - offsetY}px`; + }; + const onMouseUp = (mouseEvent: MouseEvent) => { + const gridOverlay = document.querySelector(".o-grid-overlay") as HTMLElement | null; + if (!gridOverlay) { + return; + } + const gridRect = gridOverlay.getBoundingClientRect(); + if ( + mouseEvent.clientX > gridRect.left && + mouseEvent.clientX < gridRect.right && + mouseEvent.clientY > gridRect.top && + mouseEvent.clientY < gridRect.bottom + ) { + const { scrollX, scrollY } = this.env.model.getters.getActiveSheetScrollInfo(); + const x = mouseEvent.clientX - gridRect.left - offsetX + scrollX; + const y = mouseEvent.clientY - gridRect.top - offsetY + scrollY; + const { col, row, offset } = this.env.model.getters.getPositionAnchorOffset({ x, y }); + this.env.model.dispatch("CREATE_CHART", { + chartId: UuidGenerator.smallUuid(), + figureId: UuidGenerator.smallUuid(), + sheetId: this.env.model.getters.getActiveSheetId(), + size: { width: DEFAULT_FIGURE_WIDTH, height: DEFAULT_FIGURE_HEIGHT }, + definition: this.props.definition, + col, + row, + offset, + }); + } + div.style.position = position; + div.style.left = left; + div.style.top = top; + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + } + + private createChart() { + if (!globalThis.Chart) { + throw new Error("Chart.js library is not loaded"); + } + const canvas = this.chartCanvasRef(); + if (!canvas) { + return; + } + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + const config = this.getChartConfiguration(); + if (!config) { + return; + } + //@ts-ignore + this.chart = new globalThis.Chart!(ctx, config); + this.renderedChartType = this.props.definition.type; + } + + private updateChart() { + const config = this.getChartConfiguration(); + if (!config) { + this.destroyChart(); + this.drawNativeChart(); + return; + } + if (this.chart && this.renderedChartType !== this.props.definition.type) { + this.destroyChart(); + } + if (!this.chart) { + this.createChart(); + return; + } + this.chart.config.options = config.options; + this.chart.data = config.data; + this.chart.update(); + } + + private drawNativeChart() { + const canvas = this.chartCanvasRef(); + if (!canvas) { + return; + } + const getters = this.env.model.getters; + const chart = SpreadsheetChart.fromStrDefinition( + getters, + getters.getActiveSheetId(), + this.props.definition + ); + const runtime = chart.getRuntime(getters, "myChart"); + const { type } = this.props.definition; + if (type === "scorecard") { + const rect = canvas.getBoundingClientRect(); + const config = getScorecardConfiguration( + { width: rect.width || 130, height: rect.height || 120 }, + runtime as ScorecardChartRuntime + ); + drawScoreChart(config, canvas); + } else if (type === "gauge") { + drawGaugeChart(canvas, runtime as GaugeChartRuntime); + } + } + + private destroyChart() { + this.chart?.destroy(); + this.chart = undefined; + this.renderedChartType = undefined; + } +} diff --git a/src/components/side_panel/data_analysis/chart_suggestion_preview.xml b/src/components/side_panel/data_analysis/chart_suggestion_preview.xml new file mode 100644 index 0000000000..dd349e470b --- /dev/null +++ b/src/components/side_panel/data_analysis/chart_suggestion_preview.xml @@ -0,0 +1,19 @@ + + +
+
+ +
+
+
+ + diff --git a/src/components/side_panel/data_analysis/data_analysis_panel.css b/src/components/side_panel/data_analysis/data_analysis_panel.css index e394337dea..eb19ef5a3b 100644 --- a/src/components/side_panel/data_analysis/data_analysis_panel.css +++ b/src/components/side_panel/data_analysis/data_analysis_panel.css @@ -18,6 +18,59 @@ font-weight: bold; } + .o-chart-suggestions-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; + } + + .o-chart-suggestion-thumb { + cursor: grab; + min-width: 0; + } + .o-chart-suggestion-thumb:active { + cursor: grabbing; + } + + .o-suggestion-canvas-wrap { + height: 120px; + border: 1px solid var(--os-border-color); + border-radius: 3px; + overflow: hidden; + background: white; + position: relative; + } + .o-suggestion-canvas-wrap:hover { + border-color: var(--o-action-color, #3d9be9); + } + + .o-chart-suggestion-recommended .o-suggestion-canvas-wrap { + border-color: var(--o-action-color, #3d9be9); + border-width: 2px; + } + .o-chart-suggestion-recommended .o-suggestion-canvas-wrap::after { + content: "★"; + position: absolute; + top: 3px; + right: 5px; + font-size: 10px; + color: var(--o-action-color, #3d9be9); + line-height: 1; + pointer-events: none; + } + + .o-suggestion-placeholder { + color: var(--os-text-muted, #666); + font-size: 0.7rem; + text-transform: capitalize; + } + + .o-suggestion-thumb-title { + font-size: 0.7rem; + color: var(--os-text-muted, #555); + line-height: 1.2; + } + .o-data-analysis-chart { height: 200px; cursor: pointer; diff --git a/src/components/side_panel/data_analysis/data_analysis_panel.ts b/src/components/side_panel/data_analysis/data_analysis_panel.ts index 238650a0ca..ce2ba0b7ea 100644 --- a/src/components/side_panel/data_analysis/data_analysis_panel.ts +++ b/src/components/side_panel/data_analysis/data_analysis_panel.ts @@ -1,16 +1,12 @@ -import { onWillUnmount, proxy, signal } from "@odoo/owl"; -import { Chart, ChartConfiguration } from "chart.js/auto"; -import { DEFAULT_FIGURE_HEIGHT, DEFAULT_FIGURE_WIDTH, HIGHLIGHT_COLOR } from "../../../constants"; +import { onWillUpdateProps, proxy } from "@odoo/owl"; +import { HIGHLIGHT_COLOR } from "../../../constants"; import { lightenColor } from "../../../helpers/color"; -import { SpreadsheetChart } from "../../../helpers/figures/chart"; import { - categorizeColumns, - getSmartChartDefinition, -} from "../../../helpers/figures/charts/smart_chart_engine"; -import { clipTextWithEllipsis } from "../../../helpers/text_helper"; -import { UuidGenerator } from "../../../helpers/uuid"; + ChartSuggestion, + getChartSuggestions, +} from "../../../helpers/figures/charts/chart_suggestion_engine"; import { toZone, zoneToXc } from "../../../helpers/zones"; -import { Component, useLayoutEffect } from "../../../owl3_compatibility_layer"; +import { Component } from "../../../owl3_compatibility_layer"; import { useLocalStore } from "../../../store_engine/store_hooks"; import { _t } from "../../../translation"; import { Highlight, UID, Zone } from "../../../types/misc"; @@ -22,6 +18,7 @@ import { SelectionInput } from "../../selection_input/selection_input"; import { BadgeSelection } from "../components/badge_selection/badge_selection"; import { SidePanelCollapsible } from "../components/collapsible/side_panel_collapsible"; import { Section } from "../components/section/section"; +import { ChartSuggestionPreview } from "./chart_suggestion_preview"; import { DataAnalysisStore } from "./data_analysis_store"; interface Props { @@ -40,6 +37,7 @@ export class DataAnalysisPanel extends Component { BadgeSelection, Section, SelectionInput, + ChartSuggestionPreview, }; state = proxy({ @@ -50,31 +48,27 @@ export class DataAnalysisPanel extends Component { }); store!: Store; - private chartCanvasRef = signal(null); - private chartDivRef = signal(null); - private chart?: Chart; private sheetId?: UID; + private lastPropsZones: string[] = []; setup() { this.sheetId = this.env.model.getters.getActiveSheetId(); const initialRanges = this.props.zones.map(zoneToXc); + this.lastPropsZones = initialRanges; this.state.pendingRanges = initialRanges; this.store = useLocalStore(DataAnalysisStore, initialRanges); useHighlights(this); - onWillUnmount(() => this.destroyChart()); - useLayoutEffect( - () => { - this.updateChart(); - }, - () => [this.store.countChartData, this.state.currentChart] - ); - } - - get charts() { - return [ - { value: "count", label: _t("Count"), icon: "o-spreadsheet-Icon.COUNT_CHART" }, - { value: "histogram", label: _t("Distribution"), icon: "o-spreadsheet-Icon.COUNT_CHART" }, - ]; + onWillUpdateProps((nextProps: Props) => { + const newRanges = nextProps.zones.map(zoneToXc); + if ( + newRanges.length !== this.lastPropsZones.length || + newRanges.some((r, i) => r !== this.lastPropsZones[i]) + ) { + this.lastPropsZones = newRanges; + this.state.pendingRanges = newRanges; + this.store.updateRanges(newRanges); + } + }); } get frequencyOrders() { @@ -84,206 +78,13 @@ export class DataAnalysisPanel extends Component { ]; } - get shouldShowChart(): boolean { - if (this.state.currentChart === "histogram") { - return this.store.numericValues.length > 0; - } else if (this.state.currentChart === "count") { - return this.store.values.length > 0; - } - return false; - } - - get chartErrorMessage(): string | null { - if (this.state.currentChart === "histogram" && this.store.numericValues.length === 0) { - return _t("No numeric values to display."); - } - if (this.state.currentChart === "count" && this.store.values.length === 0) { - return _t("No values to display."); - } - return null; - } - - get zonesType() { - return categorizeColumns( + get chartSuggestions(): ChartSuggestion[] { + return getChartSuggestions( this.store.ranges?.map((range) => toZone(range)) ?? [], this.env.model.getters ); } - get smartChartDefinition() { - return getSmartChartDefinition( - this.store.ranges?.map((range) => toZone(range)) ?? [], - this.env.model.getters - ); - } - - private getChartConfiguration(): ChartConfiguration | null { - const getters = this.env.model.getters; - const activeSheetId = getters.getActiveSheetId(); - const chart = SpreadsheetChart.fromStrDefinition( - getters, - activeSheetId, - this.smartChartDefinition - ); - const runtime = chart.getRuntime(getters, "myChart"); - if (!("chartJsConfig" in runtime)) { - return null; - } - let config = runtime.chartJsConfig; - const canvas = this.chartCanvasRef(); - const ctx2d = canvas?.getContext("2d") ?? null; - config = { - ...config, - options: { - ...config.options, - plugins: { - ...config.options?.plugins, - legend: { - ...config.options?.plugins?.legend, - labels: { - ...(config.options?.plugins?.legend as any)?.labels, - font: { size: 9 }, - }, - }, - tooltip: { enabled: false }, - }, - events: [], - animation: false, - scales: { - ...config.options?.scales, - x: { - ...config.options?.scales?.x, - ticks: { - ...(config.options?.scales?.x as any)?.ticks, - font: { size: 9 }, - callback: function (value: any) { - if (Math.floor(value) !== value) { - return ""; - } - const label = (this as any).getLabelForValue(value) as string | undefined; - if (!label) { - return ""; - } - if (!ctx2d) { - return label; - } - return clipTextWithEllipsis(ctx2d, label, 50); - }, - //maxRotation: 90, - //minRotation: 90, - }, - }, - y: { - ...config.options?.scales?.y, - ticks: { - ...(config.options?.scales?.y as any)?.ticks, - font: { size: 9 }, - }, - }, - }, - }, - }; - return config; - } - - startDragAndDrop(ev: MouseEvent) { - const canvas = this.chartCanvasRef(); - if (!canvas) { - return; - } - const div = this.chartDivRef(); - if (!div) { - return; - } - const rect = canvas.getBoundingClientRect(); - const { position, left, top } = getComputedStyle(div); - const offsetX = ev.clientX - rect.left; - const offsetY = ev.clientY - rect.top; - const onMouseMove = (moveEvent: MouseEvent) => { - div.style.position = "absolute"; - div.style.left = `${moveEvent.clientX - offsetX}px`; - div.style.top = `${moveEvent.clientY - offsetY}px`; - }; - const onMouseUp = (mouseEvent: MouseEvent) => { - //get grid-overlay dimensions - const gridOverlay = document.querySelector(".o-grid-overlay") as HTMLElement | null; - if (!gridOverlay) { - return; - } - const gridRect = gridOverlay.getBoundingClientRect(); - if ( - mouseEvent.clientX > gridRect.left && - mouseEvent.clientX < gridRect.right && - mouseEvent.clientY > gridRect.top && - mouseEvent.clientY < gridRect.bottom - ) { - const { scrollX, scrollY } = this.env.model.getters.getActiveSheetScrollInfo(); - const x = mouseEvent.clientX - gridRect.left - offsetX + scrollX; - const y = mouseEvent.clientY - gridRect.top - offsetY + scrollY; - - const { col, row, offset } = this.env.model.getters.getPositionAnchorOffset({ x, y }); - - this.env.model.dispatch("CREATE_CHART", { - chartId: UuidGenerator.smallUuid(), - figureId: UuidGenerator.smallUuid(), - sheetId: this.env.model.getters.getActiveSheetId(), - size: { width: DEFAULT_FIGURE_WIDTH, height: DEFAULT_FIGURE_HEIGHT }, - definition: this.smartChartDefinition, - col, - row, - offset, - }); - } - div.style.position = position; - div.style.left = left; - div.style.top = top; - window.removeEventListener("mousemove", onMouseMove); - window.removeEventListener("mouseup", onMouseUp); - }; - window.addEventListener("mousemove", onMouseMove); - window.addEventListener("mouseup", onMouseUp); - } - - private createChart() { - if (!globalThis.Chart) { - throw new Error("Chart.js library is not loaded"); - } - const canvas = this.chartCanvasRef(); - if (!canvas) { - return; - } - const ctx = canvas.getContext("2d"); - if (!ctx) { - return; - } - const config = this.getChartConfiguration(); - if (!config) { - return; - } - //@ts-ignore - this.chart = new globalThis.Chart!(ctx, config); - } - - private updateChart() { - const config = this.getChartConfiguration(); - if (!config) { - this.destroyChart(); - return; - } - if (!this.chart) { - this.createChart(); - return; - } - this.chart.config.options = config.options; - this.chart.data = config.data; - this.chart.update(); - } - - private destroyChart() { - this.chart?.destroy(); - this.chart = undefined; - } - switchChart(chartType: string) { this.state.currentChart = chartType; } @@ -294,7 +95,6 @@ export class DataAnalysisPanel extends Component { onRangeConfirmed() { this.store.updateRanges(this.state.pendingRanges); - this.updateChart(); } switchFrequencyOrder(order: string) { diff --git a/src/components/side_panel/data_analysis/data_analysis_panel.xml b/src/components/side_panel/data_analysis/data_analysis_panel.xml index 77893ae822..f40e1752ba 100644 --- a/src/components/side_panel/data_analysis/data_analysis_panel.xml +++ b/src/components/side_panel/data_analysis/data_analysis_panel.xml @@ -13,17 +13,25 @@ />
- + -
-
- -
-
+
+ +
+ + + +
+
+ +
Select a range to see chart suggestions.
+
+
diff --git a/src/helpers/figures/charts/chart_suggestion_engine.ts b/src/helpers/figures/charts/chart_suggestion_engine.ts new file mode 100644 index 0000000000..2f4d19d057 --- /dev/null +++ b/src/helpers/figures/charts/chart_suggestion_engine.ts @@ -0,0 +1,1252 @@ +import { + DEFAULT_GAUGE_LOWER_COLOR, + DEFAULT_GAUGE_MIDDLE_COLOR, + DEFAULT_GAUGE_UPPER_COLOR, + DEFAULT_SCORECARD_BASELINE_COLOR_DOWN, + DEFAULT_SCORECARD_BASELINE_COLOR_UP, +} from "../../../constants"; +import { _t } from "../../../translation"; +import { CellValueType, EvaluatedCell } from "../../../types/cells"; +import { BarChartDefinition } from "../../../types/chart/bar_chart"; +import { CalendarChartDefinition } from "../../../types/chart/calendar_chart"; +import { ChartDefinition } from "../../../types/chart/chart"; +import { FunnelChartDefinition } from "../../../types/chart/funnel_chart"; +import { GaugeChartDefinition } from "../../../types/chart/gauge_chart"; +import { LineChartDefinition } from "../../../types/chart/line_chart"; +import { PieChartDefinition } from "../../../types/chart/pie_chart"; +import { RadarChartDefinition } from "../../../types/chart/radar_chart"; +import { ScatterChartDefinition } from "../../../types/chart/scatter_chart"; +import { ScorecardChartDefinition } from "../../../types/chart/scorecard_chart"; +import { SunburstChartDefinition } from "../../../types/chart/sunburst_chart"; +import { TreeMapChartDefinition } from "../../../types/chart/tree_map_chart"; +import { Getters } from "../../../types/getters"; +import { Zone } from "../../../types/misc"; +import { isDateTimeFormat } from "../../format/format"; +import { getZonesByColumns, zoneToXc } from "../../zones"; + +export type ExtendedColumnType = + | "number" + | "percentage" + | "date" + | "categorical" + | "label" + | "hierarchy" + | "boolean" + | "empty"; + +const HIERARCHY_SEPARATORS = [">", "→", " - ", "/", "\\"]; + +interface HierarchyInfo { + separator: string; + maxDepth: number; +} + +export interface ColumnAnalysis { + zone: Zone; + type: ExtendedColumnType; + header?: string; + hasHeader: boolean; + rowCount: number; + uniqueCount: number; + uniqueRatio: number; + minValue?: number; + maxValue?: number; + lastValue?: number; + secondToLastValue?: number; + hierarchy?: HierarchyInfo; +} + +export interface ChartSuggestion { + title: string; + rationale: string; + definition: ChartDefinition; +} + +type DS = { + type: "range"; + dataSets: { dataRange: string; dataSetId: string }[]; + dataSetsHaveTitle: boolean; + labelRange?: string; +}; + +function getUnboundRange(getters: Getters, zone: Zone): string { + return zoneToXc(getters.getUnboundedZone(getters.getActiveSheetId(), zone)); +} + +function isDatasetTitled(getters: Getters, zone: Zone): boolean { + const sheetId = getters.getActiveSheetId(); + const cell = getters.getEvaluatedCell({ sheetId, col: zone.left, row: zone.top }); + return ![CellValueType.number, CellValueType.empty].includes(cell.type); +} + +function dataset(zone: Zone, getters: Getters, id = "0") { + return { dataRange: getUnboundRange(getters, zone), dataSetId: id }; +} + +function rangeSource( + dataSets: ReturnType[], + dataSetsHaveTitle: boolean, + labelRange?: string +): DS { + return { type: "range", dataSets, dataSetsHaveTitle, labelRange }; +} + +function detectHierarchy(values: string[]): HierarchyInfo | undefined { + if (values.length < 3) { + return undefined; + } + + const countBySep: Record = {}; + for (const val of values) { + for (const sep of HIERARCHY_SEPARATORS) { + if (val.includes(sep)) { + countBySep[sep] = (countBySep[sep] ?? 0) + 1; + break; + } + } + } + + const valuesWithAnySep = Object.values(countBySep).reduce((a, b) => a + b, 0); + if (valuesWithAnySep / values.length < 0.7) { + return undefined; + } + + const best = Object.entries(countBySep).sort((a, b) => b[1] - a[1])[0]; + if (!best || best[1] / values.length < 0.8) { + return undefined; + } + + const [sep] = best; + const depths = values.map((v) => v.split(sep).length); + if (new Set(depths).size < 2) { + return undefined; + } + + return { separator: sep, maxDepth: Math.max(...depths) }; +} + +function barChart( + titleText: string, + source: DS, + opts: { + stacked?: boolean; + horizontal?: boolean; + legendPosition?: "top" | "none" | "bottom" | "left" | "right"; + } = {} +): BarChartDefinition { + return { + type: "bar", + title: { text: titleText }, + dataSource: source, + dataSetStyles: {}, + legendPosition: opts.legendPosition ?? "none", + stacked: opts.stacked ?? false, + horizontal: opts.horizontal, + humanize: true, + }; +} + +function lineChart( + titleText: string, + source: DS, + opts: { + stacked?: boolean; + fillArea?: boolean; + cumulative?: boolean; + legendPosition?: "top" | "none" | "bottom" | "left" | "right"; + } = {} +): LineChartDefinition { + return { + type: "line", + title: { text: titleText }, + dataSource: source, + dataSetStyles: {}, + legendPosition: opts.legendPosition ?? "none", + stacked: opts.stacked ?? false, + cumulative: opts.cumulative ?? false, + labelsAsText: false, + fillArea: opts.fillArea, + humanize: true, + }; +} + +function pieChart( + titleText: string, + source: DS, + opts: { + isDoughnut?: boolean; + aggregated?: boolean; + legendPosition?: "top" | "none" | "bottom" | "left" | "right"; + } = {} +): PieChartDefinition { + return { + type: "pie", + title: { text: titleText }, + dataSource: source, + dataSetStyles: {}, + legendPosition: opts.legendPosition ?? "top", + isDoughnut: opts.isDoughnut, + aggregated: opts.aggregated, + humanize: true, + }; +} + +function radarChart( + titleText: string, + source: DS, + opts: { legendPosition?: "top" | "none" | "bottom" | "left" | "right" } = {} +): RadarChartDefinition { + return { + type: "radar", + title: { text: titleText }, + dataSource: source, + dataSetStyles: {}, + legendPosition: opts.legendPosition ?? "none", + stacked: false, + humanize: true, + }; +} + +function funnelChart(titleText: string, source: DS): FunnelChartDefinition { + return { + type: "funnel", + title: { text: titleText }, + dataSource: source, + dataSetStyles: {}, + legendPosition: "none", + humanize: true, + }; +} + +function sunburstChart(titleText: string, source: DS): SunburstChartDefinition { + return { + type: "sunburst", + title: { text: titleText }, + dataSource: source, + dataSetStyles: {}, + legendPosition: "none", + }; +} + +function treemapChart(titleText: string, source: DS): TreeMapChartDefinition { + return { + type: "treemap", + title: { text: titleText }, + dataSource: source, + dataSetStyles: {}, + legendPosition: "none", + }; +} + +function calendarChart(titleText: string, source: DS): CalendarChartDefinition { + return { + type: "calendar", + title: { text: titleText }, + dataSource: source, + dataSetStyles: {}, + legendPosition: "none", + horizontalGroupBy: "month_number", + verticalGroupBy: "day_of_week", + humanize: true, + }; +} + +function scatterChart(titleText: string, source: DS): ScatterChartDefinition { + return { + type: "scatter", + title: { text: titleText }, + dataSource: source, + dataSetStyles: {}, + legendPosition: "none", + labelsAsText: false, + humanize: true, + }; +} + +function scorecardChart( + titleText: string, + keyValue: string, + opts: { + baseline?: string; + baselineMode?: "difference" | "percentage" | "text" | "progress"; + } = {} +): ScorecardChartDefinition { + return { + type: "scorecard", + title: { text: titleText }, + keyValue, + baseline: opts.baseline, + baselineMode: opts.baselineMode ?? "difference", + baselineColorUp: DEFAULT_SCORECARD_BASELINE_COLOR_UP, + baselineColorDown: DEFAULT_SCORECARD_BASELINE_COLOR_DOWN, + humanize: true, + }; +} + +function gaugeChart( + titleText: string, + dataRange: string, + rangeMin: string, + rangeMax: string +): GaugeChartDefinition { + return { + type: "gauge", + title: { text: titleText }, + dataRange, + sectionRule: { + colors: { + lowerColor: DEFAULT_GAUGE_LOWER_COLOR, + middleColor: DEFAULT_GAUGE_MIDDLE_COLOR, + upperColor: DEFAULT_GAUGE_UPPER_COLOR, + }, + rangeMin, + rangeMax, + lowerInflectionPoint: { type: "percentage", value: "33", operator: "<=" }, + upperInflectionPoint: { type: "percentage", value: "66", operator: "<=" }, + }, + humanize: true, + }; +} + +function computeColumnType(cells: EvaluatedCell[], getters: Getters): ExtendedColumnType { + if (cells.length > 0) { + if (cells.every((c) => c.type === CellValueType.boolean)) { + return "boolean"; + } else if ( + cells.every( + (c) => c.type === CellValueType.number && !!c.format && isDateTimeFormat(c.format) + ) + ) { + return "date"; + } else if ( + cells.every((c) => { + if (c.type !== CellValueType.number) { + return false; + } + if (c.format?.includes("%")) { + return true; + } + const v = c.value as number; + if (v < 0 || v > 1) { + return false; + } + const str = String(v); + const dot = str.indexOf("."); + return dot < 0 || str.length - dot - 1 <= 2; + }) + ) { + return "percentage"; + } else if (cells.every((c) => c.type === CellValueType.number)) { + return "number"; + } else { + const textVals = cells + .filter((c) => c.type === CellValueType.text) + .map((c) => c.value as string); + + if (textVals.length > 0) { + const hier = detectHierarchy(textVals); + if (hier) { + return "hierarchy"; + } else { + const unique = new Set(textVals).size; + const ratio = unique / textVals.length; + return ratio < 0.5 && unique <= 20 ? "categorical" : "label"; + } + } + } + } + return "empty"; +} + +function analyzeColumn(zone: Zone, getters: Getters): ColumnAnalysis { + const sheetId = getters.getActiveSheetId(); + const cells = getters.getEvaluatedCellsInZone(sheetId, zone); + + if (!cells.length) { + return { zone, type: "empty", hasHeader: false, rowCount: 0, uniqueCount: 0, uniqueRatio: 0 }; + } + + const firstCell = cells[0]; + const rest = cells.slice(1); + + // Header: first cell is text AND rest has at least one non-text, non-empty cell + const hasHeader = + firstCell.type === CellValueType.text && + rest.some((c) => c.type !== CellValueType.text && c.type !== CellValueType.empty); + + const dataCells: EvaluatedCell[] = hasHeader ? rest : cells; + const nonEmpty = dataCells.filter((c) => c.type !== CellValueType.empty); + + const type = computeColumnType(nonEmpty, getters); + let hierarchy: HierarchyInfo | undefined = undefined; + if (type === "hierarchy") { + const textVals = cells + .filter((c) => c.type === CellValueType.text) + .map((c) => c.value as string); + hierarchy = detectHierarchy(textVals); + } + + const numVals = nonEmpty + .filter((c) => c.type === CellValueType.number) + .map((c) => c.value as number); + + const allVals = nonEmpty.map((c) => String(c.value ?? "")); + const uniqueCount = new Set(allVals).size; + + return { + zone, + type, + header: hasHeader ? String(firstCell.value ?? "") : undefined, + hasHeader, + rowCount: nonEmpty.length, + uniqueCount, + uniqueRatio: allVals.length > 0 ? uniqueCount / allVals.length : 0, + minValue: numVals.length ? Math.min(...numVals) : undefined, + maxValue: numVals.length ? Math.max(...numVals) : undefined, + lastValue: numVals.length ? numVals[numVals.length - 1] : undefined, + secondToLastValue: numVals.length >= 2 ? numVals[numVals.length - 2] : undefined, + hierarchy, + }; +} + +function analyzeColumns(zones: Zone[], getters: Getters): ColumnAnalysis[] { + return getZonesByColumns(zones).map((zone) => analyzeColumn(zone, getters)); +} + +/** Pattern A — Single numeric column */ +function chartsForSingleNumberColumn(col: ColumnAnalysis, getters: Getters): ChartSuggestion[] { + const hasTitle = col.hasHeader; + const title = col.header ?? _t("Value"); + const lastRow = col.zone.bottom; + const lastCellXC = zoneToXc({ ...col.zone, top: lastRow, bottom: lastRow }); + const prevCellXC = zoneToXc({ ...col.zone, top: lastRow - 1, bottom: lastRow - 1 }); + const hasPrev = lastRow - 1 >= col.zone.top; + + const source = rangeSource([dataset(col.zone, getters)], hasTitle); + + return [ + { + title: _t("%s — KPI Card", title), + rationale: _t("Highlights the most recent value compared to the previous one."), + definition: scorecardChart(title, lastCellXC, { + baseline: hasPrev ? prevCellXC : undefined, + baselineMode: "difference", + }), + }, + { + title: _t("%s — Gauge", title), + rationale: _t("Shows the position of the last value within the data's min-max range."), + definition: gaugeChart( + title, + lastCellXC, + String(col.minValue ?? 0), + String(col.maxValue ?? 100) + ), + }, + { + title: _t("%s — Trend Line", title), + rationale: _t("Shows the evolution of all values over the range."), + definition: lineChart(title, source), + }, + { + title: _t("%s — Bar Chart", title), + rationale: _t("Compares individual values side-by-side."), + definition: barChart(title, source), + }, + { + title: _t("%s — Area Chart", title), + rationale: _t("Emphasizes total accumulation over the range."), + definition: lineChart(title, source, { fillArea: true, cumulative: true }), + }, + ]; +} + +/** Pattern B — Single percentage column */ +function chartsForSinglePercentageColumn(col: ColumnAnalysis, getters: Getters): ChartSuggestion[] { + const hasTitle = col.hasHeader; + const title = col.header ?? _t("Rate"); + const lastRow = col.zone.bottom; + const lastCellXC = zoneToXc({ ...col.zone, top: lastRow, bottom: lastRow }); + const prevCellXC = zoneToXc({ ...col.zone, top: lastRow - 1, bottom: lastRow - 1 }); + const hasPrev = lastRow - 1 >= col.zone.top; + + const source = rangeSource([dataset(col.zone, getters)], hasTitle); + const isAboveOne = (col.maxValue ?? 0) > 1; + + return [ + { + title: _t("%s — KPI Card", title), + rationale: _t("Shows the last percentage value with its baseline."), + definition: scorecardChart(title, lastCellXC, { + baseline: hasPrev ? prevCellXC : undefined, + baselineMode: "percentage", + }), + }, + { + title: _t("%s — Gauge", title), + rationale: _t("Natural fit for a 0–100% range."), + definition: gaugeChart(title, lastCellXC, "0", isAboveOne ? "100" : "1"), + }, + { + title: _t("%s — Donut Chart", title), + rationale: _t("Shows completion against total."), + definition: pieChart(title, source, { isDoughnut: true }), + }, + { + title: _t("%s — Bar Chart", title), + rationale: _t("Compares all percentage values side-by-side."), + definition: barChart(title, source), + }, + ]; +} + +/** Pattern C — Single date column */ +function chartsForSingleDateColumn(col: ColumnAnalysis, getters: Getters): ChartSuggestion[] { + const hasTitle = col.hasHeader; + const title = col.header ?? _t("Date"); + const source = rangeSource([dataset(col.zone, getters)], hasTitle); + + return [ + { + title: _t("%s — Line (cumulative)", title), + rationale: _t("Shows cumulative event count over time."), + definition: lineChart(title, source, { cumulative: true }), + }, + { + title: _t("%s — Bar (count per period)", title), + rationale: _t("Groups events by period for a period-by-period comparison."), + definition: barChart(title, source), + }, + { + title: _t("%s — Calendar Heatmap", title), + rationale: _t("Shows event density across the year."), + definition: calendarChart(title, source), + }, + ]; +} + +/** Pattern D — Single categorical column */ +function chartsForSingleCategoricalColumn( + col: ColumnAnalysis, + getters: Getters +): ChartSuggestion[] { + const hasTitle = col.hasHeader; + const title = col.header ?? _t("Category"); + const range = getUnboundRange(getters, col.zone); + const source = rangeSource([dataset(col.zone, getters)], hasTitle, range); + + const suggestions: ChartSuggestion[] = [ + { + title: _t("%s — Pie Chart", title), + rationale: _t("Shows the share of each category."), + definition: pieChart(title, source, { aggregated: true, legendPosition: "top" }), + }, + { + title: _t("%s — Donut Chart", title), + rationale: _t("Same as pie, cleaner proportional look."), + definition: pieChart(title, source, { + aggregated: true, + isDoughnut: true, + legendPosition: "top", + }), + }, + { + title: _t("%s — Bar (count)", title), + rationale: _t("Absolute count per category."), + definition: barChart(title, { ...source, labelRange: range }, { legendPosition: "none" }), + }, + { + title: _t("%s — Treemap", title), + rationale: _t("Proportional area per category."), + definition: treemapChart(title, source), + }, + ]; + + if (col.uniqueCount <= 8) { + suggestions.push({ + title: _t("%s — Funnel", title), + rationale: _t("Useful when categories imply stages or ordering."), + definition: funnelChart(title, source), + }); + } + + return suggestions; +} + +/** Pattern F — Categorical + Number */ +function chartsForCategoricalVsNumber(cols: ColumnAnalysis[], getters: Getters): ChartSuggestion[] { + const [catCol, numCol] = cols; + const labelRange = getUnboundRange(getters, catCol.zone); + const hasTitle = numCol.hasHeader; + const title = numCol.header + ? _t("%s by %s", numCol.header, catCol.header ?? "Category") + : _t("By Category"); + const source = rangeSource([dataset(numCol.zone, getters)], hasTitle, labelRange); + + const suggestions: ChartSuggestion[] = [ + { + title: _t("%s — Bar Chart", title), + rationale: _t("Classic category-vs-value comparison."), + definition: barChart(title, source), + }, + { + title: _t("%s — Horizontal Bar", title), + rationale: _t("Better when category labels are long."), + definition: barChart(title, source, { horizontal: true }), + }, + { + title: _t("%s — Pie Chart", title), + rationale: _t("Share of total per category."), + definition: pieChart(title, source, { legendPosition: "top" }), + }, + { + title: _t("%s — Treemap", title), + rationale: _t("Proportional area — good for many categories."), + definition: treemapChart( + title, + rangeSource( + [dataset(catCol.zone, getters)], + hasTitle, + getUnboundRange(getters, numCol.zone) + ) + ), + }, + ]; + + if (catCol.rowCount <= 8) { + suggestions.push({ + title: _t("%s — Funnel", title), + rationale: _t("Useful when categories represent sequential stages."), + definition: funnelChart(title, source), + }); + } + + return suggestions; +} + +/** Pattern G — Date + Number */ +function chartsForDateVsNumber(cols: ColumnAnalysis[], getters: Getters): ChartSuggestion[] { + const [dateCol, numCol] = cols; + const labelRange = getUnboundRange(getters, dateCol.zone); + const hasTitle = isDatasetTitled(getters, dateCol.zone); + const title = numCol.header ? _t("%s over time", numCol.header) : _t("Over Time"); + const source = rangeSource([dataset(numCol.zone, getters)], hasTitle, labelRange); + + const suggestions: ChartSuggestion[] = [ + { + title: _t("%s — Line Chart", title), + rationale: _t("Best for visualizing time-series trends."), + definition: lineChart(title, source), + }, + { + title: _t("%s — Area Chart", title), + rationale: _t("Emphasizes total volume over time."), + definition: lineChart(title, source, { fillArea: true }), + }, + { + title: _t("%s — Bar Chart", title), + rationale: _t("Period-by-period comparison."), + definition: barChart(title, source), + }, + ]; + + if (numCol.rowCount >= 20) { + suggestions.push({ + title: _t("%s — Calendar Heatmap", title), + rationale: _t("Shows intensity variation across days of the year."), + definition: calendarChart(title, source), + }); + } + + return suggestions; +} + +/** Pattern H — Number + Number */ +function chartsForNumberVsNumber(cols: ColumnAnalysis[], getters: Getters): ChartSuggestion[] { + const [col1, col2] = cols; + const title = + col1.header && col2.header ? _t("%s vs %s", col2.header, col1.header) : _t("Correlation"); + const hasTitle = col1.hasHeader || col2.hasHeader; + const source2 = rangeSource( + [dataset(col2.zone, getters)], + col2.hasHeader, + getUnboundRange(getters, col1.zone) + ); + const sourceBoth = rangeSource( + [dataset(col1.zone, getters, "0"), dataset(col2.zone, getters, "1")], + hasTitle + ); + + const suggestions: ChartSuggestion[] = [ + { + title: _t("%s — Scatter Plot", title), + rationale: _t("Reveals correlation between two numeric variables."), + definition: scatterChart(title, source2), + }, + { + title: _t("%s — Combo Chart", title), + rationale: _t("Bar for the first series, line for the second — good for mixed scales."), + definition: { + type: "combo", + title: { text: title }, + dataSource: sourceBoth, + dataSetStyles: {}, + legendPosition: "top", + humanize: true, + }, + }, + { + title: _t("%s — Grouped Bar", title), + rationale: _t("Side-by-side comparison of two numeric series."), + definition: barChart(title, sourceBoth, { legendPosition: "top" }), + }, + { + title: _t("%s — Stacked Area", title), + rationale: _t("When both metrics contribute to a total."), + definition: lineChart(title, sourceBoth, { + stacked: true, + fillArea: true, + legendPosition: "top", + }), + }, + ]; + + if (col1.rowCount <= 10) { + suggestions.push({ + title: _t("%s — Radar", title), + rationale: _t("Shape-based comparison when rows represent named entities."), + definition: radarChart(title, sourceBoth, { legendPosition: "top" }), + }); + } + + return suggestions; +} + +/** Pattern I — Categorical + Percentage */ +function chartsForCategoricalVsPercentage( + cols: ColumnAnalysis[], + getters: Getters +): ChartSuggestion[] { + const [catCol, pctCol] = cols; + const labelRange = getUnboundRange(getters, catCol.zone); + const hasTitle = pctCol.hasHeader; + const title = pctCol.header + ? _t("%s by %s", pctCol.header, catCol.header ?? "Category") + : _t("Rates by Category"); + const source = rangeSource([dataset(pctCol.zone, getters)], hasTitle, labelRange); + + const suggestions: ChartSuggestion[] = [ + { + title: _t("%s — Horizontal Bar", title), + rationale: _t("Progress-bar style per category."), + definition: barChart(title, source, { horizontal: true }), + }, + { + title: _t("%s — Bar Chart", title), + rationale: _t("Vertical comparison of rates per category."), + definition: barChart(title, source), + }, + { + title: _t("%s — Pie Chart", title), + rationale: _t("Share of total percentage across categories."), + definition: pieChart(title, source, { legendPosition: "top" }), + }, + ]; + + if (catCol.rowCount <= 10) { + suggestions.push({ + title: _t("%s — Radar", title), + rationale: _t("Comparison of completion rates across categories."), + definition: radarChart(title, source), + }); + } + + return suggestions; +} + +/** Pattern J — Date + Percentage */ +function chartsForDateVsPercentage(cols: ColumnAnalysis[], getters: Getters): ChartSuggestion[] { + const [dateCol, pctCol] = cols; + const labelRange = getUnboundRange(getters, dateCol.zone); + const hasTitle = isDatasetTitled(getters, dateCol.zone); + const title = pctCol.header ? _t("%s over time", pctCol.header) : _t("Rate over Time"); + const source = rangeSource([dataset(pctCol.zone, getters)], hasTitle, labelRange); + + return [ + { + title: _t("%s — Line Chart", title), + rationale: _t("Trend of the rate over time."), + definition: lineChart(title, source), + }, + { + title: _t("%s — Area Chart", title), + rationale: _t("Emphasizes volume of the rate over time."), + definition: lineChart(title, source, { fillArea: true }), + }, + { + title: _t("%s — Bar Chart", title), + rationale: _t("Period-by-period comparison of the rate."), + definition: barChart(title, source), + }, + ]; +} + +/** Pattern K — Label + Number */ +function chartsForLabelVsNumber(cols: ColumnAnalysis[], getters: Getters): ChartSuggestion[] { + const [labelCol, numCol] = cols; + const labelRange = getUnboundRange(getters, labelCol.zone); + const hasTitle = numCol.hasHeader; + const title = numCol.header + ? _t("%s by %s", numCol.header, labelCol.header ?? "Name") + : _t("By Name"); + const source = rangeSource([dataset(numCol.zone, getters)], hasTitle, labelRange); + + const suggestions: ChartSuggestion[] = [ + { + title: _t("%s — Horizontal Bar", title), + rationale: _t("Works well for named entities with long labels."), + definition: barChart(title, source, { horizontal: true }), + }, + { + title: _t("%s — Bar Chart", title), + rationale: _t("Vertical comparison across named items."), + definition: barChart(title, source), + }, + ]; + + if (labelCol.rowCount <= 10) { + suggestions.push({ + title: _t("%s — Radar", title), + rationale: _t("Shape-based comparison across labeled items."), + definition: radarChart(title, source), + }); + } + + return suggestions; +} + +/** Pattern L — Categorical + Categorical */ +function chartsForCategoricalVsCategorical( + cols: ColumnAnalysis[], + getters: Getters +): ChartSuggestion[] { + const [cat1, cat2] = cols; + const title = + cat1.header && cat2.header + ? _t("%s × %s", cat1.header, cat2.header) + : _t("Two-level hierarchy"); + const hasTitle = cat1.hasHeader || cat2.hasHeader; + const sourceBoth = rangeSource( + [dataset(cat1.zone, getters, "0"), dataset(cat2.zone, getters, "1")], + hasTitle + ); + + return [ + { + title: _t("%s — Sunburst", title), + rationale: _t("Two-level hierarchy — outer ring = first column, inner = second."), + definition: sunburstChart(title, sourceBoth), + }, + { + title: _t("%s — Treemap", title), + rationale: _t("Proportional nested areas — outer = first column, inner = second."), + definition: treemapChart(title, sourceBoth), + }, + { + title: _t("%s — Grouped Bar (counts)", title), + rationale: _t("Co-occurrence counts between the two categories."), + definition: barChart( + title, + rangeSource( + [dataset(cat2.zone, getters)], + cat2.hasHeader, + getUnboundRange(getters, cat1.zone) + ) + ), + }, + ]; +} + +/** Pattern M — Categorical + Multiple Numbers */ +function chartsForCategoricalVsMultipleNumbers( + cols: ColumnAnalysis[], + getters: Getters +): ChartSuggestion[] { + const [catCol, ...numCols] = cols; + const labelRange = getUnboundRange(getters, catCol.zone); + const hasTitle = numCols.some((c) => c.hasHeader); + const title = catCol.header ? _t("By %s", catCol.header) : _t("Multi-series"); + const dataSets = numCols.map((c, i) => dataset(c.zone, getters, String(i))); + const source = rangeSource(dataSets, hasTitle, labelRange); + + const suggestions: ChartSuggestion[] = [ + { + title: _t("%s — Grouped Bar", title), + rationale: _t("Side-by-side comparison across categories for each series."), + definition: barChart(title, source, { legendPosition: "top" }), + }, + { + title: _t("%s — Stacked Bar", title), + rationale: _t("Shows composition and total per category."), + definition: barChart(title, source, { stacked: true, legendPosition: "top" }), + }, + { + title: _t("%s — Multi-series Line", title), + rationale: _t("Trend per series across categories."), + definition: lineChart(title, source, { legendPosition: "top" }), + }, + { + title: _t("%s — Stacked Area", title), + rationale: _t("Volume and composition across categories."), + definition: lineChart(title, source, { + stacked: true, + fillArea: true, + legendPosition: "top", + }), + }, + ]; + + if (catCol.rowCount <= 10) { + suggestions.push({ + title: _t("%s — Radar", title), + rationale: _t("Shape comparison across metrics (best for ≤ 10 rows)."), + definition: radarChart(title, source, { legendPosition: "top" }), + }); + } + + return suggestions.slice(0, 6); +} + +/** Pattern N — Date + Multiple Numbers */ +function chartsForDateVsMultipleNumbers( + cols: ColumnAnalysis[], + getters: Getters +): ChartSuggestion[] { + const [dateCol, ...numCols] = cols; + const labelRange = getUnboundRange(getters, dateCol.zone); + const hasTitle = numCols.some((c) => c.hasHeader) || isDatasetTitled(getters, dateCol.zone); + const title = + numCols.length === 1 && numCols[0].header + ? _t("%s over time", numCols[0].header) + : _t("Multi-series over Time"); + const dataSets = numCols.map((c, i) => dataset(c.zone, getters, String(i))); + const source = rangeSource(dataSets, hasTitle, labelRange); + + return [ + { + title: _t("%s — Multi-series Line", title), + rationale: _t("Trend comparison across multiple metrics over time."), + definition: lineChart(title, source, { legendPosition: "top" }), + }, + { + title: _t("%s — Stacked Area", title), + rationale: _t("Volume composition over time."), + definition: lineChart(title, source, { + stacked: true, + fillArea: true, + legendPosition: "top", + }), + }, + { + title: _t("%s — Combo Chart", title), + rationale: _t("Bar for the primary metric, line for the secondary."), + definition: { + type: "combo", + title: { text: title }, + dataSource: rangeSource(dataSets.slice(0, 2), hasTitle, labelRange), + dataSetStyles: {}, + legendPosition: "top", + humanize: true, + }, + }, + { + title: _t("%s — Grouped Bar", title), + rationale: _t("Period-by-period grouped comparison."), + definition: barChart(title, source, { legendPosition: "top" }), + }, + ]; +} + +/** Pattern O — Categorical + Categorical + Number */ +function chartsForMultipleCategoricalsVsNumber( + cols: ColumnAnalysis[], + getters: Getters +): ChartSuggestion[] { + const [cat1, cat2, numCol] = cols; + const title = numCol.header + ? _t("%s by %s and %s", numCol.header, cat1.header ?? "Level 1", cat2.header ?? "Level 2") + : _t("Two-level hierarchy"); + const hasTitle = numCol.hasHeader; + const hierarchySource = rangeSource( + [dataset(cat1.zone, getters, "0"), dataset(cat2.zone, getters, "1")], + hasTitle, + getUnboundRange(getters, numCol.zone) + ); + const barSource = rangeSource( + [dataset(numCol.zone, getters)], + hasTitle, + getUnboundRange(getters, cat1.zone) + ); + + return [ + { + title: _t("%s — Sunburst", title), + rationale: _t("Two-level hierarchy weighted by value."), + definition: sunburstChart(title, hierarchySource), + }, + { + title: _t("%s — Treemap", title), + rationale: _t("Proportional nested area weighted by value."), + definition: treemapChart(title, hierarchySource), + }, + { + title: _t("%s — Grouped Bar", title), + rationale: _t("One series per inner category, grouped by outer category."), + definition: barChart(title, barSource, { legendPosition: "top" }), + }, + { + title: _t("%s — Stacked Bar", title), + rationale: _t("Contribution of inner categories per outer category."), + definition: barChart(title, barSource, { stacked: true, legendPosition: "top" }), + }, + ]; +} + +/** Pattern S — Many Numbers (3+ numeric columns, no categorical/date) */ +function patternS(cols: ColumnAnalysis[], getters: Getters): ChartSuggestion[] { + const title = cols.every((c) => c.hasHeader) + ? cols.map((c) => c.header!).join(" / ") + : _t("KPI Overview"); + const hasTitle = cols.some((c) => c.hasHeader); + const dataSets = cols.map((c, i) => dataset(c.zone, getters, String(i))); + const source = rangeSource(dataSets, hasTitle); + + const suggestions: ChartSuggestion[] = [ + { + title: _t("%s — Grouped Bar", title), + rationale: _t("Side-by-side comparison of all numeric metrics."), + definition: barChart(title, source, { legendPosition: "top" }), + }, + { + title: _t("%s — Multi-series Line", title), + rationale: _t("Trend comparison across all metrics."), + definition: lineChart(title, source, { legendPosition: "top" }), + }, + ]; + + if (cols.length <= 12) { + suggestions.unshift({ + title: _t("%s — Radar", title), + rationale: _t("Overall shape/profile across all metrics."), + definition: radarChart(title, source, { legendPosition: "top" }), + }); + } + + return suggestions.slice(0, 5); +} + +/** Pattern T — Single Hierarchy column */ +function chartsForSingleHierarchyColumn(col: ColumnAnalysis, getters: Getters): ChartSuggestion[] { + const title = col.header ?? _t("Hierarchy"); + const source = rangeSource([dataset(col.zone, getters)], col.hasHeader); + + return [ + { + title: _t("%s — Sunburst", title), + rationale: _t("Path-based hierarchy; each segment = one level."), + definition: sunburstChart(title, source), + }, + { + title: _t("%s — Treemap", title), + rationale: _t("Proportional nested areas; count-based when no numeric column."), + definition: treemapChart(title, source), + }, + ]; +} + +/** Pattern U — Hierarchy + Number */ +function chartsForHierarchyVsNumber(cols: ColumnAnalysis[], getters: Getters): ChartSuggestion[] { + const [hierCol, numCol] = cols; + const title = numCol.header + ? _t("%s by %s", numCol.header, hierCol.header ?? "Hierarchy") + : _t("Hierarchical breakdown"); + const source = rangeSource( + [dataset(hierCol.zone, getters)], + numCol.hasHeader, + getUnboundRange(getters, numCol.zone) + ); + const barSource = rangeSource( + [dataset(numCol.zone, getters)], + numCol.hasHeader, + getUnboundRange(getters, hierCol.zone) + ); + + return [ + { + title: _t("%s — Sunburst", title), + rationale: _t("Hierarchical share; leaf value = numeric column."), + definition: sunburstChart(title, source), + }, + { + title: _t("%s — Treemap", title), + rationale: _t("Proportional nested area per leaf node."), + definition: treemapChart(title, source), + }, + { + title: _t("%s — Bar (leaf level)", title), + rationale: _t("Flat comparison of leaf nodes."), + definition: barChart(title, barSource), + }, + ]; +} + +/** Fallback — when no pattern matches */ +function fallback(cols: ColumnAnalysis[], getters: Getters): ChartSuggestion[] { + if (!cols.length) { + return []; + } + const hasTitle = cols.some((c) => c.hasHeader); + const dataSets = cols.map((c, i) => dataset(c.zone, getters, String(i))); + const title = cols[0].header ?? _t("Data"); + const source = rangeSource(dataSets, hasTitle); + + return [ + { + title: _t("%s — Bar Chart", title), + rationale: _t("General-purpose bar chart for selected data."), + definition: barChart(title, source, { legendPosition: cols.length > 1 ? "top" : "none" }), + }, + { + title: _t("%s — Line Chart", title), + rationale: _t("General-purpose line chart for selected data."), + definition: lineChart(title, source, { legendPosition: cols.length > 1 ? "top" : "none" }), + }, + { + title: _t("%s — Pie Chart", title), + rationale: _t("Share of total for the selected data."), + definition: pieChart(title, source, { legendPosition: "top" }), + }, + { + title: _t("%s — Scatter Plot", title), + rationale: _t("Correlation between the first two numeric columns."), + definition: scatterChart(title, rangeSource(dataSets.slice(0, 2), hasTitle)), + }, + ]; +} + +function matchPattern(cols: ColumnAnalysis[], getters: Getters): ChartSuggestion[] { + const nonEmpty = cols.filter((c) => c.type !== "empty"); + if (!nonEmpty.length) { + return fallback(cols, getters); + } + + const shape = nonEmpty.map((c) => c.type); + const numberOfColumns = shape.length; + + // Pattern S — 3+ all numeric/percentage + if (numberOfColumns >= 3 && shape.every((t) => t === "number" || t === "percentage")) { + return patternS(nonEmpty, getters); + } + + if (numberOfColumns === 1) { + switch (shape[0]) { + case "number": + return chartsForSingleNumberColumn(nonEmpty[0], getters); + case "percentage": + return chartsForSinglePercentageColumn(nonEmpty[0], getters); + case "date": + return chartsForSingleDateColumn(nonEmpty[0], getters); + case "categorical": + return chartsForSingleCategoricalColumn(nonEmpty[0], getters); + case "hierarchy": + return chartsForSingleHierarchyColumn(nonEmpty[0], getters); + case "label": + return [ + { + title: _t("Labels Distribution"), + rationale: _t( + "Shows the distribution of values across different labels. Useful for categorical data without a natural order." + ), + definition: barChart( + nonEmpty[0].header ?? _t("Data"), + rangeSource([dataset(nonEmpty[0].zone, getters)], nonEmpty[0].hasHeader) + ), + }, + { + title: _t("Labels Proportion"), + rationale: _t( + "Shows the proportion of values across different labels. Useful for categorical data without a natural order." + ), + definition: pieChart( + nonEmpty[0].header ?? _t("Data"), + rangeSource([dataset(nonEmpty[0].zone, getters)], nonEmpty[0].hasHeader) + ), + }, + ]; + default: + return fallback(nonEmpty, getters); + } + } + + if (numberOfColumns === 2) { + const [a, b] = shape; + if (a === "categorical" && b === "number") { + return chartsForCategoricalVsNumber(nonEmpty, getters); + } + if (a === "date" && b === "number") { + return chartsForDateVsNumber(nonEmpty, getters); + } + if (a === "number" && b === "number") { + return chartsForNumberVsNumber(nonEmpty, getters); + } + if (a === "categorical" && b === "percentage") { + return chartsForCategoricalVsPercentage(nonEmpty, getters); + } + if (a === "date" && b === "percentage") { + return chartsForDateVsPercentage(nonEmpty, getters); + } + if (a === "label" && b === "number") { + return chartsForLabelVsNumber(nonEmpty, getters); + } + if (a === "categorical" && b === "categorical") { + return chartsForCategoricalVsCategorical(nonEmpty, getters); + } + if (a === "hierarchy" && b === "number") { + return chartsForHierarchyVsNumber(nonEmpty, getters); + } + if (a === "label" && b === "percentage") { + return chartsForCategoricalVsPercentage(nonEmpty, getters); + } + } + + if (numberOfColumns === 3) { + const [a, b, c] = shape; + if (a === "categorical" && b === "categorical" && c === "number") { + return chartsForMultipleCategoricalsVsNumber(nonEmpty, getters); + } + } + + if (numberOfColumns >= 3) { + const [first, ...rest] = shape; + if ( + (first === "categorical" || first === "label") && + rest.every((t) => t === "number" || t === "percentage") + ) { + return chartsForCategoricalVsMultipleNumbers(nonEmpty, getters); + } + if (first === "date" && rest.every((t) => t === "number" || t === "percentage")) { + return chartsForDateVsMultipleNumbers(nonEmpty, getters); + } + } + + return fallback(nonEmpty, getters); +} + +/** + * Analyzes the selected zones and returns an ordered list of chart suggestions. + * The first suggestion is the primary recommendation. + */ +export function getChartSuggestions(zones: Zone[], getters: Getters): ChartSuggestion[] { + const allCols = analyzeColumns(zones, getters); + return matchPattern(allCols, getters); +} diff --git a/src/helpers/figures/charts/smart_chart_engine.ts b/src/helpers/figures/charts/smart_chart_engine.ts index 1e13ee849b..38b76a908a 100644 --- a/src/helpers/figures/charts/smart_chart_engine.ts +++ b/src/helpers/figures/charts/smart_chart_engine.ts @@ -76,7 +76,7 @@ function detectColumnType(cells: EvaluatedCell[]): ColumnType { return detectedType; } -export function categorizeColumns(zones: Zone[], getters: Getters): ColumnInfo[] { +function categorizeColumns(zones: Zone[], getters: Getters): ColumnInfo[] { const columns: ColumnInfo[] = []; for (const zone of getZonesByColumns(zones)) { const cells = getters.getEvaluatedCellsInZone(getters.getActiveSheetId(), zone); From ad669d575c55bd0aa740a49b89a4817f3c2fb7e9 Mon Sep 17 00:00:00 2001 From: "Marceline (matho)" Date: Thu, 4 Jun 2026 16:03:14 +0200 Subject: [PATCH 3/3] [WIP] --- .../data_analysis/chart_suggestion_preview.ts | 34 +++++++++---------- .../data_analysis/data_analysis_panel.ts | 7 ++-- .../figures/charts/chart_suggestion_engine.ts | 13 +++---- .../figures/charts/gauge_chart_rendering.ts | 31 ++++++++++------- 4 files changed, 44 insertions(+), 41 deletions(-) diff --git a/src/components/side_panel/data_analysis/chart_suggestion_preview.ts b/src/components/side_panel/data_analysis/chart_suggestion_preview.ts index 3a4c4a3d81..981005d6f3 100644 --- a/src/components/side_panel/data_analysis/chart_suggestion_preview.ts +++ b/src/components/side_panel/data_analysis/chart_suggestion_preview.ts @@ -1,4 +1,4 @@ -import { onWillUnmount, signal } from "@odoo/owl"; +import { onWillUnmount, props, signal } from "@odoo/owl"; import { Chart, ChartConfiguration } from "chart.js/auto"; import { DEFAULT_FIGURE_HEIGHT, DEFAULT_FIGURE_WIDTH } from "../../../constants"; import { SpreadsheetChart } from "../../../helpers/figures/chart"; @@ -7,26 +7,19 @@ import { drawScoreChart } from "../../../helpers/figures/charts/scorecard_chart" import { getScorecardConfiguration } from "../../../helpers/figures/charts/scorecard_chart_config_builder"; import { UuidGenerator } from "../../../helpers/uuid"; import { Component, useLayoutEffect } from "../../../owl3_compatibility_layer"; -import { ChartDefinition } from "../../../types/chart/chart"; import { GaugeChartRuntime } from "../../../types/chart/gauge_chart"; import { ScorecardChartRuntime } from "../../../types/chart/scorecard_chart"; import { SpreadsheetChildEnv } from "../../../types/spreadsheet_env"; +import { types } from "../../props_validation"; -interface Props { - definition: ChartDefinition; - title: string; - rationale: string; - isRecommended: boolean; -} - -export class ChartSuggestionPreview extends Component { +export class ChartSuggestionPreview extends Component { static template = "o-spreadsheet-ChartSuggestionPreview"; - static props = { - definition: Object, - title: String, - rationale: String, - isRecommended: Boolean, - }; + protected props = props({ + definition: types.ChartDefinition(), + title: types.string(), + rationale: types.string(), + isRecommended: types.boolean(), + }); private chartCanvasRef = signal(null); private chartDivRef = signal(null); @@ -214,7 +207,14 @@ export class ChartSuggestionPreview extends Component { +export class DataAnalysisPanel extends Component { static template = "o-spreadsheet-DataAnalysisPanel"; - static props = { onCloseSidePanel: Function, zones: Array }; + protected props = props({ onCloseSidePanel: types.function(), zones: types.array(types.Zone()) }); static components = { NumberInput, SidePanelCollapsible, diff --git a/src/helpers/figures/charts/chart_suggestion_engine.ts b/src/helpers/figures/charts/chart_suggestion_engine.ts index 2f4d19d057..052ffef073 100644 --- a/src/helpers/figures/charts/chart_suggestion_engine.ts +++ b/src/helpers/figures/charts/chart_suggestion_engine.ts @@ -597,7 +597,7 @@ function chartsForCategoricalVsNumber(cols: ColumnAnalysis[], getters: Getters): { title: _t("%s — Pie Chart", title), rationale: _t("Share of total per category."), - definition: pieChart(title, source, { legendPosition: "top" }), + definition: pieChart(title, source, { legendPosition: "top", aggregated: true }), }, { title: _t("%s — Treemap", title), @@ -641,22 +641,19 @@ function chartsForDateVsNumber(cols: ColumnAnalysis[], getters: Getters): ChartS { title: _t("%s — Area Chart", title), rationale: _t("Emphasizes total volume over time."), - definition: lineChart(title, source, { fillArea: true }), + definition: lineChart(title, source, { fillArea: true, cumulative: true }), }, { title: _t("%s — Bar Chart", title), rationale: _t("Period-by-period comparison."), definition: barChart(title, source), }, - ]; - - if (numCol.rowCount >= 20) { - suggestions.push({ + { title: _t("%s — Calendar Heatmap", title), rationale: _t("Shows intensity variation across days of the year."), definition: calendarChart(title, source), - }); - } + }, + ]; return suggestions; } diff --git a/src/helpers/figures/charts/gauge_chart_rendering.ts b/src/helpers/figures/charts/gauge_chart_rendering.ts index f9bbedfd74..3b919b0cb6 100644 --- a/src/helpers/figures/charts/gauge_chart_rendering.ts +++ b/src/helpers/figures/charts/gauge_chart_rendering.ts @@ -76,7 +76,8 @@ export function drawGaugeChart( canvas: CanvasSurface, runtime: GaugeAnimatedRuntime, zoom: number = 1, - dimensions?: DOMDimension // TODO VSC: this doesn't look used, consider removing this param + dimensions?: DOMDimension, // TODO VSC: this doesn't look used, consider removing this param + options?: { labelFontSize?: number } ) { const size = dimensions ?? getCanvasSize(canvas); const dpr = typeof globalThis.devicePixelRatio === "number" ? globalThis.devicePixelRatio : 1; @@ -91,7 +92,8 @@ export function drawGaugeChart( const config = getGaugeRenderingConfig( { width: size.width / zoom, height: size.height / zoom, x: 0, y: 0 }, runtime, - ctx as CanvasRenderingContext2D + ctx as CanvasRenderingContext2D, + options ); drawBackground(ctx as CanvasRenderingContext2D, config); drawGauge(ctx as CanvasRenderingContext2D, config); @@ -191,8 +193,10 @@ function drawTitle(ctx: CanvasRenderingContext2D, config: RenderingParams) { export function getGaugeRenderingConfig( boundingRect: Rect, runtime: GaugeAnimatedRuntime, - ctx: CanvasRenderingContext2D + ctx: CanvasRenderingContext2D, + options?: { labelFontSize?: number } ): RenderingParams { + const labelFontSize = options?.labelFontSize ?? GAUGE_LABELS_FONT_SIZE; const maxValue = runtime.maxValue; const minValue = runtime.minValue; const gaugeValue = getGaugeValue(runtime, "animated"); @@ -228,17 +232,17 @@ export function getGaugeRenderingConfig( const minLabelPosition = { x: gaugeRect.x + gaugeArcWidth / 2, - y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE, + y: gaugeRect.y + gaugeRect.height + labelFontSize, }; const maxLabelPosition = { x: gaugeRect.x + gaugeRect.width - gaugeArcWidth / 2, - y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE, + y: gaugeRect.y + gaugeRect.height + labelFontSize, }; const textColor = chartMutedFontColor(runtime.background); - const inflectionValues = getInflectionValues(runtime, gaugeRect, textColor, ctx); + const inflectionValues = getInflectionValues(runtime, gaugeRect, textColor, ctx, labelFontSize); let x: number = 0, titleWidth = 0, @@ -295,13 +299,13 @@ export function getGaugeRenderingConfig( minLabel: { label: runtime.minValue.label, textPosition: minLabelPosition, - fontSize: GAUGE_LABELS_FONT_SIZE, + fontSize: labelFontSize, color: textColor, }, maxLabel: { label: runtime.maxValue.label, textPosition: maxLabelPosition, - fontSize: GAUGE_LABELS_FONT_SIZE, + fontSize: labelFontSize, color: textColor, }, }; @@ -343,7 +347,8 @@ function getInflectionValues( runtime: GaugeAnimatedRuntime, gaugeRect: Rect, textColor: Color, - ctx: CanvasRenderingContext2D + ctx: CanvasRenderingContext2D, + labelFontSize: number = GAUGE_LABELS_FONT_SIZE ): InflectionValue[] { const maxValue = runtime.maxValue; const minValue = runtime.minValue; @@ -352,7 +357,7 @@ function getInflectionValues( x: gaugeRect.x + gaugeRect.width / 2, y: gaugeRect.y + gaugeRect.height, }; - const textStyle = { fontSize: GAUGE_LABELS_FONT_SIZE }; + const textStyle = { fontSize: labelFontSize }; const inflectionValues: InflectionValue[] = []; const inflectionValuesTextRects: UnalignedRectangle[] = []; @@ -368,17 +373,17 @@ function getInflectionValues( gaugeCircleCenter.x, // center of the gauge circle gaugeCircleCenter.y, // center of the gauge circle labelWidth + 2, // width of the text + some margin - GAUGE_LABELS_FONT_SIZE // height of the text + labelFontSize // height of the text ); const offset = inflectionValuesTextRects.some((rect) => doRectanglesIntersect(rect, textRect)) - ? GAUGE_LABELS_FONT_SIZE + ? labelFontSize : 0; inflectionValuesTextRects.push(textRect); inflectionValues.push({ rotation: angle, label: inflectionValue.label, - fontSize: GAUGE_LABELS_FONT_SIZE, + fontSize: labelFontSize, color: textColor, offset, });