diff --git a/src/helpers/figures/charts/smart_chart_engine.ts b/src/helpers/figures/charts/smart_chart_engine.ts index 940afd114b..0d48d2189a 100644 --- a/src/helpers/figures/charts/smart_chart_engine.ts +++ b/src/helpers/figures/charts/smart_chart_engine.ts @@ -4,25 +4,37 @@ import { DEFAULT_SCORECARD_BASELINE_MODE, } from "../../../constants"; import { CellValueType, ChartDefinition, EvaluatedCell, Getters, Zone } from "../../../types"; +import { BarChartDefinition, LineChartDefinition } from "../../../types/chart"; import { isDateTimeFormat } from "../../format/format"; -import { recomputeZones } from "../../recompute_zones"; import { getZoneArea, getZonesByColumns, zoneToXc } from "../../zones"; type ColumnType = "number" | "text" | "date" | "percentage" | "empty"; +const DEFAULT_BAR_CHART_CONFIG: BarChartDefinition = { + type: "bar", + title: {}, + dataSets: [], + legendPosition: "none", + dataSetsHaveTitle: false, + stacked: false, +}; + +const DEFAULT_LINE_CHART_CONFIG: LineChartDefinition = { + type: "line", + title: {}, + dataSets: [], + legendPosition: "none", + dataSetsHaveTitle: false, + stacked: false, + cumulative: false, + labelsAsText: false, +}; + interface ColumnInfo { zone: Zone; type: ColumnType; } -const CHART_LIMITS = { - MAX_PIE_CATEGORIES: 7, - MAX_PIE_CATEGORIES_NO_TITLE: 6, - MIN_RADAR_CATEGORIES: 3, - MAX_RADAR_CATEGORIES: 12, - PERCENTAGE_THRESHOLD: 100, -} as const; - function getUnboundRange(getters: Getters, zone: Zone): string { return zoneToXc(getters.getUnboundedZone(getters.getActiveSheetId(), zone)); } @@ -58,48 +70,21 @@ function detectColumnType(cells: EvaluatedCell[]): ColumnType { return detectedType; } -function categorizeColumns( - zones: Zone[], - getters: Getters -): Record<"number" | "text" | "date", ColumnInfo[]> { - const columns: Record<"number" | "text" | "date", ColumnInfo[]> = { - number: [], - text: [], - date: [], - }; +function categorizeColumns(zones: Zone[], getters: Getters): ColumnInfo[] { + const columns: ColumnInfo[] = []; for (const zone of getZonesByColumns(zones)) { const cells = getters.getEvaluatedCellsInZone(getters.getActiveSheetId(), zone); - const type = detectColumnType(cells); - if (type !== "empty") { - const targetType = type === "percentage" ? "number" : type; - columns[targetType].push({ zone, type }); - } + columns.push({ zone, type: detectColumnType(cells) }); } return columns; } function getCellStats(getters: Getters, zone: Zone) { const cells = getters.getEvaluatedCellsInZone(getters.getActiveSheetId(), zone); - const uniqueValues = new Set(); - let totalCount = 0; - let percentageSum = 0; - for (let i = 0; i < cells.length; i++) { - const { value } = cells[i]; - const str = value?.toString().trim(); - if (!str) { - continue; - } - uniqueValues.add(str); - totalCount++; - const num = Number(value); - if (!isNaN(num)) { - percentageSum += Math.abs(num) * 100; - } - } + const values = cells.map((c) => c.value?.toString().trim() || "").filter((s) => s); return { - uniqueCount: uniqueValues.size, - totalCount, - percentageSum, + uniqueCount: new Set(values).size, + totalCount: values.length, }; } @@ -112,20 +97,14 @@ function isDatasetTitled(getters: Getters, column: ColumnInfo): boolean { return ![CellValueType.number, CellValueType.empty].includes(titleCell.type); } -function createBaseChart( - type: string, - dataSets: any[], - options: Partial = {} -): ChartDefinition { - return { - type, - title: {}, - dataSets, - legendPosition: "none", - ...options, - } as ChartDefinition; -} - +/** + * Builds a chart definition for a single column selection. The logic to detect the chart type is as follows: + * - If the column contains a single cell, create a scorecard. + * - If the column type is "percentage", create a pie chart. + * - If the column type is "text", create a pie chart + * - If the column type is "date", create a line chart. + * - Otherwise, create a bar chart. + */ function buildSingleColumnChart(column: ColumnInfo, getters: Getters): ChartDefinition { const { type, zone } = column; const sheetId = getters.getActiveSheetId(); @@ -133,14 +112,19 @@ function buildSingleColumnChart(column: ColumnInfo, getters: Getters): ChartDefi const dataRange = getUnboundRange(getters, zone); const titleCell = getters.getEvaluatedCell({ sheetId, col: zone.left, row: zone.top }); + if (getZoneArea(zone) === 1) { + return buildScorecard(zone, getters); + } + switch (type) { case "percentage": - const { percentageSum } = getCellStats(getters, zone); - return createBaseChart("pie", [{ dataRange }], { + return { + type: "pie", title: dataSetsHaveTitle ? { text: String(titleCell.value) } : {}, + dataSets: [{ dataRange }], + legendPosition: "none", dataSetsHaveTitle, - isDoughnut: percentageSum < CHART_LIMITS.PERCENTAGE_THRESHOLD, - }); + }; case "text": const cells = getters.getEvaluatedCellsInZone(sheetId, zone); @@ -149,181 +133,182 @@ function buildSingleColumnChart(column: ColumnInfo, getters: Getters): ChartDefi 0 ); const hasUniqueTitle = titleCell.value !== null && titleCount === 1; - return createBaseChart("pie", [{ dataRange }], { + return { + type: "pie", title: hasUniqueTitle ? { text: String(titleCell.value) } : {}, + dataSets: [{ dataRange }], labelRange: dataRange, dataSetsHaveTitle: hasUniqueTitle, - isDoughnut: false, aggregated: true, legendPosition: "top", - }); + }; - // TODO: Handle date column with matrix chart when matrix chart is supported case "date": - return createBaseChart("line", [{ dataRange }], { - labelRange: dataRange, + return { + ...DEFAULT_LINE_CHART_CONFIG, + type: "line", + title: dataSetsHaveTitle ? { text: String(titleCell.value) } : {}, + dataSets: [{ dataRange }], dataSetsHaveTitle, - cumulative: false, - labelsAsText: false, - }); + }; } - return createBaseChart("bar", [{ dataRange }], { dataSetsHaveTitle }); + return { + ...DEFAULT_BAR_CHART_CONFIG, + title: dataSetsHaveTitle ? { text: String(titleCell.value) } : {}, + dataSets: [{ dataRange }], + dataSetsHaveTitle, + }; } -function buildTwoColumnChart( - columns: Record<"number" | "text" | "date", ColumnInfo[]>, - getters: Getters -): ChartDefinition { - const { number: numberColumns, text: textColumns, date: dateColumns } = columns; - - if (numberColumns.length === 2) { - return createBaseChart( - "scatter", - [{ dataRange: getUnboundRange(getters, numberColumns[1].zone) }], - { - labelRange: getUnboundRange(getters, numberColumns[0].zone), - dataSetsHaveTitle: isDatasetTitled(getters, numberColumns[1]), - labelsAsText: false, - } - ); +/** + * Builds a chart definition for a selection of two columns. The logic to detect the chart type always consider the + * columns left to right, and is as follows: + * - any type + percentage columns: pie chart + * - number + number columns: scatter chart + * - date + number columns: line chart + * - text + number columns: treemap if repetition in labels + * - any other combination: bar chart + */ +function buildTwoColumnChart(columns: ColumnInfo[], getters: Getters): ChartDefinition { + if (columns.length !== 2) { + throw new Error("buildTwoColumnChart expects exactly two columns"); } - // TODO: Handle date + number with matrix chart when matrix chart is supported - if (dateColumns.length === 1 && numberColumns.length === 1) { - return createBaseChart( - "line", - [{ dataRange: getUnboundRange(getters, numberColumns[0].zone) }], - { - labelRange: getUnboundRange(getters, dateColumns[0].zone), - dataSetsHaveTitle: isDatasetTitled(getters, numberColumns[0]), - aggregated: false, - cumulative: false, - labelsAsText: false, - } - ); + if (columns[1].type === "percentage") { + return { + type: "pie", + title: {}, + dataSets: [{ dataRange: getUnboundRange(getters, columns[1].zone) }], + labelRange: getUnboundRange(getters, columns[0].zone), + dataSetsHaveTitle: isDatasetTitled(getters, columns[1]), + aggregated: true, + legendPosition: "none", + }; + } + + if (columns[0].type === "number" && columns[1].type === "number") { + return { + type: "scatter", + title: {}, + dataSets: [{ dataRange: getUnboundRange(getters, columns[1].zone) }], + labelRange: getUnboundRange(getters, columns[0].zone), + dataSetsHaveTitle: isDatasetTitled(getters, columns[1]), + labelsAsText: false, + legendPosition: "none", + }; + } + + // TODO: Handle date + number with calendar chart when implemented (and change the docstring) + if (columns[0].type === "date" && columns[1].type === "number") { + return { + ...DEFAULT_LINE_CHART_CONFIG, + type: "line", + dataSets: [{ dataRange: getUnboundRange(getters, columns[1].zone) }], + labelRange: getUnboundRange(getters, columns[0].zone), + dataSetsHaveTitle: isDatasetTitled(getters, columns[0]), + }; } - if (textColumns.length === 1 && numberColumns.length === 1) { - const [textColumn] = textColumns; - const [numberColumn] = numberColumns; + if (columns[0].type === "text" && columns[1].type === "number") { + const textColumn = columns[0]; + const numberColumn = columns[1]; + const { uniqueCount, totalCount } = getCellStats(getters, textColumn.zone); const dataSetsHaveTitle = isDatasetTitled(getters, numberColumn); - const maxCategories = dataSetsHaveTitle - ? CHART_LIMITS.MAX_PIE_CATEGORIES - : CHART_LIMITS.MAX_PIE_CATEGORIES_NO_TITLE; - const labelRange = getUnboundRange(getters, textColumn.zone); - const dataRange = getUnboundRange(getters, numberColumn.zone); - - if (uniqueCount <= maxCategories) { - const { percentageSum } = getCellStats(getters, numberColumn.zone); - return createBaseChart("pie", [{ dataRange }], { - labelRange, - dataSetsHaveTitle, - isDoughnut: - numberColumn.type === "percentage" && percentageSum < CHART_LIMITS.PERCENTAGE_THRESHOLD, - aggregated: true, - legendPosition: "top", - }); - } - // Use treemap when categories repeat, as pie chart would be cluttered if (uniqueCount !== totalCount) { - return createBaseChart("treemap", [{ dataRange: labelRange }], { - labelRange: dataRange, + return { + type: "treemap", + title: {}, + dataSets: [{ dataRange: getUnboundRange(getters, textColumn.zone) }], + labelRange: getUnboundRange(getters, numberColumn.zone), dataSetsHaveTitle, - }); + legendPosition: "none", + }; } + } - return createBaseChart("bar", [{ dataRange }], { - labelRange, - dataSetsHaveTitle, - }); + return { + ...DEFAULT_BAR_CHART_CONFIG, + dataSets: [{ dataRange: getUnboundRange(getters, columns[1].zone) }], + labelRange: getUnboundRange(getters, columns[0].zone), + dataSetsHaveTitle: isDatasetTitled(getters, columns[1]), + }; +} + +/** + * Builds a chart definition for a selection more than two columns. The logic to detect the chart type always consider + * the columns left to right, and is as follows: + * - multiple text + single number/percentage columns: sunburst if 3+ text columns, treemap otherwise + * - any type + multiple percentage columns: pie chart + * - date + multiple number columns: line chart + * - any other combination: bar chart + */ +function buildMultiColumnChart(columns: ColumnInfo[], getters: Getters): ChartDefinition { + if (columns.length < 3) { + throw new Error("buildMultiColumnChart expects at least three columns"); } - const labelColumn = textColumns[0] || dateColumns[0] || numberColumns[0]; - const dataColumn = numberColumns[0] || textColumns[0] || dateColumns[0]; + const dataSetsHaveTitle = columns.some( + (col) => col.type !== "text" && isDatasetTitled(getters, col) + ); - return createBaseChart("line", [{ dataRange: getUnboundRange(getters, dataColumn.zone) }], { - labelRange: getUnboundRange(getters, labelColumn.zone), - dataSetsHaveTitle: isDatasetTitled(getters, dataColumn), - cumulative: false, - labelsAsText: true, - }); -} + const lastColumn = columns[columns.length - 1]; + const columnsExceptLast = columns.slice(0, columns.length - 1); -function buildMultiColumnChart( - columns: Record<"number" | "text" | "date", ColumnInfo[]>, - getters: Getters -): ChartDefinition { - const { number: numberColumns, text: textColumns, date: dateColumns } = columns; - const dataSetsHaveTitle = numberColumns.some((col) => isDatasetTitled(getters, col)); - - if (textColumns.length >= 2 && numberColumns.length === 1) { - const sortedTextColumns = textColumns.sort( - (colA, colB) => - getCellStats(getters, colA.zone).uniqueCount - getCellStats(getters, colB.zone).uniqueCount - ); - const dataSets = sortedTextColumns.map(({ zone }) => ({ + if ( + (lastColumn.type === "percentage" || lastColumn.type === "number") && + columnsExceptLast.every((col) => col.type === "text") + ) { + const dataSets = columnsExceptLast.map(({ zone }) => ({ dataRange: getUnboundRange(getters, zone), })); - return createBaseChart(textColumns.length >= 3 ? "sunburst" : "treemap", dataSets, { - labelRange: getUnboundRange(getters, numberColumns[0].zone), + return { + type: columnsExceptLast.length >= 3 ? "sunburst" : "treemap", + title: {}, + dataSets, + labelRange: getUnboundRange(getters, lastColumn.zone), dataSetsHaveTitle, - }); + legendPosition: "none", + }; } - const dataSets = recomputeZones(numberColumns.map((col) => col.zone)).map((zone) => ({ + const firstColumn = columns[0]; + const columnsExceptFirst = columns.slice(1); + const rangesOfColumnsExceptFirst = columnsExceptFirst.map(({ zone }) => ({ dataRange: getUnboundRange(getters, zone), })); - if (dateColumns.length === 1 && numberColumns.length > 1) { - return createBaseChart("line", dataSets, { - labelRange: getUnboundRange(getters, dateColumns[0].zone), + if (columnsExceptFirst.every((col) => col.type === "percentage")) { + return { + type: "pie", + title: {}, + dataSets: rangesOfColumnsExceptFirst, + labelRange: getUnboundRange(getters, firstColumn.zone), dataSetsHaveTitle, - cumulative: false, - labelsAsText: false, + aggregated: false, legendPosition: "top", - }); + }; } - if (textColumns.length === 1 && numberColumns.length >= 2) { - const [textColumn] = textColumns; - const firstCell = getters.getEvaluatedCell({ - sheetId: getters.getActiveSheetId(), - row: textColumn.zone.top, - col: textColumn.zone.left, - }); - const { uniqueCount, totalCount } = getCellStats(getters, textColumn.zone); - const categoryCount = dataSetsHaveTitle && firstCell.value ? uniqueCount - 1 : uniqueCount; - const expectedDataCount = - categoryCount * numberColumns.length + (dataSetsHaveTitle ? numberColumns.length : 0); - const actualDataCount = numberColumns.reduce( - (sum, dataCol) => sum + getCellStats(getters, dataCol.zone).totalCount, - 0 - ); - - if ( - uniqueCount === totalCount && - uniqueCount >= CHART_LIMITS.MIN_RADAR_CATEGORIES && - uniqueCount <= CHART_LIMITS.MAX_RADAR_CATEGORIES && - expectedDataCount === actualDataCount - ) { - return createBaseChart("radar", dataSets, { - title: dataSetsHaveTitle && firstCell.value ? { text: String(firstCell.value) } : {}, - labelRange: getUnboundRange(getters, textColumn.zone), - dataSetsHaveTitle, - legendPosition: "top", - }); - } + if (firstColumn.type === "date" && columnsExceptFirst.every((col) => col.type === "number")) { + return { + ...DEFAULT_LINE_CHART_CONFIG, + type: "line", + dataSets: rangesOfColumnsExceptFirst, + labelRange: getUnboundRange(getters, firstColumn.zone), + dataSetsHaveTitle, + legendPosition: "top", + }; } - const labelColumn = textColumns[0] || dateColumns[0] || numberColumns[0]; - return createBaseChart("bar", dataSets, { - labelRange: dataSets.length ? getUnboundRange(getters, labelColumn.zone) : "", + return { + ...DEFAULT_BAR_CHART_CONFIG, + dataSets: rangesOfColumnsExceptFirst, + labelRange: getUnboundRange(getters, firstColumn.zone), dataSetsHaveTitle, - aggregated: true, legendPosition: "top", - }); + }; } function buildScorecard(zone: Zone, getters: Getters): ChartDefinition { @@ -348,22 +333,19 @@ function buildScorecard(zone: Zone, getters: Getters): ChartDefinition { */ export function getSmartChartDefinition(zones: Zone[], getters: Getters): ChartDefinition { const columns = categorizeColumns(zones, getters); - const { number: numberColumns, text: textColumns, date: dateColumns } = columns; - - const columnCount = numberColumns.length + textColumns.length + dateColumns.length; - switch (columnCount) { - case 0: - return createBaseChart("bar", [{ dataRange: getUnboundRange(getters, zones[0]) }], { - dataSetsHaveTitle: false, - }); + + if (columns.length === 0 || columns.every((col) => col.type === "empty")) { + const dataSets = columns.map(({ zone }) => ({ dataRange: getUnboundRange(getters, zone) })); + return { ...DEFAULT_BAR_CHART_CONFIG, dataSets }; + } + + const nonEmptyColumns = columns.filter((col) => col.type !== "empty"); + switch (nonEmptyColumns.length) { case 1: - const singleColumn = numberColumns[0] || textColumns[0] || dateColumns[0]; - return getZoneArea(singleColumn.zone) === 1 - ? buildScorecard(singleColumn.zone, getters) - : buildSingleColumnChart(singleColumn, getters); + return buildSingleColumnChart(nonEmptyColumns[0], getters); case 2: - return buildTwoColumnChart(columns, getters); + return buildTwoColumnChart(nonEmptyColumns, getters); default: - return buildMultiColumnChart(columns, getters); + return buildMultiColumnChart(nonEmptyColumns, getters); } } diff --git a/tests/figures/chart/menu_item_insert_chart.test.ts b/tests/figures/chart/menu_item_insert_chart.test.ts index 7d9891399b..fd477711b0 100644 --- a/tests/figures/chart/menu_item_insert_chart.test.ts +++ b/tests/figures/chart/menu_item_insert_chart.test.ts @@ -4,12 +4,9 @@ import { DEFAULT_CELL_WIDTH, DEFAULT_FIGURE_HEIGHT, DEFAULT_FIGURE_WIDTH, - DEFAULT_SCORECARD_BASELINE_COLOR_DOWN, - DEFAULT_SCORECARD_BASELINE_COLOR_UP, - DEFAULT_SCORECARD_BASELINE_MODE, } from "../../../src/constants"; -import { zoneToXc } from "../../../src/helpers"; -import { SpreadsheetChildEnv } from "../../../src/types"; +import { toXC, zoneToXc } from "../../../src/helpers"; +import { ChartDefinition, CustomizedDataSet, SpreadsheetChildEnv } from "../../../src/types"; import { addColumns, addRows, @@ -24,7 +21,6 @@ import { mockChart, mountSpreadsheet, nextTick, - setGrid, spyModelDispatch, } from "../../test_helpers/helpers"; @@ -82,7 +78,6 @@ describe("Insert chart menu item", () => { let dispatchSpy: jest.SpyInstance; let defaultPayload: any; - let defaultPiePayload: any; let model: Model; let env: SpreadsheetChildEnv; let openSidePanelSpy: jest.Mock; @@ -117,22 +112,12 @@ describe("Insert chart menu item", () => { definition: { dataSets: [{ dataRange: "A1", yAxisId: "y" }], dataSetsHaveTitle: false, + stacked: false, legendPosition: "none", title: {}, type: "bar", }, }; - defaultPiePayload = { - ...defaultPayload, - definition: { - dataSets: [{ dataRange: "A1" }], - dataSetsHaveTitle: false, - legendPosition: "top", - isDoughnut: false, - title: {}, - type: "pie", - }, - }; }); test("Chart is inserted at correct position", () => { @@ -404,96 +389,19 @@ describe("Insert chart menu item", () => { }); }); - test("Chart of single column without title", () => { - setSelection(model, ["B2:B5"]); - insertChart(); - const payload = { ...defaultPayload }; - payload.definition.dataSets = [{ dataRange: "B2:B5" }]; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); - }); - - test("Chart of single column with title", () => { - setSelection(model, ["B1:B5"]); - insertChart(); - const payload = { ...defaultPayload }; - payload.definition.dataSets = [{ dataRange: "B1:B5" }]; - payload.definition.dataSetsHaveTitle = true; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); - }); - - test("Chart of several columns (ie labels) without title", () => { - setSelection(model, ["A2:B5"]); - insertChart(); - const payload = { ...defaultPiePayload }; - payload.definition.dataSets = [{ dataRange: "B2:B5" }]; - payload.definition.labelRange = "A2:A5"; - payload.definition.aggregated = true; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); - }); - - test("Chart of several columns (ie labels) with title", () => { - setSelection(model, ["A1:B5"]); - insertChart(); - const payload = { ...defaultPiePayload }; - payload.definition.dataSets = [{ dataRange: "B1:B5" }]; - payload.definition.labelRange = "A1:A5"; - payload.definition.aggregated = true; - payload.definition.dataSetsHaveTitle = true; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); - }); - - test("[Case 1] Chart is inserted with proper legend position", () => { - setSelection(model, ["A1:B5"]); - insertChart(); - const payload = { ...defaultPiePayload }; - payload.definition.dataSets = [{ dataRange: "B1:B5" }]; - payload.definition.labelRange = "A1:A5"; - payload.definition.aggregated = true; - payload.definition.dataSetsHaveTitle = true; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); - }); - test("[Case 2] Chart is inserted with proper legend position", () => { - setSelection(model, ["F1:I5"]); - insertChart(); - const payload = { ...defaultPayload }; - payload.definition.dataSets = [{ dataRange: "F1:H5" }]; - payload.definition.labelRange = "F1:F5"; - payload.definition.aggregated = true; - payload.definition.dataSetsHaveTitle = true; - payload.definition.legendPosition = "top"; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); - }); - - test("Chart of single isolated cell is a scorecard", () => { - setCellContent(model, "K5", "Hello"); - setSelection(model, ["K5"]); - insertChart(); - const payload = { ...defaultPayload }; - payload.definition = { - keyValue: "K5", - title: {}, - type: "scorecard", - baselineColorDown: DEFAULT_SCORECARD_BASELINE_COLOR_DOWN, - baselineColorUp: DEFAULT_SCORECARD_BASELINE_COLOR_UP, - baselineMode: DEFAULT_SCORECARD_BASELINE_MODE, - }; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); - }); - - test("Chart of single isolated empty cell is a bar chart", () => { - setSelection(model, ["K5"]); - insertChart(); - const payload = { ...defaultPayload }; - payload.definition.dataSets = [{ dataRange: "K5" }]; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); - }); - test("Chart of single cell will extend the selection to find a 'table'", () => { setSelection(model, ["A2"]); insertChart(); const payload = { ...defaultPayload }; - payload.definition.dataSets = [{ dataRange: "B1:H5" }]; - payload.definition.aggregated = true; + payload.definition.dataSets = [ + { dataRange: "B1:B5" }, + { dataRange: "C1:C5" }, + { dataRange: "D1:D5" }, + { dataRange: "E1:E5" }, + { dataRange: "F1:F5" }, + { dataRange: "G1:G5" }, + { dataRange: "H1:H5" }, + ]; payload.definition.labelRange = "A1:A5"; payload.definition.dataSetsHaveTitle = true; payload.definition.legendPosition = "top"; @@ -501,222 +409,212 @@ describe("Insert chart menu item", () => { expect(zoneToXc(model.getters.getSelectedZone())).toBe("A1:H5"); }); - test("Chart with number cells as labels is a scatter chart", () => { - setCellContent(model, "K1", "1"); - setCellContent(model, "K2", "2"); - setCellContent(model, "K3", "3"); - setCellContent(model, "L1", "1"); - setCellContent(model, "L2", "2"); - setCellContent(model, "L3", "3"); - - setSelection(model, ["K1"]); + test("Chart can be inserted with unbounded ranges", () => { + setSelection(model, ["A1:B100"], { unbounded: true }); insertChart(); - const payload = { ...defaultPayload }; - payload.definition.type = "scatter"; - payload.definition.dataSets = [{ dataRange: "L1:L3" }]; - payload.definition.labelRange = "K1:K3"; - payload.definition.labelsAsText = false; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); - expect(zoneToXc(model.getters.getSelectedZone())).toBe("K1:L3"); + const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0]; + expect(model.getters.getChartDefinition(chartId)).toMatchObject({ + dataSets: [{ dataRange: "B:B" }], + labelRange: "A:A", + }); }); +}); - test("Chart with date cells as labels is a linear chart", () => { - setCellContent(model, "K1", "10/10/2022"); - setCellContent(model, "K2", "10/11/2022"); - setCellContent(model, "K3", "10/12/2022"); - setCellContent(model, "L1", "1"); - setCellContent(model, "L2", "2"); - setCellContent(model, "L3", "3"); - - setSelection(model, ["K1"]); - insertChart(); - const payload = { ...defaultPayload }; - payload.definition.type = "line"; - payload.definition.dataSets = [{ dataRange: "L1:L3" }]; - payload.definition.labelRange = "K1:K3"; - payload.definition.aggregated = false; - payload.definition.cumulative = false; - payload.definition.labelsAsText = false; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); - expect(zoneToXc(model.getters.getSelectedZone())).toBe("K1:L3"); - }); +describe("Smart chart type detection", () => { + type DatasetDescriptor = string[]; - test("Chart with percentage cells is a doughnut chart when sum < 100", () => { - setCellContent(model, "K1", "10%"); - setCellContent(model, "K2", "20%"); - setCellContent(model, "K3", "30%"); - setSelection(model, ["K1:K3"]); - insertChart(); - const payload = { ...defaultPiePayload }; - payload.definition.dataSets = [{ dataRange: "K1:K3" }]; - payload.definition.legendPosition = "none"; - payload.definition.isDoughnut = true; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); - }); + let model: Model; + let env: SpreadsheetChildEnv; - test("Chart with percentage cells is a pie chart when sum >= 100", () => { - setCellContent(model, "K1", "40%"); - setCellContent(model, "K2", "30%"); - setCellContent(model, "K3", "40%"); + beforeEach(() => { + model = new Model(); + env = makeTestEnv({ model }); + }); + + /** + * Create a dataset according to the given pattern. The pattern is a list of column types, with possible modifiers + * (eg. ["text_with_header", "number_repeated", "empty", "date"]) would create a dataset of 4 columns. + */ + function createDatasetFromDescription(description: DatasetDescriptor) { + for (let col = 0; col < description.length; col++) { + const colDescription = description[col]; + const hasHeader = colDescription.includes("_with_header"); + const repeatedValues = colDescription.includes("_repeated"); + const type = colDescription.replace("_with_header", "").replace("_repeated", ""); + + for (let row = 0; row < 6; row++) { + const xc = toXC(col, row); + if (row === 0 && hasHeader) { + setCellContent(model, xc, `Header${col}`); + continue; + } + if (type === "empty") { + continue; + } + const generator = repeatedValues ? row % 3 : row; + if (type === "text") { + setCellContent(model, xc, `Text${generator}`); + } else if (type === "number") { + setCellContent(model, xc, `${generator}`); + } else if (type === "date") { + setCellContent(model, xc, `2022-10-${generator + 1}`); + } else if (type === "percentage") { + setCellContent(model, xc, `${generator * 10}%`); + } + } + } + } - setSelection(model, ["K1:K3"]); - insertChart(); - const payload = { ...defaultPiePayload }; - payload.definition.dataSets = [{ dataRange: "K1:K3" }]; - payload.definition.legendPosition = "none"; - payload.definition.isDoughnut = false; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); + test("Single cell: create a scorecard", () => { + setCellContent(model, "C3", "100"); + setSelection(model, ["C3"]); + doAction(["insert", "insert_chart"], env); + const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0]; + expect(model.getters.getChartDefinition(chartId)).toMatchObject({ + type: "scorecard", + keyValue: "C3", + }); }); - test("Chart with text cells (including empty cells) is a pie chart", () => { - setCellContent(model, "K1", "Country"); - setCellContent(model, "K2", "India"); - setCellContent(model, "K3", "Pakistan"); - setCellContent(model, "K4", "India"); - setCellContent(model, "K6", "USA"); + test.each<[DatasetDescriptor, Partial]>([ + [["percentage"], { type: "pie" }], + [["number"], { type: "bar" }], + [["text"], { type: "pie", labelRange: "A1:A6", aggregated: true }], // categorical pie chart, the data range is also the label range + [["date"], { type: "line" }], + [["percentage_with_header"], { type: "pie", dataSetsHaveTitle: true }], + [["date_with_header"], { type: "line", dataSetsHaveTitle: true }], + ])("Single column %s creates %s chart", (datasetPattern, expected) => { + createDatasetFromDescription(datasetPattern); + doAction(["insert", "insert_chart"], env); - setSelection(model, ["K1:K100"]); - insertChart(); - const payload = { ...defaultPiePayload }; - payload.definition.title = { text: "Country" }; - payload.definition.dataSets = [{ dataRange: "K:K" }]; - payload.definition.labelRange = "K:K"; - payload.definition.dataSetsHaveTitle = true; - payload.definition.aggregated = true; - payload.definition.legendPosition = "top"; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); - }); + const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0]; - test("Text + number with <=6 unique labels creates pie chart", () => { - setSelection(model, ["A1:A5", "B1:B5"]); - insertChart(); - const payload = { ...defaultPiePayload }; - payload.definition.dataSets = [{ dataRange: "B1:B5" }]; - payload.definition.labelRange = "A1:A5"; - payload.definition.isDoughnut = false; - payload.definition.aggregated = true; - payload.definition.dataSetsHaveTitle = true; - payload.definition.legendPosition = "top"; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); + const definition = model.getters.getChartDefinition(chartId); + expect(definition).toMatchObject({ + ...expected, + dataSets: [{ dataRange: "A1:A6" }], + labelRange: "labelRange" in expected ? expected.labelRange : undefined, + }); }); - test("Text + number with > 6 non-unique labels creates treemap chart", () => { - const labels = ["A", "B", "C", "D", "E", "F", "G", "A", "B"]; - const numbers = [10, 20, 30, 40, 50, 60, 70, 80, 90]; + test.each<[DatasetDescriptor, Partial]>([ + [["text", "percentage"], { type: "pie" }], + [["number", "percentage"], { type: "pie" }], + [["date", "percentage"], { type: "pie" }], + [["number", "number"], { type: "scatter" }], + [["date", "number"], { type: "line" }], + [["text", "number"], { type: "bar" }], + [["text_repeated", "number"], { type: "treemap" }], + [["text", "date"], { type: "bar" }], + [["number", "text"], { type: "bar" }], + [["text", "number_with_header"], { type: "bar", dataSetsHaveTitle: true }], + [["number", "number_with_header"], { type: "scatter", dataSetsHaveTitle: true }], + ])("Two columns %s creates %s chart", (datasetPattern, expected) => { + createDatasetFromDescription(datasetPattern); + doAction(["insert", "insert_chart"], env); - labels.forEach((label, i) => { - setCellContent(model, `K${i + 1}`, label); - }); + const expectedDataset = + expected.type === "treemap" ? [{ dataRange: "A1:A6" }] : [{ dataRange: "B1:B6" }]; + const expectedLabelRange = expected.type === "treemap" ? "B1:B6" : "A1:A6"; - numbers.forEach((value, i) => { - setCellContent(model, `L${i + 1}`, value.toString()); + const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0]; + expect(model.getters.getChartDefinition(chartId)).toMatchObject({ + ...expected, + dataSets: expectedDataset, + labelRange: expectedLabelRange, }); - - setSelection(model, ["K1:K9", "L1:L9"]); - insertChart(); - const payload = { - ...defaultPayload, - definition: { - type: "treemap", - title: {}, - labelRange: "L1:L9", - dataSets: [{ dataRange: "K1:K9" }], - dataSetsHaveTitle: false, - legendPosition: "none", - }, - }; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); }); - test("unique text column + multiple numeric columns with <= 12 category creates radar chart", () => { - ["spring", "summer", "autumn", "fall", "winter"].forEach((val, i) => { - setCellContent(model, `W${i + 1}`, val); - setCellContent(model, `X${i + 1}`, `${10 + i}`); - setCellContent(model, `Y${i + 1}`, `${20 + i}`); - }); - setSelection(model, ["W1:W5", "X1:X5", "Y1:Y5"]); - insertChart(); + test.each<[DatasetDescriptor, Partial]>([ + [["text", "text", "number"], { type: "treemap" }], + [["text", "text", "text", "number"], { type: "sunburst" }], + [["text", "text", "percentage"], { type: "treemap" }], + [["text", "text", "text", "percentage"], { type: "sunburst" }], + [["text", "text", "text", "number_with_header"], { type: "sunburst", dataSetsHaveTitle: true }], + ])("Multiple text columns %s create a %s hierarchical chart", (datasetPattern, expected) => { + createDatasetFromDescription(datasetPattern); + doAction(["insert", "insert_chart"], env); - const payload = { - ...defaultPayload, - definition: { - type: "radar", - title: {}, - dataSets: [{ dataRange: "X1:Y5" }], - labelRange: "W1:W5", - dataSetsHaveTitle: false, - legendPosition: "top", - }, - }; - expect(dispatchSpy).toHaveBeenCalledWith("CREATE_CHART", payload); - }); + const datasetLastCol = datasetPattern.findIndex((p) => !p.includes("text")); + const expectedDatasets: CustomizedDataSet[] = []; + for (let i = 0; i < datasetLastCol; i++) { + expectedDatasets.push({ dataRange: toXC(i, 0) + ":" + toXC(i, 5) }); + } + const expectedLabelRange = toXC(datasetLastCol, 0) + ":" + toXC(datasetLastCol, 5); - test("Chart with 2 string columns is a treemap chart (without headers)", () => { - // prettier-ignore - const grid = { - K1: "Group1", L1: "SubGroup1", M1: "40", - K2: "Group1", L2: "SubGroup2", M2: "20", - K3: "Group2", L3: "SubGroup1", M3: "10", - }; - setGrid(model, grid); - setSelection(model, ["K1"]); - insertChart(); - const chartId = model.getters.getChartIds(model.getters.getActiveSheetId()).at(-1)!; + const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0]; expect(model.getters.getChartDefinition(chartId)).toMatchObject({ - type: "treemap", - dataSets: [{ dataRange: "K1:K3" }, { dataRange: "L1:L3" }], - dataSetsHaveTitle: false, - labelRange: "M1:M3", + ...expected, + dataSets: expectedDatasets, + labelRange: expectedLabelRange, }); }); - test("Chart with 2 string columns is a treemap chart (with headers)", () => { - // prettier-ignore - const grid = { - K1: "Header1", L1: "Header2", M1: "Header3", - K2: "Group1", L2: "SubGroup1", M2: "20", - L3: "SubGroup2", M3: "10", - }; - setGrid(model, grid); - setSelection(model, ["K1"]); - insertChart(); - const chartId = model.getters.getChartIds(model.getters.getActiveSheetId()).at(-1)!; + test.each<[DatasetDescriptor, Partial]>([ + [["text", "percentage", "percentage"], { type: "pie" }], + [["number", "percentage", "percentage", "percentage"], { type: "pie" }], + [["date", "number", "number"], { type: "line" }], + // Any other combination should give a bar chart with correct datasets + [["text", "number", "percentage"], { type: "bar" }], + [["text", "number", "date"], { type: "bar" }], + [["text", "number", "number"], { type: "bar" }], + [["number", "number", "number"], { type: "bar" }], + [["date", "date", "number", "text"], { type: "bar" }], + [["text", "number_with_header", "percentage"], { type: "bar", dataSetsHaveTitle: true }], + [["text", "number", "date_with_header"], { type: "bar", dataSetsHaveTitle: true }], + ])("Multiple columns %s create a %s chart", (datasetPattern, expected) => { + createDatasetFromDescription(datasetPattern); + doAction(["insert", "insert_chart"], env); + + const expectedDatasets: CustomizedDataSet[] = []; + for (let i = 1; i < datasetPattern.length; i++) { + expectedDatasets.push({ dataRange: toXC(i, 0) + ":" + toXC(i, 5) }); + } + + const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0]; expect(model.getters.getChartDefinition(chartId)).toMatchObject({ - type: "treemap", - dataSets: [{ dataRange: "K1:K3" }, { dataRange: "L1:L3" }], - dataSetsHaveTitle: true, - labelRange: "M1:M3", + ...expected, + dataSets: expectedDatasets, + labelRange: "A1:A6", }); }); - test("Chart with > 2 string columns is a sunburst chart (with headers)", () => { - // prettier-ignore - const grid = { - K1: "Continent", L1: "Country", M1: "State", N1: "Sales", - K2: "Asia", L2: "India", M2: "Gujarat", N2: "100", - L3: "India", M3: "Maharashtra", N3: "200", - K4: "Europe", L4: "Germany", M4: "Bavaria", N4: "150", - }; - setGrid(model, grid); - setSelection(model, ["K1"]); - insertChart(); - - const chartId = model.getters.getChartIds(model.getters.getActiveSheetId()).at(-1)!; + test("Empty columns are passed in the chart dataset if the whole selection is empty", () => { + setSelection(model, ["A1:B6"]); + doAction(["insert", "insert_chart"], env); + const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0]; expect(model.getters.getChartDefinition(chartId)).toMatchObject({ - type: "sunburst", - dataSets: [{ dataRange: "K1:K4" }, { dataRange: "L1:L4" }, { dataRange: "M1:M4" }], - labelRange: "N1:N4", - dataSetsHaveTitle: true, + type: "bar", + dataSets: [{ dataRange: "A1:A6" }, { dataRange: "B1:B6" }], + dataSetsHaveTitle: false, }); }); - test("Chart can be inserted with unbounded ranges", () => { - setSelection(model, ["A1:B100"], { unbounded: true }); - insertChart(); + test("Empty columns are ignored in the chart dataset if other columns are not empty", () => { + createDatasetFromDescription(["number", "empty", "number"]); + setSelection(model, ["A1:C6"]); + doAction(["insert", "insert_chart"], env); const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0]; expect(model.getters.getChartDefinition(chartId)).toMatchObject({ - dataSets: [{ dataRange: "B:B" }], - labelRange: "A:A", + type: "scatter", + dataSets: [{ dataRange: "C1:C6" }], + labelRange: "A1:A6", }); }); + + test.each<[DatasetDescriptor, Partial]>([ + [["number"], { legendPosition: "none" }], + [["text", "number"], { legendPosition: "none" }], + [["date", "number", "number"], { legendPosition: "top" }], + [["text", "number", "number"], { legendPosition: "top" }], + ])( + "Pie charts and charts with more than one column in their dataset %s have a legend", + (datasetPattern, expected) => { + createDatasetFromDescription(datasetPattern); + doAction(["insert", "insert_chart"], env); + + const chartId = model.getters.getChartIds(model.getters.getActiveSheetId())[0]; + expect(model.getters.getChartDefinition(chartId)).toMatchObject(expected); + } + ); });