@odoo/o-spreadsheet 18.2.4 → 18.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,9 @@
2
2
  /**
3
3
  * This file is generated by o-spreadsheet build tools. Do not edit it.
4
4
  * @see https://github.com/odoo/o-spreadsheet
5
- * @version 18.2.4
6
- * @date 2025-03-19T08:20:57.717Z
7
- * @hash 958936a
5
+ * @version 18.2.6
6
+ * @date 2025-04-04T08:41:26.115Z
7
+ * @hash faa00e2
8
8
  */
9
9
 
10
10
  import { useEnv, useSubEnv, onWillUnmount, useComponent, status, Component, useRef, onMounted, useEffect, App, blockDom, useState, onPatched, onWillPatch, onWillUpdateProps, useExternalListener, onWillStart, xml, useChildSubEnv, markRaw, toRaw } from '@odoo/owl';
@@ -804,8 +804,7 @@ function removeFalsyAttributes(obj) {
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 @@ const whiteSpaceSpecialCharacters = [
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
@@ -1131,7 +1130,10 @@ function rgbaStringToHex(color) {
1131
1130
  }
1132
1131
  else if (stringVals.length === 4) {
1133
1132
  const alpha = parseFloat(stringVals.pop() || "1");
1134
- alphaHex = Math.round((alpha || 1) * 255);
1133
+ if (isNaN(alpha)) {
1134
+ throw new Error("invalid alpha value");
1135
+ }
1136
+ alphaHex = Math.round(alpha * 255);
1135
1137
  }
1136
1138
  const vals = stringVals.map((val) => parseInt(val, 10));
1137
1139
  if (alphaHex !== 255) {
@@ -6794,8 +6796,12 @@ function tokenize(str, locale = DEFAULT_LOCALE) {
6794
6796
  str = replaceNewLines(str);
6795
6797
  const chars = new TokenizingChars(str);
6796
6798
  const result = [];
6799
+ const tokenizeSpace = specialWhiteSpaceRegexp.test(str)
6800
+ ? tokenizeSpecialCharacterSpace
6801
+ : tokenizeSimpleSpace;
6797
6802
  while (!chars.isOver()) {
6798
- let token = tokenizeSpace(chars) ||
6803
+ let token = tokenizeNewLine(chars) ||
6804
+ tokenizeSpace(chars) ||
6799
6805
  tokenizeArgsSeparator(chars, locale) ||
6800
6806
  tokenizeParenthesis(chars) ||
6801
6807
  tokenizeOperator(chars) ||
@@ -6929,17 +6935,19 @@ function tokenizeSymbol(chars) {
6929
6935
  }
6930
6936
  return null;
6931
6937
  }
6932
- function tokenizeSpace(chars) {
6933
- let length = 0;
6934
- while (chars.current === NEWLINE) {
6935
- length++;
6936
- chars.shift();
6938
+ function tokenizeSpecialCharacterSpace(chars) {
6939
+ let spaces = "";
6940
+ while (chars.current === " " || (chars.current && chars.current.match(specialWhiteSpaceRegexp))) {
6941
+ spaces += chars.shift();
6937
6942
  }
6938
- if (length) {
6939
- return { type: "SPACE", value: NEWLINE.repeat(length) };
6943
+ if (spaces) {
6944
+ return { type: "SPACE", value: spaces };
6940
6945
  }
6946
+ return null;
6947
+ }
6948
+ function tokenizeSimpleSpace(chars) {
6941
6949
  let spaces = "";
6942
- while (chars.current && chars.current.match(whiteSpaceRegexp)) {
6950
+ while (chars.current === " ") {
6943
6951
  spaces += chars.shift();
6944
6952
  }
6945
6953
  if (spaces) {
@@ -6947,6 +6955,17 @@ function tokenizeSpace(chars) {
6947
6955
  }
6948
6956
  return null;
6949
6957
  }
6958
+ function tokenizeNewLine(chars) {
6959
+ let length = 0;
6960
+ while (chars.current === NEWLINE) {
6961
+ length++;
6962
+ chars.shift();
6963
+ }
6964
+ if (length) {
6965
+ return { type: "SPACE", value: NEWLINE.repeat(length) };
6966
+ }
6967
+ return null;
6968
+ }
6950
6969
  function tokenizeInvalidRange(chars) {
6951
6970
  if (chars.currentStartsWith(CellErrorType.InvalidReference)) {
6952
6971
  chars.advanceBy(CellErrorType.InvalidReference.length);
@@ -6999,7 +7018,7 @@ function isValidLocale(locale) {
6999
7018
  */
7000
7019
  function canonicalizeNumberContent(content, locale) {
7001
7020
  return content.startsWith("=")
7002
- ? canonicalizeFormula$1(content, locale)
7021
+ ? canonicalizeFormula(content, locale)
7003
7022
  : canonicalizeNumberLiteral(content, locale);
7004
7023
  }
7005
7024
  /**
@@ -7014,7 +7033,7 @@ function canonicalizeNumberContent(content, locale) {
7014
7033
  */
7015
7034
  function canonicalizeContent(content, locale) {
7016
7035
  return content.startsWith("=")
7017
- ? canonicalizeFormula$1(content, locale)
7036
+ ? canonicalizeFormula(content, locale)
7018
7037
  : canonicalizeLiteral(content, locale);
7019
7038
  }
7020
7039
  /**
@@ -7030,15 +7049,21 @@ function localizeContent(content, locale) {
7030
7049
  ? localizeFormula(content, locale)
7031
7050
  : localizeLiteral(content, locale);
7032
7051
  }
7052
+ /** Change a number string to its canonical form (en_US locale) */
7053
+ function canonicalizeNumberValue(content, locale) {
7054
+ return content.startsWith("=")
7055
+ ? canonicalizeFormula(content, locale)
7056
+ : canonicalizeNumberLiteral(content, locale);
7057
+ }
7033
7058
  /** Change a formula to its canonical form (en_US locale) */
7034
- function canonicalizeFormula$1(formula, locale) {
7035
- return _localizeFormula$1(formula, locale, DEFAULT_LOCALE);
7059
+ function canonicalizeFormula(formula, locale) {
7060
+ return _localizeFormula(formula, locale, DEFAULT_LOCALE);
7036
7061
  }
7037
7062
  /** Change a formula from the canonical form to the given locale */
7038
7063
  function localizeFormula(formula, locale) {
7039
- return _localizeFormula$1(formula, DEFAULT_LOCALE, locale);
7064
+ return _localizeFormula(formula, DEFAULT_LOCALE, locale);
7040
7065
  }
7041
- function _localizeFormula$1(formula, fromLocale, toLocale) {
7066
+ function _localizeFormula(formula, fromLocale, toLocale) {
7042
7067
  if (fromLocale.formulaArgSeparator === toLocale.formulaArgSeparator &&
7043
7068
  fromLocale.decimalSeparator === toLocale.decimalSeparator) {
7044
7069
  return formula;
@@ -7193,37 +7218,6 @@ function getDateTimeFormat(locale) {
7193
7218
  return locale.dateFormat + " " + locale.timeFormat;
7194
7219
  }
7195
7220
 
7196
- /** Change a number string to its canonical form (en_US locale) */
7197
- function canonicalizeNumberValue(content, locale) {
7198
- return content.startsWith("=")
7199
- ? canonicalizeFormula(content, locale)
7200
- : canonicalizeNumberLiteral(content, locale);
7201
- }
7202
- /** Change a formula to its canonical form (en_US locale) */
7203
- function canonicalizeFormula(formula, locale) {
7204
- return _localizeFormula(formula, locale, DEFAULT_LOCALE);
7205
- }
7206
- function _localizeFormula(formula, fromLocale, toLocale) {
7207
- if (fromLocale.formulaArgSeparator === toLocale.formulaArgSeparator &&
7208
- fromLocale.decimalSeparator === toLocale.decimalSeparator) {
7209
- return formula;
7210
- }
7211
- const tokens = tokenize(formula, fromLocale);
7212
- let localizedFormula = "";
7213
- for (const token of tokens) {
7214
- if (token.type === "NUMBER") {
7215
- localizedFormula += token.value.replace(fromLocale.decimalSeparator, toLocale.decimalSeparator);
7216
- }
7217
- else if (token.type === "ARG_SEPARATOR") {
7218
- localizedFormula += toLocale.formulaArgSeparator;
7219
- }
7220
- else {
7221
- localizedFormula += token.value;
7222
- }
7223
- }
7224
- return localizedFormula;
7225
- }
7226
-
7227
7221
  function boolAnd(args) {
7228
7222
  let foundBoolean = false;
7229
7223
  let acc = true;
@@ -9598,7 +9592,161 @@ class ComposerFocusStore extends SpreadsheetStore {
9598
9592
  }
9599
9593
  }
9600
9594
 
9595
+ /**
9596
+ * This file is largely inspired by owl 1.
9597
+ * `css` tag has been removed from owl 2 without workaround to manage css.
9598
+ * So, the solution was to import the behavior of owl 1 directly in our
9599
+ * codebase, with one difference: the css is added to the sheet as soon as the
9600
+ * css tag is executed. In owl 1, the css was added as soon as a Component was
9601
+ * created for the first time.
9602
+ */
9603
+ const STYLESHEETS = {};
9604
+ let nextId = 0;
9605
+ /**
9606
+ * CSS tag helper for defining inline stylesheets. With this, one can simply define
9607
+ * an inline stylesheet with just the following code:
9608
+ * ```js
9609
+ * css`.component-a { color: red; }`;
9610
+ * ```
9611
+ */
9612
+ function css(strings, ...args) {
9613
+ const name = `__sheet__${nextId++}`;
9614
+ const value = String.raw(strings, ...args);
9615
+ registerSheet(name, value);
9616
+ activateSheet(name);
9617
+ return name;
9618
+ }
9619
+ function processSheet(str) {
9620
+ const tokens = str.split(/(\{|\}|;)/).map((s) => s.trim());
9621
+ const selectorStack = [];
9622
+ const parts = [];
9623
+ let rules = [];
9624
+ function generateSelector(stackIndex, parentSelector) {
9625
+ const parts = [];
9626
+ for (const selector of selectorStack[stackIndex]) {
9627
+ let part = (parentSelector && parentSelector + " " + selector) || selector;
9628
+ if (part.includes("&")) {
9629
+ part = selector.replace(/&/g, parentSelector || "");
9630
+ }
9631
+ if (stackIndex < selectorStack.length - 1) {
9632
+ part = generateSelector(stackIndex + 1, part);
9633
+ }
9634
+ parts.push(part);
9635
+ }
9636
+ return parts.join(", ");
9637
+ }
9638
+ function generateRules() {
9639
+ if (rules.length) {
9640
+ parts.push(generateSelector(0) + " {");
9641
+ parts.push(...rules);
9642
+ parts.push("}");
9643
+ rules = [];
9644
+ }
9645
+ }
9646
+ while (tokens.length) {
9647
+ let token = tokens.shift();
9648
+ if (token === "}") {
9649
+ generateRules();
9650
+ selectorStack.pop();
9651
+ }
9652
+ else {
9653
+ if (tokens[0] === "{") {
9654
+ generateRules();
9655
+ selectorStack.push(token.split(/\s*,\s*/));
9656
+ tokens.shift();
9657
+ }
9658
+ if (tokens[0] === ";") {
9659
+ rules.push(" " + token + ";");
9660
+ }
9661
+ }
9662
+ }
9663
+ return parts.join("\n");
9664
+ }
9665
+ function registerSheet(id, css) {
9666
+ const sheet = document.createElement("style");
9667
+ sheet.textContent = processSheet(css);
9668
+ STYLESHEETS[id] = sheet;
9669
+ }
9670
+ function activateSheet(id) {
9671
+ const sheet = STYLESHEETS[id];
9672
+ sheet.setAttribute("component", id);
9673
+ document.head.appendChild(sheet);
9674
+ }
9675
+ function getTextDecoration({ strikethrough, underline, }) {
9676
+ if (!strikethrough && !underline) {
9677
+ return "none";
9678
+ }
9679
+ return `${strikethrough ? "line-through" : ""} ${underline ? "underline" : ""}`;
9680
+ }
9681
+ /**
9682
+ * Convert the cell style to CSS properties.
9683
+ */
9684
+ function cellStyleToCss(style) {
9685
+ const attributes = cellTextStyleToCss(style);
9686
+ if (!style)
9687
+ return attributes;
9688
+ if (style.fillColor) {
9689
+ attributes["background"] = style.fillColor;
9690
+ }
9691
+ return attributes;
9692
+ }
9693
+ /**
9694
+ * Convert the cell text style to CSS properties.
9695
+ */
9696
+ function cellTextStyleToCss(style) {
9697
+ const attributes = {};
9698
+ if (!style)
9699
+ return attributes;
9700
+ if (style.bold) {
9701
+ attributes["font-weight"] = "bold";
9702
+ }
9703
+ if (style.italic) {
9704
+ attributes["font-style"] = "italic";
9705
+ }
9706
+ if (style.strikethrough || style.underline) {
9707
+ let decoration = style.strikethrough ? "line-through" : "";
9708
+ decoration = style.underline ? decoration + " underline" : decoration;
9709
+ attributes["text-decoration"] = decoration;
9710
+ }
9711
+ if (style.textColor) {
9712
+ attributes["color"] = style.textColor;
9713
+ }
9714
+ return attributes;
9715
+ }
9716
+ /**
9717
+ * Transform CSS properties into a CSS string.
9718
+ */
9719
+ function cssPropertiesToCss(attributes) {
9720
+ let styleStr = "";
9721
+ for (const attName in attributes) {
9722
+ if (!attributes[attName]) {
9723
+ continue;
9724
+ }
9725
+ styleStr += `${attName}:${attributes[attName]}; `;
9726
+ }
9727
+ return styleStr;
9728
+ }
9729
+ function getElementMargins(el) {
9730
+ const style = window.getComputedStyle(el);
9731
+ return {
9732
+ top: parseInt(style.marginTop, 10) || 0,
9733
+ bottom: parseInt(style.marginBottom, 10) || 0,
9734
+ left: parseInt(style.marginLeft, 10) || 0,
9735
+ right: parseInt(style.marginRight, 10) || 0,
9736
+ };
9737
+ }
9738
+
9739
+ const chartJsExtensionRegistry = new Registry();
9740
+ /** Return window.Chart, making sure all our extensions are loaded in ChartJS */
9741
+ function getChartJSConstructor() {
9742
+ if (window.Chart && !window.Chart?.registry.plugins.get("chartShowValuesPlugin")) {
9743
+ window.Chart.register(...chartJsExtensionRegistry.getAll());
9744
+ }
9745
+ return window.Chart;
9746
+ }
9747
+
9601
9748
  const TREND_LINE_XAXIS_ID = "x1";
9749
+ const MOVING_AVERAGE_TREND_LINE_XAXIS_ID = "xMovingAverage";
9602
9750
  /**
9603
9751
  * This file contains helpers that are common to different charts (mainly
9604
9752
  * line, bar and pie charts)
@@ -9949,6 +10097,9 @@ function truncateLabel(label) {
9949
10097
  }
9950
10098
  return label;
9951
10099
  }
10100
+ function isTrendLineAxis(axisID) {
10101
+ return axisID === TREND_LINE_XAXIS_ID || axisID === MOVING_AVERAGE_TREND_LINE_XAXIS_ID;
10102
+ }
9952
10103
 
9953
10104
  /** This is a chartJS plugin that will draw the values of each data next to the point/bar/pie slice */
9954
10105
  const chartShowValuesPlugin = {
@@ -9993,7 +10144,7 @@ function drawLineOrBarOrRadarChartValues(chart, options, ctx) {
9993
10144
  const yMin = chart.chartArea.top;
9994
10145
  const textsPositions = {};
9995
10146
  for (const dataset of chart._metasets) {
9996
- if (dataset.xAxisID === TREND_LINE_XAXIS_ID || dataset.hidden) {
10147
+ if (isTrendLineAxis(dataset.axisID) || dataset.hidden) {
9997
10148
  continue;
9998
10149
  }
9999
10150
  for (let i = 0; i < dataset._parsed.length; i++) {
@@ -10036,7 +10187,7 @@ function drawHorizontalBarChartValues(chart, options, ctx) {
10036
10187
  const xMin = chart.chartArea.left;
10037
10188
  const textsPositions = {};
10038
10189
  for (const dataset of chart._metasets) {
10039
- if (dataset.xAxisID === TREND_LINE_XAXIS_ID) {
10190
+ if (isTrendLineAxis(dataset.axisID)) {
10040
10191
  return; // ignore trend lines
10041
10192
  }
10042
10193
  for (let i = 0; i < dataset._parsed.length; i++) {
@@ -10142,341 +10293,79 @@ function getNextNonEmptyBar(bars, startIndex) {
10142
10293
  return bars.find((bar, i) => i > startIndex && bar.height !== 0);
10143
10294
  }
10144
10295
 
10145
- const GAUGE_PADDING_SIDE = 30;
10146
- const GAUGE_PADDING_TOP = 10;
10147
- const GAUGE_PADDING_BOTTOM = 20;
10148
- const GAUGE_LABELS_FONT_SIZE = 12;
10149
- const GAUGE_DEFAULT_VALUE_FONT_SIZE = 80;
10150
- const GAUGE_BACKGROUND_COLOR = "#F3F2F1";
10151
- const GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN = 6;
10152
- const GAUGE_TITLE_SECTION_HEIGHT = 25;
10153
- function drawGaugeChart(canvas, runtime) {
10154
- const canvasBoundingRect = canvas.getBoundingClientRect();
10155
- canvas.width = canvasBoundingRect.width;
10156
- canvas.height = canvasBoundingRect.height;
10157
- const ctx = canvas.getContext("2d");
10158
- const config = getGaugeRenderingConfig(canvasBoundingRect, runtime, ctx);
10159
- drawBackground(ctx, config);
10160
- drawGauge(ctx, config);
10161
- drawInflectionValues(ctx, config);
10162
- drawLabels(ctx, config);
10163
- drawTitle(ctx, config);
10164
- }
10165
- function drawGauge(ctx, config) {
10166
- ctx.save();
10167
- const gauge = config.gauge;
10168
- const arcCenterX = gauge.rect.x + gauge.rect.width / 2;
10169
- const arcCenterY = gauge.rect.y + gauge.rect.height;
10170
- const arcRadius = gauge.rect.height - gauge.arcWidth / 2;
10171
- if (arcRadius < 0) {
10172
- return;
10173
- }
10174
- const gaugeAngle = gauge.percentage === 1 ? 0 : Math.PI * (1 + gauge.percentage);
10175
- // Gauge background
10176
- ctx.strokeStyle = GAUGE_BACKGROUND_COLOR;
10177
- ctx.beginPath();
10178
- ctx.lineWidth = gauge.arcWidth;
10179
- ctx.arc(arcCenterX, arcCenterY, arcRadius, gaugeAngle, 0);
10180
- ctx.stroke();
10181
- // Gauge value
10182
- ctx.strokeStyle = gauge.color;
10183
- ctx.beginPath();
10184
- ctx.arc(arcCenterX, arcCenterY, arcRadius, Math.PI, gaugeAngle);
10185
- ctx.stroke();
10186
- ctx.restore();
10187
- }
10188
- function drawBackground(ctx, config) {
10189
- ctx.save();
10190
- ctx.fillStyle = config.backgroundColor;
10191
- ctx.fillRect(0, 0, config.width, config.height);
10192
- ctx.restore();
10193
- }
10194
- function drawLabels(ctx, config) {
10195
- for (const label of [config.minLabel, config.maxLabel, config.gaugeValue]) {
10196
- ctx.save();
10197
- ctx.textAlign = "center";
10198
- ctx.fillStyle = label.color;
10199
- ctx.font = `${label.fontSize}px ${DEFAULT_FONT}`;
10200
- ctx.fillText(label.label, label.textPosition.x, label.textPosition.y);
10201
- ctx.restore();
10202
- }
10203
- }
10204
- function drawInflectionValues(ctx, config) {
10205
- const { x: rectX, y: rectY, width, height } = config.gauge.rect;
10206
- for (const inflectionValue of config.inflectionValues) {
10207
- ctx.save();
10208
- ctx.translate(rectX + width / 2 - 0.5, rectY + height - 0.5); // -0.5 for sharper lines. see RendererPlugin.drawBorders comment
10209
- ctx.rotate(Math.PI / 2 - inflectionValue.rotation);
10210
- ctx.lineWidth = 2;
10211
- ctx.strokeStyle = chartMutedFontColor(config.backgroundColor) + "aa";
10212
- ctx.beginPath();
10213
- ctx.moveTo(0, -(height - config.gauge.arcWidth));
10214
- ctx.lineTo(0, -height - 3);
10215
- ctx.stroke();
10216
- ctx.textAlign = "center";
10217
- ctx.font = `${inflectionValue.fontSize}px ${DEFAULT_FONT}`;
10218
- ctx.fillStyle = inflectionValue.color;
10219
- const textY = -height - GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN - inflectionValue.offset;
10220
- ctx.fillText(inflectionValue.label, 0, textY);
10221
- ctx.restore();
10222
- }
10223
- }
10224
- function drawTitle(ctx, config) {
10225
- ctx.save();
10226
- const title = config.title;
10227
- ctx.font = getDefaultContextFont(title.fontSize, title.bold, title.italic);
10228
- ctx.textBaseline = "middle";
10229
- ctx.fillStyle = title.color;
10230
- ctx.fillText(title.label, title.textPosition.x, title.textPosition.y);
10231
- ctx.restore();
10232
- }
10233
- function getGaugeRenderingConfig(boundingRect, runtime, ctx) {
10234
- const maxValue = runtime.maxValue;
10235
- const minValue = runtime.minValue;
10236
- const gaugeValue = runtime.gaugeValue;
10237
- const gaugeRect = getGaugeRect(boundingRect, runtime.title.text);
10238
- const gaugeArcWidth = gaugeRect.width / 6;
10239
- const gaugePercentage = gaugeValue
10240
- ? (gaugeValue.value - minValue.value) / (maxValue.value - minValue.value)
10241
- : 0;
10242
- const gaugeValuePosition = {
10243
- x: boundingRect.width / 2,
10244
- y: gaugeRect.y + gaugeRect.height - gaugeRect.height / 12,
10245
- };
10246
- let gaugeValueFontSize = GAUGE_DEFAULT_VALUE_FONT_SIZE;
10247
- // Scale down the font size if the gaugeRect is too small
10248
- if (gaugeRect.height < 300) {
10249
- gaugeValueFontSize = gaugeValueFontSize * (gaugeRect.height / 300);
10250
- }
10251
- // Scale down the font size if the text is too long
10252
- const maxTextWidth = gaugeRect.width / 2;
10253
- const gaugeLabel = gaugeValue?.label || "-";
10254
- if (computeTextWidth(ctx, gaugeLabel, { fontSize: gaugeValueFontSize }, "px") > maxTextWidth) {
10255
- gaugeValueFontSize = getFontSizeMatchingWidth(maxTextWidth, gaugeValueFontSize, (fontSize) => computeTextWidth(ctx, gaugeLabel, { fontSize }, "px"));
10296
+ css /* scss */ `
10297
+ .o-spreadsheet {
10298
+ .o-chart-custom-tooltip {
10299
+ font-size: 12px;
10300
+ background-color: #fff;
10301
+ z-index: ${ComponentsImportance.FigureTooltip};
10256
10302
  }
10257
- const minLabelPosition = {
10258
- x: gaugeRect.x + gaugeArcWidth / 2,
10259
- y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
10260
- };
10261
- const maxLabelPosition = {
10262
- x: gaugeRect.x + gaugeRect.width - gaugeArcWidth / 2,
10263
- y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
10303
+ }
10304
+ `;
10305
+ chartJsExtensionRegistry.add("chartShowValuesPlugin", chartShowValuesPlugin);
10306
+ chartJsExtensionRegistry.add("waterfallLinesPlugin", waterfallLinesPlugin);
10307
+ class ChartJsComponent extends Component {
10308
+ static template = "o-spreadsheet-ChartJsComponent";
10309
+ static props = {
10310
+ figure: Object,
10264
10311
  };
10265
- const textColor = chartMutedFontColor(runtime.background);
10266
- const inflectionValues = getInflectionValues(runtime, gaugeRect, textColor, ctx);
10267
- let x = 0, titleWidth = 0, titleHeight = 0;
10268
- if (runtime.title.text) {
10269
- ({ width: titleWidth, height: titleHeight } = computeTextDimension(ctx, runtime.title.text, { fontSize: CHART_TITLE_FONT_SIZE, ...runtime.title }, "px"));
10270
- }
10271
- switch (runtime.title.align) {
10272
- case "right":
10273
- x = boundingRect.width - titleWidth - CHART_PADDING$1;
10274
- break;
10275
- case "center":
10276
- x = (boundingRect.width - titleWidth) / 2;
10277
- break;
10278
- case "left":
10279
- default:
10280
- x = CHART_PADDING$1;
10281
- break;
10312
+ canvas = useRef("graphContainer");
10313
+ chart;
10314
+ currentRuntime;
10315
+ get background() {
10316
+ return this.chartRuntime.background;
10282
10317
  }
10283
- return {
10284
- width: boundingRect.width,
10285
- height: boundingRect.height,
10286
- title: {
10287
- label: runtime.title.text ?? "",
10288
- fontSize: runtime.title.fontSize ?? CHART_TITLE_FONT_SIZE,
10289
- textPosition: {
10290
- x,
10291
- y: CHART_PADDING_TOP + titleHeight / 2,
10292
- },
10293
- color: runtime.title.color ?? textColor,
10294
- bold: runtime.title.bold,
10295
- italic: runtime.title.italic,
10296
- },
10297
- backgroundColor: runtime.background,
10298
- gauge: {
10299
- rect: gaugeRect,
10300
- arcWidth: gaugeArcWidth,
10301
- percentage: clip(gaugePercentage, 0, 1),
10302
- color: getGaugeColor(runtime),
10303
- },
10304
- inflectionValues,
10305
- gaugeValue: {
10306
- label: gaugeLabel,
10307
- textPosition: gaugeValuePosition,
10308
- fontSize: gaugeValueFontSize,
10309
- color: textColor,
10310
- },
10311
- minLabel: {
10312
- label: runtime.minValue.label,
10313
- textPosition: minLabelPosition,
10314
- fontSize: GAUGE_LABELS_FONT_SIZE,
10315
- color: textColor,
10316
- },
10317
- maxLabel: {
10318
- label: runtime.maxValue.label,
10319
- textPosition: maxLabelPosition,
10320
- fontSize: GAUGE_LABELS_FONT_SIZE,
10321
- color: textColor,
10322
- },
10323
- };
10324
- }
10325
- /**
10326
- * Get the rectangle in which the gauge will be drawn, based on the bounding rectangle of the canvas and leaving
10327
- * space for the title and labels.
10328
- */
10329
- function getGaugeRect(boundingRect, title) {
10330
- const titleHeight = title ? GAUGE_TITLE_SECTION_HEIGHT : 0;
10331
- const drawHeight = boundingRect.height - GAUGE_PADDING_BOTTOM - titleHeight - GAUGE_PADDING_TOP;
10332
- const drawWidth = boundingRect.width - GAUGE_PADDING_SIDE * 2;
10333
- let gaugeWidth;
10334
- let gaugeHeight;
10335
- if (drawWidth > 2 * drawHeight) {
10336
- gaugeWidth = 2 * drawHeight;
10337
- gaugeHeight = drawHeight;
10318
+ get canvasStyle() {
10319
+ return `background-color: ${this.background}`;
10338
10320
  }
10339
- else {
10340
- gaugeWidth = drawWidth;
10341
- gaugeHeight = drawWidth / 2;
10321
+ get chartRuntime() {
10322
+ const runtime = this.env.model.getters.getChartRuntime(this.props.figure.id);
10323
+ if (!("chartJsConfig" in runtime)) {
10324
+ throw new Error("Unsupported chart runtime");
10325
+ }
10326
+ return runtime;
10342
10327
  }
10343
- const gaugeX = GAUGE_PADDING_SIDE + (drawWidth - gaugeWidth) / 2;
10344
- const gaugeY = titleHeight + GAUGE_PADDING_TOP + (drawHeight - gaugeHeight) / 2;
10345
- return {
10346
- x: gaugeX,
10347
- y: gaugeY,
10348
- width: gaugeWidth,
10349
- height: gaugeHeight,
10350
- };
10351
- }
10352
- /**
10353
- * 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).
10354
- *
10355
- * Also compute an offset for the text so that it doesn't overlap with other text.
10356
- */
10357
- function getInflectionValues(runtime, gaugeRect, textColor, ctx) {
10358
- const maxValue = runtime.maxValue;
10359
- const minValue = runtime.minValue;
10360
- const gaugeCircleCenter = {
10361
- x: gaugeRect.x + gaugeRect.width / 2,
10362
- y: gaugeRect.y + gaugeRect.height,
10363
- };
10364
- const textStyle = { fontSize: GAUGE_LABELS_FONT_SIZE };
10365
- const inflectionValues = [];
10366
- const inflectionValuesTextRects = [];
10367
- for (const inflectionValue of runtime.inflectionValues) {
10368
- const percentage = (inflectionValue.value - minValue.value) / (maxValue.value - minValue.value);
10369
- const labelWidth = computeTextWidth(ctx, inflectionValue.label, textStyle, "px");
10370
- const angle = Math.PI - Math.PI * percentage;
10371
- const textRect = getRectangleTangentToCircle(angle, // angle between X axis and the point where the rectangle is tangent to the circle
10372
- gaugeRect.height + GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN, // radius of the gauge circle + margin below text
10373
- gaugeCircleCenter.x, // center of the gauge circle
10374
- gaugeCircleCenter.y, // center of the gauge circle
10375
- labelWidth + 2, // width of the text + some margin
10376
- GAUGE_LABELS_FONT_SIZE // height of the text
10377
- );
10378
- let offset = inflectionValuesTextRects.some((rect) => doRectanglesIntersect(rect, textRect))
10379
- ? GAUGE_LABELS_FONT_SIZE
10380
- : 0;
10381
- inflectionValuesTextRects.push(textRect);
10382
- inflectionValues.push({
10383
- rotation: angle,
10384
- label: inflectionValue.label,
10385
- fontSize: GAUGE_LABELS_FONT_SIZE,
10386
- color: textColor,
10387
- offset,
10328
+ setup() {
10329
+ onMounted(() => {
10330
+ const runtime = this.chartRuntime;
10331
+ this.currentRuntime = runtime;
10332
+ // Note: chartJS modify the runtime in place, so it's important to give it a copy
10333
+ this.createChart(deepCopy(runtime.chartJsConfig));
10334
+ });
10335
+ onWillUnmount(() => this.chart?.destroy());
10336
+ useEffect(() => {
10337
+ const runtime = this.chartRuntime;
10338
+ if (runtime !== this.currentRuntime) {
10339
+ if (runtime.chartJsConfig.type !== this.currentRuntime.chartJsConfig.type) {
10340
+ this.chart?.destroy();
10341
+ this.createChart(deepCopy(runtime.chartJsConfig));
10342
+ }
10343
+ else {
10344
+ this.updateChartJs(deepCopy(runtime.chartJsConfig));
10345
+ }
10346
+ this.currentRuntime = runtime;
10347
+ }
10388
10348
  });
10389
10349
  }
10390
- return inflectionValues;
10391
- }
10392
- function getGaugeColor(runtime) {
10393
- const gaugeValue = runtime.gaugeValue?.value;
10394
- if (gaugeValue === undefined) {
10395
- return GAUGE_BACKGROUND_COLOR;
10396
- }
10397
- for (let i = 0; i < runtime.inflectionValues.length; i++) {
10398
- const inflectionValue = runtime.inflectionValues[i];
10399
- if (inflectionValue.operator === "<" && gaugeValue < inflectionValue.value) {
10400
- return runtime.colors[i];
10401
- }
10402
- else if (inflectionValue.operator === "<=" && gaugeValue <= inflectionValue.value) {
10403
- return runtime.colors[i];
10404
- }
10405
- }
10406
- return runtime.colors.at(-1);
10407
- }
10408
- function getSegmentsOfRectangle(rectangle) {
10409
- return [
10410
- { start: rectangle.topLeft, end: rectangle.topRight },
10411
- { start: rectangle.topRight, end: rectangle.bottomRight },
10412
- { start: rectangle.bottomRight, end: rectangle.bottomLeft },
10413
- { start: rectangle.bottomLeft, end: rectangle.topLeft },
10414
- ];
10415
- }
10416
- /**
10417
- * Check if two segment intersect. The case where the segments are colinear (both segments on the same line)
10418
- * is not handled.
10419
- */
10420
- function doSegmentIntersect(segment1, segment2) {
10421
- const A = segment1.start;
10422
- const B = segment1.end;
10423
- const C = segment2.start;
10424
- const D = segment2.end;
10425
- /**
10426
- * Line segment intersection algorithm
10427
- * https://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/
10428
- */
10429
- function ccw(a, b, c) {
10430
- return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
10350
+ createChart(chartData) {
10351
+ const canvas = this.canvas.el;
10352
+ const ctx = canvas.getContext("2d");
10353
+ const Chart = getChartJSConstructor();
10354
+ this.chart = new Chart(ctx, chartData);
10431
10355
  }
10432
- return ccw(A, C, D) !== ccw(B, C, D) && ccw(A, B, C) !== ccw(A, B, D);
10433
- }
10434
- function doRectanglesIntersect(rect1, rect2) {
10435
- const segments1 = getSegmentsOfRectangle(rect1);
10436
- const segments2 = getSegmentsOfRectangle(rect2);
10437
- for (const segment1 of segments1) {
10438
- for (const segment2 of segments2) {
10439
- if (doSegmentIntersect(segment1, segment2)) {
10440
- return true;
10356
+ updateChartJs(chartData) {
10357
+ if (chartData.data && chartData.data.datasets) {
10358
+ this.chart.data = chartData.data;
10359
+ if (chartData.options?.plugins?.title) {
10360
+ this.chart.config.options.plugins.title = chartData.options.plugins.title;
10441
10361
  }
10442
10362
  }
10363
+ else {
10364
+ this.chart.data.datasets = [];
10365
+ }
10366
+ this.chart.config.options = chartData.options;
10367
+ this.chart.update();
10443
10368
  }
10444
- return false;
10445
- }
10446
- /**
10447
- * Get the rectangle that is tangent to a circle at a given angle.
10448
- *
10449
- * @param angle angle between X axis and the point where the rectangle is tangent to the circle
10450
- */
10451
- function getRectangleTangentToCircle(angle, radius, circleCenterX, circleCenterY, rectWidth, rectHeight) {
10452
- const cos = Math.cos(angle);
10453
- const sin = Math.sin(angle);
10454
- // x, y are the distance from the center of the circle to the point where the rectangle is tangent to the circle
10455
- const x = cos * radius;
10456
- const y = sin * radius;
10457
- // x2, y2 are the distance from the point the rectangle is tangent to the circle to the bottom left corner of the rectangle
10458
- const x2 = sin * (rectWidth / 2); // cos(angle + 90°) = sin(angle)
10459
- const y2 = cos * (rectWidth / 2);
10460
- const bottomRight = {
10461
- x: x + x2 + circleCenterX,
10462
- y: circleCenterY - (y - y2),
10463
- };
10464
- const bottomLeft = {
10465
- x: x - x2 + circleCenterX,
10466
- y: circleCenterY - (y + y2),
10467
- };
10468
- // Same as above but for the top corners of the rectangle (radius + rectangle height instead of radius)
10469
- const xp = cos * (radius + rectHeight);
10470
- const yp = sin * (radius + rectHeight);
10471
- const topLeft = {
10472
- x: xp - x2 + circleCenterX,
10473
- y: circleCenterY - (yp + y2),
10474
- };
10475
- const topRight = {
10476
- x: xp + x2 + circleCenterX,
10477
- y: circleCenterY - (yp - y2),
10478
- };
10479
- return { bottomLeft, bottomRight, topRight, topLeft };
10480
10369
  }
10481
10370
 
10482
10371
  /**
@@ -11058,299 +10947,6 @@ class ScorecardChartConfigBuilder {
11058
10947
  }
11059
10948
  }
11060
10949
 
11061
- const CHART_COMMON_OPTIONS = {
11062
- // https://www.chartjs.org/docs/latest/general/responsive.html
11063
- responsive: true, // will resize when its container is resized
11064
- maintainAspectRatio: false, // doesn't maintain the aspect ratio (width/height =2 by default) so the user has the choice of the exact layout
11065
- elements: {
11066
- line: {
11067
- fill: false, // do not fill the area under line charts
11068
- },
11069
- point: {
11070
- hitRadius: 15, // increased hit radius to display point tooltip when hovering nearby
11071
- },
11072
- },
11073
- animation: false,
11074
- };
11075
- function chartToImage(runtime, figure, type) {
11076
- // wrap the canvas in a div with a fixed size because chart.js would
11077
- // fill the whole page otherwise
11078
- const div = document.createElement("div");
11079
- div.style.width = `${figure.width}px`;
11080
- div.style.height = `${figure.height}px`;
11081
- const canvas = document.createElement("canvas");
11082
- div.append(canvas);
11083
- canvas.setAttribute("width", figure.width.toString());
11084
- canvas.setAttribute("height", figure.height.toString());
11085
- // we have to add the canvas to the DOM otherwise it won't be rendered
11086
- document.body.append(div);
11087
- if ("chartJsConfig" in runtime) {
11088
- const config = deepCopy(runtime.chartJsConfig);
11089
- config.plugins = [backgroundColorChartJSPlugin];
11090
- const Chart = getChartJSConstructor();
11091
- const chart = new Chart(canvas, config);
11092
- const imgContent = chart.toBase64Image();
11093
- chart.destroy();
11094
- div.remove();
11095
- return imgContent;
11096
- }
11097
- else if (type === "scorecard") {
11098
- const design = getScorecardConfiguration(figure, runtime);
11099
- drawScoreChart(design, canvas);
11100
- const imgContent = canvas.toDataURL();
11101
- div.remove();
11102
- return imgContent;
11103
- }
11104
- else if (type === "gauge") {
11105
- drawGaugeChart(canvas, runtime);
11106
- const imgContent = canvas.toDataURL();
11107
- div.remove();
11108
- return imgContent;
11109
- }
11110
- return undefined;
11111
- }
11112
- /**
11113
- * Custom chart.js plugin to set the background color of the canvas
11114
- * https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
11115
- */
11116
- const backgroundColorChartJSPlugin = {
11117
- id: "customCanvasBackgroundColor",
11118
- beforeDraw: (chart) => {
11119
- const { ctx } = chart;
11120
- ctx.save();
11121
- ctx.globalCompositeOperation = "destination-over";
11122
- ctx.fillStyle = "#ffffff";
11123
- ctx.fillRect(0, 0, chart.width, chart.height);
11124
- ctx.restore();
11125
- },
11126
- };
11127
- /** Return window.Chart, making sure all our extensions are loaded in ChartJS */
11128
- function getChartJSConstructor() {
11129
- if (window.Chart && !window.Chart?.registry.plugins.get("chartShowValuesPlugin")) {
11130
- window.Chart.register(chartShowValuesPlugin);
11131
- window.Chart.register(waterfallLinesPlugin);
11132
- }
11133
- return window.Chart;
11134
- }
11135
-
11136
- /**
11137
- * This file is largely inspired by owl 1.
11138
- * `css` tag has been removed from owl 2 without workaround to manage css.
11139
- * So, the solution was to import the behavior of owl 1 directly in our
11140
- * codebase, with one difference: the css is added to the sheet as soon as the
11141
- * css tag is executed. In owl 1, the css was added as soon as a Component was
11142
- * created for the first time.
11143
- */
11144
- const STYLESHEETS = {};
11145
- let nextId = 0;
11146
- /**
11147
- * CSS tag helper for defining inline stylesheets. With this, one can simply define
11148
- * an inline stylesheet with just the following code:
11149
- * ```js
11150
- * css`.component-a { color: red; }`;
11151
- * ```
11152
- */
11153
- function css(strings, ...args) {
11154
- const name = `__sheet__${nextId++}`;
11155
- const value = String.raw(strings, ...args);
11156
- registerSheet(name, value);
11157
- activateSheet(name);
11158
- return name;
11159
- }
11160
- function processSheet(str) {
11161
- const tokens = str.split(/(\{|\}|;)/).map((s) => s.trim());
11162
- const selectorStack = [];
11163
- const parts = [];
11164
- let rules = [];
11165
- function generateSelector(stackIndex, parentSelector) {
11166
- const parts = [];
11167
- for (const selector of selectorStack[stackIndex]) {
11168
- let part = (parentSelector && parentSelector + " " + selector) || selector;
11169
- if (part.includes("&")) {
11170
- part = selector.replace(/&/g, parentSelector || "");
11171
- }
11172
- if (stackIndex < selectorStack.length - 1) {
11173
- part = generateSelector(stackIndex + 1, part);
11174
- }
11175
- parts.push(part);
11176
- }
11177
- return parts.join(", ");
11178
- }
11179
- function generateRules() {
11180
- if (rules.length) {
11181
- parts.push(generateSelector(0) + " {");
11182
- parts.push(...rules);
11183
- parts.push("}");
11184
- rules = [];
11185
- }
11186
- }
11187
- while (tokens.length) {
11188
- let token = tokens.shift();
11189
- if (token === "}") {
11190
- generateRules();
11191
- selectorStack.pop();
11192
- }
11193
- else {
11194
- if (tokens[0] === "{") {
11195
- generateRules();
11196
- selectorStack.push(token.split(/\s*,\s*/));
11197
- tokens.shift();
11198
- }
11199
- if (tokens[0] === ";") {
11200
- rules.push(" " + token + ";");
11201
- }
11202
- }
11203
- }
11204
- return parts.join("\n");
11205
- }
11206
- function registerSheet(id, css) {
11207
- const sheet = document.createElement("style");
11208
- sheet.textContent = processSheet(css);
11209
- STYLESHEETS[id] = sheet;
11210
- }
11211
- function activateSheet(id) {
11212
- const sheet = STYLESHEETS[id];
11213
- sheet.setAttribute("component", id);
11214
- document.head.appendChild(sheet);
11215
- }
11216
- function getTextDecoration({ strikethrough, underline, }) {
11217
- if (!strikethrough && !underline) {
11218
- return "none";
11219
- }
11220
- return `${strikethrough ? "line-through" : ""} ${underline ? "underline" : ""}`;
11221
- }
11222
- /**
11223
- * Convert the cell style to CSS properties.
11224
- */
11225
- function cellStyleToCss(style) {
11226
- const attributes = cellTextStyleToCss(style);
11227
- if (!style)
11228
- return attributes;
11229
- if (style.fillColor) {
11230
- attributes["background"] = style.fillColor;
11231
- }
11232
- return attributes;
11233
- }
11234
- /**
11235
- * Convert the cell text style to CSS properties.
11236
- */
11237
- function cellTextStyleToCss(style) {
11238
- const attributes = {};
11239
- if (!style)
11240
- return attributes;
11241
- if (style.bold) {
11242
- attributes["font-weight"] = "bold";
11243
- }
11244
- if (style.italic) {
11245
- attributes["font-style"] = "italic";
11246
- }
11247
- if (style.strikethrough || style.underline) {
11248
- let decoration = style.strikethrough ? "line-through" : "";
11249
- decoration = style.underline ? decoration + " underline" : decoration;
11250
- attributes["text-decoration"] = decoration;
11251
- }
11252
- if (style.textColor) {
11253
- attributes["color"] = style.textColor;
11254
- }
11255
- return attributes;
11256
- }
11257
- /**
11258
- * Transform CSS properties into a CSS string.
11259
- */
11260
- function cssPropertiesToCss(attributes) {
11261
- let styleStr = "";
11262
- for (const attName in attributes) {
11263
- if (!attributes[attName]) {
11264
- continue;
11265
- }
11266
- styleStr += `${attName}:${attributes[attName]}; `;
11267
- }
11268
- return styleStr;
11269
- }
11270
- function getElementMargins(el) {
11271
- const style = window.getComputedStyle(el);
11272
- return {
11273
- top: parseInt(style.marginTop, 10) || 0,
11274
- bottom: parseInt(style.marginBottom, 10) || 0,
11275
- left: parseInt(style.marginLeft, 10) || 0,
11276
- right: parseInt(style.marginRight, 10) || 0,
11277
- };
11278
- }
11279
-
11280
- css /* scss */ `
11281
- .o-spreadsheet {
11282
- .o-chart-custom-tooltip {
11283
- font-size: 12px;
11284
- background-color: #fff;
11285
- z-index: ${ComponentsImportance.FigureTooltip};
11286
- }
11287
- }
11288
- `;
11289
- class ChartJsComponent extends Component {
11290
- static template = "o-spreadsheet-ChartJsComponent";
11291
- static props = {
11292
- figure: Object,
11293
- };
11294
- canvas = useRef("graphContainer");
11295
- chart;
11296
- currentRuntime;
11297
- get background() {
11298
- return this.chartRuntime.background;
11299
- }
11300
- get canvasStyle() {
11301
- return `background-color: ${this.background}`;
11302
- }
11303
- get chartRuntime() {
11304
- const runtime = this.env.model.getters.getChartRuntime(this.props.figure.id);
11305
- if (!("chartJsConfig" in runtime)) {
11306
- throw new Error("Unsupported chart runtime");
11307
- }
11308
- return runtime;
11309
- }
11310
- setup() {
11311
- onMounted(() => {
11312
- const runtime = this.chartRuntime;
11313
- this.currentRuntime = runtime;
11314
- // Note: chartJS modify the runtime in place, so it's important to give it a copy
11315
- this.createChart(deepCopy(runtime.chartJsConfig));
11316
- });
11317
- onWillUnmount(() => this.chart?.destroy());
11318
- useEffect(() => {
11319
- const runtime = this.chartRuntime;
11320
- if (runtime !== this.currentRuntime) {
11321
- if (runtime.chartJsConfig.type !== this.currentRuntime.chartJsConfig.type) {
11322
- this.chart?.destroy();
11323
- this.createChart(deepCopy(runtime.chartJsConfig));
11324
- }
11325
- else {
11326
- this.updateChartJs(deepCopy(runtime));
11327
- }
11328
- this.currentRuntime = runtime;
11329
- }
11330
- });
11331
- }
11332
- createChart(chartData) {
11333
- const canvas = this.canvas.el;
11334
- const ctx = canvas.getContext("2d");
11335
- const Chart = getChartJSConstructor();
11336
- this.chart = new Chart(ctx, chartData);
11337
- }
11338
- updateChartJs(chartRuntime) {
11339
- const chartData = chartRuntime.chartJsConfig;
11340
- if (chartData.data && chartData.data.datasets) {
11341
- this.chart.data = chartData.data;
11342
- if (chartData.options?.plugins?.title) {
11343
- this.chart.config.options.plugins.title = chartData.options.plugins.title;
11344
- }
11345
- }
11346
- else {
11347
- this.chart.data.datasets = [];
11348
- }
11349
- this.chart.config.options = chartData.options;
11350
- this.chart.update();
11351
- }
11352
- }
11353
-
11354
10950
  class ScorecardChart extends Component {
11355
10951
  static template = "o-spreadsheet-ScorecardChart";
11356
10952
  static props = {
@@ -20544,11 +20140,26 @@ const SEARCH = {
20544
20140
  const _searchFor = toString(searchFor).toLowerCase();
20545
20141
  const _textToSearch = toString(textToSearch).toLowerCase();
20546
20142
  const _startingAt = toNumber(startingAt, this.locale);
20547
- assert(() => _textToSearch !== "", _t("The text_to_search must be non-empty."));
20548
- assert(() => _startingAt >= 1, _t("The starting_at (%s) must be greater than or equal to 1.", _startingAt.toString()));
20143
+ if (_textToSearch === "") {
20144
+ return {
20145
+ value: CellErrorType.GenericError,
20146
+ message: _t("The text_to_search must be non-empty."),
20147
+ };
20148
+ }
20149
+ if (_startingAt < 1) {
20150
+ return {
20151
+ value: CellErrorType.GenericError,
20152
+ message: _t("The starting_at (%s) must be greater than or equal to 1.", _startingAt),
20153
+ };
20154
+ }
20549
20155
  const result = _textToSearch.indexOf(_searchFor, _startingAt - 1);
20550
- assert(() => result >= 0, _t("In [[FUNCTION_NAME]] evaluation, cannot find '%s' within '%s'.", _searchFor, _textToSearch));
20551
- return result + 1;
20156
+ if (result === -1) {
20157
+ return {
20158
+ value: CellErrorType.GenericError,
20159
+ message: _t("In [[FUNCTION_NAME]] evaluation, cannot find '%s' within '%s'.", _searchFor, _textToSearch),
20160
+ };
20161
+ }
20162
+ return { value: result + 1 };
20552
20163
  },
20553
20164
  isExported: true,
20554
20165
  };
@@ -21883,11 +21494,14 @@ function compileTokens(tokens) {
21883
21494
  }
21884
21495
  }
21885
21496
  function compileTokensOrThrow(tokens) {
21886
- const { dependencies, constantValues, symbols } = formulaArguments(tokens);
21887
- const cacheKey = compilationCacheKey(tokens, dependencies, constantValues);
21497
+ const { dependencies, literalValues, symbols } = formulaArguments(tokens);
21498
+ const cacheKey = compilationCacheKey(tokens);
21888
21499
  if (!functionCache[cacheKey]) {
21889
21500
  const ast = parseTokens([...tokens]);
21890
21501
  const scope = new Scope();
21502
+ let stringCount = 0;
21503
+ let numberCount = 0;
21504
+ let dependencyCount = 0;
21891
21505
  if (ast.type === "BIN_OPERATION" && ast.value === ":") {
21892
21506
  throw new BadExpressionError(_t("Invalid formula"));
21893
21507
  }
@@ -21961,16 +21575,15 @@ function compileTokensOrThrow(tokens) {
21961
21575
  case "BOOLEAN":
21962
21576
  return code.return(`{ value: ${ast.value} }`);
21963
21577
  case "NUMBER":
21964
- return code.return(`{ value: this.constantValues.numbers[${constantValues.numbers.indexOf(ast.value)}] }`);
21578
+ return code.return(`this.literalValues.numbers[${numberCount++}]`);
21965
21579
  case "STRING":
21966
- return code.return(`{ value: this.constantValues.strings[${constantValues.strings.indexOf(ast.value)}] }`);
21580
+ return code.return(`this.literalValues.strings[${stringCount++}]`);
21967
21581
  case "REFERENCE":
21968
- const referenceIndex = dependencies.indexOf(ast.value);
21969
21582
  if ((!isMeta && ast.value.includes(":")) || hasRange) {
21970
- return code.return(`range(deps[${referenceIndex}])`);
21583
+ return code.return(`range(deps[${dependencyCount++}])`);
21971
21584
  }
21972
21585
  else {
21973
- return code.return(`ref(deps[${referenceIndex}], ${isMeta ? "true" : "false"})`);
21586
+ return code.return(`ref(deps[${dependencyCount++}], ${isMeta ? "true" : "false"})`);
21974
21587
  }
21975
21588
  case "FUNCALL":
21976
21589
  const args = compileFunctionArgs(ast).map((arg) => arg.assignResultToVariable());
@@ -22002,7 +21615,7 @@ function compileTokensOrThrow(tokens) {
22002
21615
  const compiledFormula = {
22003
21616
  execute: functionCache[cacheKey],
22004
21617
  dependencies,
22005
- constantValues,
21618
+ literalValues,
22006
21619
  symbols,
22007
21620
  tokens,
22008
21621
  isBadExpression: false,
@@ -22015,33 +21628,31 @@ function compileTokensOrThrow(tokens) {
22015
21628
  * References, numbers and strings are replaced with placeholders because
22016
21629
  * the compiled formula does not depend on their actual value.
22017
21630
  * Both `=A1+1+"2"` and `=A2+2+"3"` are compiled to the exact same function.
22018
- *
22019
21631
  * Spaces are also ignored to compute the cache key.
22020
21632
  *
22021
- * A formula `=A1+A2+SUM(2, 2, "2")` have the cache key `=|0|+|1|+SUM(|N0|,|N0|,|S0|)`
21633
+ * A formula `=A1+A2+SUM(2, 2, "2")` have the cache key `=|C|+|C|+SUM(|N|,|N|,|S|)`
22022
21634
  */
22023
- function compilationCacheKey(tokens, dependencies, constantValues, symbols) {
21635
+ function compilationCacheKey(tokens) {
22024
21636
  let cacheKey = "";
22025
21637
  for (const token of tokens) {
22026
21638
  switch (token.type) {
22027
21639
  case "STRING":
22028
- const value = removeStringQuotes(token.value);
22029
- cacheKey += `|S${constantValues.strings.indexOf(value)}|`;
21640
+ cacheKey += "|S|";
22030
21641
  break;
22031
21642
  case "NUMBER":
22032
- cacheKey += `|N${constantValues.numbers.indexOf(parseNumber(token.value, DEFAULT_LOCALE))}|`;
21643
+ cacheKey += "|N|";
22033
21644
  break;
22034
21645
  case "REFERENCE":
22035
21646
  case "INVALID_REFERENCE":
22036
21647
  if (token.value.includes(":")) {
22037
- cacheKey += `R|${dependencies.indexOf(token.value)}|`;
21648
+ cacheKey += "|R|";
22038
21649
  }
22039
21650
  else {
22040
- cacheKey += `C|${dependencies.indexOf(token.value)}|`;
21651
+ cacheKey += "|C|";
22041
21652
  }
22042
21653
  break;
22043
21654
  case "SPACE":
22044
- cacheKey += "";
21655
+ // ignore spaces
22045
21656
  break;
22046
21657
  default:
22047
21658
  cacheKey += token.value;
@@ -22054,7 +21665,7 @@ function compilationCacheKey(tokens, dependencies, constantValues, symbols) {
22054
21665
  * Return formula arguments which are references, strings and numbers.
22055
21666
  */
22056
21667
  function formulaArguments(tokens) {
22057
- const constantValues = {
21668
+ const literalValues = {
22058
21669
  numbers: [],
22059
21670
  strings: [],
22060
21671
  };
@@ -22068,15 +21679,11 @@ function formulaArguments(tokens) {
22068
21679
  break;
22069
21680
  case "STRING":
22070
21681
  const value = removeStringQuotes(token.value);
22071
- if (!constantValues.strings.includes(value)) {
22072
- constantValues.strings.push(value);
22073
- }
21682
+ literalValues.strings.push({ value });
22074
21683
  break;
22075
21684
  case "NUMBER": {
22076
21685
  const value = parseNumber(token.value, DEFAULT_LOCALE);
22077
- if (!constantValues.numbers.includes(value)) {
22078
- constantValues.numbers.push(value);
22079
- }
21686
+ literalValues.numbers.push({ value });
22080
21687
  break;
22081
21688
  }
22082
21689
  case "SYMBOL": {
@@ -22087,7 +21694,7 @@ function formulaArguments(tokens) {
22087
21694
  }
22088
21695
  return {
22089
21696
  dependencies,
22090
- constantValues,
21697
+ literalValues,
22091
21698
  symbols,
22092
21699
  };
22093
21700
  }
@@ -22937,6 +22544,343 @@ function getDateIntervals(dates) {
22937
22544
 
22938
22545
  const cellPopoverRegistry = new Registry();
22939
22546
 
22547
+ const GAUGE_PADDING_SIDE = 30;
22548
+ const GAUGE_PADDING_TOP = 10;
22549
+ const GAUGE_PADDING_BOTTOM = 20;
22550
+ const GAUGE_LABELS_FONT_SIZE = 12;
22551
+ const GAUGE_DEFAULT_VALUE_FONT_SIZE = 80;
22552
+ const GAUGE_BACKGROUND_COLOR = "#F3F2F1";
22553
+ const GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN = 6;
22554
+ const GAUGE_TITLE_SECTION_HEIGHT = 25;
22555
+ function drawGaugeChart(canvas, runtime) {
22556
+ const canvasBoundingRect = canvas.getBoundingClientRect();
22557
+ canvas.width = canvasBoundingRect.width;
22558
+ canvas.height = canvasBoundingRect.height;
22559
+ const ctx = canvas.getContext("2d");
22560
+ const config = getGaugeRenderingConfig(canvasBoundingRect, runtime, ctx);
22561
+ drawBackground(ctx, config);
22562
+ drawGauge(ctx, config);
22563
+ drawInflectionValues(ctx, config);
22564
+ drawLabels(ctx, config);
22565
+ drawTitle(ctx, config);
22566
+ }
22567
+ function drawGauge(ctx, config) {
22568
+ ctx.save();
22569
+ const gauge = config.gauge;
22570
+ const arcCenterX = gauge.rect.x + gauge.rect.width / 2;
22571
+ const arcCenterY = gauge.rect.y + gauge.rect.height;
22572
+ const arcRadius = gauge.rect.height - gauge.arcWidth / 2;
22573
+ if (arcRadius < 0) {
22574
+ return;
22575
+ }
22576
+ const gaugeAngle = gauge.percentage === 1 ? 0 : Math.PI * (1 + gauge.percentage);
22577
+ // Gauge background
22578
+ ctx.strokeStyle = GAUGE_BACKGROUND_COLOR;
22579
+ ctx.beginPath();
22580
+ ctx.lineWidth = gauge.arcWidth;
22581
+ ctx.arc(arcCenterX, arcCenterY, arcRadius, gaugeAngle, 0);
22582
+ ctx.stroke();
22583
+ // Gauge value
22584
+ ctx.strokeStyle = gauge.color;
22585
+ ctx.beginPath();
22586
+ ctx.arc(arcCenterX, arcCenterY, arcRadius, Math.PI, gaugeAngle);
22587
+ ctx.stroke();
22588
+ ctx.restore();
22589
+ }
22590
+ function drawBackground(ctx, config) {
22591
+ ctx.save();
22592
+ ctx.fillStyle = config.backgroundColor;
22593
+ ctx.fillRect(0, 0, config.width, config.height);
22594
+ ctx.restore();
22595
+ }
22596
+ function drawLabels(ctx, config) {
22597
+ for (const label of [config.minLabel, config.maxLabel, config.gaugeValue]) {
22598
+ ctx.save();
22599
+ ctx.textAlign = "center";
22600
+ ctx.fillStyle = label.color;
22601
+ ctx.font = `${label.fontSize}px ${DEFAULT_FONT}`;
22602
+ ctx.fillText(label.label, label.textPosition.x, label.textPosition.y);
22603
+ ctx.restore();
22604
+ }
22605
+ }
22606
+ function drawInflectionValues(ctx, config) {
22607
+ const { x: rectX, y: rectY, width, height } = config.gauge.rect;
22608
+ for (const inflectionValue of config.inflectionValues) {
22609
+ ctx.save();
22610
+ ctx.translate(rectX + width / 2 - 0.5, rectY + height - 0.5); // -0.5 for sharper lines. see RendererPlugin.drawBorders comment
22611
+ ctx.rotate(Math.PI / 2 - inflectionValue.rotation);
22612
+ ctx.lineWidth = 2;
22613
+ ctx.strokeStyle = chartMutedFontColor(config.backgroundColor) + "aa";
22614
+ ctx.beginPath();
22615
+ ctx.moveTo(0, -(height - config.gauge.arcWidth));
22616
+ ctx.lineTo(0, -height - 3);
22617
+ ctx.stroke();
22618
+ ctx.textAlign = "center";
22619
+ ctx.font = `${inflectionValue.fontSize}px ${DEFAULT_FONT}`;
22620
+ ctx.fillStyle = inflectionValue.color;
22621
+ const textY = -height - GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN - inflectionValue.offset;
22622
+ ctx.fillText(inflectionValue.label, 0, textY);
22623
+ ctx.restore();
22624
+ }
22625
+ }
22626
+ function drawTitle(ctx, config) {
22627
+ ctx.save();
22628
+ const title = config.title;
22629
+ ctx.font = getDefaultContextFont(title.fontSize, title.bold, title.italic);
22630
+ ctx.textBaseline = "middle";
22631
+ ctx.fillStyle = title.color;
22632
+ ctx.fillText(title.label, title.textPosition.x, title.textPosition.y);
22633
+ ctx.restore();
22634
+ }
22635
+ function getGaugeRenderingConfig(boundingRect, runtime, ctx) {
22636
+ const maxValue = runtime.maxValue;
22637
+ const minValue = runtime.minValue;
22638
+ const gaugeValue = runtime.gaugeValue;
22639
+ const gaugeRect = getGaugeRect(boundingRect, runtime.title.text);
22640
+ const gaugeArcWidth = gaugeRect.width / 6;
22641
+ const gaugePercentage = gaugeValue
22642
+ ? (gaugeValue.value - minValue.value) / (maxValue.value - minValue.value)
22643
+ : 0;
22644
+ const gaugeValuePosition = {
22645
+ x: boundingRect.width / 2,
22646
+ y: gaugeRect.y + gaugeRect.height - gaugeRect.height / 12,
22647
+ };
22648
+ let gaugeValueFontSize = GAUGE_DEFAULT_VALUE_FONT_SIZE;
22649
+ // Scale down the font size if the gaugeRect is too small
22650
+ if (gaugeRect.height < 300) {
22651
+ gaugeValueFontSize = gaugeValueFontSize * (gaugeRect.height / 300);
22652
+ }
22653
+ // Scale down the font size if the text is too long
22654
+ const maxTextWidth = gaugeRect.width / 2;
22655
+ const gaugeLabel = gaugeValue?.label || "-";
22656
+ if (computeTextWidth(ctx, gaugeLabel, { fontSize: gaugeValueFontSize }, "px") > maxTextWidth) {
22657
+ gaugeValueFontSize = getFontSizeMatchingWidth(maxTextWidth, gaugeValueFontSize, (fontSize) => computeTextWidth(ctx, gaugeLabel, { fontSize }, "px"));
22658
+ }
22659
+ const minLabelPosition = {
22660
+ x: gaugeRect.x + gaugeArcWidth / 2,
22661
+ y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
22662
+ };
22663
+ const maxLabelPosition = {
22664
+ x: gaugeRect.x + gaugeRect.width - gaugeArcWidth / 2,
22665
+ y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
22666
+ };
22667
+ const textColor = chartMutedFontColor(runtime.background);
22668
+ const inflectionValues = getInflectionValues(runtime, gaugeRect, textColor, ctx);
22669
+ let x = 0, titleWidth = 0, titleHeight = 0;
22670
+ if (runtime.title.text) {
22671
+ ({ width: titleWidth, height: titleHeight } = computeTextDimension(ctx, runtime.title.text, { fontSize: CHART_TITLE_FONT_SIZE, ...runtime.title }, "px"));
22672
+ }
22673
+ switch (runtime.title.align) {
22674
+ case "right":
22675
+ x = boundingRect.width - titleWidth - CHART_PADDING$1;
22676
+ break;
22677
+ case "center":
22678
+ x = (boundingRect.width - titleWidth) / 2;
22679
+ break;
22680
+ case "left":
22681
+ default:
22682
+ x = CHART_PADDING$1;
22683
+ break;
22684
+ }
22685
+ return {
22686
+ width: boundingRect.width,
22687
+ height: boundingRect.height,
22688
+ title: {
22689
+ label: runtime.title.text ?? "",
22690
+ fontSize: runtime.title.fontSize ?? CHART_TITLE_FONT_SIZE,
22691
+ textPosition: {
22692
+ x,
22693
+ y: CHART_PADDING_TOP + titleHeight / 2,
22694
+ },
22695
+ color: runtime.title.color ?? textColor,
22696
+ bold: runtime.title.bold,
22697
+ italic: runtime.title.italic,
22698
+ },
22699
+ backgroundColor: runtime.background,
22700
+ gauge: {
22701
+ rect: gaugeRect,
22702
+ arcWidth: gaugeArcWidth,
22703
+ percentage: clip(gaugePercentage, 0, 1),
22704
+ color: getGaugeColor(runtime),
22705
+ },
22706
+ inflectionValues,
22707
+ gaugeValue: {
22708
+ label: gaugeLabel,
22709
+ textPosition: gaugeValuePosition,
22710
+ fontSize: gaugeValueFontSize,
22711
+ color: textColor,
22712
+ },
22713
+ minLabel: {
22714
+ label: runtime.minValue.label,
22715
+ textPosition: minLabelPosition,
22716
+ fontSize: GAUGE_LABELS_FONT_SIZE,
22717
+ color: textColor,
22718
+ },
22719
+ maxLabel: {
22720
+ label: runtime.maxValue.label,
22721
+ textPosition: maxLabelPosition,
22722
+ fontSize: GAUGE_LABELS_FONT_SIZE,
22723
+ color: textColor,
22724
+ },
22725
+ };
22726
+ }
22727
+ /**
22728
+ * Get the rectangle in which the gauge will be drawn, based on the bounding rectangle of the canvas and leaving
22729
+ * space for the title and labels.
22730
+ */
22731
+ function getGaugeRect(boundingRect, title) {
22732
+ const titleHeight = title ? GAUGE_TITLE_SECTION_HEIGHT : 0;
22733
+ const drawHeight = boundingRect.height - GAUGE_PADDING_BOTTOM - titleHeight - GAUGE_PADDING_TOP;
22734
+ const drawWidth = boundingRect.width - GAUGE_PADDING_SIDE * 2;
22735
+ let gaugeWidth;
22736
+ let gaugeHeight;
22737
+ if (drawWidth > 2 * drawHeight) {
22738
+ gaugeWidth = 2 * drawHeight;
22739
+ gaugeHeight = drawHeight;
22740
+ }
22741
+ else {
22742
+ gaugeWidth = drawWidth;
22743
+ gaugeHeight = drawWidth / 2;
22744
+ }
22745
+ const gaugeX = GAUGE_PADDING_SIDE + (drawWidth - gaugeWidth) / 2;
22746
+ const gaugeY = titleHeight + GAUGE_PADDING_TOP + (drawHeight - gaugeHeight) / 2;
22747
+ return {
22748
+ x: gaugeX,
22749
+ y: gaugeY,
22750
+ width: gaugeWidth,
22751
+ height: gaugeHeight,
22752
+ };
22753
+ }
22754
+ /**
22755
+ * 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).
22756
+ *
22757
+ * Also compute an offset for the text so that it doesn't overlap with other text.
22758
+ */
22759
+ function getInflectionValues(runtime, gaugeRect, textColor, ctx) {
22760
+ const maxValue = runtime.maxValue;
22761
+ const minValue = runtime.minValue;
22762
+ const gaugeCircleCenter = {
22763
+ x: gaugeRect.x + gaugeRect.width / 2,
22764
+ y: gaugeRect.y + gaugeRect.height,
22765
+ };
22766
+ const textStyle = { fontSize: GAUGE_LABELS_FONT_SIZE };
22767
+ const inflectionValues = [];
22768
+ const inflectionValuesTextRects = [];
22769
+ for (const inflectionValue of runtime.inflectionValues) {
22770
+ const percentage = (inflectionValue.value - minValue.value) / (maxValue.value - minValue.value);
22771
+ const labelWidth = computeTextWidth(ctx, inflectionValue.label, textStyle, "px");
22772
+ const angle = Math.PI - Math.PI * percentage;
22773
+ const textRect = getRectangleTangentToCircle(angle, // angle between X axis and the point where the rectangle is tangent to the circle
22774
+ gaugeRect.height + GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN, // radius of the gauge circle + margin below text
22775
+ gaugeCircleCenter.x, // center of the gauge circle
22776
+ gaugeCircleCenter.y, // center of the gauge circle
22777
+ labelWidth + 2, // width of the text + some margin
22778
+ GAUGE_LABELS_FONT_SIZE // height of the text
22779
+ );
22780
+ let offset = inflectionValuesTextRects.some((rect) => doRectanglesIntersect(rect, textRect))
22781
+ ? GAUGE_LABELS_FONT_SIZE
22782
+ : 0;
22783
+ inflectionValuesTextRects.push(textRect);
22784
+ inflectionValues.push({
22785
+ rotation: angle,
22786
+ label: inflectionValue.label,
22787
+ fontSize: GAUGE_LABELS_FONT_SIZE,
22788
+ color: textColor,
22789
+ offset,
22790
+ });
22791
+ }
22792
+ return inflectionValues;
22793
+ }
22794
+ function getGaugeColor(runtime) {
22795
+ const gaugeValue = runtime.gaugeValue?.value;
22796
+ if (gaugeValue === undefined) {
22797
+ return GAUGE_BACKGROUND_COLOR;
22798
+ }
22799
+ for (let i = 0; i < runtime.inflectionValues.length; i++) {
22800
+ const inflectionValue = runtime.inflectionValues[i];
22801
+ if (inflectionValue.operator === "<" && gaugeValue < inflectionValue.value) {
22802
+ return runtime.colors[i];
22803
+ }
22804
+ else if (inflectionValue.operator === "<=" && gaugeValue <= inflectionValue.value) {
22805
+ return runtime.colors[i];
22806
+ }
22807
+ }
22808
+ return runtime.colors.at(-1);
22809
+ }
22810
+ function getSegmentsOfRectangle(rectangle) {
22811
+ return [
22812
+ { start: rectangle.topLeft, end: rectangle.topRight },
22813
+ { start: rectangle.topRight, end: rectangle.bottomRight },
22814
+ { start: rectangle.bottomRight, end: rectangle.bottomLeft },
22815
+ { start: rectangle.bottomLeft, end: rectangle.topLeft },
22816
+ ];
22817
+ }
22818
+ /**
22819
+ * Check if two segment intersect. The case where the segments are colinear (both segments on the same line)
22820
+ * is not handled.
22821
+ */
22822
+ function doSegmentIntersect(segment1, segment2) {
22823
+ const A = segment1.start;
22824
+ const B = segment1.end;
22825
+ const C = segment2.start;
22826
+ const D = segment2.end;
22827
+ /**
22828
+ * Line segment intersection algorithm
22829
+ * https://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/
22830
+ */
22831
+ function ccw(a, b, c) {
22832
+ return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
22833
+ }
22834
+ return ccw(A, C, D) !== ccw(B, C, D) && ccw(A, B, C) !== ccw(A, B, D);
22835
+ }
22836
+ function doRectanglesIntersect(rect1, rect2) {
22837
+ const segments1 = getSegmentsOfRectangle(rect1);
22838
+ const segments2 = getSegmentsOfRectangle(rect2);
22839
+ for (const segment1 of segments1) {
22840
+ for (const segment2 of segments2) {
22841
+ if (doSegmentIntersect(segment1, segment2)) {
22842
+ return true;
22843
+ }
22844
+ }
22845
+ }
22846
+ return false;
22847
+ }
22848
+ /**
22849
+ * Get the rectangle that is tangent to a circle at a given angle.
22850
+ *
22851
+ * @param angle angle between X axis and the point where the rectangle is tangent to the circle
22852
+ */
22853
+ function getRectangleTangentToCircle(angle, radius, circleCenterX, circleCenterY, rectWidth, rectHeight) {
22854
+ const cos = Math.cos(angle);
22855
+ const sin = Math.sin(angle);
22856
+ // x, y are the distance from the center of the circle to the point where the rectangle is tangent to the circle
22857
+ const x = cos * radius;
22858
+ const y = sin * radius;
22859
+ // x2, y2 are the distance from the point the rectangle is tangent to the circle to the bottom left corner of the rectangle
22860
+ const x2 = sin * (rectWidth / 2); // cos(angle + 90°) = sin(angle)
22861
+ const y2 = cos * (rectWidth / 2);
22862
+ const bottomRight = {
22863
+ x: x + x2 + circleCenterX,
22864
+ y: circleCenterY - (y - y2),
22865
+ };
22866
+ const bottomLeft = {
22867
+ x: x - x2 + circleCenterX,
22868
+ y: circleCenterY - (y + y2),
22869
+ };
22870
+ // Same as above but for the top corners of the rectangle (radius + rectangle height instead of radius)
22871
+ const xp = cos * (radius + rectHeight);
22872
+ const yp = sin * (radius + rectHeight);
22873
+ const topLeft = {
22874
+ x: xp - x2 + circleCenterX,
22875
+ y: circleCenterY - (yp + y2),
22876
+ };
22877
+ const topRight = {
22878
+ x: xp + x2 + circleCenterX,
22879
+ y: circleCenterY - (yp - y2),
22880
+ };
22881
+ return { bottomLeft, bottomRight, topRight, topLeft };
22882
+ }
22883
+
22940
22884
  class GaugeChartComponent extends Component {
22941
22885
  static template = "o-spreadsheet-GaugeChartComponent";
22942
22886
  canvas = useRef("chartContainer");
@@ -22969,6 +22913,73 @@ function toXlsxHexColor(color) {
22969
22913
  return color;
22970
22914
  }
22971
22915
 
22916
+ const CHART_COMMON_OPTIONS = {
22917
+ // https://www.chartjs.org/docs/latest/general/responsive.html
22918
+ responsive: true, // will resize when its container is resized
22919
+ maintainAspectRatio: false, // doesn't maintain the aspect ratio (width/height =2 by default) so the user has the choice of the exact layout
22920
+ elements: {
22921
+ line: {
22922
+ fill: false, // do not fill the area under line charts
22923
+ },
22924
+ point: {
22925
+ hitRadius: 15, // increased hit radius to display point tooltip when hovering nearby
22926
+ },
22927
+ },
22928
+ animation: false,
22929
+ };
22930
+ function chartToImage(runtime, figure, type) {
22931
+ // wrap the canvas in a div with a fixed size because chart.js would
22932
+ // fill the whole page otherwise
22933
+ const div = document.createElement("div");
22934
+ div.style.width = `${figure.width}px`;
22935
+ div.style.height = `${figure.height}px`;
22936
+ const canvas = document.createElement("canvas");
22937
+ div.append(canvas);
22938
+ canvas.setAttribute("width", figure.width.toString());
22939
+ canvas.setAttribute("height", figure.height.toString());
22940
+ // we have to add the canvas to the DOM otherwise it won't be rendered
22941
+ document.body.append(div);
22942
+ if ("chartJsConfig" in runtime) {
22943
+ const config = deepCopy(runtime.chartJsConfig);
22944
+ config.plugins = [backgroundColorChartJSPlugin];
22945
+ const Chart = getChartJSConstructor();
22946
+ const chart = new Chart(canvas, config);
22947
+ const imgContent = chart.toBase64Image();
22948
+ chart.destroy();
22949
+ div.remove();
22950
+ return imgContent;
22951
+ }
22952
+ else if (type === "scorecard") {
22953
+ const design = getScorecardConfiguration(figure, runtime);
22954
+ drawScoreChart(design, canvas);
22955
+ const imgContent = canvas.toDataURL();
22956
+ div.remove();
22957
+ return imgContent;
22958
+ }
22959
+ else if (type === "gauge") {
22960
+ drawGaugeChart(canvas, runtime);
22961
+ const imgContent = canvas.toDataURL();
22962
+ div.remove();
22963
+ return imgContent;
22964
+ }
22965
+ return undefined;
22966
+ }
22967
+ /**
22968
+ * Custom chart.js plugin to set the background color of the canvas
22969
+ * https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
22970
+ */
22971
+ const backgroundColorChartJSPlugin = {
22972
+ id: "customCanvasBackgroundColor",
22973
+ beforeDraw: (chart) => {
22974
+ const { ctx } = chart;
22975
+ ctx.save();
22976
+ ctx.globalCompositeOperation = "destination-over";
22977
+ ctx.fillStyle = "#ffffff";
22978
+ ctx.fillRect(0, 0, chart.width, chart.height);
22979
+ ctx.restore();
22980
+ },
22981
+ };
22982
+
22972
22983
  /**
22973
22984
  * Represent a raw XML string
22974
22985
  */
@@ -23008,9 +23019,9 @@ const XLSX_CHART_TYPES = [
23008
23019
  /** In XLSX color format (no #) */
23009
23020
  const AUTO_COLOR = "000000";
23010
23021
  const XLSX_ICONSET_MAP = {
23011
- arrow: "3Arrows",
23022
+ arrows: "3Arrows",
23012
23023
  smiley: "3Symbols",
23013
- dot: "3TrafficLights1",
23024
+ dots: "3TrafficLights1",
23014
23025
  };
23015
23026
  const NAMESPACE = {
23016
23027
  styleSheet: "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
@@ -23541,6 +23552,7 @@ const ICON_SET_CONVERSION_MAP = {
23541
23552
  };
23542
23553
  /** Map between legend position in XLSX file and human readable position */
23543
23554
  const DRAWING_LEGEND_POSITION_CONVERSION_MAP = {
23555
+ none: "none",
23544
23556
  b: "bottom",
23545
23557
  t: "top",
23546
23558
  l: "left",
@@ -26303,7 +26315,7 @@ class XlsxChartExtractor extends XlsxBaseExtractor {
26303
26315
  default: "ffffff",
26304
26316
  }).asString(),
26305
26317
  legendPosition: DRAWING_LEGEND_POSITION_CONVERSION_MAP[this.extractChildAttr(rootChartElement, "c:legendPos", "val", {
26306
- default: "b",
26318
+ default: "none",
26307
26319
  }).asString()],
26308
26320
  stacked: barChartGrouping === "stacked",
26309
26321
  fontColor: "000000",
@@ -26337,7 +26349,7 @@ class XlsxChartExtractor extends XlsxBaseExtractor {
26337
26349
  default: "ffffff",
26338
26350
  }).asString(),
26339
26351
  legendPosition: DRAWING_LEGEND_POSITION_CONVERSION_MAP[this.extractChildAttr(chartElement, "c:legendPos", "val", {
26340
- default: "b",
26352
+ default: "none",
26341
26353
  }).asString()],
26342
26354
  stacked: barChartGrouping === "stacked",
26343
26355
  fontColor: "000000",
@@ -28949,7 +28961,8 @@ function getChartLabelValues(getters, dataSets, labelRange) {
28949
28961
  }
28950
28962
  }
28951
28963
  else if (dataSets.length === 1) {
28952
- for (let i = 0; i < getData(getters, dataSets[0]).length; i++) {
28964
+ const dataLength = getData(getters, dataSets[0]).length;
28965
+ for (let i = 0; i < dataLength; i++) {
28953
28966
  labels.formattedValues.push("");
28954
28967
  labels.values.push("");
28955
28968
  }
@@ -29132,7 +29145,7 @@ function getLineChartDatasets(definition, args) {
29132
29145
  function getScatterChartDatasets(definition, args) {
29133
29146
  const dataSets = getLineChartDatasets(definition, args);
29134
29147
  for (const dataSet of dataSets) {
29135
- if (dataSet.xAxisID !== TREND_LINE_XAXIS_ID) {
29148
+ if (!isTrendLineAxis(dataSet.xAxisID)) {
29136
29149
  dataSet.showLine = false;
29137
29150
  }
29138
29151
  }
@@ -29259,7 +29272,9 @@ function getTrendingLineDataSet(dataset, config, data) {
29259
29272
  const borderColor = config.color || lightenColor(rgbaToHex(defaultBorderColor), 0.5);
29260
29273
  return {
29261
29274
  type: "line",
29262
- xAxisID: TREND_LINE_XAXIS_ID,
29275
+ xAxisID: config.type === "trailingMovingAverage"
29276
+ ? MOVING_AVERAGE_TREND_LINE_XAXIS_ID
29277
+ : TREND_LINE_XAXIS_ID,
29263
29278
  yAxisID: dataset.yAxisID,
29264
29279
  label: dataset.label ? _t("Trend line for %s", dataset.label) : "",
29265
29280
  data,
@@ -29334,22 +29349,19 @@ function getPieChartLegend(definition, args) {
29334
29349
  const { dataSetsValues } = args;
29335
29350
  const dataSetsLength = Math.max(0, ...dataSetsValues.map((ds) => ds?.data?.length ?? 0));
29336
29351
  const colors = getPieColors(new ColorGenerator(dataSetsLength), dataSetsValues);
29352
+ const fontColor = chartFontColor(definition.background);
29337
29353
  return {
29338
29354
  ...getLegendDisplayOptions(definition),
29339
29355
  labels: {
29340
- color: chartFontColor(definition.background),
29341
29356
  usePointStyle: true,
29342
- //@ts-ignore
29343
- generateLabels: (c) =>
29344
- //@ts-ignore
29345
- c.data.labels.map((label, index) => ({
29357
+ generateLabels: (c) => c.data.labels?.map((label, index) => ({
29346
29358
  text: truncateLabel(String(label)),
29347
29359
  strokeStyle: colors[index],
29348
29360
  fillStyle: colors[index],
29349
29361
  pointStyle: "rect",
29350
- hidden: false,
29351
29362
  lineWidth: 2,
29352
- })),
29363
+ fontColor,
29364
+ })) || [],
29353
29365
  filter: (legendItem, data) => {
29354
29366
  return "datasetIndex" in legendItem
29355
29367
  ? !data.datasets[legendItem.datasetIndex].hidden
@@ -29482,7 +29494,7 @@ function getCustomLegendLabels(fontColor, legendLabelConfig) {
29482
29494
  color: fontColor,
29483
29495
  usePointStyle: true,
29484
29496
  generateLabels: (chart) => chart.data.datasets.map((dataset, index) => {
29485
- if (dataset["xAxisID"] === TREND_LINE_XAXIS_ID) {
29497
+ if (isTrendLineAxis(dataset["xAxisID"])) {
29486
29498
  return {
29487
29499
  text: truncateLabel(dataset.label),
29488
29500
  fontColor,
@@ -29540,6 +29552,11 @@ function getBarChartScales(definition, args) {
29540
29552
  offset: false,
29541
29553
  display: false,
29542
29554
  };
29555
+ scales[MOVING_AVERAGE_TREND_LINE_XAXIS_ID] = {
29556
+ ...scales.x,
29557
+ offset: false,
29558
+ display: false,
29559
+ };
29543
29560
  }
29544
29561
  return scales;
29545
29562
  }
@@ -29573,6 +29590,10 @@ function getLineChartScales(definition, args) {
29573
29590
  ...scales.x,
29574
29591
  display: false,
29575
29592
  };
29593
+ scales[MOVING_AVERAGE_TREND_LINE_XAXIS_ID] = {
29594
+ ...scales.x,
29595
+ display: false,
29596
+ };
29576
29597
  if (axisType === "category" || axisType === "time") {
29577
29598
  /* We add a second x axis here to draw the trend lines, with the labels length being
29578
29599
  * set so that the second axis points match the classical x axis
@@ -29581,6 +29602,8 @@ function getLineChartScales(definition, args) {
29581
29602
  scales[TREND_LINE_XAXIS_ID]["type"] = "category";
29582
29603
  scales[TREND_LINE_XAXIS_ID]["labels"] = range(0, maxLength).map((x) => x.toString());
29583
29604
  scales[TREND_LINE_XAXIS_ID]["offset"] = false;
29605
+ scales[MOVING_AVERAGE_TREND_LINE_XAXIS_ID]["type"] = "category";
29606
+ scales[MOVING_AVERAGE_TREND_LINE_XAXIS_ID]["offset"] = false;
29584
29607
  }
29585
29608
  }
29586
29609
  return scales;
@@ -29899,9 +29922,7 @@ function getBarChartTooltip(definition, args) {
29899
29922
  external: customTooltipHandler,
29900
29923
  callbacks: {
29901
29924
  title: function (tooltipItems) {
29902
- return tooltipItems.some((item) => item.dataset.xAxisID !== TREND_LINE_XAXIS_ID)
29903
- ? undefined
29904
- : "";
29925
+ return tooltipItems.some((item) => !isTrendLineAxis(item.dataset.xAxisID)) ? undefined : "";
29905
29926
  },
29906
29927
  beforeLabel: (tooltipItem) => tooltipItem.dataset?.label || tooltipItem.label,
29907
29928
  label: function (tooltipItem) {
@@ -29928,7 +29949,7 @@ function getLineChartTooltip(definition, args) {
29928
29949
  if (axisType === "linear") {
29929
29950
  tooltip.callbacks.label = (tooltipItem) => {
29930
29951
  const dataSetPoint = tooltipItem.parsed.y;
29931
- let label = tooltipItem.dataset.xAxisID === TREND_LINE_XAXIS_ID
29952
+ let label = isTrendLineAxis(tooltipItem.dataset.xAxisID)
29932
29953
  ? ""
29933
29954
  : tooltipItem.parsed.x;
29934
29955
  if (typeof label === "string" && isNumber(label, locale)) {
@@ -29950,8 +29971,7 @@ function getLineChartTooltip(definition, args) {
29950
29971
  }
29951
29972
  tooltip.callbacks.beforeLabel = (tooltipItem) => tooltipItem.dataset?.label || tooltipItem.label;
29952
29973
  tooltip.callbacks.title = function (tooltipItems) {
29953
- const displayTooltipTitle = axisType !== "linear" &&
29954
- tooltipItems.some((item) => item.dataset.xAxisID !== TREND_LINE_XAXIS_ID);
29974
+ const displayTooltipTitle = axisType !== "linear" && tooltipItems.some((item) => !isTrendLineAxis(item.dataset.xAxisID));
29955
29975
  return displayTooltipTitle ? undefined : "";
29956
29976
  };
29957
29977
  return tooltip;
@@ -34205,6 +34225,7 @@ var CHART_HELPERS = /*#__PURE__*/Object.freeze({
34205
34225
  CHART_COMMON_OPTIONS: CHART_COMMON_OPTIONS,
34206
34226
  GaugeChart: GaugeChart,
34207
34227
  LineChart: LineChart,
34228
+ MOVING_AVERAGE_TREND_LINE_XAXIS_ID: MOVING_AVERAGE_TREND_LINE_XAXIS_ID,
34208
34229
  PieChart: PieChart,
34209
34230
  ScorecardChart: ScorecardChart$1,
34210
34231
  TREND_LINE_XAXIS_ID: TREND_LINE_XAXIS_ID,
@@ -34229,11 +34250,11 @@ var CHART_HELPERS = /*#__PURE__*/Object.freeze({
34229
34250
  duplicateLabelRangeInDuplicatedSheet: duplicateLabelRangeInDuplicatedSheet,
34230
34251
  formatChartDatasetValue: formatChartDatasetValue,
34231
34252
  formatTickValue: formatTickValue,
34232
- getChartJSConstructor: getChartJSConstructor,
34233
34253
  getChartPositionAtCenterOfViewport: getChartPositionAtCenterOfViewport,
34234
34254
  getDefinedAxis: getDefinedAxis,
34235
34255
  getPieColors: getPieColors,
34236
34256
  getSmartChartDefinition: getSmartChartDefinition,
34257
+ isTrendLineAxis: isTrendLineAxis,
34237
34258
  shouldRemoveFirstLabel: shouldRemoveFirstLabel,
34238
34259
  toExcelDataset: toExcelDataset,
34239
34260
  toExcelLabelRange: toExcelLabelRange,
@@ -36281,9 +36302,7 @@ class FormulaFingerprintStore extends SpreadsheetStore {
36281
36302
  }
36282
36303
  }
36283
36304
  }
36284
- // removes the index placeholders from the normalized formula
36285
- // =|N0|+|N1|+|N0| -> =|N|+|N|+|N|
36286
- const normalizedFormula = cell.compiledFormula.normalizedFormula.replace(/(|\w)(\d)(|)/g, "$1$3");
36305
+ const normalizedFormula = cell.compiledFormula.normalizedFormula;
36287
36306
  return hash(fingerprintVector) + normalizedFormula;
36288
36307
  }
36289
36308
  getLiteralFingerprint(position) {
@@ -39628,9 +39647,11 @@ class SeriesDesignEditor extends Component {
39628
39647
  if (!runtime || !("chartJsConfig" in runtime)) {
39629
39648
  return [];
39630
39649
  }
39631
- return runtime.chartJsConfig.data.datasets.map((d) => d.label);
39650
+ return runtime.chartJsConfig.data.datasets
39651
+ .filter((d) => !isTrendLineAxis(d["xAxisID"] ?? ""))
39652
+ .map((d) => d.label);
39632
39653
  }
39633
- updateSerieEditor(ev) {
39654
+ updateEditedSeries(ev) {
39634
39655
  this.state.index = ev.target.selectedIndex;
39635
39656
  }
39636
39657
  updateDataSeriesColor(color) {
@@ -39643,7 +39664,7 @@ class SeriesDesignEditor extends Component {
39643
39664
  };
39644
39665
  this.props.updateChart(this.props.figureId, { dataSets });
39645
39666
  }
39646
- getDataSerieColor() {
39667
+ getDataSeriesColor() {
39647
39668
  const dataSets = this.props.definition.dataSets;
39648
39669
  if (!dataSets?.[this.state.index])
39649
39670
  return "";
@@ -39663,7 +39684,7 @@ class SeriesDesignEditor extends Component {
39663
39684
  };
39664
39685
  this.props.updateChart(this.props.figureId, { dataSets });
39665
39686
  }
39666
- getDataSerieLabel() {
39687
+ getDataSeriesLabel() {
39667
39688
  const dataSets = this.props.definition.dataSets;
39668
39689
  return dataSets[this.state.index]?.label || this.getDataSeries()[this.state.index];
39669
39690
  }
@@ -39776,7 +39797,7 @@ class SeriesWithAxisDesignEditor extends Component {
39776
39797
  }
39777
39798
  this.updateTrendLineValue(index, { window });
39778
39799
  }
39779
- getDataSerieColor(index) {
39800
+ getDataSeriesColor(index) {
39780
39801
  const dataSets = this.props.definition.dataSets;
39781
39802
  if (!dataSets?.[index])
39782
39803
  return "";
@@ -39787,7 +39808,7 @@ class SeriesWithAxisDesignEditor extends Component {
39787
39808
  }
39788
39809
  getTrendLineColor(index) {
39789
39810
  return (this.getTrendLineConfiguration(index)?.color ??
39790
- setColorAlpha(this.getDataSerieColor(index), 0.5));
39811
+ setColorAlpha(this.getDataSeriesColor(index), 0.5));
39791
39812
  }
39792
39813
  updateTrendLineColor(index, color) {
39793
39814
  this.updateTrendLineValue(index, { color });
@@ -45076,7 +45097,8 @@ css /* scss */ `
45076
45097
  &.pivot-dimension-invalid {
45077
45098
  background-color: #ffdddd;
45078
45099
  border-color: red !important;
45079
- select {
45100
+ select,
45101
+ input {
45080
45102
  background-color: #ffdddd;
45081
45103
  }
45082
45104
  }
@@ -46937,7 +46959,7 @@ class PivotSidePanelStore extends SpreadsheetStore {
46937
46959
  this.notification.notifyUser({
46938
46960
  type: "info",
46939
46961
  text: _t("Pivot updates only work with dynamic pivot tables. Use %s or re-insert the static pivot from the Data menu.", pivotExample),
46940
- sticky: false,
46962
+ sticky: true,
46941
46963
  });
46942
46964
  }
46943
46965
  }
@@ -70767,7 +70789,9 @@ css /* scss */ `
70767
70789
  border: 1px solid;
70768
70790
  font-family: ${DEFAULT_FONT};
70769
70791
 
70770
- .o-composer:empty:not(:focus):not(.active)::before {
70792
+ /* In readonly we always show the fx icon if the composer is empty, not matter the focus */
70793
+ .o-composer:empty:not(:focus):not(.active)::before,
70794
+ &.o-topbar-composer-readonly .o-composer:empty::before {
70771
70795
  content: url("data:image/svg+xml,${encodeURIComponent(FX_SVG)}");
70772
70796
  position: relative;
70773
70797
  top: 20%;
@@ -74093,10 +74117,14 @@ function addIconSetRule(cf, rule) {
74093
74117
  continue;
74094
74118
  }
74095
74119
  const cfValueObjectNodes = cfValueObject.map((attrs) => escapeXml /*xml*/ `<cfvo ${formatAttributes(attrs)} />`);
74120
+ const iconSetAttrs = [["iconSet", getIconSet(rule.icons)]];
74121
+ if (isIconSetReversed(rule.icons)) {
74122
+ iconSetAttrs.push(["reverse", "1"]);
74123
+ }
74096
74124
  conditionalFormats.push(escapeXml /*xml*/ `
74097
74125
  <conditionalFormatting sqref="${range}">
74098
74126
  <cfRule ${formatAttributes(ruleAttributes)}>
74099
- <iconSet iconSet="${getIconSet(rule.icons)}">
74127
+ <iconSet ${formatAttributes(iconSetAttrs)}>
74100
74128
  ${joinXmlNodes(cfValueObjectNodes)}
74101
74129
  </iconSet>
74102
74130
  </cfRule>
@@ -74114,9 +74142,21 @@ function commonCfAttributes(cf) {
74114
74142
  ["stopIfTrue", cf.stopIfTrue ? 1 : 0],
74115
74143
  ];
74116
74144
  }
74145
+ function isIconSetReversed(iconSet) {
74146
+ const defaultIconSet = ICON_SETS[detectIconsType(iconSet)];
74147
+ return iconSet.upper === defaultIconSet.bad && iconSet.lower === defaultIconSet.good;
74148
+ }
74117
74149
  function getIconSet(iconSet) {
74118
- return XLSX_ICONSET_MAP[Object.keys(XLSX_ICONSET_MAP).find((key) => iconSet.upper.toLowerCase().startsWith(key)) ||
74119
- "dots"];
74150
+ return XLSX_ICONSET_MAP[detectIconsType(iconSet)];
74151
+ }
74152
+ /**
74153
+ * Partial detection based on "upper" point only.
74154
+ * We support any arbitrary icon in the set, while excel doesn't allow
74155
+ * mixing icons from different types.
74156
+ */
74157
+ function detectIconsType(iconSet) {
74158
+ const type = Object.keys(ICON_SETS).find((type) => Object.values(ICON_SETS[type]).includes(iconSet.upper)) || "dots";
74159
+ return type;
74120
74160
  }
74121
74161
  function thresholdAttributes(threshold, position) {
74122
74162
  const type = getExcelThresholdType(threshold.type, position);
@@ -75866,6 +75906,7 @@ const registries = {
75866
75906
  supportedPivotPositionalFormulaRegistry,
75867
75907
  pivotToFunctionValueRegistry,
75868
75908
  migrationStepRegistry,
75909
+ chartJsExtensionRegistry,
75869
75910
  };
75870
75911
  const helpers = {
75871
75912
  arg,
@@ -76021,6 +76062,6 @@ const chartHelpers = { ...CHART_HELPERS, ...CHART_RUNTIME_HELPERS };
76021
76062
  export { AbstractCellClipboardHandler, AbstractChart, AbstractFigureClipboardHandler, CellErrorType, CommandResult, CorePlugin, CoreViewPlugin, DispatchResult, EvaluationError, Model, PivotRuntimeDefinition, Registry, Revision, SPREADSHEET_DIMENSIONS, Spreadsheet, SpreadsheetPivotTable, UIPlugin, __info__, addFunction, addRenderingLayer, astToFormula, chartHelpers, compile, compileTokens, components, constants, convertAstNodes, coreTypes, findCellInNewZone, functionCache, helpers, hooks, invalidateCFEvaluationCommands, invalidateDependenciesCommands, invalidateEvaluationCommands, iterateAstNodes, links, load, parse, parseTokens, readonlyAllowedCommands, registries, setDefaultSheetViewSize, setTranslationMethod, stores, tokenColors, tokenize };
76022
76063
 
76023
76064
 
76024
- __info__.version = "18.2.4";
76025
- __info__.date = "2025-03-19T08:20:57.717Z";
76026
- __info__.hash = "958936a";
76065
+ __info__.version = "18.2.6";
76066
+ __info__.date = "2025-04-04T08:41:26.115Z";
76067
+ __info__.hash = "faa00e2";