All files / owid-grapher/grapher/scatterCharts MultiColorPolyline.tsx

90.08% Statements 109/121
84.62% Branches 11/13
100% Functions 6/6
90.08% Lines 109/121

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 1261x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 3x 3x 3x 3x 3x 3x 1x 10835x 10835x 10835x 10835x 10835x 10835x 1x 158x 158x 158x 158x 158x 10835x 10835x 158x 158x 158x 158x 158x 10677x 10677x                     10835x 158x 158x 1x 158x 158x 158x 158x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 158x 158x 1x 1x 158x 158x 158x 158x 158x 158x 158x 158x 158x 158x 158x 158x 158x 158x 158x 158x     158x 158x 158x          
import * as React from "react"
import { computed } from "mobx"
import { observer } from "mobx-react"
 
import { last } from "../../clientUtils/Util"
 
interface MultiColorPolylinePoint {
    x: number
    y: number
    color: string
}
 
interface Point {
    x: number
    y: number
}
 
interface Segment {
    points: Point[]
    color: string
}
 
function getMidpoint(a: Point, b: Point): Point {
    return {
        x: (a.x + b.x) / 2,
        y: (a.y + b.y) / 2,
    }
}
 
function toPoint(point: MultiColorPolylinePoint): Point {
    return {
        x: point.x,
        y: point.y,
    }
}
 
export function getSegmentsFromPoints(
    points: MultiColorPolylinePoint[]
): Segment[] {
    const segments: Segment[] = []
    points.forEach((currentPoint) => {
        const currentSegment = last(segments)
        if (currentSegment === undefined) {
            segments.push({
                points: [toPoint(currentPoint)],
                color: currentPoint.color,
            })
        } else if (currentSegment.color === currentPoint.color) {
            currentSegment.points.push(toPoint(currentPoint))
        } else {
            const midPoint = getMidpoint(
                last(currentSegment.points)!,
                currentPoint
            )
            currentSegment.points.push(midPoint)
            segments.push({
                points: [midPoint, toPoint(currentPoint)],
                color: currentPoint.color,
            })
        }
    })
    return segments
}
 
function toSvgPoints(points: Point[]): string {
    // TODO round to 1 decimal place to decrease SVG size?
    return points.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(" ")
}
 
type MultiColorPolylineProps = Omit<
    React.SVGProps<SVGPolylineElement>,
    "fill" | "stroke" | "points" | "strokeLinecap"
> & {
    points: MultiColorPolylinePoint[]
}
 
// The current approach constructs multiple polylines and joins them together at midpoints.
// Joining at midpoints allows clean miter joints, since we're joining two lines at an identical
// angle.
//
// The benefit of this approach is that it generalises to work in most cases. Where it breaks:
// - When a color transition happening at a midpoint is misleading.
// - stroke-dasharray isn't handled well because the pattern restarts on every new line. This could
//   be improved by specifying a `stroke-dashoffset` automatically.
//   We can approximate the line length pretty well without rendering:
//   https://observablehq.com/@danielgavrilov/does-gettotallength-of-polyline-path-equal-the-sum-of-coord
//
// Alternative approaches considered:
// - Single line, color by gradient: this works if the line is monotonically increasing in one axis
//   (X or Y), but otherwise doesn't (a spiral for example doesn't work).
// - Compute meter joints ourselves (https://bl.ocks.org/mbostock/4163057): this results in the most
//   accurate output, but is most complex & slow.
//
@observer
export class MultiColorPolyline extends React.Component<MultiColorPolylineProps> {
    @computed get segments(): Segment[] {
        return getSegmentsFromPoints(this.props.points)
    }
 
    render(): JSX.Element {
        const { markerStart, markerMid, markerEnd, ...polylineProps } =
            this.props
        return (
            <>
                {this.segments.map((group, index) => (
                    <polyline
                        {...polylineProps}
                        key={index}
                        points={toSvgPoints(group.points)}
                        stroke={group.color}
                        fill="none"
                        strokeLinecap="butt" // `butt` allows us to have clean miter joints
                        markerStart={index === 0 ? markerStart : undefined}
                        markerMid={markerMid}
                        markerEnd={
                            index === this.segments.length - 1
                                ? markerEnd
                                : undefined
                        }
                    />
                ))}
            </>
        )
    }
}