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