feat(RegionMap): implement shift-drag circle selection for VIA mode
This commit is contained in:
@@ -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
|
||||||
|
Reference in New Issue
Block a user