diff --git a/src/components/vue-ui-xy.vue b/src/components/vue-ui-xy.vue
index c0885ea2..d3d9234a 100644
--- a/src/components/vue-ui-xy.vue
+++ b/src/components/vue-ui-xy.vue
@@ -626,17 +626,31 @@
-
-
+
+
+
+
-
-
+
+
+
+
@@ -1588,6 +1602,12 @@ import {
objectIsEmpty,
themePalettes,
translateSize,
+ createSmoothPathWithCuts,
+ createStraightPathWithCuts,
+ createAreaWithCuts,
+ createIndividualAreaWithCuts,
+ createSmoothAreaSegments,
+ createIndividualArea
} from '../lib';
import themes from "../themes.json";
import DataTable from "../atoms/DataTable.vue";
@@ -2248,10 +2268,21 @@ export default {
}
})
- const curve = this.createSmoothPath(plots.filter(p => p.value !== null));
- const autoScaleCurve = this.createSmoothPath(autoScalePlots.filter(p => p.value !== null));
- const straight = this.createStraightPath(plots.filter(p => p.value !== null));
- const autoScaleStraight = this.createStraightPath(autoScalePlots.filter(p => p.value !== null));
+ const curve = this.FINAL_CONFIG.line.cutNullValues
+ ? this.createSmoothPathWithCuts(plots)
+ : this.createSmoothPath(plots.filter(p => p.value !== null));
+
+ const autoScaleCurve = this.FINAL_CONFIG.line.cutNullValues
+ ? this.createSmoothPathWithCuts(autoScalePlots)
+ : this.createSmoothPath(autoScalePlots.filter(p => p.value !== null));
+
+ const straight = this.FINAL_CONFIG.line.cutNullValues
+ ? this.createStraightPathWithCuts(plots)
+ : this.createStraightPath(plots.filter(p => p.value !== null));
+
+ const autoScaleStraight = this.FINAL_CONFIG.line.cutNullValues
+ ? this.createStraightPathWithCuts(autoScalePlots)
+ : this.createStraightPath(autoScalePlots.filter(p => p.value !== null));
const scaleYLabels = individualScale.ticks.map(t => {
return {
@@ -2299,11 +2330,37 @@ export default {
zeroPosition: datapoint.autoScaling ? autoScaleZeroPosition : zeroPosition,
curve: datapoint.autoScaling ? autoScaleCurve : curve,
plots: datapoint.autoScaling ? autoScalePlots : plots,
- area: !datapoint.useArea ? '' : this.mutableConfig.useIndividualScale ? this.createIndividualArea(datapoint.autoScaling ? autoScalePlots: plots, datapoint.autoScaling ? autoScaleZeroPosition : zeroPosition) : this.createArea(plots),
+ area: !datapoint.useArea
+ ? ''
+ : this.mutableConfig.useIndividualScale
+ ? this.FINAL_CONFIG.line.cutNullValues
+ ? this.createIndividualAreaWithCuts(datapoint.autoScaling
+ ? autoScalePlots
+ : plots,
+ datapoint.autoScaling ? autoScaleZeroPosition : zeroPosition,
+ )
+ : this.createIndividualArea(datapoint.autoScaling
+ ? autoScalePlots.filter(p => p.value !== null)
+ : plots.filter(p => p.value !== null),
+ datapoint.autoScaling ? autoScaleZeroPosition : zeroPosition,)
+ : this.createArea(plots.filter(p => p.value !== null), yOffset),
+
+ curveAreas: !datapoint.useArea
+ ? []
+ :createSmoothAreaSegments(
+ datapoint.autoScaling
+ ? this.FINAL_CONFIG.line.cutNullValues
+ ? autoScalePlots
+ : autoScalePlots.filter(p => p.value !== null)
+ : this.FINAL_CONFIG.line.cutNullValues
+ ? plots
+ : plots.filter(p => p.value !== null),
+ this.mutableConfig.useIndividualScale ? datapoint.autoScaling ? autoScaleZeroPosition : zeroPosition : this.zero,
+ this.FINAL_CONFIG.line.cutNullValues),
straight: datapoint.autoScaling ? autoScaleStraight : straight,
groupId: this.scaleGroups[datapoint.scaleLabel].groupId
}
- })
+ });
},
plotSet() {
return this.activeSeriesWithStackRatios.filter(s => s.type === 'plot').map((datapoint) => {
@@ -2784,6 +2841,12 @@ export default {
useNestedProp,
createUid,
placeXYTag,
+ createSmoothPathWithCuts,
+ createStraightPathWithCuts,
+ createAreaWithCuts,
+ createIndividualAreaWithCuts,
+ createSmoothAreaSegments,
+ createIndividualArea,
hideTags() {
const tags = document.querySelectorAll('.vue-ui-xy-tag')
if (tags.length) {
@@ -3098,26 +3161,17 @@ export default {
}
}
},
- createArea(plots) {
+ createArea(plots, yOffset) {
+ const zero = this.mutableConfig.isStacked ? this.drawingArea.bottom - yOffset : this.drawingArea.bottom;
if(!plots[0]) return [-10,-10, '', -10, -10];
- const start = { x: plots[0].x, y: this.zero };
- const end = { x: plots.at(-1).x, y: this.zero };
+ const start = { x: plots[0].x, y: zero };
+ const end = { x: plots.at(-1).x, y: zero };
const path = [];
plots.forEach(plot => {
path.push(`${plot.x},${plot.y} `);
});
return [ start.x, start.y, ...path, end.x, end.y].toString();
},
- createIndividualArea(plots, zero) {
- if(!plots[0]) return [-10,-10, '', -10, -10];
- const start = { x: plots[0] ? plots[0].x : Math.min(...plots.filter(p => !!p).map(p => p.x)), y: zero };
- const end = { x: plots.at(-1) ? plots.at(-1).x : Math.min(...plots.filter(p => !!p).map(p => p.x)), y: zero };
- const path = [];
- plots.filter(p => !!p).forEach(plot => {
- path.push(`${plot.x},${plot.y} `);
- });
- return [ start.x, start.y, ...path, end.x, end.y].toString();
- },
createStar,
createPolygonPath,
fillArray(len, source) {
diff --git a/src/lib.js b/src/lib.js
index 4f1d815b..d070e5c4 100755
--- a/src/lib.js
+++ b/src/lib.js
@@ -2271,6 +2271,199 @@ export function deepClone(value) {
return result;
}
+export function getAreaSegments(points) {
+ const segments = [];
+ let current = [];
+ for (const p of points) {
+ if (!p || p.value == null || Number.isNaN(p.x) || Number.isNaN(p.y)) {
+ if (current.length) segments.push(current);
+ current = [];
+ } else {
+ current.push(p);
+ }
+ }
+ if (current.length) segments.push(current);
+ return segments;
+}
+
+export function createAreaWithCuts(plots, zero) {
+ if (!plots[0]) return [-10, -10, '', -10, -10].toString();
+
+ const segments = getAreaSegments(plots);
+ if (!segments.length) return '';
+ return segments.map(seg => {
+ const start = { x: seg[0].x, y: zero };
+ const end = { x: seg.at(-1).x, y: zero };
+ const path = [];
+ seg.forEach(plot => {
+ path.push(`${plot.x},${plot.y} `);
+ });
+ return [start.x, start.y, ...path, end.x, end.y].toString();
+ }).join(';');
+}
+
+export function createIndividualArea(plots, zero) {
+ const validPlots = plots.filter(p => !!p);
+ if (!validPlots[0]) return [-10, -10, '', -10, -10].toString();
+ const start = { x: validPlots[0].x, y: zero };
+ const end = { x: validPlots.at(-1).x, y: zero };
+ const path = [];
+ validPlots.forEach(plot => {
+ path.push(`${plot.x},${plot.y} `);
+ });
+ return [start.x, start.y, ...path, end.x, end.y].toString();
+}
+
+export function createIndividualAreaWithCuts(plots, zero) {
+ if (!plots[0]) return [-10, -10, '', -10, -10].toString();
+
+ const segments = getAreaSegments(plots);
+ if (!segments.length) return '';
+ return segments.map(seg => {
+ const start = { x: seg[0].x, y: zero };
+ const end = { x: seg.at(-1).x, y: zero };
+ const path = [];
+ seg.forEach(plot => {
+ path.push(`${plot.x},${plot.y} `);
+ });
+ return [start.x, start.y, ...path, end.x, end.y].toString();
+ }).join(';');
+}
+
+export function getValidSegments(points) {
+ const segments = [];
+ let current = [];
+ for (const p of points) {
+ if (p.value == null || Number.isNaN(p.x) || Number.isNaN(p.y)) {
+ if (current.length > 1) segments.push(current);
+ current = [];
+ } else {
+ current.push(p);
+ }
+ }
+ if (current.length > 1) segments.push(current);
+ return segments;
+}
+
+export function createStraightPathWithCuts(points) {
+ let d = '';
+ let started = false;
+ for (let i = 0; i < points.length; i++) {
+ const p = points[i];
+ if (p.value == null || Number.isNaN(p.x) || Number.isNaN(p.y)) {
+ started = false;
+ continue;
+ }
+ if (!started) {
+ d += `${i === 0 ? '' : 'M'}${checkNaN(p.x)},${checkNaN(p.y)} `;
+ started = true;
+ } else {
+ d += `L${checkNaN(p.x)},${checkNaN(p.y)} `;
+ }
+ }
+ return d.trim();
+}
+
+export function createSmoothPathWithCuts(points) {
+ const segments = getValidSegments(points);
+
+ if (!segments.length) return '';
+
+ let fullPath = '';
+ for (const [idx, seg] of segments.entries()) {
+ if (seg.length < 2) continue;
+ const n = seg.length - 1;
+ const dx = [], dy = [], slopes = [], tangents = [];
+ for (let i = 0; i < n; i += 1) {
+ dx[i] = seg[i + 1].x - seg[i].x;
+ dy[i] = seg[i + 1].y - seg[i].y;
+ slopes[i] = dy[i] / dx[i];
+ }
+ tangents[0] = slopes[0];
+ tangents[n] = slopes[n - 1];
+ for (let i = 1; i < n; i += 1) {
+ if (slopes[i - 1] * slopes[i] <= 0) {
+ tangents[i] = 0;
+ } else {
+ const harmonicMean = (2 * slopes[i - 1] * slopes[i]) / (slopes[i - 1] + slopes[i]);
+ tangents[i] = harmonicMean;
+ }
+ }
+
+ fullPath += `${idx === 0 ? '' : 'M'}${checkNaN(seg[0].x)},${checkNaN(seg[0].y)} `;
+ for (let i = 0; i < n; i += 1) {
+ const x1 = seg[i].x;
+ const y1 = seg[i].y;
+ const x2 = seg[i + 1].x;
+ const y2 = seg[i + 1].y;
+ const m1 = tangents[i];
+ const m2 = tangents[i + 1];
+ const controlX1 = x1 + (x2 - x1) / 3;
+ const controlY1 = y1 + m1 * (x2 - x1) / 3;
+ const controlX2 = x2 - (x2 - x1) / 3;
+ const controlY2 = y2 - m2 * (x2 - x1) / 3;
+ fullPath += `C${checkNaN(controlX1)},${checkNaN(controlY1)} ${checkNaN(controlX2)},${checkNaN(controlY2)} ${checkNaN(x2)},${checkNaN(y2)} `;
+ }
+ }
+ return fullPath.trim();
+}
+
+export function createSmoothAreaSegments(points, zero, cut = false) {
+ function getSegments(points) {
+ const segs = [];
+ let curr = [];
+ for (const p of points) {
+ if (!p || p.value == null || Number.isNaN(p.x) || Number.isNaN(p.y)) {
+ if (curr.length > 1) segs.push(curr);
+ curr = [];
+ } else {
+ curr.push(p);
+ }
+ }
+ if (curr.length > 1) segs.push(curr);
+ return segs;
+ }
+ const segments = cut ? getSegments(points) : [points];
+ return segments.map(seg => {
+ if (seg.length < 2) return '';
+ const n = seg.length - 1;
+ const dx = [], dy = [], slopes = [], tangents = [];
+ for (let i = 0; i < n; i += 1) {
+ dx[i] = seg[i + 1].x - seg[i].x;
+ dy[i] = seg[i + 1].y - seg[i].y;
+ slopes[i] = dy[i] / dx[i];
+ }
+ tangents[0] = slopes[0];
+ tangents[n] = slopes[n - 1];
+ for (let i = 1; i < n; i += 1) {
+ if (slopes[i - 1] * slopes[i] <= 0) {
+ tangents[i] = 0;
+ } else {
+ const harmonicMean = (2 * slopes[i - 1] * slopes[i]) / (slopes[i - 1] + slopes[i]);
+ tangents[i] = harmonicMean;
+ }
+ }
+ let d = `M${seg[0].x},${zero}`;
+ d += ` L${seg[0].x},${seg[0].y}`;
+ for (let i = 0; i < n; i += 1) {
+ const x1 = seg[i].x;
+ const y1 = seg[i].y;
+ const x2 = seg[i + 1].x;
+ const y2 = seg[i + 1].y;
+ const m1 = tangents[i];
+ const m2 = tangents[i + 1];
+ const controlX1 = x1 + (x2 - x1) / 3;
+ const controlY1 = y1 + m1 * (x2 - x1) / 3;
+ const controlX2 = x2 - (x2 - x1) / 3;
+ const controlY2 = y2 - m2 * (x2 - x1) / 3;
+ d += ` C${controlX1},${controlY1} ${controlX2},${controlY2} ${x2},${y2}`;
+ }
+ d += ` L${seg[n].x},${zero} Z`;
+ return d;
+ }).filter(Boolean);
+}
+
+
const lib = {
XMLNS,
abbreviate,
@@ -2348,5 +2541,12 @@ const lib = {
themePalettes,
translateSize,
treeShake,
+ createStraightPathWithCuts,
+ createSmoothPathWithCuts,
+ getAreaSegments,
+ createAreaWithCuts,
+ createIndividualAreaWithCuts,
+ createSmoothAreaSegments,
+ createIndividualArea
};
export default lib;
\ No newline at end of file
diff --git a/src/useConfig.js b/src/useConfig.js
index cd1705c9..a096c9ae 100755
--- a/src/useConfig.js
+++ b/src/useConfig.js
@@ -554,6 +554,7 @@ export function useConfig() {
radius: 3,
useGradient: true,
strokeWidth: 3,
+ cutNullValues: false,
dot: {
hideAboveMaxSerieLength: 62,
useSerieColor: true,
diff --git a/tests/lib.test.js b/tests/lib.test.js
index 286f6158..23c0c8c2 100644
--- a/tests/lib.test.js
+++ b/tests/lib.test.js
@@ -72,6 +72,14 @@ import {
treeShake,
getPathLengthFromCoordinates,
sumSeries,
+ getAreaSegments,
+ createAreaWithCuts,
+ createIndividualArea,
+ createIndividualAreaWithCuts,
+ getValidSegments,
+ createStraightPathWithCuts,
+ createSmoothPathWithCuts,
+ createSmoothAreaSegments
} from "../src/lib";
describe("calcTrend", () => {
@@ -2570,4 +2578,867 @@ describe("placeHTMLElementAtSVGCoordinates", () => {
const result = placeHTMLElementAtSVGCoordinates({ svgElement: svgMock, x: 250, y: 490, element: elementMock, offsetY: 10 });
expect(result.top).toBe(420);
});
+});
+
+describe('getAreaSegments', () => {
+ test('returns empty array for empty input', () => {
+ expect(getAreaSegments([])).toEqual([]);
+ });
+
+ test('returns one segment for all valid points', () => {
+ const points = [
+ { x: 1, y: 2, value: 1 },
+ { x: 2, y: 3, value: 2 },
+ { x: 3, y: 4, value: 3 },
+ ];
+ expect(getAreaSegments(points)).toEqual([points]);
+ });
+
+ test('splits at value null', () => {
+ const points = [
+ { x: 1, y: 2, value: 1 },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: 3 },
+ { x: 4, y: 5, value: 4 }
+ ];
+ expect(getAreaSegments(points)).toEqual([
+ [points[0]],
+ [points[2], points[3]]
+ ]);
+ });
+
+ test('splits at undefined, NaN x, or NaN y', () => {
+ const points = [
+ { x: 1, y: 2, value: 1 },
+ undefined,
+ { x: 2, y: 3, value: 2 },
+ { x: NaN, y: 5, value: 3 },
+ { x: 4, y: 5, value: 4 },
+ { x: 5, y: NaN, value: 5 },
+ { x: 6, y: 7, value: 6 }
+ ];
+ expect(getAreaSegments(points)).toEqual([
+ [points[0]],
+ [points[2]],
+ [points[4]],
+ [points[6]]
+ ]);
+ });
+
+ test('handles multiple consecutive invalid points', () => {
+ const points = [
+ { x: 1, y: 2, value: 1 },
+ { x: 2, y: 3, value: null },
+ undefined,
+ { x: 3, y: 4, value: 2 }
+ ];
+ expect(getAreaSegments(points)).toEqual([
+ [points[0]],
+ [points[3]]
+ ]);
+ });
+
+ test('returns no segment for all invalid points', () => {
+ const points = [
+ { x: 1, y: 2, value: null },
+ undefined,
+ { x: NaN, y: 2, value: 2 },
+ { x: 1, y: NaN, value: 2 }
+ ]
+ expect(getAreaSegments(points)).toEqual([]);
+ });
+
+ test('segment must be at least length 1 (single point is valid)', () => {
+ const points = [
+ { x: 1, y: 2, value: 1 },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: 2 }
+ ];
+ expect(getAreaSegments(points)).toEqual([
+ [points[0]],
+ [points[2]]
+ ]);
+ });
+
+ test('works if last point is invalid', () => {
+ const points = [
+ { x: 1, y: 2, value: 1 },
+ { x: 2, y: 3, value: 2 },
+ { x: 3, y: 4, value: null }
+ ];
+ expect(getAreaSegments(points)).toEqual([
+ [points[0], points[1]]
+ ]);
+ });
+
+ test('works if first point is invalid', () => {
+ const points = [
+ { x: 1, y: 2, value: null },
+ { x: 2, y: 3, value: 2 },
+ { x: 3, y: 4, value: 3 }
+ ];
+ expect(getAreaSegments(points)).toEqual([
+ [points[1], points[2]]
+ ]);
+ });
+
+ test('works if valid segment is at the end', () => {
+ const points = [
+ { x: 1, y: 2, value: null },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: 3 }
+ ];
+ expect(getAreaSegments(points)).toEqual([
+ [points[2]]
+ ]);
+ });
+});
+
+describe('createAreaWithCuts', () => {
+ test('returns default string for empty input', () => {
+ expect(createAreaWithCuts([], 100)).toBe('-10,-10,,-10,-10');
+ });
+
+ test('returns default string if first plot is falsy', () => {
+ expect(createAreaWithCuts([null, { x: 1, y: 2, value: 1 }], 100)).toBe('-10,-10,,-10,-10');
+ });
+
+ test('returns empty string for no valid segments', () => {
+ const plots = [
+ { x: 1, y: 2, value: null },
+ { x: NaN, y: 2, value: 1 }
+ ];
+ expect(createAreaWithCuts(plots, 100)).toBe('');
+ });
+
+ test('returns area string for all valid points', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ { x: 2, y: 20, value: 2 },
+ { x: 3, y: 30, value: 3 }
+ ];
+ const expected = [1, 100, '1,10 ', '2,20 ', '3,30 ', 3, 100].toString();
+ expect(createAreaWithCuts(plots, 100)).toBe(expected);
+ });
+
+ test('handles a cut in the middle', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ { x: 2, y: 20, value: null },
+ { x: 3, y: 30, value: 3 }
+ ];
+ const expectedA = [1, 100, '1,10 ', 1, 100].toString();
+ const expectedB = [3, 100, '3,30 ', 3, 100].toString();
+ expect(createAreaWithCuts(plots, 100)).toBe(`${expectedA};${expectedB}`);
+ });
+
+ test('handles multiple cuts', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ { x: 2, y: 20, value: null },
+ { x: 3, y: 30, value: 3 },
+ { x: 4, y: 40, value: null },
+ { x: 5, y: 50, value: 5 }
+ ];
+ const segA = [1, 100, '1,10 ', 1, 100].toString();
+ const segB = [3, 100, '3,30 ', 3, 100].toString();
+ const segC = [5, 100, '5,50 ', 5, 100].toString();
+ expect(createAreaWithCuts(plots, 100)).toBe(`${segA};${segB};${segC}`);
+ });
+
+ test('handles valid points at the end after invalids', () => {
+ const plots = [
+ { x: 1, y: 10, value: null },
+ { x: 2, y: 20, value: null },
+ { x: 3, y: 30, value: 3 }
+ ];
+ const expected = [3, 100, '3,30 ', 3, 100].toString();
+ expect(createAreaWithCuts(plots, 100)).toBe(expected);
+ });
+
+ test('handles valid points at the beginning before invalids', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ { x: 2, y: 20, value: null },
+ { x: 3, y: 30, value: null }
+ ];
+ const expected = [1, 100, '1,10 ', 1, 100].toString();
+ expect(createAreaWithCuts(plots, 100)).toBe(expected);
+ });
+
+ test('handles segments of length 1', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ { x: 2, y: 20, value: null },
+ { x: 3, y: 30, value: 3 }
+ ];
+ const expectedA = [1, 100, '1,10 ', 1, 100].toString();
+ const expectedB = [3, 100, '3,30 ', 3, 100].toString();
+ expect(createAreaWithCuts(plots, 100)).toBe(`${expectedA};${expectedB}`);
+ });
+
+ test('handles NaN x or y as cut', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ { x: NaN, y: 20, value: 2 },
+ { x: 3, y: NaN, value: 3 },
+ { x: 4, y: 40, value: 4 }
+ ];
+ const expectedA = [1, 100, '1,10 ', 1, 100].toString();
+ const expectedB = [4, 100, '4,40 ', 4, 100].toString();
+ expect(createAreaWithCuts(plots, 100)).toBe(`${expectedA};${expectedB}`);
+ })
+
+ test('handles all invalid plots', () => {
+ const plots = [
+ { x: 1, y: 10, value: null },
+ { x: 2, y: 20, value: undefined }
+ ];
+ expect(createAreaWithCuts(plots, 100)).toBe('');
+ });
+});
+
+describe('createIndividualArea', () => {
+ test('returns default string for empty input', () => {
+ expect(createIndividualArea([], 100)).toBe('-10,-10,,-10,-10');
+ });
+
+ test('returns correct path if first plot is falsy but a valid plot exists', () => {
+ expect(createIndividualArea([null, { x: 1, y: 2, value: 1 }], 100))
+ .toBe([1, 100, '1,2 ', 1, 100].toString());
+ });
+
+ test('returns default string if all plots are falsy', () => {
+ expect(createIndividualArea([null, undefined], 100)).toBe('-10,-10,,-10,-10')
+ });
+
+ test('returns correct path for all valid points', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ { x: 2, y: 20, value: 2 },
+ { x: 3, y: 30, value: 3 }
+ ];
+ const expected = [1, 100, '1,10 ', '2,20 ', '3,30 ', 3, 100].toString();
+ expect(createIndividualArea(plots, 100)).toBe(expected);
+ })
+
+ test('ignores falsy plots in the middle', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ null,
+ { x: 2, y: 20, value: 2 },
+ undefined,
+ { x: 3, y: 30, value: 3 }
+ ];
+ const expected = [1, 100, '1,10 ', '2,20 ', '3,30 ', 3, 100].toString();
+ expect(createIndividualArea(plots, 100)).toBe(expected);
+ });
+
+ test('works for single valid plot', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 }
+ ];
+ const expected = [1, 100, '1,10 ', 1, 100].toString();
+ expect(createIndividualArea(plots, 100)).toBe(expected);
+ });
+
+ test('works for two valid plots', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ { x: 2, y: 20, value: 2 }
+ ];
+ const expected = [1, 100, '1,10 ', '2,20 ', 2, 100].toString();
+ expect(createIndividualArea(plots, 100)).toBe(expected);
+ });
+
+ test('uses first and last valid plot for start and end', () => {
+ const plots = [
+ null,
+ { x: 2, y: 20, value: 2 },
+ undefined,
+ { x: 3, y: 30, value: 3 },
+ null
+ ];
+ const expected = [2, 100, '2,20 ', '3,30 ', 3, 100].toString();
+ expect(createIndividualArea(plots, 100)).toBe(expected);
+ })
+
+ test('returns default string if only falsy in plots', () => {
+ const plots = [null, undefined, false];
+ expect(createIndividualArea(plots, 100)).toBe('-10,-10,,-10,-10');
+ });
+});
+
+describe('createIndividualAreaWithCuts', () => {
+ test('returns default string for empty input', () => {
+ expect(createIndividualAreaWithCuts([], 100)).toBe('-10,-10,,-10,-10');
+ });
+
+ test('returns default string if first plot is falsy', () => {
+ expect(createIndividualAreaWithCuts([null, { x: 1, y: 2, value: 1 }], 100)).toBe('-10,-10,,-10,-10');
+ });
+
+ test('returns empty string for no valid segments', () => {
+ const plots = [
+ { x: 1, y: 10, value: null },
+ { x: NaN, y: 2, value: 1 },
+ undefined,
+ { x: 3, y: 30, value: null }
+ ];
+ expect(createIndividualAreaWithCuts(plots, 100)).toBe('');
+ });
+
+ test('returns area string for all valid points', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ { x: 2, y: 20, value: 2 },
+ { x: 3, y: 30, value: 3 }
+ ];
+ const expected = [1, 100, '1,10 ', '2,20 ', '3,30 ', 3, 100].toString();
+ expect(createIndividualAreaWithCuts(plots, 100)).toBe(expected);
+ });
+
+ test('returns correct string for a cut in the middle', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ { x: 2, y: 20, value: null },
+ { x: 3, y: 30, value: 3 }
+ ];
+ const segA = [1, 100, '1,10 ', 1, 100].toString();
+ const segB = [3, 100, '3,30 ', 3, 100].toString();
+ expect(createIndividualAreaWithCuts(plots, 100)).toBe(`${segA};${segB}`);
+ });
+
+ test('returns correct string for multiple cuts', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ { x: 2, y: 20, value: null },
+ { x: 3, y: 30, value: 3 },
+ { x: 4, y: 40, value: null },
+ { x: 5, y: 50, value: 5 }
+ ];
+ const segA = [1, 100, '1,10 ', 1, 100].toString();
+ const segB = [3, 100, '3,30 ', 3, 100].toString();
+ const segC = [5, 100, '5,50 ', 5, 100].toString();
+ expect(createIndividualAreaWithCuts(plots, 100)).toBe(`${segA};${segB};${segC}`);
+ });
+
+ test('returns correct string when valid segment is at the end', () => {
+ const plots = [
+ { x: 1, y: 10, value: null },
+ { x: 2, y: 20, value: null },
+ { x: 3, y: 30, value: 3 }
+ ];
+ const expected = [3, 100, '3,30 ', 3, 100].toString();
+ expect(createIndividualAreaWithCuts(plots, 100)).toBe(expected);
+ });
+
+ test('returns correct string when valid segment is at the beginning', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ { x: 2, y: 20, value: null },
+ { x: 3, y: 30, value: null }
+ ];
+ const expected = [1, 100, '1,10 ', 1, 100].toString();
+ expect(createIndividualAreaWithCuts(plots, 100)).toBe(expected);
+ });
+
+ test('handles single-point segments', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ null,
+ { x: 2, y: 20, value: 2 },
+ undefined,
+ { x: 3, y: 30, value: 3 }
+ ];
+ const segA = [1, 100, '1,10 ', 1, 100].toString();
+ const segB = [2, 100, '2,20 ', 2, 100].toString();
+ const segC = [3, 100, '3,30 ', 3, 100].toString();
+ expect(createIndividualAreaWithCuts(plots, 100)).toBe(`${segA};${segB};${segC}`);
+ });
+
+ test('handles NaN x or y as a cut', () => {
+ const plots = [
+ { x: 1, y: 10, value: 1 },
+ { x: NaN, y: 20, value: 2 },
+ { x: 3, y: NaN, value: 3 },
+ { x: 4, y: 40, value: 4 }
+ ];
+ const segA = [1, 100, '1,10 ', 1, 100].toString();
+ const segB = [4, 100, '4,40 ', 4, 100].toString();
+ expect(createIndividualAreaWithCuts(plots, 100)).toBe(`${segA};${segB}`);
+ });
+
+ test('returns default string if all plots are falsy', () => {
+ expect(createIndividualAreaWithCuts([null, undefined, false], 100)).toBe('-10,-10,,-10,-10');
+ });
+
+ test('returns empty string for all plots invalid', () => {
+ const plots = [
+ { x: 1, y: 10, value: null },
+ { x: 2, y: 20, value: undefined }
+ ];
+ expect(createIndividualAreaWithCuts(plots, 100)).toBe('');
+ });
+});
+
+describe('getValidSegments', () => {
+ test('returns empty array for empty input', () => {
+ expect(getValidSegments([])).toEqual([]);
+ });
+
+ test('returns one segment if all points are valid (length > 1)', () => {
+ const points = [
+ { x: 1, y: 2, value: 1 },
+ { x: 2, y: 3, value: 2 },
+ { x: 3, y: 4, value: 3 }
+ ];
+ expect(getValidSegments(points)).toEqual([points]);
+ });
+
+ test('does not return single-point segments', () => {
+ const points = [
+ { x: 1, y: 2, value: 1 },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: 2 }
+ ];
+ expect(getValidSegments(points)).toEqual([]);
+ });
+
+ test('splits into two valid segments', () => {
+ const points = [
+ { x: 1, y: 2, value: 1 },
+ { x: 2, y: 3, value: 2 },
+ { x: 3, y: 4, value: null },
+ { x: 4, y: 5, value: 4 },
+ { x: 5, y: 6, value: 5 }
+ ];
+ expect(getValidSegments(points)).toEqual([
+ [points[0], points[1]],
+ [points[3], points[4]]
+ ]);
+ });
+
+ test('ignores single-point segments between invalids', () => {
+ const points = [
+ { x: 1, y: 2, value: 1 },
+ { x: 2, y: 3, value: 2 },
+ { x: 3, y: 4, value: null },
+ { x: 4, y: 5, value: 4 },
+ { x: 5, y: 6, value: null },
+ { x: 6, y: 7, value: 7 }
+ ];
+ expect(getValidSegments(points)).toEqual([
+ [points[0], points[1]]
+ ]);
+ });
+
+ test('handles invalid value (null)', () => {
+ const points = [
+ { x: 1, y: 2, value: null },
+ { x: 2, y: 3, value: 2 },
+ { x: 3, y: 4, value: 3 }
+ ];
+ expect(getValidSegments(points)).toEqual([
+ [points[1], points[2]]
+ ]);
+ });
+
+ test('handles NaN x or y as invalid', () => {
+ const points = [
+ { x: 1, y: 2, value: 1 },
+ { x: NaN, y: 3, value: 2 },
+ { x: 3, y: NaN, value: 3 },
+ { x: 4, y: 4, value: 4 },
+ { x: 5, y: 5, value: 5 }
+ ];
+ expect(getValidSegments(points)).toEqual([
+ [points[3], points[4]]
+ ]);
+ });
+
+ test('returns empty if all are invalid or all single-point segments', () => {
+ const points = [
+ { x: NaN, y: 3, value: 2 },
+ { x: 1, y: 2, value: null },
+ { x: 3, y: NaN, value: 4 }
+ ];
+ expect(getValidSegments(points)).toEqual([]);
+ });
+
+ test('handles segment at end only if > 1 points', () => {
+ const points = [
+ { x: 1, y: 2, value: null },
+ { x: 2, y: 3, value: 2 },
+ { x: 3, y: 4, value: 3 }
+ ];
+ expect(getValidSegments(points)).toEqual([
+ [points[1], points[2]]
+ ]);
+ });
+
+ test('handles valid segment at start and single valid at end', () => {
+ const points = [
+ { x: 1, y: 2, value: 1 },
+ { x: 2, y: 3, value: 2 },
+ { x: 3, y: 4, value: null },
+ { x: 4, y: 5, value: 4 }
+ ];
+ expect(getValidSegments(points)).toEqual([
+ [points[0], points[1]]
+ ]);
+ });
+
+ test('returns empty for single-point valid input', () => {
+ expect(getValidSegments([{ x: 1, y: 2, value: 1 }])).toEqual([]);
+ });
+});
+
+describe('createStraightPathWithCuts', () => {
+ test('returns empty string for empty input', () => {
+ expect(createStraightPathWithCuts([])).toBe('');
+ });
+
+ test('returns one line for all valid points', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 2, y: 3, value: 20 },
+ { x: 3, y: 4, value: 30 }
+ ];
+ expect(createStraightPathWithCuts(points)).toBe('1,2 L2,3 L3,4');
+ });
+
+ test('cuts path at null value', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: 30 }
+ ];
+ expect(createStraightPathWithCuts(points)).toBe('1,2 M3,4');
+ });
+
+ test('cuts path at NaN x or y', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: NaN, y: 3, value: 20 },
+ { x: 3, y: NaN, value: 30 },
+ { x: 4, y: 5, value: 40 }
+ ];
+ expect(createStraightPathWithCuts(points)).toBe('1,2 M4,5');
+ });
+
+ test('handles consecutive invalids', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: null },
+ { x: 4, y: 5, value: 40 }
+ ];
+ expect(createStraightPathWithCuts(points)).toBe('1,2 M4,5');
+ });
+
+ test('returns empty string if all points are invalid', () => {
+ const points = [
+ { x: 1, y: 2, value: null },
+ { x: NaN, y: 2, value: 1 }
+ ];
+ expect(createStraightPathWithCuts(points)).toBe('');
+ });
+
+ test('handles valid points at the end after invalids', () => {
+ const points = [
+ { x: 1, y: 2, value: null },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: 30 }
+ ];
+ expect(createStraightPathWithCuts(points)).toBe('M3,4');
+ });
+
+ test('handles valid points at the beginning before invalids', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 2, y: 3, value: 20 },
+ { x: 3, y: 4, value: null }
+ ];
+ expect(createStraightPathWithCuts(points)).toBe('1,2 L2,3');
+ });
+
+ test('handles a single valid point', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 }
+ ];
+ expect(createStraightPathWithCuts(points)).toBe('1,2');
+ });
+
+ test('uses 0 for NaN x/y when valid', () => {
+ const points = [
+ { x: NaN, y: 2, value: 10 },
+ { x: 3, y: NaN, value: 20 }
+ ];
+ expect(createStraightPathWithCuts(points)).toBe('');
+ });
+
+ test('handles multiple segments', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: 30 },
+ { x: 4, y: 5, value: null },
+ { x: 5, y: 6, value: 50 }
+ ];
+ expect(createStraightPathWithCuts(points)).toBe('1,2 M3,4 M5,6');
+ });
+});
+
+
+function roundPathNumbers(str, precision = 3) {
+ return str.replace(/-?\d+(\.\d+)?/g, n =>
+ Number.parseFloat(n).toFixed(precision).replace(/\.?0+$/, '')
+ )
+}
+
+// Simple monotone cubic for two points
+function simpleCubicStr(x0, y0, x1, y1) {
+ const dx = x1 - x0
+ const dy = y1 - y0
+ const slope = dy / dx
+ const c1x = x0 + dx / 3
+ const c1y = y0 + slope * dx / 3
+ const c2x = x1 - dx / 3
+ const c2y = y1 - slope * dx / 3
+ return `C${c1x},${c1y} ${c2x},${c2y} ${x1},${y1}`
+}
+
+describe('createSmoothPathWithCuts', () => {
+ test('returns empty string for empty input', () => {
+ expect(createSmoothPathWithCuts([])).toBe('');
+ });
+
+ test('returns empty string for single valid point', () => {
+ expect(createSmoothPathWithCuts([{ x: 1, y: 2, value: 10 }])).toBe('');
+ });
+
+ test('returns one cubic segment for two valid points (rounded)', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 3, y: 4, value: 20 }
+ ];
+ const expected = `1,2 ${simpleCubicStr(1, 2, 3, 4)}`;
+ const actual = createSmoothPathWithCuts(points);
+ expect(roundPathNumbers(actual)).toBe(roundPathNumbers(expected));
+ });
+
+ test('returns correct cubic path for a simple three-point line (format and points, rounded)', () => {
+ const points = [
+ { x: 0, y: 0, value: 10 },
+ { x: 1, y: 1, value: 20 },
+ { x: 2, y: 0, value: 30 }
+ ];
+ const d = createSmoothPathWithCuts(points);
+ expect(roundPathNumbers(d)).toContain('0,0');
+ expect(roundPathNumbers(d)).toContain('1,1');
+ expect(roundPathNumbers(d)).toContain('2,0');
+ })
+
+ test('cuts the path at a null value (rounded)', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: 30 },
+ { x: 4, y: 5, value: 40 }
+ ];
+ const expected = `3,4 ${simpleCubicStr(3, 4, 4, 5)}`;
+ const actual = createSmoothPathWithCuts(points);
+ expect(roundPathNumbers(actual)).toBe(roundPathNumbers(expected));
+ });
+
+ test('cuts at NaN x/y (rounded)', () => {
+ const points = [
+ { x: 0, y: 0, value: 10 },
+ { x: NaN, y: 1, value: 20 },
+ { x: 2, y: 2, value: 30 },
+ { x: 3, y: 3, value: 40 }
+ ];
+ const expected = `2,2 ${simpleCubicStr(2, 2, 3, 3)}`;
+ const actual = createSmoothPathWithCuts(points);
+ expect(roundPathNumbers(actual)).toBe(roundPathNumbers(expected));
+ });
+
+ test('multiple segments, separated by invalids (rounded)', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 2, y: 3, value: 20 },
+ { x: 3, y: 4, value: null },
+ { x: 4, y: 5, value: 40 },
+ { x: 5, y: 6, value: 50 }
+ ];
+ const expected = `1,2 ${simpleCubicStr(1, 2, 2, 3)} M4,5 ${simpleCubicStr(4, 5, 5, 6)}`;
+ const actual = createSmoothPathWithCuts(points);
+ expect(roundPathNumbers(actual)).toBe(roundPathNumbers(expected));
+ })
+
+ test('ignores segments of length 1 (no output)', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: 30 }
+ ];
+ expect(createSmoothPathWithCuts(points)).toBe('');
+ });
+
+ test('returns empty string for all invalid points', () => {
+ const points = [
+ { x: NaN, y: 2, value: 10 },
+ { x: 2, y: 3, value: null }
+ ];
+ expect(createSmoothPathWithCuts(points)).toBe('');
+ });
+
+ test('returns correct path with valid segment at the end (rounded)', () => {
+ const points = [
+ { x: 1, y: 2, value: null },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: 30 },
+ { x: 4, y: 5, value: 40 }
+ ];
+ const expected = `3,4 ${simpleCubicStr(3, 4, 4, 5)}`;
+ const actual = createSmoothPathWithCuts(points);
+ expect(roundPathNumbers(actual)).toBe(roundPathNumbers(expected));
+ });
+});
+
+// Simple monotone cubic for two points with zero base points on y
+function simpleSmoothArea(x0, y0, x1, y1, zero) {
+ const dx = x1 - x0;
+ const dy = y1 - y0;
+ const slope = dy / dx;
+ const c1x = x0 + dx / 3;
+ const c1y = y0 + slope * dx / 3;
+ const c2x = x1 - dx / 3;
+ const c2y = y1 - slope * dx / 3;
+ return `M${x0},${zero} L${x0},${y0} C${c1x},${c1y} ${c2x},${c2y} ${x1},${y1} L${x1},${zero} Z`;
+}
+
+describe('createSmoothAreaSegments', () => {
+ test('returns empty array for empty input', () => {
+ expect(createSmoothAreaSegments([], 100, true)).toEqual([]);
+ });
+
+ test('returns empty array for single valid point', () => {
+ expect(createSmoothAreaSegments([{ x: 1, y: 2, value: 10 }], 100, true)).toEqual([]);
+ });
+
+ test('returns area for two valid points (no cut)', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 3, y: 4, value: 20 }
+ ];
+ const zero = 100;
+ const expected = simpleSmoothArea(1, 2, 3, 4, zero);
+ const result = createSmoothAreaSegments(points, zero, false);
+ expect(roundPathNumbers(result[0])).toBe(roundPathNumbers(expected));
+ expect(result.length).toBe(1);
+ });
+
+ test('returns area for two valid points (cut=true)', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 3, y: 4, value: 20 }
+ ];
+ const zero = 100;
+ const expected = simpleSmoothArea(1, 2, 3, 4, zero);
+ const result = createSmoothAreaSegments(points, zero, true);
+ expect(roundPathNumbers(result[0])).toBe(roundPathNumbers(expected));
+ expect(result.length).toBe(1);
+ });
+
+ test('returns two segments if split by a null', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: 30 },
+ { x: 4, y: 5, value: 40 }
+ ];
+ const zero = 100;
+ const segB = simpleSmoothArea(3, 4, 4, 5, zero);
+ const result = createSmoothAreaSegments(points, zero, true);
+ expect(result.length).toBe(1);
+ expect(roundPathNumbers(result[0])).toBe(roundPathNumbers(segB));
+ });
+
+ test('returns two segments if split by NaN x/y', () => {
+ const points = [
+ { x: 0, y: 0, value: 10 },
+ { x: NaN, y: 1, value: 20 },
+ { x: 2, y: 2, value: 30 },
+ { x: 3, y: 3, value: 40 }
+ ];
+ const zero = 100;
+ const segB = simpleSmoothArea(2, 2, 3, 3, zero);
+ const result = createSmoothAreaSegments(points, zero, true);
+ expect(result.length).toBe(1);
+ expect(roundPathNumbers(result[0])).toBe(roundPathNumbers(segB));
+ });
+
+ test('returns multiple segments, separated by invalids', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 2, y: 3, value: 20 },
+ { x: 3, y: 4, value: null },
+ { x: 4, y: 5, value: 40 },
+ { x: 5, y: 6, value: 50 }
+ ];
+ const zero = 100;
+ const segA = simpleSmoothArea(1, 2, 2, 3, zero);
+ const segB = simpleSmoothArea(4, 5, 5, 6, zero);
+ const result = createSmoothAreaSegments(points, zero, true);
+ expect(result.length).toBe(2);
+ expect(roundPathNumbers(result[0])).toBe(roundPathNumbers(segA));
+ expect(roundPathNumbers(result[1])).toBe(roundPathNumbers(segB));
+ });
+
+ test('returns empty array for all invalid', () => {
+ const points = [
+ { x: NaN, y: 2, value: 10 },
+ { x: 2, y: 3, value: null }
+ ];
+ const zero = 100;
+ expect(createSmoothAreaSegments(points, zero, true)).toEqual([]);
+ });
+
+ test('returns area for non-cut mode even with invalids (should include all points)', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 2, y: 3, value: 20 }
+ ];
+ const zero = 100;
+ const expected = simpleSmoothArea(1, 2, 2, 3, zero);
+ const result = createSmoothAreaSegments(points, zero, false);
+ expect(roundPathNumbers(result[0])).toBe(roundPathNumbers(expected));
+ });
+
+ test('ignores segments of length 1', () => {
+ const points = [
+ { x: 1, y: 2, value: 10 },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: 30 }
+ ];
+ const zero = 100;
+ const result = createSmoothAreaSegments(points, zero, true);
+ expect(result).toEqual([]);
+ });
+
+ test('returns area for valid segment at the end', () => {
+ const points = [
+ { x: 1, y: 2, value: null },
+ { x: 2, y: 3, value: null },
+ { x: 3, y: 4, value: 30 },
+ { x: 4, y: 5, value: 40 }
+ ];
+ const zero = 100;
+ const seg = simpleSmoothArea(3, 4, 4, 5, zero);
+ const result = createSmoothAreaSegments(points, zero, true);
+ expect(result.length).toBe(1);
+ expect(roundPathNumbers(result[0])).toBe(roundPathNumbers(seg));
+ });
});
\ No newline at end of file
diff --git a/types/vue-data-ui.d.ts b/types/vue-data-ui.d.ts
index a5f0cb8e..3c7d14a8 100644
--- a/types/vue-data-ui.d.ts
+++ b/types/vue-data-ui.d.ts
@@ -2768,6 +2768,7 @@ declare module "vue-data-ui" {
radius?: number;
useGradient?: boolean;
strokeWidth?: number;
+ cutNullValues?: boolean;
dot?: {
hideAboveMaxSerieLength?: number;
useSerieColor?: boolean;