Created
December 3, 2021 19:29
-
-
Save robmclarty/1a3db9bd5a4992ac98072ba54f469a56 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { useEffect, useRef, useState } from 'react' | |
import PropTypes from 'prop-types' | |
const RADIUS_MAX = 120 | |
const RADIUS_MIN = 10 | |
const isHex = (props, propName, componentName) => { | |
const regex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/ | |
if (!regex.test(props[propName])) { | |
return new Error(`Invalid prop ${propName} passed to ${componentName}. Expected a valid hex color code (e.g., "#f0f0f0").`) | |
} | |
} | |
const isPercent = (props, propName, componentName) => { | |
const num = parseFloat(props[propName]); | |
if (isNaN(num) || num < 0 || num > 100) { | |
return new Error(`Invalid prop ${propName} passed to ${componentName}. Expected a percentage value.`) | |
} | |
} | |
const percentRange = (percent, min, max) => { | |
return percent * (max - min) / 100 + min | |
} | |
const itemType = PropTypes.shape({ | |
id: PropTypes.oneOf([ | |
PropTypes.string, | |
PropTypes.number | |
]), | |
color: isHex, | |
progress: isPercent, | |
importance: isPercent | |
}) | |
const defaultItem = { | |
id: '', | |
color: null, | |
progress: 0, | |
importance: null | |
} | |
/** | |
* ClosestPoint | |
* | |
* Takes a list of "items" and creates SVG circles for each item, sized to the | |
* item's "importance" and positioned based on its "progress" (e.g., how far | |
* along the path the item should start). Each item can be dragged, and whilst | |
* dragging, a line/point will be drawn to show the closes point along the path | |
* to the centre of the item being dragged. | |
* | |
* @see SVG Element Reference: https://developer.mozilla.org/en-US/docs/Web/SVG/Element | |
* @see SVG Attribute Reference: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute | |
* @see Projecting a point onto a Bezier curve: https://pomax.github.io/bezierinfo/#projections | |
* @see Closest Point on Path: https://bl.ocks.org/mbostock/8027637 | |
* @see Closest Point on Bezier: http://phrogz.net/svg/closest-point-on-bezier.html | |
*/ | |
const ClosestPoint = ({ | |
items = [], | |
width: chartWidth = 500, | |
height: chartHeight = 500 | |
}) => { | |
ClosestPoint.propTypes = { | |
items: PropTypes.arrayOf(itemType), | |
width: PropTypes.number, | |
height: PropTypes.number | |
} | |
///////////////////////////////////////////////////////////////////// State // | |
const ref = useRef(null) | |
const pathRef = useRef(null) // bellcurve path element | |
const pathNode = pathRef?.current | |
const [selectedItem, setSelectedItem] = useState(null) | |
const [dragItems, setDragItems] = useState(null) | |
/////////////////////////////////////////////////////////////////// Helpers // | |
const closestPoint = (x, y) => { | |
const distanceToPoint = p => { | |
const dx = p?.x - x | |
const dy = p?.y - y | |
return dx * dx + dy * dy | |
} | |
const pathLength = pathNode?.getTotalLength() | |
let precision = 8 | |
let best = 0 | |
let bestLength = 0 | |
let bestDistance = Infinity | |
// linear scan for coarse approximation | |
for (let scanLength = 0; scanLength <= pathLength; scanLength += precision) { | |
const scan = pathNode?.getPointAtLength(scanLength) | |
const scanDistance = distanceToPoint(scan) | |
if (scanDistance < bestDistance) { | |
best = scan | |
bestLength = scanLength | |
bestDistance = scanDistance | |
} | |
} | |
// binary search for precise estimate | |
precision /= 2 | |
while (precision > 0.5) { | |
const beforeLength = bestLength - precision | |
const before = pathNode?.getPointAtLength(beforeLength) | |
const beforeDistance = distanceToPoint(before) | |
const afterLength = bestLength + precision | |
const after = pathNode?.getPointAtLength(afterLength) | |
const afterDistance = distanceToPoint(after) | |
if (beforeLength >= 0 && beforeDistance < bestDistance) { | |
best = before | |
bestLength = beforeLength | |
bestDistance = beforeDistance | |
} else if (afterLength <= pathLength && afterDistance < bestDistance) { | |
best = after | |
bestLength = afterLength | |
bestDistance = afterDistance | |
} else { | |
precision /= 2 | |
} | |
} | |
best = [best.x, best.y] | |
best.distance = Math.sqrt(bestDistance) | |
return best | |
} | |
const toDragItem = item => { | |
const startX = percentRange(item.progress, 0, chartWidth) | |
const startY = percentRange(item.progress, 0, chartHeight) | |
const [closestX, closestY] = closestPoint(startX, startY) | |
return { | |
id: item.id, | |
color: item.color, | |
radius: percentRange(item.importance, RADIUS_MIN, RADIUS_MAX), | |
x: startX, | |
y: startY, | |
offset: 0, | |
closestX, | |
closestY | |
} | |
} | |
const dragItemsFrom = items => items.reduce((dict, item) => ({ | |
...dict, | |
[item.id]: toDragItem(item) | |
}), {}) | |
const bellcurveSvgPath = (width, height) => { | |
const minHeight = 100 | |
const maxHeight = height - 50 | |
const q = width / 4 // quarter of width | |
const startx = 0 | |
const starty = maxHeight | |
const cx1 = q * 1.5 | |
const cy1 = maxHeight | |
const cx2 = q * 1.5 | |
const cy2 = minHeight | |
const endx = q * 2 | |
const endy = minHeight | |
const sx1 = q * 2.5 | |
const sy1 = maxHeight | |
const sx2 = q * 4 | |
const sy2 = maxHeight | |
return `M ${startx} ${starty} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${endx} ${endy} S ${sx1} ${sy1}, ${sx2} ${sy2}` | |
} | |
///////////////////////////////////////////////////////////////// Lifecycle // | |
useEffect(() => { | |
if (Array.isArray(items) && items.length > 0 && dragItems === null) { | |
setDragItems(dragItemsFrom(items)) | |
} | |
}, []) | |
//////////////////////////////////////////////////////////////////// Events // | |
const startDrag = e => { | |
const itemId = e.target.dataset.itemId | |
const mouseX = e.pageX - ref.current?.offsetLeft || 0 | |
const mouseY = e.pageY - ref.current?.offsetTop || 0 | |
const offsetX = e.target.attributes?.cx?.value - mouseX | |
const offsetY = e.target.attributes?.cy?.value - mouseY | |
setSelectedItem({ | |
...dragItems[itemId], | |
offsetX, | |
offsetY | |
}) | |
} | |
const drag = e => { | |
if (selectedItem) { | |
e.preventDefault() | |
const mouseX = e.pageX - ref.current?.offsetLeft || 0 | |
const mouseY = e.pageY - ref.current?.offsetTop || 0 | |
// follow the mouse cursor exactly | |
const x = mouseX + selectedItem.offsetX | |
const y = mouseY + selectedItem.offsetY | |
const [closestX, closestY] = closestPoint(x, y) | |
setDragItems({ | |
...dragItems, | |
[selectedItem.id]: { | |
...selectedItem, | |
x, | |
y, | |
closestX, | |
closestY | |
} | |
}) | |
} | |
} | |
const endDrag = e => { | |
setSelectedItem(null) | |
} | |
///////////////////////////////////////////////////////////////// Interface // | |
return ( | |
<section ref={ref}> | |
<svg | |
width={chartWidth} | |
height={chartHeight} | |
style={{ backgroundColor: '#f0f0f0' }} | |
stroke='#000' | |
fill='#000' | |
strokeWidth={3} | |
onMouseMove={drag} | |
onMouseLeave={endDrag} | |
> | |
<path | |
ref={pathRef} | |
d={bellcurveSvgPath(chartWidth, chartHeight)} | |
x='0' | |
fill='none' | |
stroke='black' | |
/> | |
{selectedItem && ( | |
<> | |
<line | |
x1={dragItems[selectedItem.id].x} | |
y1={dragItems[selectedItem.id].y} | |
x2={dragItems[selectedItem.id].closestX} | |
y2={dragItems[selectedItem.id].closestY} | |
stroke='red' | |
/> | |
<circle | |
cx={dragItems[selectedItem.id].closestX} | |
cy={dragItems[selectedItem.id].closestY} | |
r={10} | |
fill='red' | |
stroke='transparent' | |
/> | |
</> | |
)} | |
{dragItems && Object.values(dragItems).map(item => ( | |
<circle | |
data-item-id={item.id} | |
key={`item-${item.id}`} | |
style={{ cursor: 'move' }} | |
cx={item.x} | |
cy={item.y} | |
r={item.radius} | |
fill={item.color} | |
onMouseDown={startDrag} | |
onMouseUp={endDrag} | |
/> | |
))} | |
</svg> | |
</section> | |
) | |
} | |
export default ClosestPoint |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment