Compare commits

...

1 Commits

Author SHA1 Message Date
Petar Petrov 0555186655 Optimize fillDataGapsAndRoundCaps gap fill with a single forward merge 2026-06-15 14:25:11 +03:00
2 changed files with 222 additions and 12 deletions
+62 -12
View File
@@ -47,9 +47,66 @@ export function fillDataGapsAndRoundCaps(
)
).sort((a, b) => a - b);
// make sure all datasets have the same buckets
// otherwise the chart will render incorrectly in some cases
buckets.forEach((bucket, index) => {
// Phase 1: align every dataset to the shared buckets, inserting zero-valued
// gap fillers. The original implementation spliced gaps in-place while
// iterating bucket-by-bucket, which shifts the whole tail of the array on
// every gap (O(points * gaps) in the worst case). Rebuilding each dataset in
// a single forward merge against the bucket axis yields the byte-identical
// aligned array without the per-gap shifting cost.
const bucketCount = buckets.length;
for (let i = datasets.length - 1; i >= 0; i--) {
const data = datasets[i].data;
if (!data || data.length === 0) {
continue;
}
const dataLength = data.length;
const aligned: typeof data = [];
let srcIdx = 0;
// The original loop runs once per bucket index and reads data[index],
// which (thanks to the in-place splices) always points at the next
// unconsumed source element. A defined element with a non-matching x
// inserts a gap and keeps the element for the next index; a matching or
// otherwise-defined element is kept and the pointer advances; once the
// source is exhausted, no further gaps are added.
for (let index = 0; index < bucketCount; index++) {
if (srcIdx >= dataLength) {
break;
}
const dataPoint = data[srcIdx];
const item: any =
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
? dataPoint
: { value: dataPoint };
const x = item.value?.[0];
if (x === undefined) {
// Malformed element: left untouched in place, pointer advances.
aligned.push(dataPoint);
srcIdx++;
} else if (Number(x) !== buckets[index]) {
aligned.push({
value: [buckets[index], 0],
itemStyle: {
borderWidth: 0,
},
} as (typeof data)[number]);
} else {
aligned.push(dataPoint);
srcIdx++;
}
}
// Trailing source elements past the last consumed bucket index are never
// reached by the original loop and stay in place unchanged.
for (; srcIdx < dataLength; srcIdx++) {
aligned.push(data[srcIdx]);
}
datasets[i].data = aligned;
}
// Phase 2: per bucket, mark only the uppermost positive and lowermost
// negative bar of each stack with a rounded cap, and strip the border from
// zero values. Datasets are now aligned, so data[index] always corresponds
// to buckets[index] (or is undefined past the dataset's last point).
for (let index = 0; index < bucketCount; index++) {
const capRounded = {};
const capRoundedNegative = {};
for (let i = datasets.length - 1; i >= 0; i--) {
@@ -63,14 +120,7 @@ export function fillDataGapsAndRoundCaps(
if (x === undefined) {
continue;
}
if (Number(x) !== bucket) {
datasets[i].data?.splice(index, 0, {
value: [bucket, 0],
itemStyle: {
borderWidth: 0,
},
});
} else if (item.value?.[1] === 0) {
if (item.value?.[1] === 0) {
// remove the border for zero values or it will be rendered
datasets[i].data![index] = {
...item,
@@ -99,5 +149,5 @@ export function fillDataGapsAndRoundCaps(
capRoundedNegative[stack] = true;
}
}
});
}
}
@@ -441,6 +441,166 @@ describe("fillDataGapsAndRoundCaps", () => {
assert.equal(datasets[0].data!.length, 0);
});
it("fills several consecutive gaps for one dataset", () => {
const datasets: BarSeriesOption[] = [
{
type: "bar",
stack: "a",
data: [
[1000, 1],
[4000, 4],
],
},
{
type: "bar",
stack: "a",
data: [[2000, 2]],
},
{
type: "bar",
stack: "a",
data: [
[1000, 1],
[2000, 2],
[3000, 3],
[4000, 4],
],
},
];
fillDataGapsAndRoundCaps(datasets);
// First dataset is aligned across all four buckets with 2 zero gaps.
assert.equal(datasets[0].data!.length, 4);
assert.equal(getBarItem(datasets[0], 0).value[0], 1000);
assert.equal(getBarItem(datasets[0], 1).value[0], 2000);
assert.equal(getBarItem(datasets[0], 1).value[1], 0);
assert.equal(getBarItem(datasets[0], 1).itemStyle.borderWidth, 0);
assert.equal(getBarItem(datasets[0], 2).value[0], 3000);
assert.equal(getBarItem(datasets[0], 2).value[1], 0);
assert.equal(getBarItem(datasets[0], 3).value[0], 4000);
// Second dataset only has a point at bucket 2000; the leading 1000 bucket
// is filled and no trailing buckets are added past its last point.
assert.equal(datasets[1].data!.length, 2);
assert.equal(getBarItem(datasets[1], 0).value[0], 1000);
assert.equal(getBarItem(datasets[1], 0).value[1], 0);
assert.equal(getBarItem(datasets[1], 1).value[0], 2000);
});
it("does not grow a dataset past its last bucket (duplicate x)", () => {
// Only one unique bucket exists, so the second duplicate point is never
// reached by the alignment loop and is left in place untouched.
const datasets: BarSeriesOption[] = [
{
type: "bar",
stack: "a",
data: [
[1000, 1],
[1000, 2],
],
},
];
fillDataGapsAndRoundCaps(datasets);
assert.equal(datasets[0].data!.length, 2);
const first = getBarItem(datasets[0], 0);
assert.equal(first.value[0], 1000);
assert.deepEqual(first.itemStyle.borderRadius, [4, 4, 0, 0]);
// The duplicate stays as a raw tuple, untouched.
assert.deepEqual(datasets[0].data![1], [1000, 2]);
});
it("rounds only the first positive and first negative scanned from the top per stack", () => {
// Datasets are scanned from last to first, so the first positive and first
// negative encountered from the top of the stack receive the rounded caps;
// the remaining bars in the same stack are left untouched.
const datasets: BarSeriesOption[] = [
{
type: "bar",
stack: "a",
data: [[1000, -10]],
},
{
type: "bar",
stack: "a",
data: [[1000, -5]],
},
{
type: "bar",
stack: "a",
data: [[1000, 5]],
},
{
type: "bar",
stack: "a",
data: [[1000, 10]],
},
];
fillDataGapsAndRoundCaps(datasets);
// First positive scanned from the top (last dataset) rounds the top.
assert.deepEqual(
getBarItem(datasets[3], 0).itemStyle.borderRadius,
[4, 4, 0, 0]
);
// Lower positive is left untouched.
assert.equal(getBarItem(datasets[2], 0).itemStyle?.borderRadius, undefined);
// First negative scanned from the top rounds the bottom.
assert.deepEqual(
getBarItem(datasets[1], 0).itemStyle.borderRadius,
[0, 0, 4, 4]
);
// Lower negative is left untouched.
assert.equal(getBarItem(datasets[0], 0).itemStyle?.borderRadius, undefined);
});
});
describe("fillDataGapsAndRoundCaps non-stacked", () => {
it("rounds the top of every series and bottom of negative points", () => {
const datasets: BarSeriesOption[] = [
{
type: "bar",
data: [
[1000, 5],
[2000, -3],
[3000, 0],
],
},
];
fillDataGapsAndRoundCaps(datasets, false);
// The series gets an overall top border radius.
assert.deepEqual(datasets[0].itemStyle!.borderRadius, [4, 4, 0, 0]);
// Negative point is overridden to a bottom border radius.
const negative = getBarItem(datasets[0], 1);
assert.deepEqual(negative.itemStyle.borderRadius, [0, 0, 4, 4]);
// Positive and zero points stay as raw tuples (no per-point override).
assert.deepEqual(datasets[0].data![0], [1000, 5]);
assert.deepEqual(datasets[0].data![2], [3000, 0]);
});
it("preserves existing series itemStyle and per-point style", () => {
const datasets: BarSeriesOption[] = [
{
type: "bar",
itemStyle: { color: "red" },
data: [{ value: [1000, -2], itemStyle: { opacity: 0.5 } }],
},
];
fillDataGapsAndRoundCaps(datasets, false);
assert.equal(datasets[0].itemStyle!.color, "red");
assert.deepEqual(datasets[0].itemStyle!.borderRadius, [4, 4, 0, 0]);
const point = getBarItem(datasets[0], 0);
assert.equal(point.itemStyle.opacity, 0.5);
assert.deepEqual(point.itemStyle.borderRadius, [0, 0, 4, 4]);
});
});
describe("getCompareTransform", () => {