@odoo/o-spreadsheet 18.1.13 → 18.1.15

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.13
6
- * @date 2025-03-26T12:48:31.680Z
7
- * @hash 45ec54c
5
+ * @version 18.1.15
6
+ * @date 2025-04-14T17:17:30.890Z
7
+ * @hash ddaea83
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
@@ -3564,6 +3563,7 @@
3564
3563
  "CLEAR_FORMATTING",
3565
3564
  "SET_BORDER",
3566
3565
  "SET_ZONE_BORDERS",
3566
+ "SET_BORDERS_ON_TARGET",
3567
3567
  /** CHART */
3568
3568
  "CREATE_CHART",
3569
3569
  "UPDATE_CHART",
@@ -6715,6 +6715,7 @@
6715
6715
  }
6716
6716
 
6717
6717
  class BorderClipboardHandler extends AbstractCellClipboardHandler {
6718
+ queuedBordersToAdd = {};
6718
6719
  copy(data) {
6719
6720
  const sheetId = data.sheetId;
6720
6721
  if (data.zones.length === 0) {
@@ -6745,6 +6746,7 @@
6745
6746
  const { left, top } = zones[0];
6746
6747
  this.pasteZone(sheetId, left, top, content.borders);
6747
6748
  }
6749
+ this.executeQueuedChanges(sheetId);
6748
6750
  }
6749
6751
  pasteZone(sheetId, col, row, borders) {
6750
6752
  for (const [r, rowBorders] of borders.entries()) {
@@ -6763,7 +6765,20 @@
6763
6765
  ...targetBorders,
6764
6766
  ...originBorders,
6765
6767
  };
6766
- this.dispatch("SET_BORDER", { ...target, border });
6768
+ const borderKey = JSON.stringify(border);
6769
+ if (!this.queuedBordersToAdd[borderKey]) {
6770
+ this.queuedBordersToAdd[borderKey] = [];
6771
+ }
6772
+ this.queuedBordersToAdd[borderKey].push(positionToZone(target));
6773
+ }
6774
+ executeQueuedChanges(pasteSheetTarget) {
6775
+ for (const borderKey in this.queuedBordersToAdd) {
6776
+ const zones = this.queuedBordersToAdd[borderKey];
6777
+ const border = JSON.parse(borderKey);
6778
+ const target = recomputeZones(zones, []);
6779
+ this.dispatch("SET_BORDERS_ON_TARGET", { sheetId: pasteSheetTarget, target, border });
6780
+ }
6781
+ this.queuedBordersToAdd = {};
6767
6782
  }
6768
6783
  }
6769
6784
 
@@ -6789,8 +6804,12 @@
6789
6804
  str = replaceNewLines(str);
6790
6805
  const chars = new TokenizingChars(str);
6791
6806
  const result = [];
6807
+ const tokenizeSpace = specialWhiteSpaceRegexp.test(str)
6808
+ ? tokenizeSpecialCharacterSpace
6809
+ : tokenizeSimpleSpace;
6792
6810
  while (!chars.isOver()) {
6793
- let token = tokenizeSpace(chars) ||
6811
+ let token = tokenizeNewLine(chars) ||
6812
+ tokenizeSpace(chars) ||
6794
6813
  tokenizeArgsSeparator(chars, locale) ||
6795
6814
  tokenizeParenthesis(chars) ||
6796
6815
  tokenizeOperator(chars) ||
@@ -6924,17 +6943,19 @@
6924
6943
  }
6925
6944
  return null;
6926
6945
  }
