'line/interpolation',
'line/styling',
// 'line/point-styling',
+ 'line/segments',
]
},
{
| [`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`
| [`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)'`
| [`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
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`.
--- /dev/null
+# 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,
+};
+```
if (!me.options.showLine) {
options.borderWidth = 0;
}
+ options.segment = me.options.segment;
me.updateElement(line, undefined, {
animated: !animationsDisabled,
options
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);
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) {
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 {
}
get segments() {
- return this._segments || (this._segments = _computeSegments(this));
+ return this._segments || (this._segments = _computeSegments(this, this.options.segment));
}
/**
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;
}
ctx.save();
- setStyle(ctx, options);
-
- strokePath(ctx, me, start, count);
+ draw(ctx, me, start, count);
ctx.restore();
super();
this.options = undefined;
+ this.parsed = undefined;
this.skip = undefined;
this.stop = undefined;
/**
* @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) {
* 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;
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);
}
--- /dev/null
+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}
+ }
+ }
+ }
+};
--- /dev/null
+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}
+ }
+ }
+ }
+};
--- /dev/null
+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}
+ }
+ }
+ }
+};
raw: unknown;
}
+export interface ScriptableLineSegmentContext {
+ type: 'segment',
+ p0: PointElement,
+ p1: PointElement
+}
+
export type Scriptable<T, TContext> = T | ((ctx: TContext) => T);
export type ScriptableOptions<T, TContext> = { [P in keyof T]: Scriptable<T[P], TContext> };
export type ScriptableAndArray<T, TContext> = readonly T[] | Scriptable<T, TContext>;
* @default false
*/
stepped: 'before' | 'after' | 'middle' | boolean;
+
+ segment: {
+ borderColor: Scriptable<Color|undefined, ScriptableLineSegmentContext>,
+ borderCapStyle: Scriptable<CanvasLineCap|undefined, ScriptableLineSegmentContext>;
+ borderDash: Scriptable<number[]|undefined, ScriptableLineSegmentContext>;
+ borderDashOffset: Scriptable<number|undefined, ScriptableLineSegmentContext>;
+ borderJoinStyle: Scriptable<CanvasLineJoin|undefined, ScriptableLineSegmentContext>;
+ borderWidth: Scriptable<number|undefined, ScriptableLineSegmentContext>;
+ };
}
export interface LineHoverOptions extends CommonHoverOptions {
extends Element<T, O>,
VisualElement {
readonly skip: boolean;
+ readonly parsed: CartesianParsedData;
}
export const PointElement: ChartComponent & {
--- /dev/null
+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,
+ }
+ }]
+ },
+});