diff --git a/demo/data.js b/demo/data.js index 5517d00924..6a7d7c9635 100644 --- a/demo/data.js +++ b/demo/data.js @@ -226,7 +226,7 @@ export const demoData = { colNumber: 26, rowNumber: 174, rows: {}, - cols: {}, + cols: { 22: { size: 166 } }, merges: [], cells: { B2: "42", @@ -418,9 +418,78 @@ export const demoData = { W30: "=W29 - RANDBETWEEN(10, 40)", W31: "=W30 - RANDBETWEEN(10, 40)", W32: "=W31 - RANDBETWEEN(10, 40)", + V1: "Movie Genre", + V2: "Action", + V3: "Action", + V4: "Action", + V5: "Action", + V6: "Action", + V7: "Animation", + V8: "Animation", + V9: "Animation", + V10: "Animation", + V11: "Animation", + V12: "Drama", + V13: "Drama", + V14: "Drama", + V15: "Drama", + V16: "Drama", + V17: "Action", + V18: "Animation", + V19: "Drama", + V20: "Action", + V21: "Animation", + V22: "Misc", + V23: "Misc", + W1: "Movie Name", + W2: "Avengers: Endgame", + W3: "The Dark Knight", + W4: "Jurassic World", + W5: "Fast & Furious 7", + W6: "Spider-Man: No Way Home", + W7: "Frozen II", + W8: "Minions", + W9: "Toy Story 4", + W10: "The Lion King (2019)", + W11: "Despicable Me 3", + W12: "Titanic", + W13: "Joker", + W14: "Forrest Gump", + W15: "Bohemian Rhapsody", + W16: "The Revenant", + W17: "Black Panther", + W18: "Shrek 2", + W19: "Interstellar", + W20: "Iron Man 3", + W21: "Coco", + X1: "Revenue", + X2: "2798000000", + X3: "1006000000", + X4: "1671000000", + X5: "1516000000", + X6: "1921000000", + X7: "1450000000", + X8: "1159000000", + X9: "1073000000", + X10: "1657000000", + X11: "1035000000", + X12: "2201000000", + X13: "1074000000", + X14: "678200000", + X15: "910000000", + X16: "533000000", + X17: "1347000000", + X18: "935300000", + X19: "701800000", + X20: "1215000000", + X21: "807800000", + X22: "3215000000", + X23: "1212000000", }, styles: {}, - formats: {}, + formats: { + "X1:X23": 7, + }, borders: {}, conditionalFormats: [], dataValidationRules: [], @@ -695,6 +764,29 @@ export const demoData = { region: "world", }, }, + { + id: "11", + x: 100, + y: 1800, + width: 400, + height: 400, + tag: "chart", + data: { + type: "sunburst", + dataSetsHaveTitle: true, + dataSets: [ + { + dataRange: "V1:V23", + }, + { + dataRange: "W1:W23", + }, + ], + legendPosition: "top", + labelRange: "X1:X23", + title: { text: "Movie Revenue by Genre" }, + }, + }, { id: "12", x: 950, @@ -730,6 +822,21 @@ export const demoData = { styleId: "TableStyleMedium7", }, }, + { + range: "V1:X23", + type: "static", + config: { + hasFilters: false, + totalRow: false, + firstColumn: true, + lastColumn: false, + numberOfHeaders: 1, + bandedRows: true, + bandedColumns: false, + automaticAutofill: true, + styleId: "TableStyleMedium3", + }, + }, { range: "V27:W32", type: "static", @@ -3282,6 +3389,7 @@ export const demoData = { 4: "hh:mm:ss a", 5: "d/m/yyyy", 6: "[$$]#,##0.00", + 7: '$#,##0,,"K"', }, borders: { 1: { diff --git a/src/components/figures/chart/chartJs/chartjs_sunburst_hover_plugin.ts b/src/components/figures/chart/chartJs/chartjs_sunburst_hover_plugin.ts new file mode 100644 index 0000000000..cb8f6defec --- /dev/null +++ b/src/components/figures/chart/chartJs/chartjs_sunburst_hover_plugin.ts @@ -0,0 +1,85 @@ +import { ActiveDataPoint, ChartType, Plugin } from "chart.js"; +import { lightenColor } from "../../../../helpers"; +import { GHOST_SUNBURST_VALUE } from "../../../../helpers/figures/charts/runtime/chartjs_dataset"; +import { SunburstChartRawData } from "../../../../types/chart"; + +export interface ChartSunburstHoverPluginOptions { + enabled: boolean; +} + +declare module "chart.js" { + interface PluginOptionsByType { + sunburstHoverPlugin?: ChartSunburstHoverPluginOptions; + } +} + +/** + * When a chart element is hovered (active), this plugin also activates all of its child elements and + * lightens the color of the other elements. + */ +export const sunburstHoverPlugin: Plugin = { + id: "sunburstHoverPlugin", + afterEvent(chart, args, options: ChartSunburstHoverPluginOptions) { + if (!options.enabled) { + return; + } + const chartActiveElements = chart.getActiveElements(); + let activeDataPoints: ActiveDataPoint[] = chartActiveElements.map((el) => ({ + datasetIndex: el.datasetIndex, + index: el.index, + })); + + for (const activeEl of chartActiveElements) { + const activeDataset = chart.data.datasets[activeEl.datasetIndex]; + const activeData = activeDataset.data[activeEl.index] as unknown as SunburstChartRawData; + + for (let datasetIndex = 0; datasetIndex < chart.data.datasets.length; datasetIndex++) { + const dataset = chart.data.datasets[datasetIndex]; + for (let index = 0; index < dataset.data.length; index++) { + const data = dataset.data[index] as unknown as SunburstChartRawData; + if (isChildGroup(activeData.groups, data.groups)) { + activeDataPoints.push({ datasetIndex, index }); + } + } + } + } + + activeDataPoints = activeDataPoints.filter((point) => { + const { datasetIndex, index } = point; + const data = chart.data.datasets[datasetIndex].data[index] as unknown as SunburstChartRawData; + return data.label !== GHOST_SUNBURST_VALUE; + }); + + chart.setActiveElements(activeDataPoints); + + for (const metaSet of chart.getSortedVisibleDatasetMetas()) { + for (const arcElement of metaSet.data) { + const context = arcElement["$context"]; + const { datasetIndex, index, dataset, raw } = context; + if (raw.label === GHOST_SUNBURST_VALUE) { + continue; + } + + const originalBackgroundColor = + typeof dataset.backgroundColor === "function" + ? dataset.backgroundColor(context) + : dataset.backgroundColor; + if ( + activeDataPoints.length && + !activeDataPoints.some((el) => el.datasetIndex === datasetIndex && el.index === index) + ) { + arcElement.options.backgroundColor = lightenColor(originalBackgroundColor, 0.5); + } else { + arcElement.options.backgroundColor = originalBackgroundColor; + } + } + } + }, +}; + +function isChildGroup(parentGroup: string[], childGroup: string[]) { + return ( + childGroup.length > parentGroup.length && + parentGroup.every((group, i) => group === childGroup[i]) + ); +} diff --git a/src/components/figures/chart/chartJs/chartjs_sunburst_labels_plugin.ts b/src/components/figures/chart/chartJs/chartjs_sunburst_labels_plugin.ts new file mode 100644 index 0000000000..59c76eb89a --- /dev/null +++ b/src/components/figures/chart/chartJs/chartjs_sunburst_labels_plugin.ts @@ -0,0 +1,116 @@ +import { ChartType, Plugin } from "chart.js"; +import { + getDefaultContextFont, + isDefined, + relativeLuminance, + sliceTextToFitWidth, +} from "../../../../helpers"; +import { GHOST_SUNBURST_VALUE } from "../../../../helpers/figures/charts/runtime/chartjs_dataset"; +import { Style } from "../../../../types"; +import { SunburstChartRawData } from "../../../../types/chart"; + +export interface ChartSunburstLabelsPluginOptions { + showValues: boolean; + showLabels: boolean; + style: Style; + callback: (value: number, axisId?: string) => string; +} + +declare module "chart.js" { + interface PluginOptionsByType { + sunburstLabelsPlugin?: ChartSunburstLabelsPluginOptions; + } +} + +const Y_PADDING = 3; + +export const sunburstLabelsPlugin: Plugin = { + id: "sunburstLabelsPlugin", + afterDatasetsDraw(chart: any, args, options: ChartSunburstLabelsPluginOptions) { + if ((!options.showValues && !options.showLabels) || chart.config.type !== "doughnut") { + return; + } + const ctx = chart.ctx as CanvasRenderingContext2D; + drawSunburstChartValues(chart, options, ctx); + }, +}; + +function drawSunburstChartValues( + chart: any, + options: ChartSunburstLabelsPluginOptions, + ctx: CanvasRenderingContext2D +) { + const style = options.style; + const fontSize = style.fontSize || 13; + const lineHeight = fontSize + Y_PADDING; + + for (const dataset of chart._metasets) { + for (let i = 0; i < dataset._dataset.data.length; i++) { + const rawData = dataset._dataset.data[i] as SunburstChartRawData; + if (rawData.label === GHOST_SUNBURST_VALUE) { + continue; + } + const valuesToDisplay = [ + options.showLabels ? rawData.label : undefined, + options.showValues ? options.callback(rawData.value, "y") : undefined, + ].filter(isDefined); + + const arc = dataset.data[i]; + let { startAngle, endAngle, innerRadius, outerRadius, circumference } = arc; + // Same computations as in ChartJs ArcElement's draw method. Don't ask me why they divide by 4. + const offset = arc.options.offset / 4; + const fix = 1 - Math.sin(Math.min(Math.PI, circumference || 0)); + const radiusOffset = offset * fix; + innerRadius += radiusOffset; + outerRadius += radiusOffset; + + const midAngle = (startAngle + endAngle) / 2; + const midRadius = (innerRadius + outerRadius) / 2; + + const availableWidth = (outerRadius - innerRadius) * 0.9; + const angle = endAngle - startAngle; + const availableHeight = + angle >= Math.PI ? outerRadius : Math.sin(angle / 2) * innerRadius * 2; + if (availableHeight < valuesToDisplay.length * lineHeight) { + continue; + } + + ctx.save(); + + const centerOffset = { x: Math.cos(midAngle) * offset, y: Math.sin(midAngle) * offset }; + const centerX = chart.chartArea.left + chart.chartArea.width / 2 + centerOffset.x; + const centerY = chart.chartArea.top + chart.chartArea.height / 2 + centerOffset.y; + ctx.translate(centerX, centerY); + + let x: number; + if (midAngle > Math.PI / 2) { + ctx.rotate(midAngle - Math.PI); + x = -midRadius; + } else { + x = midRadius; + ctx.rotate(midAngle); + } + + const backgroundColor = arc.options.backgroundColor; + const defaultColor = relativeLuminance(backgroundColor) > 0.7 ? "#666666" : "#FFFFFF"; + ctx.fillStyle = style.textColor || defaultColor; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.font = getDefaultContextFont(fontSize, style.bold, style.italic); + + const y = -((valuesToDisplay.length - 1) * lineHeight) / 2; + for (let j = 0; j < valuesToDisplay.length; j++) { + const fittedText = sliceTextToFitWidth( + ctx, + availableWidth, + valuesToDisplay[j], + style, + "px" + ); + ctx.fillText(fittedText, x, y + j * lineHeight); + } + + ctx.restore(); + } + } +} diff --git a/src/components/side_panel/chart/building_blocks/axis_design/axis_design_editor.ts b/src/components/side_panel/chart/building_blocks/axis_design/axis_design_editor.ts index 5f6bd71440..d70662c201 100644 --- a/src/components/side_panel/chart/building_blocks/axis_design/axis_design_editor.ts +++ b/src/components/side_panel/chart/building_blocks/axis_design/axis_design_editor.ts @@ -1,17 +1,16 @@ import { Component, useState } from "@odoo/owl"; import { CHART_AXIS_TITLE_FONT_SIZE } from "../../../../../constants"; +import { deepCopy } from "../../../../../helpers"; import { - ChartWithDataSetDefinition, - Color, + ChartWithAxisDefinition, DispatchResult, SpreadsheetChildEnv, TitleDesign, UID, } from "../../../../../types"; -import { WaterfallChartDefinition } from "../../../../../types/chart/waterfall_chart"; import { BadgeSelection } from "../../../components/badge_selection/badge_selection"; import { Section } from "../../../components/section/section"; -import { ChartTitle } from "../title/title"; +import { ChartTitle } from "../chart_title/chart_title"; export interface AxisDefinition { id: string; @@ -20,11 +19,8 @@ export interface AxisDefinition { interface Props { figureId: UID; - definition: ChartWithDataSetDefinition | WaterfallChartDefinition; - updateChart: ( - figureId: UID, - definition: Partial - ) => DispatchResult; + definition: ChartWithAxisDefinition; + updateChart: (figureId: UID, definition: Partial) => DispatchResult; axesList: AxisDefinition[]; } @@ -35,83 +31,16 @@ export class AxisDesignEditor extends Component { state = useState({ currentAxis: "x" }); + defaultFontSize = CHART_AXIS_TITLE_FONT_SIZE; + get axisTitleStyle(): TitleDesign { - const axisDesign = this.props.definition.axesDesign?.[this.state.currentAxis] ?? {}; - return { - color: "", - align: "center", - fontSize: CHART_AXIS_TITLE_FONT_SIZE, - ...axisDesign.title, - }; + return this.props.definition.axesDesign?.[this.state.currentAxis]?.title ?? {}; } get badgeAxes() { return this.props.axesList.map((axis) => ({ value: axis.id, label: axis.name })); } - updateAxisTitleColor(color: Color) { - const axesDesign = this.props.definition.axesDesign ?? {}; - axesDesign[this.state.currentAxis] = { - ...axesDesign[this.state.currentAxis], - title: { - ...(axesDesign[this.state.currentAxis]?.title ?? {}), - color, - }, - }; - this.props.updateChart(this.props.figureId, { axesDesign }); - } - - updateAxisTitleFontSize(fontSize: number) { - const axesDesign = this.props.definition.axesDesign ?? {}; - axesDesign[this.state.currentAxis] = { - ...axesDesign[this.state.currentAxis], - title: { - ...(axesDesign[this.state.currentAxis]?.title ?? {}), - fontSize, - }, - }; - this.props.updateChart(this.props.figureId, { axesDesign }); - } - - toggleBoldAxisTitle() { - const axesDesign = this.props.definition.axesDesign ?? {}; - const title = axesDesign[this.state.currentAxis]?.title ?? {}; - axesDesign[this.state.currentAxis] = { - ...axesDesign[this.state.currentAxis], - title: { - ...title, - bold: !title?.bold, - }, - }; - this.props.updateChart(this.props.figureId, { axesDesign }); - } - - toggleItalicAxisTitle() { - const axesDesign = this.props.definition.axesDesign ?? {}; - const title = axesDesign[this.state.currentAxis]?.title ?? {}; - axesDesign[this.state.currentAxis] = { - ...axesDesign[this.state.currentAxis], - title: { - ...title, - italic: !title?.italic, - }, - }; - this.props.updateChart(this.props.figureId, { axesDesign }); - } - - updateAxisTitleAlignment(align: "left" | "center" | "right") { - const axesDesign = this.props.definition.axesDesign ?? {}; - const title = axesDesign[this.state.currentAxis]?.title ?? {}; - axesDesign[this.state.currentAxis] = { - ...axesDesign[this.state.currentAxis], - title: { - ...title, - align, - }, - }; - this.props.updateChart(this.props.figureId, { axesDesign }); - } - updateAxisEditor(ev) { this.state.currentAxis = ev.target.value; } @@ -122,7 +51,7 @@ export class AxisDesignEditor extends Component { } updateAxisTitle(text: string) { - const axesDesign = this.props.definition.axesDesign ?? {}; + const axesDesign = deepCopy(this.props.definition.axesDesign) ?? {}; axesDesign[this.state.currentAxis] = { ...axesDesign[this.state.currentAxis], title: { @@ -132,4 +61,13 @@ export class AxisDesignEditor extends Component { }; this.props.updateChart(this.props.figureId, { axesDesign }); } + + updateAxisTitleStyle(style: TitleDesign) { + const axesDesign = deepCopy(this.props.definition.axesDesign) ?? {}; + axesDesign[this.state.currentAxis] = { + ...axesDesign[this.state.currentAxis], + title: style, + }; + this.props.updateChart(this.props.figureId, { axesDesign }); + } } diff --git a/src/components/side_panel/chart/building_blocks/axis_design/axis_design_editor.xml b/src/components/side_panel/chart/building_blocks/axis_design/axis_design_editor.xml index 472c121617..6692bdf993 100644 --- a/src/components/side_panel/chart/building_blocks/axis_design/axis_design_editor.xml +++ b/src/components/side_panel/chart/building_blocks/axis_design/axis_design_editor.xml @@ -11,13 +11,10 @@ diff --git a/src/components/side_panel/chart/building_blocks/chart_title/chart_title.ts b/src/components/side_panel/chart/building_blocks/chart_title/chart_title.ts new file mode 100644 index 0000000000..5e6a36bb27 --- /dev/null +++ b/src/components/side_panel/chart/building_blocks/chart_title/chart_title.ts @@ -0,0 +1,42 @@ +import { Component } from "@odoo/owl"; +import { SpreadsheetChildEnv, TitleDesign } from "../../../../../types"; +import { css } from "../../../../helpers"; +import { Section } from "../../../components/section/section"; +import { TextStyler } from "../text_styler/text_styler"; + +css/* scss */ ` + .o-chart-title-designer { + > span { + height: 30px; + } + } +`; + +interface Props { + title?: string; + updateTitle: (title: string) => void; + name?: string; + style: TitleDesign; + defaultStyle?: Partial; + updateStyle: (style: TitleDesign) => void; +} + +export class ChartTitle extends Component { + static template = "o-spreadsheet.ChartTitle"; + static components = { Section, TextStyler }; + static props = { + title: { type: String, optional: true }, + updateTitle: Function, + name: { type: String, optional: true }, + style: Object, + defaultStyle: { type: Object, optional: true }, + updateStyle: Function, + }; + static defaultProps = { + title: "", + }; + + updateTitle(ev: InputEvent) { + this.props.updateTitle((ev.target as HTMLInputElement).value); + } +} diff --git a/src/components/side_panel/chart/building_blocks/chart_title/chart_title.xml b/src/components/side_panel/chart/building_blocks/chart_title/chart_title.xml new file mode 100644 index 0000000000..49db3daf00 --- /dev/null +++ b/src/components/side_panel/chart/building_blocks/chart_title/chart_title.xml @@ -0,0 +1,24 @@ + + + Add a title + + + Title + +
+ + +
+
+
diff --git a/src/components/side_panel/chart/building_blocks/data_series/data_series.ts b/src/components/side_panel/chart/building_blocks/data_series/data_series.ts index 7016698584..335efbd306 100644 --- a/src/components/side_panel/chart/building_blocks/data_series/data_series.ts +++ b/src/components/side_panel/chart/building_blocks/data_series/data_series.ts @@ -11,6 +11,7 @@ interface Props { onSelectionReordered?: (indexes: number[]) => void; onSelectionRemoved?: (index: number) => void; onSelectionConfirmed: () => void; + title?: string; } export class ChartDataSeries extends Component { @@ -23,6 +24,7 @@ export class ChartDataSeries extends Component { onSelectionReordered: { type: Function, optional: true }, onSelectionRemoved: { type: Function, optional: true }, onSelectionConfirmed: Function, + title: { type: String, optional: true }, }; get ranges(): string[] { @@ -34,6 +36,9 @@ export class ChartDataSeries extends Component { } get title() { + if (this.props.title) { + return this.props.title; + } return this.props.hasSingleRange ? _t("Data range") : _t("Data series"); } } diff --git a/src/components/side_panel/chart/building_blocks/general_design/general_design_editor.ts b/src/components/side_panel/chart/building_blocks/general_design/general_design_editor.ts index 9ab705a240..2a98a6cd01 100644 --- a/src/components/side_panel/chart/building_blocks/general_design/general_design_editor.ts +++ b/src/components/side_panel/chart/building_blocks/general_design/general_design_editor.ts @@ -11,7 +11,7 @@ import { import { SidePanelCollapsible } from "../../../components/collapsible/side_panel_collapsible"; import { RoundColorPicker } from "../../../components/round_color_picker/round_color_picker"; import { Section } from "../../../components/section/section"; -import { ChartTitle } from "../title/title"; +import { ChartTitle } from "../chart_title/chart_title"; interface GeneralDesignEditorState { activeTool: string; @@ -70,39 +70,8 @@ export class GeneralDesignEditor extends Component { this.props.updateChart(this.props.figureId, { title }); } - get titleStyle(): TitleDesign { - return { - align: "left", - fontSize: this.props.defaultChartTitleFontSize, - ...this.title, - }; - } - - updateChartTitleColor(color: Color) { - const title = { ...this.title, color }; - this.props.updateChart(this.props.figureId, { title }); - this.state.activeTool = ""; - } - - updateChartTitleFontSize(fontSize: number) { - const title = { ...this.title, fontSize }; - this.props.updateChart(this.props.figureId, { title }); - } - - toggleBoldChartTitle() { - let title = this.title; - title = { ...title, bold: !title.bold }; - this.props.updateChart(this.props.figureId, { title }); - } - - toggleItalicChartTitle() { - let title = this.title; - title = { ...title, italic: !title.italic }; - this.props.updateChart(this.props.figureId, { title }); - } - - updateChartTitleAlignment(align: "left" | "center" | "right") { - const title = { ...this.title, align }; + updateChartTitleStyle(style: TitleDesign) { + const title = { ...this.title, ...style }; this.props.updateChart(this.props.figureId, { title }); this.state.activeTool = ""; } diff --git a/src/components/side_panel/chart/building_blocks/general_design/general_design_editor.xml b/src/components/side_panel/chart/building_blocks/general_design/general_design_editor.xml index 0ba7ac1a63..79a11ea2c1 100644 --- a/src/components/side_panel/chart/building_blocks/general_design/general_design_editor.xml +++ b/src/components/side_panel/chart/building_blocks/general_design/general_design_editor.xml @@ -13,12 +13,9 @@ title="title.text" updateTitle.bind="updateTitle" name="chart_title" - toggleItalic.bind="toggleItalicChartTitle" - toggleBold.bind="toggleBoldChartTitle" - updateAlignment.bind="updateChartTitleAlignment" - updateColor.bind="updateChartTitleColor" - style="titleStyle" - onFontSizeChanged.bind="updateChartTitleFontSize" + updateStyle.bind="updateChartTitleStyle" + style="title" + defaultStyle="{align: 'left', fontSize: this.props.defaultChartTitleFontSize}" /> diff --git a/src/components/side_panel/chart/building_blocks/generic_side_panel/config_panel.ts b/src/components/side_panel/chart/building_blocks/generic_side_panel/config_panel.ts index 8d4f4c4294..0ba1f5acc3 100644 --- a/src/components/side_panel/chart/building_blocks/generic_side_panel/config_panel.ts +++ b/src/components/side_panel/chart/building_blocks/generic_side_panel/config_panel.ts @@ -87,17 +87,18 @@ export class GenericChartConfigPanel extends Component span { + padding: 4px; + } + } + } + } +`; + +interface Props { + class?: string; + style: ChartStyle; + updateStyle: (style: ChartStyle) => void; + defaultStyle?: Partial; + hasVerticalAlign?: boolean; + hasHorizontalAlign?: boolean; + hasBackgroundColor?: boolean; +} + +export interface TextStylerState { + activeTool: string; +} + +export class TextStyler extends Component { + static template = "o-spreadsheet.TextStyler"; + static components = { ColorPickerWidget, ActionButton, FontSizeEditor }; + static props = { + style: Object, + updateStyle: { type: Function, optional: true }, + defaultStyle: { type: Object, optional: true }, + hasVerticalAlign: { type: Boolean, optional: true }, + hasHorizontalAlign: { type: Boolean, optional: true }, + hasBackgroundColor: { type: Boolean, optional: true }, + class: { type: String, optional: true }, + }; + openedEl: HTMLElement | null = null; + + setup() { + useExternalListener(window, "click", this.onExternalClick); + } + + state = useState({ + activeTool: "", + }); + + updateFontSize(fontSize: number) { + this.props.updateStyle?.({ ...this.props.style, fontSize }); + } + + toggleDropdownTool(tool: string, ev: MouseEvent) { + const isOpen = this.state.activeTool === tool; + this.closeMenus(); + this.state.activeTool = isOpen ? "" : tool; + this.openedEl = isOpen ? null : (ev.target as HTMLElement); + } + + /** + * TODO: This is clearly not a goot way to handle external click, but + * we currently have no other way to do it ... Should be done in + * another task to handle the fact we want only one menu opened at a + * time with something like a menuStore ? + */ + onExternalClick(ev: MouseEvent) { + if (this.openedEl === ev.target) { + return; + } + this.closeMenus(); + } + + onTextColorChange(color: Color) { + this.props.updateStyle?.({ ...this.props.style, color }); + this.closeMenus(); + } + + onFillColorChange(color: Color) { + this.props.updateStyle?.({ ...this.props.style, fillColor: color }); + this.closeMenus(); + } + + updateAlignment(align: Align) { + this.props.updateStyle?.({ ...this.props.style, align }); + this.closeMenus(); + } + + updateVerticalAlignment(verticalAlign: VerticalAlign) { + this.props.updateStyle?.({ ...this.props.style, verticalAlign }); + this.closeMenus(); + } + + toggleBold() { + this.props.updateStyle?.({ ...this.props.style, bold: !this.bold }); + } + + toggleItalic() { + this.props.updateStyle?.({ ...this.props.style, italic: !this.italic }); + } + + closeMenus() { + this.state.activeTool = ""; + this.openedEl = null; + } + + get align() { + return this.props.style.align ?? this.props.defaultStyle?.align; + } + + get verticalAlign() { + return this.props.style.verticalAlign || this.props.defaultStyle?.verticalAlign; + } + + get bold() { + return this.props.style.bold ?? this.props.defaultStyle?.bold; + } + + get italic() { + return this.props.style.italic ?? this.props.defaultStyle?.italic; + } + + get currentFontSize() { + return this.props.style.fontSize ?? this.props.defaultStyle?.fontSize ?? DEFAULT_STYLE.fontSize; + } + + get boldButtonAction(): ActionSpec { + return { + name: _t("Bold"), + execute: () => this.toggleBold(), + isActive: () => this.bold || false, + icon: "o-spreadsheet-Icon.BOLD", + }; + } + + get italicButtonAction(): ActionSpec { + return { + name: _t("Italic"), + execute: () => this.toggleItalic(), + isActive: () => this.italic || false, + icon: "o-spreadsheet-Icon.ITALIC", + }; + } + + get horizontalAlignButtonAction(): ActionSpec { + let icon = "o-spreadsheet-Icon.ALIGN_LEFT"; + if (this.align === "center") { + icon = "o-spreadsheet-Icon.ALIGN_CENTER"; + } else if (this.align === "right") { + icon = "o-spreadsheet-Icon.ALIGN_RIGHT"; + } + return { name: _t("Horizontal alignment"), icon }; + } + + get horizontalAlignActions(): ActionSpec[] { + return [ + { + name: _t("Left"), + execute: () => this.updateAlignment("left"), + isActive: () => this.align === "left", + icon: "o-spreadsheet-Icon.ALIGN_LEFT", + }, + { + name: _t("Center"), + execute: () => this.updateAlignment("center"), + isActive: () => this.align === "center", + icon: "o-spreadsheet-Icon.ALIGN_CENTER", + }, + { + name: _t("Right"), + execute: () => this.updateAlignment("right"), + isActive: () => this.align === "right", + icon: "o-spreadsheet-Icon.ALIGN_RIGHT", + }, + ]; + } + + get verticalAlignButtonAction(): ActionSpec { + let icon = "o-spreadsheet-Icon.ALIGN_MIDDLE"; + if (this.verticalAlign === "top") { + icon = "o-spreadsheet-Icon.ALIGN_TOP"; + } else if (this.verticalAlign === "bottom") { + icon = "o-spreadsheet-Icon.ALIGN_BOTTOM"; + } + return { name: _t("Vertical alignment"), icon }; + } + + get verticalAlignActions(): ActionSpec[] { + return [ + { + name: _t("Top"), + execute: () => this.updateVerticalAlignment("top"), + isActive: () => this.verticalAlign === "top", + icon: "o-spreadsheet-Icon.ALIGN_TOP", + }, + { + name: _t("Middle"), + execute: () => this.updateVerticalAlignment("middle"), + isActive: () => this.verticalAlign === "middle", + icon: "o-spreadsheet-Icon.ALIGN_MIDDLE", + }, + { + name: _t("Bottom"), + execute: () => this.updateVerticalAlignment("bottom"), + isActive: () => this.verticalAlign === "bottom", + icon: "o-spreadsheet-Icon.ALIGN_BOTTOM", + }, + ]; + } +} diff --git a/src/components/side_panel/chart/building_blocks/text_styler/text_styler.xml b/src/components/side_panel/chart/building_blocks/text_styler/text_styler.xml new file mode 100644 index 0000000000..64501a5731 --- /dev/null +++ b/src/components/side_panel/chart/building_blocks/text_styler/text_styler.xml @@ -0,0 +1,75 @@ + + +
+ + +
+
+ +
+
+ + + +
+
+
+
+ +
+
+ + + +
+
+
+
+ +
+ + +
+ + diff --git a/src/components/side_panel/chart/building_blocks/title/title.ts b/src/components/side_panel/chart/building_blocks/title/title.ts deleted file mode 100644 index a8fec935f2..0000000000 --- a/src/components/side_panel/chart/building_blocks/title/title.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Component, useExternalListener, useState } from "@odoo/owl"; -import { GRAY_300 } from "../../../../../constants"; -import { Color, SpreadsheetChildEnv, TitleDesign } from "../../../../../types"; -import { ColorPickerWidget } from "../../../../color_picker/color_picker_widget"; -import { FontSizeEditor } from "../../../../font_size_editor/font_size_editor"; -import { css } from "../../../../helpers"; -import { Section } from "../../../components/section/section"; - -css/* scss */ ` - .o-chart-title-designer { - > span { - height: 30px; - } - - .o-divider { - border-right: 1px solid ${GRAY_300}; - margin: 0px 4px; - height: 14px; - } - - .o-menu-item-button.active { - background-color: #e6f4ea; - color: #188038; - } - - .o-dropdown-content { - overflow-y: auto; - overflow-x: hidden; - padding: 2px; - z-index: 100; - box-shadow: 1px 2px 5px 2px rgba(51, 51, 51, 0.15); - - .o-dropdown-line { - > span { - padding: 4px; - } - } - } - } -`; - -interface Props { - title?: string; - updateTitle: (title: string) => void; - name?: string; - toggleItalic?: () => void; - toggleBold?: () => void; - updateAlignment?: (string) => void; - updateColor?: (Color) => void; - style: TitleDesign; - onFontSizeChanged: (fontSize: number) => void; -} - -export interface ChartTitleState { - activeTool: string; -} - -export class ChartTitle extends Component { - static template = "o-spreadsheet.ChartTitle"; - static components = { Section, ColorPickerWidget, FontSizeEditor }; - static props = { - title: { type: String, optional: true }, - updateTitle: Function, - name: { type: String, optional: true }, - toggleItalic: { type: Function, optional: true }, - toggleBold: { type: Function, optional: true }, - updateAlignment: { type: Function, optional: true }, - updateColor: { type: Function, optional: true }, - style: Object, - onFontSizeChanged: Function, - }; - static defaultProps = { - title: "", - }; - openedEl: HTMLElement | null = null; - - setup() { - useExternalListener(window, "click", this.onExternalClick); - } - - state = useState({ - activeTool: "", - }); - - updateTitle(ev: InputEvent) { - this.props.updateTitle((ev.target as HTMLInputElement).value); - } - - updateFontSize(fontSize: number) { - this.props.onFontSizeChanged(fontSize); - } - - toggleDropdownTool(tool: string, ev: MouseEvent) { - const isOpen = this.state.activeTool === tool; - this.closeMenus(); - this.state.activeTool = isOpen ? "" : tool; - this.openedEl = isOpen ? null : (ev.target as HTMLElement); - } - - /** - * TODO: This is clearly not a goot way to handle external click, but - * we currently have no other way to do it ... Should be done in - * another task to handle the fact we want only one menu opened at a - * time with something like a menuStore ? - */ - onExternalClick(ev: MouseEvent) { - if (this.openedEl === ev.target) { - return; - } - this.closeMenus(); - } - - onColorPicked(color: Color) { - this.props.updateColor?.(color); - this.closeMenus(); - } - - updateAlignment(aligment: "left" | "center" | "right") { - this.props.updateAlignment?.(aligment); - this.closeMenus(); - } - - closeMenus() { - this.state.activeTool = ""; - this.openedEl = null; - } -} diff --git a/src/components/side_panel/chart/building_blocks/title/title.xml b/src/components/side_panel/chart/building_blocks/title/title.xml deleted file mode 100644 index 9e6b8f6b24..0000000000 --- a/src/components/side_panel/chart/building_blocks/title/title.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - Add a title - - - Title - -
- -
- - - - - - - - - - -
- - - - - - - - -
-
- - - - - - - - - - - - - - - -
-
-
- -
- -
-
-
-
diff --git a/src/components/side_panel/chart/chart_type_picker/chart_previews.xml b/src/components/side_panel/chart/chart_type_picker/chart_previews.xml index 1dab98dda0..d56389d63a 100644 --- a/src/components/side_panel/chart/chart_type_picker/chart_previews.xml +++ b/src/components/side_panel/chart/chart_type_picker/chart_previews.xml @@ -269,4 +269,18 @@ + + + + + + diff --git a/src/components/side_panel/chart/index.ts b/src/components/side_panel/chart/index.ts index 7166671e8a..2b02b06a55 100644 --- a/src/components/side_panel/chart/index.ts +++ b/src/components/side_panel/chart/index.ts @@ -15,6 +15,8 @@ import { RadarChartDesignPanel } from "./radar_chart/radar_chart_design_panel"; import { ScatterConfigPanel } from "./scatter_chart/scatter_chart_config_panel"; import { ScorecardChartConfigPanel } from "./scorecard_chart_panel/scorecard_chart_config_panel"; import { ScorecardChartDesignPanel } from "./scorecard_chart_panel/scorecard_chart_design_panel"; +import { SunburstChartConfigPanel } from "./sunburst_chart/sunburst_chart_config_panel"; +import { SunburstChartDesignPanel } from "./sunburst_chart/sunburst_chart_design_panel"; import { WaterfallChartDesignPanel } from "./waterfall_chart/waterfall_chart_design_panel"; export { BarConfigPanel } from "./bar_chart/bar_chart_config_panel"; @@ -74,6 +76,10 @@ chartSidePanelComponentRegistry configuration: GenericChartConfigPanel, design: RadarChartDesignPanel, }) + .add("sunburst", { + configuration: SunburstChartConfigPanel, + design: SunburstChartDesignPanel, + }) .add("geo", { configuration: GeoChartConfigPanel, design: GeoChartDesignPanel, diff --git a/src/components/side_panel/chart/sunburst_chart/sunburst_chart_config_panel.ts b/src/components/side_panel/chart/sunburst_chart/sunburst_chart_config_panel.ts new file mode 100644 index 0000000000..630f89ce06 --- /dev/null +++ b/src/components/side_panel/chart/sunburst_chart/sunburst_chart_config_panel.ts @@ -0,0 +1,17 @@ +import { GenericChartConfigPanel } from "../building_blocks/generic_side_panel/config_panel"; + +export class SunburstChartConfigPanel extends GenericChartConfigPanel { + static template = "o-spreadsheet-SunburstChartConfigPanel"; + static components = { ...GenericChartConfigPanel.components }; + + getLabelRangeOptions() { + return [ + { + name: "dataSetsHaveTitle", + label: this.dataSetsHaveTitleLabel, + value: this.props.definition.dataSetsHaveTitle, + onChange: this.onUpdateDataSetsHaveTitle.bind(this), + }, + ]; + } +} diff --git a/src/components/side_panel/chart/sunburst_chart/sunburst_chart_config_panel.xml b/src/components/side_panel/chart/sunburst_chart/sunburst_chart_config_panel.xml new file mode 100644 index 0000000000..eba1c2538f --- /dev/null +++ b/src/components/side_panel/chart/sunburst_chart/sunburst_chart_config_panel.xml @@ -0,0 +1,24 @@ + + +
+ + + + +
+
+
diff --git a/src/components/side_panel/chart/sunburst_chart/sunburst_chart_design_panel.ts b/src/components/side_panel/chart/sunburst_chart/sunburst_chart_design_panel.ts new file mode 100644 index 0000000000..b5bb1e0587 --- /dev/null +++ b/src/components/side_panel/chart/sunburst_chart/sunburst_chart_design_panel.ts @@ -0,0 +1,65 @@ +import { Component } from "@odoo/owl"; +import { deepCopy } from "../../../../helpers"; +import { + SunburstChartDefaults, + SunburstChartDefinition, + SunburstChartJSDataset, + SunburstChartRuntime, +} from "../../../../types/chart"; +import { DispatchResult, SpreadsheetChildEnv, UID } from "../../../../types/index"; +import { Checkbox } from "../../components/checkbox/checkbox"; +import { SidePanelCollapsible } from "../../components/collapsible/side_panel_collapsible"; +import { RoundColorPicker } from "../../components/round_color_picker/round_color_picker"; +import { Section } from "../../components/section/section"; +import { GeneralDesignEditor } from "../building_blocks/general_design/general_design_editor"; +import { ChartLegend } from "../building_blocks/legend/legend"; +import { TextStyler } from "../building_blocks/text_styler/text_styler"; + +interface Props { + figureId: UID; + definition: SunburstChartDefinition; + canUpdateChart: (figureID: UID, definition: Partial) => DispatchResult; + updateChart: (figureId: UID, definition: Partial) => DispatchResult; +} + +export class SunburstChartDesignPanel extends Component { + static template = "o-spreadsheet-SunburstChartDesignPanel"; + static components = { + GeneralDesignEditor, + Section, + SidePanelCollapsible, + Checkbox, + TextStyler, + RoundColorPicker, + ChartLegend, + }; + static props = { + figureId: String, + definition: Object, + updateChart: Function, + canUpdateChart: { type: Function, optional: true }, + }; + + defaults = SunburstChartDefaults; + + get showValues() { + return this.props.definition.showValues ?? SunburstChartDefaults.showValues; + } + + get showLabels() { + return this.props.definition.showLabels ?? SunburstChartDefaults.showLabels; + } + + get groupColors() { + const figureId = this.props.figureId; + const runtime = this.env.model.getters.getChartRuntime(figureId) as SunburstChartRuntime; + const dataset = runtime.chartJsConfig.data.datasets[0] as SunburstChartJSDataset; + return dataset?.groupColors || []; + } + + onGroupColorChanged(index: number, color: string) { + const colors = deepCopy(this.props.definition.groupColors) ?? []; + colors[index] = color; + this.props.updateChart(this.props.figureId, { groupColors: colors }); + } +} diff --git a/src/components/side_panel/chart/sunburst_chart/sunburst_chart_design_panel.xml b/src/components/side_panel/chart/sunburst_chart/sunburst_chart_design_panel.xml new file mode 100644 index 0000000000..3d16cfb851 --- /dev/null +++ b/src/components/side_panel/chart/sunburst_chart/sunburst_chart_design_panel.xml @@ -0,0 +1,59 @@ + + + + + + + + + + +
+ +
+ + + + + +
+
+
+
+
+ + +
+
+
+ +
+
+
+
+
diff --git a/src/constants.ts b/src/constants.ts index fc512b8c29..859b5a05f2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -27,6 +27,7 @@ export const GRID_BORDER_COLOR = "#E2E3E3"; export const FROZEN_PANE_HEADER_BORDER_COLOR = "#BCBCBC"; export const FROZEN_PANE_BORDER_COLOR = "#DADFE8"; export const COMPOSER_ASSISTANT_COLOR = "#9B359B"; +export const COLOR_TRANSPARENT = "#00000000"; export const CHART_WATERFALL_POSITIVE_COLOR = "#4EA7F2"; export const CHART_WATERFALL_NEGATIVE_COLOR = "#EA6175"; diff --git a/src/helpers/color.ts b/src/helpers/color.ts index 1b02b886ba..59e51675e7 100644 --- a/src/helpers/color.ts +++ b/src/helpers/color.ts @@ -542,7 +542,7 @@ export class ColorGenerator { private currentColorIndex = 0; protected palette: Color[]; - constructor(paletteSize: number, private preferredColors: (string | undefined)[] = []) { + constructor(paletteSize: number, private preferredColors: (Color | undefined | null)[] = []) { this.palette = getColorsPalette(paletteSize).filter((c) => !preferredColors.includes(c)); } diff --git a/src/helpers/figures/charts/chart_ui_common.ts b/src/helpers/figures/charts/chart_ui_common.ts index 6c3fd4b712..99c9ffceb9 100644 --- a/src/helpers/figures/charts/chart_ui_common.ts +++ b/src/helpers/figures/charts/chart_ui_common.ts @@ -5,6 +5,8 @@ import { getFunnelChartElement, } from "../../../components/figures/chart/chartJs/chartjs_funnel_chart"; import { chartShowValuesPlugin } from "../../../components/figures/chart/chartJs/chartjs_show_values_plugin"; +import { sunburstHoverPlugin } from "../../../components/figures/chart/chartJs/chartjs_sunburst_hover_plugin"; +import { sunburstLabelsPlugin } from "../../../components/figures/chart/chartJs/chartjs_sunburst_labels_plugin"; import { waterfallLinesPlugin } from "../../../components/figures/chart/chartJs/chartjs_waterfall_plugin"; import { Figure } from "../../../types"; import { ChartType, GaugeChartRuntime, ScorecardChartRuntime } from "../../../types/chart"; @@ -123,7 +125,9 @@ export function getChartJSConstructor() { chartShowValuesPlugin, waterfallLinesPlugin, getFunnelChartController(), - getFunnelChartElement() + getFunnelChartElement(), + sunburstLabelsPlugin, + sunburstHoverPlugin ); window.Chart.Tooltip.positioners.funnelTooltipPositioner = funnelTooltipPositioner; } diff --git a/src/helpers/figures/charts/geo_chart.ts b/src/helpers/figures/charts/geo_chart.ts index 157221f416..422edf7896 100644 --- a/src/helpers/figures/charts/geo_chart.ts +++ b/src/helpers/figures/charts/geo_chart.ts @@ -99,7 +99,6 @@ export class GeoChart extends AbstractChart { title: context.title || { text: "" }, type: "geo", labelRange: context.auxiliaryRange || undefined, - aggregated: context.aggregated, }; } diff --git a/src/helpers/figures/charts/runtime/chart_data_extractor.ts b/src/helpers/figures/charts/runtime/chart_data_extractor.ts index 11c4c28ef2..3b4ad888c3 100644 --- a/src/helpers/figures/charts/runtime/chart_data_extractor.ts +++ b/src/helpers/figures/charts/runtime/chart_data_extractor.ts @@ -28,6 +28,7 @@ import { LineChartDefinition, PieChartDefinition, PyramidChartDefinition, + SunburstChartDefinition, TrendConfiguration, } from "../../../../types/chart"; import { @@ -275,6 +276,34 @@ export function getGeoChartData( }; } +export function getSunburstChartData( + definition: SunburstChartDefinition, + dataSets: DataSet[], + labelRange: Range | undefined, + getters: Getters +): ChartRuntimeGenerationArgs { + // In Sunburst, labels are the leaf values (numbers), and the hierarchy is defined in the dataSets (strings) + let labels = getChartLabelValues(getters, dataSets, labelRange).values; + let dataSetsValues = getHierarchicalDatasetValues(getters, dataSets); + const removeFirstLabel = shouldRemoveFirstLabel( + labelRange, + dataSets[0], + definition.dataSetsHaveTitle || false + ); + if (removeFirstLabel) { + labels.shift(); + } + ({ labels, dataSetsValues } = filterValuesWithDifferentSigns(labels, dataSetsValues)); + ({ labels, dataSetsValues } = filterInvalidHierarchicalPoints(labels, dataSetsValues)); + + return { + dataSetsValues, + axisFormats: { y: getChartLabelFormat(getters, labelRange, removeFirstLabel) }, + labels, + locale: getters.getLocale(), + }; +} + export function getTrendDatasetForBarChart(config: TrendConfiguration, data: any[]) { const filteredValues: number[] = []; const filteredLabels: number[] = []; @@ -646,6 +675,69 @@ function filterInvalidDataPoints( }; } +/** + * Filter the data points that have either no value, a negative value, no root group or null group values in the middle + */ +function filterInvalidHierarchicalPoints( + values: string[], + hierarchy: DatasetValues[] +): { labels: string[]; dataSetsValues: DatasetValues[] } { + const numberOfDataPoints = Math.max( + values.length, + ...hierarchy.map((dataset) => dataset.data?.length || 0) + ); + const isEmpty = (value: CellValue) => value === undefined || value === null || value === ""; + const dataPointsIndexes = range(0, numberOfDataPoints).filter((dataPointIndex) => { + const groups = hierarchy.map((dataset) => dataset.data?.[dataPointIndex]); + if (isEmpty(groups[0])) { + return false; + } + // Filter points with empty group in the middle + let hasFoundEmptyGroup = false; + for (const group of groups) { + hasFoundEmptyGroup ||= isEmpty(group); + if (hasFoundEmptyGroup && !isEmpty(group)) { + return false; + } + } + return values[dataPointIndex] && !isNaN(Number(values[dataPointIndex])); + }); + return { + labels: dataPointsIndexes.map((i) => values[i]), + dataSetsValues: hierarchy.map((dataset) => ({ + ...dataset, + data: dataPointsIndexes.map((i) => dataset.data[i]), + })), + }; +} + +/** + * If the values are a mix of positive and negative values, keep only the positive ones + */ +function filterValuesWithDifferentSigns(values: string[], hierarchy: DatasetValues[]) { + const positivePointsIndexes: number[] = []; + const negativePointsIndexes: number[] = []; + + for (let i = 0; i < values.length; i++) { + if (Number(values[i]) <= 0) { + negativePointsIndexes.push(i); + } else if (Number(values[i]) > 0) { + positivePointsIndexes.push(i); + } + } + const indexesToKeep = positivePointsIndexes.length + ? positivePointsIndexes + : negativePointsIndexes; + + return { + labels: indexesToKeep.map((i) => values[i]), + dataSetsValues: hierarchy.map((dataset) => ({ + ...dataset, + data: indexesToKeep.map((i) => dataset.data[i]), + })), + }; +} + /** * Aggregates data based on labels */ @@ -795,3 +887,47 @@ function getChartDatasetValues(getters: Getters, dataSets: DataSet[]): DatasetVa } return datasetValues; } + +/** + * Get the values for a hierarchical dataset. The values can be defined in a tree-like structure + * in the sheet, and this function will fill up the blanks. + * + * @example the following dataset: + * + * 2024 Q1 W1 100 + * W2 200 + * + * will have the same value as the dataset: + * 2024 Q1 W1 100 + * 2024 Q1 W2 200 + */ +function getHierarchicalDatasetValues(getters: Getters, dataSets: DataSet[]): DatasetValues[] { + dataSets = dataSets.filter( + (ds) => !getters.isColHidden(ds.dataRange.sheetId, ds.dataRange.zone.left) + ); + const datasetValues: DatasetValues[] = dataSets.map(() => ({ data: [], label: "" })); + const dataSetsData = dataSets.map((ds) => getData(getters, ds)); + if (!dataSetsData.length) { + return datasetValues; + } + const minLength = Math.min(...dataSetsData.map((ds) => ds.length)); + + let currentValues: (CellValue | undefined)[] = []; + const leafDatasetIndex = dataSets.length - 1; + + for (let i = 0; i < minLength; i++) { + for (let dsIndex = 0; dsIndex < dataSetsData.length; dsIndex++) { + let value = dataSetsData[dsIndex][i]; + if ((value === undefined || value === null) && dsIndex !== leafDatasetIndex) { + value = currentValues[dsIndex]; + } + if (value !== currentValues[dsIndex]) { + currentValues = currentValues.slice(0, dsIndex); + currentValues[dsIndex] = value; + } + datasetValues[dsIndex].data.push(value ?? null); + } + } + + return datasetValues.filter((ds) => ds.data.some((d) => d !== null)); +} diff --git a/src/helpers/figures/charts/runtime/chartjs_dataset.ts b/src/helpers/figures/charts/runtime/chartjs_dataset.ts index d1f930d3a5..c84afb9da4 100644 --- a/src/helpers/figures/charts/runtime/chartjs_dataset.ts +++ b/src/helpers/figures/charts/runtime/chartjs_dataset.ts @@ -4,6 +4,7 @@ import { CHART_WATERFALL_NEGATIVE_COLOR, CHART_WATERFALL_POSITIVE_COLOR, CHART_WATERFALL_SUBTOTAL_COLOR, + COLOR_TRANSPARENT, LINE_FILL_TRANSPARENCY, } from "../../../../constants"; import { _t } from "../../../../translation"; @@ -11,11 +12,16 @@ import { ChartRuntimeGenerationArgs, Color, GenericDefinition } from "../../../. import { BarChartDefinition, ChartWithDataSetDefinition, + DatasetValues, FunnelChartColors, FunnelChartDefinition, LineChartDefinition, PieChartDefinition, ScatterChartDefinition, + SunburstChartDefinition, + SunburstChartJSDataset, + SunburstChartRawData, + SunburstTreeNode, TrendConfiguration, WaterfallChartDefinition, } from "../../../../types/chart"; @@ -34,6 +40,8 @@ import { } from "../../../color"; import { TREND_LINE_XAXIS_ID, getPieColors } from "../chart_common"; +export const GHOST_SUNBURST_VALUE = "nullValue"; + export function getBarChartDatasets( definition: GenericDefinition, args: ChartRuntimeGenerationArgs @@ -206,7 +214,7 @@ export function getPieChartDatasets( data, borderColor: definition.background || "#FFFFFF", backgroundColor, - hoverOffset: 30, + hoverOffset: 10, }; dataSets!.push(dataset); } @@ -368,6 +376,132 @@ export function getFunnelLabelColors(labels: string[], colors?: FunnelChartColor return labels.map(() => colorGenerator.next()); } +export function getSunburstChartDatasets( + definition: GenericDefinition, + args: ChartRuntimeGenerationArgs +): SunburstChartJSDataset[] { + const { dataSetsValues, labels } = args; + + const tree = getSunburstTree(dataSetsValues, labels); + const data = pyramidizeTree(tree); + + const rootData = data[0] || []; + const colorGenerator = new ColorGenerator(rootData.length, definition.groupColors || []); + const groupColors = rootData.map((rawValue) => ({ + label: rawValue.label, + color: colorGenerator.next(), + })); + + const dataSets: SunburstChartJSDataset[] = []; + for (let i = data.length - 1; i >= 0; i--) { + const dataset: SunburstChartJSDataset = { + groupColors, + parsing: { key: "value" }, + data: data[i] as any, + borderColor: (ctx) => { + const data = ctx.type === "data" ? (ctx.raw as SunburstChartRawData) : undefined; + if (!data || data.label === GHOST_SUNBURST_VALUE) { + return COLOR_TRANSPARENT; + } + return definition.background || BACKGROUND_CHART_COLOR; + }, + backgroundColor: (ctx) => { + const data = ctx.type === "data" ? (ctx.raw as SunburstChartRawData) : undefined; + if (!data || data.label === GHOST_SUNBURST_VALUE) { + return COLOR_TRANSPARENT; + } + const rootGroup = data.groups[0]; + return groupColors.find((groupColor) => groupColor.label === rootGroup)?.color; + }, + hoverOffset: 10, + }; + dataSets!.push(dataset); + } + return dataSets; +} + +function getDataEntriesFromDatasets(hierarchicalDatasetValues: DatasetValues[], values: string[]) { + const entries: Record[] = []; + const maxDatasetLength = Math.max(...hierarchicalDatasetValues.map((ds) => ds.data.length)); + for (let i = 0; i < maxDatasetLength; i++) { + entries[i] = {}; + for (let j = 0; j < hierarchicalDatasetValues.length; j++) { + const groupBy = + hierarchicalDatasetValues[j].data[i] === null + ? GHOST_SUNBURST_VALUE + : String(hierarchicalDatasetValues[j].data[i]); + entries[i][j] = groupBy; + } + entries[i].value = Number(values[i]); + } + return entries; +} + +function getSunburstTree( + hierarchicalDatasetValues: DatasetValues[], + values: string[] +): SunburstTreeNode[] { + const entries = getDataEntriesFromDatasets(hierarchicalDatasetValues, values); + return sunburstGroupBy(entries, 0, hierarchicalDatasetValues.length, []); +} + +function sunburstGroupBy( + entries: Record[], + index: number, + maxDepth: number, + parentGroups: string[] +): SunburstTreeNode[] { + if (index >= maxDepth) { + return []; + } + const groups = Object.groupBy(entries, (item) => item[index]); + return Object.keys(groups) + .map((key) => { + const total = groups[key]?.reduce((acc, item) => acc + Number(item.value), 0) || 0; + const itemGroups = [...parentGroups, key]; + const tree = sunburstGroupBy(groups[key] || [], index + 1, maxDepth, [...parentGroups, key]); + return { + label: key, + value: total, + children: tree, + groups: itemGroups, + depth: index, + }; + }) + .sort((a, b) => b.value - a.value); +} + +/** + * Transform a tree into a "pyramid" array, ie. an array in which each level is an array of nodes at the same depth. + * + * Example: + * ``` + * A [ + * / \ [A], + * B C ===> [B, C], + * / \ \ [D, E, F], + * D E F ] + * ``` + */ +function pyramidizeTree(tree: SunburstTreeNode[]): SunburstTreeNode[][] { + const flattened: SunburstTreeNode[][] = []; + const queue = [...tree]; + while (queue.length > 0) { + const node = queue.shift(); + if (!node) { + continue; + } + if (!flattened[node.depth]) { + flattened[node.depth] = []; + } + flattened[node.depth].push(node); + if (node.children) { + queue.push(...node.children); + } + } + return flattened; +} + function getTrendingLineDataSet( dataset: ChartDataset<"line" | "bar">, config: TrendConfiguration, diff --git a/src/helpers/figures/charts/runtime/chartjs_legend.ts b/src/helpers/figures/charts/runtime/chartjs_legend.ts index b1cb02081d..ea7caeb537 100644 --- a/src/helpers/figures/charts/runtime/chartjs_legend.ts +++ b/src/helpers/figures/charts/runtime/chartjs_legend.ts @@ -12,6 +12,8 @@ import { ChartWithDataSetDefinition, GenericDefinition, LineChartDefinition, + SunburstChartDefinition, + SunburstChartJSDataset, WaterfallChartDefinition, } from "../../../../types/chart"; import { ComboChartDefinition } from "../../../../types/chart/combo_chart"; @@ -191,6 +193,37 @@ export function getRadarChartLegend( }; } +export function getSunburstChartLegend( + definition: SunburstChartDefinition, + args: ChartRuntimeGenerationArgs +): ChartLegend { + const fontColor = chartFontColor(definition.background); + + return { + ...getLegendDisplayOptions(definition, args), + labels: { + usePointStyle: true, + generateLabels: (chart) => { + const rootDataset = chart.data.datasets.at(-1) as SunburstChartJSDataset; + if (!rootDataset) { + return []; + } + const colors = rootDataset.groupColors; + + return colors.map(({ color, label }) => { + return { + text: truncateLabel(label), + fontColor, + fillStyle: color, + strokeStyle: color, + pointStyle: "rect" as const, + }; + }); + }, + }, + }; +} + /* Callback used to make the legend interactive * These are used to make the user able to hide/show a data series by * clicking on the corresponding label in the legend. The onHover and diff --git a/src/helpers/figures/charts/runtime/chartjs_scales.ts b/src/helpers/figures/charts/runtime/chartjs_scales.ts index eb3f3acf25..0e79bf9d04 100644 --- a/src/helpers/figures/charts/runtime/chartjs_scales.ts +++ b/src/helpers/figures/charts/runtime/chartjs_scales.ts @@ -12,7 +12,7 @@ import { AxisDesign, BarChartDefinition, ChartRuntimeGenerationArgs, - ChartWithDataSetDefinition, + ChartWithAxisDefinition, FunnelChartDefinition, GenericDefinition, LegendPosition, @@ -331,7 +331,7 @@ function getChartAxisTitleRuntime(design?: AxisDesign): } function getChartAxis( - definition: GenericDefinition, + definition: GenericDefinition, position: "left" | "right" | "bottom", type: "values" | "labels", options: LocaleFormat & { stacked?: boolean } diff --git a/src/helpers/figures/charts/runtime/chartjs_show_values.ts b/src/helpers/figures/charts/runtime/chartjs_show_values.ts index 7291ec1c77..fb7bb8320e 100644 --- a/src/helpers/figures/charts/runtime/chartjs_show_values.ts +++ b/src/helpers/figures/charts/runtime/chartjs_show_values.ts @@ -1,5 +1,11 @@ import { ChartShowValuesPluginOptions } from "../../../../components/figures/chart/chartJs/chartjs_show_values_plugin"; -import { ChartRuntimeGenerationArgs, ChartWithDataSetDefinition } from "../../../../types/chart"; +import { ChartSunburstLabelsPluginOptions } from "../../../../components/figures/chart/chartJs/chartjs_sunburst_labels_plugin"; +import { + ChartRuntimeGenerationArgs, + ChartWithDataSetDefinition, + SunburstChartDefaults, + SunburstChartDefinition, +} from "../../../../types/chart"; import { formatChartDatasetValue } from "../chart_common"; export function getChartShowValues( @@ -14,3 +20,22 @@ export function getChartShowValues( callback: formatChartDatasetValue(axisFormats, locale), }; } + +export function getSunburstShowValues( + definition: SunburstChartDefinition, + args: ChartRuntimeGenerationArgs +): ChartSunburstLabelsPluginOptions { + const { axisFormats, locale } = args; + return { + callback: formatChartDatasetValue(axisFormats, locale), + showLabels: definition.showLabels ?? SunburstChartDefaults.showLabels, + showValues: definition.showValues ?? SunburstChartDefaults.showValues, + style: { + fontSize: definition.valuesDesign?.fontSize ?? SunburstChartDefaults.valuesDesign.fontSize, + align: definition.valuesDesign?.align ?? SunburstChartDefaults.valuesDesign.align, + bold: definition.valuesDesign?.bold ?? SunburstChartDefaults.valuesDesign.bold, + italic: definition.valuesDesign?.italic ?? SunburstChartDefaults.valuesDesign.italic, + textColor: definition.valuesDesign?.color ?? SunburstChartDefaults.valuesDesign.color, + }, + }; +} diff --git a/src/helpers/figures/charts/runtime/chartjs_tooltip.ts b/src/helpers/figures/charts/runtime/chartjs_tooltip.ts index 662193e4e9..78714ea0ed 100644 --- a/src/helpers/figures/charts/runtime/chartjs_tooltip.ts +++ b/src/helpers/figures/charts/runtime/chartjs_tooltip.ts @@ -9,6 +9,8 @@ import { LineChartDefinition, PieChartDefinition, PyramidChartDefinition, + SunburstChartDefinition, + SunburstChartRawData, WaterfallChartDefinition, } from "../../../../types/chart"; import { GeoChartDefinition } from "../../../../types/chart/geo_chart"; @@ -18,6 +20,7 @@ import { formatValue } from "../../../format/format"; import { isNumber } from "../../../numbers"; import { TREND_LINE_XAXIS_ID, formatChartDatasetValue } from "../chart_common"; import { renderToString } from "./chart_custom_tooltip"; +import { GHOST_SUNBURST_VALUE } from "./chartjs_dataset"; type ChartTooltip = _DeepPartialObject>; type ChartContext = { chart: Chart; tooltip: TooltipModel }; @@ -237,6 +240,35 @@ export function getFunnelChartTooltip( }; } +export function getSunburstChartTooltip( + definition: SunburstChartDefinition, + args: ChartRuntimeGenerationArgs +): ChartTooltip { + const { locale, axisFormats } = args; + const format = axisFormats?.y || axisFormats?.y1; + return { + enabled: false, + external: customTooltipHandler, + filter: function (tooltipItem) { + const data = tooltipItem.raw as SunburstChartRawData; + return data?.label !== GHOST_SUNBURST_VALUE; + }, + callbacks: { + title: () => "", + beforeLabel: (tooltipItem) => { + const data = tooltipItem.raw as SunburstChartRawData; + return data.groups.join(" / "); + }, + label: function (tooltipItem) { + const data = tooltipItem.raw as SunburstChartRawData; + const yLabel = data.value; + const toolTipFormat = !format && yLabel >= 1000 ? "#,##" : format; + return formatValue(yLabel, { format: toolTipFormat, locale }); + }, + }, + }; +} + function calculatePercentage( dataset: (number | [number, number] | Point | BubbleDataPoint | null)[], dataIndex: number diff --git a/src/helpers/figures/charts/sunburst_chart.ts b/src/helpers/figures/charts/sunburst_chart.ts new file mode 100644 index 0000000000..be83542c53 --- /dev/null +++ b/src/helpers/figures/charts/sunburst_chart.ts @@ -0,0 +1,212 @@ +import type { ChartConfiguration, ChartOptions } from "chart.js"; +import { BACKGROUND_CHART_COLOR } from "../../../constants"; +import { + AddColumnsRowsCommand, + ApplyRangeChange, + Color, + CommandResult, + CoreGetters, + Getters, + Range, + RemoveColumnsRowsCommand, + UID, +} from "../../../types"; +import { SunburstChartDefinition, SunburstChartRuntime } from "../../../types/chart"; +import { + ChartCreationContext, + ChartStyle, + DataSet, + ExcelChartDefinition, +} from "../../../types/chart/chart"; +import { LegendPosition } from "../../../types/chart/common_chart"; +import { Validator } from "../../../types/validator"; +import { createValidRange } from "../../range"; +import { AbstractChart } from "./abstract_chart"; +import { + checkDataset, + checkLabelRange, + createDataSets, + duplicateDataSetsInDuplicatedSheet, + duplicateLabelRangeInDuplicatedSheet, + transformChartDefinitionWithDataSetsWithZone, + updateChartRangesWithDataSets, +} from "./chart_common"; +import { CHART_COMMON_OPTIONS } from "./chart_ui_common"; +import { + getChartLayout, + getChartTitle, + getSunburstChartData, + getSunburstChartDatasets, + getSunburstChartLegend, + getSunburstChartTooltip, + getSunburstShowValues, +} from "./runtime"; + +export class SunburstChart extends AbstractChart { + readonly dataSets: DataSet[]; + readonly labelRange?: Range | undefined; + readonly background?: Color; + readonly legendPosition: LegendPosition; + readonly type = "sunburst"; + readonly dataSetsHaveTitle: boolean; + readonly showValues?: boolean; + readonly showLabels?: boolean; + readonly valuesDesign?: ChartStyle; + readonly groupColors?: (Color | undefined | null)[]; + + constructor(definition: SunburstChartDefinition, sheetId: UID, getters: CoreGetters) { + super(definition, sheetId, getters); + this.dataSets = createDataSets( + getters, + definition.dataSets, + sheetId, + definition.dataSetsHaveTitle + ); + this.labelRange = createValidRange(getters, sheetId, definition.labelRange); + this.background = definition.background; + this.legendPosition = definition.legendPosition; + this.dataSetsHaveTitle = definition.dataSetsHaveTitle; + this.showValues = definition.showValues; + this.showLabels = definition.showLabels; + this.valuesDesign = definition.valuesDesign; + this.groupColors = definition.groupColors; + } + + static transformDefinition( + definition: SunburstChartDefinition, + executed: AddColumnsRowsCommand | RemoveColumnsRowsCommand + ): SunburstChartDefinition { + return transformChartDefinitionWithDataSetsWithZone(definition, executed); + } + + static validateChartDefinition( + validator: Validator, + definition: SunburstChartDefinition + ): CommandResult | CommandResult[] { + return validator.checkValidations(definition, checkDataset, checkLabelRange); + } + + static getDefinitionFromContextCreation(context: ChartCreationContext): SunburstChartDefinition { + return { + background: context.background, + dataSets: context.auxiliaryRange + ? [{ ...context.range?.[0], dataRange: context.auxiliaryRange }] + : [], + dataSetsHaveTitle: context.dataSetsHaveTitle ?? false, + legendPosition: context.legendPosition ?? "top", + title: context.title || { text: "" }, + type: "sunburst", + labelRange: context.range?.[0]?.dataRange, + showValues: context.showValues, + showLabels: context.showLabels, + valuesDesign: context.valuesDesign, + groupColors: context.groupColors, + }; + } + + getDefinition(): SunburstChartDefinition { + return this.getDefinitionWithSpecificDataSets(this.dataSets, this.labelRange); + } + + getContextCreation(): ChartCreationContext { + const leafRange = this.dataSets.at(-1)?.dataRange; + return { + ...this, + range: this.labelRange + ? [{ dataRange: this.getters.getRangeString(this.labelRange, this.sheetId) }] + : [], + auxiliaryRange: leafRange ? this.getters.getRangeString(leafRange, this.sheetId) : undefined, + }; + } + + private getDefinitionWithSpecificDataSets( + dataSets: DataSet[], + labelRange: Range | undefined, + targetSheetId?: UID + ): SunburstChartDefinition { + return { + type: "sunburst", + dataSetsHaveTitle: dataSets.length ? Boolean(dataSets[0].labelCell) : false, + background: this.background, + dataSets: dataSets.map((ds: DataSet) => ({ + dataRange: this.getters.getRangeString(ds.dataRange, targetSheetId || this.sheetId), + })), + legendPosition: this.legendPosition, + labelRange: labelRange + ? this.getters.getRangeString(labelRange, targetSheetId || this.sheetId) + : undefined, + title: this.title, + showValues: this.showValues, + showLabels: this.showLabels, + valuesDesign: this.valuesDesign, + groupColors: this.groupColors, + }; + } + + duplicateInDuplicatedSheet(newSheetId: UID): SunburstChart { + const dataSets = duplicateDataSetsInDuplicatedSheet(this.sheetId, newSheetId, this.dataSets); + const labelRange = duplicateLabelRangeInDuplicatedSheet( + this.sheetId, + newSheetId, + this.labelRange + ); + const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange, newSheetId); + return new SunburstChart(definition, newSheetId, this.getters); + } + + copyInSheetId(sheetId: UID): SunburstChart { + const definition = this.getDefinitionWithSpecificDataSets( + this.dataSets, + this.labelRange, + sheetId + ); + return new SunburstChart(definition, sheetId, this.getters); + } + + getDefinitionForExcel(): ExcelChartDefinition | undefined { + return undefined; + } + + updateRanges(applyChange: ApplyRangeChange): SunburstChart { + const { dataSets, labelRange, isStale } = updateChartRangesWithDataSets( + this.getters, + applyChange, + this.dataSets, + this.labelRange + ); + if (!isStale) { + return this; + } + const definition = this.getDefinitionWithSpecificDataSets(dataSets, labelRange); + return new SunburstChart(definition, this.sheetId, this.getters); + } +} + +export function createSunburstChartRuntime( + chart: SunburstChart, + getters: Getters +): SunburstChartRuntime { + const definition = chart.getDefinition(); + const chartData = getSunburstChartData(definition, chart.dataSets, chart.labelRange, getters); + + const config: ChartConfiguration<"doughnut"> = { + type: "doughnut", + data: { + datasets: getSunburstChartDatasets(definition, chartData), + }, + options: { + cutout: "25%", + ...(CHART_COMMON_OPTIONS as ChartOptions<"doughnut">), + layout: getChartLayout(definition), + plugins: { + title: getChartTitle(definition), + legend: getSunburstChartLegend(definition, chartData), + tooltip: getSunburstChartTooltip(definition, chartData), + sunburstLabelsPlugin: getSunburstShowValues(definition, chartData), + sunburstHoverPlugin: { enabled: true }, + }, + }, + }; + + return { chartJsConfig: config, background: chart.background || BACKGROUND_CHART_COLOR }; +} diff --git a/src/helpers/text_helper.ts b/src/helpers/text_helper.ts index 3ae8fd96e0..f215285946 100644 --- a/src/helpers/text_helper.ts +++ b/src/helpers/text_helper.ts @@ -358,3 +358,38 @@ export function drawDecoratedText( context.stroke(); } } + +export function sliceTextToFitWidth( + context: CanvasRenderingContext2D, + width: number, + text: string, + style: Style, + fontUnit: "px" | "pt" = "pt" +) { + if (computeTextWidth(context, text, style, fontUnit) <= width) { + return text; + } + const ellipsis = "..."; + const ellipsisWidth = computeTextWidth(context, ellipsis, style, fontUnit); + if (ellipsisWidth >= width) { + return ""; + } + + let lowerBoundLen = 1; + let upperBoundLen = text.length; + let currentWidth: number; + + while (lowerBoundLen <= upperBoundLen) { + const currentLen = Math.floor((lowerBoundLen + upperBoundLen) / 2); + const currentText = text.slice(0, currentLen); + currentWidth = computeTextWidth(context, currentText, style, fontUnit); + if (currentWidth + ellipsisWidth > width) { + upperBoundLen = currentLen - 1; + } else { + lowerBoundLen = currentLen + 1; + } + } + + const slicedText = text.slice(0, Math.max(0, lowerBoundLen - 1)); + return slicedText ? slicedText + ellipsis : ""; +} diff --git a/src/index.ts b/src/index.ts index b59e76ea7e..689339089a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,10 +27,10 @@ import { ScorecardChartDesignPanel, chartSidePanelComponentRegistry, } from "./components/side_panel/chart"; +import { ChartTitle } from "./components/side_panel/chart/building_blocks/chart_title/chart_title"; import { ChartDataSeries } from "./components/side_panel/chart/building_blocks/data_series/data_series"; import { ChartErrorSection } from "./components/side_panel/chart/building_blocks/error_section/error_section"; import { ChartLabelRange } from "./components/side_panel/chart/building_blocks/label_range/label_range"; -import { ChartTitle } from "./components/side_panel/chart/building_blocks/title/title"; import { ChartTypePicker } from "./components/side_panel/chart/chart_type_picker/chart_type_picker"; import { ChartPanel } from "./components/side_panel/chart/main_chart_panel/main_chart_panel"; import { PieChartDesignPanel } from "./components/side_panel/chart/pie_chart/pie_chart_design_panel"; diff --git a/src/registries/chart_types.ts b/src/registries/chart_types.ts index b675ea7c85..0e2d57c0d9 100644 --- a/src/registries/chart_types.ts +++ b/src/registries/chart_types.ts @@ -17,6 +17,10 @@ import { ScorecardChart, createScorecardChartRuntime, } from "../helpers/figures/charts/scorecard_chart"; +import { + SunburstChart, + createSunburstChartRuntime, +} from "../helpers/figures/charts/sunburst_chart"; import { WaterfallChart, createWaterfallChartRuntime, @@ -36,6 +40,7 @@ import { LineChartDefinition, PieChartDefinition, ScorecardChartDefinition, + SunburstChartDefinition, } from "../types/chart"; import { ChartCreationContext, @@ -204,6 +209,16 @@ chartRegistry.add("funnel", { getChartDefinitionFromContextCreation: FunnelChart.getDefinitionFromContextCreation, sequence: 100, }); +chartRegistry.add("sunburst", { + match: (type) => type === "sunburst", + createChart: (definition, sheetId, getters) => + new SunburstChart(definition as SunburstChartDefinition, sheetId, getters), + getChartRuntime: createSunburstChartRuntime, + validateChartDefinition: SunburstChart.validateChartDefinition, + transformDefinition: SunburstChart.transformDefinition, + getChartDefinitionFromContextCreation: SunburstChart.getDefinitionFromContextCreation, + sequence: 30, +}); export const chartComponentRegistry = new Registry Component>(); chartComponentRegistry.add("line", ChartJsComponent); @@ -218,6 +233,7 @@ chartComponentRegistry.add("pyramid", ChartJsComponent); chartComponentRegistry.add("radar", ChartJsComponent); chartComponentRegistry.add("geo", ChartJsComponent); chartComponentRegistry.add("funnel", ChartJsComponent); +chartComponentRegistry.add("sunburst", ChartJsComponent); type ChartUICategory = keyof typeof chartCategories; @@ -418,4 +434,12 @@ chartSubtypeRegistry chartType: "funnel", category: "misc", preview: "o-spreadsheet-ChartPreview.FUNNEL_CHART", + }) + .add("sunburst", { + matcher: (definition) => definition.type === "sunburst", + displayName: _t("Sunburst"), + chartSubtype: "sunburst", + chartType: "sunburst", + category: "misc", + preview: "o-spreadsheet-ChartPreview.SUNBURST_CHART", }); diff --git a/src/types/chart/chart.ts b/src/types/chart/chart.ts index c0e55d7d7e..291873562e 100644 --- a/src/types/chart/chart.ts +++ b/src/types/chart/chart.ts @@ -1,5 +1,5 @@ import { Point } from "chart.js"; -import { Align, Color, Format, Locale, Range } from "../../types"; +import { Align, Color, Format, Locale, Range, VerticalAlign } from "../../types"; import { XlsxHexColor } from "../xlsx"; import { BarChartDefinition, BarChartRuntime } from "./bar_chart"; import { ComboChartDefinition, ComboChartRuntime } from "./combo_chart"; @@ -13,6 +13,7 @@ import { PyramidChartDefinition, PyramidChartRuntime } from "./pyramid_chart"; import { RadarChartDefinition, RadarChartRuntime } from "./radar_chart"; import { ScatterChartDefinition, ScatterChartRuntime } from "./scatter_chart"; import { ScorecardChartDefinition, ScorecardChartRuntime } from "./scorecard_chart"; +import { SunburstChartDefinition, SunburstChartRuntime } from "./sunburst_chart"; import { WaterfallChartDefinition, WaterfallChartRuntime } from "./waterfall_chart"; export const CHART_TYPES = [ @@ -28,6 +29,7 @@ export const CHART_TYPES = [ "radar", "geo", "funnel", + "sunburst", ] as const; export type ChartType = (typeof CHART_TYPES)[number]; @@ -43,13 +45,19 @@ export type ChartDefinition = | PyramidChartDefinition | RadarChartDefinition | GeoChartDefinition - | FunnelChartDefinition; + | FunnelChartDefinition + | SunburstChartDefinition; export type ChartWithDataSetDefinition = Extract< ChartDefinition, { dataSets: CustomizedDataSet[]; labelRange?: string } >; +export type ChartWithAxisDefinition = Extract< + ChartWithDataSetDefinition, + { axesDesign?: AxesDesign } +>; + export type ChartJSRuntime = | LineChartRuntime | PieChartRuntime @@ -60,7 +68,8 @@ export type ChartJSRuntime = | PyramidChartRuntime | RadarChartRuntime | GeoChartRuntime - | FunnelChartRuntime; + | FunnelChartRuntime + | SunburstChartRuntime; export type ChartRuntime = ChartJSRuntime | ScorecardChartRuntime | GaugeChartRuntime; @@ -91,13 +100,18 @@ export interface AxesDesign { readonly y1?: AxisDesign; } -export interface TitleDesign { - readonly text?: string; +export interface ChartStyle { readonly bold?: boolean; readonly italic?: boolean; readonly align?: Align; + readonly verticalAlign?: VerticalAlign; readonly color?: Color; readonly fontSize?: number; + readonly fillColor?: Color; +} + +export interface TitleDesign extends ChartStyle { + readonly text?: string; } export type TrendType = "polynomial" | "exponential" | "logarithmic" | "trailingMovingAverage"; @@ -167,6 +181,9 @@ export interface ChartCreationContext { readonly fillArea?: boolean; readonly showValues?: boolean; readonly funnelColors?: FunnelChartColors; + readonly showLabels?: boolean; + readonly valuesDesign?: ChartStyle; + readonly groupColors?: (Color | undefined | null)[]; } export type ChartAxisFormats = { [axisId: string]: Format | undefined } | undefined; diff --git a/src/types/chart/geo_chart.ts b/src/types/chart/geo_chart.ts index 5d14c6967d..5b4e6b4f8e 100644 --- a/src/types/chart/geo_chart.ts +++ b/src/types/chart/geo_chart.ts @@ -1,6 +1,6 @@ import { ChartConfiguration } from "chart.js"; import { Color } from "../misc"; -import { AxesDesign, ChartRuntimeGenerationArgs, CustomizedDataSet, TitleDesign } from "./chart"; +import { ChartRuntimeGenerationArgs, CustomizedDataSet, TitleDesign } from "./chart"; import { LegendPosition } from "./common_chart"; export interface GeoChartDefinition { @@ -11,8 +11,6 @@ export interface GeoChartDefinition { readonly title: TitleDesign; readonly background?: Color; readonly legendPosition: LegendPosition; - readonly axesDesign?: AxesDesign; - readonly aggregated?: boolean; readonly colorScale?: GeoChartColorScale; readonly missingValueColor?: Color; readonly region?: string; diff --git a/src/types/chart/index.ts b/src/types/chart/index.ts index 47c13c4ded..ac2a6cf874 100644 --- a/src/types/chart/index.ts +++ b/src/types/chart/index.ts @@ -8,4 +8,5 @@ export * from "./pie_chart"; export * from "./pyramid_chart"; export * from "./scatter_chart"; export * from "./scorecard_chart"; +export * from "./sunburst_chart"; export * from "./waterfall_chart"; diff --git a/src/types/chart/pie_chart.ts b/src/types/chart/pie_chart.ts index e48c08e225..f0ac7a3aa7 100644 --- a/src/types/chart/pie_chart.ts +++ b/src/types/chart/pie_chart.ts @@ -1,6 +1,6 @@ import type { ChartConfiguration } from "chart.js"; import { Color } from "../misc"; -import { AxesDesign, CustomizedDataSet, TitleDesign } from "./chart"; +import { CustomizedDataSet, TitleDesign } from "./chart"; import { LegendPosition } from "./common_chart"; export interface PieChartDefinition { @@ -12,7 +12,6 @@ export interface PieChartDefinition { readonly background?: Color; readonly legendPosition: LegendPosition; readonly aggregated?: boolean; - readonly axesDesign?: AxesDesign; readonly isDoughnut?: boolean; readonly showValues?: boolean; } diff --git a/src/types/chart/sunburst_chart.ts b/src/types/chart/sunburst_chart.ts new file mode 100644 index 0000000000..3975c75ffb --- /dev/null +++ b/src/types/chart/sunburst_chart.ts @@ -0,0 +1,50 @@ +import type { ChartConfiguration, ChartDataset } from "chart.js"; +import { Color } from "../misc"; +import { ChartStyle, CustomizedDataSet, TitleDesign } from "./chart"; +import { LegendPosition } from "./common_chart"; + +export interface SunburstChartDefinition { + readonly type: "sunburst"; + readonly dataSets: CustomizedDataSet[]; + readonly dataSetsHaveTitle: boolean; + readonly labelRange?: string; + readonly title: TitleDesign; + readonly background?: Color; + readonly legendPosition: LegendPosition; + readonly showValues?: boolean; + readonly showLabels?: boolean; + readonly valuesDesign?: ChartStyle; + readonly groupColors?: (Color | undefined | null)[]; +} + +export type SunburstChartRuntime = { + chartJsConfig: ChartConfiguration<"doughnut">; + background: Color; +}; + +export type SunburstChartRawData = { + label: string; + value: number; + groups: string[]; +}; + +export interface SunburstTreeNode extends SunburstChartRawData { + children: SunburstTreeNode[]; + depth: number; +} + +export const SunburstChartDefaults = { + showValues: false, + showLabels: true, + valuesDesign: { + align: "center", + fontSize: 13, + } as ChartStyle, +}; + +export interface SunburstChartJSDataset extends ChartDataset<"doughnut"> { + groupColors: { + label: string; + color: Color; + }[]; +} diff --git a/tests/figures/chart/bar_chart_plugin.test.ts b/tests/figures/chart/bar_chart_plugin.test.ts index 96f2f2c2ca..85ee827d43 100644 --- a/tests/figures/chart/bar_chart_plugin.test.ts +++ b/tests/figures/chart/bar_chart_plugin.test.ts @@ -3,6 +3,7 @@ import { BACKGROUND_CHART_COLOR } from "../../../src/constants"; import { BarChart } from "../../../src/helpers/figures/charts"; import { BarChartRuntime } from "../../../src/types/chart"; import { + GENERAL_CHART_CREATION_CONTEXT, getChartLegendLabels, getChartTooltipValues, isChartAxisStacked, @@ -19,23 +20,8 @@ let model: Model; describe("bar chart", () => { test("create bar chart from creation context", () => { const context: Required = { - background: "#123456", - title: { text: "hello there" }, + ...GENERAL_CHART_CREATION_CONTEXT, range: [{ dataRange: "Sheet1!B1:B4", yAxisId: "y1" }], - auxiliaryRange: "Sheet1!A1:A4", - legendPosition: "bottom", - cumulative: true, - labelsAsText: true, - dataSetsHaveTitle: true, - aggregated: true, - stacked: true, - firstValueAsSubtotal: true, - showConnectorLines: false, - showSubTotals: true, - axesDesign: {}, - fillArea: true, - showValues: false, - funnelColors: [], }; const definition = BarChart.getDefinitionFromContextCreation(context); expect(definition).toEqual({ diff --git a/tests/figures/chart/charts_component.test.ts b/tests/figures/chart/charts_component.test.ts index 7ac0dc3a64..01f3308e57 100644 --- a/tests/figures/chart/charts_component.test.ts +++ b/tests/figures/chart/charts_component.test.ts @@ -379,7 +379,7 @@ describe("charts", () => { await mountChartSidePanel(); await openChartDesignSidePanel(model, env, fixture, chartId); const alignment_menu = fixture.querySelectorAll( - ".o-chart-title-designer > .o-menu-item-button[title='Horizontal alignment']" + ".o-chart-title-designer .o-menu-item-button[title='Horizontal alignment']" )[0]; await click(alignment_menu); @@ -503,7 +503,7 @@ describe("charts", () => { await mountChartSidePanel(); await openChartDesignSidePanel(model, env, fixture, chartId); const alignment_menu = fixture.querySelectorAll( - ".o-chart-title-designer > .o-menu-item-button[title='Horizontal alignment']" + ".o-chart-title-designer .o-menu-item-button[title='Horizontal alignment']" )[1]; await click(alignment_menu); @@ -2157,6 +2157,8 @@ test("ChartJS charts extensions are loaded when mounting a chart, and are only l "waterfallLinesPlugin", "funnel", "funnel", + "sunburstLabelsPlugin", + "sunburstHoverPlugin", ]); createChart(model, { type: "line" }, "chart2"); diff --git a/tests/figures/chart/combo_chart_plugin.test.ts b/tests/figures/chart/combo_chart_plugin.test.ts index 7e63173856..9b6752d5fe 100644 --- a/tests/figures/chart/combo_chart_plugin.test.ts +++ b/tests/figures/chart/combo_chart_plugin.test.ts @@ -1,6 +1,10 @@ import { ChartCreationContext, Model } from "../../../src"; import { ComboChartRuntime } from "../../../src/types/chart/combo_chart"; -import { getChartLegendLabels, getChartTooltipValues } from "../../test_helpers/chart_helpers"; +import { + GENERAL_CHART_CREATION_CONTEXT, + getChartLegendLabels, + getChartTooltipValues, +} from "../../test_helpers/chart_helpers"; import { createChart, setCellContent, @@ -13,23 +17,8 @@ import { ComboChart } from "./../../../src/helpers/figures/charts/combo_chart"; describe("combo chart", () => { test("create combo chart from creation context", () => { const context: Required = { - background: "#123456", - title: { text: "hello there" }, + ...GENERAL_CHART_CREATION_CONTEXT, range: [{ dataRange: "Sheet1!B1:B4", yAxisId: "y1" }], - auxiliaryRange: "Sheet1!A1:A4", - legendPosition: "bottom", - cumulative: true, - labelsAsText: true, - dataSetsHaveTitle: true, - aggregated: true, - stacked: true, - firstValueAsSubtotal: true, - showConnectorLines: false, - showSubTotals: true, - axesDesign: {}, - fillArea: true, - showValues: false, - funnelColors: [], }; const definition = ComboChart.getDefinitionFromContextCreation(context); expect(definition).toEqual({ diff --git a/tests/figures/chart/funnel/funnel_chart_plugin.test.ts b/tests/figures/chart/funnel/funnel_chart_plugin.test.ts index 4df03d236a..e96cba4b9e 100644 --- a/tests/figures/chart/funnel/funnel_chart_plugin.test.ts +++ b/tests/figures/chart/funnel/funnel_chart_plugin.test.ts @@ -3,6 +3,7 @@ import { ColorGenerator } from "../../../../src/helpers"; import { FunnelChart } from "../../../../src/helpers/figures/charts/funnel_chart"; import { FunnelChartRuntime } from "../../../../src/types/chart/funnel_chart"; import { createFunnelChart, setCellContent, setFormat } from "../../../test_helpers"; +import { GENERAL_CHART_CREATION_CONTEXT } from "../../../test_helpers/chart_helpers"; let model: Model; @@ -13,22 +14,8 @@ function getFunnelRuntime(chartId: UID): FunnelChartRuntime { describe("Funnel chart", () => { test("create funnel chart from creation context", () => { const context: Required = { - background: "#123456", - title: { text: "hello there" }, + ...GENERAL_CHART_CREATION_CONTEXT, range: [{ dataRange: "Sheet1!B1:B4", yAxisId: "y1" }], - auxiliaryRange: "Sheet1!A1:A4", - legendPosition: "bottom", - cumulative: true, - labelsAsText: true, - dataSetsHaveTitle: true, - aggregated: true, - stacked: true, - firstValueAsSubtotal: true, - showConnectorLines: false, - showSubTotals: true, - axesDesign: {}, - fillArea: true, - showValues: false, funnelColors: ["#ff0000", "#00ff00"], }; const definition = FunnelChart.getDefinitionFromContextCreation(context); diff --git a/tests/figures/chart/gauge/gauge_chart_plugin.test.ts b/tests/figures/chart/gauge/gauge_chart_plugin.test.ts index 3dc5cbaca0..929c44338a 100644 --- a/tests/figures/chart/gauge/gauge_chart_plugin.test.ts +++ b/tests/figures/chart/gauge/gauge_chart_plugin.test.ts @@ -6,6 +6,7 @@ import { GaugeChartRuntime, SectionRule, } from "../../../../src/types/chart/gauge_chart"; +import { GENERAL_CHART_CREATION_CONTEXT } from "../../../test_helpers/chart_helpers"; import { activateSheet, addColumns, @@ -104,25 +105,7 @@ describe("datasource tests", function () { }); test("create gauge from creation context", () => { - const context: Required = { - background: "#123456", - title: { text: "hello there" }, - range: [{ dataRange: "Sheet1!B1:B4" }], - auxiliaryRange: "Sheet1!A1:A4", - legendPosition: "bottom", - cumulative: true, - labelsAsText: true, - dataSetsHaveTitle: true, - aggregated: true, - stacked: true, - firstValueAsSubtotal: true, - showConnectorLines: false, - showSubTotals: true, - axesDesign: {}, - fillArea: true, - showValues: false, - funnelColors: [], - }; + const context: Required = GENERAL_CHART_CREATION_CONTEXT; const definition = GaugeChart.getDefinitionFromContextCreation(context); expect(definition).toEqual({ type: "gauge", diff --git a/tests/figures/chart/line_chart_plugin.test.ts b/tests/figures/chart/line_chart_plugin.test.ts index 22f42c6568..9c7351ca91 100644 --- a/tests/figures/chart/line_chart_plugin.test.ts +++ b/tests/figures/chart/line_chart_plugin.test.ts @@ -1,29 +1,18 @@ import { ChartCreationContext, Model } from "../../../src"; import { LineChart } from "../../../src/helpers/figures/charts"; -import { getChartLegendLabels, isChartAxisStacked } from "../../test_helpers/chart_helpers"; +import { + GENERAL_CHART_CREATION_CONTEXT, + getChartLegendLabels, + isChartAxisStacked, +} from "../../test_helpers/chart_helpers"; import { createChart, setCellContent, updateChart } from "../../test_helpers/commands_helpers"; import { createModelFromGrid } from "../../test_helpers/helpers"; describe("line chart", () => { test("create line chart from creation context", () => { const context: Required = { - background: "#123456", - title: { text: "hello there" }, + ...GENERAL_CHART_CREATION_CONTEXT, range: [{ dataRange: "Sheet1!B1:B4", yAxisId: "y1" }], - auxiliaryRange: "Sheet1!A1:A4", - legendPosition: "bottom", - cumulative: true, - labelsAsText: true, - dataSetsHaveTitle: true, - aggregated: true, - stacked: true, - firstValueAsSubtotal: true, - showConnectorLines: false, - showSubTotals: true, - axesDesign: {}, - fillArea: true, - showValues: false, - funnelColors: [], }; const definition = LineChart.getDefinitionFromContextCreation(context); expect(definition).toEqual({ diff --git a/tests/figures/chart/pie_chart_plugin.test.ts b/tests/figures/chart/pie_chart_plugin.test.ts index 36f3d2a532..ff2f8b13b8 100644 --- a/tests/figures/chart/pie_chart_plugin.test.ts +++ b/tests/figures/chart/pie_chart_plugin.test.ts @@ -1,29 +1,17 @@ import { ChartCreationContext } from "../../../src"; import { PieChart } from "../../../src/helpers/figures/charts"; import { createChart } from "../../test_helpers"; -import { getChartLegendLabels } from "../../test_helpers/chart_helpers"; +import { + GENERAL_CHART_CREATION_CONTEXT, + getChartLegendLabels, +} from "../../test_helpers/chart_helpers"; import { createModelFromGrid } from "../../test_helpers/helpers"; describe("pie chart", () => { test("create pie chart from creation context", () => { const context: Required = { - background: "#123456", - title: { text: "hello there" }, + ...GENERAL_CHART_CREATION_CONTEXT, range: [{ dataRange: "Sheet1!B1:B4", yAxisId: "y1" }], - auxiliaryRange: "Sheet1!A1:A4", - legendPosition: "bottom", - cumulative: true, - labelsAsText: true, - dataSetsHaveTitle: true, - aggregated: true, - stacked: true, - firstValueAsSubtotal: true, - showConnectorLines: false, - showSubTotals: true, - axesDesign: {}, - fillArea: true, - showValues: false, - funnelColors: [], }; const definition = PieChart.getDefinitionFromContextCreation(context); expect(definition).toEqual({ diff --git a/tests/figures/chart/pyramid_chart/pyramid_chart_plugin.test.ts b/tests/figures/chart/pyramid_chart/pyramid_chart_plugin.test.ts index e625eea3f8..4bf5ae0f3d 100644 --- a/tests/figures/chart/pyramid_chart/pyramid_chart_plugin.test.ts +++ b/tests/figures/chart/pyramid_chart/pyramid_chart_plugin.test.ts @@ -1,30 +1,18 @@ import { ChartCreationContext, ChartJSRuntime, Model } from "../../../../src"; import { PyramidChart } from "../../../../src/helpers/figures/charts/pyramid_chart"; import { PyramidChartDefinition } from "../../../../src/types/chart/pyramid_chart"; -import { getChartTooltipValues } from "../../../test_helpers/chart_helpers"; +import { + GENERAL_CHART_CREATION_CONTEXT, + getChartTooltipValues, +} from "../../../test_helpers/chart_helpers"; import { createChart, setCellContent, setFormat } from "../../../test_helpers/commands_helpers"; let model: Model; describe("population pyramid chart", () => { test("create bar chart from creation context", () => { const context: Required = { - background: "#123456", - title: { text: "hello there" }, + ...GENERAL_CHART_CREATION_CONTEXT, range: [{ dataRange: "Sheet1!B1:B4", yAxisId: "y1" }], - auxiliaryRange: "Sheet1!A1:A4", - legendPosition: "bottom", - cumulative: true, - labelsAsText: true, - dataSetsHaveTitle: true, - aggregated: true, - stacked: false, - firstValueAsSubtotal: true, - showConnectorLines: false, - showSubTotals: true, - axesDesign: {}, - fillArea: true, - showValues: false, - funnelColors: [], }; const definition = PyramidChart.getDefinitionFromContextCreation(context); expect(definition).toEqual({ diff --git a/tests/figures/chart/radar_chart_plugin.test.ts b/tests/figures/chart/radar_chart_plugin.test.ts index b8e89a451f..38aa32adc6 100644 --- a/tests/figures/chart/radar_chart_plugin.test.ts +++ b/tests/figures/chart/radar_chart_plugin.test.ts @@ -1,7 +1,11 @@ import { ChartCreationContext, Model } from "../../../src"; import { RadarChart } from "../../../src/helpers/figures/charts/radar_chart"; import { RadarChartRuntime } from "../../../src/types/chart/radar_chart"; -import { getChartLegendLabels, getChartTooltipValues } from "../../test_helpers/chart_helpers"; +import { + GENERAL_CHART_CREATION_CONTEXT, + getChartLegendLabels, + getChartTooltipValues, +} from "../../test_helpers/chart_helpers"; import { createChart, createRadarChart, @@ -14,21 +18,8 @@ import { createModelFromGrid } from "../../test_helpers/helpers"; describe("radar chart", () => { test("create radar chart from creation context", () => { const context: Required = { - background: "#123456", - title: { text: "hello there" }, + ...GENERAL_CHART_CREATION_CONTEXT, range: [{ dataRange: "Sheet1!B1:B4", yAxisId: "y1" }], - auxiliaryRange: "Sheet1!A1:A4", - legendPosition: "bottom", - cumulative: true, - labelsAsText: true, - dataSetsHaveTitle: true, - aggregated: true, - stacked: true, - firstValueAsSubtotal: true, - showConnectorLines: false, - showSubTotals: true, - axesDesign: {}, - fillArea: true, showValues: true, funnelColors: [], }; diff --git a/tests/figures/chart/scorecard/scorecard_chart_plugin.test.ts b/tests/figures/chart/scorecard/scorecard_chart_plugin.test.ts index 8ab9d646cb..a9ca0069b1 100644 --- a/tests/figures/chart/scorecard/scorecard_chart_plugin.test.ts +++ b/tests/figures/chart/scorecard/scorecard_chart_plugin.test.ts @@ -10,6 +10,7 @@ import { ScorecardChartDefinition, ScorecardChartRuntime, } from "../../../../src/types/chart/scorecard_chart"; +import { GENERAL_CHART_CREATION_CONTEXT } from "../../../test_helpers/chart_helpers"; import { addColumns, createScorecardChart, @@ -80,25 +81,7 @@ describe("datasource tests", function () { }); test("create scorecard from creation context", () => { - const context: Required = { - background: "#123456", - title: { text: "hello there" }, - range: [{ dataRange: "Sheet1!B1:B4", yAxisId: "y1" }], - auxiliaryRange: "Sheet1!A1:A4", - legendPosition: "bottom", - cumulative: true, - labelsAsText: true, - dataSetsHaveTitle: true, - aggregated: true, - stacked: true, - firstValueAsSubtotal: true, - showConnectorLines: false, - showSubTotals: true, - axesDesign: {}, - fillArea: true, - showValues: false, - funnelColors: [], - }; + const context: Required = GENERAL_CHART_CREATION_CONTEXT; const definition = ScorecardChart.getDefinitionFromContextCreation(context); expect(definition).toEqual({ type: "scorecard", diff --git a/tests/figures/chart/sunburst/sunburst_chart_plugin.test.ts b/tests/figures/chart/sunburst/sunburst_chart_plugin.test.ts new file mode 100644 index 0000000000..4730fd185c --- /dev/null +++ b/tests/figures/chart/sunburst/sunburst_chart_plugin.test.ts @@ -0,0 +1,431 @@ +import { Model, UID } from "../../../../src"; +import { COLOR_TRANSPARENT } from "../../../../src/constants"; +import { ColorGenerator } from "../../../../src/helpers"; +import { GHOST_SUNBURST_VALUE } from "../../../../src/helpers/figures/charts/runtime"; +import { SunburstChart } from "../../../../src/helpers/figures/charts/sunburst_chart"; +import { + ChartCreationContext, + SunburstChartJSDataset, + SunburstChartRawData, + SunburstChartRuntime, +} from "../../../../src/types/chart"; +import { GENERAL_CHART_CREATION_CONTEXT } from "../../../test_helpers/chart_helpers"; +import { + createSunburstChart, + setCellContent, + setFormat, +} from "../../../test_helpers/commands_helpers"; +import { setGrid } from "../../../test_helpers/helpers"; + +let model: Model; + +function getSunburstRuntime(chartId: UID): SunburstChartRuntime { + return model.getters.getChartRuntime(chartId) as SunburstChartRuntime; +} + +function toChartJSCtx(data: SunburstChartRawData) { + return { type: "data", raw: data }; +} + +// prettier-ignore +const SUNBURST_DATASET = { + A2: "Q1", B2: "January", C2: "", D2: "10", + A3: "Q1", B3: "February", C3: "", D3: "20", + A4: "Q1", B4: "March", C4: "W1", D4: "30", + A5: "Q1", B5: "March", C5: "W2", D5: "40", + A6: "Q1", B6: "March", C6: "W3", D6: "50", + A7: "Q2", B7: "April", C7: "", D7: "60", + A8: "Q2", B8: "May", C8: "", D8: "70", + A9: "Q2", B9: "June", C9: "", D9: "80", + A10: "Q3", B10: "", C10: "", D10: "200", +}; + +describe("Sunburst chart chart", () => { + beforeEach(() => { + model = new Model(); + }); + + test("Can create a sunburst chart from a creation context", () => { + const context: Required = { + ...GENERAL_CHART_CREATION_CONTEXT, + showLabels: true, + showValues: true, + valuesDesign: { italic: true }, + groupColors: ["#123456", "#654321"], + }; + expect(SunburstChart.getDefinitionFromContextCreation(context)).toEqual({ + type: "sunburst", + background: "#123456", + title: { text: "hello there" }, + dataSets: [{ dataRange: "Sheet1!A1:A4" }], + labelRange: "Sheet1!B1:B4", + legendPosition: "bottom", + dataSetsHaveTitle: true, + showValues: true, + showLabels: true, + valuesDesign: { italic: true }, + groupColors: ["#123456", "#654321"], + }); + }); + + test("Labels and datasets are swapped from the creation context", () => { + // In SunburstChart, the labels are the values (numbers) and the datasets are the categories (strings). This is the inverse + // of the usual chart structure. + const context: Required = { + ...GENERAL_CHART_CREATION_CONTEXT, + range: [{ dataRange: "Sheet1!B1:B4" }, { dataRange: "Sheet1!C1:C4" }], + auxiliaryRange: "Sheet1!A1:A4", + }; + const definition = SunburstChart.getDefinitionFromContextCreation(context); + expect(definition).toMatchObject({ + dataSets: [{ dataRange: "Sheet1!A1:A4" }], + labelRange: "Sheet1!B1:B4", + }); + const chart = new SunburstChart(definition, "Sheet1", model.getters); + expect(chart.getContextCreation()).toMatchObject({ + range: [{ dataRange: "Sheet1!B1:B4" }], + auxiliaryRange: "A1:A4", + }); + }); + + test("Simple single-level sunburst", () => { + // prettier-ignore + setGrid(model, { + A2: "Group1", B2: "10", + A3: "Group1", B3: "40", + A4: "Group2", B4: "30", + }) + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:A4" }], + labelRange: "B1:B4", + }); + + const config = getSunburstRuntime(chartId).chartJsConfig; + + expect(config).toMatchObject({ + type: "doughnut", + options: { cutout: "25%" }, + }); + expect(config.data.datasets).toHaveLength(1); + expect(config.data.datasets[0].parsing).toEqual({ key: "value" }); // read value from "value" key in data objects + expect(config.data.datasets[0].data).toMatchObject([ + { value: 50, label: "Group1", groups: ["Group1"] }, + { value: 30, label: "Group2", groups: ["Group2"] }, + ]); + }); + + test("Sunburst data is sorted", () => { + // prettier-ignore + setGrid(model, { + A2: "Group1", B2: "10", + A3: "Group2", B3: "30", + }); + + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:A3" }], + labelRange: "B1:B3", + }); + + const config = getSunburstRuntime(chartId).chartJsConfig; + expect(config.data.datasets).toHaveLength(1); + expect(config.data.datasets[0].data).toMatchObject([ + { value: 30, label: "Group2", groups: ["Group2"] }, + { value: 10, label: "Group1", groups: ["Group1"] }, + ]); + }); + + test("Multi-level sunburst", () => { + setGrid(model, SUNBURST_DATASET); + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:C10" }], + labelRange: "D1:D10", + }); + + const config = getSunburstRuntime(chartId).chartJsConfig; + expect(config.data.datasets).toHaveLength(3); + expect(config.data.datasets[2].data).toMatchObject([ + { value: 210, label: "Q2", groups: ["Q2"] }, + { value: 200, label: "Q3", groups: ["Q3"] }, + { value: 150, label: "Q1", groups: ["Q1"] }, + ]); + expect(config.data.datasets[1].data).toMatchObject([ + { value: 80, label: "June", groups: ["Q2", "June"] }, + { value: 70, label: "May", groups: ["Q2", "May"] }, + { value: 60, label: "April", groups: ["Q2", "April"] }, + { value: 200, label: GHOST_SUNBURST_VALUE }, // Q3 placeholder + { value: 120, label: "March", groups: ["Q1", "March"] }, + { value: 20, label: "February", groups: ["Q1", "February"] }, + { value: 10, label: "January", groups: ["Q1", "January"] }, + ]); + expect(config.data.datasets[0].data).toMatchObject([ + { value: 80, label: GHOST_SUNBURST_VALUE }, // June placeholder + { value: 70, label: GHOST_SUNBURST_VALUE }, // May placeholder + { value: 60, label: GHOST_SUNBURST_VALUE }, // April placeholder + { value: 200, label: GHOST_SUNBURST_VALUE }, // Q3 placeholder + { value: 50, label: "W3", groups: ["Q1", "March", "W3"] }, + { value: 40, label: "W2", groups: ["Q1", "March", "W2"] }, + { value: 30, label: "W1", groups: ["Q1", "March", "W1"] }, + { value: 20, label: GHOST_SUNBURST_VALUE }, // February placeholder + { value: 10, label: GHOST_SUNBURST_VALUE }, // January placeholder + ]); + }); + + test("Can define groups in a tree-like structure", () => { + // prettier-ignore + const grid = { + A2: "Q1", B2: "January", C2: "W1", D2: "10", + A5: "", B5: "", C5: "W2", D5: "20", + A6: "", B6: "February", C6: "W1", D6: "30", + A7: "", B7: "", C7: "W2", D7: "40", + A8: "Q2", B8: "April", C8: "W1", D8: "50", + A9: "", B9: "", C9: "W2", D9: "60", + }; + setGrid(model, grid); + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:C10" }], + labelRange: "D1:D10", + }); + + const config = getSunburstRuntime(chartId).chartJsConfig; + expect(config.data.datasets).toHaveLength(3); + expect(config.data.datasets[2].data).toMatchObject([ + { value: 110, label: "Q2", groups: ["Q2"] }, + { value: 100, label: "Q1", groups: ["Q1"] }, + ]); + expect(config.data.datasets[1].data).toMatchObject([ + { value: 110, label: "April", groups: ["Q2", "April"] }, + { value: 70, label: "February", groups: ["Q1", "February"] }, + { value: 30, label: "January", groups: ["Q1", "January"] }, + ]); + expect(config.data.datasets[0].data).toMatchObject([ + { value: 60, label: "W2", groups: ["Q2", "April", "W2"] }, + { value: 50, label: "W1", groups: ["Q2", "April", "W1"] }, + { value: 40, label: "W2", groups: ["Q1", "February", "W2"] }, + { value: 30, label: "W1", groups: ["Q1", "February", "W1"] }, + { value: 20, label: "W2", groups: ["Q1", "January", "W2"] }, + { value: 10, label: "W1", groups: ["Q1", "January", "W1"] }, + ]); + }); + + test("Invalid points are ignored", () => { + // prettier-ignore + const grid = { + A1: "", B1: "RandomMonth", C1: "W1", D1: "10", // No root group + A2: "Q1", B2: "January", C2: "W1", D2: "NotANumber", // Invalid value + A5: "Q2", B5: "", C5: "W2", D5: "20", // Week is defined but bit the month + A6: "Q3", B6: "September", C6: "W1", D6: "30", // Valid + }; + setGrid(model, grid); + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:C10" }], + labelRange: "D1:D10", + }); + + const config = getSunburstRuntime(chartId).chartJsConfig; + expect(config.data.datasets).toHaveLength(3); + expect(config.data.datasets[2].data).toMatchObject([ + { value: 30, label: "Q3", groups: ["Q3"] }, + ]); + expect(config.data.datasets[1].data).toMatchObject([ + { value: 30, label: "September", groups: ["Q3", "September"] }, + ]); + expect(config.data.datasets[0].data).toMatchObject([ + { value: 30, label: "W1", groups: ["Q3", "September", "W1"] }, + ]); + }); + + test("Cannot mix positive and negative values", () => { + // prettier-ignore + const grid = { + A2: "G1", B2: "20", + A3: "G2", B3: "-10", + }; + setGrid(model, grid); + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:A3" }], + labelRange: "B1:B3", + }); + + let config = getSunburstRuntime(chartId).chartJsConfig; + expect(config.data.datasets[0].data).toHaveLength(1); + expect(config.data.datasets[0].data).toMatchObject([{ value: 20, label: "G1" }]); + + setCellContent(model, "B2", "-20"); + config = getSunburstRuntime(chartId).chartJsConfig; + expect(config.data.datasets[0].data).toHaveLength(2); + expect(config.data.datasets[0].data).toMatchObject([ + { value: -10, label: "G2" }, + { value: -20, label: "G1" }, + ]); + }); + + test("Empty hierarchical levels are dropped", () => { + setGrid(model, { B2: "Group1", B3: "Group2", D2: "10", D3: "25" }); + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:A3" }, { dataRange: "B1:B3" }, { dataRange: "C1:C3" }], + labelRange: "D1:D3", + }); + const config = getSunburstRuntime(chartId).chartJsConfig; + expect(config.data.datasets).toHaveLength(1); + expect(config.data.datasets[0].data).toMatchObject([ + { value: 25, label: "Group2", groups: ["Group2"] }, + { value: 10, label: "Group1", groups: ["Group1"] }, + ]); + }); + + test("Sunburst items background color", () => { + setGrid(model, SUNBURST_DATASET); + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:C10" }], + labelRange: "D1:D10", + dataSetsHaveTitle: false, + groupColors: ["#FF0000", undefined, "#0000FF"], + }); + const config = getSunburstRuntime(chartId).chartJsConfig; + + const colorGenerator = new ColorGenerator(3); + colorGenerator.next(); + const secondColor = colorGenerator.next(); + + const datasets = config.data.datasets as SunburstChartJSDataset[]; + expect(datasets.length).toBe(3); + + const getBackgroundColor = (dataset: any, groups: string[]) => + dataset.backgroundColor?.( + toChartJSCtx({ value: 10, label: groups[groups.length - 1], groups }) + ); + + for (const dataset of datasets) { + expect(dataset.groupColors).toEqual([ + { color: "#FF0000", label: "Q2" }, + { color: secondColor, label: "Q3" }, + { color: "#0000FF", label: "Q1" }, + ]); + expect(getBackgroundColor(dataset, ["Q3"])).toBe(secondColor); + expect(getBackgroundColor(dataset, ["Q2", "May"])).toBe("#FF0000"); + expect(getBackgroundColor(dataset, ["Q1", "March", "W2"])).toBe("#0000FF"); + } + }); + + test("Sunburst ghost items do not have a background/border", () => { + setGrid(model, { B2: "Group1", C2: "SubGroup1", D2: "10" }); + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "B1:C2" }], + labelRange: "D1:D2", + }); + + const datasets = getSunburstRuntime(chartId).chartJsConfig.data.datasets as any; + + const label = GHOST_SUNBURST_VALUE; + const rootData: SunburstChartRawData = { value: 10, label, groups: ["Group1"] }; + expect(datasets[1].backgroundColor(toChartJSCtx(rootData))).toBeSameColorAs(COLOR_TRANSPARENT); + expect(datasets[1].borderColor(toChartJSCtx(rootData))).toBeSameColorAs(COLOR_TRANSPARENT); + + const subData: SunburstChartRawData = { value: 10, label, groups: ["Group1", "SubGroup1"] }; + expect(datasets[0].backgroundColor(toChartJSCtx(subData))).toBeSameColorAs(COLOR_TRANSPARENT); + expect(datasets[0].borderColor(toChartJSCtx(subData))).toBeSameColorAs(COLOR_TRANSPARENT); + }); + + test("Sunburst chart tooltip", () => { + setGrid(model, { A2: "Group1", B2: "10" }); + setFormat(model, "B2", "0.0$"); + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:A2" }], + labelRange: "B1:B2", + }); + + const tooltip = getSunburstRuntime(chartId).chartJsConfig.options?.plugins?.tooltip as any; + expect(tooltip).toMatchObject({ enabled: false, external: expect.any(Function) }); + + const data: SunburstChartRawData = { value: 10, label: "Group1", groups: ["Group1"] }; + expect(tooltip?.callbacks?.title?.([toChartJSCtx(data)])).toBe(""); + expect(tooltip?.callbacks?.beforeLabel?.(toChartJSCtx(data))).toBe("Group1"); + expect(tooltip?.callbacks?.label?.(toChartJSCtx(data))).toBe("10.0$"); + + const groupData: SunburstChartRawData = { value: 10, label: "W1", groups: ["Q1", "May", "W2"] }; + expect(tooltip?.callbacks?.title?.([toChartJSCtx(groupData)])).toBe(""); + expect(tooltip?.callbacks?.beforeLabel?.(toChartJSCtx(groupData))).toBe("Q1 / May / W2"); + expect(tooltip?.callbacks?.label?.(toChartJSCtx(groupData))).toBe("10.0$"); + }); + + test("Ghost sunburst values do not have a tooltip", () => { + setGrid(model, { A2: "Group1", B2: "10" }); + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:A2" }], + labelRange: "B1:B2", + }); + + const tooltip = getSunburstRuntime(chartId).chartJsConfig.options?.plugins?.tooltip as any; + const ghostData: SunburstChartRawData = { value: 10, label: GHOST_SUNBURST_VALUE, groups: [] }; + const data: SunburstChartRawData = { value: 10, label: "Group1", groups: ["Group1"] }; + + expect(tooltip?.filter?.(toChartJSCtx(ghostData))).toBe(false); + expect(tooltip?.filter?.(toChartJSCtx(data))).toBe(true); + }); + + test("Sunburst chart legend", () => { + setGrid(model, SUNBURST_DATASET); + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:C10" }], + labelRange: "D1:D10", + groupColors: ["#FF0000", "#00FF00", "#0000FF"], + }); + + const config = getSunburstRuntime(chartId).chartJsConfig; + const legend = config.options?.plugins?.legend; + expect(legend).toMatchObject({ + display: true, + labels: { + usePointStyle: true, + generateLabels: expect.any(Function), + }, + }); + + expect(legend?.labels?.generateLabels?.(config as any)).toMatchObject([ + { text: "Q2", fillStyle: "#FF0000", strokeStyle: "#FF0000" }, + { text: "Q3", fillStyle: "#00FF00", strokeStyle: "#00FF00" }, + { text: "Q1", fillStyle: "#0000FF", strokeStyle: "#0000FF" }, + ]); + }); + + test("Legend labels are truncated", () => { + setGrid(model, { A2: "GroupWithAVeryVeryVeryVeryLongLabel", B2: "10" }); + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:A2" }], + labelRange: "B1:B2", + }); + + const config = getSunburstRuntime(chartId).chartJsConfig; + expect(config.options?.plugins?.legend?.labels?.generateLabels?.(config as any)).toMatchObject([ + { text: "GroupWithAVeryVeryVe…" }, + ]); + }); + + test("Sunburst show value plugin arguments", () => { + setGrid(model, { A2: "Group1", B2: "10" }); + setFormat(model, "B2", '0 "( •⩊• )"'); + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:A2" }], + labelRange: "B1:B2", + showLabels: true, + showValues: true, + valuesDesign: { fontSize: 12, bold: true, italic: true, color: "#FF0000" }, + }); + + const config = getSunburstRuntime(chartId).chartJsConfig; + expect(config.options?.plugins?.sunburstLabelsPlugin).toMatchObject({ + showLabels: true, + showValues: true, + style: { fontSize: 12, bold: true, italic: true, textColor: "#FF0000" }, + }); + expect(config.options?.plugins?.sunburstLabelsPlugin?.callback?.(10, "y")).toBe("10 ( •⩊• )"); + }); + + test("Sunburst hover plugin is enabled", () => { + const chartId = createSunburstChart(model); + const config = getSunburstRuntime(chartId).chartJsConfig; + expect(config.options?.plugins?.sunburstHoverPlugin).toMatchObject({ + enabled: true, + }); + }); +}); diff --git a/tests/figures/chart/sunburst/sunburst_panel_component.test.ts b/tests/figures/chart/sunburst/sunburst_panel_component.test.ts new file mode 100644 index 0000000000..0924436f7f --- /dev/null +++ b/tests/figures/chart/sunburst/sunburst_panel_component.test.ts @@ -0,0 +1,175 @@ +import { Model, SpreadsheetChildEnv, UID } from "../../../../src"; +import { SidePanel } from "../../../../src/components/side_panel/side_panel/side_panel"; +import { ColorGenerator } from "../../../../src/helpers"; +import { SunburstChartDefinition } from "../../../../src/types/chart"; +import { + changeColorPickerWidgetColor, + changeRoundColorPickerColor, + createSunburstChart, + getColorPickerWidgetColor, + getHTMLCheckboxValue, + getHTMLInputValue, + getRoundColorPickerValue, + setInputValueAndTrigger, + simulateClick, +} from "../../../test_helpers"; +import { + openChartConfigSidePanel, + openChartDesignSidePanel, +} from "../../../test_helpers/chart_helpers"; +import { mountComponentWithPortalTarget, setGrid } from "../../../test_helpers/helpers"; + +let model: Model; +let fixture: HTMLElement; +let env: SpreadsheetChildEnv; + +function getSunburstDefinition(chartId: UID): SunburstChartDefinition { + return model.getters.getChartDefinition(chartId) as SunburstChartDefinition; +} + +describe("Sunburst chart side panel", () => { + beforeEach(async () => { + model = new Model(); + ({ fixture, env } = await mountComponentWithPortalTarget(SidePanel, { model })); + }); + + describe("Config panel", () => { + test("Sunburst config panel is correctly initialized", async () => { + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:A3" }], + labelRange: "B1:B3", + dataSetsHaveTitle: true, + }); + await openChartConfigSidePanel(model, env, chartId); + + expect(getHTMLInputValue(".o-data-series input")).toEqual("A1:A3"); + expect(getHTMLInputValue(".o-data-labels input")).toEqual("B1:B3"); + expect(getHTMLCheckboxValue('input[name="dataSetsHaveTitle"]')).toBe(true); + }); + + test("Can change chart values in config side panel", async () => { + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:A3" }], + labelRange: "B1:B3", + dataSetsHaveTitle: true, + }); + await openChartConfigSidePanel(model, env, chartId); + + await setInputValueAndTrigger(".o-data-labels input", "C1:C3"); + await simulateClick(".o-data-labels .o-selection-ok"); + expect(getSunburstDefinition(chartId)?.labelRange).toEqual("C1:C3"); + + await setInputValueAndTrigger(".o-data-series input", "B1:B3"); + await simulateClick(".o-data-series .o-selection-ok"); + expect(getSunburstDefinition(chartId)?.dataSets).toEqual([{ dataRange: "B1:B3" }]); + + await simulateClick('input[name="dataSetsHaveTitle"]'); + expect(getSunburstDefinition(chartId)?.dataSetsHaveTitle).toEqual(false); + }); + }); + + describe("Design panel", () => { + test("Sunburst design panel is correctly initialized", async () => { + const chartId = createSunburstChart(model, { + title: { text: "My Sunburst chart" }, + legendPosition: "bottom", + background: "#00FF00", + showLabels: true, + showValues: false, + valuesDesign: { bold: false, italic: true, fontSize: 15 }, + }); + await openChartDesignSidePanel(model, env, fixture, chartId); + + expect(".o-chart-title input").toHaveValue("My Sunburst chart"); + expect(".o-chart-legend-position").toHaveValue("bottom"); + expect(getRoundColorPickerValue(".o-chart-background-color")).toEqual("#00FF00"); + + expect('input[name="showLabels"]').toHaveValue(true); + expect('input[name="showValues"]').toHaveValue(false); + expect('.o-values-style [title="Bold"]').not.toHaveClass("active"); + expect('.o-values-style [title="Italic"]').toHaveClass("active"); + expect('.o-values-style input[type="number"]').toHaveValue("15"); + }); + + test("Can change basic chart options", async () => { + const chartId = createSunburstChart(model, {}); + await openChartDesignSidePanel(model, env, fixture, chartId); + + await setInputValueAndTrigger(".o-chart-title input", "My Sunburst Title"); + await setInputValueAndTrigger(".o-chart-legend-position", "left"); + await changeRoundColorPickerColor(".o-chart-background-color", "#000000"); + + const definition = getSunburstDefinition(chartId); + + expect(definition.title.text).toEqual("My Sunburst Title"); + expect(definition.legendPosition).toEqual("left"); + expect(definition.background).toEqual("#000000"); + }); + + test("Can display or not the labels/values", async () => { + const chartId = createSunburstChart(model, {}); + await openChartDesignSidePanel(model, env, fixture, chartId); + + expect('input[name="showLabels"]').toHaveValue(true); + expect('input[name="showValues"]').toHaveValue(false); + expect(".o-values-style").toHaveCount(1); + + await simulateClick('input[name="showLabels"]'); + + expect('input[name="showLabels"]').toHaveValue(false); + expect('input[name="showValues"]').toHaveValue(false); + expect(".o-values-style").toHaveCount(0); + expect(getSunburstDefinition(chartId).showLabels).toEqual(false); + + await simulateClick('input[name="showValues"]'); + + expect('input[name="showLabels"]').toHaveValue(false); + expect('input[name="showValues"]').toHaveValue(true); + expect(".o-values-style").toHaveCount(1); + expect(getSunburstDefinition(chartId).showValues).toEqual(true); + }); + + test("Can change Sunburst label style", async () => { + const chartId = createSunburstChart(model); + await openChartDesignSidePanel(model, env, fixture, chartId); + + expect('.o-values-style [title="Bold"]').not.toHaveClass("active"); + await simulateClick('.o-values-style [title="Bold"]'); + expect('.o-values-style [title="Bold"]').toHaveClass("active"); + expect(getSunburstDefinition(chartId).valuesDesign?.bold).toEqual(true); + + expect('.o-values-style [title="Italic"]').not.toHaveClass("active"); + await simulateClick('.o-values-style [title="Italic"]'); + expect('.o-values-style [title="Italic"]').toHaveClass("active"); + expect(getSunburstDefinition(chartId).valuesDesign?.italic).toEqual(true); + + expect('.o-values-style input[type="number"]').toHaveValue("13"); + await setInputValueAndTrigger(".o-values-style input[type='number']", "20"); + expect(getSunburstDefinition(chartId).valuesDesign?.fontSize).toEqual(20); + + await changeColorPickerWidgetColor(".o-values-style", "Text color", "#FF0000"); + expect(getColorPickerWidgetColor(".o-values-style", "Text color")).toEqual("#FF0000"); + expect(getSunburstDefinition(chartId).valuesDesign?.color).toEqual("#FF0000"); + }); + + test("Can change sunburst colors", async () => { + setGrid(model, { A2: "G1", A3: "G2", B2: "30", B3: "20" }); + const chartId = createSunburstChart(model, { + dataSets: [{ dataRange: "A1:A3" }], + labelRange: "B1:B3", + groupColors: [undefined, "#00FF00"], + }); + await openChartDesignSidePanel(model, env, fixture, chartId); + + expect(".o-sunburst-colors .o-round-color-picker-button").toHaveCount(2); + const colorGenerator = new ColorGenerator(2); + expect(getRoundColorPickerValue("[data-id='G1'] ")).toBeSameColorAs(colorGenerator.next()); + expect(getRoundColorPickerValue("[data-id='G2'] ")).toBeSameColorAs("#00FF00"); + + await changeRoundColorPickerColor("[data-id='G1'] ", "#FF0000"); + expect(getSunburstDefinition(chartId)?.groupColors).toEqual(["#FF0000", "#00FF00"]); + expect(getRoundColorPickerValue("[data-id='G1'] ")).toBeSameColorAs("#FF0000"); + expect(getRoundColorPickerValue("[data-id='G2'] ")).toBeSameColorAs("#00FF00"); + }); + }); +}); diff --git a/tests/figures/chart/waterfall/waterfall_chart_plugin.test.ts b/tests/figures/chart/waterfall/waterfall_chart_plugin.test.ts index 3019ae8fb2..9857ac98cd 100644 --- a/tests/figures/chart/waterfall/waterfall_chart_plugin.test.ts +++ b/tests/figures/chart/waterfall/waterfall_chart_plugin.test.ts @@ -12,7 +12,10 @@ import { setFormat, updateChart, } from "../../../test_helpers"; -import { getChartTooltipValues } from "../../../test_helpers/chart_helpers"; +import { + GENERAL_CHART_CREATION_CONTEXT, + getChartTooltipValues, +} from "../../../test_helpers/chart_helpers"; let model: Model; @@ -298,23 +301,8 @@ describe("Waterfall chart", () => { test("create waterfall chart from creation context", () => { const context: Required = { - background: "#123456", - title: { text: "hello there" }, + ...GENERAL_CHART_CREATION_CONTEXT, range: [{ dataRange: "Sheet1!B1:B4", yAxisId: "y1" }], - auxiliaryRange: "Sheet1!A1:A4", - legendPosition: "bottom", - cumulative: true, - labelsAsText: true, - dataSetsHaveTitle: true, - aggregated: true, - stacked: true, - firstValueAsSubtotal: true, - showConnectorLines: false, - showSubTotals: true, - axesDesign: {}, - fillArea: true, - showValues: false, - funnelColors: [], }; const definition = WaterfallChart.getDefinitionFromContextCreation(context); expect(definition).toEqual({ diff --git a/tests/side_panels/building_blocks/__snapshots__/title.test.ts.snap b/tests/side_panels/building_blocks/__snapshots__/chart_title.test.ts.snap similarity index 76% rename from tests/side_panels/building_blocks/__snapshots__/title.test.ts.snap rename to tests/side_panels/building_blocks/__snapshots__/chart_title.test.ts.snap index 23e7dff83d..fd94c2d2a1 100644 --- a/tests/side_panels/building_blocks/__snapshots__/title.test.ts.snap +++ b/tests/side_panels/building_blocks/__snapshots__/chart_title.test.ts.snap @@ -28,7 +28,9 @@ exports[`Chart title Can render a chart title component 1`] = ` class="o-menu-item-button o-hoverable-button" title="Bold" > - + + + + - + + + +
- - - + + + + + + - - - +
-
- -
- + + +
+
+
diff --git a/tests/side_panels/building_blocks/chart_title.test.ts b/tests/side_panels/building_blocks/chart_title.test.ts new file mode 100644 index 0000000000..02d0dce0a3 --- /dev/null +++ b/tests/side_panels/building_blocks/chart_title.test.ts @@ -0,0 +1,139 @@ +import { ChartTitle } from "../../../src/components/side_panel/chart/building_blocks/chart_title/chart_title"; +import { TextStyler } from "../../../src/components/side_panel/chart/building_blocks/text_styler/text_styler"; +import { click, setInputValueAndTrigger } from "../../test_helpers/dom_helper"; +import { mountComponentWithPortalTarget } from "../../test_helpers/helpers"; + +let fixture: HTMLElement; + +async function mountChartTitle(props: Partial) { + const defaultProps: ChartTitle["props"] = { + title: "My title", + updateTitle: () => {}, + updateStyle: () => {}, + style: {}, + defaultStyle: { fontSize: 10 }, + }; + ({ fixture } = await mountComponentWithPortalTarget(ChartTitle, { + props: { ...defaultProps, ...props }, + })); +} + +async function mountTextStyler(props: Partial) { + const defaultProps: TextStyler["props"] = { + updateStyle: () => {}, + style: {}, + defaultStyle: { fontSize: 10 }, + }; + ({ fixture } = await mountComponentWithPortalTarget(TextStyler, { + props: { ...defaultProps, ...props }, + })); +} + +describe("Chart title", () => { + test("Can render a chart title component", async () => { + await mountChartTitle({}); + expect(fixture).toMatchSnapshot(); + }); + + test("Can render a chart title component with default title prop if not provided", async () => { + await mountChartTitle({ + title: undefined, + }); + + const input = fixture.querySelector("input")!; + expect(input.value).toBe(""); + }); + + test("Update is called when title is changed, not on input", async () => { + const updateTitle = jest.fn(); + await mountChartTitle({ updateTitle }); + const input = fixture.querySelector("input")!; + expect(input.value).toBe("My title"); + await setInputValueAndTrigger(input, "My new title", "onlyInput"); + expect(updateTitle).toHaveBeenCalledTimes(0); + input.dispatchEvent(new Event("change")); + expect(updateTitle).toHaveBeenCalledTimes(1); + }); + + test("Can change text color", async () => { + const updateStyle = jest.fn(); + await mountChartTitle({ updateStyle }); + expect(updateStyle).toHaveBeenCalledTimes(0); + await click(fixture, ".o-color-picker-button[title='Text color']"); + await click(fixture, ".o-color-picker-line-item[data-color='#EFEFEF'"); + expect(updateStyle).toHaveBeenCalledWith({ color: "#EFEFEF" }); + }); + + test.each(["Left", "Center", "Right"])( + "Can change alignment to %s", + async (alignment: string) => { + const updateStyle = jest.fn(); + await mountChartTitle({ updateStyle }); + expect(updateStyle).toHaveBeenCalledTimes(0); + await click(fixture, ".o-menu-item-button[title='Horizontal alignment']"); + await click(fixture, `.o-menu-item-button[title='${alignment}']`); + expect(updateStyle).toHaveBeenCalledWith({ align: alignment.toLowerCase() }); + } + ); + + test("Can make text bold", async () => { + const updateStyle = jest.fn(); + await mountChartTitle({ updateStyle }); + expect(updateStyle).toHaveBeenCalledTimes(0); + await click(fixture, ".o-menu-item-button[title='Bold']"); + expect(updateStyle).toHaveBeenCalledWith({ bold: true }); + }); + + test("Can make text italic", async () => { + const updateStyle = jest.fn(); + await mountChartTitle({ updateStyle }); + expect(updateStyle).toHaveBeenCalledTimes(0); + await click(fixture, ".o-menu-item-button[title='Italic']"); + expect(updateStyle).toHaveBeenCalledWith({ italic: true }); + }); + + test("Can change font size", async () => { + const updateStyle = jest.fn(); + await mountChartTitle({ updateStyle }); + const fontSizeEditor = fixture.querySelector(".o-font-size-editor")!; + expect(fontSizeEditor).not.toBeNull(); + expect(updateStyle).toHaveBeenCalledTimes(0); + const input = fontSizeEditor.querySelector("input")!; + await setInputValueAndTrigger(input, "20"); + expect(updateStyle).toHaveBeenCalledWith({ fontSize: 20 }); + }); +}); + +describe("TextStyler", () => { + test("Alignments buttons are not visible by default", async () => { + await mountTextStyler({}); + + expect(".o-menu-item-button[title='Horizontal alignment']").toHaveCount(0); + expect(".o-menu-item-button[title='Vertical alignment']").toHaveCount(0); + }); + + test("Can show horizontal alignment buttons", async () => { + await mountTextStyler({ hasHorizontalAlign: true, hasVerticalAlign: true }); + + expect(".o-menu-item-button[title='Horizontal alignment']").toHaveCount(1); + expect(".o-menu-item-button[title='Vertical alignment']").toHaveCount(1); + }); + + test("Can change fill color", async () => { + const updateStyle = jest.fn(); + await mountTextStyler({ updateStyle, hasBackgroundColor: true }); + expect(updateStyle).toHaveBeenCalledTimes(0); + await click(fixture, ".o-color-picker-button[title='Fill color']"); + await click(fixture, ".o-color-picker-line-item[data-color='#EFEFEF'"); + expect(updateStyle).toHaveBeenCalledWith({ fillColor: "#EFEFEF" }); + }); + + test("Can change vertical alignment", async () => { + const updateStyle = jest.fn(); + await mountTextStyler({ updateStyle, hasVerticalAlign: true }); + expect(updateStyle).toHaveBeenCalledTimes(0); + await click(fixture, ".o-menu-item-button[title='Vertical alignment']"); + await click(fixture, `.o-menu-item-button[title='Middle']`); + expect(updateStyle).toHaveBeenCalledWith({ verticalAlign: "middle" }); + }); +}); diff --git a/tests/side_panels/building_blocks/title.test.ts b/tests/side_panels/building_blocks/title.test.ts deleted file mode 100644 index d789b3af70..0000000000 --- a/tests/side_panels/building_blocks/title.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { ChartTitle } from "../../../src/components/side_panel/chart/building_blocks/title/title"; -import { click, setInputValueAndTrigger } from "../../test_helpers/dom_helper"; -import { mountComponentWithPortalTarget } from "../../test_helpers/helpers"; - -let fixture: HTMLElement; - -async function mountChartTitle(props: ChartTitle["props"]) { - ({ fixture } = await mountComponentWithPortalTarget(ChartTitle, { props })); -} - -describe("Chart title", () => { - test("Can render a chart title component", async () => { - await mountChartTitle({ - title: "My title", - updateTitle: () => {}, - style: { fontSize: 22 }, - onFontSizeChanged: () => {}, - }); - expect(fixture).toMatchSnapshot(); - }); - - test("Can render a chart title component with default title prop if not provided", async () => { - await mountChartTitle({ - updateTitle: () => {}, - style: { fontSize: 22 }, - onFontSizeChanged: () => {}, - }); - - const input = fixture.querySelector("input")!; - expect(input.value).toBe(""); - }); - - test("Update is called when title is changed, not on input", async () => { - const updateTitle = jest.fn(); - await mountChartTitle({ - title: "My title", - updateTitle, - style: { fontSize: 22 }, - onFontSizeChanged: () => {}, - }); - const input = fixture.querySelector("input")!; - expect(input.value).toBe("My title"); - await setInputValueAndTrigger(input, "My new title", "onlyInput"); - expect(updateTitle).toHaveBeenCalledTimes(0); - input.dispatchEvent(new Event("change")); - expect(updateTitle).toHaveBeenCalledTimes(1); - }); - - test("UpdateColor is called when title color is changed", async () => { - const updateColor = jest.fn(); - await mountChartTitle({ - title: "My title", - updateTitle: () => {}, - style: { fontSize: 22 }, - updateColor, - onFontSizeChanged: () => {}, - }); - expect(updateColor).toHaveBeenCalledTimes(0); - await click(fixture, ".o-color-picker-button"); - await click(fixture, ".o-color-picker-line-item[data-color='#EFEFEF'"); - expect(updateColor).toHaveBeenCalledWith("#EFEFEF"); - }); - - test.each(["Left", "Center", "Right"])( - "UpdateAlignment is called when alignment is changed", - async (alignment: string) => { - const updateAlignment = jest.fn(); - await mountChartTitle({ - title: "My title", - updateTitle: () => {}, - style: { fontSize: 22 }, - updateAlignment, - onFontSizeChanged: () => {}, - }); - expect(updateAlignment).toHaveBeenCalledTimes(0); - await click(fixture, ".o-menu-item-button[title='Horizontal alignment']"); - await click(fixture, `.o-menu-item-button[title='${alignment}']`); - expect(updateAlignment).toHaveBeenCalledWith(alignment.toLowerCase()); - } - ); - - test("ToggleBold is called when clicking on bold button", async () => { - const toggleBold = jest.fn(); - await mountChartTitle({ - title: "My title", - updateTitle: () => {}, - style: { fontSize: 22 }, - toggleBold, - onFontSizeChanged: () => {}, - }); - expect(toggleBold).toHaveBeenCalledTimes(0); - await click(fixture, ".o-menu-item-button[title='Bold']"); - expect(toggleBold).toHaveBeenCalledTimes(1); - }); - - test("ToggleItalic is called when clicking on italic button", async () => { - const toggleItalic = jest.fn(); - await mountChartTitle({ - title: "My title", - updateTitle: () => {}, - style: { fontSize: 22 }, - toggleItalic, - onFontSizeChanged: () => {}, - }); - expect(toggleItalic).toHaveBeenCalledTimes(0); - await click(fixture, ".o-menu-item-button[title='Italic']"); - expect(toggleItalic).toHaveBeenCalledTimes(1); - }); - - test("OnFontSizeChanged is called when font size is changed", async () => { - const onFontSizeChanged = jest.fn(); - await mountChartTitle({ - title: "My title", - updateTitle: () => {}, - style: { fontSize: 14 }, - onFontSizeChanged, - }); - const fontSizeEditor = fixture.querySelector(".o-font-size-editor")!; - expect(fontSizeEditor).not.toBeNull(); - expect(onFontSizeChanged).toHaveBeenCalledTimes(0); - const input = fontSizeEditor.querySelector("input")!; - await setInputValueAndTrigger(input, "20"); - expect(onFontSizeChanged).toHaveBeenCalledWith(20); - }); -}); diff --git a/tests/test_helpers/chart_helpers.ts b/tests/test_helpers/chart_helpers.ts index fe26e77d40..43fcc90e06 100644 --- a/tests/test_helpers/chart_helpers.ts +++ b/tests/test_helpers/chart_helpers.ts @@ -1,5 +1,5 @@ import { TooltipItem } from "chart.js"; -import { ChartJSRuntime, Model, SpreadsheetChildEnv, UID } from "../../src"; +import { ChartCreationContext, ChartJSRuntime, Model, SpreadsheetChildEnv, UID } from "../../src"; import { range, toHex } from "../../src/helpers"; import { click, simulateClick } from "./dom_helper"; import { nextTick } from "./helpers"; @@ -95,3 +95,26 @@ export function getChartTooltipValues( beforeLabel: callbacks.beforeLabel(tooltipItem), }; } + +export const GENERAL_CHART_CREATION_CONTEXT: Required = { + background: "#123456", + title: { text: "hello there" }, + range: [{ dataRange: "Sheet1!B1:B4" }], + auxiliaryRange: "Sheet1!A1:A4", + legendPosition: "bottom", + cumulative: true, + labelsAsText: true, + dataSetsHaveTitle: true, + aggregated: true, + stacked: true, + firstValueAsSubtotal: true, + showConnectorLines: false, + showSubTotals: true, + axesDesign: {}, + fillArea: true, + showValues: false, + funnelColors: [], + showLabels: false, + valuesDesign: {}, + groupColors: [], +}; diff --git a/tests/test_helpers/commands_helpers.ts b/tests/test_helpers/commands_helpers.ts index 3db0bec825..bf12d1df46 100644 --- a/tests/test_helpers/commands_helpers.ts +++ b/tests/test_helpers/commands_helpers.ts @@ -33,6 +33,7 @@ import { } from "../../src/types"; import { target, toRangeData, toRangesData } from "./helpers"; +import { SunburstChartDefinition } from "../../src/types/chart"; import { ComboChartDefinition } from "../../src/types/chart/combo_chart"; import { FunnelChartDefinition } from "../../src/types/chart/funnel_chart"; import { GaugeChartDefinition } from "../../src/types/chart/gauge_chart"; @@ -252,6 +253,11 @@ export function createFunnelChart(model: Model, def?: Partial): UID { + createChart(model, { ...def, type: "sunburst" }); + return model.getters.getChartIds(model.getters.getActiveSheetId())[0]; +} + export function createScorecardChart( model: Model, data: Partial, diff --git a/tests/test_helpers/dom_helper.ts b/tests/test_helpers/dom_helper.ts index 779c075150..f9aee5737f 100644 --- a/tests/test_helpers/dom_helper.ts +++ b/tests/test_helpers/dom_helper.ts @@ -449,3 +449,44 @@ export function getHTMLRadioValue(target: DOMTarget): string { const radio = getTarget(target) as HTMLInputElement; return radio.querySelector("input:checked")!.value; } + +export function getRoundColorPickerValue(selector: string) { + const color = document + .querySelector(selector)! + .querySelector(".o-round-color-picker-button")?.style.background; + return toHex(color ?? ""); +} + +export async function changeRoundColorPickerColor(selector: string, color: string | undefined) { + const button = document + .querySelector(selector)! + .querySelector(".o-round-color-picker-button")!; + await click(button); + if (!color) { + await click(document.body, ".o-color-picker .o-cancel"); + } else { + await click(document.body, `.o-color-picker-line-item[data-color='${color}'`); + } +} + +export function getColorPickerWidgetColor(selector: string, widgetTitle: string) { + const el = document + .querySelector(selector)! + .querySelector(`.o-color-picker-button[title="${widgetTitle}"] span`); + + const color = el?.style.borderColor; + return toHex(color ?? ""); +} + +export async function changeColorPickerWidgetColor( + selector: string, + widgetTitle: string, + color: string +) { + const widgetButton = document + .querySelector(selector)! + .querySelector(`.o-color-picker-button[title="${widgetTitle}"]`)!; + + await click(widgetButton); + await click(document.querySelector(`.o-color-picker-line-item[data-color="${color}"]`)!); +}