diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 436e3ab19a2..5b4982b1adf 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -111,6 +111,7 @@ module.exports = { 'line/interpolation', 'line/styling', // 'line/point-styling', + 'line/segments', ] }, { diff --git a/docs/charts/line.md b/docs/charts/line.md index c04290cab44..ef342dfbb75 100644 --- a/docs/charts/line.md +++ b/docs/charts/line.md @@ -52,8 +52,8 @@ The line chart allows a number of properties to be specified for each dataset. T | [`borderJoinStyle`](#line-styling) | `string` | Yes | - | `'miter'` | [`borderWidth`](#line-styling) | `number` | Yes | - | `3` | [`clip`](#general) | `number`\|`object` | - | - | `undefined` -| [`data`](#data-structure) | `object`\|`object[]`\| `number[]`\|`string[]` | - | - | **required** | [`cubicInterpolationMode`](#cubicinterpolationmode) | `string` | Yes | - | `'default'` +| [`data`](#data-structure) | `object`\|`object[]`\| `number[]`\|`string[]` | - | - | **required** | [`fill`](#line-styling) | `boolean`\|`string` | Yes | - | `false` | [`hoverBackgroundColor`](#line-styling) | [`Color`](../general/colors.md) | Yes | - | `undefined` | [`hoverBorderCapStyle`](#line-styling) | `string` | Yes | - | `undefined` @@ -64,7 +64,6 @@ The line chart allows a number of properties to be specified for each dataset. T | [`hoverBorderWidth`](#line-styling) | `number` | Yes | - | `undefined` | [`indexAxis`](#general) | `string` | - | - | `'x'` | [`label`](#general) | `string` | - | - | `''` -| [`tension`](#line-styling) | `number` | - | - | `0` | [`order`](#general) | `number` | - | - | `0` | [`pointBackgroundColor`](#point-styling) | `Color` | Yes | Yes | `'rgba(0, 0, 0, 0.1)'` | [`pointBorderColor`](#point-styling) | `Color` | Yes | Yes | `'rgba(0, 0, 0, 0.1)'` @@ -77,10 +76,12 @@ The line chart allows a number of properties to be specified for each dataset. T | [`pointRadius`](#point-styling) | `number` | Yes | Yes | `3` | [`pointRotation`](#point-styling) | `number` | Yes | Yes | `0` | [`pointStyle`](#point-styling) | `string`\|`Image` | Yes | Yes | `'circle'` +| [`segment`](#segment) | `object` | - | - | `undefined` | [`showLine`](#line-styling) | `boolean` | - | - | `true` | [`spanGaps`](#line-styling) | `boolean`\|`number` | - | - | `undefined` | [`stack`](#general) | `string` | - | - | `'line'` | | [`stepped`](#stepped) | `boolean`\|`string` | - | - | `false` +| [`tension`](#line-styling) | `number` | - | - | `0` | [`xAxisID`](#general) | `string` | - | - | first x axis | [`yAxisID`](#general) | `string` | - | - | first y axis @@ -158,6 +159,18 @@ The `'monotone'` algorithm is more suited to `y = f(x)` datasets: it preserves m If left untouched (`undefined`), the global `options.elements.line.cubicInterpolationMode` property is used. +### Segment + +Line segment styles can be overridden by scriptable options in the `segment` object. Currently all of the `border*` options are supported. The segment styles are resolved for each section of the line between each point. `undefined` fallbacks to main line styles. + +Context for the scriptable segment contains the following properties: + +* `type`: `'segment'` +* `p0`: first point element +* `p1`: second point element + +[Example usage](../samples/line/segments.md) + ### Stepped The following values are supported for `stepped`. diff --git a/docs/samples/line/segments.md b/docs/samples/line/segments.md new file mode 100644 index 00000000000..b1b6a873554 --- /dev/null +++ b/docs/samples/line/segments.md @@ -0,0 +1,43 @@ +# Line Segment Styling + +```js chart-editor + +// +const skipped = (ctx, value) => ctx.p0.skip || ctx.p1.skip ? value : undefined; +const down = (ctx, value) => ctx.p0.parsed.y > ctx.p1.parsed.y ? value : undefined; +// + +// +const genericOptions = { + fill: false, + interaction: { + intersect: false + }, + radius: 0, +}; +// + +// +const config = { + type: 'line', + data: { + labels: Utils.months({count: 7}), + datasets: [{ + label: 'My First Dataset', + data: [65, 59, NaN, 48, 56, 57, 40], + borderColor: 'rgb(75, 192, 192)', + segment: { + borderColor: ctx => skipped(ctx, 'rgb(0,0,0,0.2)') || down(ctx, 'rgb(192,75,75)'), + borderDash: ctx => skipped(ctx, [6, 6]), + } + }] + }, + options: genericOptions +}; +// + +module.exports = { + actions: [], + config: config, +}; +``` diff --git a/src/controllers/controller.line.js b/src/controllers/controller.line.js index 53233baf707..10e2d27c5a6 100644 --- a/src/controllers/controller.line.js +++ b/src/controllers/controller.line.js @@ -33,6 +33,7 @@ export default class LineController extends DatasetController { if (!me.options.showLine) { options.borderWidth = 0; } + options.segment = me.options.segment; me.updateElement(line, undefined, { animated: !animationsDisabled, options @@ -62,6 +63,7 @@ export default class LineController extends DatasetController { const y = properties.y = reset ? yScale.getBasePixel() : yScale.getPixelForValue(_stacked ? me.applyStack(yScale, parsed, _stacked) : parsed.y, i); properties.skip = isNaN(x) || isNaN(y); properties.stop = i > 0 && (parsed.x - prevParsed.x) > maxGapLength; + properties.parsed = parsed; if (includeOptions) { properties.options = sharedOptions || me.resolveDataElementOptions(i, mode); diff --git a/src/elements/element.line.js b/src/elements/element.line.js index e81811d5913..e6265a12822 100644 --- a/src/elements/element.line.js +++ b/src/elements/element.line.js @@ -3,18 +3,19 @@ import {_bezierInterpolation, _pointInLine, _steppedInterpolation} from '../help import {_computeSegments, _boundSegments} from '../helpers/helpers.segment'; import {_steppedLineTo, _bezierCurveTo} from '../helpers/helpers.canvas'; import {_updateBezierControlPoints} from '../helpers/helpers.curve'; +import {valueOrDefault} from '../helpers'; /** * @typedef { import("./element.point").default } PointElement */ -function setStyle(ctx, vm) { - ctx.lineCap = vm.borderCapStyle; - ctx.setLineDash(vm.borderDash); - ctx.lineDashOffset = vm.borderDashOffset; - ctx.lineJoin = vm.borderJoinStyle; - ctx.lineWidth = vm.borderWidth; - ctx.strokeStyle = vm.borderColor; +function setStyle(ctx, options, style = options) { + ctx.lineCap = valueOrDefault(style.borderCapStyle, options.borderCapStyle); + ctx.setLineDash(valueOrDefault(style.borderDash, options.borderDash)); + ctx.lineDashOffset = valueOrDefault(style.borderDashOffset, options.borderDashOffset); + ctx.lineJoin = valueOrDefault(style.borderJoinStyle, options.borderJoinStyle); + ctx.lineWidth = valueOrDefault(style.borderWidth, options.borderWidth); + ctx.strokeStyle = valueOrDefault(style.borderColor, options.borderColor); } function lineTo(ctx, previous, target) { @@ -206,18 +207,33 @@ function strokePathWithCache(ctx, line, start, count) { path.closePath(); } } + setStyle(ctx, line.options); ctx.stroke(path); } + function strokePathDirect(ctx, line, start, count) { - ctx.beginPath(); - if (line.path(ctx, start, count)) { - ctx.closePath(); + const {segments, options} = line; + const segmentMethod = _getSegmentMethod(line); + + for (const segment of segments) { + setStyle(ctx, options, segment.style); + ctx.beginPath(); + if (segmentMethod(ctx, line, segment, {start, end: start + count - 1})) { + ctx.closePath(); + } + ctx.stroke(); } - ctx.stroke(); } const usePath2D = typeof Path2D === 'function'; -const strokePath = usePath2D ? strokePathWithCache : strokePathDirect; + +function draw(ctx, line, start, count) { + if (usePath2D && line.segments.length === 1) { + strokePathWithCache(ctx, line, start, count); + } else { + strokePathDirect(ctx, line, start, count); + } +} export default class LineElement extends Element { @@ -262,7 +278,7 @@ export default class LineElement extends Element { } get segments() { - return this._segments || (this._segments = _computeSegments(this)); + return this._segments || (this._segments = _computeSegments(this, this.options.segment)); } /** @@ -352,15 +368,14 @@ export default class LineElement extends Element { path(ctx, start, count) { const me = this; const segments = me.segments; - const ilen = segments.length; const segmentMethod = _getSegmentMethod(me); let loop = me._loop; start = start || 0; count = count || (me.points.length - start); - for (let i = 0; i < ilen; ++i) { - loop &= segmentMethod(ctx, me, segments[i], {start, end: start + count - 1}); + for (const segment of segments) { + loop &= segmentMethod(ctx, me, segment, {start, end: start + count - 1}); } return !!loop; } @@ -383,9 +398,7 @@ export default class LineElement extends Element { ctx.save(); - setStyle(ctx, options); - - strokePath(ctx, me, start, count); + draw(ctx, me, start, count); ctx.restore(); diff --git a/src/elements/element.point.js b/src/elements/element.point.js index 2d34d1b740e..8c4e094cc6e 100644 --- a/src/elements/element.point.js +++ b/src/elements/element.point.js @@ -14,6 +14,7 @@ export default class PointElement extends Element { super(); this.options = undefined; + this.parsed = undefined; this.skip = undefined; this.stop = undefined; diff --git a/src/helpers/helpers.segment.js b/src/helpers/helpers.segment.js index b6858231248..6c117114fd1 100644 --- a/src/helpers/helpers.segment.js +++ b/src/helpers/helpers.segment.js @@ -3,6 +3,7 @@ import {_angleBetween, _angleDiff, _normalizeAngle} from './helpers.math'; /** * @typedef { import("../elements/element.line").default } LineElement * @typedef { import("../elements/element.point").default } PointElement + * @typedef {{start: number, end: number, loop: boolean, style?: any}} Segment */ function propertyFn(property) { @@ -221,9 +222,11 @@ function solidSegments(points, start, max, loop) { * Compute the continuous segments that define the whole line * There can be skipped points within a segment, if spanGaps is true. * @param {LineElement} line + * @param {object} [segmentOptions] + * @return {Segment[]} * @private */ -export function _computeSegments(line) { +export function _computeSegments(line, segmentOptions) { const points = line.points; const spanGaps = line.options.spanGaps; const count = points.length; @@ -236,10 +239,72 @@ export function _computeSegments(line) { const {start, end} = findStartAndEnd(points, count, loop, spanGaps); if (spanGaps === true) { - return [{start, end, loop}]; + return splitByStyles([{start, end, loop}], points, segmentOptions); } const max = end < start ? end + count : end; const completeLoop = !!line._fullLoop && start === 0 && end === count - 1; - return solidSegments(points, start, max, completeLoop); + return splitByStyles(solidSegments(points, start, max, completeLoop), points, segmentOptions); +} + +/** + * @param {Segment[]} segments + * @param {PointElement[]} points + * @param {object} [segmentOptions] + * @return {Segment[]} + */ +function splitByStyles(segments, points, segmentOptions) { + if (!segmentOptions || !segmentOptions.setContext || !points) { + return segments; + } + return doSplitByStyles(segments, points, segmentOptions); +} + +/** + * @param {Segment[]} segments + * @param {PointElement[]} points + * @param {object} [segmentOptions] + * @return {Segment[]} + */ +function doSplitByStyles(segments, points, segmentOptions) { + const count = points.length; + const result = []; + let start = segments[0].start; + let i = start; + for (const segment of segments) { + let prevStyle, style; + let prev = points[start % count]; + for (i = start + 1; i <= segment.end; i++) { + const pt = points[i % count]; + style = readStyle(segmentOptions.setContext({type: 'segment', p0: prev, p1: pt})); + if (styleChanged(style, prevStyle)) { + result.push({start: start, end: i - 1, loop: segment.loop, style: prevStyle}); + prevStyle = style; + start = i - 1; + } + prev = pt; + prevStyle = style; + } + if (start < i - 1) { + result.push({start, end: i - 1, loop: segment.loop, style}); + start = i - 1; + } + } + + return result; +} + +function readStyle(options) { + return { + borderCapStyle: options.borderCapStyle, + borderDash: options.borderDash, + borderDashOffset: options.borderDashOffset, + borderJoinStyle: options.borderJoinStyle, + borderWidth: options.borderWidth, + borderColor: options.borderColor, + }; +} + +function styleChanged(style, prevStyle) { + return prevStyle && JSON.stringify(style) !== JSON.stringify(prevStyle); } diff --git a/test/fixtures/controller.line/segments/gap.js b/test/fixtures/controller.line/segments/gap.js new file mode 100644 index 00000000000..fd08a4d55e3 --- /dev/null +++ b/test/fixtures/controller.line/segments/gap.js @@ -0,0 +1,22 @@ +module.exports = { + config: { + type: 'line', + data: { + labels: ['a', 'b', 'c', 'd', 'e', 'f'], + datasets: [{ + data: [1, 3, NaN, NaN, 2, 1], + borderColor: 'black', + segment: { + borderColor: ctx => ctx.p0.skip || ctx.p1.skip ? 'red' : undefined, + borderDash: ctx => ctx.p0.skip || ctx.p1.skip ? [5, 5] : undefined + } + }] + }, + options: { + scales: { + x: {display: false}, + y: {display: false} + } + } + } +}; diff --git a/test/fixtures/controller.line/segments/gap.png b/test/fixtures/controller.line/segments/gap.png new file mode 100644 index 00000000000..5a7e5aff351 Binary files /dev/null and b/test/fixtures/controller.line/segments/gap.png differ diff --git a/test/fixtures/controller.line/segments/range.js b/test/fixtures/controller.line/segments/range.js new file mode 100644 index 00000000000..b0adfb2e14c --- /dev/null +++ b/test/fixtures/controller.line/segments/range.js @@ -0,0 +1,33 @@ +function x(ctx, {min = -Infinity, max = Infinity}) { + return (ctx.p0.parsed.x >= min || ctx.p1.parsed.x >= min) && (ctx.p0.parsed.x < max && ctx.p1.parsed.x < max); +} + +function y(ctx, {min = -Infinity, max = Infinity}) { + return (ctx.p0.parsed.y >= min || ctx.p1.parsed.y >= min) && (ctx.p0.parsed.y < max || ctx.p1.parsed.y < max); +} + +function xy(ctx, xr, yr) { + return x(ctx, xr) && y(ctx, yr); +} + +module.exports = { + config: { + type: 'line', + data: { + datasets: [{ + data: [{x: 0, y: 0}, {x: 1, y: 1}, {x: 2, y: 2}, {x: 3, y: 3}, {x: 4, y: 4}, {x: 5, y: 5}, {x: 6, y: 7}, {x: 7, y: 8}], + borderColor: 'black', + segment: { + borderColor: ctx => x(ctx, {min: 3, max: 4}) ? 'red' : y(ctx, {min: 5}) ? 'green' : xy(ctx, {min: 0}, {max: 1}) ? 'blue' : undefined, + borderDash: ctx => x(ctx, {min: 3, max: 4}) || y(ctx, {min: 5}) ? [5, 5] : undefined, + } + }] + }, + options: { + scales: { + x: {type: 'linear', display: false}, + y: {display: false} + } + } + } +}; diff --git a/test/fixtures/controller.line/segments/range.png b/test/fixtures/controller.line/segments/range.png new file mode 100644 index 00000000000..eebb8a41daa Binary files /dev/null and b/test/fixtures/controller.line/segments/range.png differ diff --git a/test/fixtures/controller.line/segments/slope.js b/test/fixtures/controller.line/segments/slope.js new file mode 100644 index 00000000000..7fcc948c131 --- /dev/null +++ b/test/fixtures/controller.line/segments/slope.js @@ -0,0 +1,26 @@ +function slope({p0, p1}) { + return (p0.y - p1.y) / (p1.x - p0.x); +} + +module.exports = { + config: { + type: 'line', + data: { + labels: ['a', 'b', 'c', 'd', 'e', 'f'], + datasets: [{ + data: [1, 2, 3, 3, 2, 1], + borderColor: 'black', + segment: { + borderColor: ctx => slope(ctx) > 0 ? 'green' : slope(ctx) < 0 ? 'red' : undefined, + borderDash: ctx => slope(ctx) < 0 ? [5, 5] : undefined + } + }] + }, + options: { + scales: { + x: {display: false}, + y: {display: false} + } + } + } +}; diff --git a/test/fixtures/controller.line/segments/slope.png b/test/fixtures/controller.line/segments/slope.png new file mode 100644 index 00000000000..969373030b6 Binary files /dev/null and b/test/fixtures/controller.line/segments/slope.png differ diff --git a/types/index.esm.d.ts b/types/index.esm.d.ts index 709b2034bb4..8c326bfadf7 100644 --- a/types/index.esm.d.ts +++ b/types/index.esm.d.ts @@ -25,6 +25,12 @@ export interface ScriptableContext { raw: unknown; } +export interface ScriptableLineSegmentContext { + type: 'segment', + p0: PointElement, + p1: PointElement +} + export type Scriptable = T | ((ctx: TContext) => T); export type ScriptableOptions = { [P in keyof T]: Scriptable }; export type ScriptableAndArray = readonly T[] | Scriptable; @@ -1683,6 +1689,15 @@ export interface LineOptions extends CommonElementOptions { * @default false */ stepped: 'before' | 'after' | 'middle' | boolean; + + segment: { + borderColor: Scriptable, + borderCapStyle: Scriptable; + borderDash: Scriptable; + borderDashOffset: Scriptable; + borderJoinStyle: Scriptable; + borderWidth: Scriptable; + }; } export interface LineHoverOptions extends CommonHoverOptions { @@ -1814,6 +1829,7 @@ export interface PointElement, VisualElement { readonly skip: boolean; + readonly parsed: CartesianParsedData; } export const PointElement: ChartComponent & { diff --git a/types/tests/controllers/line_segments.ts b/types/tests/controllers/line_segments.ts new file mode 100644 index 00000000000..5c439b74cb4 --- /dev/null +++ b/types/tests/controllers/line_segments.ts @@ -0,0 +1,15 @@ +import { Chart } from '../../index.esm'; + +const chart = new Chart('id', { + type: 'line', + data: { + labels: [], + datasets: [{ + data: [], + segment: { + borderColor: ctx => ctx.p0.skip ? 'gray' : undefined, + borderWidth: ctx => ctx.p1.parsed.y > 10 ? 5 : undefined, + } + }] + }, +});