Refactored konva map interaction and map image source

This commit is contained in:
Mitchell McCaffrey 2020-10-02 13:35:50 +10:00
parent 438e0c0bb3
commit f783a9bb70
4 changed files with 220 additions and 234 deletions

View File

@ -2,23 +2,13 @@ import React, { useState, useRef, useEffect } from "react";
import { Box } from "theme-ui"; import { Box } from "theme-ui";
import { Stage, Layer, Image } from "react-konva"; import { Stage, Layer, Image } from "react-konva";
import ReactResizeDetector from "react-resize-detector"; 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 usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useStageInteraction from "../../helpers/useStageInteraction";
import { mapSources as defaultMapSources } from "../../maps";
const wheelZoomSpeed = -0.001;
const touchZoomSpeed = 0.005;
const minZoom = 0.1;
const maxZoom = 5;
function MapEditor({ map }) { function MapEditor({ map }) {
const mapSource = useDataSource(map, defaultMapSources); const [mapImageSource] = useMapImage(map);
const [mapSourceImage] = useImage(mapSource);
const [stageWidth, setStageWidth] = useState(1); const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1); const [stageHeight, setStageHeight] = useState(1);
@ -38,9 +28,6 @@ function MapEditor({ map }) {
} }
const stageTranslateRef = useRef({ x: 0, y: 0 }); const stageTranslateRef = useRef({ x: 0, y: 0 });
const isInteractingWithCanvas = useRef(false);
const pinchPreviousDistanceRef = useRef();
const pinchPreviousOriginRef = useRef();
const mapLayerRef = useRef(); const mapLayerRef = useRef();
function handleResize(width, height) { function handleResize(width, height) {
@ -48,10 +35,11 @@ function MapEditor({ map }) {
setStageHeight(height); setStageHeight(height);
} }
// Reset map translate and scale
useEffect(() => { useEffect(() => {
const layer = mapLayerRef.current; const layer = mapLayerRef.current;
const containerRect = containerRef.current.getBoundingClientRect(); const containerRect = containerRef.current.getBoundingClientRect();
if (map && layer) { if (layer) {
let newTranslate; let newTranslate;
if (stageRatio > mapRatio) { if (stageRatio > mapRatio) {
newTranslate = { newTranslate = {
@ -72,80 +60,14 @@ function MapEditor({ map }) {
setStageScale(1); setStageScale(1);
} }
}, [map, mapWidth, mapHeight, stageRatio, mapRatio]); }, [map.id, mapWidth, mapHeight, stageRatio, mapRatio]);
const bind = useGesture({ const bind = useStageInteraction(
onWheelStart: ({ event }) => { mapLayerRef.current,
isInteractingWithCanvas.current = stageScale,
event.target === mapLayerRef.current.getCanvas()._canvas; setStageScale,
}, stageTranslateRef
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 containerRef = useRef(); const containerRef = useRef();
usePreventOverscroll(containerRef); usePreventOverscroll(containerRef);
@ -155,7 +77,7 @@ function MapEditor({ map }) {
sx={{ sx={{
width: "100%", width: "100%",
height: "300px", height: "300px",
cursor: "pointer", cursor: "move",
touchAction: "none", touchAction: "none",
outline: "none", outline: "none",
}} }}
@ -173,7 +95,7 @@ function MapEditor({ map }) {
offset={{ x: stageWidth / 2, y: stageHeight / 2 }} offset={{ x: stageWidth / 2, y: stageHeight / 2 }}
> >
<Layer ref={mapLayerRef}> <Layer ref={mapLayerRef}>
<Image image={mapSourceImage} width={mapWidth} height={mapHeight} /> <Image image={mapImageSource} width={mapWidth} height={mapHeight} />
</Layer> </Layer>
</Stage> </Stage>
</ReactResizeDetector> </ReactResizeDetector>

View File

@ -1,17 +1,13 @@
import React, { useRef, useEffect, useState, useContext } from "react"; import React, { useRef, useEffect, useState, useContext } from "react";
import { Box } from "theme-ui"; import { Box } from "theme-ui";
import { useGesture } from "react-use-gesture";
import ReactResizeDetector from "react-resize-detector"; import ReactResizeDetector from "react-resize-detector";
import useImage from "use-image";
import { Stage, Layer, Image } from "react-konva"; import { Stage, Layer, Image } from "react-konva";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import normalizeWheel from "normalize-wheel";
import useMapImage from "../../helpers/useMapImage";
import usePreventOverscroll from "../../helpers/usePreventOverscroll"; import usePreventOverscroll from "../../helpers/usePreventOverscroll";
import useDataSource from "../../helpers/useDataSource";
import useKeyboard from "../../helpers/useKeyboard"; import useKeyboard from "../../helpers/useKeyboard";
import useStageInteraction from "../../helpers/useStageInteraction";
import { mapSources as defaultMapSources } from "../../maps";
import { MapInteractionProvider } from "../../contexts/MapInteractionContext"; import { MapInteractionProvider } from "../../contexts/MapInteractionContext";
import MapStageContext, { import MapStageContext, {
@ -21,11 +17,6 @@ import AuthContext from "../../contexts/AuthContext";
import SettingsContext from "../../contexts/SettingsContext"; import SettingsContext from "../../contexts/SettingsContext";
import KeyboardContext from "../../contexts/KeyboardContext"; import KeyboardContext from "../../contexts/KeyboardContext";
const wheelZoomSpeed = -0.001;
const touchZoomSpeed = 0.005;
const minZoom = 0.1;
const maxZoom = 5;
function MapInteraction({ function MapInteraction({
map, map,
children, children,
@ -34,29 +25,7 @@ function MapInteraction({
onSelectedToolChange, onSelectedToolChange,
disabledControls, disabledControls,
}) { }) {
let mapSourceMap = map; const [mapImageSource, mapImageSourceStatus] = useMapImage(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]);
// Map loaded taking in to account different resolutions // Map loaded taking in to account different resolutions
const [mapLoaded, setMapLoaded] = useState(false); const [mapLoaded, setMapLoaded] = useState(false);
@ -64,10 +33,10 @@ function MapInteraction({
if (map === null) { if (map === null) {
setMapLoaded(false); setMapLoaded(false);
} }
if (mapSourceImageStatus === "loaded") { if (mapImageSourceStatus === "loaded") {
setMapLoaded(true); setMapLoaded(true);
} }
}, [mapSourceImageStatus, map]); }, [mapImageSourceStatus, map]);
const [stageWidth, setStageWidth] = useState(1); const [stageWidth, setStageWidth] = useState(1);
const [stageHeight, setStageHeight] = useState(1); const [stageHeight, setStageHeight] = useState(1);
@ -100,97 +69,37 @@ function MapInteraction({
previousMapIdRef.current = map && map.id; previousMapIdRef.current = map && map.id;
}, [map]); }, [map]);
const pinchPreviousDistanceRef = useRef(); function handleResize(width, height) {
const pinchPreviousOriginRef = useRef(); setStageWidth(width);
const isInteractingWithCanvas = useRef(false); setStageHeight(height);
stageWidthRef.current = width;
stageHeightRef.current = height;
}
const mapStageRef = useContext(MapStageContext);
const mapLayerRef = useRef();
const mapImageRef = useRef();
const previousSelectedToolRef = useRef(selectedToolId); const previousSelectedToolRef = useRef(selectedToolId);
const [interactionEmitter] = useState(new EventEmitter()); const [interactionEmitter] = useState(new EventEmitter());
const bind = useGesture({ const bind = useStageInteraction(
onWheelStart: ({ event }) => { mapLayerRef.current,
isInteractingWithCanvas.current = stageScale,
event.target === mapLayerRef.current.getCanvas()._canvas; setStageScale,
}, stageTranslateRef,
onWheel: ({ event }) => { preventMapInteraction,
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: () => { onPinchStart: () => {
// Change to pan tool when pinching and zooming // Change to pan tool when pinching and zooming
previousSelectedToolRef.current = selectedToolId; previousSelectedToolRef.current = selectedToolId;
onSelectedToolChange("pan"); 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: () => { onPinchEnd: () => {
onSelectedToolChange(previousSelectedToolRef.current); onSelectedToolChange(previousSelectedToolRef.current);
}, },
onDragStart: ({ event }) => { onDrag: ({ first, last }) => {
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) { if (first) {
interactionEmitter.emit("dragStart"); interactionEmitter.emit("dragStart");
} else if (last) { } else if (last) {
@ -199,14 +108,8 @@ function MapInteraction({
interactionEmitter.emit("drag"); interactionEmitter.emit("drag");
} }
}, },
});
function handleResize(width, height) {
setStageWidth(width);
setStageHeight(height);
stageWidthRef.current = width;
stageHeightRef.current = height;
} }
);
function handleKeyDown(event) { function handleKeyDown(event) {
// Change to pan tool when pressing space // Change to pan tool when pressing space
@ -272,10 +175,6 @@ function MapInteraction({
const mapWidth = stageWidth; const mapWidth = stageWidth;
const mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight; const mapHeight = map ? stageWidth * (map.height / map.width) : stageHeight;
const mapStageRef = useContext(MapStageContext);
const mapLayerRef = useRef();
const mapImageRef = useRef();
const auth = useContext(AuthContext); const auth = useContext(AuthContext);
const settings = useContext(SettingsContext); const settings = useContext(SettingsContext);
@ -314,7 +213,7 @@ function MapInteraction({
> >
<Layer ref={mapLayerRef}> <Layer ref={mapLayerRef}>
<Image <Image
image={mapLoaded && loadedMapSourceImage} image={mapLoaded && mapImageSource}
width={mapWidth} width={mapWidth}
height={mapHeight} height={mapHeight}
id="mapImage" id="mapImage"

View File

@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import useImage from "use-image";
import useDataSource from "./useDataSource";
import { mapSources as defaultMapSources } from "../maps";
function useMapImage(map) {
const [mapSourceMap, setMapSourceMap] = useState({});
// Update source map data when either the map or map quality changes
useEffect(() => {
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;

View File

@ -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;