Skip to content

Instantly share code, notes, and snippets.

@gragland
Created June 24, 2024 01:33
Show Gist options
  • Save gragland/6b2d6846e721d9c30fe8bbb506ef2d19 to your computer and use it in GitHub Desktop.
Save gragland/6b2d6846e721d9c30fe8bbb506ef2d19 to your computer and use it in GitHub Desktop.
Calculate most intuitive route through a series of objects
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