@odoo/o-spreadsheet 18.2.1 → 18.2.2

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 18.2.1
6
- * @date 2025-02-25T06:03:13.262Z
7
- * @hash 3b4b5c9
5
+ * @version 18.2.2
6
+ * @date 2025-03-07T10:41:04.411Z
7
+ * @hash f567932
8
8
  */
9
9
 
10
10
  'use strict';
@@ -6102,8 +6102,9 @@ function spreadRange(getters, dataSets) {
6102
6102
  if (zone.bottom !== zone.top && zone.left != zone.right) {
6103
6103
  if (zone.right) {
6104
6104
  for (let j = zone.left; j <= zone.right; ++j) {
6105
+ const datasetOptions = j === zone.left ? dataSet : { yAxisId: dataSet.yAxisId };
6105
6106
  postProcessedRanges.push({
6106
- ...dataSet,
6107
+ ...datasetOptions,
6107
6108
  dataRange: `${sheetPrefix}${zoneToXc({
6108
6109
  left: j,
6109
6110
  right: j,
@@ -6115,8 +6116,9 @@ function spreadRange(getters, dataSets) {
6115
6116
  }
6116
6117
  else {
6117
6118
  for (let j = zone.top; j <= zone.bottom; ++j) {
6119
+ const datasetOptions = j === zone.top ? dataSet : { yAxisId: dataSet.yAxisId };
6118
6120
  postProcessedRanges.push({
6119
- ...dataSet,
6121
+ ...datasetOptions,
6120
6122
  dataRange: `${sheetPrefix}${zoneToXc({
6121
6123
  left: zone.left,
6122
6124
  right: zone.right,
@@ -9534,150 +9536,6 @@ class ComposerFocusStore extends SpreadsheetStore {
9534
9536
  }
9535
9537
  }
9536
9538
 
9537
- /**
9538
- * This file is largely inspired by owl 1.
9539
- * `css` tag has been removed from owl 2 without workaround to manage css.
9540
- * So, the solution was to import the behavior of owl 1 directly in our
9541
- * codebase, with one difference: the css is added to the sheet as soon as the
9542
- * css tag is executed. In owl 1, the css was added as soon as a Component was
9543
- * created for the first time.
9544
- */
9545
- const STYLESHEETS = {};
9546
- let nextId = 0;
9547
- /**
9548
- * CSS tag helper for defining inline stylesheets. With this, one can simply define
9549
- * an inline stylesheet with just the following code:
9550
- * ```js
9551
- * css`.component-a { color: red; }`;
9552
- * ```
9553
- */
9554
- function css(strings, ...args) {
9555
- const name = `__sheet__${nextId++}`;
9556
- const value = String.raw(strings, ...args);
9557
- registerSheet(name, value);
9558
- activateSheet(name);
9559
- return name;
9560
- }
9561
- function processSheet(str) {
9562
- const tokens = str.split(/(\{|\}|;)/).map((s) => s.trim());
9563
- const selectorStack = [];
9564
- const parts = [];
9565
- let rules = [];
9566
- function generateSelector(stackIndex, parentSelector) {
9567
- const parts = [];
9568
- for (const selector of selectorStack[stackIndex]) {
9569
- let part = (parentSelector && parentSelector + " " + selector) || selector;
9570
- if (part.includes("&")) {
9571
- part = selector.replace(/&/g, parentSelector || "");
9572
- }
9573
- if (stackIndex < selectorStack.length - 1) {
9574
- part = generateSelector(stackIndex + 1, part);
9575
- }
9576
- parts.push(part);
9577
- }
9578
- return parts.join(", ");
9579
- }
9580
- function generateRules() {
9581
- if (rules.length) {
9582
- parts.push(generateSelector(0) + " {");
9583
- parts.push(...rules);
9584
- parts.push("}");
9585
- rules = [];
9586
- }
9587
- }
9588
- while (tokens.length) {
9589
- let token = tokens.shift();
9590
- if (token === "}") {
9591
- generateRules();
9592
- selectorStack.pop();
9593
- }
9594
- else {
9595
- if (tokens[0] === "{") {
9596
- generateRules();
9597
- selectorStack.push(token.split(/\s*,\s*/));
9598
- tokens.shift();
9599
- }
9600
- if (tokens[0] === ";") {
9601
- rules.push(" " + token + ";");
9602
- }
9603
- }
9604
- }
9605
- return parts.join("\n");
9606
- }
9607
- function registerSheet(id, css) {
9608
- const sheet = document.createElement("style");
9609
- sheet.textContent = processSheet(css);
9610
- STYLESHEETS[id] = sheet;
9611
- }
9612
- function activateSheet(id) {
9613
- const sheet = STYLESHEETS[id];
9614
- sheet.setAttribute("component", id);
9615
- document.head.appendChild(sheet);
9616
- }
9617
- function getTextDecoration({ strikethrough, underline, }) {
9618
- if (!strikethrough && !underline) {
9619
- return "none";
9620
- }
9621
- return `${strikethrough ? "line-through" : ""} ${underline ? "underline" : ""}`;
9622
- }
9623
- /**
9624
- * Convert the cell style to CSS properties.
9625
- */
9626
- function cellStyleToCss(style) {
9627
- const attributes = cellTextStyleToCss(style);
9628
- if (!style)
9629
- return attributes;
9630
- if (style.fillColor) {
9631
- attributes["background"] = style.fillColor;
9632
- }
9633
- return attributes;
9634
- }
9635
- /**
9636
- * Convert the cell text style to CSS properties.
9637
- */
9638
- function cellTextStyleToCss(style) {
9639
- const attributes = {};
9640
- if (!style)
9641
- return attributes;
9642
- if (style.bold) {
9643
- attributes["font-weight"] = "bold";
9644
- }
9645
- if (style.italic) {
9646
- attributes["font-style"] = "italic";
9647
- }
9648
- if (style.strikethrough || style.underline) {
9649
- let decoration = style.strikethrough ? "line-through" : "";
9650
- decoration = style.underline ? decoration + " underline" : decoration;
9651
- attributes["text-decoration"] = decoration;
9652
- }
9653
- if (style.textColor) {
9654
- attributes["color"] = style.textColor;
9655
- }
9656
- return attributes;
9657
- }
9658
- /**
9659
- * Transform CSS properties into a CSS string.
9660
- */
9661
- function cssPropertiesToCss(attributes) {
9662
- let styleStr = "";
9663
- for (const attName in attributes) {
9664
- if (!attributes[attName]) {
9665
- continue;
9666
- }
9667
- styleStr += `${attName}:${attributes[attName]}; `;
9668
- }
9669
- return styleStr;
9670
- }
9671
- function getElementMargins(el) {
9672
- const style = window.getComputedStyle(el);
9673
- return {
9674
- top: parseInt(style.marginTop, 10) || 0,
9675
- bottom: parseInt(style.marginBottom, 10) || 0,
9676
- left: parseInt(style.marginLeft, 10) || 0,
9677
- right: parseInt(style.marginRight, 10) || 0,
9678
- };
9679
- }
9680
-
9681
9539
  const TREND_LINE_XAXIS_ID = "x1";
9682
9540
  /**
9683
9541
  * This file contains helpers that are common to different charts (mainly
@@ -10222,79 +10080,341 @@ function getNextNonEmptyBar(bars, startIndex) {
10222
10080
  return bars.find((bar, i) => i > startIndex && bar.height !== 0);
10223
10081
  }
10224
10082
 
10225
- window.Chart?.register(waterfallLinesPlugin);
10226
- window.Chart?.register(chartShowValuesPlugin);
10227
- css /* scss */ `
10228
- .o-spreadsheet {
10229
- .o-chart-custom-tooltip {
10230
- font-size: 12px;
10231
- background-color: #fff;
10232
- z-index: ${ComponentsImportance.FigureTooltip};
10083
+ const GAUGE_PADDING_SIDE = 30;
10084
+ const GAUGE_PADDING_TOP = 10;
10085
+ const GAUGE_PADDING_BOTTOM = 20;
10086
+ const GAUGE_LABELS_FONT_SIZE = 12;
10087
+ const GAUGE_DEFAULT_VALUE_FONT_SIZE = 80;
10088
+ const GAUGE_BACKGROUND_COLOR = "#F3F2F1";
10089
+ const GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN = 6;
10090
+ const GAUGE_TITLE_SECTION_HEIGHT = 25;
10091
+ function drawGaugeChart(canvas, runtime) {
10092
+ const canvasBoundingRect = canvas.getBoundingClientRect();
10093
+ canvas.width = canvasBoundingRect.width;
10094
+ canvas.height = canvasBoundingRect.height;
10095
+ const ctx = canvas.getContext("2d");
10096
+ const config = getGaugeRenderingConfig(canvasBoundingRect, runtime, ctx);
10097
+ drawBackground(ctx, config);
10098
+ drawGauge(ctx, config);
10099
+ drawInflectionValues(ctx, config);
10100
+ drawLabels(ctx, config);
10101
+ drawTitle(ctx, config);
10102
+ }
10103
+ function drawGauge(ctx, config) {
10104
+ ctx.save();
10105
+ const gauge = config.gauge;
10106
+ const arcCenterX = gauge.rect.x + gauge.rect.width / 2;
10107
+ const arcCenterY = gauge.rect.y + gauge.rect.height;
10108
+ const arcRadius = gauge.rect.height - gauge.arcWidth / 2;
10109
+ if (arcRadius < 0) {
10110
+ return;
10233
10111
  }
10234
- }
10235
- `;
10236
- class ChartJsComponent extends owl.Component {
10237
- static template = "o-spreadsheet-ChartJsComponent";
10238
- static props = {
10239
- figure: Object,
10112
+ const gaugeAngle = gauge.percentage === 1 ? 0 : Math.PI * (1 + gauge.percentage);
10113
+ // Gauge background
10114
+ ctx.strokeStyle = GAUGE_BACKGROUND_COLOR;
10115
+ ctx.beginPath();
10116
+ ctx.lineWidth = gauge.arcWidth;
10117
+ ctx.arc(arcCenterX, arcCenterY, arcRadius, gaugeAngle, 0);
10118
+ ctx.stroke();
10119
+ // Gauge value
10120
+ ctx.strokeStyle = gauge.color;
10121
+ ctx.beginPath();
10122
+ ctx.arc(arcCenterX, arcCenterY, arcRadius, Math.PI, gaugeAngle);
10123
+ ctx.stroke();
10124
+ ctx.restore();
10125
+ }
10126
+ function drawBackground(ctx, config) {
10127
+ ctx.save();
10128
+ ctx.fillStyle = config.backgroundColor;
10129
+ ctx.fillRect(0, 0, config.width, config.height);
10130
+ ctx.restore();
10131
+ }
10132
+ function drawLabels(ctx, config) {
10133
+ for (const label of [config.minLabel, config.maxLabel, config.gaugeValue]) {
10134
+ ctx.save();
10135
+ ctx.textAlign = "center";
10136
+ ctx.fillStyle = label.color;
10137
+ ctx.font = `${label.fontSize}px ${DEFAULT_FONT}`;
10138
+ ctx.fillText(label.label, label.textPosition.x, label.textPosition.y);
10139
+ ctx.restore();
10140
+ }
10141
+ }
10142
+ function drawInflectionValues(ctx, config) {
10143
+ const { x: rectX, y: rectY, width, height } = config.gauge.rect;
10144
+ for (const inflectionValue of config.inflectionValues) {
10145
+ ctx.save();
10146
+ ctx.translate(rectX + width / 2 - 0.5, rectY + height - 0.5); // -0.5 for sharper lines. see RendererPlugin.drawBorders comment
10147
+ ctx.rotate(Math.PI / 2 - inflectionValue.rotation);
10148
+ ctx.lineWidth = 2;
10149
+ ctx.strokeStyle = chartMutedFontColor(config.backgroundColor) + "aa";
10150
+ ctx.beginPath();
10151
+ ctx.moveTo(0, -(height - config.gauge.arcWidth));
10152
+ ctx.lineTo(0, -height - 3);
10153
+ ctx.stroke();
10154
+ ctx.textAlign = "center";
10155
+ ctx.font = `${inflectionValue.fontSize}px ${DEFAULT_FONT}`;
10156
+ ctx.fillStyle = inflectionValue.color;
10157
+ const textY = -height - GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN - inflectionValue.offset;
10158
+ ctx.fillText(inflectionValue.label, 0, textY);
10159
+ ctx.restore();
10160
+ }
10161
+ }
10162
+ function drawTitle(ctx, config) {
10163
+ ctx.save();
10164
+ const title = config.title;
10165
+ ctx.font = getDefaultContextFont(title.fontSize, title.bold, title.italic);
10166
+ ctx.textBaseline = "middle";
10167
+ ctx.fillStyle = title.color;
10168
+ ctx.fillText(title.label, title.textPosition.x, title.textPosition.y);
10169
+ ctx.restore();
10170
+ }
10171
+ function getGaugeRenderingConfig(boundingRect, runtime, ctx) {
10172
+ const maxValue = runtime.maxValue;
10173
+ const minValue = runtime.minValue;
10174
+ const gaugeValue = runtime.gaugeValue;
10175
+ const gaugeRect = getGaugeRect(boundingRect, runtime.title.text);
10176
+ const gaugeArcWidth = gaugeRect.width / 6;
10177
+ const gaugePercentage = gaugeValue
10178
+ ? (gaugeValue.value - minValue.value) / (maxValue.value - minValue.value)
10179
+ : 0;
10180
+ const gaugeValuePosition = {
10181
+ x: boundingRect.width / 2,
10182
+ y: gaugeRect.y + gaugeRect.height - gaugeRect.height / 12,
10240
10183
  };
10241
- canvas = owl.useRef("graphContainer");
10242
- chart;
10243
- currentRuntime;
10244
- get background() {
10245
- return this.chartRuntime.background;
10184
+ let gaugeValueFontSize = GAUGE_DEFAULT_VALUE_FONT_SIZE;
10185
+ // Scale down the font size if the gaugeRect is too small
10186
+ if (gaugeRect.height < 300) {
10187
+ gaugeValueFontSize = gaugeValueFontSize * (gaugeRect.height / 300);
10246
10188
  }
10247
- get canvasStyle() {
10248
- return `background-color: ${this.background}`;
10189
+ // Scale down the font size if the text is too long
10190
+ const maxTextWidth = gaugeRect.width / 2;
10191
+ const gaugeLabel = gaugeValue?.label || "-";
10192
+ if (computeTextWidth(ctx, gaugeLabel, { fontSize: gaugeValueFontSize }, "px") > maxTextWidth) {
10193
+ gaugeValueFontSize = getFontSizeMatchingWidth(maxTextWidth, gaugeValueFontSize, (fontSize) => computeTextWidth(ctx, gaugeLabel, { fontSize }, "px"));
10249
10194
  }
10250
- get chartRuntime() {
10251
- const runtime = this.env.model.getters.getChartRuntime(this.props.figure.id);
10252
- if (!("chartJsConfig" in runtime)) {
10253
- throw new Error("Unsupported chart runtime");
10254
- }
10255
- return runtime;
10195
+ const minLabelPosition = {
10196
+ x: gaugeRect.x + gaugeArcWidth / 2,
10197
+ y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
10198
+ };
10199
+ const maxLabelPosition = {
10200
+ x: gaugeRect.x + gaugeRect.width - gaugeArcWidth / 2,
10201
+ y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
10202
+ };
10203
+ const textColor = chartMutedFontColor(runtime.background);
10204
+ const inflectionValues = getInflectionValues(runtime, gaugeRect, textColor, ctx);
10205
+ let x = 0, titleWidth = 0, titleHeight = 0;
10206
+ if (runtime.title.text) {
10207
+ ({ width: titleWidth, height: titleHeight } = computeTextDimension(ctx, runtime.title.text, { fontSize: CHART_TITLE_FONT_SIZE, ...runtime.title }, "px"));
10256
10208
  }
10257
- setup() {
10258
- owl.onMounted(() => {
10259
- const runtime = this.chartRuntime;
10260
- this.currentRuntime = runtime;
10261
- // Note: chartJS modify the runtime in place, so it's important to give it a copy
10262
- this.createChart(deepCopy(runtime.chartJsConfig));
10263
- });
10264
- owl.onWillUnmount(() => this.chart?.destroy());
10265
- owl.useEffect(() => {
10266
- const runtime = this.chartRuntime;
10267
- if (runtime !== this.currentRuntime) {
10268
- if (runtime.chartJsConfig.type !== this.currentRuntime.chartJsConfig.type) {
10269
- this.chart?.destroy();
10270
- this.createChart(deepCopy(runtime.chartJsConfig));
10271
- }
10272
- else {
10273
- this.updateChartJs(deepCopy(runtime));
10274
- }
10275
- this.currentRuntime = runtime;
10276
- }
10209
+ switch (runtime.title.align) {
10210
+ case "right":
10211
+ x = boundingRect.width - titleWidth - CHART_PADDING$1;
10212
+ break;
10213
+ case "center":
10214
+ x = (boundingRect.width - titleWidth) / 2;
10215
+ break;
10216
+ case "left":
10217
+ default:
10218
+ x = CHART_PADDING$1;
10219
+ break;
10220
+ }
10221
+ return {
10222
+ width: boundingRect.width,
10223
+ height: boundingRect.height,
10224
+ title: {
10225
+ label: runtime.title.text ?? "",
10226
+ fontSize: runtime.title.fontSize ?? CHART_TITLE_FONT_SIZE,
10227
+ textPosition: {
10228
+ x,
10229
+ y: CHART_PADDING_TOP + titleHeight / 2,
10230
+ },
10231
+ color: runtime.title.color ?? textColor,
10232
+ bold: runtime.title.bold,
10233
+ italic: runtime.title.italic,
10234
+ },
10235
+ backgroundColor: runtime.background,
10236
+ gauge: {
10237
+ rect: gaugeRect,
10238
+ arcWidth: gaugeArcWidth,
10239
+ percentage: clip(gaugePercentage, 0, 1),
10240
+ color: getGaugeColor(runtime),
10241
+ },
10242
+ inflectionValues,
10243
+ gaugeValue: {
10244
+ label: gaugeLabel,
10245
+ textPosition: gaugeValuePosition,
10246
+ fontSize: gaugeValueFontSize,
10247
+ color: textColor,
10248
+ },
10249
+ minLabel: {
10250
+ label: runtime.minValue.label,
10251
+ textPosition: minLabelPosition,
10252
+ fontSize: GAUGE_LABELS_FONT_SIZE,
10253
+ color: textColor,
10254
+ },
10255
+ maxLabel: {
10256
+ label: runtime.maxValue.label,
10257
+ textPosition: maxLabelPosition,
10258
+ fontSize: GAUGE_LABELS_FONT_SIZE,
10259
+ color: textColor,
10260
+ },
10261
+ };
10262
+ }
10263
+ /**
10264
+ * Get the rectangle in which the gauge will be drawn, based on the bounding rectangle of the canvas and leaving
10265
+ * space for the title and labels.
10266
+ */
10267
+ function getGaugeRect(boundingRect, title) {
10268
+ const titleHeight = title ? GAUGE_TITLE_SECTION_HEIGHT : 0;
10269
+ const drawHeight = boundingRect.height - GAUGE_PADDING_BOTTOM - titleHeight - GAUGE_PADDING_TOP;
10270
+ const drawWidth = boundingRect.width - GAUGE_PADDING_SIDE * 2;
10271
+ let gaugeWidth;
10272
+ let gaugeHeight;
10273
+ if (drawWidth > 2 * drawHeight) {
10274
+ gaugeWidth = 2 * drawHeight;
10275
+ gaugeHeight = drawHeight;
10276
+ }
10277
+ else {
10278
+ gaugeWidth = drawWidth;
10279
+ gaugeHeight = drawWidth / 2;
10280
+ }
10281
+ const gaugeX = GAUGE_PADDING_SIDE + (drawWidth - gaugeWidth) / 2;
10282
+ const gaugeY = titleHeight + GAUGE_PADDING_TOP + (drawHeight - gaugeHeight) / 2;
10283
+ return {
10284
+ x: gaugeX,
10285
+ y: gaugeY,
10286
+ width: gaugeWidth,
10287
+ height: gaugeHeight,
10288
+ };
10289
+ }
10290
+ /**
10291
+ * 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).
10292
+ *
10293
+ * Also compute an offset for the text so that it doesn't overlap with other text.
10294
+ */
10295
+ function getInflectionValues(runtime, gaugeRect, textColor, ctx) {
10296
+ const maxValue = runtime.maxValue;
10297
+ const minValue = runtime.minValue;
10298
+ const gaugeCircleCenter = {
10299
+ x: gaugeRect.x + gaugeRect.width / 2,
10300
+ y: gaugeRect.y + gaugeRect.height,
10301
+ };
10302
+ const textStyle = { fontSize: GAUGE_LABELS_FONT_SIZE };
10303
+ const inflectionValues = [];
10304
+ const inflectionValuesTextRects = [];
10305
+ for (const inflectionValue of runtime.inflectionValues) {
10306
+ const percentage = (inflectionValue.value - minValue.value) / (maxValue.value - minValue.value);
10307
+ const labelWidth = computeTextWidth(ctx, inflectionValue.label, textStyle, "px");
10308
+ const angle = Math.PI - Math.PI * percentage;
10309
+ const textRect = getRectangleTangentToCircle(angle, // angle between X axis and the point where the rectangle is tangent to the circle
10310
+ gaugeRect.height + GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN, // radius of the gauge circle + margin below text
10311
+ gaugeCircleCenter.x, // center of the gauge circle
10312
+ gaugeCircleCenter.y, // center of the gauge circle
10313
+ labelWidth + 2, // width of the text + some margin
10314
+ GAUGE_LABELS_FONT_SIZE // height of the text
10315
+ );
10316
+ let offset = inflectionValuesTextRects.some((rect) => doRectanglesIntersect(rect, textRect))
10317
+ ? GAUGE_LABELS_FONT_SIZE
10318
+ : 0;
10319
+ inflectionValuesTextRects.push(textRect);
10320
+ inflectionValues.push({
10321
+ rotation: angle,
10322
+ label: inflectionValue.label,
10323
+ fontSize: GAUGE_LABELS_FONT_SIZE,
10324
+ color: textColor,
10325
+ offset,
10277
10326
  });
10278
10327
  }
10279
- createChart(chartData) {
10280
- const canvas = this.canvas.el;
10281
- const ctx = canvas.getContext("2d");
10282
- this.chart = new window.Chart(ctx, chartData);
10328
+ return inflectionValues;
10329
+ }
10330
+ function getGaugeColor(runtime) {
10331
+ const gaugeValue = runtime.gaugeValue?.value;
10332
+ if (gaugeValue === undefined) {
10333
+ return GAUGE_BACKGROUND_COLOR;
10283
10334
  }
10284
- updateChartJs(chartRuntime) {
10285
- const chartData = chartRuntime.chartJsConfig;
10286
- if (chartData.data && chartData.data.datasets) {
10287
- this.chart.data = chartData.data;
10288
- if (chartData.options?.plugins?.title) {
10289
- this.chart.config.options.plugins.title = chartData.options.plugins.title;
10290
- }
10335
+ for (let i = 0; i < runtime.inflectionValues.length; i++) {
10336
+ const inflectionValue = runtime.inflectionValues[i];
10337
+ if (inflectionValue.operator === "<" && gaugeValue < inflectionValue.value) {
10338
+ return runtime.colors[i];
10291
10339
  }
10292
- else {
10293
- this.chart.data.datasets = [];
10340
+ else if (inflectionValue.operator === "<=" && gaugeValue <= inflectionValue.value) {
10341
+ return runtime.colors[i];
10294
10342
  }
10295
- this.chart.config.options = chartData.options;
10296
- this.chart.update();
10297
10343
  }
10344
+ return runtime.colors.at(-1);
10345
+ }
10346
+ function getSegmentsOfRectangle(rectangle) {
10347
+ return [
10348
+ { start: rectangle.topLeft, end: rectangle.topRight },
10349
+ { start: rectangle.topRight, end: rectangle.bottomRight },
10350
+ { start: rectangle.bottomRight, end: rectangle.bottomLeft },
10351
+ { start: rectangle.bottomLeft, end: rectangle.topLeft },
10352
+ ];
10353
+ }
10354
+ /**
10355
+ * Check if two segment intersect. The case where the segments are colinear (both segments on the same line)
10356
+ * is not handled.
10357
+ */
10358
+ function doSegmentIntersect(segment1, segment2) {
10359
+ const A = segment1.start;
10360
+ const B = segment1.end;
10361
+ const C = segment2.start;
10362
+ const D = segment2.end;
10363
+ /**
10364
+ * Line segment intersection algorithm
10365
+ * https://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/
10366
+ */
10367
+ function ccw(a, b, c) {
10368
+ return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
10369
+ }
10370
+ return ccw(A, C, D) !== ccw(B, C, D) && ccw(A, B, C) !== ccw(A, B, D);
10371
+ }
10372
+ function doRectanglesIntersect(rect1, rect2) {
10373
+ const segments1 = getSegmentsOfRectangle(rect1);
10374
+ const segments2 = getSegmentsOfRectangle(rect2);
10375
+ for (const segment1 of segments1) {
10376
+ for (const segment2 of segments2) {
10377
+ if (doSegmentIntersect(segment1, segment2)) {
10378
+ return true;
10379
+ }
10380
+ }
10381
+ }
10382
+ return false;
10383
+ }
10384
+ /**
10385
+ * Get the rectangle that is tangent to a circle at a given angle.
10386
+ *
10387
+ * @param angle angle between X axis and the point where the rectangle is tangent to the circle
10388
+ */
10389
+ function getRectangleTangentToCircle(angle, radius, circleCenterX, circleCenterY, rectWidth, rectHeight) {
10390
+ const cos = Math.cos(angle);
10391
+ const sin = Math.sin(angle);
10392
+ // x, y are the distance from the center of the circle to the point where the rectangle is tangent to the circle
10393
+ const x = cos * radius;
10394
+ const y = sin * radius;
10395
+ // x2, y2 are the distance from the point the rectangle is tangent to the circle to the bottom left corner of the rectangle
10396
+ const x2 = sin * (rectWidth / 2); // cos(angle + 90°) = sin(angle)
10397
+ const y2 = cos * (rectWidth / 2);
10398
+ const bottomRight = {
10399
+ x: x + x2 + circleCenterX,
10400
+ y: circleCenterY - (y - y2),
10401
+ };
10402
+ const bottomLeft = {
10403
+ x: x - x2 + circleCenterX,
10404
+ y: circleCenterY - (y + y2),
10405
+ };
10406
+ // Same as above but for the top corners of the rectangle (radius + rectangle height instead of radius)
10407
+ const xp = cos * (radius + rectHeight);
10408
+ const yp = sin * (radius + rectHeight);
10409
+ const topLeft = {
10410
+ x: xp - x2 + circleCenterX,
10411
+ y: circleCenterY - (yp + y2),
10412
+ };
10413
+ const topRight = {
10414
+ x: xp + x2 + circleCenterX,
10415
+ y: circleCenterY - (yp - y2),
10416
+ };
10417
+ return { bottomLeft, bottomRight, topRight, topLeft };
10298
10418
  }
10299
10419
 
10300
10420
  /**
@@ -10876,6 +10996,299 @@ class ScorecardChartConfigBuilder {
10876
10996
  }
10877
10997
  }
10878
10998
 
10999
+ const CHART_COMMON_OPTIONS = {
11000
+ // https://www.chartjs.org/docs/latest/general/responsive.html
11001
+ responsive: true, // will resize when its container is resized
11002
+ maintainAspectRatio: false, // doesn't maintain the aspect ratio (width/height =2 by default) so the user has the choice of the exact layout
11003
+ elements: {
11004
+ line: {
11005
+ fill: false, // do not fill the area under line charts
11006
+ },
11007
+ point: {
11008
+ hitRadius: 15, // increased hit radius to display point tooltip when hovering nearby
11009
+ },
11010
+ },
11011
+ animation: false,
11012
+ };
11013
+ function chartToImage(runtime, figure, type) {
11014
+ // wrap the canvas in a div with a fixed size because chart.js would
11015
+ // fill the whole page otherwise
11016
+ const div = document.createElement("div");
11017
+ div.style.width = `${figure.width}px`;
11018
+ div.style.height = `${figure.height}px`;
11019
+ const canvas = document.createElement("canvas");
11020
+ div.append(canvas);
11021
+ canvas.setAttribute("width", figure.width.toString());
11022
+ canvas.setAttribute("height", figure.height.toString());
11023
+ // we have to add the canvas to the DOM otherwise it won't be rendered
11024
+ document.body.append(div);
11025
+ if ("chartJsConfig" in runtime) {
11026
+ const config = deepCopy(runtime.chartJsConfig);
11027
+ config.plugins = [backgroundColorChartJSPlugin];
11028
+ const Chart = getChartJSConstructor();
11029
+ const chart = new Chart(canvas, config);
11030
+ const imgContent = chart.toBase64Image();
11031
+ chart.destroy();
11032
+ div.remove();
11033
+ return imgContent;
11034
+ }
11035
+ else if (type === "scorecard") {
11036
+ const design = getScorecardConfiguration(figure, runtime);
11037
+ drawScoreChart(design, canvas);
11038
+ const imgContent = canvas.toDataURL();
11039
+ div.remove();
11040
+ return imgContent;
11041
+ }
11042
+ else if (type === "gauge") {
11043
+ drawGaugeChart(canvas, runtime);
11044
+ const imgContent = canvas.toDataURL();
11045
+ div.remove();
11046
+ return imgContent;
11047
+ }
11048
+ return undefined;
11049
+ }
11050
+ /**
11051
+ * Custom chart.js plugin to set the background color of the canvas
11052
+ * https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
11053
+ */
11054
+ const backgroundColorChartJSPlugin = {
11055
+ id: "customCanvasBackgroundColor",
11056
+ beforeDraw: (chart) => {
11057
+ const { ctx } = chart;
11058
+ ctx.save();
11059
+ ctx.globalCompositeOperation = "destination-over";
11060
+ ctx.fillStyle = "#ffffff";
11061
+ ctx.fillRect(0, 0, chart.width, chart.height);
11062
+ ctx.restore();
11063
+ },
11064
+ };
11065
+ /** Return window.Chart, making sure all our extensions are loaded in ChartJS */
11066
+ function getChartJSConstructor() {
11067
+ if (window.Chart && !window.Chart?.registry.plugins.get("chartShowValuesPlugin")) {
11068
+ window.Chart.register(chartShowValuesPlugin);
11069
+ window.Chart.register(waterfallLinesPlugin);
11070
+ }
11071
+ return window.Chart;
11072
+ }
11073
+
11074
+ /**
11075
+ * This file is largely inspired by owl 1.
11076
+ * `css` tag has been removed from owl 2 without workaround to manage css.
11077
+ * So, the solution was to import the behavior of owl 1 directly in our
11078
+ * codebase, with one difference: the css is added to the sheet as soon as the
11079
+ * css tag is executed. In owl 1, the css was added as soon as a Component was
11080
+ * created for the first time.
11081
+ */
11082
+ const STYLESHEETS = {};
11083
+ let nextId = 0;
11084
+ /**
11085
+ * CSS tag helper for defining inline stylesheets. With this, one can simply define
11086
+ * an inline stylesheet with just the following code:
11087
+ * ```js
11088
+ * css`.component-a { color: red; }`;
11089
+ * ```
11090
+ */
11091
+ function css(strings, ...args) {
11092
+ const name = `__sheet__${nextId++}`;
11093
+ const value = String.raw(strings, ...args);
11094
+ registerSheet(name, value);
11095
+ activateSheet(name);
11096
+ return name;
11097
+ }
11098
+ function processSheet(str) {
11099
+ const tokens = str.split(/(\{|\}|;)/).map((s) => s.trim());
11100
+ const selectorStack = [];
11101
+ const parts = [];
11102
+ let rules = [];
11103
+ function generateSelector(stackIndex, parentSelector) {
11104
+ const parts = [];
11105
+ for (const selector of selectorStack[stackIndex]) {
11106
+ let part = (parentSelector && parentSelector + " " + selector) || selector;
11107
+ if (part.includes("&")) {
11108
+ part = selector.replace(/&/g, parentSelector || "");
11109
+ }
11110
+ if (stackIndex < selectorStack.length - 1) {
11111
+ part = generateSelector(stackIndex + 1, part);
11112
+ }
11113
+ parts.push(part);
11114
+ }
11115
+ return parts.join(", ");
11116
+ }
11117
+ function generateRules() {
11118
+ if (rules.length) {
11119
+ parts.push(generateSelector(0) + " {");
11120
+ parts.push(...rules);
11121
+ parts.push("}");
11122
+ rules = [];
11123
+ }
11124
+ }
11125
+ while (tokens.length) {
11126
+ let token = tokens.shift();
11127
+ if (token === "}") {
11128
+ generateRules();
11129
+ selectorStack.pop();
11130
+ }
11131
+ else {
11132
+ if (tokens[0] === "{") {
11133
+ generateRules();
11134
+ selectorStack.push(token.split(/\s*,\s*/));
11135
+ tokens.shift();
11136
+ }
11137
+ if (tokens[0] === ";") {
11138
+ rules.push(" " + token + ";");
11139
+ }
11140
+ }
11141
+ }
11142
+ return parts.join("\n");
11143
+ }
11144
+ function registerSheet(id, css) {
11145
+ const sheet = document.createElement("style");
11146
+ sheet.textContent = processSheet(css);
11147
+ STYLESHEETS[id] = sheet;
11148
+ }
11149
+ function activateSheet(id) {
11150
+ const sheet = STYLESHEETS[id];
11151
+ sheet.setAttribute("component", id);
11152
+ document.head.appendChild(sheet);
11153
+ }
11154
+ function getTextDecoration({ strikethrough, underline, }) {
11155
+ if (!strikethrough && !underline) {
11156
+ return "none";
11157
+ }
11158
+ return `${strikethrough ? "line-through" : ""} ${underline ? "underline" : ""}`;
11159
+ }
11160
+ /**
11161
+ * Convert the cell style to CSS properties.
11162
+ */
11163
+ function cellStyleToCss(style) {
11164
+ const attributes = cellTextStyleToCss(style);
11165
+ if (!style)
11166
+ return attributes;
11167
+ if (style.fillColor) {
11168
+ attributes["background"] = style.fillColor;
11169
+ }
11170
+ return attributes;
11171
+ }
11172
+ /**
11173
+ * Convert the cell text style to CSS properties.
11174
+ */
11175
+ function cellTextStyleToCss(style) {
11176
+ const attributes = {};
11177
+ if (!style)
11178
+ return attributes;
11179
+ if (style.bold) {
11180
+ attributes["font-weight"] = "bold";
11181
+ }
11182
+ if (style.italic) {
11183
+ attributes["font-style"] = "italic";
11184
+ }
11185
+ if (style.strikethrough || style.underline) {
11186
+ let decoration = style.strikethrough ? "line-through" : "";
11187
+ decoration = style.underline ? decoration + " underline" : decoration;
11188
+ attributes["text-decoration"] = decoration;
11189
+ }
11190
+ if (style.textColor) {
11191
+ attributes["color"] = style.textColor;
11192
+ }
11193
+ return attributes;
11194
+ }
11195
+ /**
11196
+ * Transform CSS properties into a CSS string.
11197
+ */
11198
+ function cssPropertiesToCss(attributes) {
11199
+ let styleStr = "";
11200
+ for (const attName in attributes) {
11201
+ if (!attributes[attName]) {
11202
+ continue;
11203
+ }
11204
+ styleStr += `${attName}:${attributes[attName]}; `;
11205
+ }
11206
+ return styleStr;
11207
+ }
11208
+ function getElementMargins(el) {
11209
+ const style = window.getComputedStyle(el);
11210
+ return {
11211
+ top: parseInt(style.marginTop, 10) || 0,
11212
+ bottom: parseInt(style.marginBottom, 10) || 0,
11213
+ left: parseInt(style.marginLeft, 10) || 0,
11214
+ right: parseInt(style.marginRight, 10) || 0,
11215
+ };
11216
+ }
11217
+
11218
+ css /* scss */ `
11219
+ .o-spreadsheet {
11220
+ .o-chart-custom-tooltip {
11221
+ font-size: 12px;
11222
+ background-color: #fff;
11223
+ z-index: ${ComponentsImportance.FigureTooltip};
11224
+ }
11225
+ }
11226
+ `;
11227
+ class ChartJsComponent extends owl.Component {
11228
+ static template = "o-spreadsheet-ChartJsComponent";
11229
+ static props = {
11230
+ figure: Object,
11231
+ };
11232
+ canvas = owl.useRef("graphContainer");
11233
+ chart;
11234
+ currentRuntime;
11235
+ get background() {
11236
+ return this.chartRuntime.background;
11237
+ }
11238
+ get canvasStyle() {
11239
+ return `background-color: ${this.background}`;
11240
+ }
11241
+ get chartRuntime() {
11242
+ const runtime = this.env.model.getters.getChartRuntime(this.props.figure.id);
11243
+ if (!("chartJsConfig" in runtime)) {
11244
+ throw new Error("Unsupported chart runtime");
11245
+ }
11246
+ return runtime;
11247
+ }
11248
+ setup() {
11249
+ owl.onMounted(() => {
11250
+ const runtime = this.chartRuntime;
11251
+ this.currentRuntime = runtime;
11252
+ // Note: chartJS modify the runtime in place, so it's important to give it a copy
11253
+ this.createChart(deepCopy(runtime.chartJsConfig));
11254
+ });
11255
+ owl.onWillUnmount(() => this.chart?.destroy());
11256
+ owl.useEffect(() => {
11257
+ const runtime = this.chartRuntime;
11258
+ if (runtime !== this.currentRuntime) {
11259
+ if (runtime.chartJsConfig.type !== this.currentRuntime.chartJsConfig.type) {
11260
+ this.chart?.destroy();
11261
+ this.createChart(deepCopy(runtime.chartJsConfig));
11262
+ }
11263
+ else {
11264
+ this.updateChartJs(deepCopy(runtime));
11265
+ }
11266
+ this.currentRuntime = runtime;
11267
+ }
11268
+ });
11269
+ }
11270
+ createChart(chartData) {
11271
+ const canvas = this.canvas.el;
11272
+ const ctx = canvas.getContext("2d");
11273
+ const Chart = getChartJSConstructor();
11274
+ this.chart = new Chart(ctx, chartData);
11275
+ }
11276
+ updateChartJs(chartRuntime) {
11277
+ const chartData = chartRuntime.chartJsConfig;
11278
+ if (chartData.data && chartData.data.datasets) {
11279
+ this.chart.data = chartData.data;
11280
+ if (chartData.options?.plugins?.title) {
11281
+ this.chart.config.options.plugins.title = chartData.options.plugins.title;
11282
+ }
11283
+ }
11284
+ else {
11285
+ this.chart.data.datasets = [];
11286
+ }
11287
+ this.chart.config.options = chartData.options;
11288
+ this.chart.update();
11289
+ }
11290
+ }
11291
+
10879
11292
  class ScorecardChart extends owl.Component {
10880
11293
  static template = "o-spreadsheet-ScorecardChart";
10881
11294
  static props = {
@@ -22328,7 +22741,7 @@ autofillRulesRegistry
22328
22741
  condition: (cell) => !cell.isFormula &&
22329
22742
  evaluateLiteral(cell, { locale: DEFAULT_LOCALE }).type === CellValueType.text &&
22330
22743
  alphaNumericValueRegExp.test(cell.content),
22331
- generateRule: (cell, cells) => {
22744
+ generateRule: (cell, cells, direction) => {
22332
22745
  const numberPostfix = parseInt(cell.content.match(numberPostfixRegExp)[0]);
22333
22746
  const prefix = cell.content.match(stringPrefixRegExp)[0];
22334
22747
  const numberPostfixLength = cell.content.length - prefix.length;
@@ -22336,7 +22749,10 @@ autofillRulesRegistry
22336
22749
  alphaNumericValueRegExp.test(evaluatedCell.value)) // get consecutive alphanumeric cells, no matter what the prefix is
22337
22750
  .filter((cell) => prefix === (cell.value ?? "").toString().match(stringPrefixRegExp)[0])
22338
22751
  .map((cell) => parseInt((cell.value ?? "").toString().match(numberPostfixRegExp)[0]));
22339
- const increment = calculateIncrementBasedOnGroup(group);
22752
+ let increment = calculateIncrementBasedOnGroup(group);
22753
+ if (["up", "left"].includes(direction) && group.length === 1) {
22754
+ increment = -increment;
22755
+ }
22340
22756
  return {
22341
22757
  type: "ALPHANUMERIC_INCREMENT_MODIFIER",
22342
22758
  prefix,
@@ -22399,10 +22815,13 @@ autofillRulesRegistry
22399
22815
  .add("increment_number", {
22400
22816
  condition: (cell) => !cell.isFormula &&
22401
22817
  evaluateLiteral(cell, { locale: DEFAULT_LOCALE }).type === CellValueType.number,
22402
- generateRule: (cell, cells) => {
22818
+ generateRule: (cell, cells, direction) => {
22403
22819
  const group = getGroup(cell, cells, (evaluatedCell) => evaluatedCell.type === CellValueType.number &&
22404
22820
  !isDateTimeFormat(evaluatedCell.format || "")).map((cell) => Number(cell.value));
22405
- const increment = calculateIncrementBasedOnGroup(group);
22821
+ let increment = calculateIncrementBasedOnGroup(group);
22822
+ if (["up", "left"].includes(direction) && group.length === 1) {
22823
+ increment = -increment;
22824
+ }
22406
22825
  const evaluation = evaluateLiteral(cell, { locale: DEFAULT_LOCALE });
22407
22826
  return {
22408
22827
  type: "INCREMENT_MODIFIER",
@@ -22446,343 +22865,6 @@ function getDateIntervals(dates) {
22446
22865
 
22447
22866
  const cellPopoverRegistry = new Registry();
22448
22867
 
22449
- const GAUGE_PADDING_SIDE = 30;
22450
- const GAUGE_PADDING_TOP = 10;
22451
- const GAUGE_PADDING_BOTTOM = 20;
22452
- const GAUGE_LABELS_FONT_SIZE = 12;
22453
- const GAUGE_DEFAULT_VALUE_FONT_SIZE = 80;
22454
- const GAUGE_BACKGROUND_COLOR = "#F3F2F1";
22455
- const GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN = 6;
22456
- const GAUGE_TITLE_SECTION_HEIGHT = 25;
22457
- function drawGaugeChart(canvas, runtime) {
22458
- const canvasBoundingRect = canvas.getBoundingClientRect();
22459
- canvas.width = canvasBoundingRect.width;
22460
- canvas.height = canvasBoundingRect.height;
22461
- const ctx = canvas.getContext("2d");
22462
- const config = getGaugeRenderingConfig(canvasBoundingRect, runtime, ctx);
22463
- drawBackground(ctx, config);
22464
- drawGauge(ctx, config);
22465
- drawInflectionValues(ctx, config);
22466
- drawLabels(ctx, config);
22467
- drawTitle(ctx, config);
22468
- }
22469
- function drawGauge(ctx, config) {
22470
- ctx.save();
22471
- const gauge = config.gauge;
22472
- const arcCenterX = gauge.rect.x + gauge.rect.width / 2;
22473
- const arcCenterY = gauge.rect.y + gauge.rect.height;
22474
- const arcRadius = gauge.rect.height - gauge.arcWidth / 2;
22475
- if (arcRadius < 0) {
22476
- return;
22477
- }
22478
- const gaugeAngle = gauge.percentage === 1 ? 0 : Math.PI * (1 + gauge.percentage);
22479
- // Gauge background
22480
- ctx.strokeStyle = GAUGE_BACKGROUND_COLOR;
22481
- ctx.beginPath();
22482
- ctx.lineWidth = gauge.arcWidth;
22483
- ctx.arc(arcCenterX, arcCenterY, arcRadius, gaugeAngle, 0);
22484
- ctx.stroke();
22485
- // Gauge value
22486
- ctx.strokeStyle = gauge.color;
22487
- ctx.beginPath();
22488
- ctx.arc(arcCenterX, arcCenterY, arcRadius, Math.PI, gaugeAngle);
22489
- ctx.stroke();
22490
- ctx.restore();
22491
- }
22492
- function drawBackground(ctx, config) {
22493
- ctx.save();
22494
- ctx.fillStyle = config.backgroundColor;
22495
- ctx.fillRect(0, 0, config.width, config.height);
22496
- ctx.restore();
22497
- }
22498
- function drawLabels(ctx, config) {
22499
- for (const label of [config.minLabel, config.maxLabel, config.gaugeValue]) {
22500
- ctx.save();
22501
- ctx.textAlign = "center";
22502
- ctx.fillStyle = label.color;
22503
- ctx.font = `${label.fontSize}px ${DEFAULT_FONT}`;
22504
- ctx.fillText(label.label, label.textPosition.x, label.textPosition.y);
22505
- ctx.restore();
22506
- }
22507
- }
22508
- function drawInflectionValues(ctx, config) {
22509
- const { x: rectX, y: rectY, width, height } = config.gauge.rect;
22510
- for (const inflectionValue of config.inflectionValues) {
22511
- ctx.save();
22512
- ctx.translate(rectX + width / 2 - 0.5, rectY + height - 0.5); // -0.5 for sharper lines. see RendererPlugin.drawBorders comment
22513
- ctx.rotate(Math.PI / 2 - inflectionValue.rotation);
22514
- ctx.lineWidth = 2;
22515
- ctx.strokeStyle = chartMutedFontColor(config.backgroundColor) + "aa";
22516
- ctx.beginPath();
22517
- ctx.moveTo(0, -(height - config.gauge.arcWidth));
22518
- ctx.lineTo(0, -height - 3);
22519
- ctx.stroke();
22520
- ctx.textAlign = "center";
22521
- ctx.font = `${inflectionValue.fontSize}px ${DEFAULT_FONT}`;
22522
- ctx.fillStyle = inflectionValue.color;
22523
- const textY = -height - GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN - inflectionValue.offset;
22524
- ctx.fillText(inflectionValue.label, 0, textY);
22525
- ctx.restore();
22526
- }
22527
- }
22528
- function drawTitle(ctx, config) {
22529
- ctx.save();
22530
- const title = config.title;
22531
- ctx.font = getDefaultContextFont(title.fontSize, title.bold, title.italic);
22532
- ctx.textBaseline = "middle";
22533
- ctx.fillStyle = title.color;
22534
- ctx.fillText(title.label, title.textPosition.x, title.textPosition.y);
22535
- ctx.restore();
22536
- }
22537
- function getGaugeRenderingConfig(boundingRect, runtime, ctx) {
22538
- const maxValue = runtime.maxValue;
22539
- const minValue = runtime.minValue;
22540
- const gaugeValue = runtime.gaugeValue;
22541
- const gaugeRect = getGaugeRect(boundingRect, runtime.title.text);
22542
- const gaugeArcWidth = gaugeRect.width / 6;
22543
- const gaugePercentage = gaugeValue
22544
- ? (gaugeValue.value - minValue.value) / (maxValue.value - minValue.value)
22545
- : 0;
22546
- const gaugeValuePosition = {
22547
- x: boundingRect.width / 2,
22548
- y: gaugeRect.y + gaugeRect.height - gaugeRect.height / 12,
22549
- };
22550
- let gaugeValueFontSize = GAUGE_DEFAULT_VALUE_FONT_SIZE;
22551
- // Scale down the font size if the gaugeRect is too small
22552
- if (gaugeRect.height < 300) {
22553
- gaugeValueFontSize = gaugeValueFontSize * (gaugeRect.height / 300);
22554
- }
22555
- // Scale down the font size if the text is too long
22556
- const maxTextWidth = gaugeRect.width / 2;
22557
- const gaugeLabel = gaugeValue?.label || "-";
22558
- if (computeTextWidth(ctx, gaugeLabel, { fontSize: gaugeValueFontSize }, "px") > maxTextWidth) {
22559
- gaugeValueFontSize = getFontSizeMatchingWidth(maxTextWidth, gaugeValueFontSize, (fontSize) => computeTextWidth(ctx, gaugeLabel, { fontSize }, "px"));
22560
- }
22561
- const minLabelPosition = {
22562
- x: gaugeRect.x + gaugeArcWidth / 2,
22563
- y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
22564
- };
22565
- const maxLabelPosition = {
22566
- x: gaugeRect.x + gaugeRect.width - gaugeArcWidth / 2,
22567
- y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
22568
- };
22569
- const textColor = chartMutedFontColor(runtime.background);
22570
- const inflectionValues = getInflectionValues(runtime, gaugeRect, textColor, ctx);
22571
- let x = 0, titleWidth = 0, titleHeight = 0;
22572
- if (runtime.title.text) {
22573
- ({ width: titleWidth, height: titleHeight } = computeTextDimension(ctx, runtime.title.text, { fontSize: CHART_TITLE_FONT_SIZE, ...runtime.title }, "px"));
22574
- }
22575
- switch (runtime.title.align) {
22576
- case "right":
22577
- x = boundingRect.width - titleWidth - CHART_PADDING$1;
22578
- break;
22579
- case "center":
22580
- x = (boundingRect.width - titleWidth) / 2;
22581
- break;
22582
- case "left":
22583
- default:
22584
- x = CHART_PADDING$1;
22585
- break;
22586
- }
22587
- return {
22588
- width: boundingRect.width,
22589
- height: boundingRect.height,
22590
- title: {
22591
- label: runtime.title.text ?? "",
22592
- fontSize: runtime.title.fontSize ?? CHART_TITLE_FONT_SIZE,
22593
- textPosition: {
22594
- x,
22595
- y: CHART_PADDING_TOP + titleHeight / 2,
22596
- },
22597
- color: runtime.title.color ?? textColor,
22598
- bold: runtime.title.bold,
22599
- italic: runtime.title.italic,
22600
- },
22601
- backgroundColor: runtime.background,
22602
- gauge: {
22603
- rect: gaugeRect,
22604
- arcWidth: gaugeArcWidth,
22605
- percentage: clip(gaugePercentage, 0, 1),
22606
- color: getGaugeColor(runtime),
22607
- },
22608
- inflectionValues,
22609
- gaugeValue: {
22610
- label: gaugeLabel,
22611
- textPosition: gaugeValuePosition,
22612
- fontSize: gaugeValueFontSize,
22613
- color: textColor,
22614
- },
22615
- minLabel: {
22616
- label: runtime.minValue.label,
22617
- textPosition: minLabelPosition,
22618
- fontSize: GAUGE_LABELS_FONT_SIZE,
22619
- color: textColor,
22620
- },
22621
- maxLabel: {
22622
- label: runtime.maxValue.label,
22623
- textPosition: maxLabelPosition,
22624
- fontSize: GAUGE_LABELS_FONT_SIZE,
22625
- color: textColor,
22626
- },
22627
- };
22628
- }
22629
- /**
22630
- * Get the rectangle in which the gauge will be drawn, based on the bounding rectangle of the canvas and leaving
22631
- * space for the title and labels.
22632
- */
22633
- function getGaugeRect(boundingRect, title) {
22634
- const titleHeight = title ? GAUGE_TITLE_SECTION_HEIGHT : 0;
22635
- const drawHeight = boundingRect.height - GAUGE_PADDING_BOTTOM - titleHeight - GAUGE_PADDING_TOP;
22636
- const drawWidth = boundingRect.width - GAUGE_PADDING_SIDE * 2;
22637
- let gaugeWidth;
22638
- let gaugeHeight;
22639
- if (drawWidth > 2 * drawHeight) {
22640
- gaugeWidth = 2 * drawHeight;
22641
- gaugeHeight = drawHeight;
22642
- }
22643
- else {
22644
- gaugeWidth = drawWidth;
22645
- gaugeHeight = drawWidth / 2;
22646
- }
22647
- const gaugeX = GAUGE_PADDING_SIDE + (drawWidth - gaugeWidth) / 2;
22648
- const gaugeY = titleHeight + GAUGE_PADDING_TOP + (drawHeight - gaugeHeight) / 2;
22649
- return {
22650
- x: gaugeX,
22651
- y: gaugeY,
22652
- width: gaugeWidth,
22653
- height: gaugeHeight,
22654
- };
22655
- }
22656
- /**
22657
- * 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).
22658
- *
22659
- * Also compute an offset for the text so that it doesn't overlap with other text.
22660
- */
22661
- function getInflectionValues(runtime, gaugeRect, textColor, ctx) {
22662
- const maxValue = runtime.maxValue;
22663
- const minValue = runtime.minValue;
22664
- const gaugeCircleCenter = {
22665
- x: gaugeRect.x + gaugeRect.width / 2,
22666
- y: gaugeRect.y + gaugeRect.height,
22667
- };
22668
- const textStyle = { fontSize: GAUGE_LABELS_FONT_SIZE };
22669
- const inflectionValues = [];
22670
- const inflectionValuesTextRects = [];
22671
- for (const inflectionValue of runtime.inflectionValues) {
22672
- const percentage = (inflectionValue.value - minValue.value) / (maxValue.value - minValue.value);
22673
- const labelWidth = computeTextWidth(ctx, inflectionValue.label, textStyle, "px");
22674
- const angle = Math.PI - Math.PI * percentage;
22675
- const textRect = getRectangleTangentToCircle(angle, // angle between X axis and the point where the rectangle is tangent to the circle
22676
- gaugeRect.height + GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN, // radius of the gauge circle + margin below text
22677
- gaugeCircleCenter.x, // center of the gauge circle
22678
- gaugeCircleCenter.y, // center of the gauge circle
22679
- labelWidth + 2, // width of the text + some margin
22680
- GAUGE_LABELS_FONT_SIZE // height of the text
22681
- );
22682
- let offset = inflectionValuesTextRects.some((rect) => doRectanglesIntersect(rect, textRect))
22683
- ? GAUGE_LABELS_FONT_SIZE
22684
- : 0;
22685
- inflectionValuesTextRects.push(textRect);
22686
- inflectionValues.push({
22687
- rotation: angle,
22688
- label: inflectionValue.label,
22689
- fontSize: GAUGE_LABELS_FONT_SIZE,
22690
- color: textColor,
22691
- offset,
22692
- });
22693
- }
22694
- return inflectionValues;
22695
- }
22696
- function getGaugeColor(runtime) {
22697
- const gaugeValue = runtime.gaugeValue?.value;
22698
- if (gaugeValue === undefined) {
22699
- return GAUGE_BACKGROUND_COLOR;
22700
- }
22701
- for (let i = 0; i < runtime.inflectionValues.length; i++) {
22702
- const inflectionValue = runtime.inflectionValues[i];
22703
- if (inflectionValue.operator === "<" && gaugeValue < inflectionValue.value) {
22704
- return runtime.colors[i];
22705
- }
22706
- else if (inflectionValue.operator === "<=" && gaugeValue <= inflectionValue.value) {
22707
- return runtime.colors[i];
22708
- }
22709
- }
22710
- return runtime.colors.at(-1);
22711
- }
22712
- function getSegmentsOfRectangle(rectangle) {
22713
- return [
22714
- { start: rectangle.topLeft, end: rectangle.topRight },
22715
- { start: rectangle.topRight, end: rectangle.bottomRight },
22716
- { start: rectangle.bottomRight, end: rectangle.bottomLeft },
22717
- { start: rectangle.bottomLeft, end: rectangle.topLeft },
22718
- ];
22719
- }
22720
- /**
22721
- * Check if two segment intersect. The case where the segments are colinear (both segments on the same line)
22722
- * is not handled.
22723
- */
22724
- function doSegmentIntersect(segment1, segment2) {
22725
- const A = segment1.start;
22726
- const B = segment1.end;
22727
- const C = segment2.start;
22728
- const D = segment2.end;
22729
- /**
22730
- * Line segment intersection algorithm
22731
- * https://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/
22732
- */
22733
- function ccw(a, b, c) {
22734
- return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
22735
- }
22736
- return ccw(A, C, D) !== ccw(B, C, D) && ccw(A, B, C) !== ccw(A, B, D);
22737
- }
22738
- function doRectanglesIntersect(rect1, rect2) {
22739
- const segments1 = getSegmentsOfRectangle(rect1);
22740
- const segments2 = getSegmentsOfRectangle(rect2);
22741
- for (const segment1 of segments1) {
22742
- for (const segment2 of segments2) {
22743
- if (doSegmentIntersect(segment1, segment2)) {
22744
- return true;
22745
- }
22746
- }
22747
- }
22748
- return false;
22749
- }
22750
- /**
22751
- * Get the rectangle that is tangent to a circle at a given angle.
22752
- *
22753
- * @param angle angle between X axis and the point where the rectangle is tangent to the circle
22754
- */
22755
- function getRectangleTangentToCircle(angle, radius, circleCenterX, circleCenterY, rectWidth, rectHeight) {
22756
- const cos = Math.cos(angle);
22757
- const sin = Math.sin(angle);
22758
- // x, y are the distance from the center of the circle to the point where the rectangle is tangent to the circle
22759
- const x = cos * radius;
22760
- const y = sin * radius;
22761
- // x2, y2 are the distance from the point the rectangle is tangent to the circle to the bottom left corner of the rectangle
22762
- const x2 = sin * (rectWidth / 2); // cos(angle + 90°) = sin(angle)
22763
- const y2 = cos * (rectWidth / 2);
22764
- const bottomRight = {
22765
- x: x + x2 + circleCenterX,
22766
- y: circleCenterY - (y - y2),
22767
- };
22768
- const bottomLeft = {
22769
- x: x - x2 + circleCenterX,
22770
- y: circleCenterY - (y + y2),
22771
- };
22772
- // Same as above but for the top corners of the rectangle (radius + rectangle height instead of radius)
22773
- const xp = cos * (radius + rectHeight);
22774
- const yp = sin * (radius + rectHeight);
22775
- const topLeft = {
22776
- x: xp - x2 + circleCenterX,
22777
- y: circleCenterY - (yp + y2),
22778
- };
22779
- const topRight = {
22780
- x: xp + x2 + circleCenterX,
22781
- y: circleCenterY - (yp - y2),
22782
- };
22783
- return { bottomLeft, bottomRight, topRight, topLeft };
22784
- }
22785
-
22786
22868
  class GaugeChartComponent extends owl.Component {
22787
22869
  static template = "o-spreadsheet-GaugeChartComponent";
22788
22870
  canvas = owl.useRef("chartContainer");
@@ -22815,72 +22897,6 @@ function toXlsxHexColor(color) {
22815
22897
  return color;
22816
22898
  }
22817
22899
 
22818
- const CHART_COMMON_OPTIONS = {
22819
- // https://www.chartjs.org/docs/latest/general/responsive.html
22820
- responsive: true, // will resize when its container is resized
22821
- maintainAspectRatio: false, // doesn't maintain the aspect ratio (width/height =2 by default) so the user has the choice of the exact layout
22822
- elements: {
22823
- line: {
22824
- fill: false, // do not fill the area under line charts
22825
- },
22826
- point: {
22827
- hitRadius: 15, // increased hit radius to display point tooltip when hovering nearby
22828
- },
22829
- },
22830
- animation: false,
22831
- };
22832
- function chartToImage(runtime, figure, type) {
22833
- // wrap the canvas in a div with a fixed size because chart.js would
22834
- // fill the whole page otherwise
22835
- const div = document.createElement("div");
22836
- div.style.width = `${figure.width}px`;
22837
- div.style.height = `${figure.height}px`;
22838
- const canvas = document.createElement("canvas");
22839
- div.append(canvas);
22840
- canvas.setAttribute("width", figure.width.toString());
22841
- canvas.setAttribute("height", figure.height.toString());
22842
- // we have to add the canvas to the DOM otherwise it won't be rendered
22843
- document.body.append(div);
22844
- if ("chartJsConfig" in runtime) {
22845
- const config = deepCopy(runtime.chartJsConfig);
22846
- config.plugins = [backgroundColorChartJSPlugin];
22847
- const chart = new window.Chart(canvas, config);
22848
- const imgContent = chart.toBase64Image();
22849
- chart.destroy();
22850
- div.remove();
22851
- return imgContent;
22852
- }
22853
- else if (type === "scorecard") {
22854
- const design = getScorecardConfiguration(figure, runtime);
22855
- drawScoreChart(design, canvas);
22856
- const imgContent = canvas.toDataURL();
22857
- div.remove();
22858
- return imgContent;
22859
- }
22860
- else if (type === "gauge") {
22861
- drawGaugeChart(canvas, runtime);
22862
- const imgContent = canvas.toDataURL();
22863
- div.remove();
22864
- return imgContent;
22865
- }
22866
- return undefined;
22867
- }
22868
- /**
22869
- * Custom chart.js plugin to set the background color of the canvas
22870
- * https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
22871
- */
22872
- const backgroundColorChartJSPlugin = {
22873
- id: "customCanvasBackgroundColor",
22874
- beforeDraw: (chart) => {
22875
- const { ctx } = chart;
22876
- ctx.save();
22877
- ctx.globalCompositeOperation = "destination-over";
22878
- ctx.fillStyle = "#ffffff";
22879
- ctx.fillRect(0, 0, chart.width, chart.height);
22880
- ctx.restore();
22881
- },
22882
- };
22883
-
22884
22900
  /**
22885
22901
  * Represent a raw XML string
22886
22902
  */
@@ -22946,6 +22962,7 @@ const CONTENT_TYPES = {
22946
22962
  macroEnabledTemplateWorkbook: "application/vnd.ms-excel.template.macroEnabled.main+xml",
22947
22963
  excelAddInWorkbook: "application/vnd.ms-excel.addin.macroEnabled.main+xml",
22948
22964
  sheet: "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml",
22965
+ metadata: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml",
22949
22966
  sharedStrings: "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml",
22950
22967
  styles: "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml",
22951
22968
  drawing: "application/vnd.openxmlformats-officedocument.drawing+xml",
@@ -22958,6 +22975,7 @@ const CONTENT_TYPES = {
22958
22975
  const XLSX_RELATION_TYPE = {
22959
22976
  document: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
22960
22977
  sheet: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet",
22978
+ metadata: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sheetMetadata",
22961
22979
  sharedStrings: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings",
22962
22980
  styles: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles",
22963
22981
  drawing: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing",
@@ -22967,6 +22985,7 @@ const XLSX_RELATION_TYPE = {
22967
22985
  hyperlink: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink",
22968
22986
  image: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
22969
22987
  };
22988
+ const ARRAY_FORMULA_URI = "bdbb8cdc-fa1e-496e-a857-3c3f30c029c3";
22970
22989
  const RELATIONSHIP_NSR = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
22971
22990
  const HEIGHT_FACTOR = 0.75; // 100px => 75 u
22972
22991
  /**
@@ -25388,29 +25407,34 @@ function convertPivotTableConfig(pivotTable) {
25388
25407
  * In all the sheets, replace the table-only references in the formula cells with standard references.
25389
25408
  */
25390
25409
  function convertTableFormulaReferences(convertedSheets, xlsxSheets) {
25391
- for (let sheet of convertedSheets) {
25392
- const tables = xlsxSheets.find((s) => s.sheetName === sheet.name).tables;
25410
+ for (let tableSheet of convertedSheets) {
25411
+ const tables = xlsxSheets.find((s) => s.sheetName === tableSheet.name).tables;
25393
25412
  for (let table of tables) {
25394
25413
  const tabRef = table.name + "[";
25395
- for (let position of positions(toZone(table.ref))) {
25396
- const xc = toXC(position.col, position.row);
25397
- let cellContent = sheet.cells[xc];
25398
- if (cellContent?.startsWith("=")) {
25399
- let refIndex;
25400
- while ((refIndex = cellContent.indexOf(tabRef)) !== -1) {
25401
- let reference = cellContent.slice(refIndex + tabRef.length);
25402
- // Expression can either be tableName[colName] or tableName[[#This Row], [colName]]
25403
- let endIndex = reference.indexOf("]");
25404
- if (reference.startsWith(`[`)) {
25405
- endIndex = reference.indexOf("]", endIndex + 1);
25406
- endIndex = reference.indexOf("]", endIndex + 1);
25414
+ for (let sheet of convertedSheets) {
25415
+ for (let xc in sheet.cells) {
25416
+ const cell = sheet.cells[xc];
25417
+ let cellContent = sheet.cells[xc];
25418
+ if (cell && cellContent && cellContent.startsWith("=")) {
25419
+ let refIndex;
25420
+ while ((refIndex = cellContent.indexOf(tabRef)) !== -1) {
25421
+ let endIndex = refIndex + tabRef.length;
25422
+ let openBrackets = 1;
25423
+ while (openBrackets > 0 && endIndex < cellContent.length) {
25424
+ if (cellContent[endIndex] === "[") {
25425
+ openBrackets++;
25426
+ }
25427
+ else if (cellContent[endIndex] === "]") {
25428
+ openBrackets--;
25429
+ }
25430
+ endIndex++;
25431
+ }
25432
+ let reference = cellContent.slice(refIndex + tabRef.length, endIndex - 1);
25433
+ const sheetPrefix = tableSheet.id === sheet.id ? "" : tableSheet.name + "!";
25434
+ const convertedRef = convertTableReference(sheetPrefix, reference, table, xc);
25435
+ cellContent =
25436
+ cellContent.slice(0, refIndex) + convertedRef + cellContent.slice(endIndex);
25407
25437
  }
25408
- reference = reference.slice(0, endIndex);
25409
- const convertedRef = convertTableReference(reference, table, xc);
25410
- cellContent =
25411
- cellContent.slice(0, refIndex) +
25412
- convertedRef +
25413
- cellContent.slice(tabRef.length + refIndex + endIndex + 1);
25414
25438
  }
25415
25439
  sheet.cells[xc] = cellContent;
25416
25440
  }
@@ -25419,11 +25443,17 @@ function convertTableFormulaReferences(convertedSheets, xlsxSheets) {
25419
25443
  }
25420
25444
  }
25421
25445
  /**
25422
- * Convert table-specific references in formulas into standard references.
25446
+ * Convert table-specific references in formulas into standard references. A table reference is composed of columns names,
25447
+ * and of keywords determining the rows of the table to reference.
25423
25448
  *
25424
25449
  * A reference in a table can have the form (only the part between brackets should be given to this function):
25425
25450
  * - tableName[colName] : reference to the whole column "colName"
25451
+ * - tableName[#keyword] : reference to the whatever row the keyword refers to
25426
25452
  * - tableName[[#keyword], [colName]] : reference to some of the element(s) of the column colName
25453
+ * - tableName[[#keyword], [colName]:[col2Name]] : reference to some of the element(s) of the columns colName to col2Name
25454
+ * - tableName[[#keyword1], [#keyword2], [colName]] : reference to all the rows referenced by the keywords in the column colName
25455
+ * - tableName[[#keyword1], [colName], [#keyword2]]: the keywords and colName can be in any order
25456
+ *
25427
25457
  *
25428
25458
  * The available keywords are :
25429
25459
  * - #All : all the column (including totals)
@@ -25431,58 +25461,109 @@ function convertTableFormulaReferences(convertedSheets, xlsxSheets) {
25431
25461
  * - #Headers : only the header of the column
25432
25462
  * - #Totals : only the totals of the column
25433
25463
  * - #This Row : only the element in the same row as the cell
25464
+ *
25465
+ * Note that the only valid combination of multiple keywords are #Data + #Totals and #Headers + #Data.
25434
25466
  */
25435
- function convertTableReference(expr, table, cellXc) {
25436
- const refElements = expr.split(",");
25467
+ function convertTableReference(sheetPrefix, expr, table, cellXc) {
25468
+ // TODO: Ideally we'd want to make a real tokenizer, this simple approach won't work if for example the column name
25469
+ // contain # or , characters. But that's probably an edge case that we can ignore for now.
25470
+ const parts = expr.split(",").map((part) => part.trim());
25437
25471
  const tableZone = toZone(table.ref);
25438
- const refZone = { ...tableZone };
25439
- let isReferencedZoneValid = true;
25440
- // Single column reference
25441
- if (refElements.length === 1) {
25442
- const colRelativeIndex = table.cols.findIndex((col) => col.name === refElements[0]);
25443
- refZone.left = refZone.right = colRelativeIndex + tableZone.left;
25444
- if (table.headerRowCount) {
25445
- refZone.top += table.headerRowCount;
25446
- }
25447
- if (table.totalsRowCount) {
25448
- refZone.bottom -= 1;
25472
+ const colIndexes = [];
25473
+ const rowIndexes = [];
25474
+ const foundKeywords = [];
25475
+ for (const part of parts) {
25476
+ if (removeBrackets(part).startsWith("#")) {
25477
+ const keyWord = removeBrackets(part);
25478
+ foundKeywords.push(keyWord);
25479
+ switch (keyWord) {
25480
+ case "#All":
25481
+ rowIndexes.push(tableZone.top, tableZone.bottom);
25482
+ break;
25483
+ case "#Data":
25484
+ const top = table.headerRowCount ? tableZone.top + table.headerRowCount : tableZone.top;
25485
+ const bottom = table.totalsRowCount
25486
+ ? tableZone.bottom - table.totalsRowCount
25487
+ : tableZone.bottom;
25488
+ rowIndexes.push(top, bottom);
25489
+ break;
25490
+ case "#This Row":
25491
+ rowIndexes.push(toCartesian(cellXc).row);
25492
+ break;
25493
+ case "#Headers":
25494
+ if (!table.headerRowCount) {
25495
+ return CellErrorType.InvalidReference;
25496
+ }
25497
+ rowIndexes.push(tableZone.top);
25498
+ break;
25499
+ case "#Totals":
25500
+ if (!table.totalsRowCount) {
25501
+ return CellErrorType.InvalidReference;
25502
+ }
25503
+ rowIndexes.push(tableZone.bottom);
25504
+ break;
25505
+ }
25449
25506
  }
25450
- }
25451
- // Other references
25452
- else {
25453
- switch (refElements[0].slice(1, refElements[0].length - 1)) {
25454
- case "#All":
25455
- refZone.top = table.headerRowCount ? tableZone.top + table.headerRowCount : tableZone.top;
25456
- refZone.bottom = tableZone.bottom;
25457
- break;
25458
- case "#Data":
25459
- refZone.top = table.headerRowCount ? tableZone.top + table.headerRowCount : tableZone.top;
25460
- refZone.bottom = table.totalsRowCount ? tableZone.bottom + 1 : tableZone.bottom;
25461
- break;
25462
- case "#This Row":
25463
- refZone.top = refZone.bottom = toCartesian(cellXc).row;
25464
- break;
25465
- case "#Headers":
25466
- refZone.top = refZone.bottom = tableZone.top;
25467
- if (!table.headerRowCount) {
25468
- isReferencedZoneValid = false;
25469
- }
25470
- break;
25471
- case "#Totals":
25472
- refZone.top = refZone.bottom = tableZone.bottom;
25473
- if (!table.totalsRowCount) {
25474
- isReferencedZoneValid = false;
25507
+ else {
25508
+ const columns = part
25509
+ .split(":")
25510
+ .map((part) => part.trim())
25511
+ .map(removeBrackets);
25512
+ if (colIndexes.length) {
25513
+ return CellErrorType.InvalidReference;
25514
+ }
25515
+ const colRelativeIndex = table.cols.findIndex((col) => col.name === columns[0]);
25516
+ if (colRelativeIndex === -1) {
25517
+ return CellErrorType.InvalidReference;
25518
+ }
25519
+ colIndexes.push(colRelativeIndex + tableZone.left);
25520
+ if (columns[1]) {
25521
+ const colRelativeIndex2 = table.cols.findIndex((col) => col.name === columns[1]);
25522
+ if (colRelativeIndex2 === -1) {
25523
+ return CellErrorType.InvalidReference;
25475
25524
  }
25476
- break;
25525
+ colIndexes.push(colRelativeIndex2 + tableZone.left);
25526
+ }
25477
25527
  }
25478
- const colRef = refElements[1].slice(1, refElements[1].length - 1);
25479
- const colRelativeIndex = table.cols.findIndex((col) => col.name === colRef);
25480
- refZone.left = refZone.right = colRelativeIndex + tableZone.left;
25481
25528
  }
25482
- if (!isReferencedZoneValid) {
25529
+ if (!areKeywordsCompatible(foundKeywords)) {
25483
25530
  return CellErrorType.InvalidReference;
25484
25531
  }
25485
- return refZone.top !== refZone.bottom ? zoneToXc(refZone) : toXC(refZone.left, refZone.top);
25532
+ if (rowIndexes.length === 0) {
25533
+ const top = table.headerRowCount ? tableZone.top + table.headerRowCount : tableZone.top;
25534
+ const bottom = table.totalsRowCount
25535
+ ? tableZone.bottom - table.totalsRowCount
25536
+ : tableZone.bottom;
25537
+ rowIndexes.push(top, bottom);
25538
+ }
25539
+ if (colIndexes.length === 0) {
25540
+ colIndexes.push(tableZone.left, tableZone.right);
25541
+ }
25542
+ const refZone = {
25543
+ top: Math.min(...rowIndexes),
25544
+ left: Math.min(...colIndexes),
25545
+ bottom: Math.max(...rowIndexes),
25546
+ right: Math.max(...colIndexes),
25547
+ };
25548
+ return sheetPrefix + zoneToXc(refZone);
25549
+ }
25550
+ function removeBrackets(str) {
25551
+ return str.startsWith("[") && str.endsWith("]") ? str.slice(1, str.length - 1) : str;
25552
+ }
25553
+ function areKeywordsCompatible(keywords) {
25554
+ if (keywords.length < 2) {
25555
+ return true;
25556
+ }
25557
+ else if (keywords.length > 2) {
25558
+ return false;
25559
+ }
25560
+ else if (keywords.includes("#Data") && keywords.includes("#Totals")) {
25561
+ return true;
25562
+ }
25563
+ else if (keywords.includes("#Headers") && keywords.includes("#Data")) {
25564
+ return true;
25565
+ }
25566
+ return false;
25486
25567
  }
25487
25568
 
25488
25569
  // -------------------------------------
@@ -28650,11 +28731,12 @@ function canBeLinearChart(definition, dataSets, labelRange, getters) {
28650
28731
  }
28651
28732
  let missingTimeAdapterAlreadyWarned = false;
28652
28733
  function isLuxonTimeAdapterInstalled() {
28653
- if (!window.Chart) {
28734
+ const Chart = getChartJSConstructor();
28735
+ if (!Chart) {
28654
28736
  return false;
28655
28737
  }
28656
28738
  // @ts-ignore
28657
- const adapter = new window.Chart._adapters._date({});
28739
+ const adapter = new Chart._adapters._date({});
28658
28740
  const isInstalled = adapter._id === "luxon";
28659
28741
  if (!isInstalled && !missingTimeAdapterAlreadyWarned) {
28660
28742
  missingTimeAdapterAlreadyWarned = true;
@@ -32508,10 +32590,6 @@ class Popover extends owl.Component {
32508
32590
  this.currentDisplayValue = newDisplay;
32509
32591
  if (!anchor)
32510
32592
  return;
32511
- el.style.top = "";
32512
- el.style.left = "";
32513
- el.style["max-height"] = "";
32514
- el.style["max-width"] = "";
32515
32593
  const propsMaxSize = { width: this.props.maxWidth, height: this.props.maxHeight };
32516
32594
  let elDims = {
32517
32595
  width: el.getBoundingClientRect().width,
@@ -34063,6 +34141,7 @@ var CHART_HELPERS = /*#__PURE__*/Object.freeze({
34063
34141
  duplicateLabelRangeInDuplicatedSheet: duplicateLabelRangeInDuplicatedSheet,
34064
34142
  formatChartDatasetValue: formatChartDatasetValue,
34065
34143
  formatTickValue: formatTickValue,
34144
+ getChartJSConstructor: getChartJSConstructor,
34066
34145
  getChartPositionAtCenterOfViewport: getChartPositionAtCenterOfViewport,
34067
34146
  getDefinedAxis: getDefinedAxis,
34068
34147
  getPieColors: getPieColors,
@@ -37863,6 +37942,11 @@ class SelectionInputStore extends SpreadsheetStore {
37863
37942
  }
37864
37943
  updateColors(colors) {
37865
37944
  this.colors = colors;
37945
+ const colorGenerator = new ColorGenerator(this.ranges.length, this.colors);
37946
+ this.ranges = this.ranges.map((range) => ({
37947
+ ...range,
37948
+ color: colorGenerator.next(),
37949
+ }));
37866
37950
  }
37867
37951
  confirm() {
37868
37952
  for (const range of this.selectionInputs) {
@@ -37897,12 +37981,11 @@ class SelectionInputStore extends SpreadsheetStore {
37897
37981
  * e.g. ["A1", "Sheet2!B3", "E12"]
37898
37982
  */
37899
37983
  get selectionInputs() {
37900
- const generator = new ColorGenerator(this.ranges.length, this.colors);
37901
37984
  return this.ranges.map((input, index) => Object.assign({}, input, {
37902
37985
  color: this.hasMainFocus &&
37903
37986
  this.focusedRangeIndex !== null &&
37904
37987
  this.getters.isRangeValid(input.xc)
37905
- ? generator.next()
37988
+ ? input.color
37906
37989
  : null,
37907
37990
  isFocused: this.hasMainFocus && this.focusedRangeIndex === index,
37908
37991
  isValidRange: input.xc === "" || this.getters.isRangeValid(input.xc),
@@ -38172,10 +38255,10 @@ class SelectionInput extends owl.Component {
38172
38255
  if (originalIndex === finalIndex) {
38173
38256
  return;
38174
38257
  }
38175
- const draggedItems = [...draggableIds];
38176
- draggedItems.splice(originalIndex, 1);
38177
- draggedItems.splice(finalIndex, 0, rangeId);
38178
- this.props.onSelectionReordered?.(this.store.selectionInputs.map((range) => draggedItems.indexOf(range.id)));
38258
+ const indexes = range(0, draggableIds.length);
38259
+ indexes.splice(originalIndex, 1);
38260
+ indexes.splice(finalIndex, 0, originalIndex);
38261
+ this.props.onSelectionReordered?.(indexes);
38179
38262
  this.props.onSelectionConfirmed?.();
38180
38263
  this.store.confirm();
38181
38264
  },
@@ -38453,6 +38536,9 @@ class GenericChartConfigPanel extends owl.Component {
38453
38536
  this.state.datasetDispatchResult = this.props.updateChart(this.props.figureId, {
38454
38537
  dataSets: this.dataSets,
38455
38538
  });
38539
+ if (this.state.datasetDispatchResult.isSuccessful) {
38540
+ this.dataSets = this.env.model.getters.getChartDefinition(this.props.figureId).dataSets;
38541
+ }
38456
38542
  }
38457
38543
  getDataSeriesRanges() {
38458
38544
  return this.dataSets;
@@ -39869,8 +39955,16 @@ class ContentEditableHelper {
39869
39955
  }
39870
39956
  let startNode = this.findChildAtCharacterIndex(start);
39871
39957
  let endNode = this.findChildAtCharacterIndex(end);
39872
- range.setStart(startNode.node, startNode.offset);
39873
- range.setEnd(endNode.node, endNode.offset);
39958
+ // setEnd (setStart) will result in a collapsed range if the end point is before the start point
39959
+ // https://developer.mozilla.org/en-US/docs/Web/API/Range/setEnd
39960
+ if (start <= end) {
39961
+ range.setStart(startNode.node, startNode.offset);
39962
+ range.setEnd(endNode.node, endNode.offset);
39963
+ }
39964
+ else {
39965
+ range.setStart(endNode.node, endNode.offset);
39966
+ range.setEnd(startNode.node, startNode.offset);
39967
+ }
39874
39968
  }
39875
39969
  }
39876
39970
  /**
@@ -40172,8 +40266,7 @@ css /* scss */ `
40172
40266
  }
40173
40267
 
40174
40268
  .o-composer-assistant {
40175
- position: absolute;
40176
- margin: 1px 4px;
40269
+ margin-top: 1px;
40177
40270
 
40178
40271
  .o-semi-bold {
40179
40272
  /* FIXME: to remove in favor of Bootstrap
@@ -40224,10 +40317,11 @@ class Composer extends owl.Component {
40224
40317
  });
40225
40318
  compositionActive = false;
40226
40319
  spreadsheetRect = useSpreadsheetRect();
40227
- get assistantStyle() {
40320
+ get assistantStyleProperties() {
40228
40321
  const composerRect = this.composerRef.el.getBoundingClientRect();
40229
40322
  const assistantStyle = {};
40230
- assistantStyle["min-width"] = `${this.props.rect?.width || ASSISTANT_WIDTH}px`;
40323
+ const minWidth = Math.min(this.props.rect?.width || Infinity, ASSISTANT_WIDTH);
40324
+ assistantStyle["min-width"] = `${minWidth}px`;
40231
40325
  const proposals = this.autoCompleteState.provider?.proposals;
40232
40326
  const proposalsHaveDescription = proposals?.some((proposal) => proposal.description);
40233
40327
  if (this.functionDescriptionState.showDescription || proposalsHaveDescription) {
@@ -40251,13 +40345,29 @@ class Composer extends owl.Component {
40251
40345
  }
40252
40346
  }
40253
40347
  else {
40254
- assistantStyle["max-height"] = `${this.spreadsheetRect.height - composerRect.bottom}px`;
40348
+ assistantStyle["max-height"] = `${this.spreadsheetRect.height - composerRect.bottom - 1}px`; // -1: margin
40255
40349
  if (composerRect.left + ASSISTANT_WIDTH + SCROLLBAR_WIDTH + CLOSE_ICON_RADIUS >
40256
40350
  this.spreadsheetRect.width) {
40257
40351
  assistantStyle.right = `${CLOSE_ICON_RADIUS}px`;
40258
40352
  }
40259
40353
  }
40260
- return cssPropertiesToCss(assistantStyle);
40354
+ return assistantStyle;
40355
+ }
40356
+ get assistantStyle() {
40357
+ const allProperties = this.assistantStyleProperties;
40358
+ return cssPropertiesToCss({
40359
+ "max-height": allProperties["max-height"],
40360
+ width: allProperties["width"],
40361
+ "min-width": allProperties["min-width"],
40362
+ });
40363
+ }
40364
+ get assistantContainerStyle() {
40365
+ const allProperties = this.assistantStyleProperties;
40366
+ return cssPropertiesToCss({
40367
+ top: allProperties["top"],
40368
+ right: allProperties["right"],
40369
+ transform: allProperties["transform"],
40370
+ });
40261
40371
  }
40262
40372
  // we can't allow input events to be triggered while we remove and add back the content of the composer in processContent
40263
40373
  shouldProcessInputEvents = false;
@@ -46731,9 +46841,7 @@ class PivotSidePanelStore extends SpreadsheetStore {
46731
46841
  pivot: this.draft,
46732
46842
  });
46733
46843
  this.draft = null;
46734
- if (!this.alreadyNotified &&
46735
- !this.isDynamicPivotInViewport() &&
46736
- this.isStaticPivotInViewport()) {
46844
+ if (!this.alreadyNotified && this.isUpdatedPivotVisibleInViewportOnlyAsStaticPivot()) {
46737
46845
  const formulaId = this.getters.getPivotFormulaId(this.pivotId);
46738
46846
  const pivotExample = `=PIVOT(${formulaId})`;
46739
46847
  this.alreadyNotified = true;
@@ -46789,26 +46897,33 @@ class PivotSidePanelStore extends SpreadsheetStore {
46789
46897
  this.applyUpdate();
46790
46898
  }
46791
46899
  }
46792
- isDynamicPivotInViewport() {
46793
- for (const position of this.getters.getVisibleCellPositions()) {
46794
- const isDynamicPivot = this.getters.isSpillPivotFormula(position);
46795
- if (isDynamicPivot) {
46796
- return true;
46797
- }
46798
- }
46799
- return false;
46800
- }
46801
- isStaticPivotInViewport() {
46900
+ /**
46901
+ * @returns true if the updated pivot is visible in the viewport only as a
46902
+ * static pivot and not as a dynamic pivot
46903
+ */
46904
+ isUpdatedPivotVisibleInViewportOnlyAsStaticPivot() {
46905
+ let staticPivotCount = 0;
46906
+ const updatedPivotFormulaId = this.getters.getPivotFormulaId(this.pivotId);
46802
46907
  for (const position of this.getters.getVisibleCellPositions()) {
46803
46908
  const cell = this.getters.getCell(position);
46804
46909
  if (cell?.isFormula) {
46805
46910
  const pivotFunction = getFirstPivotFunction(cell.compiledFormula.tokens);
46806
- if (pivotFunction && pivotFunction.functionName !== "PIVOT") {
46807
- return true;
46911
+ const pivotFormulaId = pivotFunction?.args[0]?.value;
46912
+ if (pivotFunction && updatedPivotFormulaId === pivotFormulaId.toString()) {
46913
+ if (pivotFunction.functionName === "PIVOT") {
46914
+ // if we have at least one dynamic pivot visible inserted the viewport
46915
+ // we return false
46916
+ return false;
46917
+ }
46918
+ else {
46919
+ staticPivotCount++;
46920
+ }
46808
46921
  }
46809
46922
  }
46810
46923
  }
46811
- return false;
46924
+ // we return true if there are only static pivots visible inserted the viewport,
46925
+ // otherwise false
46926
+ return staticPivotCount > 0;
46812
46927
  }
46813
46928
  addDefaultDateTimeGranularity(fields, definition) {
46814
46929
  const { columns, rows } = definition;
@@ -60662,6 +60777,7 @@ class EvaluationPlugin extends CoreViewPlugin {
60662
60777
  exportForExcel(data) {
60663
60778
  for (const sheet of data.sheets) {
60664
60779
  sheet.cellValues = {};
60780
+ sheet.formulaSpillRanges = {};
60665
60781
  }
60666
60782
  for (const position of this.evaluator.getEvaluatedPositions()) {
60667
60783
  const evaluatedCell = this.evaluator.getEvaluatedCell(position);
@@ -60673,8 +60789,9 @@ class EvaluationPlugin extends CoreViewPlugin {
60673
60789
  const exportedSheetData = data.sheets.find((sheet) => sheet.id === position.sheetId);
60674
60790
  const formulaCell = this.getCorrespondingFormulaCell(position);
60675
60791
  if (formulaCell) {
60792
+ const cell = this.getters.getCell(position);
60676
60793
  isExported = isExportableToExcel(formulaCell.compiledFormula.tokens);
60677
- isFormula = isExported;
60794
+ isFormula = isExported && cell?.content === formulaCell.content;
60678
60795
  // If the cell contains a non-exported formula and that is evaluates to
60679
60796
  // nothing* ,we don't export it.
60680
60797
  // * non-falsy value are relevant and so are 0 and FALSE, which only leaves
@@ -60697,7 +60814,11 @@ class EvaluationPlugin extends CoreViewPlugin {
60697
60814
  content = !isExported ? newContent : exportedCellData;
60698
60815
  }
60699
60816
  exportedSheetData.cells[xc] = content;
60700
- exportedSheetData.cellValues[xc] = value;
60817
+ exportedSheetData.cellValues[xc] = evaluatedCell.type !== "error" ? value : undefined;
60818
+ const spillZone = this.getSpreadZone(position);
60819
+ if (spillZone) {
60820
+ exportedSheetData.formulaSpillRanges[xc] = this.getters.getRangeString(this.getters.getRangeFromZone(position.sheetId, spillZone), position.sheetId);
60821
+ }
60701
60822
  }
60702
60823
  }
60703
60824
  /**
@@ -62925,7 +63046,7 @@ class AutofillPlugin extends UIPlugin {
62925
63046
  getRule(cell, cells) {
62926
63047
  const rules = autofillRulesRegistry.getAll().sort((a, b) => a.sequence - b.sequence);
62927
63048
  const rule = rules.find((rule) => rule.condition(cell, cells));
62928
- return rule && rule.generateRule(cell, cells);
63049
+ return rule && this.direction && rule.generateRule(cell, cells, this.direction);
62929
63050
  }
62930
63051
  /**
62931
63052
  * Create the generator to be able to autofill the next cells.
@@ -73407,7 +73528,7 @@ function numberRef(reference) {
73407
73528
  `;
73408
73529
  }
73409
73530
 
73410
- function addFormula(formula, value) {
73531
+ function addFormula(formula, value, formulaSpillRange) {
73411
73532
  if (!formula) {
73412
73533
  return { attrs: [], node: escapeXml `` };
73413
73534
  }
@@ -73415,10 +73536,17 @@ function addFormula(formula, value) {
73415
73536
  if (type === undefined) {
73416
73537
  return { attrs: [], node: escapeXml `` };
73417
73538
  }
73418
- const attrs = [["t", type]];
73539
+ const attrs = [
73540
+ ["cm", "1"],
73541
+ ["t", type],
73542
+ ];
73419
73543
  const XlsxFormula = adaptFormulaToExcel(formula);
73420
73544
  const exportedValue = adaptFormulaValueToExcel(value);
73421
- const node = escapeXml /*xml*/ `<f>${XlsxFormula}</f><v>${exportedValue}</v>`;
73545
+ // We treat all formulas as array formulas (a simple formula
73546
+ // is an array formula that spills on only one cell) to avoid
73547
+ // trying to detect spilling sub-formulas which is not a trivial task.
73548
+ let node;
73549
+ node = escapeXml /*xml*/ `<f t="array" ref="${formulaSpillRange}">${XlsxFormula}</f><v>${exportedValue}</v>`;
73422
73550
  return { attrs, node };
73423
73551
  }
73424
73552
  function addContent(content, sharedStrings, forceString = false) {
@@ -74408,7 +74536,7 @@ function addRows(construct, data, sheet) {
74408
74536
  let cellNode = escapeXml ``;
74409
74537
  // Either formula or static value inside the cell
74410
74538
  if (content?.startsWith("=") && value !== undefined) {
74411
- const res = addFormula(content, value);
74539
+ const res = addFormula(content, value, sheet.formulaSpillRanges[xc] ?? xc);
74412
74540
  if (!res) {
74413
74541
  continue;
74414
74542
  }
@@ -74694,6 +74822,30 @@ function createWorksheets(data, construct) {
74694
74822
  `;
74695
74823
  files.push(createXMLFile(parseXML(sheetXml), `xl/worksheets/sheet${sheetIndex}.xml`, "sheet"));
74696
74824
  }
74825
+ const sheetMetadataXml = escapeXml /*xml*/ `
74826
+ <metadata xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:xda="http://schemas.microsoft.com/office/spreadsheetml/2017/dynamicarray">
74827
+ <metadataTypes count="1">
74828
+ <metadataType name="XLDAPR" minSupportedVersion="120000" copy="1" pasteAll="1"
74829
+ pasteValues="1" merge="1" splitFirst="1" rowColShift="1" clearFormats="1"
74830
+ clearComments="1" assign="1" coerce="1" cellMeta="1" />
74831
+ </metadataTypes>
74832
+ <futureMetadata name="XLDAPR" count="1">
74833
+ <bk>
74834
+ <extLst>
74835
+ <ext uri="{${ARRAY_FORMULA_URI}}">
74836
+ <xda:dynamicArrayProperties fDynamic="1" fCollapsed="0" />
74837
+ </ext>
74838
+ </extLst>
74839
+ </bk>
74840
+ </futureMetadata>
74841
+ <cellMetadata count="1">
74842
+ <bk>
74843
+ <rc t="1" v="0" />
74844
+ </bk>
74845
+ </cellMetadata>
74846
+ </metadata>
74847
+ `;
74848
+ files.push(createXMLFile(parseXML(sheetMetadataXml), "xl/metadata.xml", "metadata"));
74697
74849
  addRelsToFile(construct.relsFiles, "xl/_rels/workbook.xml.rels", {
74698
74850
  type: XLSX_RELATION_TYPE.sharedStrings,
74699
74851
  target: "sharedStrings.xml",
@@ -74702,6 +74854,10 @@ function createWorksheets(data, construct) {
74702
74854
  type: XLSX_RELATION_TYPE.styles,
74703
74855
  target: "styles.xml",
74704
74856
  });
74857
+ addRelsToFile(construct.relsFiles, "xl/_rels/workbook.xml.rels", {
74858
+ type: XLSX_RELATION_TYPE.metadata,
74859
+ target: "metadata.xml",
74860
+ });
74705
74861
  return files;
74706
74862
  }
74707
74863
  /**
@@ -75663,6 +75819,6 @@ exports.tokenColors = tokenColors;
75663
75819
  exports.tokenize = tokenize;
75664
75820
 
75665
75821
 
75666
- __info__.version = "18.2.1";
75667
- __info__.date = "2025-02-25T06:03:13.262Z";
75668
- __info__.hash = "3b4b5c9";
75822
+ __info__.version = "18.2.2";
75823
+ __info__.date = "2025-03-07T10:41:04.411Z";
75824
+ __info__.hash = "f567932";