Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update progress graph line algorithm #1239

Merged
merged 1 commit into from
Jul 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
Comment on lines +52 to +60
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As it turns out, when a vertical gradient is applied to a horizontal (flat) line, the svg breaks and shows nothing. This method is that the line creates a mask over a rectangle that is colored with the gradient, and the mask only shows the resulting line and thus works for horizontal lines.

<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
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if points are within 1% of each other of each other, they will be considered equal for the purposes of smoothing.

const SMOOTHING_THRESHOLD_DELTA = 0.2
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If points are more than 20% different from eachother (with respect to the total height of the graph) they will be considered extreme enough to be smoothed

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
}
Comment on lines +19 to +21
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cover the case of a list of 0's [0,0,0,0,0]


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

export const drawSegmentPath = (dataPoints: Array<DataPoint>): string => {
export const mapToSvgDimensions = (
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

function extracted from the component for clarity and ease of comprehension

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]
Comment on lines +64 to +66
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the current index is near the beginning or the end, these may be undefined, which is why they are being handled carfully on lines 69 and 77


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
Comment on lines +163 to +164
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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