]> git.ipfire.org Git - thirdparty/Chart.js.git/commitdiff
Allow styling of line segments (#8844)
authorJukka Kurkela <jukka.kurkela@gmail.com>
Thu, 8 Apr 2021 22:02:12 +0000 (01:02 +0300)
committerGitHub <noreply@github.com>
Thu, 8 Apr 2021 22:02:12 +0000 (18:02 -0400)
Allow styling of line segments

* docs & sample
* Types
* update sample

15 files changed:
docs/.vuepress/config.js
docs/charts/line.md
docs/samples/line/segments.md [new file with mode: 0644]
src/controllers/controller.line.js
src/elements/element.line.js
src/elements/element.point.js
src/helpers/helpers.segment.js
test/fixtures/controller.line/segments/gap.js [new file with mode: 0644]
test/fixtures/controller.line/segments/gap.png [new file with mode: 0644]
test/fixtures/controller.line/segments/range.js [new file with mode: 0644]
test/fixtures/controller.line/segments/range.png [new file with mode: 0644]
test/fixtures/controller.line/segments/slope.js [new file with mode: 0644]
test/fixtures/controller.line/segments/slope.png [new file with mode: 0644]
types/index.esm.d.ts
types/tests/controllers/line_segments.ts [new file with mode: 0644]

index 436e3ab19a20684878ffd92dea73f24fa034023f..5b4982b1adf2c5fe3d36f3e52ed7bc9b8860b204 100644 (file)
@@ -111,6 +111,7 @@ module.exports = {
             'line/interpolation',
             'line/styling',
             // 'line/point-styling',
+            'line/segments',
           ]
         },
         {
index c04290cab441ee389ba958bd26af860eb22a437e..ef342dfbb75dcb12bea450b5114c0fa0d65501ba 100644 (file)
@@ -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 (file)
index 0000000..b1b6a87
--- /dev/null
@@ -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,
+};
+```
index 142c1150044559459886eda8f1e47540426de883..f0f0e39d588d53a14d2d81a32498cabdff2ab4c4 100644 (file)
@@ -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);
index e81811d5913ce66081367baccf6a64fe08335742..e6265a128225ee445829307389a3af4d9304c1ed 100644 (file)
@@ -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();
 
index 2d34d1b740ee982a8939c007e31333d53ba5f4c9..8c4e094cc6e6f8808c880b7cf39a4087539a0ebb 100644 (file)
@@ -14,6 +14,7 @@ export default class PointElement extends Element {
     super();
 
     this.options = undefined;
+    this.parsed = undefined;
     this.skip = undefined;
     this.stop = undefined;
 
index b68582312489bb1c2d128347141042bb6663f9c6..6c117114fd187e0fae6f6b3ec75f2bd4b78d357f 100644 (file)
@@ -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 (file)
index 0000000..fd08a4d
--- /dev/null
@@ -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 (file)
index 0000000..5a7e5af
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 (file)
index 0000000..b0adfb2
--- /dev/null
@@ -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 (file)
index 0000000..eebb8a4
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 (file)
index 0000000..7fcc948
--- /dev/null
@@ -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 (file)
index 0000000..9693730
Binary files /dev/null and b/test/fixtures/controller.line/segments/slope.png differ
index 4c3dde6923b9db4006d121883ce189d853ae1a7b..39c3dcb9438cdaff44f65b7240028826f207b20c 100644 (file)
@@ -25,6 +25,12 @@ export interface ScriptableContext<TType extends ChartType> {
   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>;
@@ -1683,6 +1689,15 @@ export interface LineOptions extends CommonElementOptions {
    * @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 {
@@ -1814,6 +1829,7 @@ export interface PointElement<T extends PointProps = PointProps, O extends Point
   extends Element<T, O>,
     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 (file)
index 0000000..5c439b7
--- /dev/null
@@ -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,
+      }
+    }]
+  },
+});