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