Skip to content
198 changes: 164 additions & 34 deletions src/components/svg-wrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@ import { nanoid } from 'nanoid';
import React from 'react';
import { MdDoubleArrow } from 'react-icons/md';
import useEvent from 'react-use-event-hook';
import { NODES_MOVE_DISTANCE } from '../constants/canvas';
import {
DELTA_LINE_MULTIPLIER,
DELTA_PAGE_MULTIPLIER,
NODES_MOVE_DISTANCE,
TOUCH_PLACE_DEFER_MS,
TOUCHPAD_SENSITIVITY,
WHEEL_SENSITIVITY,
ZOOM_BASE,
ZOOM_DAMPING_RANGE,
ZOOM_MAX,
ZOOM_MIN,
} from '../constants/canvas';
import { Events, Id, NodeId, RuntimeMode, StnId } from '../constants/constants';
import { LinePathType } from '../constants/lines';
import { MAX_MASTER_NODE_FREE } from '../constants/master';
Expand Down Expand Up @@ -102,6 +113,19 @@ const SvgWrapper = () => {
// background dragging related
const dragStartRef = React.useRef({ x: 0, y: 0 });
const initialViewBoxMinRef = React.useRef({ x: 0, y: 0 });
// live refs keep zoom/min in sync with the latest dispatch so that consecutive
// wheel/pinch events within the same React batch read up-to-date values instead
// of the stale Redux snapshot captured at the start of the render.
const liveZoomRef = React.useRef(svgViewBoxZoom);
const liveMinRef = React.useRef(svgViewBoxMin);
liveZoomRef.current = svgViewBoxZoom;
liveMinRef.current = svgViewBoxMin;
// defer touch node placement so a second finger (pinch) can cancel it
const touchDeferTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pointerCountRef = React.useRef(0);
// tracks the latest drag-computed viewBoxMin so that multi-touch cancellation
// can commit the current visual position instead of reverting to Redux state.
const lastDragViewBoxMinRef = React.useRef(svgViewBoxMin);

// helper function to calculate the new svgViewBoxMin based on the current
// mouse position and the initial position when dragging starts
Expand Down Expand Up @@ -149,51 +173,112 @@ const SvgWrapper = () => {
// instead of dispatching Redux action which will cause re-render on every pointer move.
const rafRef = React.useRef<number | null>(null);

// cleanup RAF on unmount to prevent calling updateViewportTransform after unmount
// cleanup RAF and touch defer timer on unmount
React.useEffect(() => {
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (touchDeferTimerRef.current) {
clearTimeout(touchDeferTimerRef.current);
touchDeferTimerRef.current = null;
}
};
}, []);
// prevent browser zoom when ctrl + wheel or cmd + wheel
const svgRef = React.useRef<SVGSVGElement>(null);

React.useEffect(() => {
const svgInfo = svgRef.current;
if (!svgInfo) return;

const preventBrowserZoom = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
}
};

svgInfo.addEventListener('wheel', preventBrowserZoom, { passive: false });
return () => {
svgInfo.removeEventListener('wheel', preventBrowserZoom);
};
}, []);

