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