Skip to content

Commit 77986f2

Browse files
committed
[IMP] chart: add sunburst chart
This commit adds a new chart type: sunburst chart. The sunburst chart is used to display hierarchical data in a circular chart. Technically, the chart is implemented using a doughnut chart with multiple levels and invisible segments. Task: 4575651
1 parent a29d655 commit 77986f2

26 files changed

+1582
-5
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ChartJSRuntime } from "../../../../types/chart/chart";
77
import { css } from "../../../helpers";
88
import { FunnelChartController, FunnelChartElement } from "./chartjs_funnel_chart";
99
import { chartShowValuesPlugin } from "./chartjs_show_values_plugin";
10+
import { sunburstLabelsPlugin } from "./chartjs_sunburst_labels_plugin";
1011
import { waterfallLinesPlugin } from "./chartjs_waterfall_plugin";
1112

1213
interface Props {
@@ -16,6 +17,7 @@ interface Props {
1617
window.Chart?.register(waterfallLinesPlugin);
1718
window.Chart?.register(chartShowValuesPlugin);
1819
window.Chart?.register(FunnelChartController, FunnelChartElement);
20+
window.Chart?.register(sunburstLabelsPlugin);
1921

2022
css/* scss */ `
2123
.o-spreadsheet {
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { ChartType, Plugin } from "chart.js";
2+
import {
3+
getDefaultContextFont,
4+
isDefined,
5+
relativeLuminance,
6+
sliceTextToFitWidth,
7+
} from "../../../../helpers";
8+
import { GHOST_SUNBURST_VALUE } from "../../../../helpers/figures/charts/runtime";
9+
import { Style } from "../../../../types";
10+
import { SunburstChartRawData } from "../../../../types/chart";
11+
12+
export interface ChartSunburstLabelsPluginOptions {
13+
showValues: boolean;
14+
showLabels: boolean;
15+
style: Style;
16+
callback: (value: number, axisId?: string) => string;
17+
}
18+
19+
declare module "chart.js" {
20+
interface PluginOptionsByType<TType extends ChartType> {
21+
sunburstLabelsPlugin?: ChartSunburstLabelsPluginOptions;
22+
}
23+
}
24+
25+
const Y_PADDING = 3;
26+
27+
export const sunburstLabelsPlugin: Plugin = {
28+
id: "sunburstLabelsPlugin",
29+
afterDatasetsDraw(chart: any, args, options: ChartSunburstLabelsPluginOptions) {
30+
if ((!options.showValues && !options.showLabels) || chart.config.type !== "doughnut") {
31+
return;
32+
}
33+
const ctx = chart.ctx as CanvasRenderingContext2D;
34+
drawSunburstChartValues(chart, options, ctx);
35+
},
36+
};
37+
38+
function drawSunburstChartValues(
39+
chart: any,
40+
options: ChartSunburstLabelsPluginOptions,
41+
ctx: CanvasRenderingContext2D
42+
) {
43+
const style = options.style;
44+
const fontSize = style.fontSize || 13;
45+
const lineHeight = fontSize + Y_PADDING;
46+
47+
for (const dataset of chart._metasets) {
48+
for (let i = 0; i < dataset._dataset.data.length; i++) {
49+
const rawData = dataset._dataset.data[i] as SunburstChartRawData;
50+
if (rawData.label === GHOST_SUNBURST_VALUE) {
51+
continue;
52+
}
53+
const valuesToDisplay = [
54+
options.showLabels ? rawData.label : undefined,
55+
options.showValues ? options.callback(rawData.value, "y") : undefined,
56+
].filter(isDefined);
57+
58+
const arc = dataset.data[i];
59+
60+
const { startAngle, endAngle, innerRadius, outerRadius } = arc;
61+
const midAngle = (startAngle + endAngle) / 2;
62+
const midRadius = (innerRadius + outerRadius) / 2;
63+
64+
const availableWidth = (outerRadius - innerRadius) * 0.9;
65+
const angle = endAngle - startAngle;
66+
const availableHeight =
67+
angle >= Math.PI ? outerRadius : Math.sin(angle / 2) * innerRadius * 2;
68+
if (availableHeight < valuesToDisplay.length * lineHeight) {
69+
continue;
70+
}
71+
72+
ctx.save();
73+
74+
const centerX = chart.chartArea.left + chart.chartArea.width / 2;
75+
const centerY = chart.chartArea.top + chart.chartArea.height / 2;
76+
ctx.translate(centerX, centerY);
77+
78+
let x: number;
79+
if (midAngle > Math.PI / 2) {
80+
ctx.rotate(midAngle - Math.PI);
81+
x = -midRadius;
82+
} else {
83+
x = midRadius;
84+
ctx.rotate(midAngle);
85+
}
86+
87+
const backgroundColor = arc.options.backgroundColor;
88+
const defaultColor = relativeLuminance(backgroundColor) > 0.7 ? "#666666" : "#FFFFFF";
89+
ctx.fillStyle = style.textColor || defaultColor;
90+
ctx.textAlign = "center";
91+
ctx.font = getDefaultContextFont(fontSize, style.bold, style.italic);
92+
93+
const y = -((valuesToDisplay.length - 1) * lineHeight) / 2;
94+
for (let j = 0; j < valuesToDisplay.length; j++) {
95+
const fittedText = sliceTextToFitWidth(
96+
ctx,
97+
availableWidth,
98+
valuesToDisplay[j],
99+
style,
100+
"px"
101+
);
102+
ctx.fillText(fittedText, x, y + j * lineHeight);
103+
}
104+
105+
ctx.restore();
106+
}
107+
}
108+
}

src/components/side_panel/chart/building_blocks/data_series/data_series.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface Props {
1111
onSelectionReordered?: (indexes: number[]) => void;
1212
onSelectionRemoved?: (index: number) => void;
1313
onSelectionConfirmed: () => void;
14+
title?: string;
1415
}
1516

1617
export class ChartDataSeries extends Component<Props, SpreadsheetChildEnv> {
@@ -23,6 +24,7 @@ export class ChartDataSeries extends Component<Props, SpreadsheetChildEnv> {
2324
onSelectionReordered: { type: Function, optional: true },
2425
onSelectionRemoved: { type: Function, optional: true },
2526
onSelectionConfirmed: Function,
27+
title: { type: String, optional: true },
2628
};
2729

2830
get ranges(): string[] {
@@ -34,6 +36,9 @@ export class ChartDataSeries extends Component<Props, SpreadsheetChildEnv> {
3436
}
3537

3638
get title() {
39+
if (this.props.title) {
40+
return this.props.title;
41+
}
3742
return this.props.hasSingleRange ? _t("Data range") : _t("Data series");
3843
}
3944
}

src/components/side_panel/chart/chart_type_picker/chart_previews.xml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,4 +269,18 @@
269269
<path stroke="#eb6d00" fill="#ffe1c8" d="M21.5,35.5 h5 l-2.5,7 l-2.5,-7 h1"/>
270270
</svg>
271271
</t>
272+
<t t-name="o-spreadsheet-ChartPreview.SUNBURST_CHART">
273+
<svg viewBox="0 0 48 48" class="o-chart-preview" xmlns="http://www.w3.org/2000/svg">
274+
<path
275+
fill="#ffe1c8"
276+
stroke="#eb6d00"
277+
d="M24,12 v8A4,4,0,1,0,27.46,26 L41.32, 34 A20,20,0,0,1,8.679,36.856 L14.807,31.713 A12,12,0,0,1,24,12 M34.4,30 A12,12,0,0,1,14.807,31.713"
278+
/>
279+
<path
280+
fill="#c4e4ff"
281+
stroke="#0074d9"
282+
d="M24,20 v-16 A20 20, 0, 0, 1, 41.32, 34 L27.46,26 A4,4,0,0,0,24,20 M24,12 A12,12,0,0,1,34.4,30 M33.193,16.287 L39.321,11.144 M36,24 L44,24"
283+
/>
284+
</svg>
285+
</t>
272286
</templates>

src/components/side_panel/chart/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { RadarChartDesignPanel } from "./radar_chart/radar_chart_design_panel";
1515
import { ScatterConfigPanel } from "./scatter_chart/scatter_chart_config_panel";
1616
import { ScorecardChartConfigPanel } from "./scorecard_chart_panel/scorecard_chart_config_panel";
1717
import { ScorecardChartDesignPanel } from "./scorecard_chart_panel/scorecard_chart_design_panel";
18+
import { SunburstChartConfigPanel } from "./sunburst_chart/sunburst_chart_config_panel";
19+
import { SunburstChartDesignPanel } from "./sunburst_chart/sunburst_chart_design_panel";
1820
import { WaterfallChartDesignPanel } from "./waterfall_chart/waterfall_chart_design_panel";
1921

2022
export { BarConfigPanel } from "./bar_chart/bar_chart_config_panel";
@@ -74,6 +76,10 @@ chartSidePanelComponentRegistry
7476
configuration: GenericChartConfigPanel,
7577
design: RadarChartDesignPanel,
7678
})
79+
.add("sunburst", {
80+
configuration: SunburstChartConfigPanel,
81+
design: SunburstChartDesignPanel,
82+
})
7783
.add("geo", {
7884
configuration: GeoChartConfigPanel,
7985
design: GeoChartDesignPanel,
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { GenericChartConfigPanel } from "../building_blocks/generic_side_panel/config_panel";
2+
3+
export class SunburstChartConfigPanel extends GenericChartConfigPanel {
4+
static template = "o-spreadsheet-SunburstChartConfigPanel";
5+
static components = { ...GenericChartConfigPanel.components };
6+
7+
getLabelRangeOptions() {
8+
return [
9+
{
10+
name: "dataSetsHaveTitle",
11+
label: this.dataSetsHaveTitleLabel,
12+
value: this.props.definition.dataSetsHaveTitle,
13+
onChange: this.onUpdateDataSetsHaveTitle.bind(this),
14+
},
15+
];
16+
}
17+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<templates>
2+
<t t-name="o-spreadsheet-SunburstChartConfigPanel">
3+
<div>
4+
<ChartDataSeries
5+
ranges="this.getDataSeriesRanges()"
6+
onSelectionChanged.bind="onDataSeriesRangesChanged"
7+
onSelectionConfirmed.bind="onDataSeriesConfirmed"
8+
onSelectionReordered.bind="onDataSeriesReordered"
9+
onSelectionRemoved.bind="onDataSeriesRemoved"
10+
title.translate="Hierarchy"
11+
/>
12+
<ChartLabelRange
13+
range="this.getLabelRange()"
14+
isInvalid="isLabelInvalid"
15+
onSelectionChanged.bind="onLabelRangeChanged"
16+
onSelectionConfirmed.bind="onLabelRangeConfirmed"
17+
options="this.getLabelRangeOptions()"
18+
title.translate="Values"
19+
/>
20+
21+
<ChartErrorSection t-if="errorMessages.length" messages="errorMessages"/>
22+
</div>
23+
</t>
24+
</templates>
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { Component } from "@odoo/owl";
2+
import { deepCopy } from "../../../../helpers";
3+
import {
4+
SunburstChartDefaults,
5+
SunburstChartDefinition,
6+
SunburstChartJSDataset,
7+
SunburstChartRuntime,
8+
} from "../../../../types/chart";
9+
import { DispatchResult, SpreadsheetChildEnv, UID } from "../../../../types/index";
10+
import { Checkbox } from "../../components/checkbox/checkbox";
11+
import { SidePanelCollapsible } from "../../components/collapsible/side_panel_collapsible";
12+
import { RoundColorPicker } from "../../components/round_color_picker/round_color_picker";
13+
import { Section } from "../../components/section/section";
14+
import { GeneralDesignEditor } from "../building_blocks/general_design/general_design_editor";
15+
import { ChartLegend } from "../building_blocks/legend/legend";
16+
import { TextStyler } from "../building_blocks/text_styler/text_styler";
17+
18+
interface Props {
19+
figureId: UID;
20+
definition: SunburstChartDefinition;
21+
canUpdateChart: (figureID: UID, definition: Partial<SunburstChartDefinition>) => DispatchResult;
22+
updateChart: (figureId: UID, definition: Partial<SunburstChartDefinition>) => DispatchResult;
23+
}
24+
25+
export class SunburstChartDesignPanel extends Component<Props, SpreadsheetChildEnv> {
26+
static template = "o-spreadsheet-SunburstChartDesignPanel";
27+
static components = {
28+
GeneralDesignEditor,
29+
Section,
30+
SidePanelCollapsible,
31+
Checkbox,
32+
TextStyler,
33+
RoundColorPicker,
34+
ChartLegend,
35+
};
36+
static props = {
37+
figureId: String,
38+
definition: Object,
39+
updateChart: Function,
40+
canUpdateChart: { type: Function, optional: true },
41+
};
42+
43+
defaults = SunburstChartDefaults;
44+
45+
get showValues() {
46+
return this.props.definition.showValues ?? SunburstChartDefaults.showValues;
47+
}
48+
49+
get showLabels() {
50+
return this.props.definition.showLabels ?? SunburstChartDefaults.showLabels;
51+
}
52+
53+
get groupColors() {
54+
const figureId = this.props.figureId;
55+
const runtime = this.env.model.getters.getChartRuntime(figureId) as SunburstChartRuntime;
56+
const dataset = runtime.chartJsConfig.data.datasets[0] as SunburstChartJSDataset;
57+
return dataset?.groupColors || [];
58+
}
59+
60+
onGroupColorChanged(index: number, color: string) {
61+
const colors = deepCopy(this.props.definition.groupColors) ?? [];
62+
colors[index] = color;
63+
this.props.updateChart(this.props.figureId, { groupColors: colors });
64+
}
65+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<templates>
2+
<t t-name="o-spreadsheet-SunburstChartDesignPanel">
3+
<GeneralDesignEditor
4+
figureId="props.figureId"
5+
definition="props.definition"
6+
updateChart="props.updateChart">
7+
<t t-set-slot="general-extension">
8+
<ChartLegend
9+
figureId="props.figureId"
10+
definition="props.definition"
11+
updateChart="props.updateChart"
12+
/>
13+
</t>
14+
</GeneralDesignEditor>
15+
16+
<SidePanelCollapsible collapsedAtInit="false" title.translate="Sunburst options">
17+
<t t-set-slot="content">
18+
<Section class="'pt-0 o-sunburst-colors'" title.translate="Colors">
19+
<t t-foreach="groupColors" t-as="item" t-key="item.label">
20+
<div class="d-flex align-items-center mb-2" t-att-data-id="item.label">
21+
<RoundColorPicker
22+
currentColor="item.color"
23+
onColorPicked="(color) => this.onGroupColorChanged(item_index, color)"
24+
/>
25+
<span class="ps-2">
26+
<span t-esc="'(#' + (item_index +1 ) + ')'" class="o-text-bolder pe-1"/>
27+
<span class="text-muted" t-esc="item.label"/>
28+
</span>
29+
</div>
30+
</t>
31+
</Section>
32+
<Section title.translate="Labels" class="'pt-0 pb-0'">
33+
<div class="d-flex flex-row gap-4">
34+
<Checkbox
35+
name="'showLabels'"
36+
label.translate="Show labels"
37+
value="showLabels"
38+
onChange="(showLabels) => props.updateChart(this.props.figureId, { showLabels })"
39+
/>
40+
<Checkbox
41+
name="'showValues'"
42+
label.translate="Show values"
43+
value="showValues"
44+
onChange="(showValues) => props.updateChart(this.props.figureId, { showValues })"
45+
/>
46+
</div>
47+
</Section>
48+
<Section class="'pt-0'" t-if="showValues || showLabels">
49+
<TextStyler
50+
class="'o-values-style'"
51+
updateStyle="(valuesDesign) => props.updateChart(this.props.figureId, { valuesDesign })"
52+
style="props.definition.valuesDesign || {}"
53+
defaultStyle="defaults.valuesDesign"
54+
/>
55+
</Section>
56+
</t>
57+
</SidePanelCollapsible>
58+
</t>
59+
</templates>

src/helpers/color.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,7 @@ export class ColorGenerator {
542542
private currentColorIndex = 0;
543543
protected palette: Color[];
544544

545-
constructor(paletteSize: number, private preferredColors: (string | undefined)[] = []) {
545+
constructor(paletteSize: number, private preferredColors: (Color | undefined | null)[] = []) {
546546
this.palette = getColorsPalette(paletteSize).filter((c) => !preferredColors.includes(c));
547547
}
548548

0 commit comments

Comments
 (0)