From f783a9bb70396f02e09ee3d99090e5364dfc7924 Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Fri, 2 Oct 2020 13:35:50 +1000 Subject: [PATCH] Refactored konva map interaction and map image source --- src/components/map/MapEditor.js | 106 ++-------------- src/components/map/MapInteraction.js | 183 ++++++--------------------- src/helpers/useMapImage.js | 58 +++++++++ src/helpers/useStageInteraction.js | 107 ++++++++++++++++ 4 files changed, 220 insertions(+), 234 deletions(-) create mode 100644 src/helpers/useMapImage.js create mode 100644 src/helpers/useStageInteraction.js diff --git a/src/components/map/MapEditor.js b/src/components/map/MapEditor.js index 74d977f..29a970b 100644 --- a/src/components/map/MapEditor.js +++ b/src/components/map/MapEditor.js @@ -2,23 +2,13 @@ import React, { useState, useRef, useEffect } from "react"; import { Box } from "theme-ui"; import { Stage, Layer, Image } from "react-konva"; import ReactResizeDetector from "react-resize-detector"; -import useImage from "use-image"; -import { useGesture } from "react-use-gesture"; -import normalizeWheel from "normalize-wheel"; -import useDataSource from "../../helpers/useDataSource"; +import useMapImage from "../../helpers/useMapImage"; import usePreventOverscroll from "../../helpers/usePreventOverscroll"; - -import { mapSources as defaultMapSources } from "../../maps"; - -const wheelZoomSpeed = -0.001; -const touchZoomSpeed = 0.005; -const minZoom = 0.1; -const maxZoom = 5; +import useStageInteraction from "../../helpers/useStageInteraction"; function MapEditor({ map }) { - const mapSource = useDataSource(map, defaultMapSources); - const [mapSourceImage] = useImage(mapSource); + const [mapImageSource] = useMapImage(map); const [stageWidth, setStageWidth] = useState(1); const [stageHeight, setStageHeight] = useState(1); @@ -38,9 +28,6 @@ function MapEditor({ map }) { } const stageTranslateRef = useRef({ x: 0, y: 0 }); - const isInteractingWithCanvas = useRef(false); - const pinchPreviousDistanceRef = useRef(); - const pinchPreviousOriginRef = useRef(); const mapLayerRef = useRef(); function handleResize(width, height) { @@ -48,10 +35,11 @@ function MapEditor({ map }) { setStageHeight(height); } + // Reset map translate and scale useEffect(() => { const layer = mapLayerRef.current; const containerRect = containerRef.current.getBoundingClientRect(); - if (map && layer) { + if (layer) { let newTranslate; if (stageRatio > mapRatio) { newTranslate = { @@ -72,80 +60,14 @@ function MapEditor({ map }) { setStageScale(1); } - }, [map, mapWidth, mapHeight, stageRatio, mapRatio]); + }, [map.id, mapWidth, mapHeight, stageRatio, mapRatio]); - const bind = useGesture({ - onWheelStart: ({ event }) => { - isInteractingWithCanvas.current = - event.target === mapLayerRef.current.getCanvas()._canvas; - }, - onWheel: ({ event }) => { - event.persist(); - const { pixelY } = normalizeWheel(event); - if (!isInteractingWithCanvas.current) { - return; - } - const newScale = Math.min( - Math.max(stageScale + pixelY * wheelZoomSpeed, minZoom), - maxZoom - ); - setStageScale(newScale); - }, - 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; - const newScale = Math.min( - Math.max(stageScale + distanceDelta * touchZoomSpeed, minZoom), - 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 }; - }, - onDragStart: ({ event }) => { - isInteractingWithCanvas.current = - event.target === mapLayerRef.current.getCanvas()._canvas; - }, - onDrag: ({ delta, pinching }) => { - if (pinching || !isInteractingWithCanvas.current) { - return; - } - - const [dx, dy] = delta; - const stageTranslate = stageTranslateRef.current; - const layer = mapLayerRef.current; - const newTranslate = { - x: stageTranslate.x + dx / stageScale, - y: stageTranslate.y + dy / stageScale, - }; - layer.x(newTranslate.x); - layer.y(newTranslate.y); - layer.draw(); - stageTranslateRef.current = newTranslate; - }, - }); + const bind = useStageInteraction( + mapLayerRef.current, + stageScale, + setStageScale, + stageTranslateRef + ); const containerRef = useRef(); usePreventOverscroll(containerRef); @@ -155,7 +77,7 @@ function MapEditor({ map }) { sx={{ width: "100%", height: "300px", - cursor: "pointer", + cursor: "move", touchAction: "none", outline: "none", }} @@ -173,7 +95,7 @@ function MapEditor({ map }) { offset={{ x: stageWidth / 2, y: stageHeight / 2 }} > - + diff --git a/src/components/map/MapInteraction.js b/src/components/map/MapInteraction.js index 7194d93..0e39319 100644 --- a/src/components/map/MapInteraction.js +++ b/src/components/map/MapInteraction.js @@ -1,17 +1,13 @@ import React, { useRef, useEffect, useState, useContext } from "react"; import { Box } from "theme-ui"; -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"; -import normalizeWheel from "normalize-wheel"; +import useMapImage from "../../helpers/useMapImage"; import usePreventOverscroll from "../../helpers/usePreventOverscroll"; -import useDataSource from "../../helpers/useDataSource"; import useKeyboard from "../../helpers/useKeyboard"; - -import { mapSources as defaultMapSources } from "../../maps"; +import useStageInteraction from "../../helpers/useStageInteraction"; import { MapInteractionProvider } from "../../contexts/MapInteractionContext"; import MapStageContext, { @@ -21,11 +17,6 @@ import AuthContext from "../../contexts/AuthContext"; import SettingsContext from "../../contexts/SettingsContext"; import KeyboardContext from "../../contexts/KeyboardContext"; -const wheelZoomSpeed = -0.001; -const touchZoomSpeed = 0.005; -const minZoom = 0.1; -const maxZoom = 5; - function MapInteraction({ map, children, @@ -34,29 +25,7 @@ function MapInteraction({ onSelectedToolChange, disabledControls, }) { - let mapSourceMap = map; - if (map && map.type === "file" && map.resolutions) { - // Set to the quality if available - if (map.quality !== "original" && map.resolutions[map.quality]) { - mapSourceMap = map.resolutions[map.quality]; - } else if (!map.file) { - // If no file fallback to the highest resolution - for (let resolution in map.resolutions) { - mapSourceMap = map.resolutions[resolution]; - } - } - } - - 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]); + const [mapImageSource, mapImageSourceStatus] = useMapImage(map); // Map loaded taking in to account different resolutions const [mapLoaded, setMapLoaded] = useState(false); @@ -64,10 +33,10 @@ function MapInteraction({ if (map === null) { setMapLoaded(false); } - if (mapSourceImageStatus === "loaded") { + if (mapImageSourceStatus === "loaded") { setMapLoaded(true); } - }, [mapSourceImageStatus, map]); + }, [mapImageSourceStatus, map]); const [stageWidth, setStageWidth] = useState(1); const [stageHeight, setStageHeight] = useState(1); @@ -100,107 +69,6 @@ function MapInteraction({ previousMapIdRef.current = map && map.id; }, [map]); - const pinchPreviousDistanceRef = useRef(); - const pinchPreviousOriginRef = useRef(); - const isInteractingWithCanvas = useRef(false); - const previousSelectedToolRef = useRef(selectedToolId); - - const [interactionEmitter] = useState(new EventEmitter()); - - const bind = useGesture({ - onWheelStart: ({ event }) => { - isInteractingWithCanvas.current = - event.target === mapLayerRef.current.getCanvas()._canvas; - }, - onWheel: ({ event }) => { - event.persist(); - const { pixelY } = normalizeWheel(event); - if (preventMapInteraction || !isInteractingWithCanvas.current) { - return; - } - const newScale = Math.min( - Math.max(stageScale + pixelY * 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; - const newScale = Math.min( - Math.max(stageScale + distanceDelta * touchZoomSpeed, minZoom), - 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 }; - }, - onPinchEnd: () => { - onSelectedToolChange(previousSelectedToolRef.current); - }, - onDragStart: ({ event }) => { - isInteractingWithCanvas.current = - event.target === mapLayerRef.current.getCanvas()._canvas; - }, - onDrag: ({ delta, first, last, pinching }) => { - if ( - preventMapInteraction || - pinching || - !isInteractingWithCanvas.current - ) { - return; - } - - const [dx, dy] = delta; - const stageTranslate = stageTranslateRef.current; - const layer = mapLayerRef.current; - if (selectedToolId === "pan") { - const newTranslate = { - x: stageTranslate.x + dx / stageScale, - y: stageTranslate.y + dy / stageScale, - }; - layer.x(newTranslate.x); - layer.y(newTranslate.y); - layer.draw(); - stageTranslateRef.current = newTranslate; - } - if (first) { - interactionEmitter.emit("dragStart"); - } else if (last) { - interactionEmitter.emit("dragEnd"); - } else { - interactionEmitter.emit("drag"); - } - }, - }); - function handleResize(width, height) { setStageWidth(width); setStageHeight(height); @@ -208,6 +76,41 @@ function MapInteraction({ stageHeightRef.current = height; } + const mapStageRef = useContext(MapStageContext); + const mapLayerRef = useRef(); + const mapImageRef = useRef(); + + const previousSelectedToolRef = useRef(selectedToolId); + + const [interactionEmitter] = useState(new EventEmitter()); + + const bind = useStageInteraction( + mapLayerRef.current, + stageScale, + setStageScale, + stageTranslateRef, + preventMapInteraction, + { + onPinchStart: () => { + // Change to pan tool when pinching and zooming + previousSelectedToolRef.current = selectedToolId; + onSelectedToolChange("pan"); + }, + onPinchEnd: () => { + onSelectedToolChange(previousSelectedToolRef.current); + }, + onDrag: ({ first, last }) => { + if (first) { + interactionEmitter.emit("dragStart"); + } else if (last) { + interactionEmitter.emit("dragEnd"); + } else { + interactionEmitter.emit("drag"); + } + }, + } + ); + function handleKeyDown(event) { // Change to pan tool when pressing space if (event.key === " " && selectedToolId === "pan") { @@ -272,10 +175,6 @@ function MapInteraction({ const mapWidth = stageWidth; const mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight; - const mapStageRef = useContext(MapStageContext); - const mapLayerRef = useRef(); - const mapImageRef = useRef(); - const auth = useContext(AuthContext); const settings = useContext(SettingsContext); @@ -314,7 +213,7 @@ function MapInteraction({ > { + function updateMapSource() { + if (map && map.type === "file" && map.resolutions) { + // If quality is set and the quality is available + if (map.quality !== "original" && map.resolutions[map.quality]) { + setMapSourceMap({ + ...map.resolutions[map.quality], + id: map.id, + quality: map.quality, + }); + } else if (!map.file) { + // If no file fallback to the highest resolution + const resolutionArray = Object.keys(map.resolutions); + setMapSourceMap({ + ...map.resolutions[resolutionArray[resolutionArray.length - 1]], + id: map.id, + }); + } else { + setMapSourceMap(map); + } + } else { + setMapSourceMap(map); + } + } + if (map && map.id !== mapSourceMap.id) { + updateMapSource(); + } else if (map && map.type === "file") { + if (map.file && map.quality !== mapSourceMap.quality) { + updateMapSource(); + } + } + }, [map, mapSourceMap]); + + 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]); + + return [loadedMapSourceImage, mapSourceImageStatus]; +} + +export default useMapImage; diff --git a/src/helpers/useStageInteraction.js b/src/helpers/useStageInteraction.js new file mode 100644 index 0000000..395964e --- /dev/null +++ b/src/helpers/useStageInteraction.js @@ -0,0 +1,107 @@ +import { useRef } from "react"; +import { useGesture } from "react-use-gesture"; +import normalizeWheel from "normalize-wheel"; + +const wheelZoomSpeed = -0.001; +const touchZoomSpeed = 0.005; +const minZoom = 0.1; +const maxZoom = 5; + +function useStageInteraction( + layer, + stageScale, + onStageScaleChange, + stageTranslateRef, + preventInteraction = false, + gesture = {} +) { + const isInteractingWithCanvas = useRef(false); + const pinchPreviousDistanceRef = useRef(); + const pinchPreviousOriginRef = useRef(); + + const bind = useGesture({ + ...gesture, + onWheelStart: (props) => { + const { event } = props; + isInteractingWithCanvas.current = + event.target === layer.getCanvas()._canvas; + gesture.onWheelStart && gesture.onWheelStart(props); + }, + onWheel: (props) => { + const { event } = props; + event.persist(); + const { pixelY } = normalizeWheel(event); + if (preventInteraction || !isInteractingWithCanvas.current) { + return; + } + const newScale = Math.min( + Math.max(stageScale + pixelY * wheelZoomSpeed, minZoom), + maxZoom + ); + onStageScaleChange(newScale); + gesture.onWheel && gesture.onWheel(props); + }, + onPinch: (props) => { + const { da, origin, first } = props; + 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; + const newScale = Math.min( + Math.max(stageScale + distanceDelta * touchZoomSpeed, minZoom), + maxZoom + ); + onStageScaleChange(newScale); + + // Apply translate + const stageTranslate = stageTranslateRef.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 }; + gesture.onPinch && gesture.onPinch(props); + }, + onDragStart: (props) => { + const { event } = props; + isInteractingWithCanvas.current = + event.target === layer.getCanvas()._canvas; + gesture.onDragStart && gesture.onDragStart(props); + }, + onDrag: (props) => { + const { delta, pinching } = props; + if (preventInteraction || pinching || !isInteractingWithCanvas.current) { + return; + } + + const [dx, dy] = delta; + const stageTranslate = stageTranslateRef.current; + const newTranslate = { + x: stageTranslate.x + dx / stageScale, + y: stageTranslate.y + dy / stageScale, + }; + layer.x(newTranslate.x); + layer.y(newTranslate.y); + layer.draw(); + stageTranslateRef.current = newTranslate; + gesture.onDrag && gesture.onDrag(props); + }, + }); + + return bind; +} + +export default useStageInteraction;