├── .babelrc ├── .gitignore ├── .npmignore ├── .prettierrc.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── components │ ├── Annotation.js │ ├── ComposableMap.js │ ├── Geographies.js │ ├── Geography.js │ ├── Graticule.js │ ├── Line.js │ ├── MapProvider.js │ ├── Marker.js │ ├── Sphere.js │ ├── ZoomPanProvider.js │ ├── ZoomableGroup.js │ ├── useGeographies.js │ └── useZoomPan.js ├── index.js └── utils.js └── tests └── utils.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"], 3 | "plugins": ["@babel/transform-react-jsx", "@babel/plugin-proposal-object-rest-spread"] 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .next 3 | .DS_Store 4 | node_modules 5 | dist 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | 2 | .next 3 | .DS_Store 4 | node_modules 5 | examples 6 | src 7 | topojson-maps 8 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "embeddedLanguageFormatting": "auto", 5 | "htmlWhitespaceSensitivity": "css", 6 | "insertPragma": false, 7 | "jsxBracketSameLine": false, 8 | "jsxSingleQuote": false, 9 | "printWidth": 80, 10 | "proseWrap": "preserve", 11 | "quoteProps": "preserve", 12 | "requirePragma": false, 13 | "semi": false, 14 | "singleQuote": false, 15 | "tabWidth": 2, 16 | "trailingComma": "es5", 17 | "useTabs": false, 18 | "vueIndentScriptAndStyle": false 19 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # Changelog 3 | 4 | ## v3.0.0 2022-07-25 5 | 6 | - Added `forwardRef` to mapping components 7 | - Added `ZoomPanContext` and `ZoomPanProvider` 8 | - Added `useZoomPanContext` and `useMapContext` hooks 9 | - Added support for React 18 10 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at richard@zcreativelabs.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Richard Zimerman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # react-simple-maps 4 | 5 | Create beautiful SVG maps in react with d3-geo and topojson using a declarative api. 6 | 7 | Read the [docs](https://www.react-simple-maps.io/docs/getting-started/), or check out the [examples](https://www.react-simple-maps.io/examples/). 8 | 9 | ### Why 10 | 11 | `React-simple-maps` aims to make working with svg maps in react easier. It handles tasks such as panning, zooming and simple rendering optimization, and takes advantage of parts of [d3-geo](https://github.com/d3/d3-geo) and topojson-client instead of relying on the entire d3 library. 12 | 13 | Since `react-simple-maps` leaves DOM work to react, it can also easily be used with other libraries, such as [react-spring](https://github.com/react-spring/react-spring) and [react-annotation](https://github.com/susielu/react-annotation/). 14 | 15 | ### Install 16 | 17 | To install `react-simple-maps` 18 | 19 | ```bash 20 | $ npm install react-simple-maps 21 | ``` 22 | 23 | ...or if you use yarn: 24 | 25 | ```bash 26 | $ yarn add react-simple-maps 27 | ``` 28 | 29 | ### Usage 30 | 31 | `React-simple-maps` exposes a set of components that can be combined to create svg maps with markers and annotations. In order to render a map you have to provide a reference to a valid topojson file. You can find example topojson files on [here](https://github.com/topojson/world-atlas) or [here](https://github.com/deldersveld/topojson). To learn how to make your own topojson maps from shapefiles, please read ["How to convert and prepare TopoJSON files for interactive mapping with d3"](https://hackernoon.com/how-to-convert-and-prepare-topojson-files-for-interactive-mapping-with-d3-499cf0ced5f) on medium. 32 | 33 | ```jsx 34 | import React from "react"; 35 | import ReactDOM from "react-dom"; 36 | import { ComposableMap, Geographies, Geography } from "react-simple-maps"; 37 | 38 | // url to a valid topojson file 39 | const geoUrl = 40 | "https://raw.githubusercontent.com/deldersveld/topojson/master/world-countries.json"; 41 | 42 | const App = () => { 43 | return ( 44 |
45 | 46 | 47 | {({ geographies }) => 48 | geographies.map((geo) => ( 49 | 50 | )) 51 | } 52 | 53 | 54 |
55 | ); 56 | }; 57 | 58 | document.addEventListener("DOMContentLoaded", () => { 59 | ReactDOM.render(, document.getElementById("app")); 60 | }); 61 | ``` 62 | 63 | Check out the [live example](https://codesandbox.io/s/basic-map-wvlol) 64 | 65 | The above will render a world map using the [equal earth projection](https://observablehq.com/@d3/equal-earth). You can read more about this projection on [Shaded Relief](http://shadedrelief.com/ee_proj/) and on [Wikipedia](https://en.wikipedia.org/wiki/Equal_Earth_projection). 66 | 67 | For other examples and components, check out the [documentation](https://www.react-simple-maps.io/docs/getting-started). 68 | 69 | ### Map files 70 | 71 | React-simple-maps does not restrict you to one specific map and relies on custom map files that you can modify in any way necessary for the project. This means that you can visualise countries, regions, and continents in various resolutions, as long as they can be represented using geojson/topojson. 72 | 73 | In order for this to work properly, you will however need to provide these valid map files to react-simple-maps yourself. Luckily, there are decent sources for map files on github and elsewhere. Here are some you can check out: 74 | 75 | * [Natural Earth](https://github.com/nvkelso/natural-earth-vector) 76 | * [Topojson maps by @deldersveld](https://github.com/deldersveld/topojson) 77 | * [Topojson world atlas](https://github.com/topojson/world-atlas) 78 | 79 | ### License 80 | 81 | MIT licensed. Copyright (c) Richard Zimerman 2017. See [LICENSE.md](https://github.com/zcreativelabs/react-simple-maps/blob/master/LICENSE) for more details. 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-simple-maps", 3 | "version": "3.0.0", 4 | "description": "An svg map chart component built with and for React", 5 | "main": "dist/index.js", 6 | "module": "dist/index.es.js", 7 | "browser": "dist/index.umd.js", 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "build": "rollup -c", 13 | "watch": "rollup -cw", 14 | "prepare": "rollup -c", 15 | "test": "mocha './tests/**/*.spec.js' --compilers js:@babel/register" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/zcreativelabs/react-simple-maps.git" 20 | }, 21 | "keywords": [ 22 | "react", 23 | "maps", 24 | "charts", 25 | "worldmap", 26 | "usa", 27 | "d3-geo" 28 | ], 29 | "author": "Richard Zimerman (https://github.com/zimrick)", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/zcreativelabs/react-simple-maps/issues" 33 | }, 34 | "homepage": "https://github.com/zcreativelabs/react-simple-maps#readme", 35 | "devDependencies": { 36 | "@babel/core": "^7.18.6", 37 | "@babel/plugin-external-helpers": "^7.18.6", 38 | "@babel/plugin-proposal-object-rest-spread": "^7.18.6", 39 | "@babel/plugin-transform-react-jsx": "^7.18.6", 40 | "@babel/preset-env": "^7.18.6", 41 | "@babel/register": "^7.18.6", 42 | "@rollup/plugin-babel": "^5.3.1", 43 | "@rollup/plugin-commonjs": "^22.0.1", 44 | "@rollup/plugin-node-resolve": "^13.3.0", 45 | "expect": "^23.5.0", 46 | "mocha": "^5.2.0", 47 | "prop-types": "^15.7.2", 48 | "react": "^17.0.1", 49 | "react-dom": "^17.0.1", 50 | "rollup": "^2.75.7", 51 | "rollup-plugin-terser": "^7.0.2" 52 | }, 53 | "peerDependencies": { 54 | "prop-types": "^15.7.2", 55 | "react": "^16.8.0 || 17.x || 18.x", 56 | "react-dom": "^16.8.0 || 17.x || 18.x" 57 | }, 58 | "dependencies": { 59 | "d3-color": "^3.1.0", 60 | "d3-geo": "^3.1.0", 61 | "d3-interpolate": "^3.0.1", 62 | "d3-selection": "^3.0.0", 63 | "d3-zoom": "^3.0.0", 64 | "topojson-client": "^3.1.0" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { babel } from "@rollup/plugin-babel"; 2 | import resolve from "@rollup/plugin-node-resolve"; 3 | import commonjs from "@rollup/plugin-commonjs"; 4 | import { terser } from "rollup-plugin-terser"; 5 | 6 | import pkg from "./package.json"; 7 | 8 | const external = [ 9 | ...Object.keys(pkg.dependencies || {}), 10 | ...Object.keys(pkg.peerDependencies || {}), 11 | ]; 12 | 13 | export default [ 14 | { 15 | input: "src/index.js", 16 | external, 17 | output: { 18 | name: "reactSimpleMaps", 19 | file: pkg.browser, 20 | format: "umd", 21 | extend: true, 22 | globals: { 23 | react: "React", 24 | "react-dom": "ReactDOM", 25 | "d3-geo": "d3", 26 | "d3-zoom": "d3", 27 | "d3-selection": "d3", 28 | "topojson-client": "topojson", 29 | "prop-types": "PropTypes", 30 | }, 31 | }, 32 | plugins: [ 33 | babel({ babelHelpers: "bundled" }), 34 | resolve(), 35 | commonjs(), 36 | terser(), 37 | ], 38 | }, 39 | { 40 | input: "src/index.js", 41 | external, 42 | output: [ 43 | { 44 | file: pkg.main, 45 | format: "cjs", 46 | }, 47 | { 48 | file: pkg.module, 49 | format: "es", 50 | }, 51 | ], 52 | plugins: [babel({ babelHelpers: "bundled" })], 53 | }, 54 | ]; 55 | -------------------------------------------------------------------------------- /src/components/Annotation.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, forwardRef } from "react" 2 | import PropTypes from "prop-types" 3 | 4 | import { MapContext } from "./MapProvider" 5 | import { createConnectorPath } from "../utils" 6 | 7 | const Annotation = forwardRef( 8 | ( 9 | { 10 | subject, 11 | children, 12 | connectorProps, 13 | dx = 30, 14 | dy = 30, 15 | curve = 0, 16 | className = "", 17 | ...restProps 18 | }, 19 | ref 20 | ) => { 21 | const { projection } = useContext(MapContext) 22 | const [x, y] = projection(subject) 23 | const connectorPath = createConnectorPath(dx, dy, curve) 24 | 25 | return ( 26 | 32 | 38 | {children} 39 | 40 | ) 41 | } 42 | ) 43 | 44 | Annotation.displayName = "Annotation" 45 | 46 | Annotation.propTypes = { 47 | subject: PropTypes.array, 48 | children: PropTypes.oneOfType([ 49 | PropTypes.node, 50 | PropTypes.arrayOf(PropTypes.node), 51 | ]), 52 | dx: PropTypes.number, 53 | dy: PropTypes.number, 54 | curve: PropTypes.number, 55 | connectorProps: PropTypes.object, 56 | className: PropTypes.string, 57 | } 58 | 59 | export default Annotation 60 | -------------------------------------------------------------------------------- /src/components/ComposableMap.js: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from "react" 2 | import PropTypes from "prop-types" 3 | 4 | import { MapProvider } from "./MapProvider" 5 | 6 | const ComposableMap = forwardRef( 7 | ( 8 | { 9 | width = 800, 10 | height = 600, 11 | projection = "geoEqualEarth", 12 | projectionConfig = {}, 13 | className = "", 14 | ...restProps 15 | }, 16 | ref 17 | ) => { 18 | return ( 19 | 25 | 31 | 32 | ) 33 | } 34 | ) 35 | 36 | ComposableMap.displayName = "ComposableMap" 37 | 38 | ComposableMap.propTypes = { 39 | width: PropTypes.number, 40 | height: PropTypes.number, 41 | projection: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), 42 | projectionConfig: PropTypes.object, 43 | className: PropTypes.string, 44 | } 45 | 46 | export default ComposableMap 47 | -------------------------------------------------------------------------------- /src/components/Geographies.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, forwardRef } from "react" 2 | import PropTypes from "prop-types" 3 | 4 | import { MapContext } from "./MapProvider" 5 | import useGeographies from "./useGeographies" 6 | 7 | const Geographies = forwardRef( 8 | ( 9 | { geography, children, parseGeographies, className = "", ...restProps }, 10 | ref 11 | ) => { 12 | const { path, projection } = useContext(MapContext) 13 | const { geographies, outline, borders } = useGeographies({ 14 | geography, 15 | parseGeographies, 16 | }) 17 | 18 | return ( 19 | 20 | {geographies && 21 | geographies.length > 0 && 22 | children({ geographies, outline, borders, path, projection })} 23 | 24 | ) 25 | } 26 | ) 27 | 28 | Geographies.displayName = "Geographies" 29 | 30 | Geographies.propTypes = { 31 | geography: PropTypes.oneOfType([ 32 | PropTypes.string, 33 | PropTypes.object, 34 | PropTypes.array, 35 | ]), 36 | children: PropTypes.func, 37 | parseGeographies: PropTypes.func, 38 | className: PropTypes.string, 39 | } 40 | 41 | export default Geographies 42 | -------------------------------------------------------------------------------- /src/components/Geography.js: -------------------------------------------------------------------------------- 1 | import React, { useState, memo, forwardRef } from "react" 2 | import PropTypes from "prop-types" 3 | 4 | const Geography = forwardRef( 5 | ( 6 | { 7 | geography, 8 | onMouseEnter, 9 | onMouseLeave, 10 | onMouseDown, 11 | onMouseUp, 12 | onFocus, 13 | onBlur, 14 | style = {}, 15 | className = "", 16 | ...restProps 17 | }, 18 | ref 19 | ) => { 20 | const [isPressed, setPressed] = useState(false) 21 | const [isFocused, setFocus] = useState(false) 22 | 23 | function handleMouseEnter(evt) { 24 | setFocus(true) 25 | if (onMouseEnter) onMouseEnter(evt) 26 | } 27 | 28 | function handleMouseLeave(evt) { 29 | setFocus(false) 30 | if (isPressed) setPressed(false) 31 | if (onMouseLeave) onMouseLeave(evt) 32 | } 33 | 34 | function handleFocus(evt) { 35 | setFocus(true) 36 | if (onFocus) onFocus(evt) 37 | } 38 | 39 | function handleBlur(evt) { 40 | setFocus(false) 41 | if (isPressed) setPressed(false) 42 | if (onBlur) onBlur(evt) 43 | } 44 | 45 | function handleMouseDown(evt) { 46 | setPressed(true) 47 | if (onMouseDown) onMouseDown(evt) 48 | } 49 | 50 | function handleMouseUp(evt) { 51 | setPressed(false) 52 | if (onMouseUp) onMouseUp(evt) 53 | } 54 | 55 | return ( 56 | 78 | ) 79 | } 80 | ) 81 | 82 | Geography.displayName = "Geography" 83 | 84 | Geography.propTypes = { 85 | geography: PropTypes.object, 86 | onMouseEnter: PropTypes.func, 87 | onMouseLeave: PropTypes.func, 88 | onMouseDown: PropTypes.func, 89 | onMouseUp: PropTypes.func, 90 | onFocus: PropTypes.func, 91 | onBlur: PropTypes.func, 92 | style: PropTypes.object, 93 | className: PropTypes.string, 94 | } 95 | 96 | export default memo(Geography) 97 | -------------------------------------------------------------------------------- /src/components/Graticule.js: -------------------------------------------------------------------------------- 1 | import React, { memo, useContext, forwardRef } from "react" 2 | import PropTypes from "prop-types" 3 | import { geoGraticule } from "d3-geo" 4 | 5 | import { MapContext } from "./MapProvider" 6 | 7 | const Graticule = forwardRef( 8 | ( 9 | { 10 | fill = "transparent", 11 | stroke = "currentcolor", 12 | step = [10, 10], 13 | className = "", 14 | ...restProps 15 | }, 16 | ref 17 | ) => { 18 | const { path } = useContext(MapContext) 19 | return ( 20 | 28 | ) 29 | } 30 | ) 31 | 32 | Graticule.displayName = "Graticule" 33 | 34 | Graticule.propTypes = { 35 | fill: PropTypes.string, 36 | stroke: PropTypes.string, 37 | step: PropTypes.array, 38 | className: PropTypes.string, 39 | } 40 | 41 | export default memo(Graticule) 42 | -------------------------------------------------------------------------------- /src/components/Line.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, forwardRef } from "react" 2 | import PropTypes from "prop-types" 3 | 4 | import { MapContext } from "./MapProvider" 5 | 6 | const Line = forwardRef( 7 | ( 8 | { 9 | from = [0, 0], 10 | to = [0, 0], 11 | coordinates, 12 | stroke = "currentcolor", 13 | strokeWidth = 3, 14 | fill = "transparent", 15 | className = "", 16 | ...restProps 17 | }, 18 | ref 19 | ) => { 20 | const { path } = useContext(MapContext) 21 | 22 | const lineData = { 23 | type: "LineString", 24 | coordinates: coordinates || [from, to], 25 | } 26 | 27 | return ( 28 | 37 | ) 38 | } 39 | ) 40 | 41 | Line.displayName = "Line" 42 | 43 | Line.propTypes = { 44 | from: PropTypes.array, 45 | to: PropTypes.array, 46 | coordinates: PropTypes.array, 47 | stroke: PropTypes.string, 48 | strokeWidth: PropTypes.number, 49 | fill: PropTypes.string, 50 | className: PropTypes.string, 51 | } 52 | 53 | export default Line 54 | -------------------------------------------------------------------------------- /src/components/MapProvider.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, useMemo, useCallback, useContext } from "react" 2 | import PropTypes from "prop-types" 3 | import * as d3Geo from "d3-geo" 4 | 5 | const { geoPath, ...projections } = d3Geo 6 | 7 | const MapContext = createContext() 8 | 9 | const makeProjection = ({ 10 | projectionConfig = {}, 11 | projection = "geoEqualEarth", 12 | width = 800, 13 | height = 600, 14 | }) => { 15 | const isFunc = typeof projection === "function" 16 | 17 | if (isFunc) return projection 18 | 19 | let proj = projections[projection]().translate([width / 2, height / 2]) 20 | 21 | const supported = [ 22 | proj.center ? "center" : null, 23 | proj.rotate ? "rotate" : null, 24 | proj.scale ? "scale" : null, 25 | proj.parallels ? "parallels" : null, 26 | ] 27 | 28 | supported.forEach((d) => { 29 | if (!d) return 30 | proj = proj[d](projectionConfig[d] || proj[d]()) 31 | }) 32 | 33 | return proj 34 | } 35 | 36 | const MapProvider = ({ 37 | width, 38 | height, 39 | projection, 40 | projectionConfig, 41 | ...restProps 42 | }) => { 43 | const [cx, cy] = projectionConfig.center || [] 44 | const [rx, ry, rz] = projectionConfig.rotate || [] 45 | const [p1, p2] = projectionConfig.parallels || [] 46 | const s = projectionConfig.scale || null 47 | 48 | const projMemo = useMemo(() => { 49 | return makeProjection({ 50 | projectionConfig: { 51 | center: cx || cx === 0 || cy || cy === 0 ? [cx, cy] : null, 52 | rotate: rx || rx === 0 || ry || ry === 0 ? [rx, ry, rz] : null, 53 | parallels: p1 || p1 === 0 || p2 || p2 === 0 ? [p1, p2] : null, 54 | scale: s, 55 | }, 56 | projection, 57 | width, 58 | height, 59 | }) 60 | }, [width, height, projection, cx, cy, rx, ry, rz, p1, p2, s]) 61 | 62 | const proj = useCallback(projMemo, [projMemo]) 63 | 64 | const value = useMemo(() => { 65 | return { 66 | width, 67 | height, 68 | projection: proj, 69 | path: geoPath().projection(proj), 70 | } 71 | }, [width, height, proj]) 72 | 73 | return 74 | } 75 | 76 | MapProvider.propTypes = { 77 | width: PropTypes.number, 78 | height: PropTypes.number, 79 | projection: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), 80 | projectionConfig: PropTypes.object, 81 | } 82 | 83 | const useMapContext = () => { 84 | return useContext(MapContext) 85 | } 86 | 87 | export { MapProvider, MapContext, useMapContext } 88 | -------------------------------------------------------------------------------- /src/components/Marker.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState, forwardRef } from "react" 2 | import PropTypes from "prop-types" 3 | 4 | import { MapContext } from "./MapProvider" 5 | 6 | const Marker = forwardRef( 7 | ( 8 | { 9 | coordinates, 10 | children, 11 | onMouseEnter, 12 | onMouseLeave, 13 | onMouseDown, 14 | onMouseUp, 15 | onFocus, 16 | onBlur, 17 | style = {}, 18 | className = "", 19 | ...restProps 20 | }, 21 | ref 22 | ) => { 23 | const { projection } = useContext(MapContext) 24 | const [isPressed, setPressed] = useState(false) 25 | const [isFocused, setFocus] = useState(false) 26 | 27 | const [x, y] = projection(coordinates) 28 | 29 | function handleMouseEnter(evt) { 30 | setFocus(true) 31 | if (onMouseEnter) onMouseEnter(evt) 32 | } 33 | 34 | function handleMouseLeave(evt) { 35 | setFocus(false) 36 | if (isPressed) setPressed(false) 37 | if (onMouseLeave) onMouseLeave(evt) 38 | } 39 | 40 | function handleFocus(evt) { 41 | setFocus(true) 42 | if (onFocus) onFocus(evt) 43 | } 44 | 45 | function handleBlur(evt) { 46 | setFocus(false) 47 | if (isPressed) setPressed(false) 48 | if (onBlur) onBlur(evt) 49 | } 50 | 51 | function handleMouseDown(evt) { 52 | setPressed(true) 53 | if (onMouseDown) onMouseDown(evt) 54 | } 55 | 56 | function handleMouseUp(evt) { 57 | setPressed(false) 58 | if (onMouseUp) onMouseUp(evt) 59 | } 60 | 61 | return ( 62 | 83 | {children} 84 | 85 | ) 86 | } 87 | ) 88 | 89 | Marker.displayName = "Marker" 90 | 91 | Marker.propTypes = { 92 | coordinates: PropTypes.array, 93 | children: PropTypes.oneOfType([ 94 | PropTypes.node, 95 | PropTypes.arrayOf(PropTypes.node), 96 | ]), 97 | onMouseEnter: PropTypes.func, 98 | onMouseLeave: PropTypes.func, 99 | onMouseDown: PropTypes.func, 100 | onMouseUp: PropTypes.func, 101 | onFocus: PropTypes.func, 102 | onBlur: PropTypes.func, 103 | style: PropTypes.object, 104 | className: PropTypes.string, 105 | } 106 | 107 | export default Marker 108 | -------------------------------------------------------------------------------- /src/components/Sphere.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, memo, useMemo, useContext, forwardRef } from "react" 2 | import PropTypes from "prop-types" 3 | 4 | import { MapContext } from "./MapProvider" 5 | 6 | const Sphere = forwardRef( 7 | ( 8 | { 9 | id = "rsm-sphere", 10 | fill = "transparent", 11 | stroke = "currentcolor", 12 | strokeWidth = 0.5, 13 | className = "", 14 | ...restProps 15 | }, 16 | ref 17 | ) => { 18 | const { path } = useContext(MapContext) 19 | const spherePath = useMemo(() => path({ type: "Sphere" }), [path]) 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 37 | 38 | ) 39 | } 40 | ) 41 | 42 | Sphere.displayName = "Sphere" 43 | 44 | Sphere.propTypes = { 45 | id: PropTypes.string, 46 | fill: PropTypes.string, 47 | stroke: PropTypes.string, 48 | strokeWidth: PropTypes.number, 49 | className: PropTypes.string, 50 | } 51 | 52 | export default memo(Sphere) 53 | -------------------------------------------------------------------------------- /src/components/ZoomPanProvider.js: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext } from "react" 2 | import PropTypes from "prop-types" 3 | 4 | const ZoomPanContext = createContext() 5 | 6 | const defaultValue = { 7 | x: 0, 8 | y: 0, 9 | k: 1, 10 | transformString: "translate(0 0) scale(1)", 11 | } 12 | 13 | const ZoomPanProvider = ({ value = defaultValue, ...restProps }) => { 14 | return 15 | } 16 | 17 | ZoomPanProvider.propTypes = { 18 | x: PropTypes.number, 19 | y: PropTypes.number, 20 | k: PropTypes.number, 21 | transformString: PropTypes.string, 22 | } 23 | 24 | const useZoomPanContext = () => { 25 | return useContext(ZoomPanContext) 26 | } 27 | 28 | export { ZoomPanContext, ZoomPanProvider, useZoomPanContext } 29 | -------------------------------------------------------------------------------- /src/components/ZoomableGroup.js: -------------------------------------------------------------------------------- 1 | import React, { useContext, forwardRef } from "react" 2 | import PropTypes from "prop-types" 3 | 4 | import { MapContext } from "./MapProvider" 5 | import { ZoomPanProvider } from "./ZoomPanProvider" 6 | import useZoomPan from "./useZoomPan" 7 | 8 | const ZoomableGroup = forwardRef( 9 | ( 10 | { 11 | center = [0, 0], 12 | zoom = 1, 13 | minZoom = 1, 14 | maxZoom = 8, 15 | translateExtent, 16 | filterZoomEvent, 17 | onMoveStart, 18 | onMove, 19 | onMoveEnd, 20 | className, 21 | ...restProps 22 | }, 23 | ref 24 | ) => { 25 | const { width, height } = useContext(MapContext) 26 | 27 | const { mapRef, transformString, position } = useZoomPan({ 28 | center, 29 | filterZoomEvent, 30 | onMoveStart, 31 | onMove, 32 | onMoveEnd, 33 | scaleExtent: [minZoom, maxZoom], 34 | translateExtent, 35 | zoom, 36 | }) 37 | 38 | return ( 39 | 42 | 43 | 44 | 50 | 51 | 52 | ) 53 | } 54 | ) 55 | 56 | ZoomableGroup.displayName = "ZoomableGroup" 57 | 58 | ZoomableGroup.propTypes = { 59 | center: PropTypes.array, 60 | zoom: PropTypes.number, 61 | minZoom: PropTypes.number, 62 | maxZoom: PropTypes.number, 63 | translateExtent: PropTypes.arrayOf(PropTypes.array), 64 | onMoveStart: PropTypes.func, 65 | onMove: PropTypes.func, 66 | onMoveEnd: PropTypes.func, 67 | className: PropTypes.string, 68 | } 69 | 70 | export default ZoomableGroup 71 | -------------------------------------------------------------------------------- /src/components/useGeographies.js: -------------------------------------------------------------------------------- 1 | import { useMemo, useState, useEffect, useContext } from "react" 2 | import { MapContext } from "./MapProvider" 3 | 4 | import { 5 | fetchGeographies, 6 | getFeatures, 7 | getMesh, 8 | prepareFeatures, 9 | isString, 10 | prepareMesh, 11 | } from "../utils" 12 | 13 | export default function useGeographies({ geography, parseGeographies }) { 14 | const { path } = useContext(MapContext) 15 | const [output, setOutput] = useState({}) 16 | 17 | useEffect(() => { 18 | if (typeof window === `undefined`) return 19 | 20 | if (!geography) return 21 | 22 | if (isString(geography)) { 23 | fetchGeographies(geography).then((geos) => { 24 | if (geos) { 25 | setOutput({ 26 | geographies: getFeatures(geos, parseGeographies), 27 | mesh: getMesh(geos), 28 | }) 29 | } 30 | }) 31 | } else { 32 | setOutput({ 33 | geographies: getFeatures(geography, parseGeographies), 34 | mesh: getMesh(geography), 35 | }) 36 | } 37 | }, [geography, parseGeographies]) 38 | 39 | const { geographies, outline, borders } = useMemo(() => { 40 | const mesh = output.mesh || {} 41 | const preparedMesh = prepareMesh(mesh.outline, mesh.borders, path) 42 | return { 43 | geographies: prepareFeatures(output.geographies, path), 44 | outline: preparedMesh.outline, 45 | borders: preparedMesh.borders, 46 | } 47 | }, [output, path]) 48 | 49 | return { geographies, outline, borders } 50 | } 51 | -------------------------------------------------------------------------------- /src/components/useZoomPan.js: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState, useContext } from "react" 2 | import { zoom as d3Zoom, zoomIdentity as d3ZoomIdentity } from "d3-zoom" 3 | import { select as d3Select } from "d3-selection" 4 | 5 | import { MapContext } from "./MapProvider" 6 | import { getCoords } from "../utils" 7 | 8 | export default function useZoomPan({ 9 | center, 10 | filterZoomEvent, 11 | onMoveStart, 12 | onMoveEnd, 13 | onMove, 14 | translateExtent = [ 15 | [-Infinity, -Infinity], 16 | [Infinity, Infinity], 17 | ], 18 | scaleExtent = [1, 8], 19 | zoom = 1, 20 | }) { 21 | const { width, height, projection } = useContext(MapContext) 22 | 23 | const [lon, lat] = center 24 | const [position, setPosition] = useState({ x: 0, y: 0, k: 1 }) 25 | const lastPosition = useRef({ x: 0, y: 0, k: 1 }) 26 | const mapRef = useRef() 27 | const zoomRef = useRef() 28 | const bypassEvents = useRef(false) 29 | 30 | const [a, b] = translateExtent 31 | const [a1, a2] = a 32 | const [b1, b2] = b 33 | const [minZoom, maxZoom] = scaleExtent 34 | 35 | useEffect(() => { 36 | const svg = d3Select(mapRef.current) 37 | 38 | function handleZoomStart(d3Event) { 39 | if (!onMoveStart || bypassEvents.current) return 40 | onMoveStart( 41 | { 42 | coordinates: projection.invert( 43 | getCoords(width, height, d3Event.transform) 44 | ), 45 | zoom: d3Event.transform.k, 46 | }, 47 | d3Event 48 | ) 49 | } 50 | 51 | function handleZoom(d3Event) { 52 | if (bypassEvents.current) return 53 | const { transform, sourceEvent } = d3Event 54 | setPosition({ 55 | x: transform.x, 56 | y: transform.y, 57 | k: transform.k, 58 | dragging: sourceEvent, 59 | }) 60 | if (!onMove) return 61 | onMove( 62 | { 63 | x: transform.x, 64 | y: transform.y, 65 | zoom: transform.k, 66 | dragging: sourceEvent, 67 | }, 68 | d3Event 69 | ) 70 | } 71 | 72 | function handleZoomEnd(d3Event) { 73 | if (bypassEvents.current) { 74 | bypassEvents.current = false 75 | return 76 | } 77 | const [x, y] = projection.invert( 78 | getCoords(width, height, d3Event.transform) 79 | ) 80 | lastPosition.current = { x, y, k: d3Event.transform.k } 81 | if (!onMoveEnd) return 82 | onMoveEnd({ coordinates: [x, y], zoom: d3Event.transform.k }, d3Event) 83 | } 84 | 85 | function filterFunc(d3Event) { 86 | if (filterZoomEvent) { 87 | return filterZoomEvent(d3Event) 88 | } 89 | return d3Event ? !d3Event.ctrlKey && !d3Event.button : false 90 | } 91 | 92 | const zoom = d3Zoom() 93 | .filter(filterFunc) 94 | .scaleExtent([minZoom, maxZoom]) 95 | .translateExtent([ 96 | [a1, a2], 97 | [b1, b2], 98 | ]) 99 | .on("start", handleZoomStart) 100 | .on("zoom", handleZoom) 101 | .on("end", handleZoomEnd) 102 | 103 | zoomRef.current = zoom 104 | svg.call(zoom) 105 | }, [ 106 | width, 107 | height, 108 | a1, 109 | a2, 110 | b1, 111 | b2, 112 | minZoom, 113 | maxZoom, 114 | projection, 115 | onMoveStart, 116 | onMove, 117 | onMoveEnd, 118 | filterZoomEvent, 119 | ]) 120 | 121 | useEffect(() => { 122 | if ( 123 | lon === lastPosition.current.x && 124 | lat === lastPosition.current.y && 125 | zoom === lastPosition.current.k 126 | ) 127 | return 128 | 129 | const coords = projection([lon, lat]) 130 | const x = coords[0] * zoom 131 | const y = coords[1] * zoom 132 | const svg = d3Select(mapRef.current) 133 | 134 | bypassEvents.current = true 135 | 136 | svg.call( 137 | zoomRef.current.transform, 138 | d3ZoomIdentity.translate(width / 2 - x, height / 2 - y).scale(zoom) 139 | ) 140 | setPosition({ x: width / 2 - x, y: height / 2 - y, k: zoom }) 141 | 142 | lastPosition.current = { x: lon, y: lat, k: zoom } 143 | }, [lon, lat, zoom, width, height, projection]) 144 | 145 | return { 146 | mapRef, 147 | position, 148 | transformString: `translate(${position.x} ${position.y}) scale(${position.k})`, 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as ComposableMap } from "./components/ComposableMap" 2 | export { default as Geographies } from "./components/Geographies" 3 | export { default as Geography } from "./components/Geography" 4 | export { default as Graticule } from "./components/Graticule" 5 | export { default as ZoomableGroup } from "./components/ZoomableGroup" 6 | export { default as Sphere } from "./components/Sphere" 7 | export { default as Marker } from "./components/Marker" 8 | export { default as Line } from "./components/Line" 9 | export { default as Annotation } from "./components/Annotation" 10 | export { 11 | MapProvider, 12 | MapContext, 13 | useMapContext, 14 | } from "./components/MapProvider" 15 | export { 16 | ZoomPanProvider, 17 | ZoomPanContext, 18 | useZoomPanContext, 19 | } from "./components/ZoomPanProvider" 20 | export { default as useGeographies } from "./components/useGeographies" 21 | export { default as useZoomPan } from "./components/useZoomPan" 22 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { feature, mesh } from "topojson-client" 2 | 3 | export function getCoords(w, h, t) { 4 | const xOffset = (w * t.k - w) / 2 5 | const yOffset = (h * t.k - h) / 2 6 | return [w / 2 - (xOffset + t.x) / t.k, h / 2 - (yOffset + t.y) / t.k] 7 | } 8 | 9 | export function fetchGeographies(url) { 10 | return fetch(url) 11 | .then((res) => { 12 | if (!res.ok) { 13 | throw Error(res.statusText) 14 | } 15 | return res.json() 16 | }) 17 | .catch((error) => { 18 | console.log("There was a problem when fetching the data: ", error) 19 | }) 20 | } 21 | 22 | export function getFeatures(geographies, parseGeographies) { 23 | const isTopojson = geographies.type === "Topology" 24 | if (!isTopojson) { 25 | return parseGeographies 26 | ? parseGeographies(geographies.features || geographies) 27 | : geographies.features || geographies 28 | } 29 | const feats = feature( 30 | geographies, 31 | geographies.objects[Object.keys(geographies.objects)[0]] 32 | ).features 33 | return parseGeographies ? parseGeographies(feats) : feats 34 | } 35 | 36 | export function getMesh(geographies) { 37 | const isTopojson = geographies.type === "Topology" 38 | if (!isTopojson) return null 39 | const outline = mesh( 40 | geographies, 41 | geographies.objects[Object.keys(geographies.objects)[0]], 42 | (a, b) => a === b 43 | ) 44 | const borders = mesh( 45 | geographies, 46 | geographies.objects[Object.keys(geographies.objects)[0]], 47 | (a, b) => a !== b 48 | ) 49 | return { outline, borders } 50 | } 51 | 52 | export function prepareMesh(outline, borders, path) { 53 | return outline && borders 54 | ? { 55 | outline: { ...outline, rsmKey: "outline", svgPath: path(outline) }, 56 | borders: { ...borders, rsmKey: "borders", svgPath: path(borders) }, 57 | } 58 | : {} 59 | } 60 | 61 | export function prepareFeatures(geographies, path) { 62 | return geographies 63 | ? geographies.map((d, i) => { 64 | return { 65 | ...d, 66 | rsmKey: `geo-${i}`, 67 | svgPath: path(d), 68 | } 69 | }) 70 | : [] 71 | } 72 | 73 | export function createConnectorPath(dx = 30, dy = 30, curve = 0.5) { 74 | const curvature = Array.isArray(curve) ? curve : [curve, curve] 75 | const curveX = (dx / 2) * curvature[0] 76 | const curveY = (dy / 2) * curvature[1] 77 | return `M${0},${0} Q${-dx / 2 - curveX},${-dy / 2 + curveY} ${-dx},${-dy}` 78 | } 79 | 80 | export function isString(geo) { 81 | return typeof geo === "string" 82 | } 83 | -------------------------------------------------------------------------------- /tests/utils.spec.js: -------------------------------------------------------------------------------- 1 | 2 | import expect from "expect" 3 | 4 | describe("sampleTest", () => { 5 | it("should exist", () => { 6 | expect(true).toEqual(true) 7 | }) 8 | }) 9 | --------------------------------------------------------------------------------