diff --git a/src/components/ZoomableGeo.js b/src/components/ZoomableGeo.js new file mode 100644 index 0000000..c879a24 --- /dev/null +++ b/src/components/ZoomableGeo.js @@ -0,0 +1,56 @@ + +import React, { useContext } from "react" +import PropTypes from "prop-types" + +import { MapContext } from "./MapProvider" +import useZoomGeo from "./useZoomGeo" + +const ZoomableGeo = ({ + bounds = null, + boundsMargin = 0.1, + duration = 750, + minZoom = 1, + maxZoom = 8, + onMoveStart, + onMove, + onMoveEnd, + className, + ...restProps +}) => { + const { width, height } = useContext(MapContext) + + const { + mapRef, + transformString, + style + } = useZoomGeo({ + bounds, + boundsMargin, + duration, + onMoveStart, + onMove, + onMoveEnd, + scaleExtent: [minZoom, maxZoom], + }); + + return ( + + + + + ) +} + +ZoomableGeo.propTypes = { + bounds: PropTypes.object, + boundsMargin: PropTypes.number, + duration: PropTypes.number, + minZoom: PropTypes.number, + maxZoom: PropTypes.number, + onMoveStart: PropTypes.func, + onMove: PropTypes.func, + onMoveEnd: PropTypes.func, + className: PropTypes.string, +} + +export default ZoomableGeo diff --git a/src/components/useZoomGeo.js b/src/components/useZoomGeo.js new file mode 100644 index 0000000..2635333 --- /dev/null +++ b/src/components/useZoomGeo.js @@ -0,0 +1,99 @@ +// Converted from this D3 demo: +// https://observablehq.com/@d3/zoom-to-bounding-box +import { useEffect, useRef, useState, useContext } from "react" +import { zoom as d3Zoom, zoomIdentity } from "d3-zoom" +import { select, event as d3Event } from "d3-selection" + +import { MapContext } from "./MapProvider" +import { getCoords } from "../utils" + +export default function useZoomGeo({ + bounds, + boundsMargin, + duration, + onMoveStart, + onMove, + onMoveEnd, + scaleExtent = [1, 8], +}) { + const { width, height, projection, path } = useContext(MapContext) + + const [position, setPosition] = useState({ x: 0, y: 0, k: 1 }) + const mapRef = useRef() + const zoomRef = useRef() + const transformRef = useRef() + + const [a, b] = [[-Infinity, -Infinity], [Infinity, Infinity]]; + const [a1, a2] = a + const [b1, b2] = b + const [minZoom, maxZoom] = scaleExtent + + useEffect(() => { + const svg = select(mapRef.current) + + function handleZoomStart() { + if (!onMoveStart) return + onMoveStart({ coordinates: projection.invert(getCoords(width, height, d3Event.transform)), zoom: d3Event.transform.k }, d3Event) + } + + function handleZoom() { + const {transform, sourceEvent} = d3Event + setPosition({ x: transform.x, y: transform.y, k: transform.k, dragging: sourceEvent }) + if (!onMove) return + onMove({ x: transform.x, y: transform.y, k: transform.k, dragging: sourceEvent }, d3Event) + } + + function handleZoomEnd() { + transformRef.current = d3Event.transform; + const [x, y] = projection.invert(getCoords(width, height, d3Event.transform)) + if (!onMoveEnd) return + onMoveEnd({ coordinates: [x, y], zoom: d3Event.transform.k }, d3Event) + } + + const zoom = d3Zoom() + .scaleExtent([minZoom, maxZoom]) + .translateExtent([[a1, a1], [b1, b2]]) + .on("start", handleZoomStart) + .on("zoom", handleZoom) + .on("end", handleZoomEnd) + + zoomRef.current = zoom + + // Prevent the default zooming behaviors + svg.call(zoom) + .on("mousedown.zoom", null) + .on("dblclick.zoom", null) + .on("wheel.zoom", null) + + }, [width, height, a1, a2, b1, b2, minZoom, maxZoom, projection, onMoveStart, onMove, onMoveEnd]) + + // Zoom to the specfied geometry so that it's centered and perfectly bound + useEffect(() => { + const svg = select(mapRef.current) + const transform = zoomRef.current.transform + + if (bounds) { + const [[x0, y0], [x1, y1]] = path.bounds(bounds); + svg.transition().duration(duration).call( + transform, + zoomIdentity + .translate(width / 2, height / 2) + .scale(Math.min(maxZoom, (1 - boundsMargin) / Math.max((x1 - x0) / width, (y1 - y0) / height))) + .translate(-(x0 + x1) / 2, -(y0 + y1) / 2), + ); + } else { + svg.transition().duration(duration).call( + transform, + zoomIdentity, + transformRef.current ? transformRef.current.invert([width / 2, height / 2]) : null + ); + } + }, [bounds, boundsMargin, duration, height, maxZoom, path, width]); + + return { + mapRef, + position, + transformString: `translate(${position.x} ${position.y}) scale(${position.k})`, + style: { strokeWidth: 1 / position.k }, + } +} diff --git a/src/index.js b/src/index.js index 4554dd7..2ba314b 100644 --- a/src/index.js +++ b/src/index.js @@ -3,10 +3,12 @@ export { default as ComposableMap } from "./components/ComposableMap" export { default as Geographies } from "./components/Geographies" export { default as Geography } from "./components/Geography" export { default as Graticule } from "./components/Graticule" +export { default as ZoomableGeo } from "./components/ZoomableGeo" export { default as ZoomableGroup } from "./components/ZoomableGroup" export { default as Sphere } from "./components/Sphere" export { default as Marker } from "./components/Marker" export { default as Line } from "./components/Line" export { default as Annotation } from "./components/Annotation" export { default as useGeographies } from "./components/useGeographies" +export { default as useZoomGeo } from "./components/useZoomGeo" export { default as useZoomPan } from "./components/useZoomPan"