const handleBackgroundDown = useEvent(async (e: React.PointerEvent<SVGSVGElement>) => {
const isTouch = e.pointerType === 'touch';
pointerCountRef.current++;

// multi-touch: cancel pending deferred actions and stop background drag
// pinch-to-zoom is handled by touch-overlay
if (isTouch && pointerCountRef.current >= 2) {
if (touchDeferTimerRef.current) {
clearTimeout(touchDeferTimerRef.current);
touchDeferTimerRef.current = null;
}
if (active === 'background') {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
// commit the position the user dragged to, not the pre-drag Redux value
dispatch(setSvgViewBoxMin(lastDragViewBoxMinRef.current));
dispatch(setActive(undefined));
}
return;
}

// when a node is already being interacted with (e.g. dragging a station),
// ignore additional touch on the background to prevent overwriting active
// and causing canvas drift. the user must lift all fingers first.
if (isTouch && active && active !== 'background') {
return;
}

if (contextMenu.isOpen) {
// close context menu if it's open
setContextMenu({ isOpen: false, position: { x: 0, y: 0 } });
}

const { x, y } = getMousePosition(e);
if (mode.startsWith('station') || mode.startsWith('misc-node')) {
dispatch(setMode('free'));
const rand = nanoid(10);
const { x: svgX, y: svgY } = pointerPosToSVGCoord(x, y, svgViewBoxZoom, svgViewBoxMin);

const isStation = mode.startsWith('station');
const id: NodeId = isStation ? `stn_${rand}` : `misc_node_${rand}`;
const type = (isStation ? mode.slice(8) : mode.slice(10)) as StationType | MiscNodeType;

// deep copy to prevent mutual reference
const attr = structuredClone({ ...stations, ...miscNodes }[type].defaultAttrs);
// inject runtime color if registered in dynamicColorInjection
if (dynamicColorInjection.has(type)) (attr as AttributesWithColor).color = theme;
// Add random names for stations
if (isStation) (attr as StationAttributes).names = await makeStationName(type as StationType);

graph.current.addNode(id, {
visible: true,
zIndex: 0,
x: roundToMultiple(svgX, 5),
y: roundToMultiple(svgY, 5),
type,
[type]: attr,
});
// console.log('down', active, offset);
if (isAllowProjectTelemetry) rmgRuntime.event(Events.ADD_STATION, { type });
refreshAndSave();
dispatch(setSelected(new Set([id])));
const doPlaceNode = async () => {
dispatch(setMode('free'));
const rand = nanoid(10);
const { x: svgX, y: svgY } = pointerPosToSVGCoord(x, y, svgViewBoxZoom, svgViewBoxMin);

const isStation = mode.startsWith('station');
const id: NodeId = isStation ? `stn_${rand}` : `misc_node_${rand}`;
const type = (isStation ? mode.slice(8) : mode.slice(10)) as StationType | MiscNodeType;

// deep copy to prevent mutual reference
const attr = structuredClone({ ...stations, ...miscNodes }[type].defaultAttrs);
// inject runtime color if registered in dynamicColorInjection
if (dynamicColorInjection.has(type)) (attr as AttributesWithColor).color = theme;
// Add random names for stations
if (isStation) (attr as StationAttributes).names = await makeStationName(type as StationType);

graph.current.addNode(id, {
visible: true,
zIndex: 0,
x: roundToMultiple(svgX, 5),
y: roundToMultiple(svgY, 5),
type,
[type]: attr,
});
if (isAllowProjectTelemetry) rmgRuntime.event(Events.ADD_STATION, { type });
refreshAndSave();
dispatch(setSelected(new Set([id])));
};

if (isTouch) {
touchDeferTimerRef.current = setTimeout(() => {
touchDeferTimerRef.current = null;
doPlaceNode();
}, TOUCH_PLACE_DEFER_MS);
} else {
await doPlaceNode();
}
} else if (mode === 'free' || mode.startsWith('line')) {
// deselect line tool if user clicks on the background
if (mode.startsWith('line')) {
Expand All @@ -205,6 +290,7 @@ const SvgWrapper = () => {
// set initial position of the pointer, this is used in handleBackgroundMove
dragStartRef.current = { x, y };
initialViewBoxMinRef.current = svgViewBoxMin;
lastDragViewBoxMinRef.current = svgViewBoxMin;
if (!e.shiftKey) {
// when user holding the shift key and mis-click the background
// preserve the current selection
Expand All @@ -223,19 +309,37 @@ const SvgWrapper = () => {
setSelectMoving(pointerPosToSVGCoord(x, y, svgViewBoxZoom, svgViewBoxMin));
}
} else if (active === 'background') {
// skip drag when multi-touch is active (pinch handled by touch-overlay)
if (pointerCountRef.current >= 2) return;
const { x, y } = getMousePosition(e);
if (!rafRef.current) {
// Use requestAnimationFrame to throttle the updates to at most
// once per frame for better performance during dragging.
rafRef.current = requestAnimationFrame(() => {
const tempSVGViewBoxMin = makeSVGViewBoxMin(x, y);
lastDragViewBoxMinRef.current = tempSVGViewBoxMin;
updateViewportTransform(tempSVGViewBoxMin, svgViewBoxZoom);
rafRef.current = null;
});
}
} else if (
e.pointerType === 'touch' &&
pointerCountRef.current === 1 &&
active === undefined &&
(mode === 'free' || mode.startsWith('line'))
) {
// re-initiate drag after multi-touch (pinch) ends with one finger remaining.
// setActive dispatches are batched by React so multiple pointermove events may
// arrive before the next render — the active === undefined guard prevents duplicates.
const { x, y } = getMousePosition(e);
dragStartRef.current = { x, y };
initialViewBoxMinRef.current = svgViewBoxMin;
lastDragViewBoxMinRef.current = svgViewBoxMin;
dispatch(setActive('background'));
}
});
const handleBackgroundUp = useEvent((e: React.PointerEvent<SVGSVGElement>) => {
pointerCountRef.current = Math.max(0, pointerCountRef.current - 1);
const { x, y } = getMousePosition(e);
if (mode === 'select') {
const { x: svgX, y: svgY } = pointerPosToSVGCoord(x, y, svgViewBoxZoom, svgViewBoxMin);
Expand Down Expand Up @@ -268,19 +372,44 @@ const SvgWrapper = () => {
});

const handleBackgroundWheel = useEvent((e: React.WheelEvent<SVGSVGElement>) => {
let newSvgViewBoxZoom = svgViewBoxZoom;
if (e.deltaY > 0 && svgViewBoxZoom + 10 < 400) newSvgViewBoxZoom = svgViewBoxZoom + 10;
else if (e.deltaY < 0 && svgViewBoxZoom - 10 > 0) newSvgViewBoxZoom = svgViewBoxZoom - 10;
// normalize deltaY: DOM_DELTA_PIXEL (0) needs no conversion,
// DOM_DELTA_LINE (1, Firefox mouse) and DOM_DELTA_PAGE (2, rare) are scaled
// to approximate pixel equivalents.
let delta = e.deltaY;
if (e.deltaMode === 1) delta *= DELTA_LINE_MULTIPLIER;
else if (e.deltaMode === 2) delta *= DELTA_PAGE_MULTIPLIER;

// touchpad pinch fires as ctrlKey + wheel with small deltaY, use lower divisor
const sensitivity = e.ctrlKey || e.metaKey ? TOUCHPAD_SENSITIVITY : WHEEL_SENSITIVITY;

const currentZoom = liveZoomRef.current;
const currentMin = liveMinRef.current;

let factor = Math.pow(ZOOM_BASE, delta / sensitivity);
// dampen the factor near ZOOM_MIN / ZOOM_MAX so the user feels deceleration
// instead of an abrupt stop at the boundary
const raw = currentZoom * factor;
if (raw < ZOOM_MIN + ZOOM_DAMPING_RANGE && factor < 1) {
const t = Math.max(0, (currentZoom - ZOOM_MIN) / ZOOM_DAMPING_RANGE);
factor = 1 + (factor - 1) * t;
} else if (raw > ZOOM_MAX - ZOOM_DAMPING_RANGE && factor > 1) {
const t = Math.max(0, (ZOOM_MAX - currentZoom) / ZOOM_DAMPING_RANGE);
factor = 1 + (factor - 1) * t;
}
const newSvgViewBoxZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, currentZoom * factor));

const { x, y } = getMousePosition(e);
const bbox = e.currentTarget.getBoundingClientRect();
const [x_factor, y_factor] = [x / bbox.width, y / bbox.height];

const newMin = {
x: svgViewBoxMin.x + (x * svgViewBoxZoom) / 100 - ((width * newSvgViewBoxZoom) / 100) * x_factor,
y: svgViewBoxMin.y + (y * svgViewBoxZoom) / 100 - ((height * newSvgViewBoxZoom) / 100) * y_factor,
x: currentMin.x + (x * currentZoom) / 100 - ((width * newSvgViewBoxZoom) / 100) * x_factor,
y: currentMin.y + (y * currentZoom) / 100 - ((height * newSvgViewBoxZoom) / 100) * y_factor,
};

liveZoomRef.current = newSvgViewBoxZoom;
liveMinRef.current = newMin;

dispatch(setSvgViewBoxZoom(newSvgViewBoxZoom));
dispatch(setSvgViewBoxMin(newMin));
});
Expand Down Expand Up @@ -449,6 +578,7 @@ const SvgWrapper = () => {
height={height}
width={width}
viewBox={`0 0 ${width} ${height}`}
ref={svgRef}
onPointerDown={handleBackgroundDown}
onPointerMove={handleBackgroundMove}
onPointerUp={handleBackgroundUp}
Expand Down
Loading