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