Skip to content

Commit 6c90592

Browse files
committed
[IMP] charts: allows to customize axis scales and gridlines
Task Description This commit aims to add the possibility for the user to customize the axes scale (linear vs logarithmic type and mix/max values) for linear scale. We also add the possibility to show/hide major and minor gridlines (minor gridlines being only available for linear scale and line/scatter chart (for x axis), while avalaible for bar/line/scatter/combo chart for both y axes. Related Task Task: 5159370
1 parent a11279d commit 6c90592

File tree

13 files changed

+872
-37
lines changed

13 files changed

+872
-37
lines changed

packages/o-spreadsheet-engine/src/types/chart/chart.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,19 @@ export interface DatasetDesign {
103103
readonly label?: string;
104104
}
105105

106+
export type AxisScaleType = "linear" | "logarithmic";
107+
108+
export interface AxisGridDesign {
109+
readonly major?: boolean;
110+
readonly minor?: boolean;
111+
}
112+
106113
export interface AxisDesign {
107114
readonly title?: TitleDesign;
115+
readonly min?: number;
116+
readonly max?: number;
117+
readonly scaleType?: AxisScaleType;
118+
readonly grid?: AxisGridDesign;
108119
}
109120

110121
export interface AxesDesign {

packages/o-spreadsheet-engine/src/types/chart/scatter_chart.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { LineChartDefinition, LineChartRuntime } from "./line_chart";
22

33
export interface ScatterChartDefinition
4-
extends Omit<LineChartDefinition, "type" | "stacked" | "cumulative"> {
4+
extends Omit<LineChartDefinition, "type" | "stacked" | "cumulative" | "zoomable"> {
55
readonly type: "scatter";
66
}
77

src/components/figures/chart/chartJs/chartjs.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getFunnelChartController,
1313
getFunnelChartElement,
1414
} from "./chartjs_funnel_chart";
15+
import { chartMinorGridPlugin } from "./chartjs_minor_grid_plugin";
1516
import { chartShowValuesPlugin } from "./chartjs_show_values_plugin";
1617
import { sunburstHoverPlugin } from "./chartjs_sunburst_hover_plugin";
1718
import { sunburstLabelsPlugin } from "./chartjs_sunburst_labels_plugin";
@@ -26,6 +27,10 @@ chartJsExtensionRegistry.add("chartShowValuesPlugin", {
2627
register: (Chart) => Chart.register(chartShowValuesPlugin),
2728
unregister: (Chart) => Chart.unregister(chartShowValuesPlugin),
2829
});
30+
chartJsExtensionRegistry.add("chartMinorGridPlugin", {
31+
register: (Chart) => Chart.register(chartMinorGridPlugin),
32+
unregister: (Chart) => Chart.unregister(chartMinorGridPlugin),
33+
});
2934
chartJsExtensionRegistry.add("waterfallLinesPlugin", {
3035
register: (Chart) => Chart.register(waterfallLinesPlugin),
3136
unregister: (Chart) => Chart.unregister(waterfallLinesPlugin),
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { Chart } from "chart.js";
2+
import { Color } from "../../../..";
3+
4+
interface MinorGridOptions {
5+
display?: boolean;
6+
divisions?: number;
7+
color?: Color;
8+
lineWidth?: number;
9+
borderDash?: number[];
10+
opacity?: number;
11+
}
12+
13+
export const chartMinorGridPlugin = {
14+
id: "o-spreadsheet-minor-gridlines",
15+
beforeDatasetsDraw(chart: Chart) {
16+
const ctx = chart.ctx;
17+
const chartArea = chart.chartArea;
18+
if (!chartArea) {
19+
return;
20+
}
21+
22+
for (const scaleId of Object.keys(chart.scales)) {
23+
const scale = chart.scales[scaleId];
24+
const options = scale.options as any;
25+
const minor: MinorGridOptions | undefined = options?.grid?.minor;
26+
if (!minor?.display) {
27+
continue;
28+
}
29+
const ticks = scale.ticks;
30+
if (!ticks || ticks.length < 2) {
31+
continue;
32+
}
33+
const divisions = minor.divisions ?? 4;
34+
if (!divisions || divisions < 1) {
35+
continue;
36+
}
37+
38+
const strokeStyle = minor.color ?? options?.grid?.color ?? "#bdbdbd";
39+
const lineWidth = minor.lineWidth ?? options?.grid?.lineWidth ?? 1 / 2;
40+
const dash = minor.borderDash ?? options?.grid?.borderDash ?? [];
41+
const opacity = minor.opacity ?? 0.6;
42+
43+
ctx.save();
44+
ctx.lineWidth = lineWidth;
45+
ctx.strokeStyle = strokeStyle;
46+
ctx.setLineDash(dash);
47+
ctx.globalAlpha = opacity;
48+
49+
const drawVerticalLine = (x: number) => {
50+
ctx.beginPath();
51+
ctx.moveTo(x, chartArea.top);
52+
ctx.lineTo(x, chartArea.bottom);
53+
ctx.stroke();
54+
};
55+
const drawHorizontalLine = (y: number) => {
56+
ctx.beginPath();
57+
ctx.moveTo(chartArea.left, y);
58+
ctx.lineTo(chartArea.right, y);
59+
ctx.stroke();
60+
};
61+
62+
for (let i = 0; i < ticks.length - 1; i++) {
63+
const start = scale.getPixelForTick(i);
64+
const end = scale.getPixelForTick(i + 1);
65+
if (!isFinite(start) || !isFinite(end)) {
66+
continue;
67+
}
68+
for (let j = 1; j < divisions; j++) {
69+
const ratio = j / divisions;
70+
const position = start + (end - start) * ratio;
71+
if (scale.isHorizontal()) {
72+
drawVerticalLine(position);
73+
} else {
74+
drawHorizontalLine(position);
75+
}
76+
}
77+
}
78+
79+
ctx.restore();
80+
}
81+
},
82+
};

src/components/side_panel/chart/building_blocks/axis_design/axis_design_editor.ts

Lines changed: 151 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
1+
import { _t } from "@odoo/o-spreadsheet-engine";
12
import { CHART_AXIS_TITLE_FONT_SIZE } from "@odoo/o-spreadsheet-engine/constants";
3+
import { LineChartRuntime } from "@odoo/o-spreadsheet-engine/types/chart";
24
import { SpreadsheetChildEnv } from "@odoo/o-spreadsheet-engine/types/spreadsheet_env";
35
import { Component, useState } from "@odoo/owl";
46
import { deepCopy } from "../../../../../helpers";
5-
import { ChartWithAxisDefinition, DispatchResult, TitleDesign, UID } from "../../../../../types";
7+
import { getDefinedAxis } from "../../../../../helpers/figures/charts";
8+
import {
9+
AxisDesign,
10+
AxisScaleType,
11+
ChartWithAxisDefinition,
12+
DispatchResult,
13+
TitleDesign,
14+
UID,
15+
} from "../../../../../types";
616
import { BadgeSelection } from "../../../components/badge_selection/badge_selection";
17+
import { Checkbox } from "../../../components/checkbox/checkbox";
718
import { Section } from "../../../components/section/section";
819
import { ChartTitle } from "../chart_title/chart_title";
920

@@ -21,7 +32,7 @@ interface Props {
2132

2233
export class AxisDesignEditor extends Component<Props, SpreadsheetChildEnv> {
2334
static template = "o-spreadsheet-AxisDesignEditor";
24-
static components = { Section, ChartTitle, BadgeSelection };
35+
static components = { Section, ChartTitle, BadgeSelection, Checkbox };
2536
static props = { chartId: String, definition: Object, updateChart: Function, axesList: Array };
2637

2738
state = useState({ currentAxis: "x" });
@@ -42,7 +53,7 @@ export class AxisDesignEditor extends Component<Props, SpreadsheetChildEnv> {
4253

4354
getAxisTitle() {
4455
const axesDesign = this.props.definition.axesDesign ?? {};
45-
return axesDesign[this.state.currentAxis]?.title.text || "";
56+
return axesDesign[this.state.currentAxis]?.title?.text || "";
4657
}
4758

4859
updateAxisTitle(text: string) {
@@ -65,4 +76,141 @@ export class AxisDesignEditor extends Component<Props, SpreadsheetChildEnv> {
6576
};
6677
this.props.updateChart(this.props.chartId, { axesDesign });
6778
}
79+
80+
get axisMin(): number | undefined {
81+
return this.currentAxisDesign?.min;
82+
}
83+
84+
get axisMax(): number | undefined {
85+
return this.currentAxisDesign?.max;
86+
}
87+
88+
get axisScaleType(): AxisScaleType {
89+
return this.currentAxisDesign?.scaleType ?? "linear";
90+
}
91+
92+
get isMajorGridEnabled(): boolean {
93+
const designValue = this.currentAxisDesign?.grid?.major;
94+
if (designValue !== undefined) {
95+
return designValue;
96+
}
97+
return this.getDefaultMajorGridValue(this.state.currentAxis);
98+
}
99+
100+
get isMinorGridEnabled(): boolean {
101+
return !!this.currentAxisDesign?.grid?.minor;
102+
}
103+
104+
get majorGridLabel() {
105+
return this.state.currentAxis === "x"
106+
? _t("Vertical major gridlines")
107+
: _t("Horizontal major gridlines");
108+
}
109+
110+
get minorGridLabel() {
111+
return this.state.currentAxis === "x"
112+
? _t("Vertical minor gridlines")
113+
: _t("Horizontal minor gridlines");
114+
}
115+
116+
updateAxisMin(ev: InputEvent) {
117+
const value = (ev.target as HTMLInputElement).value.trim();
118+
const parsed = value === "" ? undefined : Number(value);
119+
if (parsed === undefined || !isNaN(parsed)) {
120+
const axesDesign = deepCopy(this.props.definition.axesDesign) ?? {};
121+
axesDesign[this.state.currentAxis] = {
122+
...axesDesign[this.state.currentAxis],
123+
min: parsed,
124+
};
125+
this.props.updateChart(this.props.chartId, { axesDesign });
126+
}
127+
}
128+
129+
updateAxisMax(ev: InputEvent) {
130+
const value = (ev.target as HTMLInputElement).value.trim();
131+
const parsed = value === "" ? undefined : Number(value);
132+
if (parsed === undefined || !isNaN(parsed)) {
133+
const axesDesign = deepCopy(this.props.definition.axesDesign) ?? {};
134+
axesDesign[this.state.currentAxis] = {
135+
...axesDesign[this.state.currentAxis],
136+
max: parsed,
137+
};
138+
this.props.updateChart(this.props.chartId, { axesDesign });
139+
}
140+
}
141+
142+
updateAxisScaleType(ev: InputEvent) {
143+
const type = (ev.target as HTMLSelectElement).value as AxisScaleType;
144+
const axesDesign = deepCopy(this.props.definition.axesDesign) ?? {};
145+
axesDesign[this.state.currentAxis] = {
146+
...axesDesign[this.state.currentAxis],
147+
scaleType: type === "linear" ? undefined : type,
148+
};
149+
this.props.updateChart(this.props.chartId, { axesDesign });
150+
}
151+
152+
toggleMajorGrid(major: boolean) {
153+
const axesDesign = deepCopy(this.props.definition.axesDesign) ?? {};
154+
axesDesign[this.state.currentAxis] = {
155+
...axesDesign[this.state.currentAxis],
156+
grid: {
157+
...axesDesign[this.state.currentAxis]?.grid,
158+
major,
159+
},
160+
};
161+
this.props.updateChart(this.props.chartId, { axesDesign });
162+
}
163+
164+
toggleMinorGrid(minor: boolean) {
165+
const axesDesign = deepCopy(this.props.definition.axesDesign) ?? {};
166+
axesDesign[this.state.currentAxis] = {
167+
...axesDesign[this.state.currentAxis],
168+
grid: {
169+
...axesDesign[this.state.currentAxis]?.grid,
170+
minor,
171+
},
172+
};
173+
this.props.updateChart(this.props.chartId, { axesDesign });
174+
}
175+
176+
private get currentAxisDesign(): AxisDesign | undefined {
177+
return this.props.definition.axesDesign?.[this.state.currentAxis];
178+
}
179+
180+
private getDefaultMajorGridValue(axisId: string): boolean {
181+
const { useLeftAxis, useRightAxis } = getDefinedAxis(this.props.definition);
182+
if (axisId === "x") {
183+
if ("horizontal" in this.props.definition && this.props.definition.horizontal) {
184+
return true;
185+
}
186+
if (this.props.definition.type === "scatter") {
187+
return true;
188+
}
189+
} else if (axisId === "y") {
190+
return true;
191+
} else if (axisId === "y1") {
192+
return !useLeftAxis && useRightAxis;
193+
}
194+
return false;
195+
}
196+
197+
get isCategoricalAxis(): boolean {
198+
if (this.state.currentAxis !== "x") {
199+
return false;
200+
}
201+
const runtime = this.env.model.getters.getChartRuntime(this.props.chartId) as LineChartRuntime;
202+
const axisType = runtime?.chartJsConfig.options?.scales?.x?.type;
203+
return axisType === undefined || axisType === "time";
204+
}
205+
206+
get canChangeMinorGridVisibility(): boolean {
207+
if (this.state.currentAxis !== "x") {
208+
return true;
209+
}
210+
if (this.isCategoricalAxis) {
211+
return false;
212+
}
213+
const type = this.props.definition.type;
214+
return type === "line" || type === "scatter";
215+
}
68216
}

src/components/side_panel/chart/building_blocks/axis_design/axis_design_editor.xml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,60 @@
1616
style="axisTitleStyle"
1717
defaultStyle="{align: 'center', color: '', fontSize: defaultFontSize}"
1818
/>
19+
<Section
20+
t-if="!isCategoricalAxis"
21+
class="'pt-0 o-axis-bounds-section'"
22+
title.translate="Axis range">
23+
<div class="d-flex">
24+
<div class="w-50">
25+
<span class="o-section-subtitle my-0">Minimum</span>
26+
<input
27+
type="number"
28+
class="o-input w-100"
29+
data-testid="axis-min-input"
30+
t-att-value="axisMin"
31+
t-on-change="updateAxisMin"
32+
/>
33+
</div>
34+
<div class="w-50 ms-3">
35+
<span class="o-section-subtitle my-0">Maximum</span>
36+
<input
37+
type="number"
38+
class="o-input w-100"
39+
data-testid="axis-max-input"
40+
t-att-value="axisMax"
41+
t-on-change="updateAxisMax"
42+
/>
43+
</div>
44+
</div>
45+
</Section>
46+
<Section
47+
t-if="!isCategoricalAxis"
48+
class="'pt-0 o-axis-scale-section'"
49+
title.translate="Scale type">
50+
<select
51+
class="o-input w-100"
52+
data-testid="axis-scale-select"
53+
t-att-value="axisScaleType"
54+
t-on-change="updateAxisScaleType">
55+
<option value="linear">Linear</option>
56+
<option value="logarithmic">Logarithmic</option>
57+
</select>
58+
</Section>
59+
<Section class="'pt-0'" title.translate="Gridlines">
60+
<Checkbox
61+
className="'o-axis-grid-major'"
62+
label="majorGridLabel"
63+
value="isMajorGridEnabled"
64+
onChange.bind="toggleMajorGrid"
65+
/>
66+
<Checkbox
67+
t-if="canChangeMinorGridVisibility"
68+
className="'o-axis-grid-minor'"
69+
label="minorGridLabel"
70+
value="isMinorGridEnabled"
71+
onChange.bind="toggleMinorGrid"
72+
/>
73+
</Section>
1974
</t>
2075
</templates>

src/components/side_panel/chart/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ chartSidePanelComponentRegistry
4646
})
4747
.add("scatter", {
4848
configuration: ScatterConfigPanel,
49-
design: GenericZoomableChartDesignPanel,
49+
design: ChartWithAxisDesignPanel,
5050
})
5151
.add("bar", {
5252
configuration: BarConfigPanel,

0 commit comments

Comments
 (0)