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