From 3209131bccb56714b66834fdc595420283a76b85 Mon Sep 17 00:00:00 2001 From: Alaric Taylor-Roffey Date: Mon, 19 Oct 2026 10:24:52 +0000 Subject: [PATCH 1/3] feat(line): read a per-datum smoothBreak and keep it on the morphed vertex. --- src/chart/line/LineSeries.ts | 7 +++ src/chart/line/LineView.ts | 82 ++++++++++++++++++++++++++--- src/chart/line/helper.ts | 16 +++++- src/chart/line/lineAnimationDiff.ts | 13 ++++- src/chart/line/poly.ts | 5 ++ 5 files changed, 111 insertions(+), 12 deletions(-) diff --git a/src/chart/line/LineSeries.ts b/src/chart/line/LineSeries.ts index 88dbdb4044..98603f69df 100644 --- a/src/chart/line/LineSeries.ts +++ b/src/chart/line/LineSeries.ts @@ -64,6 +64,13 @@ export interface LineDataItemOption extends SymbolOptionMixin, name?: string value?: LineDataValue + + /** + * Restart smoothing at this vertex. The stroke stays connected. + * A missing sample (`null`) is still what lifts the pen. + * Ignored while `step` is set. Dataset rows do not carry this flag. + */ + smoothBreak?: boolean } export interface LineEndLabelOption extends SeriesLabelOption { diff --git a/src/chart/line/LineView.ts b/src/chart/line/LineView.ts index 847a876495..3f42019307 100644 --- a/src/chart/line/LineView.ts +++ b/src/chart/line/LineView.ts @@ -27,7 +27,7 @@ import * as graphic from '../../util/graphic'; import * as modelUtil from '../../util/model'; import { ECPolyline, ECPolygon } from './poly'; import ChartView from '../../view/Chart'; -import { prepareDataCoordInfo, getStackedOnPoint, isPointIllegal } from './helper'; +import { prepareDataCoordInfo, getStackedOnPoint, isPointIllegal, isSmoothBreakDatum } from './helper'; import { createGridClipPath, createPolarClipPath } from '../helper/createClipPathFromCoordSys'; import LineSeriesModel, { LineSeriesOption } from './LineSeries'; import type GlobalModel from '../../model/Global'; @@ -119,6 +119,44 @@ function getSmooth(smooth: number | boolean) { return zrUtil.isNumber(smooth) ? smooth : (smooth ? 0.5 : 0); } +/** + * One flag per layout point. `step` rebuilds the point list, so a seam index + * would no longer name a sample. No flags means the line is one smooth piece. + */ +function readSmoothBreaks( + data: SeriesData, + pointCount: number, + step: LineSeriesOption['step'] +): boolean[] | null { + if (step || pointCount <= 0 || data.count() !== pointCount) { + return null; + } + const breaks: boolean[] = []; + let any = false; + for (let i = 0; i < pointCount; i++) { + const on = isSmoothBreakDatum(data, i); + breaks.push(on); + any = any || on; + } + return any ? breaks : null; +} + +function breaksOrNull(flags: ArrayLike | null | undefined): boolean[] | null { + if (!flags) { + return null; + } + for (let i = 0; i < flags.length; i++) { + if (flags[i]) { + const out: boolean[] = []; + for (let j = 0; j < flags.length; j++) { + out.push(!!flags[j]); + } + return out; + } + } + return null; +} + function getStackedOnPoints( coordSys: Cartesian2D | Polar, data: SeriesData, @@ -684,6 +722,9 @@ class LineView extends ChartView { // FIXME step not support polar const step = !isCoordSysPolar ? seriesModel.get('step') : false; + // Taken from the layout points, before `step` rewrites that array. + const layoutBreaks = readSmoothBreaks(data, points.length / 2, step); + let seamFlagsFromAnimation = false; let clipShapeForSymbol: PolarArea | Cartesian2DArea; if (coordSys && coordSys.getArea && seriesModel.get('clip', true)) { clipShapeForSymbol = coordSys.getArea(); @@ -802,6 +843,7 @@ class LineView extends ChartView { this._doUpdateAnimation( data, stackedOnPoints, coordSys, api, step, valueOrigin, connectNulls ); + seamFlagsFromAnimation = true; } else { // Not do it in update with animation @@ -814,11 +856,13 @@ class LineView extends ChartView { } polyline.setShape({ - points: points + points: points, + smoothBreaks: layoutBreaks }); polygon && polygon.setShape({ points: points, - stackedOnPoints: stackedOnPoints + stackedOnPoints: stackedOnPoints, + smoothBreaks: layoutBreaks }); } } @@ -853,10 +897,15 @@ class LineView extends ChartView { const smooth = getSmooth(seriesModel.get('smooth')); const smoothMonotone = seriesModel.get('smoothMonotone'); - polyline.setShape({ + polyline.setShape(seamFlagsFromAnimation ? { smooth, smoothMonotone, connectNulls + } : { + smooth, + smoothMonotone, + connectNulls, + smoothBreaks: layoutBreaks }); if (polygon) { @@ -877,11 +926,17 @@ class LineView extends ChartView { stackedOnSmooth = getSmooth(stackedOnSeries.get('smooth')); } - polygon.setShape({ + polygon.setShape(seamFlagsFromAnimation ? { smooth, stackedOnSmooth, smoothMonotone, connectNulls + } : { + smooth, + stackedOnSmooth, + smoothMonotone, + connectNulls, + smoothBreaks: layoutBreaks }); setStatesStylesFromModel(polygon, seriesModel, 'areaStyle'); @@ -1348,6 +1403,9 @@ class LineView extends ChartView { let stackedOnCurrent = diff.stackedOnCurrent; let next = diff.next; let stackedOnNext = diff.stackedOnNext; + // Step expands the point list, so these flags would name the wrong vertex. + // Otherwise use the target datum for the whole morph: booleans do not interpolate. + const smoothBreaks = step ? null : breaksOrNull(diff.smoothBreaks); if (step) { // TODO If stacked series is not step stackedOnCurrent = turnPointsIntoStep(diff.stackedOnCurrent, diff.current, coordSys, step, connectNulls); @@ -1363,13 +1421,15 @@ class LineView extends ChartView { ) { polyline.stopAnimation(); polyline.setShape({ - points: next + points: next, + smoothBreaks: smoothBreaks }); if (polygon) { polygon.stopAnimation(); polygon.setShape({ points: next, - stackedOnPoints: stackedOnNext + stackedOnPoints: stackedOnNext, + smoothBreaks: smoothBreaks }); } return; @@ -1377,6 +1437,11 @@ class LineView extends ChartView { (polyline.shape as any).__points = diff.current; polyline.shape.points = current; + // Not part of the animation target. The flag stays on this vertex + // while `points` is interpolated, and it is already the target value. + polyline.setShape({ + smoothBreaks: smoothBreaks + }); const target = { shape: { @@ -1397,7 +1462,8 @@ class LineView extends ChartView { polygon.setShape({ // Reuse the points with polyline. points: current, - stackedOnPoints: stackedOnCurrent + stackedOnPoints: stackedOnCurrent, + smoothBreaks: smoothBreaks }); polygon.stopAnimation(); graphic.updateProps(polygon, { diff --git a/src/chart/line/helper.ts b/src/chart/line/helper.ts index ba04dba5cd..eba29596d0 100644 --- a/src/chart/line/helper.ts +++ b/src/chart/line/helper.ts @@ -18,12 +18,12 @@ */ import {isDimensionStacked} from '../../data/helper/dataStackHelper'; -import {isNumber, map} from 'zrender/src/core/util'; +import {isArray, isNumber, map} from 'zrender/src/core/util'; import type Polar from '../../coord/polar/Polar'; import type Cartesian2D from '../../coord/cartesian/Cartesian2D'; import SeriesData from '../../data/SeriesData'; import Axis from '../../coord/Axis'; -import type { LineSeriesOption } from './LineSeries'; +import type { LineDataItemOption, LineSeriesOption } from './LineSeries'; interface CoordInfo { dataDimsForPoint: string[] @@ -133,6 +133,18 @@ export function getStackedOnPoint( return coordSys.dataToPoint(stackedData); } +/** + * `smoothBreak` lives on the datum object. Numbers and `[x, y]` tuples cannot carry it. + * `getItemModel` is used for object items so the flag is read the same way as other item options. + */ +export function isSmoothBreakDatum(data: SeriesData, dataIndex: number): boolean { + const raw = data.getRawDataItem(dataIndex); + if (raw == null || typeof raw !== 'object' || isArray(raw)) { + return false; + } + return !!(data.getItemModel>(dataIndex).get('smoothBreak')); +} + export function isPointIllegal(xOrY: number, yOrX: number) { // NOTE: // - `NaN` point x/y may be generated by, e.g., diff --git a/src/chart/line/lineAnimationDiff.ts b/src/chart/line/lineAnimationDiff.ts index 60a93a1b1c..4655eb6e8c 100644 --- a/src/chart/line/lineAnimationDiff.ts +++ b/src/chart/line/lineAnimationDiff.ts @@ -17,7 +17,7 @@ * under the License. */ -import {prepareDataCoordInfo, getStackedOnPoint} from './helper'; +import {prepareDataCoordInfo, getStackedOnPoint, isSmoothBreakDatum} from './helper'; import SeriesData from '../../data/SeriesData'; import type Cartesian2D from '../../coord/cartesian/Cartesian2D'; import type Polar from '../../coord/polar/Polar'; @@ -74,6 +74,9 @@ export default function lineAnimationDiff( const status = []; const sortedIndices: number[] = []; const rawIndices: number[] = []; + // Target seam flags, one per morphed vertex, in the same order as + // currPoints / nextPoints before sorting. + const nextBreaks: boolean[] = []; const newDataOldCoordInfo = prepareDataCoordInfo(oldCoordSys, newData, oldValueOrigin); // const oldDataNewCoordInfo = prepareDataCoordInfo(newCoordSys, oldData, newValueOrigin); @@ -111,6 +114,7 @@ export default function lineAnimationDiff( nextStackedPoints.push(newStackedOnPoints[newIdx2], newStackedOnPoints[newIdx2 + 1]); rawIndices.push(newData.getRawIndex(diffItem.idx1)); + nextBreaks.push(isSmoothBreakDatum(newData, diffItem.idx1)); break; case '+': const newIdx = diffItem.idx; @@ -130,6 +134,7 @@ export default function lineAnimationDiff( nextStackedPoints.push(newStackedOnPoints[newIdx2], newStackedOnPoints[newIdx2 + 1]); rawIndices.push(newData.getRawIndex(newIdx)); + nextBreaks.push(isSmoothBreakDatum(newData, newIdx)); break; case '-': pointAdded = false; @@ -156,6 +161,7 @@ export default function lineAnimationDiff( const sortedNextStackedPoints = createFloat32Array(len); const sortedStatus = []; + const sortedBreaks: boolean[] = []; for (let i = 0; i < sortedIndices.length; i++) { const idx = sortedIndices[i]; const i2 = i * 2; @@ -171,6 +177,7 @@ export default function lineAnimationDiff( sortedNextStackedPoints[i2 + 1] = nextStackedPoints[idx2 + 1]; sortedStatus[i] = status[idx]; + sortedBreaks[i] = nextBreaks[idx]; } return { @@ -180,6 +187,8 @@ export default function lineAnimationDiff( stackedOnCurrent: sortedCurrStackedPoints, stackedOnNext: sortedNextStackedPoints, - status: sortedStatus + status: sortedStatus, + // Target flags for the whole animation. A boolean does not interpolate. + smoothBreaks: sortedBreaks }; } diff --git a/src/chart/line/poly.ts b/src/chart/line/poly.ts index 10ca2a7472..f8e4a97a1f 100644 --- a/src/chart/line/poly.ts +++ b/src/chart/line/poly.ts @@ -215,6 +215,11 @@ class ECPolylineShape { smoothConstraint = true; smoothMonotone: 'x' | 'y' | 'none'; connectNulls: boolean; + /** + * Parallel to `points`: true restarts smoothing at that vertex. + * The baseline of an area does not use this series' flags. + */ + smoothBreaks: ArrayLike | null = null; } interface ECPolylineProps extends PathProps { From 1988ccb3e8d6aa8b5f01a58de00b81b1227135c8 Mon Sep 17 00:00:00 2001 From: Alaric Taylor-Roffey Date: Mon, 19 Oct 2026 10:25:04 +0000 Subject: [PATCH 2/3] feat(line): restart smoothing at a marked vertex without lifting the pen. --- src/chart/line/poly.ts | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/chart/line/poly.ts b/src/chart/line/poly.ts index f8e4a97a1f..4da861dd62 100644 --- a/src/chart/line/poly.ts +++ b/src/chart/line/poly.ts @@ -28,6 +28,13 @@ import { isPointIllegal } from './helper'; const mathMin = Math.min; const mathMax = Math.max; +function pointIsSmoothBreak( + smoothBreaks: ArrayLike | null | undefined, + idx: number +): boolean { + return !!(smoothBreaks && idx >= 0 && idx < smoothBreaks.length && smoothBreaks[idx]); +} + /** * 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 @@ -42,7 +49,8 @@ function drawSegment( dir: number, smooth: number, smoothMonotone: 'x' | 'y' | 'none', - connectNulls: boolean + connectNulls: boolean, + smoothBreaks?: ArrayLike | null ) { let prevX: number; let prevY: number; @@ -87,11 +95,13 @@ function drawSegment( let nextIdx = idx + dir; let nextX = points[nextIdx * 2]; let nextY = points[nextIdx * 2 + 1]; + let seam = pointIsSmoothBreak(smoothBreaks, idx); // Ignore duplicate point while (nextX === x && nextY === y && k < segLen) { k++; nextIdx += dir; idx += dir; + seam = seam || pointIsSmoothBreak(smoothBreaks, idx); nextX = points[nextIdx * 2]; nextY = points[nextIdx * 2 + 1]; x = points[idx * 2]; @@ -116,10 +126,16 @@ function drawSegment( let vy: number = 0; let nextCpx0; let nextCpy0; - // Is last point - if (tmpK >= segLen || isPointIllegal(nextX, nextY)) { + // Last point, or a seam: incoming control stops on this vertex. + // The outgoing control is this vertex too, so the next piece + // does not inherit a tangent from the previous one. + // A real last point leaves no following piece; setting the + // outgoing control here is unused in that case. + if (tmpK >= segLen || isPointIllegal(nextX, nextY) || seam) { cpx1 = x; cpy1 = y; + nextCpx0 = x; + nextCpy0 = y; } else { vx = nextX - prevX; @@ -273,7 +289,8 @@ export class ECPolyline extends Path { ctx, points, i, len, len, 1, shape.smooth, - shape.smoothMonotone, shape.connectNulls + shape.smoothMonotone, shape.connectNulls, + shape.smoothBreaks ) + 1; } } @@ -397,13 +414,16 @@ export class ECPolygon extends Path { ctx, points, i, len, len, 1, shape.smooth, - smoothMonotone, shape.connectNulls + smoothMonotone, shape.connectNulls, + shape.smoothBreaks ); + // The stacked baseline is another series. Do not copy this series' seams onto it. drawSegment( ctx, stackedOnPoints, i + k - 1, k, len, -1, shape.stackedOnSmooth, - smoothMonotone, shape.connectNulls + smoothMonotone, shape.connectNulls, + null ); i += k + 1; From d79307c47352e2844f46ac8e45d8ef123700f362 Mon Sep 17 00:00:00 2001 From: Alaric Taylor-Roffey Date: Mon, 19 Oct 2026 10:27:05 +0000 Subject: [PATCH 3/3] test(line): cover a connected smoothing seam. close #21796 --- test/line-smooth-break.html | 155 ++++++++++++ test/ut/jest.config.cjs | 1 + test/ut/spec/chart/line/smoothBreak.test.ts | 255 ++++++++++++++++++++ 3 files changed, 411 insertions(+) create mode 100644 test/line-smooth-break.html create mode 100644 test/ut/spec/chart/line/smoothBreak.test.ts diff --git a/test/line-smooth-break.html b/test/line-smooth-break.html new file mode 100644 index 0000000000..13fadad243 --- /dev/null +++ b/test/line-smooth-break.html @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + 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/smoothBreak.test.ts b/test/ut/spec/chart/line/smoothBreak.test.ts new file mode 100644 index 0000000000..0f0daf938c --- /dev/null +++ b/test/ut/spec/chart/line/smoothBreak.test.ts @@ -0,0 +1,255 @@ +/* +* 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 PathProxy from 'zrender/src/core/PathProxy'; +import { ECPolygon, ECPolyline } from '@/src/chart/line/poly'; +import { createChart } from '../../../core/utHelper'; + +const points = [0, 0, 30, 80, 60, 10, 90, 70, 120, 5]; +const seamAt = 2; + +interface CubicBezierSegment { + x0: number + y0: number + cpx1: number + cpy1: number + cpx2: number + cpy2: number + x1: number + y1: number +} + +describe('chart/line/smoothBreak', function () { + + it('restarts Bézier smoothing at the seam without lifting the pen', function () { + const seamed = readPath(polyline(points, [false, false, true, false, false])); + const left = readCubics(polyline(points.slice(0, (seamAt + 1) * 2), null)); + const right = readCubics(polyline(points.slice(seamAt * 2), null)); + const plain = readCubics(polyline(points, null)); + + expect(seamed.moves).toBe(1); + expect(seamed.cubics.length).toBe(left.length + right.length); + expectCubicsClose(seamed.cubics.slice(0, left.length), left); + expectCubicsClose(seamed.cubics.slice(left.length), right); + expect(cubicsDiffer(seamed.cubics, plain)).toBe(true); + expectConnected(seamed.cubics); + }); + + it('keeps a null as a gap when another sample is a seam', function () { + const broken = readPath(new ECPolyline({ + shape: { + points: [0, 0, 30, 80, NaN, NaN, 90, 70, 120, 5], + smooth: 0.5, + smoothBreaks: [false, true, false, false, false], + connectNulls: false + } + })); + expect(broken.moves).toBe(2); + }); + + it('seams this series only, and leaves the stacked baseline alone', function () { + const breaks = [false, false, true, false, false]; + const stacked = [0, 0, 30, 0, 60, 0, 90, 0, 120, 0]; + const seamedPoly = readCubics(polygon(points, stacked, breaks)); + const plainPoly = readCubics(polygon(points, stacked, null)); + const seamedTop = readCubics(polyline(points, breaks)); + const plainTop = readCubics(polyline(points, null)); + + expect(seamedPoly.length).toBe(plainPoly.length); + expect(seamedTop.length).toBe(plainTop.length); + expectCubicsClose(seamedPoly.slice(0, seamedTop.length), seamedTop); + expect(cubicsDiffer(seamedTop, plainTop)).toBe(true); + expectCubicsClose(seamedPoly.slice(seamedTop.length), plainPoly.slice(plainTop.length)); + }); + + it('reads smoothBreak from the datum and ignores it while step is set', function () { + const chart = createChart({ width: 400, height: 300 }); + try { + chart.setOption(lineOption(false, [ + 10, 40, { value: 90, smoothBreak: true }, 20, 70 + ])); + const view = lineView(chart); + expect(view._polyline.shape.smoothBreaks).toEqual([false, false, true, false, false]); + const read = readPath(view._polyline); + expect(read.moves).toBe(1); + + chart.setOption(lineOption(false, [ + 10, 40, { value: 90, smoothBreak: true }, 20, 70 + ], 'end')); + expect(lineView(chart)._polyline.shape.smoothBreaks).toBeNull(); + } + finally { + chart.dispose(); + } + }); + + it('keeps the target seam on the morphed vertex during an update', function () { + const chart = createChart({ width: 400, height: 300 }); + try { + chart.setOption(lineOption(true, [10, 40, 90, 20, 70])); + chart.setOption(lineOption(true, [ + 12, 42, { value: 88, smoothBreak: true }, 22, 68 + ])); + const breaks = lineView(chart)._polyline.shape.smoothBreaks; + expect(breaks).toEqual([false, false, true, false, false]); + expect(breaks.length).toBe(lineView(chart)._polyline.shape.points.length / 2); + } + finally { + chart.dispose(); + } + }); +}); + +function lineOption( + animation: boolean, + data: (number | { value: number, smoothBreak?: boolean })[], + step?: 'end' +) { + return { + animation: animation, + xAxis: { type: 'category', data: ['A', 'B', 'C', 'D', 'E'] }, + yAxis: { type: 'value' }, + series: [{ + type: 'line', + smooth: true, + step: step || false, + showSymbol: false, + data: data + }] + }; +} + +function lineView(chart: ReturnType) { + const api = chart as any; + const seriesModel = api.getModel().getSeriesByIndex(0); + return api.getViewOfSeriesModel(seriesModel); +} + +function polyline(pts: number[], breaks: boolean[] | null) { + return new ECPolyline({ + shape: { + points: pts, + smooth: 0.5, + smoothBreaks: breaks + } + }); +} + +function polygon(pts: number[], stacked: number[], breaks: boolean[] | null) { + return new ECPolygon({ + shape: { + points: pts, + stackedOnPoints: stacked, + smooth: 0.5, + stackedOnSmooth: 0.5, + smoothBreaks: breaks + } + }); +} + +function expectConnected(cubics: CubicBezierSegment[]) { + for (let i = 1; i < cubics.length; i++) { + expect(cubics[i].x0).toBeCloseTo(cubics[i - 1].x1, 6); + expect(cubics[i].y0).toBeCloseTo(cubics[i - 1].y1, 6); + } +} + +function expectCubicsClose(actual: CubicBezierSegment[], expected: CubicBezierSegment[]) { + expect(actual.length).toBe(expected.length); + for (let i = 0; i < actual.length; i++) { + expect(actual[i].x0).toBeCloseTo(expected[i].x0, 4); + expect(actual[i].y0).toBeCloseTo(expected[i].y0, 4); + expect(actual[i].cpx1).toBeCloseTo(expected[i].cpx1, 4); + expect(actual[i].cpy1).toBeCloseTo(expected[i].cpy1, 4); + expect(actual[i].cpx2).toBeCloseTo(expected[i].cpx2, 4); + expect(actual[i].cpy2).toBeCloseTo(expected[i].cpy2, 4); + expect(actual[i].x1).toBeCloseTo(expected[i].x1, 4); + expect(actual[i].y1).toBeCloseTo(expected[i].y1, 4); + } +} + +function cubicsDiffer(actual: CubicBezierSegment[], expected: CubicBezierSegment[]): boolean { + if (actual.length !== expected.length) { + return true; + } + for (let i = 0; i < actual.length; i++) { + const a = actual[i]; + const b = expected[i]; + if (Math.abs(a.cpx1 - b.cpx1) > 1e-4 + || Math.abs(a.cpy1 - b.cpy1) > 1e-4 + || Math.abs(a.cpx2 - b.cpx2) > 1e-4 + || Math.abs(a.cpy2 - b.cpy2) > 1e-4 + ) { + return true; + } + } + return false; +} + +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 }; +}