@odoo/o-spreadsheet 17.4.24 → 17.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,9 @@
2
2
  /**
3
3
  * This file is generated by o-spreadsheet build tools. Do not edit it.
4
4
  * @see https://github.com/odoo/o-spreadsheet
5
- * @version 17.4.24
6
- * @date 2025-02-25T05:58:55.802Z
7
- * @hash 163efbd
5
+ * @version 17.4.26
6
+ * @date 2025-03-12T15:31:45.184Z
7
+ * @hash a18429e
8
8
  */
9
9
 
10
10
  (function (exports, owl) {
@@ -5058,8 +5058,9 @@
5058
5058
  if (zone.bottom !== zone.top && zone.left != zone.right) {
5059
5059
  if (zone.right) {
5060
5060
  for (let j = zone.left; j <= zone.right; ++j) {
5061
+ const datasetOptions = j === zone.left ? dataSet : { yAxisId: dataSet.yAxisId };
5061
5062
  postProcessedRanges.push({
5062
- ...dataSet,
5063
+ ...datasetOptions,
5063
5064
  dataRange: `${sheetPrefix}${zoneToXc({
5064
5065
  left: j,
5065
5066
  right: j,
@@ -5071,8 +5072,9 @@
5071
5072
  }
5072
5073
  else {
5073
5074
  for (let j = zone.top; j <= zone.bottom; ++j) {
5075
+ const datasetOptions = j === zone.top ? dataSet : { yAxisId: dataSet.yAxisId };
5074
5076
  postProcessedRanges.push({
5075
- ...dataSet,
5077
+ ...datasetOptions,
5076
5078
  dataRange: `${sheetPrefix}${zoneToXc({
5077
5079
  left: zone.left,
5078
5080
  right: zone.right,
@@ -10184,6 +10186,15 @@ stores.inject(MyMetaStore, storeInstance);
10184
10186
  const exactMatch = proposals?.find((p) => p.text === tokenAtCursor.value);
10185
10187
  // remove tokens that are likely to be other parts of the formula that slipped in the token if it's a string
10186
10188
  const searchTerm = tokenAtCursor.value.replace(/[ ,\(\)]/g, "");
10189
+ if (this._currentContent === this.initialContent &&
10190
+ provider.displayAllOnInitialContent &&
10191
+ proposals?.length) {
10192
+ return {
10193
+ proposals,
10194
+ selectProposal: provider.selectProposal,
10195
+ autoSelectFirstProposal: provider.autoSelectFirstProposal ?? false,
10196
+ };
10197
+ }
10187
10198
  if (exactMatch && this._currentContent !== this.initialContent) {
10188
10199
  // this means the user has chosen a proposal
10189
10200
  return;
@@ -10831,70 +10842,347 @@ stores.inject(MyMetaStore, storeInstance);
10831
10842
  return bars.find((bar, i) => i > startIndex && bar.height !== 0);
10832
10843
  }
10833
10844
 
10834
- window.Chart?.register(waterfallLinesPlugin);
10835
- window.Chart?.register(chartShowValuesPlugin);
10836
- class ChartJsComponent extends owl.Component {
10837
- static template = "o-spreadsheet-ChartJsComponent";
10838
- static props = {
10839
- figure: Object,
10845
+ const GAUGE_PADDING_SIDE = 30;
10846
+ const GAUGE_PADDING_TOP = 10;
10847
+ const GAUGE_PADDING_BOTTOM = 20;
10848
+ const GAUGE_LABELS_FONT_SIZE = 12;
10849
+ const GAUGE_DEFAULT_VALUE_FONT_SIZE = 80;
10850
+ const GAUGE_BACKGROUND_COLOR = "#F3F2F1";
10851
+ const GAUGE_TEXT_COLOR = "#666666";
10852
+ const GAUGE_TEXT_COLOR_HIGH_CONTRAST = "#C8C8C8";
10853
+ const GAUGE_INFLECTION_MARKER_COLOR = "#666666aa";
10854
+ const GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN = 6;
10855
+ const GAUGE_TITLE_SECTION_HEIGHT = 25;
10856
+ const GAUGE_TITLE_FONT_SIZE = DEFAULT_CHART_FONT_SIZE;
10857
+ const GAUGE_TITLE_PADDING_LEFT = DEFAULT_CHART_PADDING;
10858
+ const GAUGE_TITLE_PADDING_TOP = DEFAULT_CHART_PADDING;
10859
+ function drawGaugeChart(canvas, runtime) {
10860
+ const canvasBoundingRect = canvas.getBoundingClientRect();
10861
+ canvas.width = canvasBoundingRect.width;
10862
+ canvas.height = canvasBoundingRect.height;
10863
+ const ctx = canvas.getContext("2d");
10864
+ const config = getGaugeRenderingConfig(canvasBoundingRect, runtime, ctx);
10865
+ drawBackground(ctx, config);
10866
+ drawGauge(ctx, config);
10867
+ drawInflectionValues(ctx, config);
10868
+ drawLabels(ctx, config);
10869
+ drawTitle(ctx, config);
10870
+ }
10871
+ function drawGauge(ctx, config) {
10872
+ ctx.save();
10873
+ const gauge = config.gauge;
10874
+ const arcCenterX = gauge.rect.x + gauge.rect.width / 2;
10875
+ const arcCenterY = gauge.rect.y + gauge.rect.height;
10876
+ const arcRadius = gauge.rect.height - gauge.arcWidth / 2;
10877
+ if (arcRadius < 0) {
10878
+ return;
10879
+ }
10880
+ const gaugeAngle = gauge.percentage === 1 ? 0 : Math.PI * (1 + gauge.percentage);
10881
+ // Gauge background
10882
+ ctx.strokeStyle = GAUGE_BACKGROUND_COLOR;
10883
+ ctx.beginPath();
10884
+ ctx.lineWidth = gauge.arcWidth;
10885
+ ctx.arc(arcCenterX, arcCenterY, arcRadius, gaugeAngle, 0);
10886
+ ctx.stroke();
10887
+ // Gauge value
10888
+ ctx.strokeStyle = gauge.color;
10889
+ ctx.beginPath();
10890
+ ctx.arc(arcCenterX, arcCenterY, arcRadius, Math.PI, gaugeAngle);
10891
+ ctx.stroke();
10892
+ ctx.restore();
10893
+ }
10894
+ function drawBackground(ctx, config) {
10895
+ ctx.save();
10896
+ ctx.fillStyle = config.backgroundColor;
10897
+ ctx.fillRect(0, 0, config.width, config.height);
10898
+ ctx.restore();
10899
+ }
10900
+ function drawLabels(ctx, config) {
10901
+ for (const label of [config.minLabel, config.maxLabel, config.gaugeValue]) {
10902
+ ctx.save();
10903
+ ctx.textAlign = "center";
10904
+ ctx.fillStyle = label.color;
10905
+ ctx.font = `${label.fontSize}px ${DEFAULT_FONT}`;
10906
+ ctx.fillText(label.label, label.textPosition.x, label.textPosition.y);
10907
+ ctx.restore();
10908
+ }
10909
+ }
10910
+ function drawInflectionValues(ctx, config) {
10911
+ const { x: rectX, y: rectY, width, height } = config.gauge.rect;
10912
+ for (const inflectionValue of config.inflectionValues) {
10913
+ ctx.save();
10914
+ ctx.translate(rectX + width / 2 - 0.5, rectY + height - 0.5); // -0.5 for sharper lines. see RendererPlugin.drawBorders comment
10915
+ ctx.rotate(Math.PI / 2 - inflectionValue.rotation);
10916
+ ctx.lineWidth = 2;
10917
+ ctx.strokeStyle = GAUGE_INFLECTION_MARKER_COLOR;
10918
+ ctx.beginPath();
10919
+ ctx.moveTo(0, -(height - config.gauge.arcWidth));
10920
+ ctx.lineTo(0, -height - 3);
10921
+ ctx.stroke();
10922
+ ctx.textAlign = "center";
10923
+ ctx.font = `${inflectionValue.fontSize}px ${DEFAULT_FONT}`;
10924
+ ctx.fillStyle = inflectionValue.color;
10925
+ const textY = -height - GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN - inflectionValue.offset;
10926
+ ctx.fillText(inflectionValue.label, 0, textY);
10927
+ ctx.restore();
10928
+ }
10929
+ }
10930
+ function drawTitle(ctx, config) {
10931
+ ctx.save();
10932
+ const title = config.title;
10933
+ ctx.font = getDefaultContextFont(title.fontSize, title.bold, title.italic);
10934
+ ctx.textBaseline = "middle";
10935
+ ctx.fillStyle = title.color;
10936
+ ctx.fillText(title.label, title.textPosition.x, title.textPosition.y);
10937
+ ctx.restore();
10938
+ }
10939
+ function getGaugeRenderingConfig(boundingRect, runtime, ctx) {
10940
+ const maxValue = runtime.maxValue;
10941
+ const minValue = runtime.minValue;
10942
+ const gaugeValue = runtime.gaugeValue;
10943
+ const gaugeRect = getGaugeRect(boundingRect, runtime.title.text);
10944
+ const gaugeArcWidth = gaugeRect.width / 6;
10945
+ const gaugePercentage = gaugeValue
10946
+ ? (gaugeValue.value - minValue.value) / (maxValue.value - minValue.value)
10947
+ : 0;
10948
+ const gaugeValuePosition = {
10949
+ x: boundingRect.width / 2,
10950
+ y: gaugeRect.y + gaugeRect.height - gaugeRect.height / 12,
10840
10951
  };
10841
- canvas = owl.useRef("graphContainer");
10842
- chart;
10843
- currentRuntime;
10844
- get background() {
10845
- return this.chartRuntime.background;
10952
+ let gaugeValueFontSize = GAUGE_DEFAULT_VALUE_FONT_SIZE;
10953
+ // Scale down the font size if the gaugeRect is too small
10954
+ if (gaugeRect.height < 300) {
10955
+ gaugeValueFontSize = gaugeValueFontSize * (gaugeRect.height / 300);
10846
10956
  }
10847
- get canvasStyle() {
10848
- return `background-color: ${this.background}`;
10957
+ // Scale down the font size if the text is too long
10958
+ const maxTextWidth = gaugeRect.width / 2;
10959
+ const gaugeLabel = gaugeValue?.label || "-";
10960
+ if (computeTextWidth(ctx, gaugeLabel, { fontSize: gaugeValueFontSize }, "px") > maxTextWidth) {
10961
+ gaugeValueFontSize = getFontSizeMatchingWidth(maxTextWidth, gaugeValueFontSize, (fontSize) => computeTextWidth(ctx, gaugeLabel, { fontSize }, "px"));
10849
10962
  }
10850
- get chartRuntime() {
10851
- const runtime = this.env.model.getters.getChartRuntime(this.props.figure.id);
10852
- if (!("chartJsConfig" in runtime)) {
10853
- throw new Error("Unsupported chart runtime");
10854
- }
10855
- return runtime;
10963
+ const minLabelPosition = {
10964
+ x: gaugeRect.x + gaugeArcWidth / 2,
10965
+ y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
10966
+ };
10967
+ const maxLabelPosition = {
10968
+ x: gaugeRect.x + gaugeRect.width - gaugeArcWidth / 2,
10969
+ y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
10970
+ };
10971
+ const textColor = getContrastedTextColor(runtime.background);
10972
+ const inflectionValues = getInflectionValues(runtime, gaugeRect, textColor, ctx);
10973
+ let x = 0, titleWidth = 0, titleHeight = 0;
10974
+ if (runtime.title.text) {
10975
+ ({ width: titleWidth, height: titleHeight } = computeTextDimension(ctx, runtime.title.text, { ...runtime.title, fontSize: GAUGE_TITLE_FONT_SIZE }, "px"));
10856
10976
  }
10857
- setup() {
10858
- owl.onMounted(() => {
10859
- const runtime = this.chartRuntime;
10860
- this.currentRuntime = runtime;
10861
- // Note: chartJS modify the runtime in place, so it's important to give it a copy
10862
- this.createChart(deepCopy(runtime.chartJsConfig));
10863
- });
10864
- owl.onWillUnmount(() => this.chart?.destroy());
10865
- owl.useEffect(() => {
10866
- const runtime = this.chartRuntime;
10867
- if (runtime !== this.currentRuntime) {
10868
- if (runtime.chartJsConfig.type !== this.currentRuntime.chartJsConfig.type) {
10869
- this.chart?.destroy();
10870
- this.createChart(deepCopy(runtime.chartJsConfig));
10871
- }
10872
- else {
10873
- this.updateChartJs(deepCopy(runtime));
10874
- }
10875
- this.currentRuntime = runtime;
10876
- }
10977
+ switch (runtime.title.align) {
10978
+ case "right":
10979
+ x = boundingRect.width - titleWidth - GAUGE_TITLE_PADDING_LEFT;
10980
+ break;
10981
+ case "center":
10982
+ x = (boundingRect.width - titleWidth) / 2;
10983
+ break;
10984
+ case "left":
10985
+ default:
10986
+ x = GAUGE_TITLE_PADDING_LEFT;
10987
+ break;
10988
+ }
10989
+ return {
10990
+ width: boundingRect.width,
10991
+ height: boundingRect.height,
10992
+ title: {
10993
+ label: runtime.title.text ?? "",
10994
+ fontSize: GAUGE_TITLE_FONT_SIZE,
10995
+ textPosition: {
10996
+ x,
10997
+ y: GAUGE_TITLE_PADDING_TOP + titleHeight / 2,
10998
+ },
10999
+ color: runtime.title.color ?? textColor,
11000
+ bold: runtime.title.bold,
11001
+ italic: runtime.title.italic,
11002
+ },
11003
+ backgroundColor: runtime.background,
11004
+ gauge: {
11005
+ rect: gaugeRect,
11006
+ arcWidth: gaugeArcWidth,
11007
+ percentage: clip(gaugePercentage, 0, 1),
11008
+ color: getGaugeColor(runtime),
11009
+ },
11010
+ inflectionValues,
11011
+ gaugeValue: {
11012
+ label: gaugeLabel,
11013
+ textPosition: gaugeValuePosition,
11014
+ fontSize: gaugeValueFontSize,
11015
+ color: textColor,
11016
+ },
11017
+ minLabel: {
11018
+ label: runtime.minValue.label,
11019
+ textPosition: minLabelPosition,
11020
+ fontSize: GAUGE_LABELS_FONT_SIZE,
11021
+ color: textColor,
11022
+ },
11023
+ maxLabel: {
11024
+ label: runtime.maxValue.label,
11025
+ textPosition: maxLabelPosition,
11026
+ fontSize: GAUGE_LABELS_FONT_SIZE,
11027
+ color: textColor,
11028
+ },
11029
+ };
11030
+ }
11031
+ /**
11032
+ * Get the rectangle in which the gauge will be drawn, based on the bounding rectangle of the canvas and leaving
11033
+ * space for the title and labels.
11034
+ */
11035
+ function getGaugeRect(boundingRect, title) {
11036
+ const titleHeight = title ? GAUGE_TITLE_SECTION_HEIGHT : 0;
11037
+ const drawHeight = boundingRect.height - GAUGE_PADDING_BOTTOM - titleHeight - GAUGE_PADDING_TOP;
11038
+ const drawWidth = boundingRect.width - GAUGE_PADDING_SIDE * 2;
11039
+ let gaugeWidth;
11040
+ let gaugeHeight;
11041
+ if (drawWidth > 2 * drawHeight) {
11042
+ gaugeWidth = 2 * drawHeight;
11043
+ gaugeHeight = drawHeight;
11044
+ }
11045
+ else {
11046
+ gaugeWidth = drawWidth;
11047
+ gaugeHeight = drawWidth / 2;
11048
+ }
11049
+ const gaugeX = GAUGE_PADDING_SIDE + (drawWidth - gaugeWidth) / 2;
11050
+ const gaugeY = titleHeight + GAUGE_PADDING_TOP + (drawHeight - gaugeHeight) / 2;
11051
+ return {
11052
+ x: gaugeX,
11053
+ y: gaugeY,
11054
+ width: gaugeWidth,
11055
+ height: gaugeHeight,
11056
+ };
11057
+ }
11058
+ /**
11059
+ * Get the infliction values of the gauge, and where to draw them (the angle from the center of the gauge at which they are drawn).
11060
+ *
11061
+ * Also compute an offset for the text so that it doesn't overlap with other text.
11062
+ */
11063
+ function getInflectionValues(runtime, gaugeRect, textColor, ctx) {
11064
+ const maxValue = runtime.maxValue;
11065
+ const minValue = runtime.minValue;
11066
+ const gaugeCircleCenter = {
11067
+ x: gaugeRect.x + gaugeRect.width / 2,
11068
+ y: gaugeRect.y + gaugeRect.height,
11069
+ };
11070
+ const textStyle = { fontSize: GAUGE_LABELS_FONT_SIZE };
11071
+ const inflectionValues = [];
11072
+ const inflectionValuesTextRects = [];
11073
+ for (const inflectionValue of runtime.inflectionValues) {
11074
+ const percentage = (inflectionValue.value - minValue.value) / (maxValue.value - minValue.value);
11075
+ const labelWidth = computeTextWidth(ctx, inflectionValue.label, textStyle, "px");
11076
+ const angle = Math.PI - Math.PI * percentage;
11077
+ const textRect = getRectangleTangentToCircle(angle, // angle between X axis and the point where the rectangle is tangent to the circle
11078
+ gaugeRect.height + GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN, // radius of the gauge circle + margin below text
11079
+ gaugeCircleCenter.x, // center of the gauge circle
11080
+ gaugeCircleCenter.y, // center of the gauge circle
11081
+ labelWidth + 2, // width of the text + some margin
11082
+ GAUGE_LABELS_FONT_SIZE // height of the text
11083
+ );
11084
+ let offset = inflectionValuesTextRects.some((rect) => doRectanglesIntersect(rect, textRect))
11085
+ ? GAUGE_LABELS_FONT_SIZE
11086
+ : 0;
11087
+ inflectionValuesTextRects.push(textRect);
11088
+ inflectionValues.push({
11089
+ rotation: angle,
11090
+ label: inflectionValue.label,
11091
+ fontSize: GAUGE_LABELS_FONT_SIZE,
11092
+ color: textColor,
11093
+ offset,
10877
11094
  });
10878
11095
  }
10879
- createChart(chartData) {
10880
- const canvas = this.canvas.el;
10881
- const ctx = canvas.getContext("2d");
10882
- this.chart = new window.Chart(ctx, chartData);
11096
+ return inflectionValues;
11097
+ }
11098
+ function getGaugeColor(runtime) {
11099
+ const gaugeValue = runtime.gaugeValue?.value;
11100
+ if (gaugeValue === undefined) {
11101
+ return GAUGE_BACKGROUND_COLOR;
10883
11102
  }
10884
- updateChartJs(chartRuntime) {
10885
- const chartData = chartRuntime.chartJsConfig;
10886
- if (chartData.data && chartData.data.datasets) {
10887
- this.chart.data = chartData.data;
10888
- if (chartData.options?.plugins?.title) {
10889
- this.chart.config.options.plugins.title = chartData.options.plugins.title;
11103
+ let colorIndex = 0;
11104
+ while (runtime.inflectionValues[colorIndex]?.value <= gaugeValue) {
11105
+ colorIndex++;
11106
+ }
11107
+ return runtime.colors[colorIndex];
11108
+ }
11109
+ function getContrastedTextColor(backgroundColor) {
11110
+ return relativeLuminance(backgroundColor) > 0.3
11111
+ ? GAUGE_TEXT_COLOR
11112
+ : GAUGE_TEXT_COLOR_HIGH_CONTRAST;
11113
+ }
11114
+ function getSegmentsOfRectangle(rectangle) {
11115
+ return [
11116
+ { start: rectangle.topLeft, end: rectangle.topRight },
11117
+ { start: rectangle.topRight, end: rectangle.bottomRight },
11118
+ { start: rectangle.bottomRight, end: rectangle.bottomLeft },
11119
+ { start: rectangle.bottomLeft, end: rectangle.topLeft },
11120
+ ];
11121
+ }
11122
+ /**
11123
+ * Check if two segment intersect. The case where the segments are colinear (both segments on the same line)
11124
+ * is not handled.
11125
+ */
11126
+ function doSegmentIntersect(segment1, segment2) {
11127
+ const A = segment1.start;
11128
+ const B = segment1.end;
11129
+ const C = segment2.start;
11130
+ const D = segment2.end;
11131
+ /**
11132
+ * Line segment intersection algorithm
11133
+ * https://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/
11134
+ */
11135
+ function ccw(a, b, c) {
11136
+ return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
11137
+ }
11138
+ return ccw(A, C, D) !== ccw(B, C, D) && ccw(A, B, C) !== ccw(A, B, D);
11139
+ }
11140
+ function doRectanglesIntersect(rect1, rect2) {
11141
+ const segments1 = getSegmentsOfRectangle(rect1);
11142
+ const segments2 = getSegmentsOfRectangle(rect2);
11143
+ for (const segment1 of segments1) {
11144
+ for (const segment2 of segments2) {
11145
+ if (doSegmentIntersect(segment1, segment2)) {
11146
+ return true;
10890
11147
  }
10891
11148
  }
10892
- else {
10893
- this.chart.data.datasets = [];
10894
- }
10895
- this.chart.config.options = chartData.options;
10896
- this.chart.update();
10897
11149
  }
11150
+ return false;
11151
+ }
11152
+ /**
11153
+ * Get the rectangle that is tangent to a circle at a given angle.
11154
+ *
11155
+ * @param angle angle between X axis and the point where the rectangle is tangent to the circle
11156
+ */
11157
+ function getRectangleTangentToCircle(angle, radius, circleCenterX, circleCenterY, rectWidth, rectHeight) {
11158
+ const cos = Math.cos(angle);
11159
+ const sin = Math.sin(angle);
11160
+ // x, y are the distance from the center of the circle to the point where the rectangle is tangent to the circle
11161
+ const x = cos * radius;
11162
+ const y = sin * radius;
11163
+ // x2, y2 are the distance from the point the rectangle is tangent to the circle to the bottom left corner of the rectangle
11164
+ const x2 = sin * (rectWidth / 2); // cos(angle + 90°) = sin(angle)
11165
+ const y2 = cos * (rectWidth / 2);
11166
+ const bottomRight = {
11167
+ x: x + x2 + circleCenterX,
11168
+ y: circleCenterY - (y - y2),
11169
+ };
11170
+ const bottomLeft = {
11171
+ x: x - x2 + circleCenterX,
11172
+ y: circleCenterY - (y + y2),
11173
+ };
11174
+ // Same as above but for the top corners of the rectangle (radius + rectangle height instead of radius)
11175
+ const xp = cos * (radius + rectHeight);
11176
+ const yp = sin * (radius + rectHeight);
11177
+ const topLeft = {
11178
+ x: xp - x2 + circleCenterX,
11179
+ y: circleCenterY - (yp + y2),
11180
+ };
11181
+ const topRight = {
11182
+ x: xp + x2 + circleCenterX,
11183
+ y: circleCenterY - (yp - y2),
11184
+ };
11185
+ return { bottomLeft, bottomRight, topRight, topLeft };
10898
11186
  }
10899
11187
 
10900
11188
  /**
@@ -11560,6 +11848,364 @@ stores.inject(MyMetaStore, storeInstance);
11560
11848
  }
11561
11849
  }
11562
11850
 
11851
+ /**
11852
+ * This file contains helpers that are common to different runtime charts (mainly
11853
+ * line, bar and pie charts)
11854
+ */
11855
+ /**
11856
+ * Get the data from a dataSet
11857
+ */
11858
+ function getData(getters, ds) {
11859
+ if (ds.dataRange) {
11860
+ const labelCellZone = ds.labelCell ? [ds.labelCell.zone] : [];
11861
+ const dataZone = recomputeZones([ds.dataRange.zone], labelCellZone)[0];
11862
+ if (dataZone === undefined) {
11863
+ return [];
11864
+ }
11865
+ const dataRange = getters.getRangeFromZone(ds.dataRange.sheetId, dataZone);
11866
+ return getters.getRangeValues(dataRange).map((value) => (value === "" ? undefined : value));
11867
+ }
11868
+ return [];
11869
+ }
11870
+ function filterEmptyDataPoints(labels, datasets) {
11871
+ const numberOfDataPoints = Math.max(labels.length, ...datasets.map((dataset) => dataset.data?.length || 0));
11872
+ const dataPointsIndexes = range(0, numberOfDataPoints).filter((dataPointIndex) => {
11873
+ const label = labels[dataPointIndex];
11874
+ const values = datasets.map((dataset) => dataset.data?.[dataPointIndex]);
11875
+ return label || values.some((value) => value === 0 || Boolean(value));
11876
+ });
11877
+ return {
11878
+ labels: dataPointsIndexes.map((i) => labels[i] || ""),
11879
+ dataSetsValues: datasets.map((dataset) => ({
11880
+ ...dataset,
11881
+ data: dataPointsIndexes.map((i) => dataset.data[i]),
11882
+ })),
11883
+ };
11884
+ }
11885
+ /**
11886
+ * Aggregates data based on labels
11887
+ */
11888
+ function aggregateDataForLabels(labels, datasets) {
11889
+ const parseNumber = (value) => (typeof value === "number" ? value : 0);
11890
+ const labelSet = new Set(labels);
11891
+ const labelMap = {};
11892
+ labelSet.forEach((label) => {
11893
+ labelMap[label] = new Array(datasets.length).fill(0);
11894
+ });
11895
+ for (const indexOfLabel of range(0, labels.length)) {
11896
+ const label = labels[indexOfLabel];
11897
+ for (const indexOfDataset of range(0, datasets.length)) {
11898
+ labelMap[label][indexOfDataset] += parseNumber(datasets[indexOfDataset].data[indexOfLabel]);
11899
+ }
11900
+ }
11901
+ return {
11902
+ labels: Array.from(labelSet),
11903
+ dataSetsValues: datasets.map((dataset, indexOfDataset) => ({
11904
+ ...dataset,
11905
+ data: Array.from(labelSet).map((label) => labelMap[label][indexOfDataset]),
11906
+ })),
11907
+ };
11908
+ }
11909
+ function truncateLabel(label) {
11910
+ if (!label) {
11911
+ return "";
11912
+ }
11913
+ if (label.length > MAX_CHAR_LABEL) {
11914
+ return label.substring(0, MAX_CHAR_LABEL) + "…";
11915
+ }
11916
+ return label;
11917
+ }
11918
+ /**
11919
+ * Get a default chart js configuration
11920
+ */
11921
+ function getDefaultChartJsRuntime(chart, labels, fontColor, { format, locale, truncateLabels = true, horizontalChart, }) {
11922
+ const chartTitle = chart.title.text ? chart.title : { ...chart.title, content: "" };
11923
+ const options = {
11924
+ // https://www.chartjs.org/docs/latest/general/responsive.html
11925
+ responsive: true, // will resize when its container is resized
11926
+ maintainAspectRatio: false, // doesn't maintain the aspect ration (width/height =2 by default) so the user has the choice of the exact layout
11927
+ layout: {
11928
+ padding: {
11929
+ left: DEFAULT_CHART_PADDING,
11930
+ right: DEFAULT_CHART_PADDING,
11931
+ top: chartTitle.text ? DEFAULT_CHART_PADDING / 2 : DEFAULT_CHART_PADDING + 5,
11932
+ bottom: DEFAULT_CHART_PADDING,
11933
+ },
11934
+ },
11935
+ elements: {
11936
+ line: {
11937
+ fill: false, // do not fill the area under line charts
11938
+ },
11939
+ point: {
11940
+ hitRadius: 15, // increased hit radius to display point tooltip when hovering nearby
11941
+ },
11942
+ },
11943
+ animation: false,
11944
+ plugins: {
11945
+ title: {
11946
+ display: !!chartTitle.text,
11947
+ text: _t(chartTitle.text),
11948
+ color: chartTitle?.color ?? fontColor,
11949
+ align: chartTitle.align === "center" ? "center" : chartTitle.align === "right" ? "end" : "start",
11950
+ font: {
11951
+ size: DEFAULT_CHART_FONT_SIZE,
11952
+ weight: chartTitle.bold ? "bold" : "normal",
11953
+ style: chartTitle.italic ? "italic" : "normal",
11954
+ },
11955
+ },
11956
+ legend: {
11957
+ // Disable default legend onClick (show/hide dataset), to allow us to set a global onClick on the chart container.
11958
+ // If we want to re-enable this in the future, we need to override the default onClick to stop the event propagation
11959
+ onClick: () => { },
11960
+ },
11961
+ tooltip: {
11962
+ callbacks: {
11963
+ label: function (tooltipItem) {
11964
+ const xLabel = tooltipItem.dataset?.label || tooltipItem.label;
11965
+ // tooltipItem.parsed can be an object or a number for pie charts
11966
+ let yLabel = horizontalChart ? tooltipItem.parsed.x : tooltipItem.parsed.y;
11967
+ if (yLabel === undefined || yLabel === null) {
11968
+ yLabel = tooltipItem.parsed;
11969
+ }
11970
+ const toolTipFormat = !format && Math.abs(yLabel) >= 1000 ? "#,##" : format;
11971
+ const yLabelStr = formatValue(yLabel, { format: toolTipFormat, locale });
11972
+ return xLabel ? `${xLabel}: ${yLabelStr}` : yLabelStr;
11973
+ },
11974
+ },
11975
+ },
11976
+ },
11977
+ };
11978
+ return {
11979
+ type: chart.type,
11980
+ options,
11981
+ data: {
11982
+ labels: truncateLabels ? labels.map(truncateLabel) : labels,
11983
+ datasets: [],
11984
+ },
11985
+ platform: undefined, // This key is optional and will be set by chart.js
11986
+ plugins: [],
11987
+ };
11988
+ }
11989
+ function getChartLabelFormat(getters, range, shouldRemoveFirstLabel) {
11990
+ if (!range)
11991
+ return undefined;
11992
+ const { sheetId, zone } = range;
11993
+ const formats = positions(zone).map((position) => getters.getEvaluatedCell({ sheetId, ...position }).format);
11994
+ if (shouldRemoveFirstLabel) {
11995
+ formats.shift();
11996
+ }
11997
+ return formats.find((format) => format !== undefined);
11998
+ }
11999
+ function getChartLabelValues(getters, dataSets, labelRange) {
12000
+ let labels = { values: [], formattedValues: [] };
12001
+ if (labelRange) {
12002
+ if (!labelRange.invalidXc && !labelRange.invalidSheetName) {
12003
+ labels = {
12004
+ formattedValues: getters.getRangeFormattedValues(labelRange),
12005
+ values: getters.getRangeValues(labelRange).map((val) => String(val ?? "")),
12006
+ };
12007
+ }
12008
+ }
12009
+ else if (dataSets.length === 1) {
12010
+ for (let i = 0; i < getData(getters, dataSets[0]).length; i++) {
12011
+ labels.formattedValues.push("");
12012
+ labels.values.push("");
12013
+ }
12014
+ }
12015
+ else {
12016
+ if (dataSets[0]) {
12017
+ const ranges = getData(getters, dataSets[0]);
12018
+ labels = {
12019
+ formattedValues: range(0, ranges.length).map((r) => r.toString()),
12020
+ values: labels.formattedValues,
12021
+ };
12022
+ }
12023
+ }
12024
+ return labels;
12025
+ }
12026
+ /**
12027
+ * Get the format to apply to the the dataset values. This format is defined as the first format
12028
+ * found in the dataset ranges that isn't a date format.
12029
+ */
12030
+ function getChartDatasetFormat(getters, dataSets) {
12031
+ for (const ds of dataSets) {
12032
+ const formatsInDataset = getters.getRangeFormats(ds.dataRange);
12033
+ const format = formatsInDataset.find((f) => f !== undefined && !isDateTimeFormat(f));
12034
+ if (format)
12035
+ return format;
12036
+ }
12037
+ return undefined;
12038
+ }
12039
+ function getChartDatasetValues(getters, dataSets) {
12040
+ const datasetValues = [];
12041
+ for (const [dsIndex, ds] of Object.entries(dataSets)) {
12042
+ let label;
12043
+ if (ds.labelCell) {
12044
+ const labelRange = ds.labelCell;
12045
+ const cell = labelRange
12046
+ ? getters.getEvaluatedCell({
12047
+ sheetId: labelRange.sheetId,
12048
+ col: labelRange.zone.left,
12049
+ row: labelRange.zone.top,
12050
+ })
12051
+ : undefined;
12052
+ label =
12053
+ cell && labelRange
12054
+ ? truncateLabel(cell.formattedValue)
12055
+ : (label = `${ChartTerms.Series} ${parseInt(dsIndex) + 1}`);
12056
+ }
12057
+ else {
12058
+ label = `${ChartTerms.Series} ${parseInt(dsIndex) + 1}`;
12059
+ }
12060
+ let data = ds.dataRange ? getData(getters, ds) : [];
12061
+ if (data.every((e) => typeof e === "string" && !isEvaluationError(e))) {
12062
+ // In this case, we want a chart based on the string occurrences count
12063
+ // This will be done by associating each string with a value of 1 and
12064
+ // the using the classical aggregation method to sum the values.
12065
+ data.fill(1);
12066
+ }
12067
+ datasetValues.push({ data, label });
12068
+ }
12069
+ return datasetValues;
12070
+ }
12071
+ /**
12072
+ * If the chart is a stacked area chart, we want to fill until the next dataset.
12073
+ * If the chart is a simple area chart, we want to fill until the origin (bottom axis).
12074
+ *
12075
+ * See https://www.chartjs.org/docs/latest/charts/area.html#filling-modes
12076
+ */
12077
+ function getFillingMode(index, stackedChart) {
12078
+ if (!stackedChart) {
12079
+ return "origin";
12080
+ }
12081
+ return index === 0 ? "origin" : "-1";
12082
+ }
12083
+ function chartToImage(runtime, figure, type) {
12084
+ // wrap the canvas in a div with a fixed size because chart.js would
12085
+ // fill the whole page otherwise
12086
+ const div = document.createElement("div");
12087
+ div.style.width = `${figure.width}px`;
12088
+ div.style.height = `${figure.height}px`;
12089
+ const canvas = document.createElement("canvas");
12090
+ div.append(canvas);
12091
+ canvas.setAttribute("width", figure.width.toString());
12092
+ canvas.setAttribute("height", figure.height.toString());
12093
+ // we have to add the canvas to the DOM otherwise it won't be rendered
12094
+ document.body.append(div);
12095
+ if ("chartJsConfig" in runtime) {
12096
+ const config = deepCopy(runtime.chartJsConfig);
12097
+ config.plugins = [backgroundColorChartJSPlugin];
12098
+ const Chart = getChartJSConstructor();
12099
+ const chart = new Chart(canvas, config);
12100
+ const imgContent = chart.toBase64Image();
12101
+ chart.destroy();
12102
+ div.remove();
12103
+ return imgContent;
12104
+ }
12105
+ else if (type === "scorecard") {
12106
+ const design = getScorecardConfiguration(figure, runtime);
12107
+ drawScoreChart(design, canvas);
12108
+ const imgContent = canvas.toDataURL();
12109
+ div.remove();
12110
+ return imgContent;
12111
+ }
12112
+ else if (type === "gauge") {
12113
+ drawGaugeChart(canvas, runtime);
12114
+ const imgContent = canvas.toDataURL();
12115
+ div.remove();
12116
+ return imgContent;
12117
+ }
12118
+ return undefined;
12119
+ }
12120
+ /**
12121
+ * Custom chart.js plugin to set the background color of the canvas
12122
+ * https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
12123
+ */
12124
+ const backgroundColorChartJSPlugin = {
12125
+ id: "customCanvasBackgroundColor",
12126
+ beforeDraw: (chart) => {
12127
+ const { ctx } = chart;
12128
+ ctx.save();
12129
+ ctx.globalCompositeOperation = "destination-over";
12130
+ ctx.fillStyle = "#ffffff";
12131
+ ctx.fillRect(0, 0, chart.width, chart.height);
12132
+ ctx.restore();
12133
+ },
12134
+ };
12135
+ /** Return window.Chart, making sure all our extensions are loaded in ChartJS */
12136
+ function getChartJSConstructor() {
12137
+ if (window.Chart && !window.Chart?.registry.plugins.get("chartShowValuesPlugin")) {
12138
+ window.Chart.register(chartShowValuesPlugin);
12139
+ window.Chart.register(waterfallLinesPlugin);
12140
+ }
12141
+ return window.Chart;
12142
+ }
12143
+
12144
+ class ChartJsComponent extends owl.Component {
12145
+ static template = "o-spreadsheet-ChartJsComponent";
12146
+ static props = {
12147
+ figure: Object,
12148
+ };
12149
+ canvas = owl.useRef("graphContainer");
12150
+ chart;
12151
+ currentRuntime;
12152
+ get background() {
12153
+ return this.chartRuntime.background;
12154
+ }
12155
+ get canvasStyle() {
12156
+ return `background-color: ${this.background}`;
12157
+ }
12158
+ get chartRuntime() {
12159
+ const runtime = this.env.model.getters.getChartRuntime(this.props.figure.id);
12160
+ if (!("chartJsConfig" in runtime)) {
12161
+ throw new Error("Unsupported chart runtime");
12162
+ }
12163
+ return runtime;
12164
+ }
12165
+ setup() {
12166
+ owl.onMounted(() => {
12167
+ const runtime = this.chartRuntime;
12168
+ this.currentRuntime = runtime;
12169
+ // Note: chartJS modify the runtime in place, so it's important to give it a copy
12170
+ this.createChart(deepCopy(runtime.chartJsConfig));
12171
+ });
12172
+ owl.onWillUnmount(() => this.chart?.destroy());
12173
+ owl.useEffect(() => {
12174
+ const runtime = this.chartRuntime;
12175
+ if (runtime !== this.currentRuntime) {
12176
+ if (runtime.chartJsConfig.type !== this.currentRuntime.chartJsConfig.type) {
12177
+ this.chart?.destroy();
12178
+ this.createChart(deepCopy(runtime.chartJsConfig));
12179
+ }
12180
+ else {
12181
+ this.updateChartJs(deepCopy(runtime));
12182
+ }
12183
+ this.currentRuntime = runtime;
12184
+ }
12185
+ });
12186
+ }
12187
+ createChart(chartData) {
12188
+ const canvas = this.canvas.el;
12189
+ const ctx = canvas.getContext("2d");
12190
+ const Chart = getChartJSConstructor();
12191
+ this.chart = new Chart(ctx, chartData);
12192
+ }
12193
+ updateChartJs(chartRuntime) {
12194
+ const chartData = chartRuntime.chartJsConfig;
12195
+ if (chartData.data && chartData.data.datasets) {
12196
+ this.chart.data = chartData.data;
12197
+ if (chartData.options?.plugins?.title) {
12198
+ this.chart.config.options.plugins.title = chartData.options.plugins.title;
12199
+ }
12200
+ }
12201
+ else {
12202
+ this.chart.data.datasets = [];
12203
+ }
12204
+ this.chart.config.options = chartData.options;
12205
+ this.chart.update();
12206
+ }
12207
+ }
12208
+
11563
12209
  class ScorecardChart extends owl.Component {
11564
12210
  static template = "o-spreadsheet-ScorecardChart";
11565
12211
  static props = {
@@ -11589,6 +12235,7 @@ stores.inject(MyMetaStore, storeInstance);
11589
12235
  }
11590
12236
 
11591
12237
  autoCompleteProviders.add("dataValidation", {
12238
+ displayAllOnInitialContent: true,
11592
12239
  getProposals(tokenAtCursor, content) {
11593
12240
  if (content.startsWith("=")) {
11594
12241
  return [];
@@ -24400,7 +25047,7 @@ stores.inject(MyMetaStore, storeInstance);
24400
25047
  condition: (cell) => !cell.isFormula &&
24401
25048
  evaluateLiteral(cell, { locale: DEFAULT_LOCALE }).type === CellValueType.text &&
24402
25049
  alphaNumericValueRegExp.test(cell.content),
24403
- generateRule: (cell, cells) => {
25050
+ generateRule: (cell, cells, direction) => {
24404
25051
  const numberPostfix = parseInt(cell.content.match(numberPostfixRegExp)[0]);
24405
25052
  const prefix = cell.content.match(stringPrefixRegExp)[0];
24406
25053
  const numberPostfixLength = cell.content.length - prefix.length;
@@ -24408,7 +25055,10 @@ stores.inject(MyMetaStore, storeInstance);
24408
25055
  alphaNumericValueRegExp.test(evaluatedCell.value)) // get consecutive alphanumeric cells, no matter what the prefix is
24409
25056
  .filter((cell) => prefix === (cell.value ?? "").toString().match(stringPrefixRegExp)[0])
24410
25057
  .map((cell) => parseInt((cell.value ?? "").toString().match(numberPostfixRegExp)[0]));
24411
- const increment = calculateIncrementBasedOnGroup(group);
25058
+ let increment = calculateIncrementBasedOnGroup(group);
25059
+ if (["up", "left"].includes(direction) && group.length === 1) {
25060
+ increment = -increment;
25061
+ }
24412
25062
  return {
24413
25063
  type: "ALPHANUMERIC_INCREMENT_MODIFIER",
24414
25064
  prefix,
@@ -24437,9 +25087,12 @@ stores.inject(MyMetaStore, storeInstance);
24437
25087
  .add("increment_number", {
24438
25088
  condition: (cell) => !cell.isFormula &&
24439
25089
  evaluateLiteral(cell, { locale: DEFAULT_LOCALE }).type === CellValueType.number,
24440
- generateRule: (cell, cells) => {
25090
+ generateRule: (cell, cells, direction) => {
24441
25091
  const group = getGroup(cell, cells, (evaluatedCell) => evaluatedCell.type === CellValueType.number).map((cell) => Number(cell.value));
24442
- const increment = calculateIncrementBasedOnGroup(group);
25092
+ let increment = calculateIncrementBasedOnGroup(group);
25093
+ if (["up", "left"].includes(direction) && group.length === 1) {
25094
+ increment = -increment;
25095
+ }
24443
25096
  const evaluation = evaluateLiteral(cell, { locale: DEFAULT_LOCALE });
24444
25097
  return {
24445
25098
  type: "INCREMENT_MODIFIER",
@@ -24450,349 +25103,6 @@ stores.inject(MyMetaStore, storeInstance);
24450
25103
  sequence: 40,
24451
25104
  });
24452
25105
 
24453
- const GAUGE_PADDING_SIDE = 30;
24454
- const GAUGE_PADDING_TOP = 10;
24455
- const GAUGE_PADDING_BOTTOM = 20;
24456
- const GAUGE_LABELS_FONT_SIZE = 12;
24457
- const GAUGE_DEFAULT_VALUE_FONT_SIZE = 80;
24458
- const GAUGE_BACKGROUND_COLOR = "#F3F2F1";
24459
- const GAUGE_TEXT_COLOR = "#666666";
24460
- const GAUGE_TEXT_COLOR_HIGH_CONTRAST = "#C8C8C8";
24461
- const GAUGE_INFLECTION_MARKER_COLOR = "#666666aa";
24462
- const GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN = 6;
24463
- const GAUGE_TITLE_SECTION_HEIGHT = 25;
24464
- const GAUGE_TITLE_FONT_SIZE = DEFAULT_CHART_FONT_SIZE;
24465
- const GAUGE_TITLE_PADDING_LEFT = DEFAULT_CHART_PADDING;
24466
- const GAUGE_TITLE_PADDING_TOP = DEFAULT_CHART_PADDING;
24467
- function drawGaugeChart(canvas, runtime) {
24468
- const canvasBoundingRect = canvas.getBoundingClientRect();
24469
- canvas.width = canvasBoundingRect.width;
24470
- canvas.height = canvasBoundingRect.height;
24471
- const ctx = canvas.getContext("2d");
24472
- const config = getGaugeRenderingConfig(canvasBoundingRect, runtime, ctx);
24473
- drawBackground(ctx, config);
24474
- drawGauge(ctx, config);
24475
- drawInflectionValues(ctx, config);
24476
- drawLabels(ctx, config);
24477
- drawTitle(ctx, config);
24478
- }
24479
- function drawGauge(ctx, config) {
24480
- ctx.save();
24481
- const gauge = config.gauge;
24482
- const arcCenterX = gauge.rect.x + gauge.rect.width / 2;
24483
- const arcCenterY = gauge.rect.y + gauge.rect.height;
24484
- const arcRadius = gauge.rect.height - gauge.arcWidth / 2;
24485
- if (arcRadius < 0) {
24486
- return;
24487
- }
24488
- const gaugeAngle = gauge.percentage === 1 ? 0 : Math.PI * (1 + gauge.percentage);
24489
- // Gauge background
24490
- ctx.strokeStyle = GAUGE_BACKGROUND_COLOR;
24491
- ctx.beginPath();
24492
- ctx.lineWidth = gauge.arcWidth;
24493
- ctx.arc(arcCenterX, arcCenterY, arcRadius, gaugeAngle, 0);
24494
- ctx.stroke();
24495
- // Gauge value
24496
- ctx.strokeStyle = gauge.color;
24497
- ctx.beginPath();
24498
- ctx.arc(arcCenterX, arcCenterY, arcRadius, Math.PI, gaugeAngle);
24499
- ctx.stroke();
24500
- ctx.restore();
24501
- }
24502
- function drawBackground(ctx, config) {
24503
- ctx.save();
24504
- ctx.fillStyle = config.backgroundColor;
24505
- ctx.fillRect(0, 0, config.width, config.height);
24506
- ctx.restore();
24507
- }
24508
- function drawLabels(ctx, config) {
24509
- for (const label of [config.minLabel, config.maxLabel, config.gaugeValue]) {
24510
- ctx.save();
24511
- ctx.textAlign = "center";
24512
- ctx.fillStyle = label.color;
24513
- ctx.font = `${label.fontSize}px ${DEFAULT_FONT}`;
24514
- ctx.fillText(label.label, label.textPosition.x, label.textPosition.y);
24515
- ctx.restore();
24516
- }
24517
- }
24518
- function drawInflectionValues(ctx, config) {
24519
- const { x: rectX, y: rectY, width, height } = config.gauge.rect;
24520
- for (const inflectionValue of config.inflectionValues) {
24521
- ctx.save();
24522
- ctx.translate(rectX + width / 2 - 0.5, rectY + height - 0.5); // -0.5 for sharper lines. see RendererPlugin.drawBorders comment
24523
- ctx.rotate(Math.PI / 2 - inflectionValue.rotation);
24524
- ctx.lineWidth = 2;
24525
- ctx.strokeStyle = GAUGE_INFLECTION_MARKER_COLOR;
24526
- ctx.beginPath();
24527
- ctx.moveTo(0, -(height - config.gauge.arcWidth));
24528
- ctx.lineTo(0, -height - 3);
24529
- ctx.stroke();
24530
- ctx.textAlign = "center";
24531
- ctx.font = `${inflectionValue.fontSize}px ${DEFAULT_FONT}`;
24532
- ctx.fillStyle = inflectionValue.color;
24533
- const textY = -height - GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN - inflectionValue.offset;
24534
- ctx.fillText(inflectionValue.label, 0, textY);
24535
- ctx.restore();
24536
- }
24537
- }
24538
- function drawTitle(ctx, config) {
24539
- ctx.save();
24540
- const title = config.title;
24541
- ctx.font = getDefaultContextFont(title.fontSize, title.bold, title.italic);
24542
- ctx.textBaseline = "middle";
24543
- ctx.fillStyle = title.color;
24544
- ctx.fillText(title.label, title.textPosition.x, title.textPosition.y);
24545
- ctx.restore();
24546
- }
24547
- function getGaugeRenderingConfig(boundingRect, runtime, ctx) {
24548
- const maxValue = runtime.maxValue;
24549
- const minValue = runtime.minValue;
24550
- const gaugeValue = runtime.gaugeValue;
24551
- const gaugeRect = getGaugeRect(boundingRect, runtime.title.text);
24552
- const gaugeArcWidth = gaugeRect.width / 6;
24553
- const gaugePercentage = gaugeValue
24554
- ? (gaugeValue.value - minValue.value) / (maxValue.value - minValue.value)
24555
- : 0;
24556
- const gaugeValuePosition = {
24557
- x: boundingRect.width / 2,
24558
- y: gaugeRect.y + gaugeRect.height - gaugeRect.height / 12,
24559
- };
24560
- let gaugeValueFontSize = GAUGE_DEFAULT_VALUE_FONT_SIZE;
24561
- // Scale down the font size if the gaugeRect is too small
24562
- if (gaugeRect.height < 300) {
24563
- gaugeValueFontSize = gaugeValueFontSize * (gaugeRect.height / 300);
24564
- }
24565
- // Scale down the font size if the text is too long
24566
- const maxTextWidth = gaugeRect.width / 2;
24567
- const gaugeLabel = gaugeValue?.label || "-";
24568
- if (computeTextWidth(ctx, gaugeLabel, { fontSize: gaugeValueFontSize }, "px") > maxTextWidth) {
24569
- gaugeValueFontSize = getFontSizeMatchingWidth(maxTextWidth, gaugeValueFontSize, (fontSize) => computeTextWidth(ctx, gaugeLabel, { fontSize }, "px"));
24570
- }
24571
- const minLabelPosition = {
24572
- x: gaugeRect.x + gaugeArcWidth / 2,
24573
- y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
24574
- };
24575
- const maxLabelPosition = {
24576
- x: gaugeRect.x + gaugeRect.width - gaugeArcWidth / 2,
24577
- y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
24578
- };
24579
- const textColor = getContrastedTextColor(runtime.background);
24580
- const inflectionValues = getInflectionValues(runtime, gaugeRect, textColor, ctx);
24581
- let x = 0, titleWidth = 0, titleHeight = 0;
24582
- if (runtime.title.text) {
24583
- ({ width: titleWidth, height: titleHeight } = computeTextDimension(ctx, runtime.title.text, { ...runtime.title, fontSize: GAUGE_TITLE_FONT_SIZE }, "px"));
24584
- }
24585
- switch (runtime.title.align) {
24586
- case "right":
24587
- x = boundingRect.width - titleWidth - GAUGE_TITLE_PADDING_LEFT;
24588
- break;
24589
- case "center":
24590
- x = (boundingRect.width - titleWidth) / 2;
24591
- break;
24592
- case "left":
24593
- default:
24594
- x = GAUGE_TITLE_PADDING_LEFT;
24595
- break;
24596
- }
24597
- return {
24598
- width: boundingRect.width,
24599
- height: boundingRect.height,
24600
- title: {
24601
- label: runtime.title.text ?? "",
24602
- fontSize: GAUGE_TITLE_FONT_SIZE,
24603
- textPosition: {
24604
- x,
24605
- y: GAUGE_TITLE_PADDING_TOP + titleHeight / 2,
24606
- },
24607
- color: runtime.title.color ?? textColor,
24608
- bold: runtime.title.bold,
24609
- italic: runtime.title.italic,
24610
- },
24611
- backgroundColor: runtime.background,
24612
- gauge: {
24613
- rect: gaugeRect,
24614
- arcWidth: gaugeArcWidth,
24615
- percentage: clip(gaugePercentage, 0, 1),
24616
- color: getGaugeColor(runtime),
24617
- },
24618
- inflectionValues,
24619
- gaugeValue: {
24620
- label: gaugeLabel,
24621
- textPosition: gaugeValuePosition,
24622
- fontSize: gaugeValueFontSize,
24623
- color: textColor,
24624
- },
24625
- minLabel: {
24626
- label: runtime.minValue.label,
24627
- textPosition: minLabelPosition,
24628
- fontSize: GAUGE_LABELS_FONT_SIZE,
24629
- color: textColor,
24630
- },
24631
- maxLabel: {
24632
- label: runtime.maxValue.label,
24633
- textPosition: maxLabelPosition,
24634
- fontSize: GAUGE_LABELS_FONT_SIZE,
24635
- color: textColor,
24636
- },
24637
- };
24638
- }
24639
- /**
24640
- * Get the rectangle in which the gauge will be drawn, based on the bounding rectangle of the canvas and leaving
24641
- * space for the title and labels.
24642
- */
24643
- function getGaugeRect(boundingRect, title) {
24644
- const titleHeight = title ? GAUGE_TITLE_SECTION_HEIGHT : 0;
24645
- const drawHeight = boundingRect.height - GAUGE_PADDING_BOTTOM - titleHeight - GAUGE_PADDING_TOP;
24646
- const drawWidth = boundingRect.width - GAUGE_PADDING_SIDE * 2;
24647
- let gaugeWidth;
24648
- let gaugeHeight;
24649
- if (drawWidth > 2 * drawHeight) {
24650
- gaugeWidth = 2 * drawHeight;
24651
- gaugeHeight = drawHeight;
24652
- }
24653
- else {
24654
- gaugeWidth = drawWidth;
24655
- gaugeHeight = drawWidth / 2;
24656
- }
24657
- const gaugeX = GAUGE_PADDING_SIDE + (drawWidth - gaugeWidth) / 2;
24658
- const gaugeY = titleHeight + GAUGE_PADDING_TOP + (drawHeight - gaugeHeight) / 2;
24659
- return {
24660
- x: gaugeX,
24661
- y: gaugeY,
24662
- width: gaugeWidth,
24663
- height: gaugeHeight,
24664
- };
24665
- }
24666
- /**
24667
- * Get the infliction values of the gauge, and where to draw them (the angle from the center of the gauge at which they are drawn).
24668
- *
24669
- * Also compute an offset for the text so that it doesn't overlap with other text.
24670
- */
24671
- function getInflectionValues(runtime, gaugeRect, textColor, ctx) {
24672
- const maxValue = runtime.maxValue;
24673
- const minValue = runtime.minValue;
24674
- const gaugeCircleCenter = {
24675
- x: gaugeRect.x + gaugeRect.width / 2,
24676
- y: gaugeRect.y + gaugeRect.height,
24677
- };
24678
- const textStyle = { fontSize: GAUGE_LABELS_FONT_SIZE };
24679
- const inflectionValues = [];
24680
- const inflectionValuesTextRects = [];
24681
- for (const inflectionValue of runtime.inflectionValues) {
24682
- const percentage = (inflectionValue.value - minValue.value) / (maxValue.value - minValue.value);
24683
- const labelWidth = computeTextWidth(ctx, inflectionValue.label, textStyle, "px");
24684
- const angle = Math.PI - Math.PI * percentage;
24685
- const textRect = getRectangleTangentToCircle(angle, // angle between X axis and the point where the rectangle is tangent to the circle
24686
- gaugeRect.height + GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN, // radius of the gauge circle + margin below text
24687
- gaugeCircleCenter.x, // center of the gauge circle
24688
- gaugeCircleCenter.y, // center of the gauge circle
24689
- labelWidth + 2, // width of the text + some margin
24690
- GAUGE_LABELS_FONT_SIZE // height of the text
24691
- );
24692
- let offset = inflectionValuesTextRects.some((rect) => doRectanglesIntersect(rect, textRect))
24693
- ? GAUGE_LABELS_FONT_SIZE
24694
- : 0;
24695
- inflectionValuesTextRects.push(textRect);
24696
- inflectionValues.push({
24697
- rotation: angle,
24698
- label: inflectionValue.label,
24699
- fontSize: GAUGE_LABELS_FONT_SIZE,
24700
- color: textColor,
24701
- offset,
24702
- });
24703
- }
24704
- return inflectionValues;
24705
- }
24706
- function getGaugeColor(runtime) {
24707
- const gaugeValue = runtime.gaugeValue?.value;
24708
- if (gaugeValue === undefined) {
24709
- return GAUGE_BACKGROUND_COLOR;
24710
- }
24711
- let colorIndex = 0;
24712
- while (runtime.inflectionValues[colorIndex]?.value <= gaugeValue) {
24713
- colorIndex++;
24714
- }
24715
- return runtime.colors[colorIndex];
24716
- }
24717
- function getContrastedTextColor(backgroundColor) {
24718
- return relativeLuminance(backgroundColor) > 0.3
24719
- ? GAUGE_TEXT_COLOR
24720
- : GAUGE_TEXT_COLOR_HIGH_CONTRAST;
24721
- }
24722
- function getSegmentsOfRectangle(rectangle) {
24723
- return [
24724
- { start: rectangle.topLeft, end: rectangle.topRight },
24725
- { start: rectangle.topRight, end: rectangle.bottomRight },
24726
- { start: rectangle.bottomRight, end: rectangle.bottomLeft },
24727
- { start: rectangle.bottomLeft, end: rectangle.topLeft },
24728
- ];
24729
- }
24730
- /**
24731
- * Check if two segment intersect. The case where the segments are colinear (both segments on the same line)
24732
- * is not handled.
24733
- */
24734
- function doSegmentIntersect(segment1, segment2) {
24735
- const A = segment1.start;
24736
- const B = segment1.end;
24737
- const C = segment2.start;
24738
- const D = segment2.end;
24739
- /**
24740
- * Line segment intersection algorithm
24741
- * https://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/
24742
- */
24743
- function ccw(a, b, c) {
24744
- return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
24745
- }
24746
- return ccw(A, C, D) !== ccw(B, C, D) && ccw(A, B, C) !== ccw(A, B, D);
24747
- }
24748
- function doRectanglesIntersect(rect1, rect2) {
24749
- const segments1 = getSegmentsOfRectangle(rect1);
24750
- const segments2 = getSegmentsOfRectangle(rect2);
24751
- for (const segment1 of segments1) {
24752
- for (const segment2 of segments2) {
24753
- if (doSegmentIntersect(segment1, segment2)) {
24754
- return true;
24755
- }
24756
- }
24757
- }
24758
- return false;
24759
- }
24760
- /**
24761
- * Get the rectangle that is tangent to a circle at a given angle.
24762
- *
24763
- * @param angle angle between X axis and the point where the rectangle is tangent to the circle
24764
- */
24765
- function getRectangleTangentToCircle(angle, radius, circleCenterX, circleCenterY, rectWidth, rectHeight) {
24766
- const cos = Math.cos(angle);
24767
- const sin = Math.sin(angle);
24768
- // x, y are the distance from the center of the circle to the point where the rectangle is tangent to the circle
24769
- const x = cos * radius;
24770
- const y = sin * radius;
24771
- // x2, y2 are the distance from the point the rectangle is tangent to the circle to the bottom left corner of the rectangle
24772
- const x2 = sin * (rectWidth / 2); // cos(angle + 90°) = sin(angle)
24773
- const y2 = cos * (rectWidth / 2);
24774
- const bottomRight = {
24775
- x: x + x2 + circleCenterX,
24776
- y: circleCenterY - (y - y2),
24777
- };
24778
- const bottomLeft = {
24779
- x: x - x2 + circleCenterX,
24780
- y: circleCenterY - (y + y2),
24781
- };
24782
- // Same as above but for the top corners of the rectangle (radius + rectangle height instead of radius)
24783
- const xp = cos * (radius + rectHeight);
24784
- const yp = sin * (radius + rectHeight);
24785
- const topLeft = {
24786
- x: xp - x2 + circleCenterX,
24787
- y: circleCenterY - (yp + y2),
24788
- };
24789
- const topRight = {
24790
- x: xp + x2 + circleCenterX,
24791
- y: circleCenterY - (yp - y2),
24792
- };
24793
- return { bottomLeft, bottomRight, topRight, topLeft };
24794
- }
24795
-
24796
25106
  class GaugeChartComponent extends owl.Component {
24797
25107
  static template = "o-spreadsheet-GaugeChartComponent";
24798
25108
  canvas = owl.useRef("chartContainer");
@@ -24825,290 +25135,6 @@ stores.inject(MyMetaStore, storeInstance);
24825
25135
  return color;
24826
25136
  }
24827
25137
 
24828
- /**
24829
- * This file contains helpers that are common to different runtime charts (mainly
24830
- * line, bar and pie charts)
24831
- */
24832
- /**
24833
- * Get the data from a dataSet
24834
- */
24835
- function getData(getters, ds) {
24836
- if (ds.dataRange) {
24837
- const labelCellZone = ds.labelCell ? [ds.labelCell.zone] : [];
24838
- const dataZone = recomputeZones([ds.dataRange.zone], labelCellZone)[0];
24839
- if (dataZone === undefined) {
24840
- return [];
24841
- }
24842
- const dataRange = getters.getRangeFromZone(ds.dataRange.sheetId, dataZone);
24843
- return getters.getRangeValues(dataRange).map((value) => (value === "" ? undefined : value));
24844
- }
24845
- return [];
24846
- }
24847
- function filterEmptyDataPoints(labels, datasets) {
24848
- const numberOfDataPoints = Math.max(labels.length, ...datasets.map((dataset) => dataset.data?.length || 0));
24849
- const dataPointsIndexes = range(0, numberOfDataPoints).filter((dataPointIndex) => {
24850
- const label = labels[dataPointIndex];
24851
- const values = datasets.map((dataset) => dataset.data?.[dataPointIndex]);
24852
- return label || values.some((value) => value === 0 || Boolean(value));
24853
- });
24854
- return {
24855
- labels: dataPointsIndexes.map((i) => labels[i] || ""),
24856
- dataSetsValues: datasets.map((dataset) => ({
24857
- ...dataset,
24858
- data: dataPointsIndexes.map((i) => dataset.data[i]),
24859
- })),
24860
- };
24861
- }
24862
- /**
24863
- * Aggregates data based on labels
24864
- */
24865
- function aggregateDataForLabels(labels, datasets) {
24866
- const parseNumber = (value) => (typeof value === "number" ? value : 0);
24867
- const labelSet = new Set(labels);
24868
- const labelMap = {};
24869
- labelSet.forEach((label) => {
24870
- labelMap[label] = new Array(datasets.length).fill(0);
24871
- });
24872
- for (const indexOfLabel of range(0, labels.length)) {
24873
- const label = labels[indexOfLabel];
24874
- for (const indexOfDataset of range(0, datasets.length)) {
24875
- labelMap[label][indexOfDataset] += parseNumber(datasets[indexOfDataset].data[indexOfLabel]);
24876
- }
24877
- }
24878
- return {
24879
- labels: Array.from(labelSet),
24880
- dataSetsValues: datasets.map((dataset, indexOfDataset) => ({
24881
- ...dataset,
24882
- data: Array.from(labelSet).map((label) => labelMap[label][indexOfDataset]),
24883
- })),
24884
- };
24885
- }
24886
- function truncateLabel(label) {
24887
- if (!label) {
24888
- return "";
24889
- }
24890
- if (label.length > MAX_CHAR_LABEL) {
24891
- return label.substring(0, MAX_CHAR_LABEL) + "…";
24892
- }
24893
- return label;
24894
- }
24895
- /**
24896
- * Get a default chart js configuration
24897
- */
24898
- function getDefaultChartJsRuntime(chart, labels, fontColor, { format, locale, truncateLabels = true, horizontalChart, }) {
24899
- const chartTitle = chart.title.text ? chart.title : { ...chart.title, content: "" };
24900
- const options = {
24901
- // https://www.chartjs.org/docs/latest/general/responsive.html
24902
- responsive: true, // will resize when its container is resized
24903
- maintainAspectRatio: false, // doesn't maintain the aspect ration (width/height =2 by default) so the user has the choice of the exact layout
24904
- layout: {
24905
- padding: {
24906
- left: DEFAULT_CHART_PADDING,
24907
- right: DEFAULT_CHART_PADDING,
24908
- top: chartTitle.text ? DEFAULT_CHART_PADDING / 2 : DEFAULT_CHART_PADDING + 5,
24909
- bottom: DEFAULT_CHART_PADDING,
24910
- },
24911
- },
24912
- elements: {
24913
- line: {
24914
- fill: false, // do not fill the area under line charts
24915
- },
24916
- point: {
24917
- hitRadius: 15, // increased hit radius to display point tooltip when hovering nearby
24918
- },
24919
- },
24920
- animation: false,
24921
- plugins: {
24922
- title: {
24923
- display: !!chartTitle.text,
24924
- text: _t(chartTitle.text),
24925
- color: chartTitle?.color ?? fontColor,
24926
- align: chartTitle.align === "center" ? "center" : chartTitle.align === "right" ? "end" : "start",
24927
- font: {
24928
- size: DEFAULT_CHART_FONT_SIZE,
24929
- weight: chartTitle.bold ? "bold" : "normal",
24930
- style: chartTitle.italic ? "italic" : "normal",
24931
- },
24932
- },
24933
- legend: {
24934
- // Disable default legend onClick (show/hide dataset), to allow us to set a global onClick on the chart container.
24935
- // If we want to re-enable this in the future, we need to override the default onClick to stop the event propagation
24936
- onClick: () => { },
24937
- },
24938
- tooltip: {
24939
- callbacks: {
24940
- label: function (tooltipItem) {
24941
- const xLabel = tooltipItem.dataset?.label || tooltipItem.label;
24942
- // tooltipItem.parsed can be an object or a number for pie charts
24943
- let yLabel = horizontalChart ? tooltipItem.parsed.x : tooltipItem.parsed.y;
24944
- if (yLabel === undefined || yLabel === null) {
24945
- yLabel = tooltipItem.parsed;
24946
- }
24947
- const toolTipFormat = !format && Math.abs(yLabel) >= 1000 ? "#,##" : format;
24948
- const yLabelStr = formatValue(yLabel, { format: toolTipFormat, locale });
24949
- return xLabel ? `${xLabel}: ${yLabelStr}` : yLabelStr;
24950
- },
24951
- },
24952
- },
24953
- },
24954
- };
24955
- return {
24956
- type: chart.type,
24957
- options,
24958
- data: {
24959
- labels: truncateLabels ? labels.map(truncateLabel) : labels,
24960
- datasets: [],
24961
- },
24962
- platform: undefined, // This key is optional and will be set by chart.js
24963
- plugins: [],
24964
- };
24965
- }
24966
- function getChartLabelFormat(getters, range, shouldRemoveFirstLabel) {
24967
- if (!range)
24968
- return undefined;
24969
- const { sheetId, zone } = range;
24970
- const formats = positions(zone).map((position) => getters.getEvaluatedCell({ sheetId, ...position }).format);
24971
- if (shouldRemoveFirstLabel) {
24972
- formats.shift();
24973
- }
24974
- return formats.find((format) => format !== undefined);
24975
- }
24976
- function getChartLabelValues(getters, dataSets, labelRange) {
24977
- let labels = { values: [], formattedValues: [] };
24978
- if (labelRange) {
24979
- if (!labelRange.invalidXc && !labelRange.invalidSheetName) {
24980
- labels = {
24981
- formattedValues: getters.getRangeFormattedValues(labelRange),
24982
- values: getters.getRangeValues(labelRange).map((val) => String(val ?? "")),
24983
- };
24984
- }
24985
- }
24986
- else if (dataSets.length === 1) {
24987
- for (let i = 0; i < getData(getters, dataSets[0]).length; i++) {
24988
- labels.formattedValues.push("");
24989
- labels.values.push("");
24990
- }
24991
- }
24992
- else {
24993
- if (dataSets[0]) {
24994
- const ranges = getData(getters, dataSets[0]);
24995
- labels = {
24996
- formattedValues: range(0, ranges.length).map((r) => r.toString()),
24997
- values: labels.formattedValues,
24998
- };
24999
- }
25000
- }
25001
- return labels;
25002
- }
25003
- /**
25004
- * Get the format to apply to the the dataset values. This format is defined as the first format
25005
- * found in the dataset ranges that isn't a date format.
25006
- */
25007
- function getChartDatasetFormat(getters, dataSets) {
25008
- for (const ds of dataSets) {
25009
- const formatsInDataset = getters.getRangeFormats(ds.dataRange);
25010
- const format = formatsInDataset.find((f) => f !== undefined && !isDateTimeFormat(f));
25011
- if (format)
25012
- return format;
25013
- }
25014
- return undefined;
25015
- }
25016
- function getChartDatasetValues(getters, dataSets) {
25017
- const datasetValues = [];
25018
- for (const [dsIndex, ds] of Object.entries(dataSets)) {
25019
- let label;
25020
- if (ds.labelCell) {
25021
- const labelRange = ds.labelCell;
25022
- const cell = labelRange
25023
- ? getters.getEvaluatedCell({
25024
- sheetId: labelRange.sheetId,
25025
- col: labelRange.zone.left,
25026
- row: labelRange.zone.top,
25027
- })
25028
- : undefined;
25029
- label =
25030
- cell && labelRange
25031
- ? truncateLabel(cell.formattedValue)
25032
- : (label = `${ChartTerms.Series} ${parseInt(dsIndex) + 1}`);
25033
- }
25034
- else {
25035
- label = `${ChartTerms.Series} ${parseInt(dsIndex) + 1}`;
25036
- }
25037
- let data = ds.dataRange ? getData(getters, ds) : [];
25038
- if (data.every((e) => typeof e === "string" && !isEvaluationError(e))) {
25039
- // In this case, we want a chart based on the string occurrences count
25040
- // This will be done by associating each string with a value of 1 and
25041
- // the using the classical aggregation method to sum the values.
25042
- data.fill(1);
25043
- }
25044
- datasetValues.push({ data, label });
25045
- }
25046
- return datasetValues;
25047
- }
25048
- /**
25049
- * If the chart is a stacked area chart, we want to fill until the next dataset.
25050
- * If the chart is a simple area chart, we want to fill until the origin (bottom axis).
25051
- *
25052
- * See https://www.chartjs.org/docs/latest/charts/area.html#filling-modes
25053
- */
25054
- function getFillingMode(index, stackedChart) {
25055
- if (!stackedChart) {
25056
- return "origin";
25057
- }
25058
- return index === 0 ? "origin" : "-1";
25059
- }
25060
- function chartToImage(runtime, figure, type) {
25061
- // wrap the canvas in a div with a fixed size because chart.js would
25062
- // fill the whole page otherwise
25063
- const div = document.createElement("div");
25064
- div.style.width = `${figure.width}px`;
25065
- div.style.height = `${figure.height}px`;
25066
- const canvas = document.createElement("canvas");
25067
- div.append(canvas);
25068
- canvas.setAttribute("width", figure.width.toString());
25069
- canvas.setAttribute("height", figure.height.toString());
25070
- // we have to add the canvas to the DOM otherwise it won't be rendered
25071
- document.body.append(div);
25072
- if ("chartJsConfig" in runtime) {
25073
- const config = deepCopy(runtime.chartJsConfig);
25074
- config.plugins = [backgroundColorChartJSPlugin];
25075
- const chart = new window.Chart(canvas, config);
25076
- const imgContent = chart.toBase64Image();
25077
- chart.destroy();
25078
- div.remove();
25079
- return imgContent;
25080
- }
25081
- else if (type === "scorecard") {
25082
- const design = getScorecardConfiguration(figure, runtime);
25083
- drawScoreChart(design, canvas);
25084
- const imgContent = canvas.toDataURL();
25085
- div.remove();
25086
- return imgContent;
25087
- }
25088
- else if (type === "gauge") {
25089
- drawGaugeChart(canvas, runtime);
25090
- const imgContent = canvas.toDataURL();
25091
- div.remove();
25092
- return imgContent;
25093
- }
25094
- return undefined;
25095
- }
25096
- /**
25097
- * Custom chart.js plugin to set the background color of the canvas
25098
- * https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
25099
- */
25100
- const backgroundColorChartJSPlugin = {
25101
- id: "customCanvasBackgroundColor",
25102
- beforeDraw: (chart) => {
25103
- const { ctx } = chart;
25104
- ctx.save();
25105
- ctx.globalCompositeOperation = "destination-over";
25106
- ctx.fillStyle = "#ffffff";
25107
- ctx.fillRect(0, 0, chart.width, chart.height);
25108
- ctx.restore();
25109
- },
25110
- };
25111
-
25112
25138
  class BarChart extends AbstractChart {
25113
25139
  dataSets;
25114
25140
  labelRange;
@@ -25529,11 +25555,12 @@ stores.inject(MyMetaStore, storeInstance);
25529
25555
  }
25530
25556
  let missingTimeAdapterAlreadyWarned = false;
25531
25557
  function isLuxonTimeAdapterInstalled() {
25532
- if (!window.Chart) {
25558
+ const Chart = getChartJSConstructor();
25559
+ if (!Chart) {
25533
25560
  return false;
25534
25561
  }
25535
25562
  // @ts-ignore
25536
- const adapter = new window.Chart._adapters._date({});
25563
+ const adapter = new Chart._adapters._date({});
25537
25564
  const isInstalled = adapter._id === "luxon";
25538
25565
  if (!isInstalled && !missingTimeAdapterAlreadyWarned) {
25539
25566
  missingTimeAdapterAlreadyWarned = true;
@@ -25550,7 +25577,8 @@ stores.inject(MyMetaStore, storeInstance);
25550
25577
  generateLabels(chart) {
25551
25578
  // color the legend labels with the dataset color, without any transparency
25552
25579
  const { data } = chart;
25553
- const labels = window.Chart.defaults.plugins.legend.labels.generateLabels(chart);
25580
+ const Chart = getChartJSConstructor();
25581
+ const labels = Chart.defaults.plugins.legend.labels.generateLabels(chart);
25554
25582
  for (const [index, label] of labels.entries()) {
25555
25583
  label.fillStyle = data.datasets[index].borderColor;
25556
25584
  }
@@ -40932,6 +40960,9 @@ stores.inject(MyMetaStore, storeInstance);
40932
40960
  this.MAX_SIZE_MARGIN = 90;
40933
40961
  this.MIN_ELEMENT_SIZE = MIN_COL_WIDTH;
40934
40962
  }
40963
+ get sheetId() {
40964
+ return this.env.model.getters.getActiveSheetId();
40965
+ }
40935
40966
  _getEvOffset(ev) {
40936
40967
  return ev.offsetX;
40937
40968
  }
@@ -40954,10 +40985,10 @@ stores.inject(MyMetaStore, storeInstance);
40954
40985
  return this.env.model.getters.getEdgeScrollCol(position, position, position);
40955
40986
  }
40956
40987
  _getDimensionsInViewport(index) {
40957
- return this.env.model.getters.getColDimensionsInViewport(this.env.model.getters.getActiveSheetId(), index);
40988
+ return this.env.model.getters.getColDimensionsInViewport(this.sheetId, index);
40958
40989
  }
40959
40990
  _getElementSize(index) {
40960
- return this.env.model.getters.getColSize(this.env.model.getters.getActiveSheetId(), index);
40991
+ return this.env.model.getters.getColSize(this.sheetId, index);
40961
40992
  }
40962
40993
  _getMaxSize() {
40963
40994
  return this.colResizerRef.el.clientWidth;
@@ -40968,7 +40999,7 @@ stores.inject(MyMetaStore, storeInstance);
40968
40999
  const cols = this.env.model.getters.getActiveCols();
40969
41000
  this.env.model.dispatch("RESIZE_COLUMNS_ROWS", {
40970
41001
  dimension: "COL",
40971
- sheetId: this.env.model.getters.getActiveSheetId(),
41002
+ sheetId: this.sheetId,
40972
41003
  elements: cols.has(index) ? [...cols] : [index],
40973
41004
  size,
40974
41005
  });
@@ -40981,7 +41012,7 @@ stores.inject(MyMetaStore, storeInstance);
40981
41012
  elements.push(colIndex);
40982
41013
  }
40983
41014
  const result = this.env.model.dispatch("MOVE_COLUMNS_ROWS", {
40984
- sheetId: this.env.model.getters.getActiveSheetId(),
41015
+ sheetId: this.sheetId,
40985
41016
  dimension: "COL",
40986
41017
  base: this.state.base,
40987
41018
  elements,
@@ -41000,7 +41031,7 @@ stores.inject(MyMetaStore, storeInstance);
41000
41031
  _fitElementSize(index) {
41001
41032
  const cols = this.env.model.getters.getActiveCols();
41002
41033
  this.env.model.dispatch("AUTORESIZE_COLUMNS", {
41003
- sheetId: this.env.model.getters.getActiveSheetId(),
41034
+ sheetId: this.sheetId,
41004
41035
  cols: cols.has(index) ? [...cols] : [index],
41005
41036
  });
41006
41037
  }
@@ -41011,7 +41042,7 @@ stores.inject(MyMetaStore, storeInstance);
41011
41042
  return this.env.model.getters.getActiveCols();
41012
41043
  }
41013
41044
  _getPreviousVisibleElement(index) {
41014
- const sheetId = this.env.model.getters.getActiveSheetId();
41045
+ const sheetId = this.sheetId;
41015
41046
  let row;
41016
41047
  for (row = index - 1; row >= 0; row--) {
41017
41048
  if (!this.env.model.getters.isColHidden(sheetId, row)) {
@@ -41022,7 +41053,7 @@ stores.inject(MyMetaStore, storeInstance);
41022
41053
  }
41023
41054
  unhide(hiddenElements) {
41024
41055
  this.env.model.dispatch("UNHIDE_COLUMNS_ROWS", {
41025
- sheetId: this.env.model.getters.getActiveSheetId(),
41056
+ sheetId: this.sheetId,
41026
41057
  elements: hiddenElements,
41027
41058
  dimension: "COL",
41028
41059
  });
@@ -41038,7 +41069,7 @@ stores.inject(MyMetaStore, storeInstance);
41038
41069
  left: 0;
41039
41070
  right: 0;
41040
41071
  width: ${HEADER_WIDTH}px;
41041
- height: 100%;
41072
+ height: calc(100% - ${HEADER_HEIGHT + SCROLLBAR_WIDTH}px);
41042
41073
  &.o-dragging {
41043
41074
  cursor: grabbing;
41044
41075
  }
@@ -41096,6 +41127,9 @@ stores.inject(MyMetaStore, storeInstance);
41096
41127
  this.MIN_ELEMENT_SIZE = MIN_ROW_HEIGHT;
41097
41128
  }
41098
41129
  rowResizerRef;
41130
+ get sheetId() {
41131
+ return this.env.model.getters.getActiveSheetId();
41132
+ }
41099
41133
  _getEvOffset(ev) {
41100
41134
  return ev.offsetY;
41101
41135
  }
@@ -41118,10 +41152,10 @@ stores.inject(MyMetaStore, storeInstance);
41118
41152
  return this.env.model.getters.getEdgeScrollRow(position, position, position);
41119
41153
  }
41120
41154
  _getDimensionsInViewport(index) {
41121
- return this.env.model.getters.getRowDimensionsInViewport(this.env.model.getters.getActiveSheetId(), index);
41155
+ return this.env.model.getters.getRowDimensionsInViewport(this.sheetId, index);
41122
41156
  }
41123
41157
  _getElementSize(index) {
41124
- return this.env.model.getters.getRowSize(this.env.model.getters.getActiveSheetId(), index);
41158
+ return this.env.model.getters.getRowSize(this.sheetId, index);
41125
41159
  }
41126
41160
  _getMaxSize() {
41127
41161
  return this.rowResizerRef.el.clientHeight;
@@ -41132,7 +41166,7 @@ stores.inject(MyMetaStore, storeInstance);
41132
41166
  const rows = this.env.model.getters.getActiveRows();
41133
41167
  this.env.model.dispatch("RESIZE_COLUMNS_ROWS", {
41134
41168
  dimension: "ROW",
41135
- sheetId: this.env.model.getters.getActiveSheetId(),
41169
+ sheetId: this.sheetId,
41136
41170
  elements: rows.has(index) ? [...rows] : [index],
41137
41171
  size,
41138
41172
  });
@@ -41145,7 +41179,7 @@ stores.inject(MyMetaStore, storeInstance);
41145
41179
  elements.push(rowIndex);
41146
41180
  }
41147
41181
  const result = this.env.model.dispatch("MOVE_COLUMNS_ROWS", {
41148
- sheetId: this.env.model.getters.getActiveSheetId(),
41182
+ sheetId: this.sheetId,
41149
41183
  dimension: "ROW",
41150
41184
  base: this.state.base,
41151
41185
  elements,
@@ -41164,7 +41198,7 @@ stores.inject(MyMetaStore, storeInstance);
41164
41198
  _fitElementSize(index) {
41165
41199
  const rows = this.env.model.getters.getActiveRows();
41166
41200
  this.env.model.dispatch("AUTORESIZE_ROWS", {
41167
- sheetId: this.env.model.getters.getActiveSheetId(),
41201
+ sheetId: this.sheetId,
41168
41202
  rows: rows.has(index) ? [...rows] : [index],
41169
41203
  });
41170
41204
  }
@@ -41175,7 +41209,7 @@ stores.inject(MyMetaStore, storeInstance);
41175
41209
  return this.env.model.getters.getActiveRows();
41176
41210
  }
41177
41211
  _getPreviousVisibleElement(index) {
41178
- const sheetId = this.env.model.getters.getActiveSheetId();
41212
+ const sheetId = this.sheetId;
41179
41213
  let row;
41180
41214
  for (row = index - 1; row >= 0; row--) {
41181
41215
  if (!this.env.model.getters.isRowHidden(sheetId, row)) {
@@ -41186,7 +41220,7 @@ stores.inject(MyMetaStore, storeInstance);
41186
41220
  }
41187
41221
  unhide(hiddenElements) {
41188
41222
  this.env.model.dispatch("UNHIDE_COLUMNS_ROWS", {
41189
- sheetId: this.env.model.getters.getActiveSheetId(),
41223
+ sheetId: this.sheetId,
41190
41224
  dimension: "ROW",
41191
41225
  elements: hiddenElements,
41192
41226
  });
@@ -43149,6 +43183,7 @@ stores.inject(MyMetaStore, storeInstance);
43149
43183
  const CONTENT_TYPES = {
43150
43184
  workbook: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml",
43151
43185
  sheet: "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml",
43186
+ metadata: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml",
43152
43187
  sharedStrings: "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml",
43153
43188
  styles: "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml",
43154
43189
  drawing: "application/vnd.openxmlformats-officedocument.drawing+xml",
@@ -43161,6 +43196,7 @@ stores.inject(MyMetaStore, storeInstance);
43161
43196
  const XLSX_RELATION_TYPE = {
43162
43197
  document: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
43163
43198
  sheet: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet",
43199
+ metadata: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sheetMetadata",
43164
43200
  sharedStrings: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings",
43165
43201
  styles: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles",
43166
43202
  drawing: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing",
@@ -43170,6 +43206,7 @@ stores.inject(MyMetaStore, storeInstance);
43170
43206
  hyperlink: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
43171
43207
  image: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
43172
43208
  };
43209
+ const ARRAY_FORMULA_URI = "bdbb8cdc-fa1e-496e-a857-3c3f30c029c3";
43173
43210
  const RELATIONSHIP_NSR = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
43174
43211
  const HEIGHT_FACTOR = 0.75; // 100px => 75 u
43175
43212
  /**
@@ -45017,29 +45054,33 @@ stores.inject(MyMetaStore, storeInstance);
45017
45054
  * In all the sheets, replace the table-only references in the formula cells with standard references.
45018
45055
  */
45019
45056
  function convertTableFormulaReferences(convertedSheets, xlsxSheets) {
45020
- for (let sheet of convertedSheets) {
45021
- const tables = xlsxSheets.find((s) => s.sheetName === sheet.name).tables;
45057
+ for (let tableSheet of convertedSheets) {
45058
+ const tables = xlsxSheets.find((s) => s.sheetName === tableSheet.name).tables;
45022
45059
  for (let table of tables) {
45023
45060
  const tabRef = table.name + "[";
45024
- for (let position of positions(toZone(table.ref))) {
45025
- const xc = toXC(position.col, position.row);
45026
- const cell = sheet.cells[xc];
45027
- if (cell && cell.content && cell.content.startsWith("=")) {
45028
- let refIndex;
45029
- while ((refIndex = cell.content.indexOf(tabRef)) !== -1) {
45030
- let reference = cell.content.slice(refIndex + tabRef.length);
45031
- // Expression can either be tableName[colName] or tableName[[#This Row], [colName]]
45032
- let endIndex = reference.indexOf("]");
45033
- if (reference.startsWith(`[`)) {
45034
- endIndex = reference.indexOf("]", endIndex + 1);
45035
- endIndex = reference.indexOf("]", endIndex + 1);
45061
+ for (let sheet of convertedSheets) {
45062
+ for (let xc in sheet.cells) {
45063
+ const cell = sheet.cells[xc];
45064
+ if (cell && cell.content && cell.content.startsWith("=")) {
45065
+ let refIndex;
45066
+ while ((refIndex = cell.content.indexOf(tabRef)) !== -1) {
45067
+ let endIndex = refIndex + tabRef.length;
45068
+ let openBrackets = 1;
45069
+ while (openBrackets > 0 && endIndex < cell.content.length) {
45070
+ if (cell.content[endIndex] === "[") {
45071
+ openBrackets++;
45072
+ }
45073
+ else if (cell.content[endIndex] === "]") {
45074
+ openBrackets--;
45075
+ }
45076
+ endIndex++;
45077
+ }
45078
+ let reference = cell.content.slice(refIndex + tabRef.length, endIndex - 1);
45079
+ const sheetPrefix = tableSheet.id === sheet.id ? "" : tableSheet.name + "!";
45080
+ const convertedRef = convertTableReference(sheetPrefix, reference, table, xc);
45081
+ cell.content =
45082
+ cell.content.slice(0, refIndex) + convertedRef + cell.content.slice(endIndex);
45036
45083
  }
45037
- reference = reference.slice(0, endIndex);
45038
- const convertedRef = convertTableReference(reference, table, xc);
45039
- cell.content =
45040
- cell.content.slice(0, refIndex) +
45041
- convertedRef +
45042
- cell.content.slice(tabRef.length + refIndex + endIndex + 1);
45043
45084
  }
45044
45085
  }
45045
45086
  }
@@ -45047,11 +45088,17 @@ stores.inject(MyMetaStore, storeInstance);
45047
45088
  }
45048
45089
  }
45049
45090
  /**
45050
- * Convert table-specific references in formulas into standard references.
45091
+ * Convert table-specific references in formulas into standard references. A table reference is composed of columns names,
45092
+ * and of keywords determining the rows of the table to reference.
45051
45093
  *
45052
45094
  * A reference in a table can have the form (only the part between brackets should be given to this function):
45053
45095
  * - tableName[colName] : reference to the whole column "colName"
45096
+ * - tableName[#keyword] : reference to the whatever row the keyword refers to
45054
45097
  * - tableName[[#keyword], [colName]] : reference to some of the element(s) of the column colName
45098
+ * - tableName[[#keyword], [colName]:[col2Name]] : reference to some of the element(s) of the columns colName to col2Name
45099
+ * - tableName[[#keyword1], [#keyword2], [colName]] : reference to all the rows referenced by the keywords in the column colName
45100
+ * - tableName[[#keyword1], [colName], [#keyword2]]: the keywords and colName can be in any order
45101
+ *
45055
45102
  *
45056
45103
  * The available keywords are :
45057
45104
  * - #All : all the column (including totals)
@@ -45059,58 +45106,109 @@ stores.inject(MyMetaStore, storeInstance);
45059
45106
  * - #Headers : only the header of the column
45060
45107
  * - #Totals : only the totals of the column
45061
45108
  * - #This Row : only the element in the same row as the cell
45109
+ *
45110
+ * Note that the only valid combination of multiple keywords are #Data + #Totals and #Headers + #Data.
45062
45111
  */
45063
- function convertTableReference(expr, table, cellXc) {
45064
- const refElements = expr.split(",");
45112
+ function convertTableReference(sheetPrefix, expr, table, cellXc) {
45113
+ // TODO: Ideally we'd want to make a real tokenizer, this simple approach won't work if for example the column name
45114
+ // contain # or , characters. But that's probably an edge case that we can ignore for now.
45115
+ const parts = expr.split(",").map((part) => part.trim());
45065
45116
  const tableZone = toZone(table.ref);
45066
- const refZone = { ...tableZone };
45067
- let isReferencedZoneValid = true;
45068
- // Single column reference
45069
- if (refElements.length === 1) {
45070
- const colRelativeIndex = table.cols.findIndex((col) => col.name === refElements[0]);
45071
- refZone.left = refZone.right = colRelativeIndex + tableZone.left;
45072
- if (table.headerRowCount) {
45073
- refZone.top += table.headerRowCount;
45074
- }
45075
- if (table.totalsRowCount) {
45076
- refZone.bottom -= 1;
45117
+ const colIndexes = [];
45118
+ const rowIndexes = [];
45119
+ const foundKeywords = [];
45120
+ for (const part of parts) {
45121
+ if (removeBrackets(part).startsWith("#")) {
45122
+ const keyWord = removeBrackets(part);
45123
+ foundKeywords.push(keyWord);
45124
+ switch (keyWord) {
45125
+ case "#All":
45126
+ rowIndexes.push(tableZone.top, tableZone.bottom);
45127
+ break;
45128
+ case "#Data":
45129
+ const top = table.headerRowCount ? tableZone.top + table.headerRowCount : tableZone.top;
45130
+ const bottom = table.totalsRowCount
45131
+ ? tableZone.bottom - table.totalsRowCount
45132
+ : tableZone.bottom;
45133
+ rowIndexes.push(top, bottom);
45134
+ break;
45135
+ case "#This Row":
45136
+ rowIndexes.push(toCartesian(cellXc).row);
45137
+ break;
45138
+ case "#Headers":
45139
+ if (!table.headerRowCount) {
45140
+ return CellErrorType.InvalidReference;
45141
+ }
45142
+ rowIndexes.push(tableZone.top);
45143
+ break;
45144
+ case "#Totals":
45145
+ if (!table.totalsRowCount) {
45146
+ return CellErrorType.InvalidReference;
45147
+ }
45148
+ rowIndexes.push(tableZone.bottom);
45149
+ break;
45150
+ }
45077
45151
  }
45078
- }
45079
- // Other references
45080
- else {
45081
- switch (refElements[0].slice(1, refElements[0].length - 1)) {
45082
- case "#All":
45083
- refZone.top = table.headerRowCount ? tableZone.top + table.headerRowCount : tableZone.top;
45084
- refZone.bottom = tableZone.bottom;
45085
- break;
45086
- case "#Data":
45087
- refZone.top = table.headerRowCount ? tableZone.top + table.headerRowCount : tableZone.top;
45088
- refZone.bottom = table.totalsRowCount ? tableZone.bottom + 1 : tableZone.bottom;
45089
- break;
45090
- case "#This Row":
45091
- refZone.top = refZone.bottom = toCartesian(cellXc).row;
45092
- break;
45093
- case "#Headers":
45094
- refZone.top = refZone.bottom = tableZone.top;
45095
- if (!table.headerRowCount) {
45096
- isReferencedZoneValid = false;
45097
- }
45098
- break;
45099
- case "#Totals":
45100
- refZone.top = refZone.bottom = tableZone.bottom;
45101
- if (!table.totalsRowCount) {
45102
- isReferencedZoneValid = false;
45152
+ else {
45153
+ const columns = part
45154
+ .split(":")
45155
+ .map((part) => part.trim())
45156
+ .map(removeBrackets);
45157
+ if (colIndexes.length) {
45158
+ return CellErrorType.InvalidReference;
45159
+ }
45160
+ const colRelativeIndex = table.cols.findIndex((col) => col.name === columns[0]);
45161
+ if (colRelativeIndex === -1) {
45162
+ return CellErrorType.InvalidReference;
45163
+ }
45164
+ colIndexes.push(colRelativeIndex + tableZone.left);
45165
+ if (columns[1]) {
45166
+ const colRelativeIndex2 = table.cols.findIndex((col) => col.name === columns[1]);
45167
+ if (colRelativeIndex2 === -1) {
45168
+ return CellErrorType.InvalidReference;
45103
45169
  }
45104
- break;
45170
+ colIndexes.push(colRelativeIndex2 + tableZone.left);
45171
+ }
45105
45172
  }
45106
- const colRef = refElements[1].slice(1, refElements[1].length - 1);
45107
- const colRelativeIndex = table.cols.findIndex((col) => col.name === colRef);
45108
- refZone.left = refZone.right = colRelativeIndex + tableZone.left;
45109
45173
  }
45110
- if (!isReferencedZoneValid) {
45174
+ if (!areKeywordsCompatible(foundKeywords)) {
45111
45175
  return CellErrorType.InvalidReference;
45112
45176
  }
45113
- return refZone.top !== refZone.bottom ? zoneToXc(refZone) : toXC(refZone.left, refZone.top);
45177
+ if (rowIndexes.length === 0) {
45178
+ const top = table.headerRowCount ? tableZone.top + table.headerRowCount : tableZone.top;
45179
+ const bottom = table.totalsRowCount
45180
+ ? tableZone.bottom - table.totalsRowCount
45181
+ : tableZone.bottom;
45182
+ rowIndexes.push(top, bottom);
45183
+ }
45184
+ if (colIndexes.length === 0) {
45185
+ colIndexes.push(tableZone.left, tableZone.right);
45186
+ }
45187
+ const refZone = {
45188
+ top: Math.min(...rowIndexes),
45189
+ left: Math.min(...colIndexes),
45190
+ bottom: Math.max(...rowIndexes),
45191
+ right: Math.max(...colIndexes),
45192
+ };
45193
+ return sheetPrefix + zoneToXc(refZone);
45194
+ }
45195
+ function removeBrackets(str) {
45196
+ return str.startsWith("[") && str.endsWith("]") ? str.slice(1, str.length - 1) : str;
45197
+ }
45198
+ function areKeywordsCompatible(keywords) {
45199
+ if (keywords.length < 2) {
45200
+ return true;
45201
+ }
45202
+ else if (keywords.length > 2) {
45203
+ return false;
45204
+ }
45205
+ else if (keywords.includes("#Data") && keywords.includes("#Totals")) {
45206
+ return true;
45207
+ }
45208
+ else if (keywords.includes("#Headers") && keywords.includes("#Data")) {
45209
+ return true;
45210
+ }
45211
+ return false;
45114
45212
  }
45115
45213
 
45116
45214
  // -------------------------------------
@@ -45725,7 +45823,7 @@ stores.inject(MyMetaStore, storeInstance);
45725
45823
  title: { text: chartTitle },
45726
45824
  type: CHART_TYPE_CONVERSION_MAP[chartType],
45727
45825
  dataSets: this.extractChartDatasets(this.querySelectorAll(rootChartElement, `c:${chartType}`), chartType),
45728
- labelRange: this.extractChildTextContent(rootChartElement, `c:ser ${chartType === "scatterChart" ? "c:numRef" : "c:cat"} c:f`),
45826
+ labelRange: this.extractLabelRange(chartType, rootChartElement),
45729
45827
  backgroundColor: this.extractChildAttr(rootChartElement, "c:chartSpace > c:spPr a:srgbClr", "val", {
45730
45828
  default: "ffffff",
45731
45829
  }).asString(),
@@ -45737,6 +45835,13 @@ stores.inject(MyMetaStore, storeInstance);
45737
45835
  };
45738
45836
  })[0];
45739
45837
  }
45838
+ extractLabelRange(chartType, rootChartElement) {
45839
+ if (chartType === "scatterChart") {
45840
+ return (this.extractChildTextContent(rootChartElement, `c:ser c:strRef c:f`) ||
45841
+ this.extractChildTextContent(rootChartElement, `c:ser c:numRef c:f`));
45842
+ }
45843
+ return this.extractChildTextContent(rootChartElement, `c:ser c:cat c:f`);
45844
+ }
45740
45845
  extractComboChart(chartElement) {
45741
45846
  // Title can be separated into multiple xml elements (for styling and such), we only import the text
45742
45847
  const chartTitle = this.mapOnElements({ parent: chartElement, query: "c:title a:t" }, (textElement) => {
@@ -54940,6 +55045,9 @@ stores.inject(MyMetaStore, storeInstance);
54940
55045
  // Export
54941
55046
  // ---------------------------------------------------------------------------
54942
55047
  exportForExcel(data) {
55048
+ for (const sheet of data.sheets) {
55049
+ sheet.formulaSpillRanges = {};
55050
+ }
54943
55051
  for (const position of this.evaluator.getEvaluatedPositions()) {
54944
55052
  const evaluatedCell = this.evaluator.getEvaluatedCell(position);
54945
55053
  const xc = toXC(position.col, position.row);
@@ -54951,8 +55059,9 @@ stores.inject(MyMetaStore, storeInstance);
54951
55059
  const exportedSheetData = data.sheets.find((sheet) => sheet.id === position.sheetId);
54952
55060
  const formulaCell = this.getCorrespondingFormulaCell(position);
54953
55061
  if (formulaCell) {
55062
+ const cell = this.getters.getCell(position);
54954
55063
  isExported = isExportableToExcel(formulaCell.compiledFormula.tokens);
54955
- isFormula = isExported;
55064
+ isFormula = isExported && cell?.content === formulaCell.content;
54956
55065
  if (!isExported) {
54957
55066
  // If the cell contains a non-exported formula and that is evaluates to
54958
55067
  // nothing* ,we don't export it.
@@ -54976,6 +55085,10 @@ stores.inject(MyMetaStore, storeInstance);
54976
55085
  content = !isExported ? newContent : exportedCellData.content;
54977
55086
  }
54978
55087
  exportedSheetData.cells[xc] = { ...exportedCellData, value, isFormula, content, format };
55088
+ const spillZone = this.getSpreadZone(position);
55089
+ if (spillZone) {
55090
+ exportedSheetData.formulaSpillRanges[xc] = this.getters.getRangeString(this.getters.getRangeFromZone(position.sheetId, spillZone), position.sheetId);
55091
+ }
54979
55092
  }
54980
55093
  }
54981
55094
  /**
@@ -56626,7 +56739,7 @@ stores.inject(MyMetaStore, storeInstance);
56626
56739
  getRule(cell, cells) {
56627
56740
  const rules = autofillRulesRegistry.getAll().sort((a, b) => a.sequence - b.sequence);
56628
56741
  const rule = rules.find((rule) => rule.condition(cell, cells));
56629
- return rule && rule.generateRule(cell, cells);
56742
+ return rule && this.direction && rule.generateRule(cell, cells, this.direction);
56630
56743
  }
56631
56744
  /**
56632
56745
  * Create the generator to be able to autofill the next cells.
@@ -61708,7 +61821,8 @@ stores.inject(MyMetaStore, storeInstance);
61708
61821
  ? this.getters.getSheetViewVisibleCols()
61709
61822
  : this.getters.getSheetViewVisibleRows();
61710
61823
  const startIndex = visibleHeaders.findIndex((header) => referenceHeaderIndex >= header);
61711
- const endIndex = visibleHeaders.findIndex((header) => targetHeaderIndex <= header);
61824
+ let endIndex = visibleHeaders.findIndex((header) => targetHeaderIndex <= header);
61825
+ endIndex = endIndex === -1 ? visibleHeaders.length : endIndex;
61712
61826
  const relevantIndexes = visibleHeaders.slice(startIndex, endIndex);
61713
61827
  let offset = 0;
61714
61828
  for (const i of relevantIndexes) {
@@ -66954,7 +67068,7 @@ stores.inject(MyMetaStore, storeInstance);
66954
67068
  `;
66955
67069
  }
66956
67070
 
66957
- function addFormula(cell) {
67071
+ function addFormula(cell, formulaSpillRange) {
66958
67072
  const formula = cell.content;
66959
67073
  if (!formula) {
66960
67074
  return { attrs: [], node: escapeXml `` };
@@ -66963,10 +67077,17 @@ stores.inject(MyMetaStore, storeInstance);
66963
67077
  if (type === undefined) {
66964
67078
  return { attrs: [], node: escapeXml `` };
66965
67079
  }
66966
- const attrs = [["t", type]];
67080
+ const attrs = [
67081
+ ["cm", "1"],
67082
+ ["t", type],
67083
+ ];
66967
67084
  const XlsxFormula = adaptFormulaToExcel(formula);
66968
67085
  const exportedValue = adaptFormulaValueToExcel(cell.value);
66969
- const node = escapeXml /*xml*/ `<f>${XlsxFormula}</f><v>${exportedValue}</v>`;
67086
+ // We treat all formulas as array formulas (a simple formula
67087
+ // is an array formula that spills on only one cell) to avoid
67088
+ // trying to detect spilling sub-formulas which is not a trivial task.
67089
+ let node;
67090
+ node = escapeXml /*xml*/ `<f t="array" ref="${formulaSpillRange}">${XlsxFormula}</f><v>${exportedValue}</v>`;
66970
67091
  return { attrs, node };
66971
67092
  }
66972
67093
  function addContent(content, sharedStrings, forceString = false) {
@@ -67611,7 +67732,7 @@ stores.inject(MyMetaStore, storeInstance);
67611
67732
  }
67612
67733
  if (alignAttrs.length > 0) {
67613
67734
  attributes.push(["applyAlignment", "1"]); // for Libre Office
67614
- styleNodes.push(escapeXml /*xml*/ `<xf ${formatAttributes(attributes)}>${escapeXml /*xml*/ `<alignment ${formatAttributes(alignAttrs)} />`}</xf> `);
67735
+ styleNodes.push(escapeXml /*xml*/ `<xf ${formatAttributes(attributes)}><alignment ${formatAttributes(alignAttrs)} /></xf> `);
67615
67736
  }
67616
67737
  else {
67617
67738
  styleNodes.push(escapeXml /*xml*/ `<xf ${formatAttributes(attributes)} />`);
@@ -67810,7 +67931,7 @@ stores.inject(MyMetaStore, storeInstance);
67810
67931
  let cellNode = escapeXml ``;
67811
67932
  // Either formula or static value inside the cell
67812
67933
  if (cell.isFormula) {
67813
- const res = addFormula(cell);
67934
+ const res = addFormula(cell, sheet.formulaSpillRanges[xc] ?? xc);
67814
67935
  if (!res) {
67815
67936
  continue;
67816
67937
  }
@@ -68085,6 +68206,30 @@ stores.inject(MyMetaStore, storeInstance);
68085
68206
  `;
68086
68207
  files.push(createXMLFile(parseXML(sheetXml), `xl/worksheets/sheet${sheetIndex}.xml`, "sheet"));
68087
68208
  }
68209
+ const sheetMetadataXml = escapeXml /*xml*/ `
68210
+ <metadata xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:xda="http://schemas.microsoft.com/office/spreadsheetml/2017/dynamicarray">
68211
+ <metadataTypes count="1">
68212
+ <metadataType name="XLDAPR" minSupportedVersion="120000" copy="1" pasteAll="1"
68213
+ pasteValues="1" merge="1" splitFirst="1" rowColShift="1" clearFormats="1"
68214
+ clearComments="1" assign="1" coerce="1" cellMeta="1" />
68215
+ </metadataTypes>
68216
+ <futureMetadata name="XLDAPR" count="1">
68217
+ <bk>
68218
+ <extLst>
68219
+ <ext uri="{${ARRAY_FORMULA_URI}}">
68220
+ <xda:dynamicArrayProperties fDynamic="1" fCollapsed="0" />
68221
+ </ext>
68222
+ </extLst>
68223
+ </bk>
68224
+ </futureMetadata>
68225
+ <cellMetadata count="1">
68226
+ <bk>
68227
+ <rc t="1" v="0" />
68228
+ </bk>
68229
+ </cellMetadata>
68230
+ </metadata>
68231
+ `;
68232
+ files.push(createXMLFile(parseXML(sheetMetadataXml), "xl/metadata.xml", "metadata"));
68088
68233
  addRelsToFile(construct.relsFiles, "xl/_rels/workbook.xml.rels", {
68089
68234
  type: XLSX_RELATION_TYPE.sharedStrings,
68090
68235
  target: "sharedStrings.xml",
@@ -68093,6 +68238,10 @@ stores.inject(MyMetaStore, storeInstance);
68093
68238
  type: XLSX_RELATION_TYPE.styles,
68094
68239
  target: "styles.xml",
68095
68240
  });
68241
+ addRelsToFile(construct.relsFiles, "xl/_rels/workbook.xml.rels", {
68242
+ type: XLSX_RELATION_TYPE.metadata,
68243
+ target: "metadata.xml",
68244
+ });
68096
68245
  return files;
68097
68246
  }
68098
68247
  /**
@@ -69023,9 +69172,9 @@ stores.inject(MyMetaStore, storeInstance);
69023
69172
  exports.tokenize = tokenize;
69024
69173
 
69025
69174
 
69026
- __info__.version = "17.4.24";
69027
- __info__.date = "2025-02-25T05:58:55.802Z";
69028
- __info__.hash = "163efbd";
69175
+ __info__.version = "17.4.26";
69176
+ __info__.date = "2025-03-12T15:31:45.184Z";
69177
+ __info__.hash = "a18429e";
69029
69178
 
69030
69179
 
69031
69180
  })(this.o_spreadsheet = this.o_spreadsheet || {}, owl);