Skip to content

Commit

Permalink
update progress graph line algorithm (#1239)
Browse files Browse the repository at this point in the history
previously a sliding window algorithm was being used which found the
    line connect the prev/next points and used as parallel line to create
    the bezier control points.  This was leading to a "dipping" behaviour
    when there were extreme changes in the datapoints.  This is because in
    order for th ebezier line to curve to accomodate all of the point, it
    sometimes must curve the opposite way to accomodate the extremeness of
    a curve.

    Now using a catmullrom spline to bezier curve algorithm to produce a
    more predictable curve, and then when there is an extreme curve, points
    are being interpolated to ease it in.  There is a chance that this
    causes the same "dipping" after the curve rather than preceed it, but
    this is the limitation of using a spline to graph datapoints rather than
    computing a best-fit line.

    I think if there are further aesthetic concerns, probably would be good
    consider creating a high-resolution (many many data points) poly-line so
    that it appears smooth, but is more reflective of the true data.
  • Loading branch information
neenjaw authored Jul 14, 2021
1 parent 5555156 commit 398c2b0
Show file tree
Hide file tree
Showing 5 changed files with 461 additions and 197 deletions.
41 changes: 23 additions & 18 deletions app/javascript/components/common/ProgressGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import * as React from 'react'
import { useRef } from 'react'

import {
mapToSvgDimensions,
minMaxNormalize,
drawSegmentPath,
drawSmoothPath,
DataPoint,
smoothDataPoints,
transformCatmullRomSplineToBezierCurve,
} from './svg-graph-util'

interface IProgressGraph {
Expand All @@ -19,7 +19,6 @@ export const ProgressGraph: React.FC<IProgressGraph> = ({
data,
height,
width,
smooth = false,
}) => {
const randomIdRef = useRef<null | number>(null)

Expand All @@ -36,18 +35,11 @@ export const ProgressGraph: React.FC<IProgressGraph> = ({
const aspectRatio = width / height
const hInset = height * 0.1
const vInset = Math.round(hInset / aspectRatio)
const vBuffer = height * 0.05 // due to smoothing, some curves can go outside the boundaries without this buffer

const step = width / (data.length - 1)
const normalizedData = minMaxNormalize(data)
const dataPoints = normalizedData.map(
(n, i) =>
({
x: i * step,
y: height - vBuffer - (height - vBuffer) * n, // SVG coordinates start from the upper left corner
} as DataPoint)
)
const path = smooth ? drawSmoothPath(dataPoints) : drawSegmentPath(dataPoints)
const dataPoints = mapToSvgDimensions(normalizedData, height, width)
const smoothedDataPoints = smoothDataPoints(dataPoints, height)
const path = transformCatmullRomSplineToBezierCurve(smoothedDataPoints)

return (
<svg
Expand All @@ -57,6 +49,15 @@ export const ProgressGraph: React.FC<IProgressGraph> = ({
}`}
>
<defs>
<mask
id={`progress-graph-color-mask-${getRandomId()}`}
x="0"
y="0"
width={width}
height={height}
>
<path d={path} stroke="white" fill="transparent" />
</mask>
<linearGradient
id={`progress-graph-color-gradient-${getRandomId()}`}
x1="0%"
Expand All @@ -76,10 +77,14 @@ export const ProgressGraph: React.FC<IProgressGraph> = ({
</defs>

<g>
<path
d={path}
stroke={`url(#progress-graph-color-gradient-${getRandomId()})`}
fill="transparent"
<rect
x={`-${hInset}`}
y={`-${vInset}`}
width={`${width + 2 * hInset}`}
height={`${height + 2 * vInset}`}
stroke="none"
fill={`url(#progress-graph-color-gradient-${getRandomId()})`}
mask={`url(#progress-graph-color-mask-${getRandomId()})`}
/>
</g>
</svg>
Expand Down
276 changes: 189 additions & 87 deletions app/javascript/components/common/svg-graph-util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,119 +7,221 @@ export type Line = {
length: number
angle: number
}

const SMOOTHING_RATIO = 0.2
const SIMILAR_THRESHOLD_DELTA = 0.01
const SMOOTHING_THRESHOLD_DELTA = 0.2
const INTERPOLATION_STEPS = 3
const SEGMENT_PARTS = INTERPOLATION_STEPS + 1

export const minMaxNormalize = (data: Array<number>): Array<number> => {
const min = Math.min(0, ...data)
const max = Math.max(...data)

if (max === 0 && min === 0) {
return data
}

return data.map((n) => (n - min) / (max - min))
}

export const drawSegmentPath = (dataPoints: Array<DataPoint>): string => {
export const mapToSvgDimensions = (
data: number[],
height: number,
width: number
): DataPoint[] => {
// due to the nature of bezier splines, some curves can go outside the boundaries without this buffer
const vBuffer = height * 0.05
const step = width / (data.length - 1)

return data.map((normalizedHeight: number, index: number) => {
if (normalizedHeight < 0 || normalizedHeight > 1) {
throw new Error('Normalized height must be between 0 and 1')
}

return {
x: index * step,
y: height - vBuffer - (height - 2 * vBuffer) * normalizedHeight, // SVG coordinates start from the upper left corner
} as DataPoint
})
}

/**
* With splines (bezier or catmull-rom) when there are large differences in values, in order to
* keep the curve smooth, it often will vary above or below the baseline since they must touch all
* of the points. This function examines the list of data points, and if there is an extreme difference
* attempts to prevent the dipping above or below the baseline by interpolating points to guide the curve
*/
export const smoothDataPoints = (
dataPoints: DataPoint[],
height: number
): DataPoint[] => {
return dataPoints.reduce(
(
path: string,
acc: DataPoint[],
dataPoint: DataPoint,
idx: number,
dataPoints: Array<DataPoint>
index: number,
dataPoints: readonly DataPoint[]
) => {
switch (idx) {
case 0:
return `${drawM(dataPoint)}`
case 1:
return `${path} ${drawC(dataPoints, idx)}`
default:
return `${path} ${drawS(dataPoints, idx)}`
const prevPoint = dataPoints[index - 1]
const nextPoint = dataPoints[index + 1]
const nextNextPoint = dataPoints[index + 2]

const isLastPoint = index === dataPoints.length - 1
const isFlatLineSegmentFromPrevToCurrent = prevPoint
? areDataPointsWithinPercentDelta(
prevPoint,
dataPoint,
height,
SIMILAR_THRESHOLD_DELTA
)
: false
const isFlatLineSegmentFromNextToNextNext =
nextPoint && nextNextPoint
? areDataPointsWithinPercentDelta(
nextPoint,
nextNextPoint,
height,
SIMILAR_THRESHOLD_DELTA
)
: false

if (
isLastPoint ||
(!isFlatLineSegmentFromPrevToCurrent &&
!isFlatLineSegmentFromNextToNextNext)
) {
acc.push(dataPoint)
return acc
}
},
''
)
}

export const drawSmoothPath = (dataPoints: Array<DataPoint>): string => {
return dataPoints.reduce(
(acc: string, point: DataPoint, i: number, points: Array<DataPoint>) =>
i === 0 ? drawM(point) : `${acc} ${drawSmoothC(point, i, points)}`,
''
if (
areDataPointsWithinPercentDelta(
dataPoint,
nextPoint,
height,
SMOOTHING_THRESHOLD_DELTA
)
) {
acc.push(dataPoint)
return acc
}

const easingFunction =
dataPoint.y > nextPoint.y ? quartEaseInFunction : quartEaseOutFunction

/**
* At this point, has been determined that the curve will be arbitrarily "sharp",
* so will interpolate some points to smooth the spline towards the next point.
* Method:
* - Find the slope of the line between the two points
* - find the y intercepts when x = 0
* - use `y = mx + c` to create points between the two points with an easing
* function
*/
const slope = (nextPoint.y - dataPoint.y) / (nextPoint.x - dataPoint.x)
const yIntercept = dataPoint.y - slope * dataPoint.x
const dx = nextPoint.x - dataPoint.x

const interpolatedPoints = new Array(INTERPOLATION_STEPS)
.fill(null)
.map((_, step) => getTimeAtStep(step))
.map((t) => easingFunction(t))
.map(
(easedT, i): DataPoint => {
return {
x: dataPoint.x + getTimeAtStep(i) * dx,
y: slope * (dataPoint.x + easedT * dx) + yIntercept, // y = mx + c
}
}
)

acc.push(dataPoint, ...interpolatedPoints)

return acc
},
[]
)
}

export const drawM = ({ x, y }: DataPoint): string => `M ${x} ${y}`

export const drawS = (
dataPoints: readonly DataPoint[],
idx: number
): string => {
const { x, y } = dataPoints[idx]
const { x: xPrev } = dataPoints[idx - 1]
const areDataPointsWithinPercentDelta = (
a: DataPoint | null | undefined,
b: DataPoint | null | undefined,
height: number,
delta: number
) => {
if (!a || !b) {
return false
}

const x2 = x - (x - xPrev) / 2
const y2 = y
const aHeightPercent = a.y / height
const bHeightPercent = b.y / height

return `S ${x2} ${y2}, ${x} ${y}`
return Math.abs(aHeightPercent - bHeightPercent) < delta
}

export const drawC = (
dataPoints: readonly DataPoint[],
idx: number
): string => {
const { x, y } = dataPoints[idx]
const { x: xPrev, y: yPrev } = dataPoints[idx - 1]

const x1 = xPrev + (x - xPrev) / 2
const y1 = yPrev
const x2 = x - (x - xPrev) / 2
const y2 = y
const getTimeAtStep = (step: number): number => (1 / SEGMENT_PARTS) * (step + 1)

return `C ${x1} ${y1}, ${x2} ${y2}, ${x} ${y}`
}
const quartEaseInFunction = (t: number): number => t * t * t * t
const quartEaseOutFunction = (t: number): number => 1 - --t * t * t * t

export const drawSmoothC = (
current: DataPoint,
i: number,
/**
* Takes a sequence of CatmullRom Spline points and returns a smooth SVG Bezier
* Curve draw command. Adapted under the MIT license from:
* http://schepers.cc/svg/path/catmullrom2bezier.js
*/
export const transformCatmullRomSplineToBezierCurve = (
dataPoints: Array<DataPoint>
): string => {
const { x: x1, y: y1 } = controlPointPosition(
dataPoints[i - 1],
dataPoints[i - 2],
current
)
const { x: x2, y: y2 } = controlPointPosition(
current,
dataPoints[i - 1],
dataPoints[i + 1],
true
)

return `C ${x1} ${y1}, ${x2} ${y2}, ${current.x} ${current.y}`
}

export const line = (a: DataPoint, b: DataPoint): Line => {
const lengthX = b.x - a.x
const lengthY = b.y - a.y
if (dataPoints.length < 3) {
throw new Error('Graph requires at least 3 data points')
}

return {
length: Math.sqrt(lengthX ** 2 + lengthY ** 2),
angle: Math.atan2(lengthY, lengthX),
let path = drawM(dataPoints[0])

for (let index = 0; index < dataPoints.length - 1; index += 1) {
const points: Array<DataPoint> = []
if (index === 0) {
points.push(dataPoints[index])
points.push(dataPoints[index])
points.push(dataPoints[index + 1])
points.push(dataPoints[index + 2])
} else if (index === dataPoints.length - 2) {
points.push(dataPoints[index - 1])
points.push(dataPoints[index])
points.push(dataPoints[index + 1])
points.push(dataPoints[index + 1])
} else {
points.push(dataPoints[index - 1])
points.push(dataPoints[index])
points.push(dataPoints[index + 1])
points.push(dataPoints[index + 2])
}

path += ' ' + drawC(points[0], points[1], points[2], points[3])
}

return path
}

export const controlPointPosition = (
current: DataPoint,
previous: DataPoint | undefined,
next: DataPoint | undefined,
reverse = false
): DataPoint => {
previous = previous ?? current
next = next ?? current

const opposingLine = line(previous, next)
const angle = opposingLine.angle + (reverse ? Math.PI : 0)
const length = opposingLine.length * SMOOTHING_RATIO

return {
x: current.x + Math.cos(angle) * length,
y: current.y + Math.sin(angle) * length,
}
/**
* Draw point as SVG Move command
*/
const drawM = ({ x, y }: DataPoint): string => `M ${x} ${y}`

/**
* Translates 4 points from a CatmullRom Spline into an SVG Bezier Curve command
*/
const drawC = (
p0: DataPoint,
p1: DataPoint,
p2: DataPoint,
p3: DataPoint
): string => {
const x1 = (-p0.x + 6 * p1.x + p2.x) / 6
const y1 = (-p0.y + 6 * p1.y + p2.y) / 6
const x2 = (p1.x + 6 * p2.x - p3.x) / 6
const y2 = (p1.y + 6 * p2.y - p3.y) / 6
const x = p2.x
const y = p2.y

return `C ${x1} ${y1}, ${x2} ${y2}, ${x} ${y}`
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export const TrackSummary = ({
data={track.progressChart.data}
height={120}
width={300}
smooth
/>
<div className="info">
<h4>{track.progressChart.period}</h4>
Expand Down
Loading

0 comments on commit 398c2b0

Please sign in to comment.