-
-
Notifications
You must be signed in to change notification settings - Fork 129
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,119 +7,221 @@ export type Line = { | |
length: number | ||
angle: number | ||
} | ||
|
||
const SMOOTHING_RATIO = 0.2 | ||
const SIMILAR_THRESHOLD_DELTA = 0.01 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cover the case of a list of 0's |
||
|
||
return data.map((n) => (n - min) / (max - min)) | ||
} | ||
|
||
export const drawSegmentPath = (dataPoints: Array<DataPoint>): string => { | ||
export const mapToSvgDimensions = ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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}` | ||
} |
There was a problem hiding this comment.
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.