feat(RegionMap): implement shift-drag circle selection for VIA mode

This commit is contained in:
2025-08-11 19:29:43 +02:00
parent b0ad48985a
commit 3a4e30d372

View File

@@ -22,6 +22,9 @@ const INDICATED_RING_RADIUS = 20;
const INDICATED_RING_COLOR = '#f59e0b'; const INDICATED_RING_COLOR = '#f59e0b';
const INDICATED_RING_ANIM_VALUES = '18;22;18'; const INDICATED_RING_ANIM_VALUES = '18;22;18';
const INDICATED_RING_ANIM_DUR = '1.2s'; const INDICATED_RING_ANIM_DUR = '1.2s';
const SHIFT_SELECT_STROKE_COLOR = '#60a5fa';
const SHIFT_SELECT_FILL_COLOR = 'rgba(96,165,250,0.12)';
const SHIFT_SELECT_STROKE_WIDTH = 2;
interface RegionMapProps { interface RegionMapProps {
regionName: string; regionName: string;
@@ -117,6 +120,11 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
const selectTimerRef = useRef<number | null>(null); const selectTimerRef = useRef<number | null>(null);
const downClientPointRef = useRef<{ x: number; y: number } | null>(null); const downClientPointRef = useRef<{ x: number; y: number } | null>(null);
// New: shift-drag circle selection state (VIA mode)
const [shiftSelecting, setShiftSelecting] = useState(false);
const [shiftCenter, setShiftCenter] = useState<Position | null>(null);
const [shiftRadius, setShiftRadius] = useState<number>(0);
// When focusSystem changes, set an expiry 20s in the future // When focusSystem changes, set an expiry 20s in the future
useEffect(() => { useEffect(() => {
if (focusSystem) { if (focusSystem) {
@@ -488,6 +496,32 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
return nearestName; return nearestName;
}; };
// Commit shift selection: toggle all systems within radius
const commitShiftSelection = useCallback(() => {
if (!shiftCenter || shiftRadius <= 0) return;
const within: string[] = [];
Object.keys(positions).forEach(name => {
const pos = positions[name];
if (!pos) return;
const dx = pos.x - shiftCenter.x;
const dy = pos.y - shiftCenter.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist <= shiftRadius) within.push(name);
});
if (within.length === 0) return;
setViaQueue(prev => {
const prevSet = new Set(prev);
const toToggle = new Set(within);
// remove toggled ones that were present
const kept = prev.filter(n => !toToggle.has(n));
// add new ones (those within but not previously present), preserve within order
const additions = within.filter(n => !prevSet.has(n));
const next = kept.concat(additions);
toast({ title: 'VIA toggled', description: `${within.length} systems` });
return next;
});
}, [positions, shiftCenter, shiftRadius]);
const clearSelectTimer = () => { const clearSelectTimer = () => {
if (selectTimerRef.current !== null) { if (selectTimerRef.current !== null) {
window.clearTimeout(selectTimerRef.current); window.clearTimeout(selectTimerRef.current);
@@ -500,6 +534,22 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!svgRef.current) return; if (!svgRef.current) return;
// SHIFT + VIA mode: start circle selection
if (viaMode && e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const svgPt = clientToSvg(e.clientX, e.clientY);
setShiftSelecting(true);
setShiftCenter(svgPt);
setShiftRadius(0);
// cancel any hold-to-select/pan intents
setIsSelecting(false);
setIsPanning(false);
clearSelectTimer();
downClientPointRef.current = { x: e.clientX, y: e.clientY };
return;
}
// record down point (client) and seed pan origin // record down point (client) and seed pan origin
const rect = svgRef.current.getBoundingClientRect(); const rect = svgRef.current.getBoundingClientRect();
setLastPanPoint({ x: e.clientX - rect.left, y: e.clientY - rect.top }); setLastPanPoint({ x: e.clientX - rect.left, y: e.clientY - rect.top });
@@ -516,7 +566,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
selectTimerRef.current = window.setTimeout(() => { selectTimerRef.current = window.setTimeout(() => {
setIsSelecting(true); setIsSelecting(true);
}, SELECT_HOLD_MS); }, SELECT_HOLD_MS);
}, [positions, systems]); }, [positions, systems, viaMode]);
const handleMouseMove = useCallback((e: React.MouseEvent) => { const handleMouseMove = useCallback((e: React.MouseEvent) => {
// if dragging node, delegate // if dragging node, delegate
@@ -524,6 +574,15 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
if (!svgRef.current) return; if (!svgRef.current) return;
// Shift selection radius update
if (shiftSelecting && shiftCenter) {
const svgPt = clientToSvg(e.clientX, e.clientY);
const dx = svgPt.x - shiftCenter.x;
const dy = svgPt.y - shiftCenter.y;
setShiftRadius(Math.sqrt(dx * dx + dy * dy));
return;
}
const rect = svgRef.current.getBoundingClientRect(); const rect = svgRef.current.getBoundingClientRect();
if (isPanning) { if (isPanning) {
@@ -560,12 +619,26 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
const near = findNearestSystem(svgPt.x, svgPt.y); const near = findNearestSystem(svgPt.x, svgPt.y);
setIndicatedSystem(near); setIndicatedSystem(near);
} }
}, [draggingNode, isPanning, lastPanPoint, viewBox.width, viewBox.height, isSelecting, positions, systems]); }, [draggingNode, isPanning, lastPanPoint, viewBox.width, viewBox.height, isSelecting, positions, systems, shiftSelecting, shiftCenter]);
const handleMouseUp = useCallback((e?: React.MouseEvent) => { const handleMouseUp = useCallback((e?: React.MouseEvent) => {
// if dragging node, delegate // if dragging node, delegate
if (draggingNode) { if (e) handleSvgMouseUp(e); return; } if (draggingNode) { if (e) handleSvgMouseUp(e); return; }
// Commit shift selection if active
if (shiftSelecting) {
commitShiftSelection();
setShiftSelecting(false);
setShiftCenter(null);
setShiftRadius(0);
clearSelectTimer();
setIsPanning(false);
setIsSelecting(false);
setIndicatedSystem(null);
downClientPointRef.current = null;
return;
}
clearSelectTimer(); clearSelectTimer();
if (isPanning) { if (isPanning) {
@@ -588,7 +661,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
setIsSelecting(false); setIsSelecting(false);
setIndicatedSystem(null); setIndicatedSystem(null);
downClientPointRef.current = null; downClientPointRef.current = null;
}, [draggingNode, isPanning, indicatedSystem, positions, systems]); }, [draggingNode, isPanning, indicatedSystem, positions, systems, shiftSelecting, commitShiftSelection]);
const handleWheel = useCallback((e: React.WheelEvent) => { const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault(); e.preventDefault();
@@ -758,15 +831,22 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
useEffect(() => { useEffect(() => {
const onWindowMouseUp = () => { const onWindowMouseUp = () => {
// if shift selection ongoing, commit on global mouseup as well
if (shiftSelecting) {
commitShiftSelection();
}
clearSelectTimer(); clearSelectTimer();
setIsPanning(false); setIsPanning(false);
setIsSelecting(false); setIsSelecting(false);
setIndicatedSystem(null); setIndicatedSystem(null);
setShiftSelecting(false);
setShiftCenter(null);
setShiftRadius(0);
downClientPointRef.current = null; downClientPointRef.current = null;
}; };
window.addEventListener('mouseup', onWindowMouseUp); window.addEventListener('mouseup', onWindowMouseUp);
return () => window.removeEventListener('mouseup', onWindowMouseUp); return () => window.removeEventListener('mouseup', onWindowMouseUp);
}, []); }, [shiftSelecting, commitShiftSelection]);
if (isLoading) { if (isLoading) {
return ( return (
@@ -801,7 +881,7 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
width="100%" width="100%"
height="100%" height="100%"
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`} viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.width} ${viewBox.height}`}
className={`${isSelecting ? 'cursor-crosshair' : (isPanning ? 'cursor-grabbing' : 'cursor-grab')}`} className={`${(isSelecting || shiftSelecting) ? 'cursor-crosshair' : (isPanning ? 'cursor-grabbing' : 'cursor-grab')}`}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseUp={(e) => handleMouseUp(e)} onMouseUp={(e) => handleMouseUp(e)}
@@ -842,6 +922,20 @@ export const RegionMap = ({ regionName, focusSystem, isCompact = false, isWormho
/> />
)} )}
{/* Shift selection circle (VIA mode) */}
{shiftSelecting && shiftCenter && (
<g style={{ pointerEvents: 'none' }}>
<circle
cx={shiftCenter.x}
cy={shiftCenter.y}
r={Math.max(shiftRadius, 0)}
fill={SHIFT_SELECT_FILL_COLOR}
stroke={SHIFT_SELECT_STROKE_COLOR}
strokeWidth={SHIFT_SELECT_STROKE_WIDTH}
/>
</g>
)}
{/* Render existing systems */} {/* Render existing systems */}
{Array.from(systems.entries()).map(([key, system]) => ( {Array.from(systems.entries()).map(([key, system]) => (
<MapNode <MapNode