{
+> extends ChartWithAxisDesignPanel {
static template = "o-spreadsheet-GenericZoomableChartDesignPanel";
static components = {
...ChartWithAxisDesignPanel.components,
diff --git a/src/components/translations_terms.ts b/src/components/translations_terms.ts
index 793e370653..5c70936d4e 100644
--- a/src/components/translations_terms.ts
+++ b/src/components/translations_terms.ts
@@ -50,6 +50,24 @@ export const ChartTerms: {
CumulativeData: _t("Cumulative data"),
TreatLabelsAsText: _t("Treat labels as text"),
AggregatedChart: _t("Aggregate"),
+ PointLabels: _t("Point labels"),
+ PointLabelsMenuAdd: _t("Add labels"),
+ PointLabelsMenuRemove: _t("Remove labels"),
+ PointSizesTitle: _t("Point size"),
+ PointSizeValueLabel: _t("Size"),
+ PointSizeRangeHelper: _t("Use the data series menu to select a point size range."),
+ PointSizeMenuAdd: _t("Add point size range"),
+ PointSizeMenuRemove: _t("Remove point size range"),
+ ShowValuesModeTitle: _t("Text to display"),
+ ShowValuesModes: {
+ Value: _t("Use y-value as title"),
+ Label: _t("Use label as title"),
+ },
+ PointSizeModes: {
+ Fixed: _t("Fixed size"),
+ Range: _t("Use range values"),
+ Value: _t("Use data values"),
+ },
Errors: {
Unexpected: _t("The chart definition is invalid for an unknown reason"),
// BASIC CHART ERRORS (LINE | BAR | PIE)
diff --git a/src/constants.ts b/src/constants.ts
index ceb2657a7c..b2ba6f3dd7 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -217,6 +217,9 @@ export const DEFAULT_SCORECARD_BASELINE_FONT_SIZE = 16;
export const LINE_FILL_TRANSPARENCY = 0.4;
export const LINE_DATA_POINT_RADIUS = 3;
+export const SCATTER_MIN_POINT_RADIUS = 1;
+export const SCATTER_MAX_POINT_RADIUS = 12;
+export const SCATTER_DEFAULT_POINT_RADIUS = LINE_DATA_POINT_RADIUS;
export const DEFAULT_WINDOW_SIZE = 2;
// session
diff --git a/src/helpers/figures/charts/chart_common.ts b/src/helpers/figures/charts/chart_common.ts
index 5663f06c37..46209483c8 100644
--- a/src/helpers/figures/charts/chart_common.ts
+++ b/src/helpers/figures/charts/chart_common.ts
@@ -1,4 +1,10 @@
-import { DEFAULT_WINDOW_SIZE, MAX_CHAR_LABEL } from "../../../constants";
+import {
+ DEFAULT_WINDOW_SIZE,
+ MAX_CHAR_LABEL,
+ SCATTER_DEFAULT_POINT_RADIUS,
+ SCATTER_MAX_POINT_RADIUS,
+ SCATTER_MIN_POINT_RADIUS,
+} from "../../../constants";
import { _t } from "../../../translation";
import {
ApplyRangeChange,
@@ -73,6 +79,26 @@ export function updateChartRangesWithDataSets(
};
}
}
+ if (ds.pointLabelRange) {
+ const pointLabelRange = adaptChartRange(ds.pointLabelRange, applyChange);
+ if (pointLabelRange !== ds.pointLabelRange) {
+ isStale = true;
+ ds = {
+ ...ds,
+ pointLabelRange,
+ };
+ }
+ }
+ if (ds.pointSizeRange) {
+ const pointSizeRange = adaptChartRange(ds.pointSizeRange, applyChange);
+ if (pointSizeRange !== ds.pointSizeRange) {
+ isStale = true;
+ ds = {
+ ...ds,
+ pointSizeRange,
+ };
+ }
+ }
const dataRange = adaptChartRange(ds.dataRange, applyChange);
if (
dataRange === undefined ||
@@ -118,6 +144,14 @@ export function duplicateDataSetsInDuplicatedSheet(
labelCell: ds.labelCell
? duplicateRangeInDuplicatedSheet(sheetIdFrom, sheetIdTo, ds.labelCell)
: undefined,
+ pointLabelRange: ds.pointLabelRange
+ ? duplicateRangeInDuplicatedSheet(sheetIdFrom, sheetIdTo, ds.pointLabelRange)
+ : undefined,
+ pointSizeRange: ds.pointSizeRange
+ ? duplicateRangeInDuplicatedSheet(sheetIdFrom, sheetIdTo, ds.pointSizeRange)
+ : undefined,
+ pointSizeMode: ds.pointSizeMode,
+ pointSize: ds.pointSize,
};
});
}
@@ -171,6 +205,12 @@ export function createDataSets(
if (invalidSheetName || invalidXc) {
continue;
}
+ const pointLabelRange = dataSet.pointLabelRange
+ ? getters.getRangeFromSheetXC(sheetId, dataSet.pointLabelRange)
+ : undefined;
+ const pointSizeRange = dataSet.pointSizeRange
+ ? getters.getRangeFromSheetXC(sheetId, dataSet.pointSizeRange)
+ : undefined;
// It's a rectangle. We treat all columns (arbitrary) as different data series.
if (zone.left !== zone.right && zone.top !== zone.bottom) {
if (zone.right === undefined) {
@@ -202,6 +242,10 @@ export function createDataSets(
rightYAxis: dataSet.yAxisId === "y1",
customLabel: dataSet.label,
trend: dataSet.trend,
+ pointLabelRange,
+ pointSizeRange,
+ pointSizeMode: dataSet.pointSizeMode,
+ pointSize: dataSet.pointSize,
});
}
} else {
@@ -224,6 +268,10 @@ export function createDataSets(
rightYAxis: dataSet.yAxisId === "y1",
customLabel: dataSet.label,
trend: dataSet.trend,
+ pointLabelRange,
+ pointSizeRange,
+ pointSizeMode: dataSet.pointSizeMode,
+ pointSize: dataSet.pointSize,
});
}
}
@@ -344,6 +392,30 @@ export function transformChartDefinitionWithDataSetsWithZone range.pointLabelRange && !rangeReference.test(range.pointLabelRange)
+ ) !== undefined;
+ if (invalidPointLabelRanges) {
+ return CommandResult.InvalidDataSet;
+ }
+ const invalidPointSizeRanges =
+ definition.dataSets.find(
+ (range) => range.pointSizeRange && !rangeReference.test(range.pointSizeRange)
+ ) !== undefined;
+ if (invalidPointSizeRanges) {
+ return CommandResult.InvalidDataSet;
+ }
const zones = definition.dataSets.map((ds) => toUnboundedZone(ds.dataRange));
if (zones.some((zone) => zone.top !== zone.bottom && isFullRow(zone))) {
return CommandResult.InvalidDataSet;
@@ -502,3 +588,11 @@ export function truncateLabel(label: string | undefined, maxLen: number = MAX_CH
export function isTrendLineAxis(axisID: string) {
return axisID === TREND_LINE_XAXIS_ID || axisID === MOVING_AVERAGE_TREND_LINE_XAXIS_ID;
}
+
+export function adjustPointSizeRadius(size: number | undefined | null): number {
+ if (size === undefined || size === null || !isFinite(size)) {
+ return SCATTER_DEFAULT_POINT_RADIUS;
+ }
+ const absolute = Math.abs(size);
+ return Math.min(SCATTER_MAX_POINT_RADIUS, Math.max(SCATTER_MIN_POINT_RADIUS, absolute));
+}
diff --git a/src/helpers/figures/charts/runtime/chart_data_extractor.ts b/src/helpers/figures/charts/runtime/chart_data_extractor.ts
index 6ceacb27ef..5bd6f17c9b 100644
--- a/src/helpers/figures/charts/runtime/chart_data_extractor.ts
+++ b/src/helpers/figures/charts/runtime/chart_data_extractor.ts
@@ -1,5 +1,10 @@
import { Point } from "chart.js";
import { ChartTerms } from "../../../../components/translations_terms";
+import {
+ SCATTER_DEFAULT_POINT_RADIUS,
+ SCATTER_MAX_POINT_RADIUS,
+ SCATTER_MIN_POINT_RADIUS,
+} from "../../../../constants";
import {
evaluatePolynomial,
expM,
@@ -37,14 +42,15 @@ import {
GeoChartRuntimeGenerationArgs,
} from "../../../../types/chart/geo_chart";
import { RadarChartDefinition } from "../../../../types/chart/radar_chart";
+import { ScatterPointSizeMode } from "../../../../types/chart/scatter_chart";
import { TreeMapChartDefinition } from "../../../../types/chart/tree_map_chart";
import { timeFormatLuxonCompatible } from "../../../chart_date";
import { isDateTimeFormat } from "../../../format/format";
-import { deepCopy, findNextDefinedValue, range } from "../../../misc";
+import { deepCopy, findNextDefinedValue, isDefined, range } from "../../../misc";
import { isNumber } from "../../../numbers";
import { recomputeZones } from "../../../recompute_zones";
import { positions } from "../../../zones";
-import { shouldRemoveFirstLabel } from "../chart_common";
+import { adjustPointSizeRadius, shouldRemoveFirstLabel } from "../chart_common";
export function getBarChartData(
definition: GenericDefinition,
@@ -652,6 +658,12 @@ function fixEmptyLabelsForDateCharts(
newLabels[i] = findNextDefinedValue(newLabels, i);
for (const ds of newDatasets) {
ds.data[i] = undefined;
+ if (ds.pointLabels) {
+ ds.pointLabels[i] = undefined;
+ }
+ if (ds.pointSizes) {
+ ds.pointSizes[i] = undefined;
+ }
}
}
}
@@ -674,6 +686,95 @@ export function getData(getters: Getters, ds: DataSet): (CellValue | undefined)[
return [];
}
+function getPointLabels(getters: Getters, ds: DataSet): (string | undefined)[] {
+ if (!ds.pointLabelRange) {
+ return [];
+ }
+ const labelCellZone = ds.labelCell ? [ds.labelCell.zone] : [];
+ const zone = recomputeZones([ds.pointLabelRange.zone], labelCellZone)[0];
+ if (!zone) {
+ return [];
+ }
+ const range = getters.getRangeFromZone(ds.pointLabelRange.sheetId, zone);
+ return getters
+ .getRangeFormattedValues(range)
+ .map((value) => (value === undefined || value === null ? undefined : String(value)));
+}
+
+function getPointSizesFromRange(getters: Getters, ds: DataSet): number[] {
+ if (!ds.pointSizeRange) {
+ return [];
+ }
+ const labelCellZone = ds.labelCell ? [ds.labelCell.zone] : [];
+ const zone = recomputeZones([ds.pointSizeRange.zone], labelCellZone)[0];
+ if (!zone) {
+ return [];
+ }
+ const range = getters.getRangeFromZone(ds.pointSizeRange.sheetId, zone);
+ const values = getters.getRangeValues(range);
+ return normalizePointSizes(values, getters.getLocale());
+}
+
+function normalizePointSizes(_values: (CellValue | undefined)[], locale: Locale): number[] {
+ const values = _values.map((value) => {
+ if (typeof value !== "number") {
+ return undefined;
+ }
+ return Math.abs(value);
+ });
+ const definedValues = values.filter(isDefined);
+ if (!definedValues.length) {
+ return new Array(values.length).fill(SCATTER_DEFAULT_POINT_RADIUS);
+ }
+ const minValue = Math.min(...definedValues);
+ const maxValue = Math.max(...definedValues);
+ if (minValue === maxValue) {
+ const radius = adjustPointSizeRadius(minValue);
+ return values.map((value) => (value === undefined ? SCATTER_DEFAULT_POINT_RADIUS : radius));
+ }
+ return values.map((value) => {
+ if (value === undefined) {
+ return SCATTER_DEFAULT_POINT_RADIUS;
+ }
+ const ratio = (value - minValue) / (maxValue - minValue);
+ return SCATTER_MIN_POINT_RADIUS + ratio * (SCATTER_MAX_POINT_RADIUS - SCATTER_MIN_POINT_RADIUS);
+ });
+}
+
+function adjustArrayLength(data: number[], length: number): number[] {
+ if (data.length === length) {
+ return data;
+ }
+ const result: number[] = [];
+ for (let i = 0; i < length; i++) {
+ result.push(data[i] ?? SCATTER_DEFAULT_POINT_RADIUS);
+ }
+ return result;
+}
+
+function getDatasetPointSizes(
+ getters: Getters,
+ ds: DataSet,
+ data: (CellValue | undefined)[]
+): number[] | undefined {
+ const length = data.length;
+ switch (ds.pointSizeMode as ScatterPointSizeMode | undefined) {
+ case "range":
+ if (!ds.pointSizeRange) {
+ return undefined;
+ }
+ return adjustArrayLength(getPointSizesFromRange(getters, ds), length);
+ case "value":
+ return adjustArrayLength(normalizePointSizes(data, getters.getLocale()), length);
+ case "fixed": {
+ const radius = adjustPointSizeRadius(ds.pointSize);
+ return new Array(length).fill(radius);
+ }
+ default:
+ return undefined;
+ }
+}
+
/**
* Filter the data points that:
* - have neither a label nor a value
@@ -699,6 +800,10 @@ function filterInvalidDataPoints(
data: dataPointsIndexes.map((i) =>
typeof dataset.data[i] === "number" ? dataset.data[i] : null
),
+ pointLabels: dataset.pointLabels
+ ? dataPointsIndexes.map((i) => dataset.pointLabels?.[i])
+ : dataset.pointLabels,
+ pointSizes: dataset.pointSizes && dataPointsIndexes.map((i) => dataset.pointSizes?.[i]),
})),
};
}
@@ -735,6 +840,10 @@ function filterInvalidHierarchicalPoints(
dataSetsValues: hierarchy.map((dataset) => ({
...dataset,
data: dataPointsIndexes.map((i) => dataset.data[i]),
+ pointLabels: dataset.pointLabels
+ ? dataPointsIndexes.map((i) => dataset.pointLabels?.[i])
+ : dataset.pointLabels,
+ pointSizes: dataset.pointSizes && dataPointsIndexes.map((i) => dataset.pointSizes?.[i]),
})),
};
}
@@ -762,6 +871,10 @@ function filterValuesWithDifferentSigns(values: string[], hierarchy: DatasetValu
dataSetsValues: hierarchy.map((dataset) => ({
...dataset,
data: indexesToKeep.map((i) => dataset.data[i]),
+ pointLabels: dataset.pointLabels
+ ? indexesToKeep.map((i) => dataset.pointLabels?.[i])
+ : dataset.pointLabels,
+ pointSizes: dataset.pointSizes && indexesToKeep.map((i) => dataset.pointSizes?.[i]),
})),
};
}
@@ -889,6 +1002,8 @@ function getChartDatasetValues(getters: Getters, dataSets: DataSet[]): DatasetVa
}
let data = ds.dataRange ? getData(getters, ds) : [];
+ const pointLabels = ds.pointLabelRange ? getPointLabels(getters, ds) : undefined;
+ const pointSizes = getDatasetPointSizes(getters, ds, data);
if (
data.every((e) => !e || (typeof e === "string" && !isEvaluationError(e))) &&
data.filter((e) => typeof e === "string").length > 1
@@ -902,7 +1017,7 @@ function getChartDatasetValues(getters: Getters, dataSets: DataSet[]): DatasetVa
) {
hidden = true;
}
- datasetValues.push({ data, label, hidden });
+ datasetValues.push({ data, label, hidden, pointLabels, pointSizes });
}
return datasetValues;
}
diff --git a/src/helpers/figures/charts/runtime/chartjs_dataset.ts b/src/helpers/figures/charts/runtime/chartjs_dataset.ts
index 0090b54937..76d53fe374 100644
--- a/src/helpers/figures/charts/runtime/chartjs_dataset.ts
+++ b/src/helpers/figures/charts/runtime/chartjs_dataset.ts
@@ -55,6 +55,7 @@ import { isDefined, range } from "../../../misc";
import {
MOVING_AVERAGE_TREND_LINE_XAXIS_ID,
TREND_LINE_XAXIS_ID,
+ adjustPointSizeRadius,
getPieColors,
isTrendLineAxis,
} from "../chart_common";
@@ -211,9 +212,18 @@ export function getScatterChartDatasets(
args: ChartRuntimeGenerationArgs
): ChartDataset<"line">[] {
const dataSets: ChartDataset<"line">[] = getLineChartDatasets(definition, args);
- for (const dataSet of dataSets) {
+ for (const [index, dataSet] of dataSets.entries()) {
if (!isTrendLineAxis(dataSet.xAxisID as string)) {
dataSet.showLine = false;
+ const pointSizes = args.dataSetsValues?.[index]?.pointSizes;
+ if (pointSizes && pointSizes.length) {
+ dataSet.pointRadius = pointSizes;
+ dataSet.pointHoverRadius = pointSizes;
+ } else if (definition.dataSets?.[index]?.pointSizeMode === "fixed") {
+ const radius = adjustPointSizeRadius(definition.dataSets?.[index]?.pointSize);
+ dataSet.pointRadius = radius;
+ dataSet.pointHoverRadius = radius;
+ }
}
}
return dataSets;
diff --git a/src/helpers/figures/charts/runtime/chartjs_show_values.ts b/src/helpers/figures/charts/runtime/chartjs_show_values.ts
index addc7ee776..c71804a2ff 100644
--- a/src/helpers/figures/charts/runtime/chartjs_show_values.ts
+++ b/src/helpers/figures/charts/runtime/chartjs_show_values.ts
@@ -14,12 +14,18 @@ export function getChartShowValues(
definition: ChartWithDataSetDefinition,
args: ChartRuntimeGenerationArgs
): ChartShowValuesPluginOptions {
- const { axisFormats, locale } = args;
+ const { axisFormats, locale, dataSetsValues } = args;
+ const usesPointLabels = definition.type === "scatter" && definition.showValuesMode === "label";
return {
horizontal: "horizontal" in definition && definition.horizontal,
showValues: "showValues" in definition ? !!definition.showValues : false,
background: definition.background,
- callback: (value: number | string, dataset: ChartMeta) => {
+ callback: (value: number | string, dataset: ChartMeta, index: number) => {
+ if (usesPointLabels) {
+ const datasetIndex = dataset.index ?? 0;
+ const pointLabel = dataSetsValues?.[datasetIndex]?.pointLabels?.[index];
+ return pointLabel ?? "";
+ }
const axisId = getDatasetAxisId(definition, dataset);
return formatChartDatasetValue(axisFormats, locale, definition.humanize)(value, axisId);
},
diff --git a/src/helpers/figures/charts/runtime/chartjs_tooltip.ts b/src/helpers/figures/charts/runtime/chartjs_tooltip.ts
index 3ab0951dd8..edfacc955a 100644
--- a/src/helpers/figures/charts/runtime/chartjs_tooltip.ts
+++ b/src/helpers/figures/charts/runtime/chartjs_tooltip.ts
@@ -61,7 +61,7 @@ export function getLineChartTooltip(
definition: GenericDefinition,
args: ChartRuntimeGenerationArgs
): ChartTooltip {
- const { axisType, locale, axisFormats } = args;
+ const { axisType, locale, axisFormats, dataSetsValues } = args;
const labelFormat = axisFormats?.x;
const tooltip: ChartTooltip = {
@@ -88,7 +88,16 @@ export function getLineChartTooltip(
locale,
definition.humanize
);
- return formattedX ? `(${formattedX}, ${formattedY})` : `${formattedY}`;
+ let pointLabel: string | undefined;
+ const datasetIndex = tooltipItem.datasetIndex ?? -1;
+ if (datasetIndex >= 0 && datasetIndex < (dataSetsValues?.length || 0)) {
+ pointLabel = dataSetsValues?.[datasetIndex]?.pointLabels?.[tooltipItem.dataIndex];
+ }
+ const coordinates = formattedX ? `(${formattedX}, ${formattedY})` : `${formattedY}`;
+ if (pointLabel) {
+ return `${pointLabel}: ${coordinates}`;
+ }
+ return coordinates;
};
} else {
tooltip.callbacks!.label = function (tooltipItem) {
@@ -100,7 +109,12 @@ export function getLineChartTooltip(
locale,
definition.humanize
)(yLabel, axisId);
- return yLabelStr;
+ const datasetIndex = tooltipItem.datasetIndex ?? -1;
+ const pointLabel =
+ datasetIndex >= 0 && datasetIndex < (dataSetsValues?.length || 0)
+ ? dataSetsValues?.[datasetIndex]?.pointLabels?.[tooltipItem.dataIndex]
+ : undefined;
+ return pointLabel ? `${pointLabel}: ${yLabelStr}` : yLabelStr;
};
}
diff --git a/src/helpers/figures/charts/scatter_chart.ts b/src/helpers/figures/charts/scatter_chart.ts
index 964e9e3035..3838bf91dd 100644
--- a/src/helpers/figures/charts/scatter_chart.ts
+++ b/src/helpers/figures/charts/scatter_chart.ts
@@ -20,7 +20,11 @@ import {
ExcelChartDefinition,
} from "../../../types/chart/chart";
import { LegendPosition } from "../../../types/chart/common_chart";
-import { ScatterChartDefinition, ScatterChartRuntime } from "../../../types/chart/scatter_chart";
+import {
+ ScatterChartDefinition,
+ ScatterChartRuntime,
+ ScatterShowValuesMode,
+} from "../../../types/chart/scatter_chart";
import { Validator } from "../../../types/validator";
import { toXlsxHexColor } from "../../../xlsx/helpers/colors";
import { createValidRange } from "../../range";
@@ -63,6 +67,7 @@ export class ScatterChart extends AbstractChart {
readonly dataSetDesign?: DatasetDesign[];
readonly axesDesign?: AxesDesign;
readonly showValues?: boolean;
+ readonly showValuesMode?: ScatterShowValuesMode;
readonly zoomable?: boolean;
constructor(definition: ScatterChartDefinition, sheetId: UID, getters: CoreGetters) {
@@ -82,6 +87,7 @@ export class ScatterChart extends AbstractChart {
this.dataSetDesign = definition.dataSets;
this.axesDesign = definition.axesDesign;
this.showValues = definition.showValues;
+ this.showValuesMode = definition.showValuesMode;
this.zoomable = definition.zoomable;
}
@@ -113,6 +119,7 @@ export class ScatterChart extends AbstractChart {
aggregated: context.aggregated ?? false,
axesDesign: context.axesDesign,
showValues: context.showValues,
+ showValuesMode: context.showValuesMode,
zoomable: context.zoomable,
humanize: context.humanize,
};
@@ -132,6 +139,12 @@ export class ScatterChart extends AbstractChart {
ranges.push({
...this.dataSetDesign?.[i],
dataRange: this.getters.getRangeString(dataSet.dataRange, targetSheetId || this.sheetId),
+ pointLabelRange: dataSet.pointLabelRange
+ ? this.getters.getRangeString(dataSet.pointLabelRange, targetSheetId || this.sheetId)
+ : undefined,
+ pointSizeRange: dataSet.pointSizeRange
+ ? this.getters.getRangeString(dataSet.pointSizeRange, targetSheetId || this.sheetId)
+ : undefined,
});
}
return {
@@ -148,6 +161,7 @@ export class ScatterChart extends AbstractChart {
aggregated: this.aggregated,
axesDesign: this.axesDesign,
showValues: this.showValues,
+ showValuesMode: this.showValuesMode,
zoomable: this.zoomable,
humanize: this.humanize,
};
@@ -159,6 +173,12 @@ export class ScatterChart extends AbstractChart {
range.push({
...this.dataSetDesign?.[i],
dataRange: this.getters.getRangeString(dataSet.dataRange, this.sheetId),
+ pointLabelRange: dataSet.pointLabelRange
+ ? this.getters.getRangeString(dataSet.pointLabelRange, this.sheetId)
+ : undefined,
+ pointSizeRange: dataSet.pointSizeRange
+ ? this.getters.getRangeString(dataSet.pointSizeRange, this.sheetId)
+ : undefined,
});
}
return {
diff --git a/src/helpers/zones.ts b/src/helpers/zones.ts
index a7c7765cbc..faf84e1227 100644
--- a/src/helpers/zones.ts
+++ b/src/helpers/zones.ts
@@ -693,6 +693,10 @@ export function isFullCol(zone: UnboundedZone): boolean {
return zone.bottom === undefined;
}
+export function isBoundedZone(zone: Zone | UnboundedZone): boolean {
+ return zone.right !== undefined && zone.bottom !== undefined;
+}
+
/** Returns the area of a zone */
export function getZoneArea(zone: Zone): number {
return (zone.bottom - zone.top + 1) * (zone.right - zone.left + 1);
diff --git a/src/types/chart/chart.ts b/src/types/chart/chart.ts
index 26dbea6b17..32cf859bda 100644
--- a/src/types/chart/chart.ts
+++ b/src/types/chart/chart.ts
@@ -11,7 +11,12 @@ import { LineChartDefinition, LineChartRuntime } from "./line_chart";
import { PieChartDefinition, PieChartRuntime } from "./pie_chart";
import { PyramidChartDefinition, PyramidChartRuntime } from "./pyramid_chart";
import { RadarChartDefinition, RadarChartRuntime } from "./radar_chart";
-import { ScatterChartDefinition, ScatterChartRuntime } from "./scatter_chart";
+import {
+ ScatterChartDefinition,
+ ScatterChartRuntime,
+ ScatterPointSizeMode,
+ ScatterShowValuesMode,
+} from "./scatter_chart";
import { ScorecardChartDefinition, ScorecardChartRuntime } from "./scorecard_chart";
import { SunburstChartDefinition, SunburstChartRuntime } from "./sunburst_chart";
import {
@@ -92,6 +97,8 @@ export interface DatasetValues {
readonly label?: string;
readonly data: any[];
readonly hidden?: boolean;
+ readonly pointLabels?: (string | undefined)[];
+ readonly pointSizes?: (number | undefined)[];
}
export interface DatasetDesign {
@@ -136,6 +143,10 @@ export interface TrendConfiguration {
export type CustomizedDataSet = {
readonly dataRange: string;
readonly trend?: TrendConfiguration;
+ readonly pointLabelRange?: string;
+ readonly pointSizeMode?: ScatterPointSizeMode;
+ readonly pointSize?: number;
+ readonly pointSizeRange?: string;
} & DatasetDesign;
export type AxisType = "category" | "linear" | "time";
@@ -149,6 +160,10 @@ export interface DataSet {
readonly backgroundColor?: Color;
readonly customLabel?: string;
readonly trend?: TrendConfiguration;
+ readonly pointLabelRange?: Range;
+ readonly pointSizeMode?: ScatterPointSizeMode;
+ readonly pointSize?: number;
+ readonly pointSizeRange?: Range;
}
export interface ExcelChartDataset {
readonly label?: { text?: string } | { reference?: string };
@@ -221,6 +236,7 @@ export interface ChartCreationContext {
readonly treemapColoringOptions?: TreeMapColoringOptions;
readonly zoomable?: boolean;
readonly humanize?: boolean;
+ readonly showValuesMode?: ScatterShowValuesMode;
}
export type ChartAxisFormats = { [axisId: string]: Format | undefined } | undefined;
diff --git a/src/types/chart/scatter_chart.ts b/src/types/chart/scatter_chart.ts
index 6b508e901d..dd5f272d34 100644
--- a/src/types/chart/scatter_chart.ts
+++ b/src/types/chart/scatter_chart.ts
@@ -1,8 +1,21 @@
+import { CustomizedDataSet } from "./chart";
import { LineChartDefinition, LineChartRuntime } from "./line_chart";
+export type ScatterShowValuesMode = "value" | "label";
+
+export type ScatterPointSizeMode = "fixed" | "range" | "value";
+
+type ScatterDataSet = CustomizedDataSet & {
+ readonly pointSizeMode?: ScatterPointSizeMode;
+ readonly pointSize?: number;
+ readonly pointSizeRange?: string;
+};
+
export interface ScatterChartDefinition
- extends Omit {
+ extends Omit {
readonly type: "scatter";
+ readonly dataSets: ScatterDataSet[];
+ readonly showValuesMode?: ScatterShowValuesMode;
}
export type ScatterChartRuntime = LineChartRuntime;
diff --git a/tests/figures/chart/chart_plugin.test.ts b/tests/figures/chart/chart_plugin.test.ts
index 6e0ac46682..be96d7492e 100644
--- a/tests/figures/chart/chart_plugin.test.ts
+++ b/tests/figures/chart/chart_plugin.test.ts
@@ -1,5 +1,6 @@
import { Point } from "chart.js";
import { CommandResult, Model } from "../../../src";
+import { SCATTER_MAX_POINT_RADIUS, SCATTER_MIN_POINT_RADIUS } from "../../../src/constants";
import { ChartDefinition } from "../../../src/types";
import {
BarChartDefinition,
@@ -9,6 +10,7 @@ import {
LineChartDefinition,
LineChartRuntime,
PieChartRuntime,
+ ScatterChartDefinition,
} from "../../../src/types/chart";
import {
activateSheet,
@@ -2216,6 +2218,40 @@ describe("Chart design configuration", () => {
});
});
+ test("scatter chart definition stores per-series point label ranges", () => {
+ setGrid(model, {
+ A1: "X",
+ A2: "1",
+ A3: "2",
+ B1: "Dataset 1",
+ B2: "10",
+ B3: "20",
+ C1: "Point labels",
+ C2: "Alpha",
+ C3: "Beta",
+ });
+
+ createChart(
+ model,
+ {
+ labelRange: "Sheet1!A2:A3",
+ dataSets: [
+ {
+ dataRange: "Sheet1!B1:B3",
+ pointLabelRange: "Sheet1!C2:C3",
+ },
+ ],
+ type: "scatter",
+ dataSetsHaveTitle: true,
+ },
+ "1"
+ );
+
+ const definition = model.getters.getChartDefinition("1") as ScatterChartDefinition;
+ expect(definition.dataSets).toHaveLength(1);
+ expect(definition.dataSets[0].pointLabelRange).toBe("Sheet1!C2:C3");
+ });
+
test("scatter chart tooltip label", () => {
setCellContent(model, "A2", "5");
setCellFormat(model, "A2", "0%");
@@ -2239,6 +2275,218 @@ describe("Chart design configuration", () => {
expect(labelValues).toEqual({ beforeLabel: "Dataset 1", label: "(500%, $6,000.00)" });
});
+ test("scatter chart tooltip uses point label ranges", () => {
+ setGrid(model, {
+ A1: "X",
+ A2: "1",
+ A3: "2",
+ B1: "Dataset 1",
+ B2: "10",
+ B3: "20",
+ C1: "Point labels",
+ C2: "Alpha",
+ C3: "Beta",
+ });
+
+ createChart(
+ model,
+ {
+ labelRange: "Sheet1!A2:A3",
+ dataSets: [
+ {
+ dataRange: "Sheet1!B1:B3",
+ pointLabelRange: "Sheet1!C2:C3",
+ },
+ ],
+ type: "scatter",
+ dataSetsHaveTitle: true,
+ humanize: false,
+ },
+ "1"
+ );
+ const chart = model.getters.getChartRuntime("1") as ScatterChartRuntime;
+ const tooltipItem = getChartTooltipItemFromDataset(chart, 0, 1);
+ const labelValues = getChartTooltipValues(chart, tooltipItem);
+ expect(labelValues).toEqual({ beforeLabel: "Dataset 1", label: "Beta: (2, 20)" });
+ });
+
+ test("scatter chart show values plugin can display point labels", () => {
+ setGrid(model, {
+ A1: "X",
+ A2: "1",
+ A3: "2",
+ B1: "Dataset 1",
+ B2: "10",
+ B3: "20",
+ C1: "Point labels",
+ C2: "Alpha",
+ C3: "Beta",
+ });
+
+ createChart(
+ model,
+ {
+ labelRange: "Sheet1!A2:A3",
+ dataSets: [
+ {
+ dataRange: "Sheet1!B1:B3",
+ pointLabelRange: "Sheet1!C2:C3",
+ },
+ ],
+ type: "scatter",
+ dataSetsHaveTitle: true,
+ humanize: false,
+ showValues: true,
+ showValuesMode: "label",
+ },
+ "1"
+ );
+
+ const plugin = getChartConfiguration(model, "1").options?.plugins?.chartShowValuesPlugin;
+ const datasetMeta = { index: 0, yAxisID: "y" };
+ expect(plugin.showValues).toBe(true);
+ expect(plugin.callback(10, datasetMeta, 0)).toBe("Alpha");
+ expect(plugin.callback(20, datasetMeta, 1)).toBe("Beta");
+ });
+
+ test("scatter chart show values plugin shows nothing for missing point labels", () => {
+ setGrid(model, {
+ A1: "X",
+ A2: "1",
+ A3: "2",
+ B1: "Dataset 1",
+ B2: "10",
+ B3: "20",
+ C1: "Point labels",
+ C2: "Alpha",
+ C3: "",
+ });
+
+ createChart(
+ model,
+ {
+ labelRange: "Sheet1!A2:A3",
+ dataSets: [
+ {
+ dataRange: "Sheet1!B1:B3",
+ pointLabelRange: "Sheet1!C2:C3",
+ },
+ ],
+ type: "scatter",
+ dataSetsHaveTitle: true,
+ humanize: false,
+ showValues: true,
+ showValuesMode: "label",
+ },
+ "1"
+ );
+
+ const plugin = getChartConfiguration(model, "1").options?.plugins?.chartShowValuesPlugin;
+ const datasetMeta = { index: 0, yAxisID: "y" };
+ expect(plugin.callback(20, datasetMeta, 1)).toBe("");
+ });
+
+ test("scatter chart datasets use point size range", () => {
+ setGrid(model, {
+ A1: "X",
+ A2: "1",
+ A3: "2",
+ B1: "Dataset 1",
+ B2: "10",
+ B3: "20",
+ D1: "1",
+ D2: "3",
+ });
+
+ createChart(
+ model,
+ {
+ labelRange: "Sheet1!A2:A3",
+ dataSets: [
+ {
+ dataRange: "Sheet1!B1:B3",
+ pointSizeRange: "Sheet1!D1:D2",
+ pointSizeMode: "range",
+ },
+ ],
+ type: "scatter",
+ dataSetsHaveTitle: true,
+ },
+ "1"
+ );
+
+ const dataset = getChartConfiguration(model, "1").data.datasets?.[0];
+ expect(dataset?.pointRadius).toEqual([SCATTER_MIN_POINT_RADIUS, SCATTER_MAX_POINT_RADIUS]);
+ expect(dataset?.pointHoverRadius).toEqual([SCATTER_MIN_POINT_RADIUS, SCATTER_MAX_POINT_RADIUS]);
+ });
+
+ test("scatter chart datasets use point size from data values", () => {
+ setGrid(model, {
+ A1: "X",
+ A2: "1",
+ A3: "2",
+ A4: "3",
+ B1: "Dataset 1",
+ B2: "10",
+ B3: "20",
+ B4: "30",
+ });
+
+ createChart(
+ model,
+ {
+ labelRange: "Sheet1!A2:A4",
+ dataSets: [
+ {
+ dataRange: "Sheet1!B1:B4",
+ pointSizeMode: "value",
+ },
+ ],
+ type: "scatter",
+ dataSetsHaveTitle: true,
+ },
+ "1"
+ );
+
+ const dataset = getChartConfiguration(model, "1").data.datasets?.[0];
+ expect(dataset?.pointRadius?.[0]).toBe(SCATTER_MIN_POINT_RADIUS);
+ expect(dataset?.pointRadius?.[2]).toBe(SCATTER_MAX_POINT_RADIUS);
+ expect(dataset?.pointRadius?.[1]).toBeCloseTo(
+ SCATTER_MIN_POINT_RADIUS + (SCATTER_MAX_POINT_RADIUS - SCATTER_MIN_POINT_RADIUS) / 2
+ );
+ });
+
+ test("scatter chart datasets use fixed point size", () => {
+ setGrid(model, { B1: "10", B2: "20" });
+ createChart(
+ model,
+ {
+ dataSets: [
+ {
+ dataRange: "Sheet1!B1:B2",
+ pointSizeMode: "fixed",
+ pointSize: 7,
+ },
+ ],
+ type: "scatter",
+ dataSetsHaveTitle: true,
+ },
+ "1"
+ );
+
+ const dataset = getChartConfiguration(model, "1").data.datasets?.[0];
+ if (Array.isArray(dataset?.pointRadius)) {
+ expect(dataset?.pointRadius?.every((value: number) => value === 7)).toBe(true);
+ } else {
+ expect(dataset?.pointRadius).toBe(7);
+ }
+ if (Array.isArray(dataset?.pointHoverRadius)) {
+ expect(dataset?.pointHoverRadius?.every((value: number) => value === 7)).toBe(true);
+ } else {
+ expect(dataset?.pointHoverRadius).toBe(7);
+ }
+ });
+
test("scatter chart trend line tooltip label", () => {
setGrid(model, { A1: "1", A2: "2", B1: "12", B2: "15" });
@@ -3920,11 +4168,12 @@ describe("Can make numbers human-readable", () => {
"1"
);
let plugin = getChartConfiguration(model, "1").options?.plugins?.chartShowValuesPlugin;
- const valuesBefore = [1e3, 1e6].map((v) => plugin.callback(v, "x"));
+ const datasetMeta = { index: 0, xAxisID: "x", yAxisID: "y" };
+ const valuesBefore = [1e3, 1e6].map((v, index) => plugin.callback(v, datasetMeta, index));
expect(valuesBefore).toEqual(["1,000", "1,000,000"]);
updateChart(model, "1", { humanize: true });
plugin = getChartConfiguration(model, "1").options?.plugins?.chartShowValuesPlugin;
- const valuesAfter = [1e3, 1e6].map((v) => plugin.callback(v, "x"));
+ const valuesAfter = [1e3, 1e6].map((v, index) => plugin.callback(v, datasetMeta, index));
expect(valuesAfter).toEqual(["1,000", "1,000k"]);
}
);
diff --git a/tests/figures/chart/charts_component.test.ts b/tests/figures/chart/charts_component.test.ts
index 3a1ab18102..185dd95d03 100644
--- a/tests/figures/chart/charts_component.test.ts
+++ b/tests/figures/chart/charts_component.test.ts
@@ -19,7 +19,11 @@ import {
SpreadsheetChildEnv,
UID,
} from "../../../src/types";
-import { PieChartRuntime, TrendConfiguration } from "../../../src/types/chart";
+import {
+ PieChartRuntime,
+ ScatterChartDefinition,
+ TrendConfiguration,
+} from "../../../src/types/chart";
import { BarChartDefinition, BarChartRuntime } from "../../../src/types/chart/bar_chart";
import { LineChartDefinition } from "../../../src/types/chart/line_chart";
import { xmlEscape } from "../../../src/xlsx/helpers/xml_helpers";
@@ -1807,6 +1811,138 @@ describe("charts", () => {
]);
});
+ describe("Scatter chart", () => {
+ test("Can add point label range for a data series", async () => {
+ setGrid(model, { B1: "10", C1: "Alpha", B2: "20", C2: "Beta" });
+ createChart(
+ model,
+ {
+ dataSets: [{ dataRange: "B1:B2" }],
+ type: "scatter",
+ },
+ chartId,
+ sheetId
+ );
+
+ await mountChartSidePanel(chartId);
+
+ let definition = model.getters.getChartDefinition(chartId) as ScatterChartDefinition;
+ expect(definition.dataSets[0].pointLabelRange).toBeUndefined();
+ expect(document.querySelectorAll(".o-selection-extension")).toHaveLength(0);
+
+ const cogWheel = fixture.querySelector(
+ ".o-data-series .os-cog-wheel-menu-icon"
+ ) as HTMLElement;
+ await simulateClick(cogWheel);
+
+ const addMenuItem = fixture.querySelector(".o-menu-item[title='Add labels']");
+ await simulateClick(addMenuItem!);
+
+ const nestedInput = fixture.querySelector(".o-selection-extension input");
+ expect(document.querySelectorAll(".o-selection-extension").length).toBeGreaterThan(0);
+ expect(nestedInput).not.toBeNull();
+ await setInputValueAndTrigger(nestedInput!, "C2:C3");
+ await simulateClick(".o-selection-extension .o-selection-ok");
+
+ definition = model.getters.getChartDefinition(chartId) as ScatterChartDefinition;
+ expect(definition.dataSets[0].pointLabelRange).toBe("C2:C3");
+ });
+
+ test("Can remove point label range for a data series", async () => {
+ setGrid(model, { B1: "10", C1: "Alpha", B2: "20", C2: "Beta" });
+ createChart(
+ model,
+ {
+ dataSets: [{ dataRange: "B1:B2", pointLabelRange: "C1:C2" }],
+ type: "scatter",
+ },
+ chartId,
+ sheetId
+ );
+
+ await mountChartSidePanel(chartId);
+
+ let definition = model.getters.getChartDefinition(chartId) as ScatterChartDefinition;
+ expect(definition.dataSets[0].pointLabelRange).toBe("C1:C2");
+ const nestedInput = fixture.querySelector(".o-selection-extension input") as HTMLInputElement;
+ expect(nestedInput.value).toBe("C1:C2");
+
+ const removeButton = fixture.querySelector(
+ ".o-selection-extension .o-remove-extension"
+ ) as HTMLElement;
+ await simulateClick(removeButton);
+
+ definition = model.getters.getChartDefinition(chartId) as ScatterChartDefinition;
+ expect(definition.dataSets[0].pointLabelRange).toBeUndefined();
+ expect(fixture.querySelector(".o-selection-extension")).toBeNull();
+ });
+
+ test("Can add point size range for a data series", async () => {
+ setGrid(model, { B1: "10", B2: "20", D1: "1", D2: "3" });
+ createChart(
+ model,
+ {
+ dataSets: [{ dataRange: "B1:B2" }],
+ type: "scatter",
+ },
+ chartId,
+ sheetId
+ );
+
+ await mountChartSidePanel(chartId);
+
+ let definition = model.getters.getChartDefinition(chartId) as ScatterChartDefinition;
+ expect(definition.dataSets[0].pointSizeRange).toBeUndefined();
+
+ const cogWheel = fixture.querySelector(
+ ".o-data-series .os-cog-wheel-menu-icon"
+ ) as HTMLElement;
+ await simulateClick(cogWheel);
+
+ const addMenuItem = fixture.querySelector(".o-menu-item[title='Add point size range']");
+ await simulateClick(addMenuItem!);
+
+ const nestedInput = fixture.querySelector(".o-selection-extension input");
+ expect(nestedInput).not.toBeNull();
+ await setInputValueAndTrigger(nestedInput as HTMLInputElement, "D1:D2");
+ await simulateClick(".o-selection-extension .o-selection-ok");
+
+ definition = model.getters.getChartDefinition(chartId) as ScatterChartDefinition;
+ expect(definition.dataSets[0].pointSizeRange).toBe("D1:D2");
+ expect(definition.dataSets[0].pointSizeMode).toBe("range");
+ });
+
+ test("Can remove point size range for a data series", async () => {
+ setGrid(model, { B1: "10", B2: "20", D1: "1", D2: "3" });
+ createChart(
+ model,
+ {
+ dataSets: [{ dataRange: "B1:B2", pointSizeRange: "D1:D2", pointSizeMode: "range" }],
+ type: "scatter",
+ },
+ chartId,
+ sheetId
+ );
+
+ await mountChartSidePanel(chartId);
+
+ let definition = model.getters.getChartDefinition(chartId) as ScatterChartDefinition;
+ expect(definition.dataSets[0].pointSizeRange).toBe("D1:D2");
+ const nestedInput = fixture.querySelector(".o-selection-extension input") as HTMLInputElement;
+ expect(nestedInput.value).toBe("D1:D2");
+
+ const removeButton = fixture.querySelector(
+ ".o-selection-extension .o-remove-extension"
+ ) as HTMLElement;
+ await simulateClick(removeButton);
+
+ definition = model.getters.getChartDefinition(chartId) as ScatterChartDefinition;
+ expect(definition.dataSets[0].pointSizeRange).toBeUndefined();
+ expect(definition.dataSets[0].pointSizeMode).toBe("fixed");
+ expect(fixture.querySelector(".o-selection-extension")).toBeNull();
+ });
+ });
+
test.each(["bar", "line", "waterfall", "radar"])(
"showValues checkbox updates the chart",
async (type: ChartType) => {
diff --git a/tests/pivots/spreadsheet_pivot/__snapshots__/spreadsheet_pivot_side_panel.test.ts.snap b/tests/pivots/spreadsheet_pivot/__snapshots__/spreadsheet_pivot_side_panel.test.ts.snap
index 9ac08a54c4..4787be2539 100644
--- a/tests/pivots/spreadsheet_pivot/__snapshots__/spreadsheet_pivot_side_panel.test.ts.snap
+++ b/tests/pivots/spreadsheet_pivot/__snapshots__/spreadsheet_pivot_side_panel.test.ts.snap
@@ -126,24 +126,34 @@ exports[`Spreadsheet pivot side panel It should correctly be displayed 1`] = `