Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Zoom to bounding box for feature #199

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/components/ZoomableGeo.js
Original file line number Diff line number Diff line change
@@ -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 (
<g ref={mapRef}>
<rect width={width} height={height} fill="transparent" />
<g transform={transformString} style={style} className={`rsm-zoomable-geo ${className}`} {...restProps} />
</g>
)
}

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
99 changes: 99 additions & 0 deletions src/components/useZoomGeo.js
Original file line number Diff line number Diff line change
@@ -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 },
}
}
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"