Skip to content

Commit

Permalink
Allow styling of line segments (#8844)
Browse files Browse the repository at this point in the history
Allow styling of line segments

* docs & sample
* Types
* update sample
  • Loading branch information
kurkle committed Apr 8, 2021
1 parent efc1902 commit 4eb5945
Show file tree
Hide file tree
Showing 15 changed files with 274 additions and 24 deletions.
1 change: 1 addition & 0 deletions docs/.vuepress/config.js
Expand Up @@ -111,6 +111,7 @@ module.exports = {
'line/interpolation',
'line/styling',
// 'line/point-styling',
'line/segments',
]
},
{
Expand Down
17 changes: 15 additions & 2 deletions docs/charts/line.md
Expand Up @@ -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`
Expand All @@ -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)'`
Expand All @@ -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

Expand Down Expand Up @@ -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`.
Expand Down
43 changes: 43 additions & 0 deletions docs/samples/line/segments.md
@@ -0,0 +1,43 @@
# Line Segment Styling

```js chart-editor

// <block:segmentUtils:1>
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;
// </block:segmentUtils>

// <block:genericOptions:2>
const genericOptions = {
fill: false,
interaction: {
intersect: false
},
radius: 0,
};
// </block:genericOptions>

// <block:config: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
};
// </block:config>

module.exports = {
actions: [],
config: config,
};
```
2 changes: 2 additions & 0 deletions src/controllers/controller.line.js
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
51 changes: 32 additions & 19 deletions src/elements/element.line.js
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {

Expand Down Expand Up @@ -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));
}

/**
Expand Down Expand Up @@ -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;
}
Expand All @@ -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();

Expand Down
1 change: 1 addition & 0 deletions src/elements/element.point.js
Expand Up @@ -14,6 +14,7 @@ export default class PointElement extends Element {
super();

this.options = undefined;
this.parsed = undefined;
this.skip = undefined;
this.stop = undefined;

Expand Down
71 changes: 68 additions & 3 deletions src/helpers/helpers.segment.js
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
22 changes: 22 additions & 0 deletions 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}
}
}
}
};
Binary file added test/fixtures/controller.line/segments/gap.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions 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}
}
}
}
};
Binary file added test/fixtures/controller.line/segments/range.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 4eb5945

Please sign in to comment.