6927
- function tokenizeSpace(chars) {
6928
- let length = 0;
6929
- while (chars.current === NEWLINE) {
6930
- length++;
6931
- chars.shift();
6946
+ function tokenizeSpecialCharacterSpace(chars) {
6947
+ let spaces = "";
6948
+ while (chars.current === " " || (chars.current && chars.current.match(specialWhiteSpaceRegexp))) {
6949
+ spaces += chars.shift();
6932
6950
  }
6933
- if (length) {
6934
- return { type: "SPACE", value: NEWLINE.repeat(length) };
6951
+ if (spaces) {
6952
+ return { type: "SPACE", value: spaces };
6935
6953
  }
6954
+ return null;
6955
+ }
6956
+ function tokenizeSimpleSpace(chars) {
6936
6957
  let spaces = "";
6937
- while (chars.current && chars.current.match(whiteSpaceRegexp)) {
6958
+ while (chars.current === " ") {
6938
6959
  spaces += chars.shift();
6939
6960
  }
6940
6961
  if (spaces) {
@@ -6942,6 +6963,17 @@
6942
6963
  }
6943
6964
  return null;
6944
6965
  }
6966
+ function tokenizeNewLine(chars) {
6967
+ let length = 0;
6968
+ while (chars.current === NEWLINE) {
6969
+ length++;
6970
+ chars.shift();
6971
+ }
6972
+ if (length) {
6973
+ return { type: "SPACE", value: NEWLINE.repeat(length) };
6974
+ }
6975
+ return null;
6976
+ }
6945
6977
  function tokenizeInvalidRange(chars) {
6946
6978
  if (chars.currentStartsWith(CellErrorType.InvalidReference)) {
6947
6979
  chars.advanceBy(CellErrorType.InvalidReference.length);
@@ -8685,12 +8717,13 @@
8685
8717
  }
8686
8718
  pasteCf(origin, target, isCutOperation) {
8687
8719
  if (origin?.rules && origin.rules.length > 0) {
8720
+ const originZone = positionToZone(origin.position);
8688
8721
  const zone = positionToZone(target);
8689
8722
  for (const rule of origin.rules) {
8690
8723
  const toRemoveZones = [];
8691
8724
  if (isCutOperation) {
8692
8725
  //remove from current rule
8693
- toRemoveZones.push(positionToZone(origin.position));
8726
+ toRemoveZones.push(originZone);
8694
8727
  }
8695
8728
  if (origin.position.sheetId === target.sheetId) {
8696
8729
  this.adaptCFRules(origin.position.sheetId, rule, [zone], toRemoveZones);
@@ -8804,6 +8837,7 @@
8804
8837
  pasteDataValidation(origin, target, isCutOperation) {
8805
8838
  if (origin) {
8806
8839
  const zone = positionToZone(target);
8840
+ const originZone = positionToZone(origin.position);
8807
8841
  const rule = origin.rule;
8808
8842
  if (!rule) {
8809
8843
  const targetRule = this.getters.getValidationRuleForCell(target);
@@ -8815,7 +8849,7 @@
8815
8849
  }
8816
8850
  const toRemoveZone = [];
8817
8851
  if (isCutOperation) {
8818
- toRemoveZone.push(positionToZone(origin.position));
8852
+ toRemoveZone.push(originZone);
8819
8853
  }
8820
8854
  if (origin.position.sheetId === target.sheetId) {
8821
8855
  const copyToRule = this.getDataValidationRuleToCopyTo(target.sheetId, rule, false);
@@ -8875,7 +8909,7 @@
8875
8909
  continue;
8876
8910
  }
8877
8911
  this.dispatch("ADD_DATA_VALIDATION_RULE", {
8878
- rule: dv,
8912
+ rule: { id: dv.id, criterion: dv.criterion, isBlocking: dv.isBlocking },
8879
8913
  ranges: newDvZones.map((zone) => this.getters.getRangeDataFromZone(sheetId, zone)),
8880
8914
  sheetId,
8881
8915
  });
@@ -9567,6 +9601,15 @@ stores.inject(MyMetaStore, storeInstance);
9567
9601
  }
9568
9602
  }
9569
9603
 
9604
+ const chartJsExtensionRegistry = new Registry();
9605
+ /** Return window.Chart, making sure all our extensions are loaded in ChartJS */
9606
+ function getChartJSConstructor() {
9607
+ if (window.Chart && !window.Chart?.registry.plugins.get("chartShowValuesPlugin")) {
9608
+ window.Chart.register(...chartJsExtensionRegistry.getAll());
9609
+ }
9610
+ return window.Chart;
9611
+ }
9612
+
9570
9613
  const TREND_LINE_XAXIS_ID = "x1";
9571
9614
  const MOVING_AVERAGE_TREND_LINE_XAXIS_ID = "xMovingAverage";
9572
9615
  /**
@@ -10105,341 +10148,70 @@ stores.inject(MyMetaStore, storeInstance);
10105
10148
  return bars.find((bar, i) => i > startIndex && bar.height !== 0);
10106
10149
  }
10107
10150
 
10108
- const GAUGE_PADDING_SIDE = 30;
10109
- const GAUGE_PADDING_TOP = 10;
10110
- const GAUGE_PADDING_BOTTOM = 20;
10111
- const GAUGE_LABELS_FONT_SIZE = 12;
10112
- const GAUGE_DEFAULT_VALUE_FONT_SIZE = 80;
10113
- const GAUGE_BACKGROUND_COLOR = "#F3F2F1";
10114
- const GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN = 6;
10115
- const GAUGE_TITLE_SECTION_HEIGHT = 25;
10116
- function drawGaugeChart(canvas, runtime) {
10117
- const canvasBoundingRect = canvas.getBoundingClientRect();
10118
- canvas.width = canvasBoundingRect.width;
10119
- canvas.height = canvasBoundingRect.height;
10120
- const ctx = canvas.getContext("2d");
10121
- const config = getGaugeRenderingConfig(canvasBoundingRect, runtime, ctx);
10122
- drawBackground(ctx, config);
10123
- drawGauge(ctx, config);
10124
- drawInflectionValues(ctx, config);
10125
- drawLabels(ctx, config);
10126
- drawTitle(ctx, config);
10127
- }
10128
- function drawGauge(ctx, config) {
10129
- ctx.save();
10130
- const gauge = config.gauge;
10131
- const arcCenterX = gauge.rect.x + gauge.rect.width / 2;
10132
- const arcCenterY = gauge.rect.y + gauge.rect.height;
10133
- const arcRadius = gauge.rect.height - gauge.arcWidth / 2;
10134
- if (arcRadius < 0) {
10135
- return;
10136
- }
10137
- const gaugeAngle = gauge.percentage === 1 ? 0 : Math.PI * (1 + gauge.percentage);
10138
- // Gauge background
10139
- ctx.strokeStyle = GAUGE_BACKGROUND_COLOR;
10140
- ctx.beginPath();
10141
- ctx.lineWidth = gauge.arcWidth;
10142
- ctx.arc(arcCenterX, arcCenterY, arcRadius, gaugeAngle, 0);
10143
- ctx.stroke();
10144
- // Gauge value
10145
- ctx.strokeStyle = gauge.color;
10146
- ctx.beginPath();
10147
- ctx.arc(arcCenterX, arcCenterY, arcRadius, Math.PI, gaugeAngle);
10148
- ctx.stroke();
10149
- ctx.restore();
10150
- }
10151
- function drawBackground(ctx, config) {
10152
- ctx.save();
10153
- ctx.fillStyle = config.backgroundColor;
10154
- ctx.fillRect(0, 0, config.width, config.height);
10155
- ctx.restore();
10156
- }
10157
- function drawLabels(ctx, config) {
10158
- for (const label of [config.minLabel, config.maxLabel, config.gaugeValue]) {
10159
- ctx.save();
10160
- ctx.textAlign = "center";
10161
- ctx.fillStyle = label.color;
10162
- ctx.font = `${label.fontSize}px ${DEFAULT_FONT}`;
10163
- ctx.fillText(label.label, label.textPosition.x, label.textPosition.y);
10164
- ctx.restore();
10165
- }
10166
- }
10167
- function drawInflectionValues(ctx, config) {
10168
- const { x: rectX, y: rectY, width, height } = config.gauge.rect;
10169
- for (const inflectionValue of config.inflectionValues) {
10170
- ctx.save();
10171
- ctx.translate(rectX + width / 2 - 0.5, rectY + height - 0.5); // -0.5 for sharper lines. see RendererPlugin.drawBorders comment
10172
- ctx.rotate(Math.PI / 2 - inflectionValue.rotation);
10173
- ctx.lineWidth = 2;
10174
- ctx.strokeStyle = chartMutedFontColor(config.backgroundColor) + "aa";
10175
- ctx.beginPath();
10176
- ctx.moveTo(0, -(height - config.gauge.arcWidth));
10177
- ctx.lineTo(0, -height - 3);
10178
- ctx.stroke();
10179
- ctx.textAlign = "center";
10180
- ctx.font = `${inflectionValue.fontSize}px ${DEFAULT_FONT}`;
10181
- ctx.fillStyle = inflectionValue.color;
10182
- const textY = -height - GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN - inflectionValue.offset;
10183
- ctx.fillText(inflectionValue.label, 0, textY);
10184
- ctx.restore();
10185
- }
10186
- }
10187
- function drawTitle(ctx, config) {
10188
- ctx.save();
10189
- const title = config.title;
10190
- ctx.font = getDefaultContextFont(title.fontSize, title.bold, title.italic);
10191
- ctx.textBaseline = "middle";
10192
- ctx.fillStyle = title.color;
10193
- ctx.fillText(title.label, title.textPosition.x, title.textPosition.y);
10194
- ctx.restore();
10195
- }
10196
- function getGaugeRenderingConfig(boundingRect, runtime, ctx) {
10197
- const maxValue = runtime.maxValue;
10198
- const minValue = runtime.minValue;
10199
- const gaugeValue = runtime.gaugeValue;
10200
- const gaugeRect = getGaugeRect(boundingRect, runtime.title.text);
10201
- const gaugeArcWidth = gaugeRect.width / 6;
10202
- const gaugePercentage = gaugeValue
10203
- ? (gaugeValue.value - minValue.value) / (maxValue.value - minValue.value)
10204
- : 0;
10205
- const gaugeValuePosition = {
10206
- x: boundingRect.width / 2,
10207
- y: gaugeRect.y + gaugeRect.height - gaugeRect.height / 12,
10208
- };
10209
- let gaugeValueFontSize = GAUGE_DEFAULT_VALUE_FONT_SIZE;
10210
- // Scale down the font size if the gaugeRect is too small
10211
- if (gaugeRect.height < 300) {
10212
- gaugeValueFontSize = gaugeValueFontSize * (gaugeRect.height / 300);
10213
- }
10214
- // Scale down the font size if the text is too long
10215
- const maxTextWidth = gaugeRect.width / 2;
10216
- const gaugeLabel = gaugeValue?.label || "-";
10217
- if (computeTextWidth(ctx, gaugeLabel, { fontSize: gaugeValueFontSize }, "px") > maxTextWidth) {
10218
- gaugeValueFontSize = getFontSizeMatchingWidth(maxTextWidth, gaugeValueFontSize, (fontSize) => computeTextWidth(ctx, gaugeLabel, { fontSize }, "px"));
10219
- }
10220
- const minLabelPosition = {
10221
- x: gaugeRect.x + gaugeArcWidth / 2,
10222
- y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
10223
- };
10224
- const maxLabelPosition = {
10225
- x: gaugeRect.x + gaugeRect.width - gaugeArcWidth / 2,
10226
- y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
10151
+ chartJsExtensionRegistry.add("chartShowValuesPlugin", chartShowValuesPlugin);
10152
+ chartJsExtensionRegistry.add("waterfallLinesPlugin", waterfallLinesPlugin);
10153
+ class ChartJsComponent extends owl.Component {
10154
+ static template = "o-spreadsheet-ChartJsComponent";
10155
+ static props = {
10156
+ figure: Object,
10227
10157
  };
10228
- const textColor = chartMutedFontColor(runtime.background);
10229
- const inflectionValues = getInflectionValues(runtime, gaugeRect, textColor, ctx);
10230
- let x = 0, titleWidth = 0, titleHeight = 0;
10231
- if (runtime.title.text) {
10232
- ({ width: titleWidth, height: titleHeight } = computeTextDimension(ctx, runtime.title.text, { fontSize: CHART_TITLE_FONT_SIZE, ...runtime.title }, "px"));
10233
- }
10234
- switch (runtime.title.align) {
10235
- case "right":
10236
- x = boundingRect.width - titleWidth - CHART_PADDING$1;
10237
- break;
10238
- case "center":
10239
- x = (boundingRect.width - titleWidth) / 2;
10240
- break;
10241
- case "left":
10242
- default:
10243
- x = CHART_PADDING$1;
10244
- break;
10158
+ canvas = owl.useRef("graphContainer");
10159
+ chart;
10160
+ currentRuntime;
10161
+ get background() {
10162
+ return this.chartRuntime.background;
10245
10163
  }
10246
- return {
10247
- width: boundingRect.width,
10248
- height: boundingRect.height,
10249
- title: {
10250
- label: runtime.title.text ?? "",
10251
- fontSize: runtime.title.fontSize ?? CHART_TITLE_FONT_SIZE,
10252
- textPosition: {
10253
- x,
10254
- y: CHART_PADDING_TOP + titleHeight / 2,
10255
- },
10256
- color: runtime.title.color ?? textColor,
10257
- bold: runtime.title.bold,
10258
- italic: runtime.title.italic,
10259
- },
10260
- backgroundColor: runtime.background,
10261
- gauge: {
10262
- rect: gaugeRect,
10263
- arcWidth: gaugeArcWidth,
10264
- percentage: clip(gaugePercentage, 0, 1),
10265
- color: getGaugeColor(runtime),
10266
- },
10267
- inflectionValues,
10268
- gaugeValue: {
10269
- label: gaugeLabel,
10270
- textPosition: gaugeValuePosition,
10271
- fontSize: gaugeValueFontSize,
10272
- color: textColor,
10273
- },
10274
- minLabel: {
10275
- label: runtime.minValue.label,
10276
- textPosition: minLabelPosition,
10277
- fontSize: GAUGE_LABELS_FONT_SIZE,
10278
- color: textColor,
10279
- },
10280
- maxLabel: {
10281
- label: runtime.maxValue.label,
10282
- textPosition: maxLabelPosition,
10283
- fontSize: GAUGE_LABELS_FONT_SIZE,
10284
- color: textColor,
10285
- },
10286
- };
10287
- }
10288
- /**
10289
- * Get the rectangle in which the gauge will be drawn, based on the bounding rectangle of the canvas and leaving
10290
- * space for the title and labels.
10291
- */
10292
- function getGaugeRect(boundingRect, title) {
10293
- const titleHeight = title ? GAUGE_TITLE_SECTION_HEIGHT : 0;
10294
- const drawHeight = boundingRect.height - GAUGE_PADDING_BOTTOM - titleHeight - GAUGE_PADDING_TOP;
10295
- const drawWidth = boundingRect.width - GAUGE_PADDING_SIDE * 2;
10296
- let gaugeWidth;
10297
- let gaugeHeight;
10298
- if (drawWidth > 2 * drawHeight) {
10299
- gaugeWidth = 2 * drawHeight;
10300
- gaugeHeight = drawHeight;
10164
+ get canvasStyle() {
10165
+ return `background-color: ${this.background}`;
10301
10166
  }
10302
- else {
10303
- gaugeWidth = drawWidth;
10304
- gaugeHeight = drawWidth / 2;
10167
+ get chartRuntime() {
10168
+ const runtime = this.env.model.getters.getChartRuntime(this.props.figure.id);
10169
+ if (!("chartJsConfig" in runtime)) {
10170
+ throw new Error("Unsupported chart runtime");
10171
+ }
10172
+ return runtime;
10305
10173
  }
10306
- const gaugeX = GAUGE_PADDING_SIDE + (drawWidth - gaugeWidth) / 2;
10307
- const gaugeY = titleHeight + GAUGE_PADDING_TOP + (drawHeight - gaugeHeight) / 2;
10308
- return {
10309
- x: gaugeX,
10310
- y: gaugeY,
10311
- width: gaugeWidth,
10312
- height: gaugeHeight,
10313
- };
10314
- }
10315
- /**
10316
- * 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).
10317
- *
10318
- * Also compute an offset for the text so that it doesn't overlap with other text.
10319
- */
10320
- function getInflectionValues(runtime, gaugeRect, textColor, ctx) {
10321
- const maxValue = runtime.maxValue;
10322
- const minValue = runtime.minValue;
10323
- const gaugeCircleCenter = {
10324
- x: gaugeRect.x + gaugeRect.width / 2,
10325
- y: gaugeRect.y + gaugeRect.height,
10326
- };
10327
- const textStyle = { fontSize: GAUGE_LABELS_FONT_SIZE };
10328
- const inflectionValues = [];
10329
- const inflectionValuesTextRects = [];
10330
- for (const inflectionValue of runtime.inflectionValues) {
10331
- const percentage = (inflectionValue.value - minValue.value) / (maxValue.value - minValue.value);
10332
- const labelWidth = computeTextWidth(ctx, inflectionValue.label, textStyle, "px");
10333
- const angle = Math.PI - Math.PI * percentage;
10334
- const textRect = getRectangleTangentToCircle(angle, // angle between X axis and the point where the rectangle is tangent to the circle
10335
- gaugeRect.height + GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN, // radius of the gauge circle + margin below text
10336
- gaugeCircleCenter.x, // center of the gauge circle
10337
- gaugeCircleCenter.y, // center of the gauge circle
10338
- labelWidth + 2, // width of the text + some margin
10339
- GAUGE_LABELS_FONT_SIZE // height of the text
10340
- );
10341
- let offset = inflectionValuesTextRects.some((rect) => doRectanglesIntersect(rect, textRect))
10342
- ? GAUGE_LABELS_FONT_SIZE
10343
- : 0;
10344
- inflectionValuesTextRects.push(textRect);
10345
- inflectionValues.push({
10346
- rotation: angle,
10347
- label: inflectionValue.label,
10348
- fontSize: GAUGE_LABELS_FONT_SIZE,
10349
- color: textColor,
10350
- offset,
10174
+ setup() {
10175
+ owl.onMounted(() => {
10176
+ const runtime = this.chartRuntime;
10177
+ this.currentRuntime = runtime;
10178
+ // Note: chartJS modify the runtime in place, so it's important to give it a copy
10179
+ this.createChart(deepCopy(runtime.chartJsConfig));
10180
+ });
10181
+ owl.onWillUnmount(() => this.chart?.destroy());
10182
+ owl.useEffect(() => {
10183
+ const runtime = this.chartRuntime;
10184
+ if (runtime !== this.currentRuntime) {
10185
+ if (runtime.chartJsConfig.type !== this.currentRuntime.chartJsConfig.type) {
10186
+ this.chart?.destroy();
10187
+ this.createChart(deepCopy(runtime.chartJsConfig));
10188
+ }
10189
+ else {
10190
+ this.updateChartJs(deepCopy(runtime.chartJsConfig));
10191
+ }
10192
+ this.currentRuntime = runtime;
10193
+ }
10351
10194
  });
10352
10195
  }
10353
- return inflectionValues;
10354
- }
10355
- function getGaugeColor(runtime) {
10356
- const gaugeValue = runtime.gaugeValue?.value;
10357
- if (gaugeValue === undefined) {
10358
- return GAUGE_BACKGROUND_COLOR;
10359
- }
10360
- for (let i = 0; i < runtime.inflectionValues.length; i++) {
10361
- const inflectionValue = runtime.inflectionValues[i];
10362
- if (inflectionValue.operator === "<" && gaugeValue < inflectionValue.value) {
10363
- return runtime.colors[i];
10364
- }
10365
- else if (inflectionValue.operator === "<=" && gaugeValue <= inflectionValue.value) {
10366
- return runtime.colors[i];
10367
- }
10368
- }
10369
- return runtime.colors.at(-1);
10370
- }
10371
- function getSegmentsOfRectangle(rectangle) {
10372
- return [
10373
- { start: rectangle.topLeft, end: rectangle.topRight },
10374
- { start: rectangle.topRight, end: rectangle.bottomRight },
10375
- { start: rectangle.bottomRight, end: rectangle.bottomLeft },
10376
- { start: rectangle.bottomLeft, end: rectangle.topLeft },
10377
- ];
10378
- }
10379
- /**
10380
- * Check if two segment intersect. The case where the segments are colinear (both segments on the same line)
10381
- * is not handled.
10382
- */
10383
- function doSegmentIntersect(segment1, segment2) {
10384
- const A = segment1.start;
10385
- const B = segment1.end;
10386
- const C = segment2.start;
10387
- const D = segment2.end;
10388
- /**
10389
- * Line segment intersection algorithm
10390
- * https://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/
10391
- */
10392
- function ccw(a, b, c) {
10393
- return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
10196
+ createChart(chartData) {
10197
+ const canvas = this.canvas.el;
10198
+ const ctx = canvas.getContext("2d");
10199
+ const Chart = getChartJSConstructor();
10200
+ this.chart = new Chart(ctx, chartData);
10394
10201
  }
10395
- return ccw(A, C, D) !== ccw(B, C, D) && ccw(A, B, C) !== ccw(A, B, D);
10396
- }
10397
- function doRectanglesIntersect(rect1, rect2) {
10398
- const segments1 = getSegmentsOfRectangle(rect1);
10399
- const segments2 = getSegmentsOfRectangle(rect2);
10400
- for (const segment1 of segments1) {
10401
- for (const segment2 of segments2) {
10402
- if (doSegmentIntersect(segment1, segment2)) {
10403
- return true;
10202
+ updateChartJs(chartData) {
10203
+ if (chartData.data && chartData.data.datasets) {
10204
+ this.chart.data = chartData.data;
10205
+ if (chartData.options?.plugins?.title) {
10206
+ this.chart.config.options.plugins.title = chartData.options.plugins.title;
10404
10207
  }
10405
10208
  }
10209
+ else {
10210
+ this.chart.data.datasets = [];
10211
+ }
10212
+ this.chart.config.options = chartData.options;
10213
+ this.chart.update();
10406
10214
  }
10407
- return false;
10408
- }
10409
- /**
10410
- * Get the rectangle that is tangent to a circle at a given angle.
10411
- *
10412
- * @param angle angle between X axis and the point where the rectangle is tangent to the circle
10413
- */
10414
- function getRectangleTangentToCircle(angle, radius, circleCenterX, circleCenterY, rectWidth, rectHeight) {
10415
- const cos = Math.cos(angle);
10416
- const sin = Math.sin(angle);
10417
- // x, y are the distance from the center of the circle to the point where the rectangle is tangent to the circle
10418
- const x = cos * radius;
10419
- const y = sin * radius;
10420
- // x2, y2 are the distance from the point the rectangle is tangent to the circle to the bottom left corner of the rectangle
10421
- const x2 = sin * (rectWidth / 2); // cos(angle + 90°) = sin(angle)
10422
- const y2 = cos * (rectWidth / 2);
10423
- const bottomRight = {
10424
- x: x + x2 + circleCenterX,
10425
- y: circleCenterY - (y - y2),
10426
- };
10427
- const bottomLeft = {
10428
- x: x - x2 + circleCenterX,
10429
- y: circleCenterY - (y + y2),
10430
- };
10431
- // Same as above but for the top corners of the rectangle (radius + rectangle height instead of radius)
10432
- const xp = cos * (radius + rectHeight);
10433
- const yp = sin * (radius + rectHeight);
10434
- const topLeft = {
10435
- x: xp - x2 + circleCenterX,
10436
- y: circleCenterY - (yp + y2),
10437
- };
10438
- const topRight = {
10439
- x: xp + x2 + circleCenterX,
10440
- y: circleCenterY - (yp - y2),
10441
- };
10442
- return { bottomLeft, bottomRight, topRight, topLeft };
10443
10215
  }
10444
10216
 
10445
10217
  /**
@@ -11021,155 +10793,6 @@ stores.inject(MyMetaStore, storeInstance);
11021
10793
  }
11022
10794
  }
11023
10795
 
11024
- const CHART_COMMON_OPTIONS = {
11025
- // https://www.chartjs.org/docs/latest/general/responsive.html
11026
- responsive: true, // will resize when its container is resized
11027
- maintainAspectRatio: false, // doesn't maintain the aspect ratio (width/height =2 by default) so the user has the choice of the exact layout
11028
- elements: {
11029
- line: {
11030
- fill: false, // do not fill the area under line charts
11031
- },
11032
- point: {
11033
- hitRadius: 15, // increased hit radius to display point tooltip when hovering nearby
11034
- },
11035
- },
11036
- animation: false,
11037
- };
11038
- function truncateLabel(label) {
11039
- if (!label) {
11040
- return "";
11041
- }
11042
- if (label.length > MAX_CHAR_LABEL) {
11043
- return label.substring(0, MAX_CHAR_LABEL) + "…";
11044
- }
11045
- return label;
11046
- }
11047
- function chartToImage(runtime, figure, type) {
11048
- // wrap the canvas in a div with a fixed size because chart.js would
11049
- // fill the whole page otherwise
11050
- const div = document.createElement("div");
11051
- div.style.width = `${figure.width}px`;
11052
- div.style.height = `${figure.height}px`;
11053
- const canvas = document.createElement("canvas");
11054
- div.append(canvas);
11055
- canvas.setAttribute("width", figure.width.toString());
11056
- canvas.setAttribute("height", figure.height.toString());
11057
- // we have to add the canvas to the DOM otherwise it won't be rendered
11058
- document.body.append(div);
11059
- if ("chartJsConfig" in runtime) {
11060
- const config = deepCopy(runtime.chartJsConfig);
11061
- config.plugins = [backgroundColorChartJSPlugin];
11062
- const Chart = getChartJSConstructor();
11063
- const chart = new Chart(canvas, config);
11064
- const imgContent = chart.toBase64Image();
11065
- chart.destroy();
11066
- div.remove();
11067
- return imgContent;
11068
- }
11069
- else if (type === "scorecard") {
11070
- const design = getScorecardConfiguration(figure, runtime);
11071
- drawScoreChart(design, canvas);
11072
- const imgContent = canvas.toDataURL();
11073
- div.remove();
11074
- return imgContent;
11075
- }
11076
- else if (type === "gauge") {
11077
- drawGaugeChart(canvas, runtime);
11078
- const imgContent = canvas.toDataURL();
11079
- div.remove();
11080
- return imgContent;
11081
- }
11082
- return undefined;
11083
- }
11084
- /**
11085
- * Custom chart.js plugin to set the background color of the canvas
11086
- * https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
11087
- */
11088
- const backgroundColorChartJSPlugin = {
11089
- id: "customCanvasBackgroundColor",
11090
- beforeDraw: (chart) => {
11091
- const { ctx } = chart;
11092
- ctx.save();
11093
- ctx.globalCompositeOperation = "destination-over";
11094
- ctx.fillStyle = "#ffffff";
11095
- ctx.fillRect(0, 0, chart.width, chart.height);
11096
- ctx.restore();
11097
- },
11098
- };
11099
- /** Return window.Chart, making sure all our extensions are loaded in ChartJS */
11100
- function getChartJSConstructor() {
11101
- if (window.Chart && !window.Chart?.registry.plugins.get("chartShowValuesPlugin")) {
11102
- window.Chart.register(chartShowValuesPlugin);
11103
- window.Chart.register(waterfallLinesPlugin);
11104
- }
11105
- return window.Chart;
11106
- }
11107
-
11108
- class ChartJsComponent extends owl.Component {
11109
- static template = "o-spreadsheet-ChartJsComponent";
11110
- static props = {
11111
- figure: Object,
11112
- };
11113
- canvas = owl.useRef("graphContainer");
11114
- chart;
11115
- currentRuntime;
11116
- get background() {
11117
- return this.chartRuntime.background;
11118
- }
11119
- get canvasStyle() {
11120
- return `background-color: ${this.background}`;
11121
- }
11122
- get chartRuntime() {
11123
- const runtime = this.env.model.getters.getChartRuntime(this.props.figure.id);
11124
- if (!("chartJsConfig" in runtime)) {
11125
- throw new Error("Unsupported chart runtime");
11126
- }
11127
- return runtime;
11128
- }
11129
- setup() {
11130
- owl.onMounted(() => {
11131
- const runtime = this.chartRuntime;
11132
- this.currentRuntime = runtime;
11133
- // Note: chartJS modify the runtime in place, so it's important to give it a copy
11134
- this.createChart(deepCopy(runtime.chartJsConfig));
11135
- });
11136
- owl.onWillUnmount(() => this.chart?.destroy());
11137
- owl.useEffect(() => {
11138
- const runtime = this.chartRuntime;
11139
- if (runtime !== this.currentRuntime) {
11140
- if (runtime.chartJsConfig.type !== this.currentRuntime.chartJsConfig.type) {
11141
- this.chart?.destroy();
11142
- this.createChart(deepCopy(runtime.chartJsConfig));
11143
- }
11144
- else {
11145
- this.updateChartJs(deepCopy(runtime));
11146
- }
11147
- this.currentRuntime = runtime;
11148
- }
11149
- });
11150
- }
11151
- createChart(chartData) {
11152
- const canvas = this.canvas.el;
11153
- const ctx = canvas.getContext("2d");
11154
- const Chart = getChartJSConstructor();
11155
- this.chart = new Chart(ctx, chartData);
11156
- }
11157
- updateChartJs(chartRuntime) {
11158
- const chartData = chartRuntime.chartJsConfig;
11159
- if (chartData.data && chartData.data.datasets) {
11160
- this.chart.data = chartData.data;
11161
- if (chartData.options?.plugins?.title) {
11162
- this.chart.config.options.plugins.title = chartData.options.plugins.title;
11163
- }
11164
- }
11165
- else {
11166
- this.chart.data.datasets = [];
11167
- }
11168
- this.chart.config.options = chartData.options;
11169
- this.chart.update();
11170
- }
11171
- }
11172
-
11173
10796
  class ScorecardChart extends owl.Component {
11174
10797
  static template = "o-spreadsheet-ScorecardChart";
11175
10798
  static props = {
@@ -22767,6 +22390,343 @@ stores.inject(MyMetaStore, storeInstance);
22767
22390
 
22768
22391
  const cellPopoverRegistry = new Registry();
22769
22392
 
22393
+ const GAUGE_PADDING_SIDE = 30;
22394
+ const GAUGE_PADDING_TOP = 10;
22395
+ const GAUGE_PADDING_BOTTOM = 20;
22396
+ const GAUGE_LABELS_FONT_SIZE = 12;
22397
+ const GAUGE_DEFAULT_VALUE_FONT_SIZE = 80;
22398
+ const GAUGE_BACKGROUND_COLOR = "#F3F2F1";
22399
+ const GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN = 6;
22400
+ const GAUGE_TITLE_SECTION_HEIGHT = 25;
22401
+ function drawGaugeChart(canvas, runtime) {
22402
+ const canvasBoundingRect = canvas.getBoundingClientRect();
22403
+ canvas.width = canvasBoundingRect.width;
22404
+ canvas.height = canvasBoundingRect.height;
22405
+ const ctx = canvas.getContext("2d");
22406
+ const config = getGaugeRenderingConfig(canvasBoundingRect, runtime, ctx);
22407
+ drawBackground(ctx, config);
22408
+ drawGauge(ctx, config);
22409
+ drawInflectionValues(ctx, config);
22410
+ drawLabels(ctx, config);
22411
+ drawTitle(ctx, config);
22412
+ }
22413
+ function drawGauge(ctx, config) {
22414
+ ctx.save();
22415
+ const gauge = config.gauge;
22416
+ const arcCenterX = gauge.rect.x + gauge.rect.width / 2;
22417
+ const arcCenterY = gauge.rect.y + gauge.rect.height;
22418
+ const arcRadius = gauge.rect.height - gauge.arcWidth / 2;
22419
+ if (arcRadius < 0) {
22420
+ return;
22421
+ }
22422
+ const gaugeAngle = gauge.percentage === 1 ? 0 : Math.PI * (1 + gauge.percentage);
22423
+ // Gauge background
22424
+ ctx.strokeStyle = GAUGE_BACKGROUND_COLOR;
22425
+ ctx.beginPath();
22426
+ ctx.lineWidth = gauge.arcWidth;
22427
+ ctx.arc(arcCenterX, arcCenterY, arcRadius, gaugeAngle, 0);
22428
+ ctx.stroke();
22429
+ // Gauge value
22430
+ ctx.strokeStyle = gauge.color;
22431
+ ctx.beginPath();
22432
+ ctx.arc(arcCenterX, arcCenterY, arcRadius, Math.PI, gaugeAngle);
22433
+ ctx.stroke();
22434
+ ctx.restore();
22435
+ }
22436
+ function drawBackground(ctx, config) {
22437
+ ctx.save();
22438
+ ctx.fillStyle = config.backgroundColor;
22439
+ ctx.fillRect(0, 0, config.width, config.height);
22440
+ ctx.restore();
22441
+ }
22442
+ function drawLabels(ctx, config) {
22443
+ for (const label of [config.minLabel, config.maxLabel, config.gaugeValue]) {
22444
+ ctx.save();
22445
+ ctx.textAlign = "center";
22446
+ ctx.fillStyle = label.color;
22447
+ ctx.font = `${label.fontSize}px ${DEFAULT_FONT}`;
22448
+ ctx.fillText(label.label, label.textPosition.x, label.textPosition.y);
22449
+ ctx.restore();
22450
+ }
22451
+ }
22452
+ function drawInflectionValues(ctx, config) {
22453
+ const { x: rectX, y: rectY, width, height } = config.gauge.rect;
22454
+ for (const inflectionValue of config.inflectionValues) {
22455
+ ctx.save();
22456
+ ctx.translate(rectX + width / 2 - 0.5, rectY + height - 0.5); // -0.5 for sharper lines. see RendererPlugin.drawBorders comment
22457
+ ctx.rotate(Math.PI / 2 - inflectionValue.rotation);
22458
+ ctx.lineWidth = 2;
22459
+ ctx.strokeStyle = chartMutedFontColor(config.backgroundColor) + "aa";
22460
+ ctx.beginPath();
22461
+ ctx.moveTo(0, -(height - config.gauge.arcWidth));
22462
+ ctx.lineTo(0, -height - 3);
22463
+ ctx.stroke();
22464
+ ctx.textAlign = "center";
22465
+ ctx.font = `${inflectionValue.fontSize}px ${DEFAULT_FONT}`;
22466
+ ctx.fillStyle = inflectionValue.color;
22467
+ const textY = -height - GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN - inflectionValue.offset;
22468
+ ctx.fillText(inflectionValue.label, 0, textY);
22469
+ ctx.restore();
22470
+ }
22471
+ }
22472
+ function drawTitle(ctx, config) {
22473
+ ctx.save();
22474
+ const title = config.title;
22475
+ ctx.font = getDefaultContextFont(title.fontSize, title.bold, title.italic);
22476
+ ctx.textBaseline = "middle";
22477
+ ctx.fillStyle = title.color;
22478
+ ctx.fillText(title.label, title.textPosition.x, title.textPosition.y);
22479
+ ctx.restore();
22480
+ }
22481
+ function getGaugeRenderingConfig(boundingRect, runtime, ctx) {
22482
+ const maxValue = runtime.maxValue;
22483
+ const minValue = runtime.minValue;
22484
+ const gaugeValue = runtime.gaugeValue;
22485
+ const gaugeRect = getGaugeRect(boundingRect, runtime.title.text);
22486
+ const gaugeArcWidth = gaugeRect.width / 6;
22487
+ const gaugePercentage = gaugeValue
22488
+ ? (gaugeValue.value - minValue.value) / (maxValue.value - minValue.value)
22489
+ : 0;
22490
+ const gaugeValuePosition = {
22491
+ x: boundingRect.width / 2,
22492
+ y: gaugeRect.y + gaugeRect.height - gaugeRect.height / 12,
22493
+ };
22494
+ let gaugeValueFontSize = GAUGE_DEFAULT_VALUE_FONT_SIZE;
22495
+ // Scale down the font size if the gaugeRect is too small
22496
+ if (gaugeRect.height < 300) {
22497
+ gaugeValueFontSize = gaugeValueFontSize * (gaugeRect.height / 300);
22498
+ }
22499
+ // Scale down the font size if the text is too long
22500
+ const maxTextWidth = gaugeRect.width / 2;
22501
+ const gaugeLabel = gaugeValue?.label || "-";
22502
+ if (computeTextWidth(ctx, gaugeLabel, { fontSize: gaugeValueFontSize }, "px") > maxTextWidth) {
22503
+ gaugeValueFontSize = getFontSizeMatchingWidth(maxTextWidth, gaugeValueFontSize, (fontSize) => computeTextWidth(ctx, gaugeLabel, { fontSize }, "px"));
22504
+ }
22505
+ const minLabelPosition = {
22506
+ x: gaugeRect.x + gaugeArcWidth / 2,
22507
+ y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
22508
+ };
22509
+ const maxLabelPosition = {
22510
+ x: gaugeRect.x + gaugeRect.width - gaugeArcWidth / 2,
22511
+ y: gaugeRect.y + gaugeRect.height + GAUGE_LABELS_FONT_SIZE,
22512
+ };
22513
+ const textColor = chartMutedFontColor(runtime.background);
22514
+ const inflectionValues = getInflectionValues(runtime, gaugeRect, textColor, ctx);
22515
+ let x = 0, titleWidth = 0, titleHeight = 0;
22516
+ if (runtime.title.text) {
22517
+ ({ width: titleWidth, height: titleHeight } = computeTextDimension(ctx, runtime.title.text, { fontSize: CHART_TITLE_FONT_SIZE, ...runtime.title }, "px"));
22518
+ }
22519
+ switch (runtime.title.align) {
22520
+ case "right":
22521
+ x = boundingRect.width - titleWidth - CHART_PADDING$1;
22522
+ break;
22523
+ case "center":
22524
+ x = (boundingRect.width - titleWidth) / 2;
22525
+ break;
22526
+ case "left":
22527
+ default:
22528
+ x = CHART_PADDING$1;
22529
+ break;
22530
+ }
22531
+ return {
22532
+ width: boundingRect.width,
22533
+ height: boundingRect.height,
22534
+ title: {
22535
+ label: runtime.title.text ?? "",
22536
+ fontSize: runtime.title.fontSize ?? CHART_TITLE_FONT_SIZE,
22537
+ textPosition: {
22538
+ x,
22539
+ y: CHART_PADDING_TOP + titleHeight / 2,
22540
+ },
22541
+ color: runtime.title.color ?? textColor,
22542
+ bold: runtime.title.bold,
22543
+ italic: runtime.title.italic,
22544
+ },
22545
+ backgroundColor: runtime.background,
22546
+ gauge: {
22547
+ rect: gaugeRect,
22548
+ arcWidth: gaugeArcWidth,
22549
+ percentage: clip(gaugePercentage, 0, 1),
22550
+ color: getGaugeColor(runtime),
22551
+ },
22552
+ inflectionValues,
22553
+ gaugeValue: {
22554
+ label: gaugeLabel,
22555
+ textPosition: gaugeValuePosition,
22556
+ fontSize: gaugeValueFontSize,
22557
+ color: textColor,
22558
+ },
22559
+ minLabel: {
22560
+ label: runtime.minValue.label,
22561
+ textPosition: minLabelPosition,
22562
+ fontSize: GAUGE_LABELS_FONT_SIZE,
22563
+ color: textColor,
22564
+ },
22565
+ maxLabel: {
22566
+ label: runtime.maxValue.label,
22567
+ textPosition: maxLabelPosition,
22568
+ fontSize: GAUGE_LABELS_FONT_SIZE,
22569
+ color: textColor,
22570
+ },
22571
+ };
22572
+ }
22573
+ /**
22574
+ * Get the rectangle in which the gauge will be drawn, based on the bounding rectangle of the canvas and leaving
22575
+ * space for the title and labels.
22576
+ */
22577
+ function getGaugeRect(boundingRect, title) {
22578
+ const titleHeight = title ? GAUGE_TITLE_SECTION_HEIGHT : 0;
22579
+ const drawHeight = boundingRect.height - GAUGE_PADDING_BOTTOM - titleHeight - GAUGE_PADDING_TOP;
22580
+ const drawWidth = boundingRect.width - GAUGE_PADDING_SIDE * 2;
22581
+ let gaugeWidth;
22582
+ let gaugeHeight;
22583
+ if (drawWidth > 2 * drawHeight) {
22584
+ gaugeWidth = 2 * drawHeight;
22585
+ gaugeHeight = drawHeight;
22586
+ }
22587
+ else {
22588
+ gaugeWidth = drawWidth;
22589
+ gaugeHeight = drawWidth / 2;
22590
+ }
22591
+ const gaugeX = GAUGE_PADDING_SIDE + (drawWidth - gaugeWidth) / 2;
22592
+ const gaugeY = titleHeight + GAUGE_PADDING_TOP + (drawHeight - gaugeHeight) / 2;
22593
+ return {
22594
+ x: gaugeX,
22595
+ y: gaugeY,
22596
+ width: gaugeWidth,
22597
+ height: gaugeHeight,
22598
+ };
22599
+ }
22600
+ /**
22601
+ * 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).
22602
+ *
22603
+ * Also compute an offset for the text so that it doesn't overlap with other text.
22604
+ */
22605
+ function getInflectionValues(runtime, gaugeRect, textColor, ctx) {
22606
+ const maxValue = runtime.maxValue;
22607
+ const minValue = runtime.minValue;
22608
+ const gaugeCircleCenter = {
22609
+ x: gaugeRect.x + gaugeRect.width / 2,
22610
+ y: gaugeRect.y + gaugeRect.height,
22611
+ };
22612
+ const textStyle = { fontSize: GAUGE_LABELS_FONT_SIZE };
22613
+ const inflectionValues = [];
22614
+ const inflectionValuesTextRects = [];
22615
+ for (const inflectionValue of runtime.inflectionValues) {
22616
+ const percentage = (inflectionValue.value - minValue.value) / (maxValue.value - minValue.value);
22617
+ const labelWidth = computeTextWidth(ctx, inflectionValue.label, textStyle, "px");
22618
+ const angle = Math.PI - Math.PI * percentage;
22619
+ const textRect = getRectangleTangentToCircle(angle, // angle between X axis and the point where the rectangle is tangent to the circle
22620
+ gaugeRect.height + GAUGE_INFLECTION_LABEL_BOTTOM_MARGIN, // radius of the gauge circle + margin below text
22621
+ gaugeCircleCenter.x, // center of the gauge circle
22622
+ gaugeCircleCenter.y, // center of the gauge circle
22623
+ labelWidth + 2, // width of the text + some margin
22624
+ GAUGE_LABELS_FONT_SIZE // height of the text
22625
+ );
22626
+ let offset = inflectionValuesTextRects.some((rect) => doRectanglesIntersect(rect, textRect))
22627
+ ? GAUGE_LABELS_FONT_SIZE
22628
+ : 0;
22629
+ inflectionValuesTextRects.push(textRect);
22630
+ inflectionValues.push({
22631
+ rotation: angle,
22632
+ label: inflectionValue.label,
22633
+ fontSize: GAUGE_LABELS_FONT_SIZE,
22634
+ color: textColor,
22635
+ offset,
22636
+ });
22637
+ }
22638
+ return inflectionValues;
22639
+ }
22640
+ function getGaugeColor(runtime) {
22641
+ const gaugeValue = runtime.gaugeValue?.value;
22642
+ if (gaugeValue === undefined) {
22643
+ return GAUGE_BACKGROUND_COLOR;
22644
+ }
22645
+ for (let i = 0; i < runtime.inflectionValues.length; i++) {
22646
+ const inflectionValue = runtime.inflectionValues[i];
22647
+ if (inflectionValue.operator === "<" && gaugeValue < inflectionValue.value) {
22648
+ return runtime.colors[i];
22649
+ }
22650
+ else if (inflectionValue.operator === "<=" && gaugeValue <= inflectionValue.value) {
22651
+ return runtime.colors[i];
22652
+ }
22653
+ }
22654
+ return runtime.colors.at(-1);
22655
+ }
22656
+ function getSegmentsOfRectangle(rectangle) {
22657
+ return [
22658
+ { start: rectangle.topLeft, end: rectangle.topRight },
22659
+ { start: rectangle.topRight, end: rectangle.bottomRight },
22660
+ { start: rectangle.bottomRight, end: rectangle.bottomLeft },
22661
+ { start: rectangle.bottomLeft, end: rectangle.topLeft },
22662
+ ];
22663
+ }
22664
+ /**
22665
+ * Check if two segment intersect. The case where the segments are colinear (both segments on the same line)
22666
+ * is not handled.
22667
+ */
22668
+ function doSegmentIntersect(segment1, segment2) {
22669
+ const A = segment1.start;
22670
+ const B = segment1.end;
22671
+ const C = segment2.start;
22672
+ const D = segment2.end;
22673
+ /**
22674
+ * Line segment intersection algorithm
22675
+ * https://bryceboe.com/2006/10/23/line-segment-intersection-algorithm/
22676
+ */
22677
+ function ccw(a, b, c) {
22678
+ return (c.y - a.y) * (b.x - a.x) > (b.y - a.y) * (c.x - a.x);
22679
+ }
22680
+ return ccw(A, C, D) !== ccw(B, C, D) && ccw(A, B, C) !== ccw(A, B, D);
22681
+ }
22682
+ function doRectanglesIntersect(rect1, rect2) {
22683
+ const segments1 = getSegmentsOfRectangle(rect1);
22684
+ const segments2 = getSegmentsOfRectangle(rect2);
22685
+ for (const segment1 of segments1) {
22686
+ for (const segment2 of segments2) {
22687
+ if (doSegmentIntersect(segment1, segment2)) {
22688
+ return true;
22689
+ }
22690
+ }
22691
+ }
22692
+ return false;
22693
+ }
22694
+ /**
22695
+ * Get the rectangle that is tangent to a circle at a given angle.
22696
+ *
22697
+ * @param angle angle between X axis and the point where the rectangle is tangent to the circle
22698
+ */
22699
+ function getRectangleTangentToCircle(angle, radius, circleCenterX, circleCenterY, rectWidth, rectHeight) {
22700
+ const cos = Math.cos(angle);
22701
+ const sin = Math.sin(angle);
22702
+ // x, y are the distance from the center of the circle to the point where the rectangle is tangent to the circle
22703
+ const x = cos * radius;
22704
+ const y = sin * radius;
22705
+ // x2, y2 are the distance from the point the rectangle is tangent to the circle to the bottom left corner of the rectangle
22706
+ const x2 = sin * (rectWidth / 2); // cos(angle + 90°) = sin(angle)
22707
+ const y2 = cos * (rectWidth / 2);
22708
+ const bottomRight = {
22709
+ x: x + x2 + circleCenterX,
22710
+ y: circleCenterY - (y - y2),
22711
+ };
22712
+ const bottomLeft = {
22713
+ x: x - x2 + circleCenterX,
22714
+ y: circleCenterY - (y + y2),
22715
+ };
22716
+ // Same as above but for the top corners of the rectangle (radius + rectangle height instead of radius)
22717
+ const xp = cos * (radius + rectHeight);
22718
+ const yp = sin * (radius + rectHeight);
22719
+ const topLeft = {
22720
+ x: xp - x2 + circleCenterX,
22721
+ y: circleCenterY - (yp + y2),
22722
+ };
22723
+ const topRight = {
22724
+ x: xp + x2 + circleCenterX,
22725
+ y: circleCenterY - (yp - y2),
22726
+ };
22727
+ return { bottomLeft, bottomRight, topRight, topLeft };
22728
+ }
22729
+
22770
22730
  class GaugeChartComponent extends owl.Component {
22771
22731
  static template = "o-spreadsheet-GaugeChartComponent";
22772
22732
  canvas = owl.useRef("chartContainer");
@@ -22799,6 +22759,82 @@ stores.inject(MyMetaStore, storeInstance);
22799
22759
  return color;
22800
22760
  }
22801
22761
 
22762
+ const CHART_COMMON_OPTIONS = {
22763
+ // https://www.chartjs.org/docs/latest/general/responsive.html
22764
+ responsive: true, // will resize when its container is resized
22765
+ maintainAspectRatio: false, // doesn't maintain the aspect ratio (width/height =2 by default) so the user has the choice of the exact layout
22766
+ elements: {
22767
+ line: {
22768
+ fill: false, // do not fill the area under line charts
22769
+ },
22770
+ point: {
22771
+ hitRadius: 15, // increased hit radius to display point tooltip when hovering nearby
22772
+ },
22773
+ },
22774
+ animation: false,
22775
+ };
22776
+ function truncateLabel(label) {
22777
+ if (!label) {
22778
+ return "";
22779
+ }
22780
+ if (label.length > MAX_CHAR_LABEL) {
22781
+ return label.substring(0, MAX_CHAR_LABEL) + "…";
22782
+ }
22783
+ return label;
22784
+ }
22785
+ function chartToImage(runtime, figure, type) {
22786
+ // wrap the canvas in a div with a fixed size because chart.js would
22787
+ // fill the whole page otherwise
22788
+ const div = document.createElement("div");
22789
+ div.style.width = `${figure.width}px`;
22790
+ div.style.height = `${figure.height}px`;
22791
+ const canvas = document.createElement("canvas");
22792
+ div.append(canvas);
22793
+ canvas.setAttribute("width", figure.width.toString());
22794
+ canvas.setAttribute("height", figure.height.toString());
22795
+ // we have to add the canvas to the DOM otherwise it won't be rendered
22796
+ document.body.append(div);
22797
+ if ("chartJsConfig" in runtime) {
22798
+ const config = deepCopy(runtime.chartJsConfig);
22799
+ config.plugins = [backgroundColorChartJSPlugin];
22800
+ const Chart = getChartJSConstructor();
22801
+ const chart = new Chart(canvas, config);
22802
+ const imgContent = chart.toBase64Image();
22803
+ chart.destroy();
22804
+ div.remove();
22805
+ return imgContent;
22806
+ }
22807
+ else if (type === "scorecard") {
22808
+ const design = getScorecardConfiguration(figure, runtime);
22809
+ drawScoreChart(design, canvas);
22810
+ const imgContent = canvas.toDataURL();
22811
+ div.remove();
22812
+ return imgContent;
22813
+ }
22814
+ else if (type === "gauge") {
22815
+ drawGaugeChart(canvas, runtime);
22816
+ const imgContent = canvas.toDataURL();
22817
+ div.remove();
22818
+ return imgContent;
22819
+ }
22820
+ return undefined;
22821
+ }
22822
+ /**
22823
+ * Custom chart.js plugin to set the background color of the canvas
22824
+ * https://github.com/chartjs/Chart.js/blob/8fdf76f8f02d31684d34704341a5d9217e977491/docs/configuration/canvas-background.md
22825
+ */
22826
+ const backgroundColorChartJSPlugin = {
22827
+ id: "customCanvasBackgroundColor",
22828
+ beforeDraw: (chart) => {
22829
+ const { ctx } = chart;
22830
+ ctx.save();
22831
+ ctx.globalCompositeOperation = "destination-over";
22832
+ ctx.fillStyle = "#ffffff";
22833
+ ctx.fillRect(0, 0, chart.width, chart.height);
22834
+ ctx.restore();
22835
+ },
22836
+ };
22837
+
22802
22838
  /**
22803
22839
  * Represent a raw XML string
22804
22840
  */
@@ -34038,7 +34074,6 @@ stores.inject(MyMetaStore, storeInstance);
34038
34074
  drawScoreChart: drawScoreChart,
34039
34075
  formatChartDatasetValue: formatChartDatasetValue,
34040
34076
  formatTickValue: formatTickValue,
34041
- getChartJSConstructor: getChartJSConstructor,
34042
34077
  getChartPositionAtCenterOfViewport: getChartPositionAtCenterOfViewport,
34043
34078
  getDefinedAxis: getDefinedAxis,
34044
34079
  getPieColors: getPieColors,
@@ -44748,7 +44783,8 @@ stores.inject(MyMetaStore, storeInstance);
44748
44783
  &.pivot-dimension-invalid {
44749
44784
  background-color: #ffdddd;
44750
44785
  border-color: red !important;
44751
- select {
44786
+ select,
44787
+ input {
44752
44788
  background-color: #ffdddd;
44753
44789
  }
44754
44790
  }
@@ -46611,7 +46647,7 @@ stores.inject(MyMetaStore, storeInstance);
46611
46647
  this.notification.notifyUser({
46612
46648
  type: "info",
46613
46649
  text: _t("Pivot updates only work with dynamic pivot tables. Use %s or re-insert the static pivot from the Data menu.", pivotExample),
46614
- sticky: false,
46650
+ sticky: true,
46615
46651
  });
46616
46652
  }
46617
46653
  }
@@ -52853,6 +52889,15 @@ stores.inject(MyMetaStore, storeInstance);
52853
52889
  case "SET_BORDER":
52854
52890
  this.setBorder(cmd.sheetId, cmd.col, cmd.row, cmd.border);
52855
52891
  break;
52892
+ case "SET_BORDERS_ON_TARGET":
52893
+ for (const zone of cmd.target) {
52894
+ for (let row = zone.top; row <= zone.bottom; row++) {
52895
+ for (let col = zone.left; col <= zone.right; col++) {
52896
+ this.setBorder(cmd.sheetId, col, row, cmd.border);
52897
+ }
52898
+ }
52899
+ }
52900
+ break;
52856
52901
  case "SET_ZONE_BORDERS":
52857
52902
  if (cmd.border) {
52858
52903
  const target = cmd.target.map((zone) => this.getters.expandZone(cmd.sheetId, zone));
@@ -62579,25 +62624,6 @@ stores.inject(MyMetaStore, storeInstance);
62579
62624
  case "AUTOFILL_AUTO":
62580
62625
  this.autofillAuto();
62581
62626
  break;
62582
- case "AUTOFILL_CELL":
62583
- this.autoFillMerge(cmd.originCol, cmd.originRow, cmd.col, cmd.row);
62584
- const sheetId = this.getters.getActiveSheetId();
62585
- this.dispatch("UPDATE_CELL", {
62586
- sheetId,
62587
- col: cmd.col,
62588
- row: cmd.row,
62589
- style: cmd.style || null,
62590
- content: cmd.content || "",
62591
- format: cmd.format || "",
62592
- });
62593
- this.dispatch("SET_BORDER", {
62594
- sheetId,
62595
- col: cmd.col,
62596
- row: cmd.row,
62597
- border: cmd.border,
62598
- });
62599
- this.autofillCF(cmd.originCol, cmd.originRow, cmd.col, cmd.row);
62600
- this.autofillDV(cmd.originCol, cmd.originRow, cmd.col, cmd.row);
62601
62627
  }
62602
62628
  }
62603
62629
  // ---------------------------------------------------------------------------
@@ -62621,6 +62647,7 @@ stores.inject(MyMetaStore, storeInstance);
62621
62647
  }
62622
62648
  const source = this.getters.getSelectedZone();
62623
62649
  const target = this.autofillZone;
62650
+ const autofillCellsData = [];
62624
62651
  switch (this.direction) {
62625
62652
  case "down" /* DIRECTION.DOWN */:
62626
62653
  for (let col = source.left; col <= source.right; col++) {
@@ -62630,7 +62657,7 @@ stores.inject(MyMetaStore, storeInstance);
62630
62657
  }
62631
62658
  const generator = this.createGenerator(xcs);
62632
62659
  for (let row = target.top; row <= target.bottom; row++) {
62633
- this.computeNewCell(generator, col, row, apply);
62660
+ autofillCellsData.push(this.computeNewCell(generator, col, row));
62634
62661
  }
62635
62662
  }
62636
62663
  break;
@@ -62642,7 +62669,7 @@ stores.inject(MyMetaStore, storeInstance);
62642
62669
  }
62643
62670
  const generator = this.createGenerator(xcs);
62644
62671
  for (let row = target.bottom; row >= target.top; row--) {
62645
- this.computeNewCell(generator, col, row, apply);
62672
+ autofillCellsData.push(this.computeNewCell(generator, col, row));
62646
62673
  }
62647
62674
  }
62648
62675
  break;
@@ -62654,7 +62681,7 @@ stores.inject(MyMetaStore, storeInstance);
62654
62681
  }
62655
62682
  const generator = this.createGenerator(xcs);
62656
62683
  for (let col = target.right; col >= target.left; col--) {
62657
- this.computeNewCell(generator, col, row, apply);
62684
+ autofillCellsData.push(this.computeNewCell(generator, col, row));
62658
62685
  }
62659
62686
  }
62660
62687
  break;
@@ -62666,12 +62693,26 @@ stores.inject(MyMetaStore, storeInstance);
62666
62693
  }
62667
62694
  const generator = this.createGenerator(xcs);
62668
62695
  for (let col = target.left; col <= target.right; col++) {
62669
- this.computeNewCell(generator, col, row, apply);
62696
+ autofillCellsData.push(this.computeNewCell(generator, col, row));
62670
62697
  }
62671
62698
  }
62672
62699
  break;
62673
62700
  }
62674
62701
  if (apply) {
62702
+ const bordersZones = {};
62703
+ const cfNewRanges = {};
62704
+ const dvNewZones = {};
62705
+ const sheetId = this.getters.getActiveSheetId();
62706
+ for (const data of autofillCellsData) {
62707
+ this.collectBordersData(data, bordersZones);
62708
+ this.autofillMerge(sheetId, data);
62709
+ this.autofillCell(sheetId, data);
62710
+ this.collectConditionalFormatsData(sheetId, data, cfNewRanges);
62711
+ this.collectDataValidationsData(sheetId, data, dvNewZones);
62712
+ }
62713
+ this.autofillBorders(sheetId, bordersZones);
62714
+ this.autofillConditionalFormats(sheetId, cfNewRanges);
62715
+ this.autofillDataValidations(sheetId, dvNewZones);
62675
62716
  this.autofillZone = undefined;
62676
62717
  this.selection.resizeAnchorZone(this.direction, this.steps);
62677
62718
  this.lastCellSelected = {};
@@ -62680,6 +62721,95 @@ stores.inject(MyMetaStore, storeInstance);
62680
62721
  this.tooltip = undefined;
62681
62722
  }
62682
62723
  }
62724
+ collectBordersData(data, bordersPositions) {
62725
+ const key = JSON.stringify(data.border);
62726
+ if (!(key in bordersPositions)) {
62727
+ bordersPositions[key] = [];
62728
+ }
62729
+ bordersPositions[key].push(positionToZone({ col: data.col, row: data.row }));
62730
+ }
62731
+ collectConditionalFormatsData(sheetId, data, cfNewRanges) {
62732
+ const { originCol, originRow, col, row } = data;
62733
+ const cfsAtOrigin = this.getters.getRulesByCell(sheetId, originCol, originRow);
62734
+ const xc = toXC(col, row);
62735
+ for (const cf of cfsAtOrigin) {
62736
+ if (!(cf.id in cfNewRanges)) {
62737
+ cfNewRanges[cf.id] = [];
62738
+ }
62739
+ cfNewRanges[cf.id].push(xc);
62740
+ }
62741
+ }
62742
+ collectDataValidationsData(sheetId, data, dvNewZones) {
62743
+ const { originCol, originRow, col, row } = data;
62744
+ const cellPosition = { sheetId, col: originCol, row: originRow };
62745
+ const dvsAtOrigin = this.getters.getValidationRuleForCell(cellPosition);
62746
+ if (!dvsAtOrigin) {
62747
+ return;
62748
+ }
62749
+ if (!(dvsAtOrigin.id in dvNewZones)) {
62750
+ dvNewZones[dvsAtOrigin.id] = [];
62751
+ }
62752
+ dvNewZones[dvsAtOrigin.id].push(positionToZone({ col, row }));
62753
+ }
62754
+ autofillCell(sheetId, data) {
62755
+ this.dispatch("UPDATE_CELL", {
62756
+ sheetId,
62757
+ col: data.col,
62758
+ row: data.row,
62759
+ content: data.content || "",
62760
+ style: data.style || null,
62761
+ format: data.format || "",
62762
+ });
62763
+ // Still usefull in odoo ATM to autofill field sync
62764
+ this.dispatch("AUTOFILL_CELL", data);
62765
+ }
62766
+ autofillBorders(sheetId, bordersPositions) {
62767
+ for (const stringifiedBorder in bordersPositions) {
62768
+ const border = stringifiedBorder === "undefined" ? undefined : JSON.parse(stringifiedBorder);
62769
+ this.dispatch("SET_BORDERS_ON_TARGET", {
62770
+ sheetId,
62771
+ border,
62772
+ target: recomputeZones(bordersPositions[stringifiedBorder]),
62773
+ });
62774
+ }
62775
+ }
62776
+ autofillConditionalFormats(sheetId, cfNewRanges) {
62777
+ for (const cfId in cfNewRanges) {
62778
+ const changes = cfNewRanges[cfId];
62779
+ const cf = this.getters.getConditionalFormats(sheetId).find((cf) => cf.id === cfId);
62780
+ if (!cf) {
62781
+ continue;
62782
+ }
62783
+ const newCfRanges = this.getters.getAdaptedCfRanges(sheetId, cf, changes.map(toZone), []);
62784
+ if (newCfRanges) {
62785
+ this.dispatch("ADD_CONDITIONAL_FORMAT", {
62786
+ cf: {
62787
+ id: cf.id,
62788
+ rule: cf.rule,
62789
+ stopIfTrue: cf.stopIfTrue,
62790
+ },
62791
+ ranges: newCfRanges,
62792
+ sheetId,
62793
+ });
62794
+ }
62795
+ }
62796
+ }
62797
+ autofillDataValidations(sheetId, dvNewZones) {
62798
+ for (const dvId in dvNewZones) {
62799
+ const changes = dvNewZones[dvId];
62800
+ const dvOrigin = this.getters.getDataValidationRule(sheetId, dvId);
62801
+ if (!dvOrigin) {
62802
+ continue;
62803
+ }
62804
+ const dvRangesXcs = dvOrigin.ranges.map((range) => range.zone);
62805
+ const newDvRanges = recomputeZones(dvRangesXcs.concat(changes), []);
62806
+ this.dispatch("ADD_DATA_VALIDATION_RULE", {
62807
+ rule: dvOrigin,
62808
+ ranges: newDvRanges.map((zone) => this.getters.getRangeDataFromZone(sheetId, zone)),
62809
+ sheetId,
62810
+ });
62811
+ }
62812
+ }
62683
62813
  /**
62684
62814
  * Select a cell which becomes the last cell of the autofillZone
62685
62815
  */
@@ -62758,22 +62888,20 @@ stores.inject(MyMetaStore, storeInstance);
62758
62888
  /**
62759
62889
  * Generate the next cell
62760
62890
  */
62761
- computeNewCell(generator, col, row, apply) {
62891
+ computeNewCell(generator, col, row) {
62762
62892
  const { cellData, tooltip, origin } = generator.next();
62763
62893
  const { content, style, border, format } = cellData;
62764
62894
  this.tooltip = tooltip;
62765
- if (apply) {
62766
- this.dispatch("AUTOFILL_CELL", {
62767
- originCol: origin.col,
62768
- originRow: origin.row,
62769
- col,
62770
- row,
62771
- content,
62772
- style,
62773
- border,
62774
- format,
62775
- });
62776
- }
62895
+ return {
62896
+ originCol: origin.col,
62897
+ originRow: origin.row,
62898
+ col,
62899
+ row,
62900
+ content,
62901
+ style,
62902
+ border,
62903
+ format,
62904
+ };
62777
62905
  }
62778
62906
  /**
62779
62907
  * Get the rule associated to the current cell
@@ -62841,8 +62969,8 @@ stores.inject(MyMetaStore, storeInstance);
62841
62969
  ? position[first].value
62842
62970
  : position[second].value;
62843
62971
  }
62844
- autoFillMerge(originCol, originRow, col, row) {
62845
- const sheetId = this.getters.getActiveSheetId();
62972
+ autofillMerge(sheetId, data) {
62973
+ const { originCol, originRow, col, row } = data;
62846
62974
  const position = { sheetId, col, row };
62847
62975
  const originPosition = { sheetId, col: originCol, row: originRow };
62848
62976
  if (this.getters.isInMerge(position) && !this.getters.isInMerge(originPosition)) {
@@ -62869,35 +62997,6 @@ stores.inject(MyMetaStore, storeInstance);
62869
62997
  });
62870
62998
  }
62871
62999
  }
62872
- autofillCF(originCol, originRow, col, row) {
62873
- const sheetId = this.getters.getActiveSheetId();
62874
- const cfOrigin = this.getters.getRulesByCell(sheetId, originCol, originRow);
62875
- for (const cf of cfOrigin) {
62876
- const newCfRanges = this.getters.getAdaptedCfRanges(sheetId, cf, [positionToZone({ col, row })], []);
62877
- if (newCfRanges) {
62878
- this.dispatch("ADD_CONDITIONAL_FORMAT", {
62879
- cf: deepCopy(cf),
62880
- ranges: newCfRanges,
62881
- sheetId,
62882
- });
62883
- }
62884
- }
62885
- }
62886
- autofillDV(originCol, originRow, col, row) {
62887
- const sheetId = this.getters.getActiveSheetId();
62888
- const cellPosition = { sheetId, col: originCol, row: originRow };
62889
- const dvOrigin = this.getters.getValidationRuleForCell(cellPosition);
62890
- if (!dvOrigin) {
62891
- return;
62892
- }
62893
- const dvRangesZones = dvOrigin.ranges.map((range) => range.zone);
62894
- const newDvRanges = recomputeZones(dvRangesZones.concat(positionToZone({ col, row })), []);
62895
- this.dispatch("ADD_DATA_VALIDATION_RULE", {
62896
- rule: dvOrigin,
62897
- ranges: newDvRanges.map((zone) => this.getters.getRangeDataFromZone(sheetId, zone)),
62898
- sheetId,
62899
- });
62900
- }
62901
63000
  // ---------------------------------------------------------------------------
62902
63001
  // Grid rendering
62903
63002
  // ---------------------------------------------------------------------------
@@ -75427,6 +75526,7 @@ stores.inject(MyMetaStore, storeInstance);
75427
75526
  supportedPivotPositionalFormulaRegistry,
75428
75527
  pivotToFunctionValueRegistry,
75429
75528
  migrationStepRegistry,
75529
+ chartJsExtensionRegistry,
75430
75530
  };
75431
75531
  const helpers = {
75432
75532
  arg,
@@ -75625,9 +75725,9 @@ stores.inject(MyMetaStore, storeInstance);
75625
75725
  exports.tokenize = tokenize;
75626
75726
 
75627
75727
 
75628
- __info__.version = "18.1.13";
75629
- __info__.date = "2025-03-26T12:48:31.680Z";
75630
- __info__.hash = "45ec54c";
75728
+ __info__.version = "18.1.15";
75729
+ __info__.date = "2025-04-14T17:17:30.890Z";
75730
+ __info__.hash = "ddaea83";
75631
75731
 
75632
75732
 
75633
75733
  })(this.o_spreadsheet = this.o_spreadsheet || {}, owl);