@odoo/o-spreadsheet 18.1.12 → 18.1.14

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.1.12
6
- * @date 2025-03-19T08:23:50.676Z
7
- * @hash 32f788f
5
+ * @version 18.1.14
6
+ * @date 2025-04-04T08:42:40.149Z
7
+ * @hash 63b2fb7
8
8
  */
9
9
 
10
10
  'use strict';
@@ -805,8 +805,7 @@ function removeFalsyAttributes(obj) {
805
805
  *
806
806
  * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Character_Classes
807
807
  */
808
- const whiteSpaceSpecialCharacters = [
809
- " ",
808
+ const specialWhiteSpaceSpecialCharacters = [
810
809
  "\t",
811
810
  "\f",
812
811
  "\v",
@@ -821,7 +820,7 @@ const whiteSpaceSpecialCharacters = [
821
820
  String.fromCharCode(parseInt("3000", 16)),
822
821
  String.fromCharCode(parseInt("feff", 16)),
823
822
  ];
824
- const whiteSpaceRegexp = new RegExp(whiteSpaceSpecialCharacters.join("|"), "g");
823
+ const specialWhiteSpaceRegexp = new RegExp(specialWhiteSpaceSpecialCharacters.join("|"), "g");
825
824
  const newLineRegexp = /(\r\n|\r)/g;
826
825
  /**
827
826
  * Replace all different newlines characters by \n
@@ -1122,7 +1121,10 @@ function rgbaStringToHex(color) {
1122
1121
  }
1123
1122
  else if (stringVals.length === 4) {
1124
1123
  const alpha = parseFloat(stringVals.pop() || "1");
1125
- alphaHex = Math.round((alpha || 1) * 255);
1124
+ if (isNaN(alpha)) {
1125
+ throw new Error("invalid alpha value");
1126
+ }
1127
+ alphaHex = Math.round(alpha * 255);
1126
1128
  }
1127
1129
  const vals = stringVals.map((val) => parseInt(val, 10));
1128
1130
  if (alphaHex !== 255) {
@@ -6787,8 +6789,12 @@ function tokenize(str, locale = DEFAULT_LOCALE) {
6787
6789
  str = replaceNewLines(str);
6788
6790
  const chars = new TokenizingChars(str);
6789
6791
  const result = [];
6792
+ const tokenizeSpace = specialWhiteSpaceRegexp.test(str)
6793
+ ? tokenizeSpecialCharacterSpace
6794
+ : tokenizeSimpleSpace;
6790
6795
  while (!chars.isOver()) {
6791
- let token = tokenizeSpace(chars) ||
6796
+ let token = tokenizeNewLine(chars) ||
6797
+ tokenizeSpace(chars) ||
6792
6798
  tokenizeArgsSeparator(chars, locale) ||
6793
6799
  tokenizeParenthesis(chars) ||
6794
6800
  tokenizeOperator(chars) ||
@@ -6922,17 +6928,19 @@ function tokenizeSymbol(chars) {
6922
6928
  }
6923
6929
  return null;
6924
6930
  }
6925
- function tokenizeSpace(chars) {
6926
- let length = 0;
6927
- while (chars.current === NEWLINE) {
6928
- length++;
6929
- chars.shift();
6931
+ function tokenizeSpecialCharacterSpace(chars) {
6932
+ let spaces = "";
6933
+ while (chars.current === " " || (chars.current && chars.current.match(specialWhiteSpaceRegexp))) {
6934
+ spaces += chars.shift();
6930
6935
  }
6931
- if (length) {
6932
- return { type: "SPACE", value: NEWLINE.repeat(length) };
6936
+ if (spaces) {
6937
+ return { type: "SPACE", value: spaces };
6933
6938
  }
6939
+ return null;
6940
+ }
6941
+ function tokenizeSimpleSpace(chars) {
6934
6942
  let spaces = "";
6935
- while (chars.current && chars.current.match(whiteSpaceRegexp)) {
6943
+ while (chars.current === " ") {
6936
6944
  spaces += chars.shift();
6937
6945
  }
6938
6946
  if (spaces) {
@@ -6940,6 +6948,17 @@ function tokenizeSpace(chars) {
6940
6948
  }
6941
6949
  return null;
6942
6950
  }
6951
+ function tokenizeNewLine(chars) {
6952
+ let length = 0;
6953
+ while (chars.current === NEWLINE) {
6954
+ length++;
6955
+ chars.shift();
6956
+ }
6957
+ if (length) {
6958
+ return { type: "SPACE", value: NEWLINE.repeat(length) };
6959
+ }
6960
+ return null;
6961
+ }
6943
6962
  function tokenizeInvalidRange(chars) {
6944
6963
  if (chars.currentStartsWith(CellErrorType.InvalidReference)) {
6945
6964
  chars.advanceBy(CellErrorType.InvalidReference.length);
@@ -6992,7 +7011,7 @@ function isValidLocale(locale) {
6992
7011
  */
6993
7012
  function canonicalizeNumberContent(content, locale) {
6994
7013
  return content.startsWith("=")
6995
- ? canonicalizeFormula$1(content, locale)
7014
+ ? canonicalizeFormula(content, locale)
6996
7015
  : canonicalizeNumberLiteral(content, locale);
6997
7016
  }
6998
7017
  /**
@@ -7007,7 +7026,7 @@ function canonicalizeNumberContent(content, locale) {
7007
7026
  */
7008
7027
  function canonicalizeContent(content, locale) {
7009
7028
  return content.startsWith("=")
7010
- ? canonicalizeFormula$1(content, locale)
7029
+ ? canonicalizeFormula(content, locale)
7011
7030
  : canonicalizeLiteral(content, locale);
7012
7031
  }
7013
7032
  /**
@@ -7023,15 +7042,21 @@ function localizeContent(content, locale) {
7023
7042
  ? localizeFormula(content, locale)
7024
7043
  : localizeLiteral(content, locale);
7025
7044
  }
7045
+ /** Change a number string to its canonical form (en_US locale) */
7046
+ function canonicalizeNumberValue(content, locale) {
7047
+ return content.startsWith("=")
7048
+ ? canonicalizeFormula(content, locale)
7049
+ : canonicalizeNumberLiteral(content, locale);
7050
+ }
7026
7051
  /** Change a formula to its canonical form (en_US locale) */
7027
- function canonicalizeFormula$1(formula, locale) {
7028
- return _localizeFormula$1(formula, locale, DEFAULT_LOCALE);
7052
+ function canonicalizeFormula(formula, locale) {
7053
+ return _localizeFormula(formula, locale, DEFAULT_LOCALE);
7029
7054
  }
7030
7055
  /** Change a formula from the canonical form to the given locale */
7031
7056
  function localizeFormula(formula, locale) {
7032
- return _localizeFormula$1(formula, DEFAULT_LOCALE, locale);
7057
+ return _localizeFormula(formula, DEFAULT_LOCALE, locale);
7033
7058
  }
7034
- function _localizeFormula$1(formula, fromLocale, toLocale) {
7059
+ function _localizeFormula(formula, fromLocale, toLocale) {
7035
7060
  if (fromLocale.formulaArgSeparator === toLocale.formulaArgSeparator &&
7036
7061
  fromLocale.decimalSeparator === toLocale.decimalSeparator) {
7037
7062
  return formula;
@@ -7186,37 +7211,6 @@ function getDateTimeFormat(locale) {
7186
7211
  return locale.dateFormat + " " + locale.timeFormat;
7187
7212
  }
7188
7213
 
7189
- /** Change a number string to its canonical form (en_US locale) */
7190
- function canonicalizeNumberValue(content, locale) {
7191
- return content.startsWith("=")
7192
- ? canonicalizeFormula(content, locale)
7193
- : canonicalizeNumberLiteral(content, locale);
7194
- }
7195
- /** Change a formula to its canonical form (en_US locale) */
7196
- function canonicalizeFormula(formula, locale) {
7197
- return _localizeFormula(formula, locale, DEFAULT_LOCALE);
7198
- }
7199
- function _localizeFormula(formula, fromLocale, toLocale) {
7200
- if (fromLocale.formulaArgSeparator === toLocale.formulaArgSeparator &&
7201
- fromLocale.decimalSeparator === toLocale.decimalSeparator) {
7202
- return formula;
7203
- }
7204
- const tokens = tokenize(formula, fromLocale);
7205
- let localizedFormula = "";
7206
- for (const token of tokens) {
7207
- if (token.type === "NUMBER") {
7208
- localizedFormula += token.value.replace(fromLocale.decimalSeparator, toLocale.decimalSeparator);
7209
- }
7210
- else if (token.type === "ARG_SEPARATOR") {
7211
- localizedFormula += toLocale.formulaArgSeparator;
7212
- }
7213
- else {
7214
- localizedFormula += token.value;
7215
- }
7216
- }
7217
- return localizedFormula;
7218
- }
7219
-
7220
7214
  function boolAnd(args) {
7221
7215
  let foundBoolean = false;
7222
7216
  let acc = true;
@@ -9590,7 +9584,17 @@ class ComposerFocusStore extends SpreadsheetStore {
9590
9584
  }
9591
9585
  }
9592
9586
 
9587
+ const chartJsExtensionRegistry = new Registry();
9588
+ /** Return window.Chart, making sure all our extensions are loaded in ChartJS */
9589
+ function getChartJSConstructor() {
9590
+ if (window.Chart && !window.Chart?.registry.plugins.get("chartShowValuesPlugin")) {
9591
+ window.Chart.register(...chartJsExtensionRegistry.getAll());
9592
+ }
9593
+ return window.Chart;
9594
+ }
9595
+
9593
9596
  const TREND_LINE_XAXIS_ID = "x1";
9597
+ const MOVING_AVERAGE_TREND_LINE_XAXIS_ID = "xMovingAverage";
9594
9598
  /**
9595
9599
  * This file contains helpers that are common to different charts (mainly
9596
9600
  * line, bar and pie charts)
@@ -9931,6 +9935,9 @@ function getPieColors(colors, dataSetsValues) {
9931
9935
  }
9932
9936
  return pieColors;
9933
9937
  }
9938
+ function isTrendLineAxis(axisID) {
9939
+ return axisID === TREND_LINE_XAXIS_ID || axisID === MOVING_AVERAGE_TREND_LINE_XAXIS_ID;
9940
+ }
9934
9941
 
9935
9942
  /** This is a chartJS plugin that will draw the values of each data next to the point/bar/pie slice */
9936
9943
  const chartShowValuesPlugin = {
@@ -9975,7 +9982,7 @@ function drawLineOrBarOrRadarChartValues(chart, options, ctx) {
9975
9982
  const yMin = chart.chartArea.top;
9976
9983
  const textsPositions = {};
9977
9984
  for (const dataset of chart._metasets) {
9978
- if (dataset.xAxisID === TREND_LINE_XAXIS_ID || dataset.hidden) {
9985
+ if (isTrendLineAxis(dataset.axisID) || dataset.hidden) {
9979
9986
  continue;
9980
9987
  }
9981
9988
  for (let i = 0; i < dataset._parsed.length; i++) {
@@ -10018,7 +10025,7 @@ function drawHorizontalBarChartValues(chart, options, ctx) {
10018
10025
  const xMin = chart.chartArea.left;
10019
10026
  const textsPositions = {};
10020
10027
  for (const dataset of chart._metasets) {
10021
- if (dataset.xAxisID === TREND_LINE_XAXIS_ID) {
10028
+ if (isTrendLineAxis(dataset.axisID)) {
10022
10029
  return; // ignore trend lines
10023
10030
  }
10024
10031
  for (let i = 0; i < dataset._parsed.length; i++) {
@@ -10124,341 +10131,70 @@ function getNextNonEmptyBar(bars, startIndex) {
10124
10131
  return bars.find((bar, i) => i > startIndex && bar.height !== 0);
10125
10132
  }
10126
10133
 
10127
- const GAUGE_PADDING_SIDE = 30;
10128
- const GAUGE_PADDING_TOP = 10;
10129
- const GAUGE_PADDING_BOTTOM = 20;
10130
- const GAUGE_LABELS_FONT_SIZE = 12;
10131
- const GAUGE_DEFAULT_VALUE_FONT_SIZE = 80;
10132
- const GAUGE_BACKGROUND_COLOR = "#F3F2F1";
10133
- const GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN = 6;
10134
- const GAUGE_TITLE_SECTION_HEIGHT = 25;
10135
- function drawGaugeChart(canvas, runtime) {
10136
- const canvasBoundingRect = canvas.getBoundingClientRect();
10137
- canvas.width = canvasBoundingRect.width;
10138
- canvas.height = canvasBoundingRect.height;
10139
- const ctx = canvas.getContext("2d");
10140
- const config = getGaugeRenderingConfig(canvasBoundingRect, runtime, ctx);
10141
- drawBackground(ctx, config);
10142
- drawGauge(ctx, config);
10143
- drawInflectionValues(ctx, config);
10144
- drawLabels(ctx, config);
10145
- drawTitle(ctx, config);
10146
- }
10147
- function drawGauge(ctx, config) {
10148
- ctx.save();
10149
- const gauge = config.gauge;
10150
- const arcCenterX = gauge.rect.x + gauge.rect.width / 2;
10151
- const arcCenterY = gauge.rect.y + gauge.rect.height;
10152
- const arcRadius = gauge.rect.height - gauge.arcWidth / 2;
10153
- if (arcRadius < 0) {
10154
- return;
10155
- }
10156
- const gaugeAngle = gauge.percentage === 1 ? 0 : Math.PI * (1 + gauge.percentage);
10157
- // Gauge background
10158
- ctx.strokeStyle = GAUGE_BACKGROUND_COLOR;
10159
- ctx.beginPath();
10160
- ctx.lineWidth = gauge.arcWidth;
10161
- ctx.arc(arcCenterX, arcCenterY, arcRadius, gaugeAngle, 0);
10162
- ctx.stroke();
10163
- // Gauge value
10164
- ctx.strokeStyle = gauge.color;
10165
- ctx.beginPath();
10166
- ctx.arc(arcCenterX, arcCenterY, arcRadius, Math.PI, gaugeAngle);
10167
- ctx.stroke();
10168
- ctx.restore();
10169
- }
10170
- function drawBackground(ctx, config) {
10171
- ctx.save();
10172
- ctx.fillStyle = config.backgroundColor;
10173
- ctx.fillRect(0, 0, config.width, config.height);
10174
- ctx.restore();
10175
- }
10176
- function drawLabels(ctx, config) {
10177
- for (const label of [config.minLabel, config.maxLabel, config.gaugeValue]) {
10178
- ctx.save();
10179
- ctx.textAlign = "center";
10180
- ctx.fillStyle = label.color;
10181
- ctx.font = `${label.fontSize}px ${DEFAULT_FONT}`;
10182
- ctx.fillText(label.label, label.textPosition.x, label.textPosition.y);
10183
- ctx.restore();
10184
- }
10185
- }
10186
- function drawInflectionValues(ctx, config) {
10187
- const { x: rectX, y: rectY, width, height } = config.gauge.rect;
10188
- for (const inflectionValue of config.inflectionValues) {
10189
- ctx.save();
10190
- ctx.translate(rectX + width / 2 - 0.5, rectY + height - 0.5); // -0.5 for sharper lines. see RendererPlugin.drawBorders comment
10191
- ctx.rotate(Math.PI / 2 - inflectionValue.rotation);
10192
- ctx.lineWidth = 2;
10193
- ctx.strokeStyle = chartMutedFontColor(config.backgroundColor) + "aa";
10194
- ctx.beginPath();
10195
- ctx.moveTo(0, -(height - config.gauge.arcWidth));
10196
- ctx.lineTo(0, -height - 3);
10197
- ctx.stroke();
10198
- ctx.textAlign = "center";
10199
- ctx.font = `${inflectionValue.fontSize}px ${DEFAULT_FONT}`;
10200
- ctx.fillStyle = inflectionValue.color;
10201
- const textY = -height - GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN - inflectionValue.offset;
10202
- ctx.fillText(inflectionValue.label, 0, textY);
10203
- ctx.restore();
10204
- }
10205
- }
10206
- function drawTitle(ctx, config) {
10207
- ctx.save();
10208
- const title = config.title;
10209
- ctx.font = getDefaultContextFont(title.fontSize, title.bold, title.italic);
10210
- ctx.textBaseline = "middle";
10211
- ctx.fillStyle = title.color;
10212
- ctx.fillText(title.label, title.textPosition.x, title.textPosition.y);
10213
- ctx.restore();
10214
- }
10215
- function getGaugeRenderingConfig(boundingRect, runtime, ctx) {
10216
- const maxValue = runtime.maxValue;
10217
- const minValue = runtime.minValue;
10218
- const gaugeValue = runtime.gaugeValue;
10219
- const gaugeRect = getGaugeRect(boundingRect, runtime.title.text);
10220
- const gaugeArcWidth = gaugeRect.width / 6;
10221
- const gaugePercentage = gaugeValue
10222
- ? (gaugeValue.value - minValue.value) / (maxValue.value - minValue.value)
10223
- : 0;
10224
- const gaugeValuePosition = {
10225
- x: boundingRect.width / 2,
10226
- y: gaugeRect.y + gaugeRect.height - gaugeRect.height / 12,
10227
- };
10228
- let gaugeValueFontSize = GAUGE_DEFAULT_VALUE_FONT_SIZE;
10229
- // Scale down the font size if the gaugeRect is too small
10230
- if (gaugeRect.height < 300) {
10231
- gaugeValueFontSize = gaugeValueFontSize * (gaugeRect.height / 300);
10232
- }
10233
- // Scale down the font size if the text is too long
10234
- const maxTextWidth = gaugeRect.width / 2;
10235
- const gaugeLabel = gaugeValue?.label || "-";
10236
- if (computeTextWidth(ctx, gaugeLabel, { fontSize: gaugeValueFontSize }, "px") > maxTextWidth) {
10237
- gaugeValueFontSize = getFontSizeMatchingWidth(maxTextWidth, gaugeValueFontSize, (fontSize) => computeTextWidth(ctx, gaugeLabel, { fontSize }, "px"));
10238
- }
10239
- const minLabelPosition = {
10240
- x: gaugeRect.x + gaugeArcWidth / 2,
10241
- y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
10242
- };
10243
- const maxLabelPosition = {
10244
- x: gaugeRect.x + gaugeRect.width - gaugeArcWidth / 2,
10245
- y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
10134
+ chartJsExtensionRegistry.add("chartShowValuesPlugin", chartShowValuesPlugin);
10135
+ chartJsExtensionRegistry.add("waterfallLinesPlugin", waterfallLinesPlugin);
10136
+ class ChartJsComponent extends owl.Component {
10137
+ static template = "o-spreadsheet-ChartJsComponent";
10138
+ static props = {
10139
+ figure: Object,
10246
10140
  };
10247
- const textColor = chartMutedFontColor(runtime.background);
10248
- const inflectionValues = getInflectionValues(runtime, gaugeRect, textColor, ctx);
10249
- let x = 0, titleWidth = 0, titleHeight = 0;
10250
- if (runtime.title.text) {
10251
- ({ width: titleWidth, height: titleHeight } = computeTextDimension(ctx, runtime.title.text, { fontSize: CHART_TITLE_FONT_SIZE, ...runtime.title }, "px"));
10252
- }
10253
- switch (runtime.title.align) {
10254
- case "right":
10255
- x = boundingRect.width - titleWidth - CHART_PADDING$1;
10256
- break;
10257
- case "center":
10258
- x = (boundingRect.width - titleWidth) / 2;
10259
- break;
10260
- case "left":
10261
- default:
10262
- x = CHART_PADDING$1;
10263
- break;
10141
+ canvas = owl.useRef("graphContainer");
10142
+ chart;
10143
+ currentRuntime;
10144
+ get background() {
10145
+ return this.chartRuntime.background;
10264
10146
  }
10265
- return {
10266
- width: boundingRect.width,
10267
- height: boundingRect.height,
10268
- title: {
10269
- label: runtime.title.text ?? "",
10270
- fontSize: runtime.title.fontSize ?? CHART_TITLE_FONT_SIZE,
10271
- textPosition: {
10272
- x,
10273
- y: CHART_PADDING_TOP + titleHeight / 2,
10274
- },
10275
- color: runtime.title.color ?? textColor,
10276
- bold: runtime.title.bold,
10277
- italic: runtime.title.italic,
10278
- },
10279
- backgroundColor: runtime.background,
10280
- gauge: {
10281
- rect: gaugeRect,
10282
- arcWidth: gaugeArcWidth,
10283
- percentage: clip(gaugePercentage, 0, 1),
10284
- color: getGaugeColor(runtime),
10285
- },
10286
- inflectionValues,
10287
- gaugeValue: {
10288
- label: gaugeLabel,
10289
- textPosition: gaugeValuePosition,
10290
- fontSize: gaugeValueFontSize,
10291
- color: textColor,
10292
- },
10293
- minLabel: {
10294
- label: runtime.minValue.label,
10295
- textPosition: minLabelPosition,
10296
- fontSize: GAUGE_LABELS_FONT_SIZE,
10297
- color: textColor,
10298
- },
10299
- maxLabel: {
10300
- label: runtime.maxValue.label,
10301
- textPosition: maxLabelPosition,
10302
- fontSize: GAUGE_LABELS_FONT_SIZE,
10303
- color: textColor,
10304
- },
10305
- };
10306
- }
10307
- /**
10308
- * Get the rectangle in which the gauge will be drawn, based on the bounding rectangle of the canvas and leaving
10309
- * space for the title and labels.
10310
- */
10311
- function getGaugeRect(boundingRect, title) {
10312
- const titleHeight = title ? GAUGE_TITLE_SECTION_HEIGHT : 0;
10313
- const drawHeight = boundingRect.height - GAUGE_PADDING_BOTTOM - titleHeight - GAUGE_PADDING_TOP;
10314
- const drawWidth = boundingRect.width - GAUGE_PADDING_SIDE * 2;
10315
- let gaugeWidth;
10316
- let gaugeHeight;
10317
- if (drawWidth > 2 * drawHeight) {
10318
- gaugeWidth = 2 * drawHeight;
10319
- gaugeHeight = drawHeight;
10147
+ get canvasStyle() {
10148
+ return `background-color: ${this.background}`;
10320
10149
  }
10321
- else {
10322
- gaugeWidth = drawWidth;
10323
- gaugeHeight = drawWidth / 2;
10150
+ get chartRuntime() {
10151
+ const runtime = this.env.model.getters.getChartRuntime(this.props.figure.id);
10152
+ if (!("chartJsConfig" in runtime)) {
10153
+ throw new Error("Unsupported chart runtime");
10154
+ }
10155
+ return runtime;
10324
10156
  }
10325
- const gaugeX = GAUGE_PADDING_SIDE + (drawWidth - gaugeWidth) / 2;
10326
- const gaugeY = titleHeight + GAUGE_PADDING_TOP + (drawHeight - gaugeHeight) / 2;
10327
- return {
10328
- x: gaugeX,
10329
- y: gaugeY,
10330
- width: gaugeWidth,
10331
- height: gaugeHeight,
10332
- };
10333
- }
10334
- /**
10335
- * 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).
10336
- *
10337
- * Also compute an offset for the text so that it doesn't overlap with other text.
10338
- */
10339
- function getInflectionValues(runtime, gaugeRect, textColor, ctx) {
10340
- const maxValue = runtime.maxValue;
10341
- const minValue = runtime.minValue;
10342
- const gaugeCircleCenter = {
10343
- x: gaugeRect.x + gaugeRect.width / 2,
10344
- y: gaugeRect.y + gaugeRect.height,
10345
- };
10346
- const textStyle = { fontSize: GAUGE_LABELS_FONT_SIZE };
10347
- const inflectionValues = [];
10348
- const inflectionValuesTextRects = [];
10349
- for (const inflectionValue of runtime.inflectionValues) {
10350
- const percentage = (inflectionValue.value - minValue.value) / (maxValue.value - minValue.value);
10351
- const labelWidth = computeTextWidth(ctx, inflectionValue.label, textStyle, "px");
10352
- const angle = Math.PI - Math.PI * percentage;
10353
- const textRect = getRectangleTangentToCircle(angle, // angle between X axis and the point where the rectangle is tangent to the circle
10354
- gaugeRect.height + GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN, // radius of the gauge circle + margin below text
10355
- gaugeCircleCenter.x, // center of the gauge circle
10356
- gaugeCircleCenter.y, // center of the gauge circle
10357
- labelWidth + 2, // width of the text + some margin
10358
- GAUGE_LABELS_FONT_SIZE // height of the text
10359
- );
10360
- let offset = inflectionValuesTextRects.some((rect) => doRectanglesIntersect(rect, textRect))
10361
- ? GAUGE_LABELS_FONT_SIZE
10362
- : 0;
10363
- inflectionValuesTextRects.push(textRect);
10364
- inflectionValues.push({
10365
- rotation: angle,
10366
- label: inflectionValue.label,
10367
- fontSize: GAUGE_LABELS_FONT_SIZE,
10368
- color: textColor,
10369
- offset,
10157
+ setup() {
10158
+ owl.onMounted(() => {
10159
+ const runtime = this.chartRuntime;
10160
+ this.currentRuntime = runtime;
10161
+ // Note: chartJS modify the runtime in place, so it's important to give it a copy
10162
+ this.createChart(deepCopy(runtime.chartJsConfig));
10163
+ });
10164
+ owl.onWillUnmount(() => this.chart?.destroy());
10165
+ owl.useEffect(() => {
10166
+ const runtime = this.chartRuntime;
10167
+ if (runtime !== this.currentRuntime) {
10168
+ if (runtime.chartJsConfig.type !== this.currentRuntime.chartJsConfig.type) {
10169
+ this.chart?.destroy();
10170
+ this.createChart(deepCopy(runtime.chartJsConfig));
10171
+ }
10172
+ else {
10173
+ this.updateChartJs(deepCopy(runtime.chartJsConfig));
10174
+ }
10175
+ this.currentRuntime = runtime;
10176
+ }
10370
10177
  });
10371
10178
  }
10372
- return inflectionValues;
10373
- }
10374
- function getGaugeColor(runtime) {
10375
- const gaugeValue = runtime.gaugeValue?.value;
10376
- if (gaugeValue === undefined) {
10377
- return GAUGE_BACKGROUND_COLOR;
10378
- }
10379
- for (let i = 0; i < runtime.inflectionValues.length; i++) {
10380
- const inflectionValue = runtime.inflectionValues[i];
10381
- if (inflectionValue.operator === "<" && gaugeValue < inflectionValue.value) {
10382
- return runtime.colors[i];
10383
- }
10384
- else if (inflectionValue.operator === "<=" && gaugeValue <= inflectionValue.value) {
10385
- return runtime.colors[i];
10386
- }
10387
- }
10388
- return runtime.colors.at(-1);
10389
- }
10390
- function getSegmentsOfRectangle(rectangle) {
10391
- return [
10392
- { start: rectangle.topLeft, end: rectangle.topRight },
10393
- { start: rectangle.topRight, end: rectangle.bottomRight },
10394
- { start: rectangle.bottomRight, end: rectangle.bottomLeft },
10395
- { start: rectangle.bottomLeft, end: rectangle.topLeft },
10396
- ];
10397
- }
10398
- /**
10399
- * Check if two segment intersect. The case where the segments are colinear (both segments on the same line)
10400
- * is not handled.
10401
- */
10402
- function doSegmentIntersect(segment1, segment2) {
10403
- const A = segment1.start;
10404
- const B = segment1.end;
10405
- const C = segment2.start;
10406
- const D = segment2.end;
10407
- /**
10408
- * Line segment intersection algorithm
10409
- * https://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/
10410
- */
10411
- function ccw(a, b, c) {
10412
- return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
10179
+ createChart(chartData) {
10180
+ const canvas = this.canvas.el;
10181
+ const ctx = canvas.getContext("2d");
10182
+ const Chart = getChartJSConstructor();
10183
+ this.chart = new Chart(ctx, chartData);
10413
10184
  }
10414
- return ccw(A, C, D) !== ccw(B, C, D) && ccw(A, B, C) !== ccw(A, B, D);
10415
- }
10416
- function doRectanglesIntersect(rect1, rect2) {
10417
- const segments1 = getSegmentsOfRectangle(rect1);
10418
- const segments2 = getSegmentsOfRectangle(rect2);
10419
- for (const segment1 of segments1) {
10420
- for (const segment2 of segments2) {
10421
- if (doSegmentIntersect(segment1, segment2)) {
10422
- return true;
10185
+ updateChartJs(chartData) {
10186
+ if (chartData.data && chartData.data.datasets) {
10187
+ this.chart.data = chartData.data;
10188
+ if (chartData.options?.plugins?.title) {
10189
+ this.chart.config.options.plugins.title = chartData.options.plugins.title;
10423
10190
  }
10424
10191
  }
10192
+ else {
10193
+ this.chart.data.datasets = [];
10194
+ }
10195
+ this.chart.config.options = chartData.options;
10196
+ this.chart.update();
10425
10197
  }
10426
- return false;
10427
- }
10428
- /**
10429
- * Get the rectangle that is tangent to a circle at a given angle.
10430
- *
10431
- * @param angle angle between X axis and the point where the rectangle is tangent to the circle
10432
- */
10433
- function getRectangleTangentToCircle(angle, radius, circleCenterX, circleCenterY, rectWidth, rectHeight) {
10434
- const cos = Math.cos(angle);
10435
- const sin = Math.sin(angle);
10436
- // x, y are the distance from the center of the circle to the point where the rectangle is tangent to the circle
10437
- const x = cos * radius;
10438
- const y = sin * radius;
10439
- // x2, y2 are the distance from the point the rectangle is tangent to the circle to the bottom left corner of the rectangle
10440
- const x2 = sin * (rectWidth / 2); // cos(angle + 90°) = sin(angle)
10441
- const y2 = cos * (rectWidth / 2);
10442
- const bottomRight = {
10443
- x: x + x2 + circleCenterX,
10444
- y: circleCenterY - (y - y2),
10445
- };
10446
- const bottomLeft = {
10447
- x: x - x2 + circleCenterX,
10448
- y: circleCenterY - (y + y2),
10449
- };
10450
- // Same as above but for the top corners of the rectangle (radius + rectangle height instead of radius)
10451
- const xp = cos * (radius + rectHeight);
10452
- const yp = sin * (radius + rectHeight);
10453
- const topLeft = {
10454
- x: xp - x2 + circleCenterX,
10455
- y: circleCenterY - (yp + y2),
10456
- };
10457
- const topRight = {
10458
- x: xp + x2 + circleCenterX,
10459
- y: circleCenterY - (yp - y2),
10460
- };
10461
- return { bottomLeft, bottomRight, topRight, topLeft };
10462
10198
  }
10463
10199
 
10464
10200
  /**
@@ -11040,155 +10776,6 @@ class ScorecardChartConfigBuilder {
11040
10776
  }
11041
10777
  }
11042
10778
 
11043
- const CHART_COMMON_OPTIONS = {
11044
- // https://www.chartjs.org/docs/latest/general/responsive.html
11045
- responsive: true, // will resize when its container is resized
11046
- maintainAspectRatio: false, // doesn't maintain the aspect ratio (width/height =2 by default) so the user has the choice of the exact layout
11047
- elements: {
11048
- line: {
11049
- fill: false, // do not fill the area under line charts
11050
- },
11051
- point: {
11052
- hitRadius: 15, // increased hit radius to display point tooltip when hovering nearby
11053
- },
11054
- },
11055
- animation: false,
11056
- };
11057
- function truncateLabel(label) {
11058
- if (!label) {
11059
- return "";
11060
- }
11061
- if (label.length > MAX_CHAR_LABEL) {
11062
- return label.substring(0, MAX_CHAR_LABEL) + "…";
11063
- }
11064
- return label;
11065
- }
11066
- function chartToImage(runtime, figure, type) {
11067
- // wrap the canvas in a div with a fixed size because chart.js would
11068
- // fill the whole page otherwise
11069
- const div = document.createElement("div");
11070
- div.style.width = `${figure.width}px`;
11071
- div.style.height = `${figure.height}px`;
11072
- const canvas = document.createElement("canvas");
11073
- div.append(canvas);
11074
- canvas.setAttribute("width", figure.width.toString());
11075
- canvas.setAttribute("height", figure.height.toString());
11076
- // we have to add the canvas to the DOM otherwise it won't be rendered
11077
- document.body.append(div);
11078
- if ("chartJsConfig" in runtime) {
11079
- const config = deepCopy(runtime.chartJsConfig);
11080
- config.plugins = [backgroundColorChartJSPlugin];
11081
- const Chart = getChartJSConstructor();
11082
- const chart = new Chart(canvas, config);
11083
- const imgContent = chart.toBase64Image();
11084
- chart.destroy();
11085
- div.remove();
11086
- return imgContent;
11087
- }
11088
- else if (type === "scorecard") {
11089
- const design = getScorecardConfiguration(figure, runtime);
11090
- drawScoreChart(design, canvas);
11091
- const imgContent = canvas.toDataURL();
11092
- div.remove();
11093
- return imgContent;
11094
- }
11095
- else if (type === "gauge") {
11096
- drawGaugeChart(canvas, runtime);
11097
- const imgContent = canvas.toDataURL();
11098
- div.remove();
11099
- return imgContent;
11100
- }
11101
- return undefined;
11102
- }
11103
- /**
11104
- * Custom chart.js plugin to set the background color of the canvas
11105
- * https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
11106
- */
11107
- const backgroundColorChartJSPlugin = {
11108
- id: "customCanvasBackgroundColor",
11109
- beforeDraw: (chart) => {
11110
- const { ctx } = chart;
11111
- ctx.save();
11112
- ctx.globalCompositeOperation = "destination-over";
11113
- ctx.fillStyle = "#ffffff";
11114
- ctx.fillRect(0, 0, chart.width, chart.height);
11115
- ctx.restore();
11116
- },
11117
- };
11118
- /** Return window.Chart, making sure all our extensions are loaded in ChartJS */
11119
- function getChartJSConstructor() {
11120
- if (window.Chart && !window.Chart?.registry.plugins.get("chartShowValuesPlugin")) {
11121
- window.Chart.register(chartShowValuesPlugin);
11122
- window.Chart.register(waterfallLinesPlugin);
11123
- }
11124
- return window.Chart;
11125
- }
11126
-
11127
- class ChartJsComponent extends owl.Component {
11128
- static template = "o-spreadsheet-ChartJsComponent";
11129
- static props = {
11130
- figure: Object,
11131
- };
11132
- canvas = owl.useRef("graphContainer");
11133
- chart;
11134
- currentRuntime;
11135
- get background() {
11136
- return this.chartRuntime.background;
11137
- }
11138
- get canvasStyle() {
11139
- return `background-color: ${this.background}`;
11140
- }
11141
- get chartRuntime() {
11142
- const runtime = this.env.model.getters.getChartRuntime(this.props.figure.id);
11143
- if (!("chartJsConfig" in runtime)) {
11144
- throw new Error("Unsupported chart runtime");
11145
- }
11146
- return runtime;
11147
- }
11148
- setup() {
11149
- owl.onMounted(() => {
11150
- const runtime = this.chartRuntime;
11151
- this.currentRuntime = runtime;
11152
- // Note: chartJS modify the runtime in place, so it's important to give it a copy
11153
- this.createChart(deepCopy(runtime.chartJsConfig));
11154
- });
11155
- owl.onWillUnmount(() => this.chart?.destroy());
11156
- owl.useEffect(() => {
11157
- const runtime = this.chartRuntime;
11158
- if (runtime !== this.currentRuntime) {
11159
- if (runtime.chartJsConfig.type !== this.currentRuntime.chartJsConfig.type) {
11160
- this.chart?.destroy();
11161
- this.createChart(deepCopy(runtime.chartJsConfig));
11162
- }
11163
- else {
11164
- this.updateChartJs(deepCopy(runtime));
11165
- }
11166
- this.currentRuntime = runtime;
11167
- }
11168
- });
11169
- }
11170
- createChart(chartData) {
11171
- const canvas = this.canvas.el;
11172
- const ctx = canvas.getContext("2d");
11173
- const Chart = getChartJSConstructor();
11174
- this.chart = new Chart(ctx, chartData);
11175
- }
11176
- updateChartJs(chartRuntime) {
11177
- const chartData = chartRuntime.chartJsConfig;
11178
- if (chartData.data && chartData.data.datasets) {
11179
- this.chart.data = chartData.data;
11180
- if (chartData.options?.plugins?.title) {
11181
- this.chart.config.options.plugins.title = chartData.options.plugins.title;
11182
- }
11183
- }
11184
- else {
11185
- this.chart.data.datasets = [];
11186
- }
11187
- this.chart.config.options = chartData.options;
11188
- this.chart.update();
11189
- }
11190
- }
11191
-
11192
10779
  class ScorecardChart extends owl.Component {
11193
10780
  static template = "o-spreadsheet-ScorecardChart";
11194
10781
  static props = {
@@ -20382,11 +19969,26 @@ const SEARCH = {
20382
19969
  const _searchFor = toString(searchFor).toLowerCase();
20383
19970
  const _textToSearch = toString(textToSearch).toLowerCase();
20384
19971
  const _startingAt = toNumber(startingAt, this.locale);
20385
- assert(() => _textToSearch !== "", _t("The text_to_search must be non-empty."));
20386
- assert(() => _startingAt >= 1, _t("The starting_at (%s) must be greater than or equal to 1.", _startingAt.toString()));
19972
+ if (_textToSearch === "") {
19973
+ return {
19974
+ value: CellErrorType.GenericError,
19975
+ message: _t("The text_to_search must be non-empty."),
19976
+ };
19977
+ }
19978
+ if (_startingAt < 1) {
19979
+ return {
19980
+ value: CellErrorType.GenericError,
19981
+ message: _t("The starting_at (%s) must be greater than or equal to 1.", _startingAt),
19982
+ };
19983
+ }
20387
19984
  const result = _textToSearch.indexOf(_searchFor, _startingAt - 1);
20388
- assert(() => result >= 0, _t("In [[FUNCTION_NAME]] evaluation, cannot find '%s' within '%s'.", _searchFor, _textToSearch));
20389
- return result + 1;
19985
+ if (result === -1) {
19986
+ return {
19987
+ value: CellErrorType.GenericError,
19988
+ message: _t("In [[FUNCTION_NAME]] evaluation, cannot find '%s' within '%s'.", _searchFor, _textToSearch),
19989
+ };
19990
+ }
19991
+ return { value: result + 1 };
20390
19992
  },
20391
19993
  isExported: true,
20392
19994
  };
@@ -21721,11 +21323,14 @@ function compileTokens(tokens) {
21721
21323
  }
21722
21324
  }
21723
21325
  function compileTokensOrThrow(tokens) {
21724
- const { dependencies, constantValues, symbols } = formulaArguments(tokens);
21725
- const cacheKey = compilationCacheKey(tokens, dependencies, constantValues);
21326
+ const { dependencies, literalValues, symbols } = formulaArguments(tokens);
21327
+ const cacheKey = compilationCacheKey(tokens);
21726
21328
  if (!functionCache[cacheKey]) {
21727
21329
  const ast = parseTokens([...tokens]);
21728
21330
  const scope = new Scope();
21331
+ let stringCount = 0;
21332
+ let numberCount = 0;
21333
+ let dependencyCount = 0;
21729
21334
  if (ast.type === "BIN_OPERATION" && ast.value === ":") {
21730
21335
  throw new BadExpressionError(_t("Invalid formula"));
21731
21336
  }
@@ -21799,16 +21404,15 @@ function compileTokensOrThrow(tokens) {
21799
21404
  case "BOOLEAN":
21800
21405
  return code.return(`{ value: ${ast.value} }`);
21801
21406
  case "NUMBER":
21802
- return code.return(`{ value: this.constantValues.numbers[${constantValues.numbers.indexOf(ast.value)}] }`);
21407
+ return code.return(`this.literalValues.numbers[${numberCount++}]`);
21803
21408
  case "STRING":
21804
- return code.return(`{ value: this.constantValues.strings[${constantValues.strings.indexOf(ast.value)}] }`);
21409
+ return code.return(`this.literalValues.strings[${stringCount++}]`);
21805
21410
  case "REFERENCE":
21806
- const referenceIndex = dependencies.indexOf(ast.value);
21807
21411
  if ((!isMeta && ast.value.includes(":")) || hasRange) {
21808
- return code.return(`range(deps[${referenceIndex}])`);
21412
+ return code.return(`range(deps[${dependencyCount++}])`);
21809
21413
  }
21810
21414
  else {
21811
- return code.return(`ref(deps[${referenceIndex}], ${isMeta ? "true" : "false"})`);
21415
+ return code.return(`ref(deps[${dependencyCount++}], ${isMeta ? "true" : "false"})`);
21812
21416
  }
21813
21417
  case "FUNCALL":
21814
21418
  const args = compileFunctionArgs(ast).map((arg) => arg.assignResultToVariable());
@@ -21840,7 +21444,7 @@ function compileTokensOrThrow(tokens) {
21840
21444
  const compiledFormula = {
21841
21445
  execute: functionCache[cacheKey],
21842
21446
  dependencies,
21843
- constantValues,
21447
+ literalValues,
21844
21448
  symbols,
21845
21449
  tokens,
21846
21450
  isBadExpression: false,
@@ -21853,33 +21457,31 @@ function compileTokensOrThrow(tokens) {
21853
21457
  * References, numbers and strings are replaced with placeholders because
21854
21458
  * the compiled formula does not depend on their actual value.
21855
21459
  * Both `=A1+1+"2"` and `=A2+2+"3"` are compiled to the exact same function.
21856
- *
21857
21460
  * Spaces are also ignored to compute the cache key.
21858
21461
  *
21859
- * A formula `=A1+A2+SUM(2, 2, "2")` have the cache key `=|0|+|1|+SUM(|N0|,|N0|,|S0|)`
21462
+ * A formula `=A1+A2+SUM(2, 2, "2")` have the cache key `=|C|+|C|+SUM(|N|,|N|,|S|)`
21860
21463
  */
21861
- function compilationCacheKey(tokens, dependencies, constantValues, symbols) {
21464
+ function compilationCacheKey(tokens) {
21862
21465
  let cacheKey = "";
21863
21466
  for (const token of tokens) {
21864
21467
  switch (token.type) {
21865
21468
  case "STRING":
21866
- const value = removeStringQuotes(token.value);
21867
- cacheKey += `|S${constantValues.strings.indexOf(value)}|`;
21469
+ cacheKey += "|S|";
21868
21470
  break;
21869
21471
  case "NUMBER":
21870
- cacheKey += `|N${constantValues.numbers.indexOf(parseNumber(token.value, DEFAULT_LOCALE))}|`;
21472
+ cacheKey += "|N|";
21871
21473
  break;
21872
21474
  case "REFERENCE":
21873
21475
  case "INVALID_REFERENCE":
21874
21476
  if (token.value.includes(":")) {
21875
- cacheKey += `R|${dependencies.indexOf(token.value)}|`;
21477
+ cacheKey += "|R|";
21876
21478
  }
21877
21479
  else {
21878
- cacheKey += `C|${dependencies.indexOf(token.value)}|`;
21480
+ cacheKey += "|C|";
21879
21481
  }
21880
21482
  break;
21881
21483
  case "SPACE":
21882
- cacheKey += "";
21484
+ // ignore spaces
21883
21485
  break;
21884
21486
  default:
21885
21487
  cacheKey += token.value;
@@ -21892,7 +21494,7 @@ function compilationCacheKey(tokens, dependencies, constantValues, symbols) {
21892
21494
  * Return formula arguments which are references, strings and numbers.
21893
21495
  */
21894
21496
  function formulaArguments(tokens) {
21895
- const constantValues = {
21497
+ const literalValues = {
21896
21498
  numbers: [],
21897
21499
  strings: [],
21898
21500
  };
@@ -21906,15 +21508,11 @@ function formulaArguments(tokens) {
21906
21508
  break;
21907
21509
  case "STRING":
21908
21510
  const value = removeStringQuotes(token.value);
21909
- if (!constantValues.strings.includes(value)) {
21910
- constantValues.strings.push(value);
21911
- }
21511
+ literalValues.strings.push({ value });
21912
21512
  break;
21913
21513
  case "NUMBER": {
21914
21514
  const value = parseNumber(token.value, DEFAULT_LOCALE);
21915
- if (!constantValues.numbers.includes(value)) {
21916
- constantValues.numbers.push(value);
21917
- }
21515
+ literalValues.numbers.push({ value });
21918
21516
  break;
21919
21517
  }
21920
21518
  case "SYMBOL": {
@@ -21925,7 +21523,7 @@ function formulaArguments(tokens) {
21925
21523
  }
21926
21524
  return {
21927
21525
  dependencies,
21928
- constantValues,
21526
+ literalValues,
21929
21527
  symbols,
21930
21528
  };
21931
21529
  }
@@ -22775,6 +22373,343 @@ function getDateIntervals(dates) {
22775
22373
 
22776
22374
  const cellPopoverRegistry = new Registry();
22777
22375
 
22376
+ const GAUGE_PADDING_SIDE = 30;
22377
+ const GAUGE_PADDING_TOP = 10;
22378
+ const GAUGE_PADDING_BOTTOM = 20;
22379
+ const GAUGE_LABELS_FONT_SIZE = 12;
22380
+ const GAUGE_DEFAULT_VALUE_FONT_SIZE = 80;
22381
+ const GAUGE_BACKGROUND_COLOR = "#F3F2F1";
22382
+ const GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN = 6;
22383
+ const GAUGE_TITLE_SECTION_HEIGHT = 25;
22384
+ function drawGaugeChart(canvas, runtime) {
22385
+ const canvasBoundingRect = canvas.getBoundingClientRect();
22386
+ canvas.width = canvasBoundingRect.width;
22387
+ canvas.height = canvasBoundingRect.height;
22388
+ const ctx = canvas.getContext("2d");
22389
+ const config = getGaugeRenderingConfig(canvasBoundingRect, runtime, ctx);
22390
+ drawBackground(ctx, config);
22391
+ drawGauge(ctx, config);
22392
+ drawInflectionValues(ctx, config);
22393
+ drawLabels(ctx, config);
22394
+ drawTitle(ctx, config);
22395
+ }
22396
+ function drawGauge(ctx, config) {
22397
+ ctx.save();
22398
+ const gauge = config.gauge;
22399
+ const arcCenterX = gauge.rect.x + gauge.rect.width / 2;
22400
+ const arcCenterY = gauge.rect.y + gauge.rect.height;
22401
+ const arcRadius = gauge.rect.height - gauge.arcWidth / 2;
22402
+ if (arcRadius < 0) {
22403
+ return;
22404
+ }
22405
+ const gaugeAngle = gauge.percentage === 1 ? 0 : Math.PI * (1 + gauge.percentage);
22406
+ // Gauge background
22407
+ ctx.strokeStyle = GAUGE_BACKGROUND_COLOR;
22408
+ ctx.beginPath();
22409
+ ctx.lineWidth = gauge.arcWidth;
22410
+ ctx.arc(arcCenterX, arcCenterY, arcRadius, gaugeAngle, 0);
22411
+ ctx.stroke();
22412
+ // Gauge value
22413
+ ctx.strokeStyle = gauge.color;
22414
+ ctx.beginPath();
22415
+ ctx.arc(arcCenterX, arcCenterY, arcRadius, Math.PI, gaugeAngle);
22416
+ ctx.stroke();
22417
+ ctx.restore();
22418
+ }
22419
+ function drawBackground(ctx, config) {
22420
+ ctx.save();
22421
+ ctx.fillStyle = config.backgroundColor;
22422
+ ctx.fillRect(0, 0, config.width, config.height);
22423
+ ctx.restore();
22424
+ }
22425
+ function drawLabels(ctx, config) {
22426
+ for (const label of [config.minLabel, config.maxLabel, config.gaugeValue]) {
22427
+ ctx.save();
22428
+ ctx.textAlign = "center";
22429
+ ctx.fillStyle = label.color;
22430
+ ctx.font = `${label.fontSize}px ${DEFAULT_FONT}`;
22431
+ ctx.fillText(label.label, label.textPosition.x, label.textPosition.y);
22432
+ ctx.restore();
22433
+ }
22434
+ }
22435
+ function drawInflectionValues(ctx, config) {
22436
+ const { x: rectX, y: rectY, width, height } = config.gauge.rect;
22437
+ for (const inflectionValue of config.inflectionValues) {
22438
+ ctx.save();
22439
+ ctx.translate(rectX + width / 2 - 0.5, rectY + height - 0.5); // -0.5 for sharper lines. see RendererPlugin.drawBorders comment
22440
+ ctx.rotate(Math.PI / 2 - inflectionValue.rotation);
22441
+ ctx.lineWidth = 2;
22442
+ ctx.strokeStyle = chartMutedFontColor(config.backgroundColor) + "aa";
22443
+ ctx.beginPath();
22444
+ ctx.moveTo(0, -(height - config.gauge.arcWidth));
22445
+ ctx.lineTo(0, -height - 3);
22446
+ ctx.stroke();
22447
+ ctx.textAlign = "center";
22448
+ ctx.font = `${inflectionValue.fontSize}px ${DEFAULT_FONT}`;
22449
+ ctx.fillStyle = inflectionValue.color;
22450
+ const textY = -height - GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN - inflectionValue.offset;
22451
+ ctx.fillText(inflectionValue.label, 0, textY);
22452
+ ctx.restore();
22453
+ }
22454
+ }
22455
+ function drawTitle(ctx, config) {
22456
+ ctx.save();
22457
+ const title = config.title;
22458
+ ctx.font = getDefaultContextFont(title.fontSize, title.bold, title.italic);
22459
+ ctx.textBaseline = "middle";
22460
+ ctx.fillStyle = title.color;
22461
+ ctx.fillText(title.label, title.textPosition.x, title.textPosition.y);
22462
+ ctx.restore();
22463
+ }
22464
+ function getGaugeRenderingConfig(boundingRect, runtime, ctx) {
22465
+ const maxValue = runtime.maxValue;
22466
+ const minValue = runtime.minValue;
22467
+ const gaugeValue = runtime.gaugeValue;
22468
+ const gaugeRect = getGaugeRect(boundingRect, runtime.title.text);
22469
+ const gaugeArcWidth = gaugeRect.width / 6;
22470
+ const gaugePercentage = gaugeValue
22471
+ ? (gaugeValue.value - minValue.value) / (maxValue.value - minValue.value)
22472
+ : 0;
22473
+ const gaugeValuePosition = {
22474
+ x: boundingRect.width / 2,
22475
+ y: gaugeRect.y + gaugeRect.height - gaugeRect.height / 12,
22476
+ };
22477
+ let gaugeValueFontSize = GAUGE_DEFAULT_VALUE_FONT_SIZE;
22478
+ // Scale down the font size if the gaugeRect is too small
22479
+ if (gaugeRect.height < 300) {
22480
+ gaugeValueFontSize = gaugeValueFontSize * (gaugeRect.height / 300);
22481
+ }
22482
+ // Scale down the font size if the text is too long
22483
+ const maxTextWidth = gaugeRect.width / 2;
22484
+ const gaugeLabel = gaugeValue?.label || "-";
22485
+ if (computeTextWidth(ctx, gaugeLabel, { fontSize: gaugeValueFontSize }, "px") > maxTextWidth) {
22486
+ gaugeValueFontSize = getFontSizeMatchingWidth(maxTextWidth, gaugeValueFontSize, (fontSize) => computeTextWidth(ctx, gaugeLabel, { fontSize }, "px"));
22487
+ }
22488
+ const minLabelPosition = {
22489
+ x: gaugeRect.x + gaugeArcWidth / 2,
22490
+ y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
22491
+ };
22492
+ const maxLabelPosition = {
22493
+ x: gaugeRect.x + gaugeRect.width - gaugeArcWidth / 2,
22494
+ y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
22495
+ };
22496
+ const textColor = chartMutedFontColor(runtime.background);
22497
+ const inflectionValues = getInflectionValues(runtime, gaugeRect, textColor, ctx);
22498
+ let x = 0, titleWidth = 0, titleHeight = 0;
22499
+ if (runtime.title.text) {
22500
+ ({ width: titleWidth, height: titleHeight } = computeTextDimension(ctx, runtime.title.text, { fontSize: CHART_TITLE_FONT_SIZE, ...runtime.title }, "px"));
22501
+ }
22502
+ switch (runtime.title.align) {
22503
+ case "right":
22504
+ x = boundingRect.width - titleWidth - CHART_PADDING$1;
22505
+ break;
22506
+ case "center":
22507
+ x = (boundingRect.width - titleWidth) / 2;
22508
+ break;
22509
+ case "left":
22510
+ default:
22511
+ x = CHART_PADDING$1;
22512
+ break;
22513
+ }
22514
+ return {
22515
+ width: boundingRect.width,
22516
+ height: boundingRect.height,
22517
+ title: {
22518
+ label: runtime.title.text ?? "",
22519
+ fontSize: runtime.title.fontSize ?? CHART_TITLE_FONT_SIZE,
22520
+ textPosition: {
22521
+ x,
22522
+ y: CHART_PADDING_TOP + titleHeight / 2,
22523
+ },
22524
+ color: runtime.title.color ?? textColor,
22525
+ bold: runtime.title.bold,
22526
+ italic: runtime.title.italic,
22527
+ },
22528
+ backgroundColor: runtime.background,
22529
+ gauge: {
22530
+ rect: gaugeRect,
22531
+ arcWidth: gaugeArcWidth,
22532
+ percentage: clip(gaugePercentage, 0, 1),
22533
+ color: getGaugeColor(runtime),
22534
+ },
22535
+ inflectionValues,
22536
+ gaugeValue: {
22537
+ label: gaugeLabel,
22538
+ textPosition: gaugeValuePosition,
22539
+ fontSize: gaugeValueFontSize,
22540
+ color: textColor,
22541
+ },
22542
+ minLabel: {
22543
+ label: runtime.minValue.label,
22544
+ textPosition: minLabelPosition,
22545
+ fontSize: GAUGE_LABELS_FONT_SIZE,
22546
+ color: textColor,
22547
+ },
22548
+ maxLabel: {
22549
+ label: runtime.maxValue.label,
22550
+ textPosition: maxLabelPosition,
22551
+ fontSize: GAUGE_LABELS_FONT_SIZE,
22552
+ color: textColor,
22553
+ },
22554
+ };
22555
+ }
22556
+ /**
22557
+ * Get the rectangle in which the gauge will be drawn, based on the bounding rectangle of the canvas and leaving
22558
+ * space for the title and labels.
22559
+ */
22560
+ function getGaugeRect(boundingRect, title) {
22561
+ const titleHeight = title ? GAUGE_TITLE_SECTION_HEIGHT : 0;
22562
+ const drawHeight = boundingRect.height - GAUGE_PADDING_BOTTOM - titleHeight - GAUGE_PADDING_TOP;
22563
+ const drawWidth = boundingRect.width - GAUGE_PADDING_SIDE * 2;
22564
+ let gaugeWidth;
22565
+ let gaugeHeight;
22566
+ if (drawWidth > 2 * drawHeight) {
22567
+ gaugeWidth = 2 * drawHeight;
22568
+ gaugeHeight = drawHeight;
22569
+ }
22570
+ else {
22571
+ gaugeWidth = drawWidth;
22572
+ gaugeHeight = drawWidth / 2;
22573
+ }
22574
+ const gaugeX = GAUGE_PADDING_SIDE + (drawWidth - gaugeWidth) / 2;
22575
+ const gaugeY = titleHeight + GAUGE_PADDING_TOP + (drawHeight - gaugeHeight) / 2;
22576
+ return {
22577
+ x: gaugeX,
22578
+ y: gaugeY,
22579
+ width: gaugeWidth,
22580
+ height: gaugeHeight,
22581
+ };
22582
+ }
22583
+ /**
22584
+ * 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).
22585
+ *
22586
+ * Also compute an offset for the text so that it doesn't overlap with other text.
22587
+ */
22588
+ function getInflectionValues(runtime, gaugeRect, textColor, ctx) {
22589
+ const maxValue = runtime.maxValue;
22590
+ const minValue = runtime.minValue;
22591
+ const gaugeCircleCenter = {
22592
+ x: gaugeRect.x + gaugeRect.width / 2,
22593
+ y: gaugeRect.y + gaugeRect.height,
22594
+ };
22595
+ const textStyle = { fontSize: GAUGE_LABELS_FONT_SIZE };
22596
+ const inflectionValues = [];
22597
+ const inflectionValuesTextRects = [];
22598
+ for (const inflectionValue of runtime.inflectionValues) {
22599
+ const percentage = (inflectionValue.value - minValue.value) / (maxValue.value - minValue.value);
22600
+ const labelWidth = computeTextWidth(ctx, inflectionValue.label, textStyle, "px");
22601
+ const angle = Math.PI - Math.PI * percentage;
22602
+ const textRect = getRectangleTangentToCircle(angle, // angle between X axis and the point where the rectangle is tangent to the circle
22603
+ gaugeRect.height + GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN, // radius of the gauge circle + margin below text
22604
+ gaugeCircleCenter.x, // center of the gauge circle
22605
+ gaugeCircleCenter.y, // center of the gauge circle
22606
+ labelWidth + 2, // width of the text + some margin
22607
+ GAUGE_LABELS_FONT_SIZE // height of the text
22608
+ );
22609
+ let offset = inflectionValuesTextRects.some((rect) => doRectanglesIntersect(rect, textRect))
22610
+ ? GAUGE_LABELS_FONT_SIZE
22611
+ : 0;
22612
+ inflectionValuesTextRects.push(textRect);
22613
+ inflectionValues.push({
22614
+ rotation: angle,
22615
+ label: inflectionValue.label,
22616
+ fontSize: GAUGE_LABELS_FONT_SIZE,
22617
+ color: textColor,
22618
+ offset,
22619
+ });
22620
+ }
22621
+ return inflectionValues;
22622
+ }
22623
+ function getGaugeColor(runtime) {
22624
+ const gaugeValue = runtime.gaugeValue?.value;
22625
+ if (gaugeValue === undefined) {
22626
+ return GAUGE_BACKGROUND_COLOR;
22627
+ }
22628
+ for (let i = 0; i < runtime.inflectionValues.length; i++) {
22629
+ const inflectionValue = runtime.inflectionValues[i];
22630
+ if (inflectionValue.operator === "<" && gaugeValue < inflectionValue.value) {
22631
+ return runtime.colors[i];
22632
+ }
22633
+ else if (inflectionValue.operator === "<=" && gaugeValue <= inflectionValue.value) {
22634
+ return runtime.colors[i];
22635
+ }
22636
+ }
22637
+ return runtime.colors.at(-1);
22638
+ }
22639
+ function getSegmentsOfRectangle(rectangle) {
22640
+ return [
22641
+ { start: rectangle.topLeft, end: rectangle.topRight },
22642
+ { start: rectangle.topRight, end: rectangle.bottomRight },
22643
+ { start: rectangle.bottomRight, end: rectangle.bottomLeft },
22644
+ { start: rectangle.bottomLeft, end: rectangle.topLeft },
22645
+ ];
22646
+ }
22647
+ /**
22648
+ * Check if two segment intersect. The case where the segments are colinear (both segments on the same line)
22649
+ * is not handled.
22650
+ */
22651
+ function doSegmentIntersect(segment1, segment2) {
22652
+ const A = segment1.start;
22653
+ const B = segment1.end;
22654
+ const C = segment2.start;
22655
+ const D = segment2.end;
22656
+ /**
22657
+ * Line segment intersection algorithm
22658
+ * https://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/
22659
+ */
22660
+ function ccw(a, b, c) {
22661
+ return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
22662
+ }
22663
+ return ccw(A, C, D) !== ccw(B, C, D) && ccw(A, B, C) !== ccw(A, B, D);
22664
+ }
22665
+ function doRectanglesIntersect(rect1, rect2) {
22666
+ const segments1 = getSegmentsOfRectangle(rect1);
22667
+ const segments2 = getSegmentsOfRectangle(rect2);
22668
+ for (const segment1 of segments1) {
22669
+ for (const segment2 of segments2) {
22670
+ if (doSegmentIntersect(segment1, segment2)) {
22671
+ return true;
22672
+ }
22673
+ }
22674
+ }
22675
+ return false;
22676
+ }
22677
+ /**
22678
+ * Get the rectangle that is tangent to a circle at a given angle.
22679
+ *
22680
+ * @param angle angle between X axis and the point where the rectangle is tangent to the circle
22681
+ */
22682
+ function getRectangleTangentToCircle(angle, radius, circleCenterX, circleCenterY, rectWidth, rectHeight) {
22683
+ const cos = Math.cos(angle);
22684
+ const sin = Math.sin(angle);
22685
+ // x, y are the distance from the center of the circle to the point where the rectangle is tangent to the circle
22686
+ const x = cos * radius;
22687
+ const y = sin * radius;
22688
+ // x2, y2 are the distance from the point the rectangle is tangent to the circle to the bottom left corner of the rectangle
22689
+ const x2 = sin * (rectWidth / 2); // cos(angle + 90°) = sin(angle)
22690
+ const y2 = cos * (rectWidth / 2);
22691
+ const bottomRight = {
22692
+ x: x + x2 + circleCenterX,
22693
+ y: circleCenterY - (y - y2),
22694
+ };
22695
+ const bottomLeft = {
22696
+ x: x - x2 + circleCenterX,
22697
+ y: circleCenterY - (y + y2),
22698
+ };
22699
+ // Same as above but for the top corners of the rectangle (radius + rectangle height instead of radius)
22700
+ const xp = cos * (radius + rectHeight);
22701
+ const yp = sin * (radius + rectHeight);
22702
+ const topLeft = {
22703
+ x: xp - x2 + circleCenterX,
22704
+ y: circleCenterY - (yp + y2),
22705
+ };
22706
+ const topRight = {
22707
+ x: xp + x2 + circleCenterX,
22708
+ y: circleCenterY - (yp - y2),
22709
+ };
22710
+ return { bottomLeft, bottomRight, topRight, topLeft };
22711
+ }
22712
+
22778
22713
  class GaugeChartComponent extends owl.Component {
22779
22714
  static template = "o-spreadsheet-GaugeChartComponent";
22780
22715
  canvas = owl.useRef("chartContainer");
@@ -22807,6 +22742,82 @@ function toXlsxHexColor(color) {
22807
22742
  return color;
22808
22743
  }
22809
22744
 
22745
+ const CHART_COMMON_OPTIONS = {
22746
+ // https://www.chartjs.org/docs/latest/general/responsive.html
22747
+ responsive: true, // will resize when its container is resized
22748
+ maintainAspectRatio: false, // doesn't maintain the aspect ratio (width/height =2 by default) so the user has the choice of the exact layout
22749
+ elements: {
22750
+ line: {
22751
+ fill: false, // do not fill the area under line charts
22752
+ },
22753
+ point: {
22754
+ hitRadius: 15, // increased hit radius to display point tooltip when hovering nearby
22755
+ },
22756
+ },
22757
+ animation: false,
22758
+ };
22759
+ function truncateLabel(label) {
22760
+ if (!label) {
22761
+ return "";
22762
+ }
22763
+ if (label.length > MAX_CHAR_LABEL) {
22764
+ return label.substring(0, MAX_CHAR_LABEL) + "…";
22765
+ }
22766
+ return label;
22767
+ }
22768
+ function chartToImage(runtime, figure, type) {
22769
+ // wrap the canvas in a div with a fixed size because chart.js would
22770
+ // fill the whole page otherwise
22771
+ const div = document.createElement("div");
22772
+ div.style.width = `${figure.width}px`;
22773
+ div.style.height = `${figure.height}px`;
22774
+ const canvas = document.createElement("canvas");
22775
+ div.append(canvas);
22776
+ canvas.setAttribute("width", figure.width.toString());
22777
+ canvas.setAttribute("height", figure.height.toString());
22778
+ // we have to add the canvas to the DOM otherwise it won't be rendered
22779
+ document.body.append(div);
22780
+ if ("chartJsConfig" in runtime) {
22781
+ const config = deepCopy(runtime.chartJsConfig);
22782
+ config.plugins = [backgroundColorChartJSPlugin];
22783
+ const Chart = getChartJSConstructor();
22784
+ const chart = new Chart(canvas, config);
22785
+ const imgContent = chart.toBase64Image();
22786
+ chart.destroy();
22787
+ div.remove();
22788
+ return imgContent;
22789
+ }
22790
+ else if (type === "scorecard") {
22791
+ const design = getScorecardConfiguration(figure, runtime);
22792
+ drawScoreChart(design, canvas);
22793
+ const imgContent = canvas.toDataURL();
22794
+ div.remove();
22795
+ return imgContent;
22796
+ }
22797
+ else if (type === "gauge") {
22798
+ drawGaugeChart(canvas, runtime);
22799
+ const imgContent = canvas.toDataURL();
22800
+ div.remove();
22801
+ return imgContent;
22802
+ }
22803
+ return undefined;
22804
+ }
22805
+ /**
22806
+ * Custom chart.js plugin to set the background color of the canvas
22807
+ * https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
22808
+ */
22809
+ const backgroundColorChartJSPlugin = {
22810
+ id: "customCanvasBackgroundColor",
22811
+ beforeDraw: (chart) => {
22812
+ const { ctx } = chart;
22813
+ ctx.save();
22814
+ ctx.globalCompositeOperation = "destination-over";
22815
+ ctx.fillStyle = "#ffffff";
22816
+ ctx.fillRect(0, 0, chart.width, chart.height);
22817
+ ctx.restore();
22818
+ },
22819
+ };
22820
+
22810
22821
  /**
22811
22822
  * Represent a raw XML string
22812
22823
  */
@@ -22846,9 +22857,9 @@ const XLSX_CHART_TYPES = [
22846
22857
  /** In XLSX color format (no #) */
22847
22858
  const AUTO_COLOR = "000000";
22848
22859
  const XLSX_ICONSET_MAP = {
22849
- arrow: "3Arrows",
22860
+ arrows: "3Arrows",
22850
22861
  smiley: "3Symbols",
22851
- dot: "3TrafficLights1",
22862
+ dots: "3TrafficLights1",
22852
22863
  };
22853
22864
  const NAMESPACE = {
22854
22865
  styleSheet: "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
@@ -23519,6 +23530,7 @@ const ICON_SET_CONVERSION_MAP = {
23519
23530
  };
23520
23531
  /** Map between legend position in XLSX file and human readable position */
23521
23532
  const DRAWING_LEGEND_POSITION_CONVERSION_MAP = {
23533
+ none: "none",
23522
23534
  b: "bottom",
23523
23535
  t: "top",
23524
23536
  l: "left",
@@ -26281,7 +26293,7 @@ class XlsxChartExtractor extends XlsxBaseExtractor {
26281
26293
  default: "ffffff",
26282
26294
  }).asString(),
26283
26295
  legendPosition: DRAWING_LEGEND_POSITION_CONVERSION_MAP[this.extractChildAttr(rootChartElement, "c:legendPos", "val", {
26284
- default: "b",
26296
+ default: "none",
26285
26297
  }).asString()],
26286
26298
  stacked: barChartGrouping === "stacked",
26287
26299
  fontColor: "000000",
@@ -26315,7 +26327,7 @@ class XlsxChartExtractor extends XlsxBaseExtractor {
26315
26327
  default: "ffffff",
26316
26328
  }).asString(),
26317
26329
  legendPosition: DRAWING_LEGEND_POSITION_CONVERSION_MAP[this.extractChildAttr(chartElement, "c:legendPos", "val", {
26318
- default: "b",
26330
+ default: "none",
26319
26331
  }).asString()],
26320
26332
  stacked: barChartGrouping === "stacked",
26321
26333
  fontColor: "000000",
@@ -28927,7 +28939,8 @@ function getChartLabelValues(getters, dataSets, labelRange) {
28927
28939
  }
28928
28940
  }
28929
28941
  else if (dataSets.length === 1) {
28930
- for (let i = 0; i < getData(getters, dataSets[0]).length; i++) {
28942
+ const dataLength = getData(getters, dataSets[0]).length;
28943
+ for (let i = 0; i < dataLength; i++) {
28931
28944
  labels.formattedValues.push("");
28932
28945
  labels.values.push("");
28933
28946
  }
@@ -29110,7 +29123,7 @@ function getLineChartDatasets(definition, args) {
29110
29123
  function getScatterChartDatasets(definition, args) {
29111
29124
  const dataSets = getLineChartDatasets(definition, args);
29112
29125
  for (const dataSet of dataSets) {
29113
- if (dataSet.xAxisID !== TREND_LINE_XAXIS_ID) {
29126
+ if (!isTrendLineAxis(dataSet.xAxisID)) {
29114
29127
  dataSet.showLine = false;
29115
29128
  }
29116
29129
  }
@@ -29237,7 +29250,9 @@ function getTrendingLineDataSet(dataset, config, data) {
29237
29250
  const borderColor = config.color || lightenColor(rgbaToHex(defaultBorderColor), 0.5);
29238
29251
  return {
29239
29252
  type: "line",
29240
- xAxisID: TREND_LINE_XAXIS_ID,
29253
+ xAxisID: config.type === "trailingMovingAverage"
29254
+ ? MOVING_AVERAGE_TREND_LINE_XAXIS_ID
29255
+ : TREND_LINE_XAXIS_ID,
29241
29256
  yAxisID: dataset.yAxisID,
29242
29257
  label: dataset.label ? _t("Trend line for %s", dataset.label) : "",
29243
29258
  data,
@@ -29312,22 +29327,19 @@ function getPieChartLegend(definition, args) {
29312
29327
  const { dataSetsValues } = args;
29313
29328
  const dataSetsLength = Math.max(0, ...dataSetsValues.map((ds) => ds?.data?.length ?? 0));
29314
29329
  const colors = getPieColors(new ColorGenerator(dataSetsLength), dataSetsValues);
29330
+ const fontColor = chartFontColor(definition.background);
29315
29331
  return {
29316
29332
  ...getLegendDisplayOptions(definition),
29317
29333
  labels: {
29318
- color: chartFontColor(definition.background),
29319
29334
  usePointStyle: true,
29320
- //@ts-ignore
29321
- generateLabels: (c) =>
29322
- //@ts-ignore
29323
- c.data.labels.map((label, index) => ({
29324
- text: label,
29335
+ generateLabels: (c) => c.data.labels?.map((label, index) => ({
29336
+ text: String(label),
29325
29337
  strokeStyle: colors[index],
29326
29338
  fillStyle: colors[index],
29327
29339
  pointStyle: "rect",
29328
- hidden: false,
29329
29340
  lineWidth: 2,
29330
- })),
29341
+ fontColor,
29342
+ })) || [],
29331
29343
  filter: (legendItem, data) => {
29332
29344
  return "datasetIndex" in legendItem
29333
29345
  ? !data.datasets[legendItem.datasetIndex].hidden
@@ -29460,7 +29472,7 @@ function getCustomLegendLabels(fontColor, legendLabelConfig) {
29460
29472
  color: fontColor,
29461
29473
  usePointStyle: true,
29462
29474
  generateLabels: (chart) => chart.data.datasets.map((dataset, index) => {
29463
- if (dataset["xAxisID"] === TREND_LINE_XAXIS_ID) {
29475
+ if (isTrendLineAxis(dataset["xAxisID"])) {
29464
29476
  return {
29465
29477
  text: dataset.label ?? "",
29466
29478
  fontColor,
@@ -29518,6 +29530,11 @@ function getBarChartScales(definition, args) {
29518
29530
  offset: false,
29519
29531
  display: false,
29520
29532
  };
29533
+ scales[MOVING_AVERAGE_TREND_LINE_XAXIS_ID] = {
29534
+ ...scales.x,
29535
+ offset: false,
29536
+ display: false,
29537
+ };
29521
29538
  }
29522
29539
  return scales;
29523
29540
  }
@@ -29551,6 +29568,10 @@ function getLineChartScales(definition, args) {
29551
29568
  ...scales.x,
29552
29569
  display: false,
29553
29570
  };
29571
+ scales[MOVING_AVERAGE_TREND_LINE_XAXIS_ID] = {
29572
+ ...scales.x,
29573
+ display: false,
29574
+ };
29554
29575
  if (axisType === "category" || axisType === "time") {
29555
29576
  /* We add a second x axis here to draw the trend lines, with the labels length being
29556
29577
  * set so that the second axis points match the classical x axis
@@ -29559,6 +29580,8 @@ function getLineChartScales(definition, args) {
29559
29580
  scales[TREND_LINE_XAXIS_ID]["type"] = "category";
29560
29581
  scales[TREND_LINE_XAXIS_ID]["labels"] = range(0, maxLength).map((x) => x.toString());
29561
29582
  scales[TREND_LINE_XAXIS_ID]["offset"] = false;
29583
+ scales[MOVING_AVERAGE_TREND_LINE_XAXIS_ID]["type"] = "category";
29584
+ scales[MOVING_AVERAGE_TREND_LINE_XAXIS_ID]["offset"] = false;
29562
29585
  }
29563
29586
  }
29564
29587
  return scales;
@@ -29804,9 +29827,7 @@ function getBarChartTooltip(definition, args) {
29804
29827
  return {
29805
29828
  callbacks: {
29806
29829
  title: function (tooltipItems) {
29807
- return tooltipItems.some((item) => item.dataset.xAxisID !== TREND_LINE_XAXIS_ID)
29808
- ? undefined
29809
- : "";
29830
+ return tooltipItems.some((item) => !isTrendLineAxis(item.dataset.xAxisID)) ? undefined : "";
29810
29831
  },
29811
29832
  label: function (tooltipItem) {
29812
29833
  const xLabel = tooltipItem.dataset?.label || tooltipItem.label;
@@ -29829,7 +29850,7 @@ function getLineChartTooltip(definition, args) {
29829
29850
  if (axisType === "linear") {
29830
29851
  tooltip.callbacks.label = (tooltipItem) => {
29831
29852
  const dataSetPoint = tooltipItem.parsed.y;
29832
- let label = tooltipItem.dataset.xAxisID === TREND_LINE_XAXIS_ID
29853
+ let label = isTrendLineAxis(tooltipItem.dataset.xAxisID)
29833
29854
  ? ""
29834
29855
  : tooltipItem.parsed.x;
29835
29856
  if (typeof label === "string" && isNumber(label, locale)) {
@@ -29854,8 +29875,7 @@ function getLineChartTooltip(definition, args) {
29854
29875
  };
29855
29876
  }
29856
29877
  tooltip.callbacks.title = function (tooltipItems) {
29857
- const displayTooltipTitle = axisType !== "linear" &&
29858
- tooltipItems.some((item) => item.dataset.xAxisID !== TREND_LINE_XAXIS_ID);
29878
+ const displayTooltipTitle = axisType !== "linear" && tooltipItems.some((item) => !isTrendLineAxis(item.dataset.xAxisID));
29859
29879
  return displayTooltipTitle ? undefined : "";
29860
29880
  };
29861
29881
  return tooltip;
@@ -34012,6 +34032,7 @@ var CHART_HELPERS = /*#__PURE__*/Object.freeze({
34012
34032
  CHART_COMMON_OPTIONS: CHART_COMMON_OPTIONS,
34013
34033
  GaugeChart: GaugeChart,
34014
34034
  LineChart: LineChart,
34035
+ MOVING_AVERAGE_TREND_LINE_XAXIS_ID: MOVING_AVERAGE_TREND_LINE_XAXIS_ID,
34015
34036
  PieChart: PieChart,
34016
34037
  ScorecardChart: ScorecardChart$1,
34017
34038
  TREND_LINE_XAXIS_ID: TREND_LINE_XAXIS_ID,
@@ -34036,11 +34057,11 @@ var CHART_HELPERS = /*#__PURE__*/Object.freeze({
34036
34057
  drawScoreChart: drawScoreChart,
34037
34058
  formatChartDatasetValue: formatChartDatasetValue,
34038
34059
  formatTickValue: formatTickValue,
34039
- getChartJSConstructor: getChartJSConstructor,
34040
34060
  getChartPositionAtCenterOfViewport: getChartPositionAtCenterOfViewport,
34041
34061
  getDefinedAxis: getDefinedAxis,
34042
34062
  getPieColors: getPieColors,
34043
34063
  getSmartChartDefinition: getSmartChartDefinition,
34064
+ isTrendLineAxis: isTrendLineAxis,
34044
34065
  shouldRemoveFirstLabel: shouldRemoveFirstLabel,
34045
34066
  toExcelDataset: toExcelDataset,
34046
34067
  toExcelLabelRange: toExcelLabelRange,
@@ -36088,9 +36109,7 @@ class FormulaFingerprintStore extends SpreadsheetStore {
36088
36109
  }
36089
36110
  }
36090
36111
  }
36091
- // removes the index placeholders from the normalized formula
36092
- // =|N0|+|N1|+|N0| -> =|N|+|N|+|N|
36093
- const normalizedFormula = cell.compiledFormula.normalizedFormula.replace(/(|\w)(\d)(|)/g, "$1$3");
36112
+ const normalizedFormula = cell.compiledFormula.normalizedFormula;
36094
36113
  return hash(fingerprintVector) + normalizedFormula;
36095
36114
  }
36096
36115
  getLiteralFingerprint(position) {
@@ -39081,9 +39100,11 @@ class SeriesDesignEditor extends owl.Component {
39081
39100
  if (!runtime || !("chartJsConfig" in runtime)) {
39082
39101
  return [];
39083
39102
  }
39084
- return runtime.chartJsConfig.data.datasets.map((d) => d.label);
39103
+ return runtime.chartJsConfig.data.datasets
39104
+ .filter((d) => !isTrendLineAxis(d["xAxisID"] ?? ""))
39105
+ .map((d) => d.label);
39085
39106
  }
39086
- updateSerieEditor(ev) {
39107
+ updateEditedSeries(ev) {
39087
39108
  this.state.index = ev.target.selectedIndex;
39088
39109
  }
39089
39110
  updateDataSeriesColor(color) {
@@ -39096,7 +39117,7 @@ class SeriesDesignEditor extends owl.Component {
39096
39117
  };
39097
39118
  this.props.updateChart(this.props.figureId, { dataSets });
39098
39119
  }
39099
- getDataSerieColor() {
39120
+ getDataSeriesColor() {
39100
39121
  const dataSets = this.props.definition.dataSets;
39101
39122
  if (!dataSets?.[this.state.index])
39102
39123
  return "";
@@ -39116,7 +39137,7 @@ class SeriesDesignEditor extends owl.Component {
39116
39137
  };
39117
39138
  this.props.updateChart(this.props.figureId, { dataSets });
39118
39139
  }
39119
- getDataSerieLabel() {
39140
+ getDataSeriesLabel() {
39120
39141
  const dataSets = this.props.definition.dataSets;
39121
39142
  return dataSets[this.state.index]?.label || this.getDataSeries()[this.state.index];
39122
39143
  }
@@ -39229,7 +39250,7 @@ class SeriesWithAxisDesignEditor extends owl.Component {
39229
39250
  }
39230
39251
  this.updateTrendLineValue(index, { window });
39231
39252
  }
39232
- getDataSerieColor(index) {
39253
+ getDataSeriesColor(index) {
39233
39254
  const dataSets = this.props.definition.dataSets;
39234
39255
  if (!dataSets?.[index])
39235
39256
  return "";
@@ -39240,7 +39261,7 @@ class SeriesWithAxisDesignEditor extends owl.Component {
39240
39261
  }
39241
39262
  getTrendLineColor(index) {
39242
39263
  return (this.getTrendLineConfiguration(index)?.color ??
39243
- setColorAlpha(this.getDataSerieColor(index), 0.5));
39264
+ setColorAlpha(this.getDataSeriesColor(index), 0.5));
39244
39265
  }
39245
39266
  updateTrendLineColor(index, color) {
39246
39267
  this.updateTrendLineValue(index, { color });
@@ -44745,7 +44766,8 @@ css /* scss */ `
44745
44766
  &.pivot-dimension-invalid {
44746
44767
  background-color: #ffdddd;
44747
44768
  border-color: red !important;
44748
- select {
44769
+ select,
44770
+ input {
44749
44771
  background-color: #ffdddd;
44750
44772
  }
44751
44773
  }
@@ -46608,7 +46630,7 @@ class PivotSidePanelStore extends SpreadsheetStore {
46608
46630
  this.notification.notifyUser({
46609
46631
  type: "info",
46610
46632
  text: _t("Pivot updates only work with dynamic pivot tables. Use %s or re-insert the static pivot from the Data menu.", pivotExample),
46611
- sticky: false,
46633
+ sticky: true,
46612
46634
  });
46613
46635
  }
46614
46636
  }
@@ -70323,7 +70345,9 @@ css /* scss */ `
70323
70345
  border: 1px solid;
70324
70346
  font-family: ${DEFAULT_FONT};
70325
70347
 
70326
- .o-composer:empty:not(:focus):not(.active)::before {
70348
+ /* In readonly we always show the fx icon if the composer is empty, not matter the focus */
70349
+ .o-composer:empty:not(:focus):not(.active)::before,
70350
+ &.o-topbar-composer-readonly .o-composer:empty::before {
70327
70351
  content: url("data:image/svg+xml,${encodeURIComponent(FX_SVG)}");
70328
70352
  position: relative;
70329
70353
  top: 20%;
@@ -73664,10 +73688,14 @@ function addIconSetRule(cf, rule) {
73664
73688
  continue;
73665
73689
  }
73666
73690
  const cfValueObjectNodes = cfValueObject.map((attrs) => escapeXml /*xml*/ `<cfvo ${formatAttributes(attrs)} />`);
73691
+ const iconSetAttrs = [["iconSet", getIconSet(rule.icons)]];
73692
+ if (isIconSetReversed(rule.icons)) {
73693
+ iconSetAttrs.push(["reverse", "1"]);
73694
+ }
73667
73695
  conditionalFormats.push(escapeXml /*xml*/ `
73668
73696
  <conditionalFormatting sqref="${range}">
73669
73697
  <cfRule ${formatAttributes(ruleAttributes)}>
73670
- <iconSet iconSet="${getIconSet(rule.icons)}">
73698
+ <iconSet ${formatAttributes(iconSetAttrs)}>
73671
73699
  ${joinXmlNodes(cfValueObjectNodes)}
73672
73700
  </iconSet>
73673
73701
  </cfRule>
@@ -73685,9 +73713,21 @@ function commonCfAttributes(cf) {
73685
73713
  ["stopIfTrue", cf.stopIfTrue ? 1 : 0],
73686
73714
  ];
73687
73715
  }
73716
+ function isIconSetReversed(iconSet) {
73717
+ const defaultIconSet = ICON_SETS[detectIconsType(iconSet)];
73718
+ return iconSet.upper === defaultIconSet.bad && iconSet.lower === defaultIconSet.good;
73719
+ }
73688
73720
  function getIconSet(iconSet) {
73689
- return XLSX_ICONSET_MAP[Object.keys(XLSX_ICONSET_MAP).find((key) => iconSet.upper.toLowerCase().startsWith(key)) ||
73690
- "dots"];
73721
+ return XLSX_ICONSET_MAP[detectIconsType(iconSet)];
73722
+ }
73723
+ /**
73724
+ * Partial detection based on "upper" point only.
73725
+ * We support any arbitrary icon in the set, while excel doesn't allow
73726
+ * mixing icons from different types.
73727
+ */
73728
+ function detectIconsType(iconSet) {
73729
+ const type = Object.keys(ICON_SETS).find((type) => Object.values(ICON_SETS[type]).includes(iconSet.upper)) || "dots";
73730
+ return type;
73691
73731
  }
73692
73732
  function thresholdAttributes(threshold, position) {
73693
73733
  const type = getExcelThresholdType(threshold.type, position);
@@ -75406,6 +75446,7 @@ const registries = {
75406
75446
  supportedPivotPositionalFormulaRegistry,
75407
75447
  pivotToFunctionValueRegistry,
75408
75448
  migrationStepRegistry,
75449
+ chartJsExtensionRegistry,
75409
75450
  };
75410
75451
  const helpers = {
75411
75452
  arg,
@@ -75604,6 +75645,6 @@ exports.tokenColors = tokenColors;
75604
75645
  exports.tokenize = tokenize;
75605
75646
 
75606
75647
 
75607
- __info__.version = "18.1.12";
75608
- __info__.date = "2025-03-19T08:23:50.676Z";
75609
- __info__.hash = "32f788f";
75648
+ __info__.version = "18.1.14";
75649
+ __info__.date = "2025-04-04T08:42:40.149Z";
75650
+ __info__.hash = "63b2fb7";