diff --git a/packages/o-spreadsheet-engine/src/types/chart/pyramid_chart.ts b/packages/o-spreadsheet-engine/src/types/chart/pyramid_chart.ts index ed4a737099..8c8b61db5f 100644 --- a/packages/o-spreadsheet-engine/src/types/chart/pyramid_chart.ts +++ b/packages/o-spreadsheet-engine/src/types/chart/pyramid_chart.ts @@ -2,7 +2,7 @@ import { ChartConfiguration } from "chart.js"; import { Color } from "../misc"; import { BarChartDefinition } from "./bar_chart"; -export interface PyramidChartDefinition extends Omit { +export interface PyramidChartDefinition extends Omit { readonly type: "pyramid"; } diff --git a/src/components/figures/chart/chartJs/zoomable_chart/zoomable_chart_store.ts b/src/components/figures/chart/chartJs/zoomable_chart/zoomable_chart_store.ts index 8f03143ba1..41dd46f186 100644 --- a/src/components/figures/chart/chartJs/zoomable_chart/zoomable_chart_store.ts +++ b/src/components/figures/chart/chartJs/zoomable_chart/zoomable_chart_store.ts @@ -8,9 +8,15 @@ import { SpreadsheetStore } from "../../../../../stores"; const TREND_LINE_AXES_IDS = [TREND_LINE_XAXIS_ID, MOVING_AVERAGE_TREND_LINE_XAXIS_ID] as const; const ZOOMABLE_AXIS_IDS = ["x", ...TREND_LINE_AXES_IDS] as const; export type AxisId = (typeof ZOOMABLE_AXIS_IDS)[number]; + +export interface Boundaries { + min: number; + max: number; +} export type AxesLimits = { - [chartId: UID]: { [axisId in AxisId]?: { min: number; max: number } | undefined }; + [chartId: UID]: { [axisId in AxisId]?: Boundaries } & { x: Boundaries }; }; + export class ZoomableChartStore extends SpreadsheetStore { mutators = [ "resetAxisLimits", @@ -53,34 +59,21 @@ export class ZoomableChartStore extends SpreadsheetStore { return "noStateChange"; } - resetAxisLimits( - chartId: UID, - limits: { [key: string]: { min: number; max: number } | undefined } | undefined - ) { + resetAxisLimits(chartId: UID, limits: { [key: string]: Boundaries }) { for (const axisId of ZOOMABLE_AXIS_IDS) { - if (limits?.[axisId]) { - if (!this.originalAxisLimits[chartId]?.[axisId]) { - this.originalAxisLimits[chartId] = { - ...this.originalAxisLimits[chartId], - [axisId]: {}, - }; - } - this.originalAxisLimits[chartId][axisId]!["min"] = limits[axisId].min; - this.originalAxisLimits[chartId][axisId]!["max"] = limits[axisId].max; - } else { - if (this.originalAxisLimits[chartId]?.[axisId]) { - delete this.originalAxisLimits[chartId][axisId]; - } + if (limits[axisId]) { + this.originalAxisLimits[chartId] = { + ...this.originalAxisLimits[chartId], + [axisId]: { ...limits[axisId] }, + }; + } else if (this.originalAxisLimits[chartId]?.[axisId]) { + delete this.originalAxisLimits[chartId][axisId]; } } return "noStateChange"; } - updateAxisLimits(chartId: UID, limits?: { min: number; max: number } | undefined) { - if (limits === undefined) { - delete this.currentAxesLimits[chartId]; - return "noStateChange"; - } + updateAxisLimits(chartId: UID, limits: Boundaries) { let { min, max } = limits; if (min > max) { [min, max] = [max, min]; @@ -97,35 +90,25 @@ export class ZoomableChartStore extends SpreadsheetStore { * for the current trend line axes. */ updateTrendLineConfiguration(chartId: UID) { - if (!this.originalAxisLimits[chartId]) { + if (!this.originalAxisLimits[chartId]?.x || !this.currentAxesLimits[chartId]?.x) { return "noStateChange"; } const chartLimits = this.originalAxisLimits[chartId].x; - if (chartLimits === undefined) { - return "noStateChange"; - } for (const axisId of TREND_LINE_AXES_IDS) { if (!this.originalAxisLimits[chartId][axisId]) { continue; } - if (!this.currentAxesLimits[chartId]?.[axisId]) { - this.currentAxesLimits[chartId] = { - ...this.currentAxesLimits[chartId], - [axisId]: {}, - }; - } - if (this.currentAxesLimits[chartId]?.x === undefined) { - return "noStateChange"; - } const realRange = chartLimits.max - chartLimits.min; const trendingLimits = this.originalAxisLimits[chartId][axisId]; - const trendingRange = trendingLimits.max! - trendingLimits.min!; + const trendingRange = trendingLimits.max - trendingLimits.min; const slope = trendingRange / realRange; - const intercept = trendingLimits.min! - chartLimits.min * slope; + const intercept = trendingLimits.min - chartLimits.min * slope; const newXMin = this.currentAxesLimits[chartId].x.min; const newXMax = this.currentAxesLimits[chartId].x.max; - this.currentAxesLimits[chartId][axisId]!.min = newXMin * slope + intercept; - this.currentAxesLimits[chartId][axisId]!.max = newXMax * slope + intercept; + this.currentAxesLimits[chartId][axisId] = { + min: newXMin * slope + intercept, + max: newXMax * slope + intercept, + }; } return "noStateChange"; } diff --git a/src/components/figures/chart/chartJs/zoomable_chart/zoomable_chartjs.ts b/src/components/figures/chart/chartJs/zoomable_chart/zoomable_chartjs.ts index 40a71c574a..18e0e3155d 100644 --- a/src/components/figures/chart/chartJs/zoomable_chart/zoomable_chartjs.ts +++ b/src/components/figures/chart/chartJs/zoomable_chart/zoomable_chartjs.ts @@ -11,7 +11,7 @@ import { Store, useStore } from "../../../../../store_engine"; import { ChartJSRuntime } from "../../../../../types"; import { withZoom } from "../../../../helpers/zoom"; import { ChartJsComponent } from "../chartjs"; -import { ZoomableChartStore } from "./zoomable_chart_store"; +import { Boundaries, ZoomableChartStore } from "./zoomable_chart_store"; import { zoomWindowPlugin } from "./zoomable_chartjs_plugins"; chartJsExtensionRegistry.add("zoomWindowPlugin", { @@ -30,8 +30,9 @@ export class ZoomableChartJsComponent extends ChartJsComponent { private hasLinearScale?: boolean; private isBarChart?: boolean; private chartId: string = ""; - private datasetBoundaries: { xMin: number; xMax: number } = { xMin: 0, xMax: 0 }; + private datasetBoundaries: Boundaries = { min: 0, max: 0 }; private removeEventListeners = () => {}; + private isMasterChartAllowed: boolean = false; setup() { this.store = useStore(ZoomableChartStore); @@ -51,12 +52,20 @@ export class ZoomableChartJsComponent extends ChartJsComponent { `; } + get masterChartContainerStyle() { + const runtime = this.env.model.getters.getChartRuntime(this.props.chartId) as ChartJSRuntime; + if (runtime && !runtime.chartJsConfig.data.datasets.some((ds) => ds.data.length > 1)) { + return "opacity: 0.3;"; + } + return ""; + } + get sliceable(): boolean { if (this.props.isFullScreen) { return true; } const definition = this.env.model.getters.getChartDefinition(this.props.chartId); - return ("zoomable" in definition && definition?.zoomable) ?? false; + return ("zoomable" in definition && definition.zoomable) ?? false; } get axisOffset(): number { @@ -84,15 +93,13 @@ export class ZoomableChartJsComponent extends ChartJsComponent { if (!this.sliceable) { return chartData; } - const xAxis = this.store.currentAxesLimits[this.chartId]?.x; - const xScale = { - ...chartData.options.scales?.x, - }; - if (xAxis?.min !== undefined) { - xScale.min = this.hasLinearScale ? xAxis.min : Math.ceil(xAxis.min) - this.axisOffset; - } - if (xAxis?.max !== undefined) { - xScale.max = this.hasLinearScale ? xAxis.max : Math.floor(xAxis.max) - this.axisOffset; + let x = chartData.options.scales.x; + const limits = this.store.currentAxesLimits[this.chartId]?.x; + if (limits) { + x = { + ...x, + ...this.getStoredBoundaries(), + }; } return { ...chartData, @@ -100,7 +107,7 @@ export class ZoomableChartJsComponent extends ChartJsComponent { ...chartData.options, scales: { ...chartData.options.scales, - x: xScale, + x, }, layout: { ...chartData.options.layout, @@ -113,15 +120,23 @@ export class ZoomableChartJsComponent extends ChartJsComponent { }; } - private getAxisLimitsFromDataset(chartData: ChartConfiguration): { - xMin: number; - xMax: number; - } { + private getAxisLimitsFromDataset(chartData: ChartConfiguration): Boundaries { const data = chartData.data.datasets.map((ds) => ds.data).flat(); const xValues = data.map((d, i) => (typeof d === "object" && d !== null ? d.x : i)); - const xMin = Math.min(...xValues); - const xMax = Math.max(...xValues); - return { xMin, xMax }; + const min = Math.min(...xValues); + const max = Math.max(...xValues); + return { min, max }; + } + + private setMasterChartCursor(runtime: ChartJSRuntime) { + const masterElement = this.masterChartCanvas?.el as HTMLCanvasElement; + if (runtime && !runtime.chartJsConfig.data.datasets.some((ds) => ds.data.length > 1)) { + masterElement.style.cursor = "not-allowed"; + this.isMasterChartAllowed = false; + return; + } + masterElement.style.cursor = "default"; + this.isMasterChartAllowed = true; } protected createChart(chartRuntime: ChartJSRuntime) { @@ -139,14 +154,16 @@ export class ZoomableChartJsComponent extends ChartJsComponent { } super.createChart(chartRuntime); - this.hasLinearScale = this.chart?.scales?.x.type === "linear"; + this.hasLinearScale = this.chart?.scales?.x?.type === "linear"; if (!this.sliceable || !("masterChartConfig" in chartRuntime)) { + this.isMasterChartAllowed = false; return; } this.masterChart?.destroy(); - const masterChartCtx = (this.masterChartCanvas!.el as HTMLCanvasElement).getContext("2d")!; + const masterChartCtx = (this.masterChartCanvas?.el as HTMLCanvasElement).getContext("2d")!; + this.setMasterChartCursor(chartRuntime); this.masterChart = new globalThis.Chart( masterChartCtx, this.getMasterChartConfiguration(chartRuntime["masterChartConfig"] as ChartConfiguration) @@ -162,13 +179,10 @@ export class ZoomableChartJsComponent extends ChartJsComponent { throw new Error("Chart.js library is not loaded"); } const chartData = chartRuntime.chartJsConfig as ChartConfiguration; - const newDatasetBoundaries = this.getAxisLimitsFromDataset(chartData); - if ( - this.datasetBoundaries.xMin !== newDatasetBoundaries.xMin || - this.datasetBoundaries.xMax !== newDatasetBoundaries.xMax - ) { + const { min, max } = this.getAxisLimitsFromDataset(chartData); + if (this.datasetBoundaries.min !== min || this.datasetBoundaries.max !== max) { this.store.clearAxisLimits(this.chartId); - this.datasetBoundaries = newDatasetBoundaries; + this.datasetBoundaries = { min, max }; } this.isBarChart = chartData?.type === "bar"; this.chartId = `${chartData.type}-${this.props.chartId}`; @@ -179,9 +193,10 @@ export class ZoomableChartJsComponent extends ChartJsComponent { } super.updateChartJs(chartRuntime); - this.hasLinearScale = this.chart?.scales?.x.type === "linear"; + this.hasLinearScale = this.chart?.scales?.x?.type === "linear"; if (!this.sliceable || !("masterChartConfig" in chartRuntime)) { this.masterChart = undefined; + this.isMasterChartAllowed = false; } else { const masterChartConfig = this.getMasterChartConfiguration( chartRuntime["masterChartConfig"] as ChartConfiguration @@ -194,6 +209,7 @@ export class ZoomableChartJsComponent extends ChartJsComponent { this.masterChart.config.options = masterChartConfig.options; this.masterChart.update(); } + this.setMasterChartCursor(chartRuntime); } this.resetAxesLimits(); if (this.chart?.options) { @@ -205,18 +221,15 @@ export class ZoomableChartJsComponent extends ChartJsComponent { if (!this.chart) { return; } - const previousAxisLimits = this.store.originalAxisLimits[this.chartId]; - if (previousAxisLimits?.x?.min === undefined && previousAxisLimits?.x?.max === undefined) { - let scales: { [key: string]: { min: number; max: number } } = this.masterChart + const storedLimits = this.store.originalAxisLimits[this.chartId]?.x; + if (!storedLimits) { + let scales: { [key: string]: Boundaries } = this.masterChart ? this.masterChart.scales : this.chart.scales; - if (!this.hasLinearScale && scales?.x) { + if (!this.hasLinearScale && scales.x) { scales = { ...scales, - x: { - min: Math.ceil(scales.x.min) - this.axisOffset, - max: Math.floor(scales.x.max) + this.axisOffset, - }, + x: this.adjustBoundaries(scales.x), }; } this.store.resetAxisLimits(this.chartId, scales); @@ -232,13 +245,13 @@ export class ZoomableChartJsComponent extends ChartJsComponent { private updateTrendingLineAxes() { this.store.updateTrendLineConfiguration(this.chartId); - const config = this.store.currentAxesLimits[this.chartId]; + const limits = this.store.currentAxesLimits[this.chartId]; for (const axisId of [TREND_LINE_XAXIS_ID, MOVING_AVERAGE_TREND_LINE_XAXIS_ID]) { - if (!this.chart?.config.options?.scales?.[axisId] || !config?.[axisId]) { + if (!this.chart?.config?.options?.scales?.[axisId] || !limits?.[axisId]) { continue; } - this.chart.config.options.scales[axisId].min = config[axisId].min; - this.chart.config.options.scales[axisId].max = config[axisId].max; + this.chart.config.options.scales[axisId].min = limits[axisId].min; + this.chart.config.options.scales[axisId].max = limits[axisId].max; } } @@ -288,21 +301,68 @@ export class ZoomableChartJsComponent extends ChartJsComponent { ); } - private updateAxisLimits(xMin: number, xMax: number) { + /** + * Compute min and max from the store, adjusting them if needed for non linear scales. + * Getting the value from the store, we have to ensure that the values are integers for + * non linear scales (bar and category). To select a bar in the chart, we have to include + * the whole bar, which means that for the i-th bar, the selected min should be <= i and + * the selected max should be >= i, so using the Math.floor and Math.ceil functions is + * the right way to do it. + * Sometimes, we can get a minimal value > the maximal value, which arise when the user + * select a very small area in the master chart, and hasn't selected the middle of a bar + * or a group of bars (in case of more than one data series). + * Assuming we have to select the middle of a bar/a groupe of bars, we will reject the + * coming value afterward. In this case, we do not update the chart because it would lead + * to an empty chart. + */ + + private getStoredBoundaries(): Boundaries { + let { min, max } = this.store.currentAxesLimits[this.chartId].x; if (!this.hasLinearScale) { - this.chart!.config.options!.scales!.x!.min = Math.ceil(xMin); - this.chart!.config.options!.scales!.x!.max = Math.floor(xMax); - } else { - this.chart!.config.options!.scales!.x!.min = xMin; - this.chart!.config.options!.scales!.x!.max = xMax; + min = Math.ceil(min); + max = Math.floor(max); + } + return { min, max }; + } + + /** + * Adjust the min and max values of an axis if needed for non linear scales. + * Here, after rounding (see docstring of getStoredBoundaries), we adjust the min by + * substracting the axis offset, and we add it to the max, because when computing from the + * scale, chartJs use integer values as the limits for non linear scales. If we have a min + * value of 1, it means we want to start displaying from 0.5, and if we have a max value of + * 4, it means we want to display until 4.5. + * Here, we don't have to check if min > max because we are computing from the scale, and + * chartJs ensures that this won't happen, even after our adjustments. + */ + + private adjustBoundaries({ min, max }: Boundaries): Boundaries { + if (!this.hasLinearScale) { + min = Math.ceil(min) - this.axisOffset; + max = Math.floor(max) + this.axisOffset; + } + return { min, max }; + } + + private updateAxisLimits(xMin: number, xMax: number) { + if (xMin === xMax) { + return; + } + if (!this.chart) { + return; } this.store.updateAxisLimits(this.chartId, { min: xMin, max: xMax }); - this.updateTrendingLineAxes(); + const { min, max } = this.getStoredBoundaries(); + if (max > min || (this.isBarChart && max === min)) { + this.chart.config.options!.scales!.x!.min = min; + this.chart.config.options!.scales!.x!.max = max; + this.updateTrendingLineAxes(); + this.chart.update(); + } this.masterChart?.update(); - this.chart?.update(); } - onPointerDownInMasterChart(ev: PointerEvent) { + onMasterChartPointerDown(ev: PointerEvent) { this.removeEventListeners(); const zoomedEvent = withZoom(this.env, ev, this.masterChartCanvas.el?.getBoundingClientRect()); const position = zoomedEvent.offsetX; @@ -310,8 +370,8 @@ export class ZoomableChartJsComponent extends ChartJsComponent { return; } const { left, right, top, bottom } = this.masterChart.chartArea; - const xMax = this.upperBound ?? right; - const xMin = this.lowerBound ?? left; + const upperBound = this.upperBound ?? right; + const lowerBound = this.lowerBound ?? left; if ( position < left - 5 || position > right + 5 || @@ -324,8 +384,12 @@ export class ZoomableChartJsComponent extends ChartJsComponent { ev.stopPropagation(); let startingPositionOnChart: number, windowSize: number, startX: number | undefined; const startingEventPosition = position; - if ((xMin !== left || xMax !== right) && position > xMin + 5 && position < xMax - 5) { - startingPositionOnChart = zoomedEvent.offsetX - xMin; + if ( + (lowerBound !== left || upperBound !== right) && + position > lowerBound + 5 && + position < upperBound - 5 + ) { + startingPositionOnChart = zoomedEvent.offsetX - lowerBound; this.mode = "moveInMaster"; const currentLimits = this.store.currentAxesLimits[this.chartId]?.x; windowSize = @@ -333,29 +397,27 @@ export class ZoomableChartJsComponent extends ChartJsComponent { (currentLimits?.min ?? this.chart.scales.x.min); } else { this.mode = "selectInMaster"; - if (Math.abs(position - xMin) < 5) { - startingPositionOnChart = xMax; - } else if (Math.abs(position - xMax) < 5) { - startingPositionOnChart = xMin; + if (Math.abs(position - lowerBound) < 5) { + startingPositionOnChart = upperBound; + } else if (Math.abs(position - upperBound) < 5) { + startingPositionOnChart = lowerBound; } else { startingPositionOnChart = clip(position, left, right); } startX = this.computeCoordinate(startingPositionOnChart); } - const originalXMin = this.store.originalAxisLimits[this.chartId].x!.min; - const originalXMax = this.store.originalAxisLimits[this.chartId].x!.max; + const storedMin = this.store.originalAxisLimits[this.chartId].x.min; + const storedMax = this.store.originalAxisLimits[this.chartId].x.max; const computeNewAxisLimits = (position: number) => { - let xMin: number | undefined, xMax: number | undefined; - const { left, right } = this.masterChart!.chartArea; if (this.mode === "moveInMaster") { - xMin = this.computeCoordinate(position - startingPositionOnChart)!; - if (xMin < originalXMin) { - xMin = originalXMin; - } else if (xMin > originalXMax - windowSize) { - xMin = originalXMax - windowSize; + let min = this.computeCoordinate(position - startingPositionOnChart)!; + if (min < storedMin) { + min = storedMin; + } else if (min > storedMax - windowSize) { + min = storedMax - windowSize; } - xMax = xMin + windowSize; + return { min, max: min + windowSize }; } else if (this.mode === "selectInMaster") { const upperBound = clip(position, left, right); if (Math.abs(startingPositionOnChart - upperBound) > 5) { @@ -363,14 +425,16 @@ export class ZoomableChartJsComponent extends ChartJsComponent { if (startX === undefined || endX === undefined) { return {}; } - xMin = Math.min(startX, endX); - xMax = Math.max(startX, endX); + return { + min: Math.min(startX, endX), + max: Math.max(startX, endX), + }; } } - return { min: xMin, max: xMax }; + return {}; }; - const onDragFromMasterChart = (ev: PointerEvent) => { + const onMasterChartDrag = (ev: PointerEvent) => { const zoomedEvent = withZoom( this.env, ev, @@ -380,54 +444,46 @@ export class ZoomableChartJsComponent extends ChartJsComponent { if (Math.abs(position - startingEventPosition) < 5) { return; } - const { min: xMin, max: xMax } = computeNewAxisLimits(position); - if (xMin !== undefined && xMax !== undefined) { - this.updateAxisLimits(xMin, xMax); + const { min, max } = computeNewAxisLimits(position); + if (min !== undefined && max !== undefined) { + this.updateAxisLimits(min, max); } }; - const onPointerUpInMasterChart = (ev: PointerEvent) => { + const onMasterChartPointerUp = (ev: PointerEvent) => { this.removeEventListeners(); - const zoomedEvent = withZoom( - this.env, - ev, - this.masterChartCanvas.el?.getBoundingClientRect() - ); - const position = zoomedEvent.offsetX; - if (Math.abs(position - startingEventPosition) > 5) { - let { min: xMin, max: xMax } = computeNewAxisLimits(position); - if (xMin !== undefined && xMax !== undefined) { - if (!this.hasLinearScale) { - if (this.mode === "moveInMaster" && windowSize && !this.isBarChart) { - xMin = Math.round(xMin) - this.axisOffset; - xMax = xMin + windowSize; - } else { - xMin = Math.ceil(xMin) - this.axisOffset; - xMax = Math.floor(xMax) + this.axisOffset; - } - } - this.updateAxisLimits(xMin, xMax); + let { min, max } = this.chart!.scales.x; + if (!this.hasLinearScale) { + if (this.mode === "moveInMaster") { + min = Math.round(min) - this.axisOffset; + max = min + windowSize; + } else { + ({ min, max } = this.adjustBoundaries({ min, max })); } } + this.updateAxisLimits(min, max); this.mode = undefined; }; this.removeEventListeners = () => { - window.removeEventListener("pointermove", onDragFromMasterChart, true); - window.removeEventListener("pointerup", onPointerUpInMasterChart, true); + window.removeEventListener("pointermove", onMasterChartDrag, true); + window.removeEventListener("pointerup", onMasterChartPointerUp, true); }; - window.addEventListener("pointermove", onDragFromMasterChart, true); - window.addEventListener("pointerup", onPointerUpInMasterChart, true); + window.addEventListener("pointermove", onMasterChartDrag, true); + window.addEventListener("pointerup", onMasterChartPointerUp, true); } - onPointerMoveInMasterChart(ev: PointerEvent) { + onMasterChartPointerMove(ev: PointerEvent) { + const target = ev.target; const { offsetX: x, offsetY: y } = withZoom( this.env, ev, - (ev.target as HTMLElement)?.getBoundingClientRect() + (target as HTMLElement)?.getBoundingClientRect() ); + if (!target || !this.isMasterChartAllowed) { + return; + } if (this.mode === undefined) { - const target = ev.target!; if (!this.masterChart?.chartArea) { target["style"].cursor = "default"; return; @@ -447,15 +503,15 @@ export class ZoomableChartJsComponent extends ChartJsComponent { } } - onMouseLeaveMasterChart(ev: PointerEvent) { + onMasterChartMouseLeave(ev: PointerEvent) { const target = ev.target; - if (!target) { + if (!target || !this.isMasterChartAllowed) { return; } target["style"].cursor = "default"; } - onDoubleClickInMasterChart(ev: PointerEvent) { + onMasterChartDoubleClick(ev: PointerEvent) { this.mode = undefined; const zoomedEvent = withZoom(this.env, ev, this.masterChartCanvas.el?.getBoundingClientRect()); const position = zoomedEvent.offsetX; @@ -478,30 +534,21 @@ export class ZoomableChartJsComponent extends ChartJsComponent { } ev.preventDefault(); ev.stopPropagation(); - let { min: xMin, max: xMax } = - this.store.currentAxesLimits[this.chartId]?.x ?? this.chart.scales.x; + let { min, max } = this.store.currentAxesLimits[this.chartId]?.x ?? this.chart.scales.x; const originalAxisLimits = this.store.originalAxisLimits[this.chartId].x; if (!originalAxisLimits) { return; } - let originalXMin = originalAxisLimits.min; - let originalXMax = originalAxisLimits.max; - if (this.hasLinearScale) { - originalXMin = Math.ceil(originalXMin) - this.axisOffset; - originalXMax = Math.floor(originalXMax) + this.axisOffset; - } if (Math.abs(position - lowerBound) < 5) { - // Reset to original min - xMin = originalXMin; + min = originalAxisLimits.min; } else if (Math.abs(position - upperBound) < 5) { - xMax = originalXMax; + max = originalAxisLimits.max; } else if (lowerBound < position && position < upperBound) { - // Reset to original limits - xMin = originalXMin; - xMax = originalXMax; + min = originalAxisLimits.min; + max = originalAxisLimits.max; } else { return; } - this.updateAxisLimits(xMin, xMax); + this.updateAxisLimits(min, max); } } diff --git a/src/components/figures/chart/chartJs/zoomable_chart/zoomable_chartjs.xml b/src/components/figures/chart/chartJs/zoomable_chart/zoomable_chartjs.xml index 83719261c5..c4d96a27b4 100644 --- a/src/components/figures/chart/chartJs/zoomable_chart/zoomable_chartjs.xml +++ b/src/components/figures/chart/chartJs/zoomable_chart/zoomable_chartjs.xml @@ -4,14 +4,17 @@
-
+
diff --git a/src/components/side_panel/chart/bar_chart/bar_chart_design_panel.ts b/src/components/side_panel/chart/bar_chart/bar_chart_design_panel.ts new file mode 100644 index 0000000000..a08aec1d9e --- /dev/null +++ b/src/components/side_panel/chart/bar_chart/bar_chart_design_panel.ts @@ -0,0 +1,17 @@ +import { BarChartDefinition } from "@odoo/o-spreadsheet-engine/types/chart"; +import { DispatchResult, UID } from "../../../../types/index"; +import { GenericZoomableChartDesignPanel } from "../zoomable_chart/design_panel"; + +interface Props { + chartId: UID; + definition: BarChartDefinition; + canUpdateChart: (chartId: UID, definition: BarChartDefinition) => DispatchResult; + updateChart: (chartId: UID, definition: BarChartDefinition) => DispatchResult; +} + +export class BarChartDesignPanel extends GenericZoomableChartDesignPanel { + static template = "o-spreadsheet-BarChartDesignPanel"; + get isZoomable() { + return !this.props.definition.horizontal; + } +} diff --git a/src/components/side_panel/chart/bar_chart/bar_chart_design_panel.xml b/src/components/side_panel/chart/bar_chart/bar_chart_design_panel.xml new file mode 100644 index 0000000000..7b36213594 --- /dev/null +++ b/src/components/side_panel/chart/bar_chart/bar_chart_design_panel.xml @@ -0,0 +1,35 @@ + + + + + +
+ +
+
+ +
+
+ +
+
+
+ + + + + + +
+
diff --git a/src/components/side_panel/chart/index.ts b/src/components/side_panel/chart/index.ts index 843bbec0fb..dd9ca49d75 100644 --- a/src/components/side_panel/chart/index.ts +++ b/src/components/side_panel/chart/index.ts @@ -1,6 +1,7 @@ import { Registry } from "@odoo/o-spreadsheet-engine/registries/registry"; import { Component } from "@odoo/owl"; import { BarConfigPanel } from "./bar_chart/bar_chart_config_panel"; +import { BarChartDesignPanel } from "./bar_chart/bar_chart_design_panel"; import { GenericChartConfigPanel } from "./building_blocks/generic_side_panel/config_panel"; import { CalendarChartConfigPanel } from "./calendar_chart/calendar_chart_config_panel"; import { CalendarChartDesignPanel } from "./calendar_chart/calendar_chart_design_panel"; @@ -52,7 +53,7 @@ chartSidePanelComponentRegistry }) .add("bar", { configuration: BarConfigPanel, - design: GenericZoomableChartDesignPanel, + design: BarChartDesignPanel, }) .add("combo", { configuration: GenericChartConfigPanel, diff --git a/tests/figures/chart/chart_full_screen.test.ts b/tests/figures/chart/chart_full_screen.test.ts index 5f9aa2d7bd..a73d58ecb6 100644 --- a/tests/figures/chart/chart_full_screen.test.ts +++ b/tests/figures/chart/chart_full_screen.test.ts @@ -9,7 +9,13 @@ import { } from "../../test_helpers/dom_helper"; import { mockChart, mountSpreadsheet, nextTick } from "../../test_helpers/helpers"; -mockChart(); +mockChart({ + scales: { + x: { + type: "categorical", + }, + }, +}); let model: Model; let fixture: HTMLElement; diff --git a/tests/figures/chart/charts_component.test.ts b/tests/figures/chart/charts_component.test.ts index d661642847..b88574fc34 100644 --- a/tests/figures/chart/charts_component.test.ts +++ b/tests/figures/chart/charts_component.test.ts @@ -1891,7 +1891,7 @@ describe("charts", () => { ); test.each(["bar", "line", "waterfall", "treemap", "sunburst"])( - "humanizeNumbers checkbox updates the chart", + "humanizeNumbers checkbox updates the %s chart", async (type: ChartType) => { createTestChart(type); await mountChartSidePanel(); diff --git a/tests/figures/chart/zoomable_charts/zoomable_charts_component.test.ts b/tests/figures/chart/zoomable_charts/zoomable_charts_component.test.ts index e64466ad5e..73eb679a8a 100644 --- a/tests/figures/chart/zoomable_charts/zoomable_charts_component.test.ts +++ b/tests/figures/chart/zoomable_charts/zoomable_charts_component.test.ts @@ -4,7 +4,7 @@ import { CreateFigureCommand, Model, UID } from "../../../../src"; import { ZoomableChartStore } from "../../../../src/components/figures/chart/chartJs/zoomable_chart/zoomable_chart_store"; import { ChartPanel } from "../../../../src/components/side_panel/chart/main_chart_panel/main_chart_panel"; import { openChartDesignSidePanel } from "../../../test_helpers/chart_helpers"; -import { createChart, setCellContent } from "../../../test_helpers/commands_helpers"; +import { createChart, setCellContent, updateChart } from "../../../test_helpers/commands_helpers"; import { TEST_CHART_DATA } from "../../../test_helpers/constants"; import { clickAndDrag, simulateClick, triggerMouseEvent } from "../../../test_helpers/dom_helper"; import { @@ -150,14 +150,14 @@ describe("zoom", () => { test("Can select on the master chart", async () => { const element = fixture.querySelector(".o-master-chart-canvas") as HTMLCanvasElement; const { left, top, width, height } = element.getBoundingClientRect(); - const startX = left + width / 3 - 5; + const startX = left + width / 4; const startY = top + height / 2; - const offsetX = width / 3 + 10; - await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }, true); + const offsetX = width / 2; + await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }); const store = env.getStore(ZoomableChartStore); const { min: newMin, max: newMax } = store.currentAxesLimits[lineChartId]?.x ?? {}; - expect(newMin).toEqual(1); - expect(newMax).toEqual(2); + expect(newMin).toEqual(0.75); + expect(newMax).toEqual(2.25); }); test("Can select from the left bound on the master chart", async () => { @@ -166,10 +166,10 @@ describe("zoom", () => { const startX = left; const startY = top + height / 2; const offsetX = width / 2; - await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }, true); + await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }); const store = env.getStore(ZoomableChartStore); const { min: newMin, max: newMax } = store.currentAxesLimits[lineChartId]?.x ?? {}; - expect(newMin).toEqual(2); + expect(newMin).toEqual(1.5); expect(newMax).toEqual(3); }); @@ -179,11 +179,11 @@ describe("zoom", () => { const startX = left + width; const startY = top + height / 2; const offsetX = -width / 2; - await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }, true); + await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }); const store = env.getStore(ZoomableChartStore); const { min: newMin, max: newMax } = store.currentAxesLimits[lineChartId]?.x ?? {}; expect(newMin).toEqual(0); - expect(newMax).toEqual(1); + expect(newMax).toEqual(1.5); }); test("Can move the slicer on the master chart", async () => { @@ -196,7 +196,7 @@ describe("zoom", () => { const startX = left + width / 2; const startY = top + height / 2; const offsetX = width / 3 + 10; - await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }, true); + await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }); const { min: newMin, max: newMax } = store.currentAxesLimits[lineChartId]?.x ?? {}; expect(newMin).toEqual(2); expect(newMax).toEqual(3); @@ -265,19 +265,6 @@ describe("zoom", () => { expect(newMin).toEqual(1); expect(newMax).toEqual(2); }); - - test("Boundaries are rounded for categorical axis", async () => { - const element = fixture.querySelector(".o-master-chart-canvas") as HTMLCanvasElement; - const { left, top, width, height } = element.getBoundingClientRect(); - const startX = left + width / 6; - const startY = top + height / 2; - const offsetX = (2 * width) / 3; - await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }, true); - const store = env.getStore(ZoomableChartStore); - const { min: newMin, max: newMax } = store.currentAxesLimits[lineChartId]?.x ?? {}; - expect(newMin).toEqual(1); - expect(newMax).toEqual(2); - }); }); describe("Bar chart", () => { @@ -287,13 +274,26 @@ describe("zoom", () => { await nextTick(); }); + test("allowZoom checkbox is hidden for horizontal bar chart", async () => { + createTestChart(chartId); + await mountChartSidePanel(); + await openChartDesignSidePanel(model, env, fixture, chartId); + + expect(fixture.querySelector("input[name='zoomable']")).not.toBeNull(); + + updateChart(model, chartId, { horizontal: true }); + await nextTick(); + + expect(fixture.querySelector("input[name='zoomable']")).toBeNull(); + }); + test("Can select on the master chart", async () => { const element = fixture.querySelector(".o-master-chart-canvas") as HTMLCanvasElement; const { left, top, width, height } = element.getBoundingClientRect(); - const startX = left + width / 4 - 5; + const startX = left + width / 4; const startY = top + height / 2; - const offsetX = width / 4 + 10; - await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }, true); + const offsetX = width / 4; + await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }); const store = env.getStore(ZoomableChartStore); const { min: newMin, max: newMax } = store.currentAxesLimits[barChartId]?.x ?? {}; expect(newMin).toEqual(0.5); @@ -306,7 +306,7 @@ describe("zoom", () => { const startX = left; const startY = top + height / 2; const offsetX = width / 2; - await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }, true); + await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }); const store = env.getStore(ZoomableChartStore); const { min: newMin, max: newMax } = store.currentAxesLimits[barChartId]?.x ?? {}; expect(newMin).toEqual(1.5); @@ -319,7 +319,7 @@ describe("zoom", () => { const startX = left + width; const startY = top + height / 2; const offsetX = -width / 2; - await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }, true); + await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }); const store = env.getStore(ZoomableChartStore); const { min: newMin, max: newMax } = store.currentAxesLimits[barChartId]?.x ?? {}; expect(newMin).toEqual(-0.5); @@ -336,7 +336,7 @@ describe("zoom", () => { const startX = left + width / 2; const startY = top + height / 2; const offsetX = width / 2; - await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }, true); + await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }); const { min: newMin, max: newMax } = store.currentAxesLimits[barChartId]?.x ?? {}; expect(newMin).toEqual(1.5); expect(newMax).toEqual(3.5); @@ -406,19 +406,6 @@ describe("zoom", () => { expect(newMax).toEqual(2.5); }); - test("Boundaries are half-rounded for categorical axis", async () => { - const element = fixture.querySelector(".o-master-chart-canvas") as HTMLCanvasElement; - const { left, top, width, height } = element.getBoundingClientRect(); - const startX = left + (3 * width) / 8 - 5; - const startY = top + height / 2; - const offsetX = width / 2; - await clickAndDrag(element, { x: offsetX, y: 0 }, { x: startX, y: startY }, true); - const store = env.getStore(ZoomableChartStore); - const { min: newMin, max: newMax } = store.currentAxesLimits[barChartId]?.x ?? {}; - expect(newMin).toEqual(0.5); - expect(newMax).toEqual(2.5); - }); - test("Changing dataset boundaries clear the zoom", async () => { const store = env.getStore(ZoomableChartStore); store.updateAxisLimits(barChartId, { min: 0.5, max: 2.5 }); @@ -433,4 +420,19 @@ describe("zoom", () => { expect(newMax).toBeUndefined(); }); }); + + test("Chart with one point shows timeline as disabled", async () => { + await mountSpreadsheet(); + createTestChart(chartId, {}, { zoomable: true }); + await openChartDesignSidePanel(model, env, fixture, chartId); + let container = fixture.querySelector(".o-master-chart-container"); + let style = container?.getAttribute("style"); + expect(style).toEqual(""); + + updateChart(model, chartId, { dataSets: [{ dataRange: "B2:B2" }], labelRange: "C2:C2" }); + await nextTick(); + container = fixture.querySelector(".o-master-chart-container"); + style = container?.getAttribute("style"); + expect(style).toContain("opacity: 0.3"); + }); }); diff --git a/tests/test_helpers/helpers.ts b/tests/test_helpers/helpers.ts index 37bcc61012..9084f06a3d 100644 --- a/tests/test_helpers/helpers.ts +++ b/tests/test_helpers/helpers.ts @@ -939,7 +939,7 @@ export const mockChart = (options: any = {}) => { static BarController = class {}; static BarElement = class {}; chartArea = options.chartArea ?? { left: 0, top: 0, right: 100, bottom: 100 }; - scales = options.scales ?? undefined; + scales = options.scales ?? { x: { type: "category" } }; } //@ts-ignore