Skip to content

Instantly share code, notes, and snippets.

@robmclarty
Created December 3, 2021 19:29
Show Gist options
  • Save robmclarty/1a3db9bd5a4992ac98072ba54f469a56 to your computer and use it in GitHub Desktop.
Save robmclarty/1a3db9bd5a4992ac98072ba54f469a56 to your computer and use it in GitHub Desktop.
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