Created
June 24, 2024 01:33
-
-
Save gragland/6b2d6846e721d9c30fe8bbb506ef2d19 to your computer and use it in GitHub Desktop.
Calculate most intuitive route through a series of objects
This file contains hidden or 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, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; | |
const MAX_DISTANCE = 200; | |
const RIGHT_PRIORITY = 1.2; | |
const DOWN_PRIORITY = 1.0; | |
const DIRECTION_WEIGHT = 50; | |
const ARROW_PADDING = 5; | |
const ChainContext = createContext(); | |
const useChain = () => { | |
const context = useContext(ChainContext); | |
if (context === undefined) { | |
throw new Error('useChain must be used within a ChainProvider'); | |
} | |
return context; | |
}; | |
const ChainProvider = ({ children }) => { | |
const [objects, setObjects] = useState([ | |
{ id: 1, rect: { x: 50, y: 50, width: 50, height: 50 } }, | |
{ id: 2, rect: { x: 200, y: 50, width: 50, height: 50 } }, | |
{ id: 3, rect: { x: 350, y: 50, width: 50, height: 50 } } | |
]); | |
const [chain, setChain] = useState([]); | |
const [hoveredObjectId, setHoveredObjectId] = useState(null); | |
const [selectedObjectId, setSelectedObjectId] = useState(null); | |
const calculateScore = useCallback((current, next) => { | |
const dx = next.rect.x - current.rect.x; | |
const dy = next.rect.y - current.rect.y; | |
const distance = Math.sqrt(dx*dx + dy*dy); | |
let angle = Math.atan2(dy, dx); | |
if (angle < 0) angle += 2 * Math.PI; | |
const rightComponent = Math.cos(angle) * RIGHT_PRIORITY; | |
const downComponent = Math.sin(angle) * DOWN_PRIORITY; | |
const directionScore = rightComponent + downComponent; | |
return distance - (directionScore * DIRECTION_WEIGHT); | |
}, []); | |
const findNextInChain = useCallback((currentObj, currentChain) => { | |
return objects | |
.filter(obj => obj !== currentObj && !currentChain.includes(obj)) | |
.filter(obj => { | |
const dx = obj.rect.x - currentObj.rect.x; | |
const dy = obj.rect.y - currentObj.rect.y; | |
return Math.sqrt(dx*dx + dy*dy) <= MAX_DISTANCE; | |
}) | |
.reduce((best, obj) => { | |
const score = calculateScore(currentObj, obj); | |
return (best === null || score < best.score) ? {obj, score} : best; | |
}, null)?.obj || null; | |
}, [objects, calculateScore]); | |
const calculateChain = useCallback((startObjectId) => { | |
const startObject = objects.find(obj => obj.id === startObjectId); | |
if (!startObject) return []; | |
const newChain = [startObject]; | |
let currentObject = startObject; | |
while (true) { | |
const nextObject = findNextInChain(currentObject, newChain); | |
if (!nextObject) break; | |
newChain.push(nextObject); | |
currentObject = nextObject; | |
} | |
return newChain; | |
}, [objects, findNextInChain]); | |
useEffect(() => { | |
if (selectedObjectId !== null) { | |
setChain(calculateChain(selectedObjectId)); | |
} else if (hoveredObjectId !== null) { | |
setChain(calculateChain(hoveredObjectId)); | |
} else { | |
setChain([]); | |
} | |
}, [hoveredObjectId, selectedObjectId, calculateChain, objects]); | |
const addObject = useCallback((x, y) => { | |
const newId = Math.max(...objects.map(obj => obj.id), 0) + 1; | |
setObjects(prev => [...prev, { id: newId, rect: { x, y, width: 50, height: 50 } }]); | |
}, [objects]); | |
const updateObjectPosition = useCallback((id, x, y) => { | |
setObjects(prev => prev.map(obj => | |
obj.id === id ? { ...obj, rect: { ...obj.rect, x, y } } : obj | |
)); | |
}, []); | |
const selectObject = useCallback((id) => { | |
setSelectedObjectId(prevId => prevId === id ? null : id); | |
}, []); | |
return ( | |
<ChainContext.Provider value={{ | |
objects, | |
chain, | |
hoveredObjectId, | |
selectedObjectId, | |
setHoveredObjectId, | |
addObject, | |
updateObjectPosition, | |
selectObject | |
}}> | |
{children} | |
</ChainContext.Provider> | |
); | |
}; | |
const Square = ({ object }) => { | |
const { hoveredObjectId, selectedObjectId, setHoveredObjectId, updateObjectPosition, selectObject } = useChain(); | |
const [isDragging, setIsDragging] = useState(false); | |
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); | |
const dragStartPos = useRef({ x: 0, y: 0 }); | |
const handleMouseDown = (e) => { | |
setIsDragging(true); | |
setDragOffset({ | |
x: e.clientX - object.rect.x, | |
y: e.clientY - object.rect.y | |
}); | |
dragStartPos.current = { x: e.clientX, y: e.clientY }; | |
}; | |
const handleMouseMove = useCallback((e) => { | |
if (isDragging) { | |
updateObjectPosition( | |
object.id, | |
e.clientX - dragOffset.x, | |
e.clientY - dragOffset.y | |
); | |
} | |
}, [isDragging, dragOffset, object.id, updateObjectPosition]); | |
const handleMouseUp = (e) => { | |
if (isDragging) { | |
const dx = e.clientX - dragStartPos.current.x; | |
const dy = e.clientY - dragStartPos.current.y; | |
if (Math.sqrt(dx*dx + dy*dy) < 5) { // If moved less than 5 pixels, consider it a click | |
selectObject(object.id); | |
} | |
} | |
setIsDragging(false); | |
}; | |
useEffect(() => { | |
if (isDragging) { | |
window.addEventListener('mousemove', handleMouseMove); | |
window.addEventListener('mouseup', handleMouseUp); | |
} | |
return () => { | |
window.removeEventListener('mousemove', handleMouseMove); | |
window.removeEventListener('mouseup', handleMouseUp); | |
}; | |
}, [isDragging, handleMouseMove, handleMouseUp]); | |
return ( | |
<div | |
style={{ | |
position: 'absolute', | |
left: object.rect.x, | |
top: object.rect.y, | |
width: object.rect.width, | |
height: object.rect.height, | |
backgroundColor: object.id === selectedObjectId ? 'red' : 'blue', | |
cursor: isDragging ? 'grabbing' : 'grab' | |
}} | |
onMouseDown={handleMouseDown} | |
onMouseEnter={() => setHoveredObjectId(object.id)} | |
onMouseLeave={() => setHoveredObjectId(null)} | |
/> | |
); | |
}; | |
const Arrow = ({ start, end }) => { | |
const dx = end.x - start.x; | |
const dy = end.y - start.y; | |
const angle = Math.atan2(dy, dx); | |
const startX = start.x + Math.cos(angle) * (start.width / 2 + ARROW_PADDING); | |
const startY = start.y + Math.sin(angle) * (start.height / 2 + ARROW_PADDING); | |
const endX = end.x - Math.cos(angle) * (end.width / 2 + ARROW_PADDING); | |
const endY = end.y - Math.sin(angle) * (end.height / 2 + ARROW_PADDING); | |
const arrowHeadLength = 10; | |
const arrowHead1X = endX - arrowHeadLength * Math.cos(angle - Math.PI / 6); | |
const arrowHead1Y = endY - arrowHeadLength * Math.sin(angle - Math.PI / 6); | |
const arrowHead2X = endX - arrowHeadLength * Math.cos(angle + Math.PI / 6); | |
const arrowHead2Y = endY - arrowHeadLength * Math.sin(angle + Math.PI / 6); | |
return ( | |
<g> | |
<line | |
x1={startX} | |
y1={startY} | |
x2={endX} | |
y2={endY} | |
stroke="red" | |
strokeWidth="2" | |
/> | |
<polygon | |
points={`${endX},${endY} ${arrowHead1X},${arrowHead1Y} ${arrowHead2X},${arrowHead2Y}`} | |
fill="red" | |
/> | |
</g> | |
); | |
}; | |
const ChainVisualization = () => { | |
const { chain } = useChain(); | |
if (chain.length < 2) return null; | |
return ( | |
<svg style={{ position: 'absolute', left: 0, top: 0, width: '100%', height: '100%', pointerEvents: 'none' }}> | |
{chain.slice(0, -1).map((obj, index) => { | |
const nextObj = chain[index + 1]; | |
const start = { | |
x: obj.rect.x + obj.rect.width / 2, | |
y: obj.rect.y + obj.rect.height / 2, | |
width: obj.rect.width, | |
height: obj.rect.height | |
}; | |
const end = { | |
x: nextObj.rect.x + nextObj.rect.width / 2, | |
y: nextObj.rect.y + nextObj.rect.height / 2, | |
width: nextObj.rect.width, | |
height: nextObj.rect.height | |
}; | |
return <Arrow key={`${obj.id}-${nextObj.id}`} start={start} end={end} />; | |
})} | |
</svg> | |
); | |
}; | |
const InteractiveArea = () => { | |
const { objects, addObject } = useChain(); | |
const containerRef = useRef(null); | |
const handleClick = (e) => { | |
if (e.target === containerRef.current) { | |
const rect = containerRef.current.getBoundingClientRect(); | |
addObject(e.clientX - rect.left, e.clientY - rect.top); | |
} | |
}; | |
return ( | |
<div | |
ref={containerRef} | |
style={{ position: 'relative', width: '100%', height: '500px', border: '1px solid black' }} | |
onClick={handleClick} | |
> | |
{objects.map(obj => ( | |
<Square key={obj.id} object={obj} /> | |
))} | |
<ChainVisualization /> | |
</div> | |
); | |
}; | |
const App = () => { | |
return ( | |
<ChainProvider> | |
<InteractiveArea /> | |
</ChainProvider> | |
); | |
}; | |
export default App; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment