diff --git a/src/chart/line/LineSeries.ts b/src/chart/line/LineSeries.ts index 88dbdb4044..ce85662f44 100644 --- a/src/chart/line/LineSeries.ts +++ b/src/chart/line/LineSeries.ts @@ -19,6 +19,7 @@ import createSeriesData from '../helper/createSeriesData'; import SeriesModel from '../../model/Series'; +import type { LineSmoothMonotone } from './monotoneCubic'; import { SeriesOnCartesianOptionMixin, SeriesOption, @@ -109,7 +110,15 @@ export interface LineSeriesOption extends SeriesOption, + dependent: ArrayLike, + scale: number +): number[] { + const n = independent.length; + const slopes: number[] = []; + if (n <= 0) { + return slopes; + } + if (n === 1) { + slopes.push(0); + return slopes; + } + + const increasing = independent[n - 1] >= independent[0]; + const indep: number[] = []; + const dep: number[] = []; + for (let i = 0; i < n; i++) { + const src = increasing ? i : n - 1 - i; + indep.push(independent[src]); + dep.push(dependent[src]); + } + + const h: number[] = []; + const delta: number[] = []; + for (let i = 0; i < n - 1; i++) { + const hi = indep[i + 1] - indep[i]; + h.push(hi); + delta.push((dep[i + 1] - dep[i]) / hi); + } + + const raw: number[] = []; + if (n === 2) { + raw.push(delta[0], delta[0]); + } + else { + raw.push(endpointSlope(h[0], h[1], delta[0], delta[1])); + for (let i = 1; i < n - 1; i++) { + raw.push(interiorSlope(h[i - 1], h[i], delta[i - 1], delta[i])); + } + raw.push(endpointSlope(h[n - 2], h[n - 3], delta[n - 2], delta[n - 3])); + } + + const tangentScale = clampTangentScale(scale); + for (let i = 0; i < n; i++) { + const src = increasing ? i : n - 1 - i; + slopes.push(raw[src] * tangentScale); + } + return slopes; +} + +/** + * Cubic Bézier segments through a strictly monotone run. + * `axis` selects the independent coordinate. `scale` multiplies tangents. + */ +export function monotoneCubicSegments( + points: ArrayLike, + axis: 'x' | 'y', + scale: number +): CubicBezierSegment[] { + const n = points.length / 2; + const independent: number[] = []; + const dependent: number[] = []; + const xs: number[] = []; + const ys: number[] = []; + for (let i = 0; i < n; i++) { + const x = points[i * 2]; + const y = points[i * 2 + 1]; + xs.push(x); + ys.push(y); + if (axis === 'x') { + independent.push(x); + dependent.push(y); + } + else { + independent.push(y); + dependent.push(x); + } + } + + const slopes = monotoneTangents(independent, dependent, scale); + const segments: CubicBezierSegment[] = []; + for (let i = 0; i < n - 1; i++) { + const x0 = xs[i]; + const y0 = ys[i]; + const x1 = xs[i + 1]; + const y1 = ys[i + 1]; + const m0 = slopes[i]; + const m1 = slopes[i + 1]; + if (axis === 'x') { + const dx = x1 - x0; + segments.push({ + x0: x0, + y0: y0, + cpx1: x0 + dx / 3, + cpy1: y0 + m0 * dx / 3, + cpx2: x1 - dx / 3, + cpy2: y1 - m1 * dx / 3, + x1: x1, + y1: y1 + }); + } + else { + const dy = y1 - y0; + segments.push({ + x0: x0, + y0: y0, + cpx1: x0 + m0 * dy / 3, + cpy1: y0 + dy / 3, + cpx2: x1 - m1 * dy / 3, + cpy2: y1 - dy / 3, + x1: x1, + y1: y1 + }); + } + } + return segments; +} + +function clampTangentScale(scale: number): number { + if (!(scale > 0)) { + return 0; + } + return scale > 1 ? 1 : scale; +} + +/** + * One-sided three-point slope at an end sample, then the Fritsch–Carlson + * endpoint clamp. `h0` / `delta0` are the interval adjacent to the endpoint. + */ +function endpointSlope(h0: number, h1: number, delta0: number, delta1: number): number { + const slope = ((2 * h0 + h1) * delta0 - h0 * delta1) / (h0 + h1); + if (sign(slope) !== sign(delta0)) { + return 0; + } + if (sign(delta0) !== sign(delta1) && Math.abs(slope) > 3 * Math.abs(delta0)) { + return 3 * delta0; + } + return slope; +} + +/** + * Fritsch–Butland weighted harmonic mean. Zero when the secants change + * sign or either secant is zero, which is where the data has an extremum. + */ +function interiorSlope(hPrev: number, hNext: number, deltaPrev: number, deltaNext: number): number { + if (!(deltaPrev * deltaNext > 0)) { + return 0; + } + const w1 = 2 * hNext + hPrev; + const w2 = hNext + 2 * hPrev; + return (w1 + w2) / (w1 / deltaPrev + w2 / deltaNext); +} + +function sign(value: number): number { + if (value < 0) { + return -1; + } + if (value > 0) { + return 1; + } + return 0; +} diff --git a/src/chart/line/poly.ts b/src/chart/line/poly.ts index 10ca2a7472..7757c567c9 100644 --- a/src/chart/line/poly.ts +++ b/src/chart/line/poly.ts @@ -24,10 +24,271 @@ import PathProxy from 'zrender/src/core/PathProxy'; import { cubicRootAt, cubicAt } from 'zrender/src/core/curve'; import tokens from '../../visual/tokens'; import { isPointIllegal } from './helper'; +import { monotoneCubicSegments, CubicBezierSegment, LineSmoothMonotone } from './monotoneCubic'; const mathMin = Math.min; const mathMax = Math.max; +/** + * Knots visited by `drawSegment`, in walk order, plus the same `k` the + * straight and Bézier paths return. Stacked areas use `k` to walk the + * baseline back over the same indices. + */ +function collectSmoothKnots( + points: ArrayLike, + start: number, + segLen: number, + allLen: number, + dir: number, + connectNulls: boolean, + knots: number[] +): number { + let prevX = 0; + let prevY = 0; + let started = false; + let idx = start; + let k = 0; + for (; k < segLen; k++) { + let x = points[idx * 2]; + let y = points[idx * 2 + 1]; + + if (idx >= allLen || idx < 0) { + break; + } + if (isPointIllegal(x, y)) { + if (connectNulls) { + idx += dir; + continue; + } + break; + } + + if (!started) { + knots.push(x, y); + started = true; + } + else { + const dx = x - prevX; + const dy = y - prevY; + // Ignore tiny segment. + if ((dx * dx + dy * dy) < 0.5) { + idx += dir; + continue; + } + + let nextIdx = idx + dir; + let nextX = points[nextIdx * 2]; + let nextY = points[nextIdx * 2 + 1]; + // Ignore duplicate point + while (nextX === x && nextY === y && k < segLen) { + k++; + nextIdx += dir; + idx += dir; + nextX = points[nextIdx * 2]; + nextY = points[nextIdx * 2 + 1]; + x = points[idx * 2]; + y = points[idx * 2 + 1]; + } + knots.push(x, y); + } + + prevX = x; + prevY = y; + idx += dir; + } + return k; +} + +function drawMonotoneSegment( + ctx: PathProxy, + points: ArrayLike, + start: number, + segLen: number, + allLen: number, + dir: number, + smooth: number, + axis: 'x' | 'y', + connectNulls: boolean +): number { + const knots: number[] = []; + const k = collectSmoothKnots(points, start, segLen, allLen, dir, connectNulls, knots); + emitMonotoneKnots(ctx, knots, dir, axis, smooth); + return k; +} + +function emitMonotoneKnots( + ctx: PathProxy, + knots: number[], + dir: number, + axis: 'x' | 'y', + scale: number +) { + const count = knots.length / 2; + if (count === 0) { + return; + } + const forward = dir < 0 ? reversePointPairs(knots) : knots; + const segments = smoothRuns(forward, axis, scale); + if (dir < 0) { + ctx.lineTo(knots[0], knots[1]); + for (let i = segments.length - 1; i >= 0; i--) { + const seg = segments[i]; + ctx.bezierCurveTo(seg.cpx2, seg.cpy2, seg.cpx1, seg.cpy1, seg.x0, seg.y0); + } + } + else { + ctx.moveTo(forward[0], forward[1]); + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + ctx.bezierCurveTo(seg.cpx1, seg.cpy1, seg.cpx2, seg.cpy2, seg.x1, seg.y1); + } + } +} + +/** + * Strictly monotone runs use Fritsch–Butland cubics. A piece whose + * independent coordinate stands still uses the constrained Bézier. + */ +function smoothRuns( + points: number[], + axis: 'x' | 'y', + scale: number +): CubicBezierSegment[] { + const n = points.length / 2; + const segments: CubicBezierSegment[] = []; + let i = 0; + while (i < n - 1) { + const delta = independentDelta(points, i, axis); + if (delta === 0) { + let j = i + 1; + while (j < n - 1 && independentDelta(points, j, axis) === 0) { + j++; + } + appendSegments(segments, legacySmoothSegments(slicePoints(points, i, j), scale)); + i = j; + } + else { + const positive = delta > 0; + let j = i + 1; + while (j < n - 1 && sameStrictSign(independentDelta(points, j, axis), positive)) { + j++; + } + appendSegments( + segments, + monotoneCubicSegments(slicePoints(points, i, j), axis, scale) + ); + i = j; + } + } + return segments; +} + +function independentDelta(points: number[], index: number, axis: 'x' | 'y'): number { + const offset = axis === 'x' ? 0 : 1; + return points[(index + 1) * 2 + offset] - points[index * 2 + offset]; +} + +function sameStrictSign(delta: number, positive: boolean): boolean { + return positive ? delta > 0 : delta < 0; +} + +function slicePoints(points: number[], start: number, endInclusive: number): number[] { + return points.slice(start * 2, (endInclusive + 1) * 2); +} + +function appendSegments(out: CubicBezierSegment[], extra: CubicBezierSegment[]) { + for (let i = 0; i < extra.length; i++) { + out.push(extra[i]); + } +} + +function reversePointPairs(points: number[]): number[] { + const out: number[] = []; + for (let i = points.length - 2; i >= 0; i -= 2) { + out.push(points[i], points[i + 1]); + } + return out; +} + +/** + * Constrained Bézier used when `smoothMonotone` is null or `'none'`. + * Kept here so a non-monotone piece of a monotone series can reuse it + * without changing the original `drawSegment` branch. + */ +function legacySmoothSegments(points: number[], smooth: number): CubicBezierSegment[] { + const n = points.length / 2; + const segments: CubicBezierSegment[] = []; + let outX = points[0]; + let outY = points[1]; + for (let i = 1; i < n; i++) { + const prevX = points[(i - 1) * 2]; + const prevY = points[(i - 1) * 2 + 1]; + const x = points[i * 2]; + const y = points[i * 2 + 1]; + let inX = x; + let inY = y; + let nextOutX = x; + let nextOutY = y; + if (i < n - 1) { + const nextX = points[(i + 1) * 2]; + const nextY = points[(i + 1) * 2 + 1]; + const dx0 = x - prevX; + const dy0 = y - prevY; + const dx1 = nextX - x; + const dy1 = nextY - y; + const lenPrevSeg = Math.sqrt(dx0 * dx0 + dy0 * dy0); + const lenNextSeg = Math.sqrt(dx1 * dx1 + dy1 * dy1); + if (lenPrevSeg !== 0 && lenNextSeg !== 0) { + let vx = nextX - prevX; + let vy = nextY - prevY; + const ratioNextSeg = lenNextSeg / (lenNextSeg + lenPrevSeg); + inX = x - vx * smooth * (1 - ratioNextSeg); + inY = y - vy * smooth * (1 - ratioNextSeg); + nextOutX = x + vx * smooth * ratioNextSeg; + nextOutY = y + vy * smooth * ratioNextSeg; + + // Smooth constraint between point and next point. + // Avoid exceeding extreme after smoothing. + nextOutX = mathMin(nextOutX, mathMax(nextX, x)); + nextOutY = mathMin(nextOutY, mathMax(nextY, y)); + nextOutX = mathMax(nextOutX, mathMin(nextX, x)); + nextOutY = mathMax(nextOutY, mathMin(nextY, y)); + vx = nextOutX - x; + vy = nextOutY - y; + + inX = x - vx * lenPrevSeg / lenNextSeg; + inY = y - vy * lenPrevSeg / lenNextSeg; + + // Smooth constraint between point and prev point. + // Avoid exceeding extreme after smoothing. + inX = mathMin(inX, mathMax(prevX, x)); + inY = mathMin(inY, mathMax(prevY, y)); + inX = mathMax(inX, mathMin(prevX, x)); + inY = mathMax(inY, mathMin(prevY, y)); + + // Adjust next cp0 again. + vx = x - inX; + vy = y - inY; + nextOutX = x + vx * lenNextSeg / lenPrevSeg; + nextOutY = y + vy * lenNextSeg / lenPrevSeg; + } + } + segments.push({ + x0: prevX, + y0: prevY, + cpx1: outX, + cpy1: outY, + cpx2: inX, + cpy2: inY, + x1: x, + y1: y + }); + outX = nextOutX; + outY = nextOutY; + } + return segments; +} + /** * Draw smoothed line in non-monotone, in may cause undesired curve in extreme * situations. This should be used when points are non-monotone neither in x or @@ -41,9 +302,16 @@ function drawSegment( allLen: number, dir: number, smooth: number, - smoothMonotone: 'x' | 'y' | 'none', + smoothMonotone: LineSmoothMonotone, connectNulls: boolean ) { + if ((smoothMonotone === 'monotoneX' || smoothMonotone === 'monotoneY') && smooth > 0) { + return drawMonotoneSegment( + ctx, points, start, segLen, allLen, dir, smooth, + smoothMonotone === 'monotoneX' ? 'x' : 'y', + connectNulls + ); + } let prevX: number; let prevY: number; let cpx0: number; @@ -213,7 +481,7 @@ class ECPolylineShape { points: ArrayLike; smooth = 0; smoothConstraint = true; - smoothMonotone: 'x' | 'y' | 'none'; + smoothMonotone: LineSmoothMonotone; connectNulls: boolean; } diff --git a/test/line-monotone.html b/test/line-monotone.html new file mode 100644 index 0000000000..7955e7385d --- /dev/null +++ b/test/line-monotone.html @@ -0,0 +1,250 @@ + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + + + + + diff --git a/test/ut/jest.config.cjs b/test/ut/jest.config.cjs index 5ca8031953..ec4575589f 100644 --- a/test/ut/jest.config.cjs +++ b/test/ut/jest.config.cjs @@ -41,6 +41,7 @@ module.exports = { ], testMatch: [ '**/spec/api/*.test.ts', + '**/spec/chart/**/*.test.ts', '**/spec/component/**/*.test.ts', '**/spec/series/**/*.test.ts', '**/spec/data/*.test.ts', diff --git a/test/ut/spec/chart/line/monotoneCubic.test.ts b/test/ut/spec/chart/line/monotoneCubic.test.ts new file mode 100644 index 0000000000..174ced563c --- /dev/null +++ b/test/ut/spec/chart/line/monotoneCubic.test.ts @@ -0,0 +1,339 @@ +/* +* Licensed to the Apache Software Foundation (ASF) under one +* or more contributor license agreements. See the NOTICE file +* distributed with this work for additional information +* regarding copyright ownership. The ASF licenses this file +* to you under the Apache License, Version 2.0 (the +* "License"); you may not use this file except in compliance +* with the License. You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, +* software distributed under the License is distributed on an +* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +* KIND, either express or implied. See the License for the +* specific language governing permissions and limitations +* under the License. +*/ + +import { cubicAt } from 'zrender/src/core/curve'; +import PathProxy from 'zrender/src/core/PathProxy'; +import { ECPolyline, ECPolygon } from '@/src/chart/line/poly'; +import { + monotoneCubicSegments, + monotoneTangentScale, + monotoneTangents, + CubicBezierSegment +} from '@/src/chart/line/monotoneCubic'; + +describe('chart/line/monotoneCubic', function () { + + it('uses the Fritsch–Butland slopes on equal and unequal spacing', function () { + // x = [0, 1, 2, 3], y = [0, 1, 2, 4] + // interior harmonic means are 1 and 4/3; ends are 1 and 2.5 + const equal = monotoneTangents([0, 1, 2, 3], [0, 1, 2, 4], 1); + expect(equal[0]).toBeCloseTo(1, 10); + expect(equal[1]).toBeCloseTo(1, 10); + expect(equal[2]).toBeCloseTo(4 / 3, 10); + expect(equal[3]).toBeCloseTo(2.5, 10); + + // h = [1, 2], delta = [1, 3] + // interior (w1 + w2) / (w1/d0 + w2/d1) with w1 = 5, w2 = 4 is 27/19 + // ends are 1/3 and 13/3 + const unequal = monotoneTangents([0, 1, 3], [0, 1, 7], 1); + expect(unequal[0]).toBeCloseTo(1 / 3, 10); + expect(unequal[1]).toBeCloseTo(27 / 19, 10); + expect(unequal[2]).toBeCloseTo(13 / 3, 10); + }); + + it('clamps an endpoint slope into the monotone region', function () { + // delta = [1, -11]. The raw start slope is 7, which is outside + // the region, so it becomes 3. The other end stays at -17. + const slopes = monotoneTangents([0, 1, 2], [0, 1, -10], 1); + expect(slopes[0]).toBeCloseTo(3, 10); + expect(slopes[1]).toBeCloseTo(0, 10); + expect(slopes[2]).toBeCloseTo(-17, 10); + }); + + it('keeps the same slopes when the independent coordinate decreases', function () { + const forward = monotoneTangents([0, 1, 2, 3], [0, 1, 2, 4], 1); + const backward = monotoneTangents([3, 2, 1, 0], [4, 2, 1, 0], 1); + expect(backward[0]).toBeCloseTo(forward[3], 10); + expect(backward[1]).toBeCloseTo(forward[2], 10); + expect(backward[2]).toBeCloseTo(forward[1], 10); + expect(backward[3]).toBeCloseTo(forward[0], 10); + }); + + it('scales tangents toward zero and clamps the scale to 1', function () { + expect(monotoneTangentScale(true)).toBe(1); + expect(monotoneTangentScale(false)).toBe(0); + expect(monotoneTangentScale(0.4)).toBe(0.4); + expect(monotoneTangentScale(3)).toBe(1); + expect(monotoneTangentScale(-2)).toBe(0); + + const full = monotoneTangents([0, 1, 2, 3], [0, 1, 2, 4], 1); + const half = monotoneTangents([0, 1, 2, 3], [0, 1, 2, 4], 0.5); + const above = monotoneTangents([0, 1, 2, 3], [0, 1, 2, 4], 2); + for (let i = 0; i < full.length; i++) { + expect(half[i]).toBeCloseTo(full[i] * 0.5, 10); + expect(Math.abs(half[i])).toBeLessThanOrEqual(Math.abs(full[i]) + 1e-10); + expect(above[i]).toBeCloseTo(full[i], 10); + } + }); + + it('does not leave the range of a monotone segment', function () { + const points = [0, 0, 1, 1, 3, 7, 6, 8]; + for (let s = 0; s < 2; s++) { + const segments = monotoneCubicSegments(points, 'x', s === 0 ? 1 : 0.35); + expect(segments.length).toBe(3); + for (let i = 0; i < segments.length; i++) { + expectDependentInRange(segments[i], 'x'); + } + } + }); + + it('places a zero tangent on a local peak and does not pass it', function () { + const slopes = monotoneTangents([0, 1, 2], [0, 1, 0], 1); + expect(slopes[1]).toBeCloseTo(0, 10); + + const segments = monotoneCubicSegments([0, 0, 1, 1, 2, 0], 'x', 1); + expect(segments.length).toBe(2); + for (let i = 0; i < segments.length; i++) { + expectDependentInRange(segments[i], 'x'); + for (let step = 0; step <= 16; step++) { + const y = sampleDependent(segments[i], 'x', step / 16); + expect(y).toBeLessThanOrEqual(1 + 1e-8); + expect(y).toBeGreaterThanOrEqual(-1e-8); + } + } + }); + + it('stays flat when the samples are flat', function () { + const slopes = monotoneTangents([0, 1, 3], [5, 5, 5], 1); + expect(slopes).toEqual([0, 0, 0]); + + const segments = monotoneCubicSegments([0, 5, 1, 5, 3, 5], 'x', 1); + for (let i = 0; i < segments.length; i++) { + for (let step = 0; step <= 8; step++) { + expect(sampleDependent(segments[i], 'x', step / 8)).toBeCloseTo(5, 10); + } + } + }); + + it('draws a straight segment through two points at full strength', function () { + const segments = monotoneCubicSegments([0, 0, 2, 4], 'x', 1); + expect(segments.length).toBe(1); + for (let step = 0; step <= 8; step++) { + const t = step / 8; + expect(sampleDependent(segments[0], 'x', t)).toBeCloseTo(4 * t, 8); + } + }); + + it('keeps monotoneY inside the x range of each segment', function () { + // Same samples as the equal-spacing slope case, with the axes swapped. + const slopes = monotoneTangents([0, 1, 2, 3], [0, 1, 2, 4], 1); + const segments = monotoneCubicSegments([0, 0, 1, 1, 2, 2, 4, 3], 'y', 1); + expect(segments.length).toBe(3); + for (let i = 0; i < segments.length; i++) { + expectDependentInRange(segments[i], 'y'); + } + expect(slopes[2]).toBeCloseTo(4 / 3, 10); + }); + + it('builds the polyline from those cubics and leaves flat tangents unchanged', function () { + const points = [0, 0, 1, 1, 2, 2, 3, 4]; + const expected = monotoneCubicSegments(points, 'x', 1); + const cubics = readCubics(new ECPolyline({ + shape: { + points: points, + smooth: 1, + smoothMonotone: 'monotoneX' + } + })); + expect(cubics.length).toBe(expected.length); + for (let i = 0; i < expected.length; i++) { + expectSegmentClose(cubics[i], expected[i]); + } + + const flat = readCubics(new ECPolyline({ + shape: { + points: [0, 0, 10, 5, 20, 8], + smooth: 1, + smoothMonotone: 'x' + } + })); + expect(flat.length).toBeGreaterThan(0); + for (let i = 0; i < flat.length; i++) { + expect(near(flat[i].cpy1, flat[i].y0) || near(flat[i].cpy1, flat[i].y1)).toBe(true); + expect(near(flat[i].cpy2, flat[i].y0) || near(flat[i].cpy2, flat[i].y1)).toBe(true); + } + }); + + it('splits a turnaround and a repeated x into monotone pieces', function () { + const turn = [0, 0, 1, 1, 2, 3, 1, 2, 0, 0]; + const turnCubics = readCubics(new ECPolyline({ + shape: { + points: turn, + smooth: 1, + smoothMonotone: 'monotoneX' + } + })); + const rising = monotoneCubicSegments([0, 0, 1, 1, 2, 3], 'x', 1); + const falling = monotoneCubicSegments([2, 3, 1, 2, 0, 0], 'x', 1); + expect(turnCubics.length).toBe(rising.length + falling.length); + for (let i = 0; i < rising.length; i++) { + expectSegmentClose(turnCubics[i], rising[i]); + } + for (let i = 0; i < falling.length; i++) { + expectSegmentClose(turnCubics[rising.length + i], falling[i]); + expectDependentInRange(turnCubics[rising.length + i], 'x'); + } + + const repeated = readCubics(new ECPolyline({ + shape: { + points: [0, 0, 1, 2, 1, 5, 2, 3], + smooth: 1, + smoothMonotone: 'monotoneX' + } + })); + expectSegmentClose(repeated[0], monotoneCubicSegments([0, 0, 1, 2], 'x', 1)[0]); + expect(near(repeated[1].x0, 1)).toBe(true); + expect(near(repeated[1].x1, 1)).toBe(true); + expect(near(repeated[1].cpx1, 1)).toBe(true); + expect(near(repeated[1].cpx2, 1)).toBe(true); + expectSegmentClose(repeated[2], monotoneCubicSegments([1, 5, 2, 3], 'x', 1)[0]); + }); + + it('breaks on nulls unless connectNulls is set', function () { + const points = [0, 0, 1, 1, NaN, NaN, 3, 2, 4, 2]; + const broken = readPath(new ECPolyline({ + shape: { + points: points, + smooth: 1, + smoothMonotone: 'monotoneX', + connectNulls: false + } + })); + const joined = readPath(new ECPolyline({ + shape: { + points: points, + smooth: 1, + smoothMonotone: 'monotoneX', + connectNulls: true + } + })); + expect(broken.moves).toBe(2); + expect(joined.moves).toBe(1); + expect(joined.cubics.length).toBe(3); + }); + + it('smooths a stacked area in both directions without leaving the sample range', function () { + const polygon = new ECPolygon({ + shape: { + points: [0, 1, 1, 2, 2, 2, 3, 4], + stackedOnPoints: [0, 0, 1, 0, 2, 0, 3, 0], + smooth: 1, + stackedOnSmooth: 1, + smoothMonotone: 'monotoneX' + } + }); + const read = readPath(polygon); + expect(read.moves).toBe(1); + expect(read.cubics.length).toBeGreaterThan(0); + for (let i = 0; i < read.cubics.length; i++) { + const seg = read.cubics[i]; + for (let step = 0; step <= 8; step++) { + const y = sampleDependent(seg, 'x', step / 8); + expect(y).toBeGreaterThanOrEqual(-1e-4); + expect(y).toBeLessThanOrEqual(4 + 1e-4); + } + } + }); +}); + +function sampleDependent(segment: CubicBezierSegment, axis: 'x' | 'y', t: number): number { + if (axis === 'x') { + return cubicAt(segment.y0, segment.cpy1, segment.cpy2, segment.y1, t); + } + return cubicAt(segment.x0, segment.cpx1, segment.cpx2, segment.x1, t); +} + +function expectDependentInRange(segment: CubicBezierSegment, axis: 'x' | 'y') { + const dep0 = axis === 'x' ? segment.y0 : segment.x0; + const dep1 = axis === 'x' ? segment.y1 : segment.x1; + const lo = Math.min(dep0, dep1) - 1e-6; + const hi = Math.max(dep0, dep1) + 1e-6; + for (let step = 0; step <= 16; step++) { + const value = sampleDependent(segment, axis, step / 16); + expect(value).toBeGreaterThanOrEqual(lo); + expect(value).toBeLessThanOrEqual(hi); + } +} + +function expectSegmentClose(actual: CubicBezierSegment, expected: CubicBezierSegment) { + expect(actual.x0).toBeCloseTo(expected.x0, 4); + expect(actual.y0).toBeCloseTo(expected.y0, 4); + expect(actual.cpx1).toBeCloseTo(expected.cpx1, 4); + expect(actual.cpy1).toBeCloseTo(expected.cpy1, 4); + expect(actual.cpx2).toBeCloseTo(expected.cpx2, 4); + expect(actual.cpy2).toBeCloseTo(expected.cpy2, 4); + expect(actual.x1).toBeCloseTo(expected.x1, 4); + expect(actual.y1).toBeCloseTo(expected.y1, 4); +} + +function near(actual: number, expected: number): boolean { + return Math.abs(actual - expected) < 1e-4; +} + +function readCubics(path: ECPolyline | ECPolygon): CubicBezierSegment[] { + return readPath(path).cubics; +} + +function readPath(path: ECPolyline | ECPolygon) { + const data = path.getUpdatedPathProxy().data; + const CMD = PathProxy.CMD; + const cubics: CubicBezierSegment[] = []; + let moves = 0; + let x0 = 0; + let y0 = 0; + for (let i = 0; i < data.length;) { + const cmd = data[i++]; + if (cmd === CMD.M) { + moves++; + x0 = data[i++]; + y0 = data[i++]; + } + else if (cmd === CMD.L) { + x0 = data[i++]; + y0 = data[i++]; + } + else if (cmd === CMD.C) { + const cpx1 = data[i++]; + const cpy1 = data[i++]; + const cpx2 = data[i++]; + const cpy2 = data[i++]; + const x1 = data[i++]; + const y1 = data[i++]; + cubics.push({ + x0: x0, + y0: y0, + cpx1: cpx1, + cpy1: cpy1, + cpx2: cpx2, + cpy2: cpy2, + x1: x1, + y1: y1 + }); + x0 = x1; + y0 = y1; + } + else if (cmd === CMD.Z) { + // closePath stores no coordinates. + } + else { + throw new Error('Unexpected path command ' + cmd); + } + } + return { cubics: cubics, moves: moves }; +}