@odoo/o-spreadsheet 17.4.24 → 17.4.25

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