Files
grungnet/src/components/map/MapInteraction.js

324 lines
9.5 KiB
JavaScript
Raw Normal View History

import React, { useRef, useEffect, useState, useContext } from "react";
import { Box } from "theme-ui";
2020-05-21 16:46:50 +10:00
import { useGesture } from "react-use-gesture";
import ReactResizeDetector from "react-resize-detector";
import useImage from "use-image";
import { Stage, Layer, Image } from "react-konva";
import { EventEmitter } from "events";
2020-05-21 16:46:50 +10:00
import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useDataSource from "../../helpers/useDataSource";
import { mapSources as defaultMapSources } from "../../maps";
2020-05-21 16:46:50 +10:00
import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
import MapStageContext, {
MapStageProvider,
} from "../../contexts/MapStageContext";
2020-05-21 16:46:50 +10:00
import AuthContext from "../../contexts/AuthContext";
2020-04-30 22:08:03 +10:00
2020-05-29 07:36:11 +10:00
const wheelZoomSpeed = -0.001;
const touchZoomSpeed = 0.005;
const minZoom = 0.1;
const maxZoom = 5;
function MapInteraction({
map,
children,
controls,
selectedToolId,
onSelectedToolChange,
2020-06-26 12:32:51 +10:00
disabledControls,
}) {
let mapSourceMap = map;
if (map && map.type === "file") {
if (map.resolutions && map.quality !== "original") {
mapSourceMap = map.resolutions[map.quality];
}
}
const mapSource = useDataSource(mapSourceMap, defaultMapSources);
const [mapSourceImage, mapSourceImageStatus] = useImage(mapSource);
// Create a map source that only updates when the image is fully loaded
const [loadedMapSourceImage, setLoadedMapSourceImage] = useState();
useEffect(() => {
if (mapSourceImageStatus === "loaded") {
setLoadedMapSourceImage(mapSourceImage);
}
}, [mapSourceImage, mapSourceImageStatus]);
// Map loaded taking in to account different resolutions
const [mapLoaded, setMapLoaded] = useState(false);
useEffect(() => {
if (map === null) {
setMapLoaded(false);
}
if (mapSourceImageStatus === "loaded") {
setMapLoaded(true);
}
}, [mapSourceImageStatus, map]);
2020-05-21 16:46:50 +10:00
const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1);
const [stageScale, setStageScale] = useState(1);
const [preventMapInteraction, setPreventMapInteraction] = useState(false);
2020-05-21 16:46:50 +10:00
const stageWidthRef = useRef(stageWidth);
const stageHeightRef = useRef(stageHeight);
// Avoid state udpates when panning the map by using a ref and updating the konva element directly
const stageTranslateRef = useRef({ x: 0, y: 0 });
// Reset transform when map changes
useEffect(() => {
const layer = mapLayerRef.current;
if (map && layer) {
2020-05-21 16:46:50 +10:00
const mapHeight = stageWidthRef.current * (map.height / map.width);
const newTranslate = {
x: 0,
y: -(mapHeight - stageHeightRef.current) / 2,
};
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
setStageScale(1);
2020-05-21 16:46:50 +10:00
}
}, [map]);
const pinchPreviousDistanceRef = useRef();
const pinchPreviousOriginRef = useRef();
const isInteractingWithCanvas = useRef(false);
const previousSelectedToolRef = useRef(selectedToolId);
const [interactionEmitter] = useState(new EventEmitter());
2020-05-21 16:46:50 +10:00
const bind = useGesture({
2020-05-28 11:52:18 +10:00
onWheelStart: ({ event }) => {
isInteractingWithCanvas.current =
2020-05-28 11:52:18 +10:00
event.target === mapLayerRef.current.getCanvas()._canvas;
},
2020-05-21 16:46:50 +10:00
onWheel: ({ delta }) => {
if (preventMapInteraction || !isInteractingWithCanvas.current) {
2020-05-26 15:44:41 +10:00
return;
}
2020-05-21 16:46:50 +10:00
const newScale = Math.min(
2020-05-22 23:43:55 +10:00
Math.max(stageScale + delta[1] * wheelZoomSpeed, minZoom),
maxZoom
);
setStageScale(newScale);
},
onPinchStart: () => {
// Change to pan tool when pinching and zooming
previousSelectedToolRef.current = selectedToolId;
onSelectedToolChange("pan");
},
onPinch: ({ da, origin, first }) => {
const [distance] = da;
const [originX, originY] = origin;
if (first) {
pinchPreviousDistanceRef.current = distance;
pinchPreviousOriginRef.current = { x: originX, y: originY };
}
// Apply scale
const distanceDelta = distance - pinchPreviousDistanceRef.current;
const originXDelta = originX - pinchPreviousOriginRef.current.x;
const originYDelta = originY - pinchPreviousOriginRef.current.y;
2020-05-22 23:43:55 +10:00
const newScale = Math.min(
Math.max(stageScale + distanceDelta * touchZoomSpeed, minZoom),
2020-05-21 16:46:50 +10:00
maxZoom
);
setStageScale(newScale);
// Apply translate
const stageTranslate = stageTranslateRef.current;
const layer = mapLayerRef.current;
const newTranslate = {
x: stageTranslate.x + originXDelta / newScale,
y: stageTranslate.y + originYDelta / newScale,
};
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
stageTranslateRef.current = newTranslate;
pinchPreviousDistanceRef.current = distance;
pinchPreviousOriginRef.current = { x: originX, y: originY };
2020-05-21 16:46:50 +10:00
},
onPinchEnd: () => {
onSelectedToolChange(previousSelectedToolRef.current);
},
onDragStart: ({ event }) => {
isInteractingWithCanvas.current =
event.target === mapLayerRef.current.getCanvas()._canvas;
},
onDrag: ({ delta, first, last, pinching }) => {
if (
preventMapInteraction ||
pinching ||
!isInteractingWithCanvas.current
) {
2020-05-22 13:47:11 +10:00
return;
}
2020-05-22 13:47:11 +10:00
const [dx, dy] = delta;
const stageTranslate = stageTranslateRef.current;
const layer = mapLayerRef.current;
2020-05-22 13:47:11 +10:00
if (selectedToolId === "pan") {
2020-05-21 16:46:50 +10:00
const newTranslate = {
2020-05-22 13:47:11 +10:00
x: stageTranslate.x + dx / stageScale,
y: stageTranslate.y + dy / stageScale,
2020-05-21 16:46:50 +10:00
};
layer.x(newTranslate.x);
layer.y(newTranslate.y);
layer.draw();
2020-05-21 16:46:50 +10:00
stageTranslateRef.current = newTranslate;
}
if (first) {
interactionEmitter.emit("dragStart");
} else if (last) {
interactionEmitter.emit("dragEnd");
} else {
interactionEmitter.emit("drag");
2020-05-21 16:46:50 +10:00
}
},
});
function handleResize(width, height) {
setStageWidth(width);
setStageHeight(height);
stageWidthRef.current = width;
stageHeightRef.current = height;
}
function handleKeyDown(event) {
// Change to pan tool when pressing space
if (event.key === " " && selectedToolId === "pan") {
// Stop active state on pan icon from being selected
event.preventDefault();
}
2020-06-26 12:32:51 +10:00
if (
event.key === " " &&
selectedToolId !== "pan" &&
!disabledControls.includes("pan")
) {
event.preventDefault();
previousSelectedToolRef.current = selectedToolId;
onSelectedToolChange("pan");
}
2020-06-26 12:32:51 +10:00
// Basic keyboard shortcuts
if (event.key === "w" && !disabledControls.includes("pan")) {
onSelectedToolChange("pan");
}
if (event.key === "d" && !disabledControls.includes("drawing")) {
onSelectedToolChange("drawing");
}
if (event.key === "f" && !disabledControls.includes("fog")) {
onSelectedToolChange("fog");
}
if (event.key === "m" && !disabledControls.includes("measure")) {
onSelectedToolChange("measure");
}
interactionEmitter.emit("keyDown", event);
}
function handleKeyUp(event) {
if (event.key === " " && selectedToolId === "pan") {
onSelectedToolChange(previousSelectedToolRef.current);
}
interactionEmitter.emit("keyUp", event);
}
function getCursorForTool(tool) {
switch (tool) {
case "pan":
return "move";
case "fog":
2020-06-21 11:01:03 +10:00
case "drawing":
2020-06-26 12:33:07 +10:00
case "measure":
return "crosshair";
default:
return "default";
}
}
2020-05-21 16:46:50 +10:00
const containerRef = useRef();
usePreventOverscroll(containerRef);
2020-05-21 16:46:50 +10:00
const mapWidth = stageWidth;
const mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight;
2020-05-21 16:46:50 +10:00
const mapStageRef = useContext(MapStageContext);
const mapLayerRef = useRef();
2020-05-22 13:47:11 +10:00
const mapImageRef = useRef();
2020-05-21 16:46:50 +10:00
const auth = useContext(AuthContext);
2020-05-21 16:46:50 +10:00
const mapInteraction = {
stageScale,
stageWidth,
stageHeight,
setPreventMapInteraction,
mapWidth,
mapHeight,
interactionEmitter,
2020-05-21 16:46:50 +10:00
};
return (
<Box
sx={{
flexGrow: 1,
position: "relative",
cursor: getCursorForTool(selectedToolId),
2020-05-22 23:43:55 +10:00
touchAction: "none",
outline: "none",
}}
2020-05-21 16:46:50 +10:00
ref={containerRef}
{...bind()}
className="map"
tabIndex={1}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
>
2020-05-21 16:46:50 +10:00
<ReactResizeDetector handleWidth handleHeight onResize={handleResize}>
<Stage
width={stageWidth}
height={stageHeight}
scale={{ x: stageScale, y: stageScale }}
x={stageWidth / 2}
y={stageHeight / 2}
offset={{ x: stageWidth / 2, y: stageHeight / 2 }}
ref={mapStageRef}
>
<Layer ref={mapLayerRef}>
2020-05-21 16:46:50 +10:00
<Image
image={mapLoaded && loadedMapSourceImage}
2020-05-21 16:46:50 +10:00
width={mapWidth}
height={mapHeight}
id="mapImage"
2020-05-22 13:47:11 +10:00
ref={mapImageRef}
/>
2020-05-21 16:46:50 +10:00
{/* Forward auth context to konva elements */}
<AuthContext.Provider value={auth}>
<MapInteractionProvider value={mapInteraction}>
<MapStageProvider value={mapStageRef}>
{mapLoaded && children}
</MapStageProvider>
2020-05-21 16:46:50 +10:00
</MapInteractionProvider>
</AuthContext.Provider>
</Layer>
</Stage>
</ReactResizeDetector>
<MapInteractionProvider value={mapInteraction}>
{controls}
</MapInteractionProvider>
</Box>
);
}
export default MapInteraction;