diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 9b963de69..000000000 --- a/.babelrc +++ /dev/null @@ -1,35 +0,0 @@ -{ - "presets": [ - "babel-preset-philpl" - ], - "ignore": [ - "/node_modules/", - "/example/", - "/lib/", - "/es/", - "/docs/" - ], - "plugins": [ - "transform-runtime", - "transform-react-constant-elements", - "transform-react-inline-elements" - ], - "env": { - "commonjssimple": { - "plugins": [ - ["transform-es2015-modules-commonjs-simple", { - "noMangle": true, - "addExports": true - }] - ] - }, - "test": { - "plugins": [ - ["transform-es2015-modules-commonjs-simple", { - "noMangle": true, - "addExports": true - }] - ] - } - } -} diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 5a8ac3eb5..000000000 --- a/.eslintrc +++ /dev/null @@ -1,24 +0,0 @@ -{ - "parser": "babel-eslint", - "ecmaFeatures": { - "classes": true, - "jsx": true - }, - "extends": "airbnb-base", - "cache": true, - "plugins": [ - "react" - ], - "rules": { - "react/jsx-uses-vars": 1, - "react/jsx-uses-react": "error", - "class-methods-use-this": 0, - "no-underscore-dangle": 0, - "import/extensions": 0, - "no-mixed-operators": 0 - }, - "globals": { - "window": true, - "document": true - } -} diff --git a/README.md b/README.md index 944225202..0f98884bf 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # react-mapbox-gl - ![London cycle example gif](docs/london-cycle-example.gif "London cycle example gif") -React wrapper of [mapbox-gl-js](https://www.mapbox.com/mapbox-gl-js/api/) which bring the API to a react friendly way and expose projected react components. +React wrapper of [mapbox-gl-js](https://www.mapbox.com/mapbox-gl-js/api/) which bring the API to a react friendly way. +On top of the layers provided, `react-mapbox-gl` add some React rendered layers, projected using `map.project`. -Include the following elements : +Include the following elements: - ReactMapboxGl - Layer - Source @@ -20,15 +20,29 @@ Include the following elements : - Popup (Projected component) - Cluster +> The source files are written in Typescript, you can consume the compiled files in Javascript or Typescript and get the type definition files. + + +## Do you need `mapbox-gl-js` and `react-mapbox-gl` +Mapbox-gl expose a map rendered in a canvas using web gl this mean: +- All the shapes are in vector +- Fast rendering +- Smooth transitions +- All the data are on the client side, you can interact with anything on the map +- You can customize everything on the map using [mapbox studio](https://www.mapbox.com/mapbox-studio/) + +See all the features of the map exposed by [mapbox-gl-js](https://www.mapbox.com/maps/) + + ## How to start -``` +```javascript npm install react-mapbox-gl --save ``` Example: -``` +```javascript // ES6 import ReactMapboxGl, { Layer, Feature } from "react-mapbox-gl"; @@ -54,21 +68,21 @@ var Feature = ReactMapboxGl.Feature; ``` ## Disclaimer - -The zoom property is an array on purpose. With a float as a value we can't tell whether the zoom has changed because `7 === 7 // true`. We did a work around using array so that `[7] !== [7] // true`, this way we can reliably update the zoom value. +The zoom property is an array on purpose. With a float as a value we can't tell whether the zoom has changed when checking for value equality `7 === 7 // true`. +We changed it to an array so that between 2 render it check for a reference equality `[7] === [7] // false`, +this way we can reliably update the zoom value. See https://github.com/alex3165/react-mapbox-gl/issues/57 for more informations. ## Examples - -- See the example to display a big amount of markers : [London cycle example](example/src/london-cycle.js) -- See the example to display all the availables shapes : [All shapes example](example/src/all-shapes.js) -- See the example to display a GEOJson file : [geojson example](example/src/geojson-example.js) +- Display a big amount of markers: [London cycle example](example/src/london-cycle.js) +- Display all the availables shapes: [All shapes example](example/src/all-shapes.js) +- Display a GEOJson file: [geojson example](example/src/geojson-example.js) +- Display Cluster of Markers: [cluster example](example/src/cluster.js) ### Run the examples - - Clone the repository -- Go to example folder +- Go to the example folder - Install the dependencies: `npm install` - Run the example - Build the library `npm run build` diff --git a/example/server.js b/example/server.js index 601955655..ef254ee25 100644 --- a/example/server.js +++ b/example/server.js @@ -1,17 +1,17 @@ -var WebpackDevServer = require('webpack-dev-server') -var webpack = require('webpack') -var config = require('./webpack.config') +var WebpackDevServer = require('webpack-dev-server'); +var webpack = require('webpack'); +var config = require('./webpack.config'); var compiler = webpack(config) -var port = 8080; +var port = 8081; var host = 'localhost'; var server = new WebpackDevServer(compiler, { publicPath: config.output.publicPath, historyApiFallback: true, - noInfo: true + noInfo: true, }) server.listen(port, host, function() { console.log(`☕️ Server is listening on http://${host}:${port}.`); -}) +}); diff --git a/example/src/cluster.js b/example/src/cluster.js index 0074cb268..1b00ce6e2 100644 --- a/example/src/cluster.js +++ b/example/src/cluster.js @@ -40,7 +40,7 @@ export default class ClusterExample extends Component { } clusterMarker = (coordinates, pointCount) => ( - + { pointCount } ); diff --git a/package.json b/package.json index e0b86fa14..69e1153cc 100644 --- a/package.json +++ b/package.json @@ -3,24 +3,25 @@ "version": "0.29.2", "description": "A React binding of mapbox-gl-js", "main": "lib/index.js", - "jsnext:main": "es/index.js", "scripts": { "clean": "rm -rf dist", - "lint": "eslint src --ignore-pattern __tests__", "test": "jest", - "build:commonjs": "BABEL_ENV=commonjssimple babel src --out-dir lib", - "build:watch": "BABEL_ENV=commonjssimple babel src --watch --out-dir lib", - "build:es": "babel src --out-dir es", - "build": "npm run lint && npm run test && npm run build:commonjs && npm run build:es", + "lint": "tslint --project tsconfig.json", + "build": "npm run lint && npm run test && tsc", + "build:watch": "tsc --watch", "prepublish": "npm run clean && npm run build", "version": "npm run build", "postversion": "git push && git push --tags" }, "jest": { - "testPathIgnorePatterns": [ - "/node_modules/", - "/lib/", - "/es/" + "transform": { + ".(ts|tsx)": "/node_modules/ts-jest/preprocessor.js" + }, + "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx|js)$", + "moduleFileExtensions": [ + "ts", + "tsx", + "js" ], "verbose": true }, @@ -49,7 +50,6 @@ }, "homepage": "https://github.com/alex3165/react-mapbox-gl#readme", "dependencies": { - "babel-runtime": "^6.11.6", "deep-equal": "^1.0.1", "mapbox-gl": "^0.32.1", "reduce-object": "^0.1.3", @@ -60,27 +60,21 @@ "react-dom": "^15.0.1" }, "devDependencies": { - "babel": "^6.5.2", - "babel-cli": "^6.7.5", - "babel-core": "^6.18.2", - "babel-eslint": "^7.1.0", - "babel-jest": "^17.0.2", - "babel-loader": "^6.2.4", - "babel-plugin-transform-es2015-modules-commonjs": "^6.10.3", - "babel-plugin-transform-es2015-modules-commonjs-simple": "^6.7.4", - "babel-plugin-transform-react-constant-elements": "^6.9.1", - "babel-plugin-transform-react-inline-elements": "^6.8.0", - "babel-plugin-transform-runtime": "^6.15.0", - "babel-preset-philpl": "^0.4.0", - "eslint": "^3.9.1", - "eslint-config-airbnb-base": "^9.0.0", - "eslint-plugin-import": "^2.1.0", - "eslint-plugin-react": "^6.5.0", + "@types/jest": "^18.1.1", + "@types/mapbox-gl": "^0.29.0", + "@types/node": "^7.0.5", + "@types/react": "^15.0.6", + "@types/react-addons-test-utils": "^0.14.17", + "@types/recompose": "^0.20.3", "jest": "^17.0.1", "react": "^15.4.0", "react-addons-test-utils": "^15.4.0", "react-dom": "^15.4.0", "react-test-renderer": "^15.4.0", - "recompose": "^0.20.2" + "recompose": "^0.20.2", + "ts-jest": "^18.0.3", + "tslint": "^4.4.2", + "tslint-react": "^2.3.0", + "typescript": "^2.1.5" } } diff --git a/src/__tests__/layer.test.js b/src/__tests__/layer.test.tsx similarity index 68% rename from src/__tests__/layer.test.js rename to src/__tests__/layer.test.tsx index 88bbd4678..edd423220 100644 --- a/src/__tests__/layer.test.js +++ b/src/__tests__/layer.test.tsx @@ -1,16 +1,18 @@ -import React from 'react'; +import * as React from 'react'; import Layer from '../layer'; -import TestUtils from 'react-addons-test-utils'; +import * as TestUtils from 'react-addons-test-utils'; import { withContext } from 'recompose'; describe('Layer', () => { - let LayerWithContext; - let addLayerMock; - let addSourceMock; + let LayerWithContext: any; + let addLayerMock = jest.fn(); + let addSourceMock = jest.fn(); + let children: any[]; beforeEach(() => { addLayerMock = jest.fn(); addSourceMock = jest.fn(); + children = [{ props: {}}]; LayerWithContext = withContext({ map: React.PropTypes.object @@ -25,9 +27,10 @@ describe('Layer', () => { }); it('Should render layer with default options', () => { - const LayerComponent = TestUtils.renderIntoDocument( + TestUtils.renderIntoDocument( + children={children} + /> as React.ReactElement ); expect(addLayerMock.mock.calls[0]).toEqual([{ @@ -40,16 +43,17 @@ describe('Layer', () => { }); it('Should render layer with default source', () => { - const LayerComponent = TestUtils.renderIntoDocument( + TestUtils.renderIntoDocument( + children={children} + /> as React.ReactElement ); expect(addSourceMock.mock.calls[0]).toEqual(['layer-2', { type: 'geojson', data: { type: 'FeatureCollection', - features: [], + features: [] } }]); }); diff --git a/src/cluster.js b/src/cluster.js deleted file mode 100644 index b760caefd..000000000 --- a/src/cluster.js +++ /dev/null @@ -1,100 +0,0 @@ -import React, { PropTypes, Component } from 'react'; -import supercluster from 'supercluster'; - -export default class Cluster extends Component { - - static propTypes = { - ClusterMarkerFactory: PropTypes.func.isRequired, - clusterThreshold: PropTypes.number, - radius: PropTypes.number, - minZoom: PropTypes.number, - maxZoom: PropTypes.number, - extent: PropTypes.number, - nodeSize: PropTypes.number, - log: PropTypes.bool, - }; - - static contextTypes = { - map: PropTypes.object, - }; - - static defaultProps = { - clusterThreshold: 1, - radius: 60, - minZoom: 0, - maxZoom: 16, - extent: 512, - nodeSize: 64, - log: false, - }; - - state = { - clusterIndex: supercluster({ - radius: this.props.radius, - maxZoom: this.props.maxZoom, - }), - clusterPoints: [], - }; - - componentWillMount() { - const { map } = this.context; - const { clusterIndex } = this.state; - - const features = this.childrenToFeatures(this.props.children); - clusterIndex.load(features); - - // TODO: Debounce ? - map.on('move', this.mapChange); - map.on('zoom', this.mapChange); - this.mapChange(); - } - - mapChange = () => { - const { map } = this.context; - const { clusterIndex, clusterPoints } = this.state; - - const { _sw, _ne } = map.getBounds(); - const zoom = map.getZoom(); - const newPoints = clusterIndex.getClusters( - [_sw.lng, _sw.lat, _ne.lng, _ne.lat], - Math.round(zoom) - ); - - if (newPoints.length !== clusterPoints.length) { - this.setState({ clusterPoints: newPoints }); - } - }; - - feature(coordinates) { - return { - type: 'Feature', - geometry: { - type: 'point', - coordinates, - }, - properties: { - point_count: 1, - }, - }; - } - - childrenToFeatures(children) { - return children.map(child => this.feature(child.props.coordinates)); - } - - render() { - const { children, ClusterMarkerFactory, clusterThreshold } = this.props; - const { clusterPoints } = this.state; - - return ( -
- { - clusterPoints.length <= clusterThreshold ? - children : - clusterPoints.map(({ geometry, properties }) => - ClusterMarkerFactory(geometry.coordinates, properties.point_count)) - } -
- ); - } -} diff --git a/src/cluster.tsx b/src/cluster.tsx new file mode 100644 index 000000000..f34d29561 --- /dev/null +++ b/src/cluster.tsx @@ -0,0 +1,128 @@ +import * as React from 'react'; +import * as MapboxGL from 'mapbox-gl'; +import { Props as MarkerProps } from './marker'; +const supercluster = require('supercluster'); // tslint:disable-line +import * as GeoJSON from 'geojson'; +import { Feature } from './util/types'; + +export interface Props { + ClusterMarkerFactory(coordinates: GeoJSON.Position, pointCount: number): JSX.Element; + clusterThreshold?: number; + radius?: number; + maxZoom?: number; + minZoom?: number; + extent?: number; + nodeSize?: number; + log?: boolean; + children?: Array>; +} + +export interface State { + superC: any; + clusterPoints: any[]; +} + +export interface Context { + map: MapboxGL.Map; +} + +export default class Cluster extends React.Component { + public context: Context; + + public static contextTypes = { + map: React.PropTypes.object + }; + + public static defaultProps = { + clusterThreshold: 1, + radius: 60, + minZoom: 0, + maxZoom: 16, + extent: 512, + nodeSize: 64, + log: false + }; + + public state = { + superC: supercluster({ + radius: this.props.radius, + maxZoom: this.props.maxZoom, + minZoom: this.props.minZoom, + extent: this.props.extent, + nodeSize: this.props.nodeSize, + log: this.props.log + }), + clusterPoints: [] + }; + + public componentWillMount() { + const { map } = this.context; + const { superC } = this.state; + const { children } = this.props; + + if (children) { + const features = this.childrenToFeatures(children as any); + superC.load(features); + } + + // TODO: Debounce ? + map.on('move', this.mapChange); + map.on('zoom', this.mapChange); + this.mapChange(); + } + + private mapChange = () => { + const { map } = this.context; + const { superC, clusterPoints } = this.state; + + const { _sw, _ne } = map.getBounds() as any; + const zoom = map.getZoom(); + const newPoints = superC.getClusters( + [_sw.lng, _sw.lat, _ne.lng, _ne.lat], + Math.round(zoom) + ); + + if (newPoints.length !== clusterPoints.length) { + this.setState({ clusterPoints: newPoints }); + } + } + + private feature(coordinates: GeoJSON.Position): Feature { + return { + type: 'Feature', + geometry: { + type: 'point', + coordinates + }, + properties: { + point_count: 1 + } + }; + } + + private childrenToFeatures = (children: Array>) => ( + children.map((child) => this.feature(child && child.props.coordinates)) + ); + + public render() { + const { children, ClusterMarkerFactory, clusterThreshold } = this.props; + const { clusterPoints } = this.state; + + if (clusterPoints.length <= clusterThreshold) { + return ( +
+ {children} +
+ ); + } + + return ( +
+ {// tslint:disable-line:jsx-no-multiline-js + clusterPoints.map(({ geometry, properties }: Feature) => ( + ClusterMarkerFactory(geometry.coordinates, properties.point_count)) + )} +
+ ); + } +} diff --git a/src/constants/css.js b/src/constants/css.ts similarity index 99% rename from src/constants/css.js rename to src/constants/css.ts index dd40c6721..121cdcdaa 100644 --- a/src/constants/css.js +++ b/src/constants/css.ts @@ -1,3 +1,5 @@ +// tslint:disable:max-line-length + export default ` .mapboxgl-map { font: 12px/20px 'Helvetica Neue', Arial, Helvetica, sans-serif; diff --git a/src/feature.js b/src/feature.js deleted file mode 100644 index b9f3260ea..000000000 --- a/src/feature.js +++ /dev/null @@ -1,17 +0,0 @@ -import React, { PropTypes } from 'react'; - -class Feature extends React.PureComponent { - render() { - return null; - } -} - -Feature.propTypes = { - coordinates: PropTypes.array.isRequired, - onClick: PropTypes.func, - onHover: PropTypes.func, - onEndHover: PropTypes.func, - properties: PropTypes.object, -}; - -export default Feature; diff --git a/src/feature.ts b/src/feature.ts new file mode 100644 index 000000000..d0ec29d54 --- /dev/null +++ b/src/feature.ts @@ -0,0 +1,18 @@ +import { Component } from 'react'; +import * as GeoJSON from 'geojson'; + +export interface Props { + coordinates: GeoJSON.Position; + properties: any; + onClick?: Function; + onHover?: Function; + onEndHover?: Function; +} + +class Feature extends Component { + public render() { + return null; + } +} + +export default Feature; diff --git a/src/geojson-layer.js b/src/geojson-layer.js deleted file mode 100644 index c85d33124..000000000 --- a/src/geojson-layer.js +++ /dev/null @@ -1,130 +0,0 @@ -import React, { PropTypes } from 'react'; -import isEqual from 'deep-equal'; -import diff from './util/diff'; - -let index = 0; -const generateID = () => { - const newId = index + 1; - index = newId; - return index; -}; - -export default class GeoJSONLayer extends React.PureComponent { - static contextTypes = { - map: PropTypes.object, - }; - - static propTypes = { - id: PropTypes.string, - - data: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.object, - ]).isRequired, - - lineLayout: PropTypes.object, - symbolLayout: PropTypes.object, - circleLayout: PropTypes.object, - fillLayout: PropTypes.object, - - linePaint: PropTypes.object, - symbolPaint: PropTypes.object, - circlePaint: PropTypes.object, - fillPaint: PropTypes.object, - - sourceOptions: PropTypes.string, - before: PropTypes.string, - }; - - id = this.props.id || `geojson-${generateID()}`; - - source = { - type: 'geojson', - ...this.props.sourceOptions, - data: this.props.data, - }; - - layerIds = []; - - createLayer = (type) => { - const { id, layerIds } = this; - const { before } = this.props; - const { map } = this.context; - - const layerId = `${id}-${type}`; - layerIds.push(layerId); - - const paint = this.props[`${type}Paint`] || {}; - const layout = this.props[`${type}Layout`] || {}; - - map.addLayer({ - id: layerId, - source: id, - type, - paint, - layout, - }, before); - }; - - componentWillMount() { - const { id, source } = this; - const { map } = this.context; - - map.addSource(id, source); - - this.createLayer('symbol'); - this.createLayer('line'); - this.createLayer('fill'); - this.createLayer('circle'); - } - - componentWillUnmount() { - const { id, layerIds } = this; - const { map } = this.context; - - map.removeSource(id); - - layerIds.forEach(key => map.removeLayer(key)); - } - - componentWillReceiveProps(props) { - const { id } = this; - const { data, paint, layout } = this.props; - const { map } = this.context; - - if (!isEqual(props.paint, paint)) { - const paintDiff = diff(paint, props.paint); - - Object.keys(paintDiff).forEach((key) => { - map.setPaintProperty(this.id, key, paintDiff[key]); - }); - } - - if (!isEqual(props.layout, layout)) { - const layoutDiff = diff(layout, props.layout); - - Object.keys(layoutDiff).forEach((key) => { - map.setLayoutProperty(this.id, key, layoutDiff[key]); - }); - } - - if (props.data !== data) { - map - .getSource(id) - .setData(props.data); - } - } - - shouldComponentUpdate(nextProps) { - return ( - !isEqual(nextProps.paint, this.props.paint) || - !isEqual(nextProps.layout, this.props.layout) || - nextProps.data !== this.props.data - ); - } - - render() { - return null; - } -} - diff --git a/src/geojson-layer.ts b/src/geojson-layer.ts new file mode 100644 index 000000000..140eea63f --- /dev/null +++ b/src/geojson-layer.ts @@ -0,0 +1,99 @@ +import * as React from 'react'; +import * as MapboxGL from 'mapbox-gl/dist/mapbox-gl'; +import { generateID } from './util/uid'; +import { Sources, SourceOptionData } from './util/types'; + +export interface Props { + id?: string; + data: SourceOptionData; + sourceOptions: MapboxGL.VectorSource | MapboxGL.RasterSource | MapboxGL.GeoJSONSource | MapboxGL.GeoJSONSourceRaw; + before?: string; + fillLayout?: MapboxGL.FillLayout; + symbolLayout?: MapboxGL.SymbolLayout; + circleLayout?: MapboxGL.CircleLayout; + lineLayout?: MapboxGL.LineLayout; + linePaint?: MapboxGL.LinePaint; + symbolPaint?: MapboxGL.SymbolPaint; + circlePaint?: MapboxGL.CirclePaint; + fillPaint?: MapboxGL.FillPaint; +} + +type Paints = MapboxGL.LinePaint | MapboxGL.SymbolPaint | MapboxGL.CirclePaint | MapboxGL.FillPaint; +type Layouts = MapboxGL.FillLayout | MapboxGL.LineLayout | MapboxGL.CircleLayout | MapboxGL.SymbolLayout; + +export interface Context { + map: MapboxGL.Map; +} + +export default class GeoJSONLayer extends React.Component { + public context: Context; + + public static contextTypes = { + map: React.PropTypes.object + }; + + private id: string = this.props.id || `geojson-${generateID()}`; + + private source: Sources = { + type: 'geojson', + ...this.props.sourceOptions, + data: this.props.data + }; + + private layerIds: string[] = []; + + private createLayer = (type: string) => { + const { id, layerIds } = this; + const { before } = this.props; + const { map } = this.context; + + const layerId = `${id}-${type}`; + layerIds.push(layerId); + + const paint: Paints = this.props[`${type}Paint`] || {}; + const layout: Layouts = this.props[`${type}Layout`] || {}; + + map.addLayer({ + id: layerId, + source: id, + type, + paint, + layout + }, before); + } + + public componentWillMount() { + const { id, source } = this; + const { map } = this.context; + + map.addSource(id, source); + + this.createLayer('symbol'); + this.createLayer('line'); + this.createLayer('fill'); + this.createLayer('circle'); + } + + public componentWillUnmount() { + const { id, layerIds } = this; + const { map } = this.context; + + map.removeSource(id); + + layerIds.forEach((lId) => map.removeLayer(lId)); + } + + public componentWillReceiveProps(props: Props) { + const { id } = this; + const { data } = this.props; + const { map } = this.context; + + if (props.data !== data) { + (map.getSource(id) as MapboxGL.GeoJSONSource).setData(props.data); + } + } + + public render() { + return null; + } +} diff --git a/src/index.js b/src/index.ts similarity index 98% rename from src/index.js rename to src/index.ts index 908ec87ee..2e46f5082 100644 --- a/src/index.js +++ b/src/index.ts @@ -23,8 +23,7 @@ export { ScaleControl, Marker, Source, - Cluster, + Cluster }; export default Map; - diff --git a/src/layer.js b/src/layer.js deleted file mode 100644 index bba78350c..000000000 --- a/src/layer.js +++ /dev/null @@ -1,223 +0,0 @@ -import React, { PropTypes } from 'react'; -import isEqual from 'deep-equal'; -import diff from './util/diff'; - -let index = 0; -const generateID = () => { - const newId = index + 1; - index = newId; - return index; -}; - -export default class Layer extends React.PureComponent { - static contextTypes = { - map: PropTypes.object, - }; - - static propTypes = { - id: PropTypes.string, - - type: PropTypes.oneOf([ - 'symbol', - 'line', - 'fill', - 'circle', - 'raster', - ]), - - layout: PropTypes.object, - paint: PropTypes.object, - sourceOptions: PropTypes.object, - layerOptions: PropTypes.object, - sourceId: PropTypes.string, - before: PropTypes.string, - }; - - static defaultProps = { - type: 'symbol', - layout: {}, - paint: {}, - }; - - hover = []; - - id = this.props.id || `layer-${generateID()}`; - - source = { - type: 'geojson', - ...this.props.sourceOptions, - data: { - type: 'FeatureCollection', - features: [], - }, - }; - - geometry = (coordinates) => { - switch (this.props.type) { - case 'symbol': - case 'circle': return { - type: 'Point', - coordinates, - }; - - case 'fill': return { - type: coordinates.length > 1 ? 'MultiPolygon' : 'Polygon', - coordinates, - }; - - case 'line': return { - type: 'LineString', - coordinates, - }; - - default: return null; - } - }; - - feature = (props, id) => ({ - type: 'Feature', - geometry: this.geometry(props.coordinates), - properties: { - ...props.properties, - id, - }, - }) - - onClick = (evt) => { - const children = [].concat(this.props.children); - const { map } = this.context; - const { id } = this; - const features = map.queryRenderedFeatures(evt.point, { layers: [id] }); - - features.forEach((feature) => { - const { properties } = feature; - const child = children[properties.id]; - - const onClick = child && child.props.onClick; - if (onClick) { - onClick({ ...evt, feature, map }); - } - }); - }; - - onMouseMove = (evt) => { - const children = [].concat(this.props.children); - const { map } = this.context; - const { id } = this; - - const oldHover = this.hover; - const hover = []; - - const features = map.queryRenderedFeatures(evt.point, { layers: [id] }); - - features.forEach((feature) => { - const { properties } = feature; - const child = children[properties.id]; - hover.push(properties.id); - - const onHover = child && child.props.onHover; - if (onHover) { - onHover({ ...evt, feature, map }); - } - }); - - oldHover - .filter(prevHoverId => hover.indexOf(prevHoverId) === -1) - .forEach((key) => { - const onEndHover = children[key] && children[key].props.onEndHover; - if (onEndHover) { - onEndHover({ ...evt, map }); - } - }); - - this.hover = hover; - }; - - componentWillMount() { - const { id, source } = this; - const { type, layout, paint, layerOptions, sourceId, before } = this.props; - const { map } = this.context; - - const layer = { - id, - source: sourceId || id, - type, - layout, - paint, - ...layerOptions, - }; - - if (!sourceId) { - map.addSource(id, source); - } - - map.addLayer(layer, before); - - map.on('click', this.onClick); - map.on('mousemove', this.onMouseMove); - } - - componentWillUnmount() { - const { id } = this; - - const { map } = this.context; - - map.removeLayer(id); - // if pointing to an existing source, don't remove - // as other layers may be dependent upon it - if (!this.props.sourceId) { - map.removeSource(id); - } - - map.off('click', this.onClick); - map.off('mousemove', this.onMouseMove); - } - - componentWillReceiveProps(props) { - const { paint, layout } = this.props; - const { map } = this.context; - - if (!isEqual(props.paint, paint)) { - const paintDiff = diff(paint, props.paint); - - Object.keys(paintDiff).forEach((key) => { - map.setPaintProperty(this.id, key, paintDiff[key]); - }); - } - - if (!isEqual(props.layout, layout)) { - const layoutDiff = diff(layout, props.layout); - - Object.keys(layoutDiff).forEach((key) => { - map.setLayoutProperty(this.id, key, layoutDiff[key]); - }); - } - } - - shouldComponentUpdate(nextProps) { - return !isEqual(nextProps.children, this.props.children) - || !isEqual(nextProps.paint, this.props.paint) - || !isEqual(nextProps.layout, this.props.layout); - } - - render() { - const { map } = this.context; - - if (this.props.children) { - const children = [].concat(this.props.children); - - const features = children - .map(({ props }, id) => this.feature(props, id)) - .filter(Boolean); - - const source = map.getSource(this.props.sourceId || this.id); - source.setData({ - type: 'FeatureCollection', - features, - }); - } - - return null; - } -} - diff --git a/src/layer.ts b/src/layer.ts new file mode 100644 index 000000000..cf39197c9 --- /dev/null +++ b/src/layer.ts @@ -0,0 +1,241 @@ +import * as React from 'react'; +import * as MapboxGL from 'mapbox-gl'; +const isEqual = require('deep-equal'); //tslint:disable-line +import diff from './util/diff'; +import * as GeoJSON from 'geojson'; +import { generateID } from './util/uid'; +import { Sources } from './util/types'; +import { Feature } from './util/types'; + +export type Paint = ( + MapboxGL.BackgroundPaint | + MapboxGL.FillPaint | + MapboxGL.FillExtrusionPaint | + MapboxGL.LinePaint | + MapboxGL.SymbolPaint | + MapboxGL.RasterPaint | + MapboxGL.CirclePaint +); + +export type Layout = ( + MapboxGL.BackgroundLayout | + MapboxGL.FillLayout | + MapboxGL.FillExtrusionLayout | + MapboxGL.LineLayout | + MapboxGL.SymbolLayout | + MapboxGL.RasterLayout | + MapboxGL.CircleLayout +); + +export interface Props { + id?: string; + type?: 'symbol' | 'line' | 'fill' | 'circle' | 'raster'; + sourceId?: string; + before?: string; + sourceOptions?: Sources; + paint?: Paint; + layout?: Layout; + layerOptions?: MapboxGL.Layer; + children?: JSX.Element; +} + +export interface Context { + map: MapboxGL.Map; +} + +export default class Layer extends React.PureComponent { + public context: Context; + + public static contextTypes = { + map: React.PropTypes.object + }; + + public static defaultProps = { + type: 'symbol', + layout: {}, + paint: {} + }; + + private hover: string[] = []; + + private id: string = this.props.id || `layer-${generateID()}`; + + private source: Sources = { + type: 'geojson', + ...this.props.sourceOptions, + data: { + type: 'FeatureCollection', + features: [] + } + }; + + private geometry = (coordinates: GeoJSON.Position) => { + switch (this.props.type) { + case 'symbol': + case 'circle': return { + type: 'Point', + coordinates + }; + + case 'fill': return { + type: coordinates.length > 1 ? 'MultiPolygon' : 'Polygon', + coordinates + }; + + case 'line': return { + type: 'LineString', + coordinates + }; + + default: return { + type: 'Point', + coordinates + }; + } + } + + private makeFeature = (props: any, id: string): Feature => ({ + type: 'Feature', + geometry: this.geometry(props.coordinates), + properties: { ...props.properties, id } + }) + + private onClick = (evt: any) => { + const children = ([] as any).concat(this.props.children); + const { map } = this.context; + const features = map.queryRenderedFeatures(evt.point, { layers: [this.id] }) as Feature[]; + + features.forEach((feature) => { + const { id } = feature.properties; + if (children && id) { + const child = children[id]; + + const onClick = child && child.props.onClick; + if (onClick) { + onClick({ ...evt, feature, map }); + } + } + }); + } + + private onMouseMove = (evt: any) => { + const children = ([] as any).concat(this.props.children); + const { map } = this.context; + + const oldHover = this.hover; + const hover: string[] = []; + + const features = map.queryRenderedFeatures(evt.point, { layers: [this.id] }) as Feature[]; + + features.forEach((feature) => { + const { id } = feature.properties; + if (children && id) { + const child = children[id]; + hover.push(id); + + const onHover = child && child.props.onHover; + if (onHover) { + onHover({ ...evt, feature, map }); + } + } + }); + + oldHover + .filter((prevHoverId) => hover.indexOf(prevHoverId) === -1) + .forEach((key) => { + const onEndHover = children[key] && children[key].props.onEndHover; + if (onEndHover) { + onEndHover({ ...evt, map }); + } + }); + + this.hover = hover; + } + + public componentWillMount() { + const { id, source } = this; + const { type, layout, paint, layerOptions, sourceId, before } = this.props; + const { map } = this.context; + + const layer = { + id, + source: sourceId || id, + type, + layout, + paint, + ...layerOptions + }; + + if (!sourceId) { + map.addSource(id, source); + } + + map.addLayer(layer, before); + + map.on('click', this.onClick); + map.on('mousemove', this.onMouseMove); + } + + public componentWillUnmount() { + const { id } = this; + + const { map } = this.context; + + map.removeLayer(id); + // if pointing to an existing source, don't remove + // as other layers may be dependent upon it + if (!this.props.sourceId) { + map.removeSource(id); + } + + map.off('click', this.onClick); + map.off('mousemove', this.onMouseMove); + } + + public componentWillReceiveProps(props: Props) { + const { paint, layout } = this.props; + const { map } = this.context; + + if (!isEqual(props.paint, paint)) { + const paintDiff = diff(paint, props.paint); + + Object.keys(paintDiff).forEach((key) => { + map.setPaintProperty(this.id, key, paintDiff[key]); + }); + } + + if (!isEqual(props.layout, layout)) { + const layoutDiff = diff(layout, props.layout); + + Object.keys(layoutDiff).forEach((key) => { + map.setLayoutProperty(this.id, key, layoutDiff[key]); + }); + } + } + + public shouldComponentUpdate(nextProps: Props) { + return !isEqual(nextProps.children, this.props.children) + || !isEqual(nextProps.paint, this.props.paint) + || !isEqual(nextProps.layout, this.props.layout); + } + + public render() { + const { map } = this.context; + + if (this.props.children) { + const children = ([] as any).concat(this.props.children); + + const features = children + .map(({ props }: any, id: string) => this.makeFeature(props, id)) + .filter(Boolean); + + const source = map.getSource(this.props.sourceId || this.id); + (source as MapboxGL.GeoJSONSource).setData({ + type: 'FeatureCollection', + features + }); + } + + return null; + } +} diff --git a/src/map.js b/src/map.tsx similarity index 51% rename from src/map.js rename to src/map.tsx index 04ab820e6..561a9f32f 100644 --- a/src/map.js +++ b/src/map.tsx @@ -1,6 +1,6 @@ -import MapboxGl from 'mapbox-gl/dist/mapbox-gl.js'; -import React, { Component, PropTypes } from 'react'; -import isEqual from 'deep-equal'; +import * as MapboxGl from 'mapbox-gl/dist/mapbox-gl'; +import * as React from 'react'; +const isEqual = require('deep-equal'); //tslint:disable-line const events = { onStyleLoad: 'style.load', // Should remain first @@ -17,82 +17,102 @@ const events = { onDragEnd: 'dragend', onZoomStart: 'zoomstart', onZoom: 'zoom', - onZoomEnd: 'zoomend', + onZoomEnd: 'zoomend' }; -export default class ReactMapboxGl extends Component { - static propTypes = { - // Events propTypes - ...Object.keys(events) - .reduce((acc, event) => ( - Object.assign({}, acc, { [event]: PropTypes.func }) - ), {}), - - // Main propTypes - style: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.object, - ]).isRequired, - accessToken: PropTypes.string.isRequired, - center: PropTypes.arrayOf(PropTypes.number), - zoom: PropTypes.arrayOf(PropTypes.number), - minZoom: PropTypes.number, - maxZoom: PropTypes.number, - maxBounds: PropTypes.array, - fitBounds: PropTypes.array, - fitBoundsOptions: PropTypes.object, - bearing: PropTypes.number, - pitch: PropTypes.number, - containerStyle: PropTypes.object, - hash: PropTypes.bool, - preserveDrawingBuffer: PropTypes.bool, - scrollZoom: PropTypes.bool, - movingMethod: PropTypes.oneOf([ - 'jumpTo', - 'easeTo', - 'flyTo', - ]), - attributionPosition: PropTypes.oneOf([ - 'top-left', - 'top-right', - 'bottom-left', - 'bottom-right', - ]), - interactive: PropTypes.bool, - dragRotate: PropTypes.bool, - }; +export interface Events { + onStyleLoad: Function; + onResize: Function; + onDblClick: Function; + onClick: Function; + onMouseMove: Function; + onMoveStart: Function; + onMove: Function; + onMoveEnd: Function; + onMouseUp: Function; + onDragStart: Function; + onDragEnd: Function; + onDrag: Function; + onZoomStart: Function; + onZoom: Function; + onZoomEnd: Function; +} + +export interface FitBoundsOptions { + linear?: boolean; + easing?: Function; + padding?: number; + offset?: MapboxGl.Point | number[]; + maxZoom?: number; +} + +export interface Props { + style: string | MapboxGl.Style; + accessToken: string; + center?: number[]; + zoom?: number[]; + minZoom?: number; + maxZoom?: number; + maxBounds?: MapboxGl.LngLatBounds | number[][]; + fitBounds?: number[][]; + fitBoundsOptions?: FitBoundsOptions; + bearing?: number; + pitch?: number; + containerStyle?: React.CSSProperties; + hash?: boolean; + preserveDrawingBuffer?: boolean; + scrollZoom?: boolean; + interactive?: boolean; + dragRotate?: boolean; + movingMethod?: 'jumpTo' | 'easeTo' | 'flyTo'; + attributionControl?: boolean; + children?: JSX.Element; +} + +export interface State { + map?: MapboxGl.Map; +} - static defaultProps = { +// Satisfy typescript pitfall with defaultProps +const defaultZoom = [11]; +const defaultMovingMethod = 'flyTo'; + +export default class ReactMapboxGl extends React.Component { + public static defaultProps = { hash: false, - onStyleLoad: (...args) => args, + onStyleLoad: (...args: any[]) => args, preserveDrawingBuffer: false, center: [ -0.2416815, - 51.5285582, + 51.5285582 ], - zoom: [11], + zoom: defaultZoom, minZoom: 0, maxZoom: 20, bearing: 0, scrollZoom: true, - movingMethod: 'flyTo', + movingMethod: defaultMovingMethod, pitch: 0, attributionPosition: 'bottom-right', interactive: true, - dragRotate: true, + dragRotate: true + }; + + public static childContextTypes = { + map: React.PropTypes.object }; - static childContextTypes = { - map: React.PropTypes.object, + public state = { + map: undefined }; - state = {}; + public getChildContext = () => ({ + map: this.state.map + }) - getChildContext = () => ({ - map: this.state.map, - }); + private container: HTMLElement; - componentDidMount() { + public componentDidMount() { const { style, hash, @@ -108,17 +128,18 @@ export default class ReactMapboxGl extends Component { fitBoundsOptions, bearing, scrollZoom, - attributionPosition, + attributionControl, interactive, - dragRotate, + dragRotate } = this.props; - MapboxGl.accessToken = accessToken; + (MapboxGl as any).accessToken = accessToken; const map = new MapboxGl.Map({ preserveDrawingBuffer, hash, - zoom: zoom[0], + // Duplicated default because Typescript can't figure out there is a defaultProps and zoom will never be undefined + zoom: zoom ? zoom[0] : defaultZoom[0], minZoom, maxZoom, maxBounds, @@ -128,11 +149,9 @@ export default class ReactMapboxGl extends Component { pitch, style, scrollZoom, - attributionControl: { - position: attributionPosition, - }, + attributionControl, interactive, - dragRotate, + dragRotate }); if (fitBounds) { @@ -143,7 +162,7 @@ export default class ReactMapboxGl extends Component { const propEvent = this.props[event]; if (propEvent) { - map.on(events[event], (...args) => { + map.on(events[event], (...args: any[]) => { propEvent(map, ...args); if (index === 0) { @@ -154,8 +173,8 @@ export default class ReactMapboxGl extends Component { }); } - componentWillUnmount() { - const { map } = this.state; + public componentWillUnmount() { + const { map } = this.state as State; if (map) { // Remove all events attached to the map @@ -168,7 +187,7 @@ export default class ReactMapboxGl extends Component { } } - shouldComponentUpdate(nextProps, nextState) { + public shouldComponentUpdate(nextProps: Props, nextState: State) { return ( nextProps.children !== this.props.children || nextProps.containerStyle !== this.props.containerStyle || @@ -178,8 +197,8 @@ export default class ReactMapboxGl extends Component { ); } - componentWillReceiveProps(nextProps) { - const { map } = this.state; + public componentWillReceiveProps(nextProps: Props) { + const { map } = this.state as State; if (!map) { return null; } @@ -191,12 +210,15 @@ export default class ReactMapboxGl extends Component { const didZoomUpdate = ( this.props.zoom !== nextProps.zoom && - nextProps.zoom[0] !== zoom + (nextProps.zoom && nextProps.zoom[0]) !== zoom ); const didCenterUpdate = ( this.props.center !== nextProps.center && - (nextProps.center[0] !== center.lng || nextProps.center[1] !== center.lat) + ( + (nextProps.center && nextProps.center[0]) !== center.lng || + (nextProps.center && nextProps.center[1]) !== center.lat + ) ); const didBearingUpdate = ( @@ -215,10 +237,10 @@ export default class ReactMapboxGl extends Component { const didFitBoundsUpdate = ( fitBounds !== nextProps.fitBounds || // Check for reference equality nextProps.fitBounds.length !== (fitBounds && fitBounds.length) || // Added element - !!fitBounds.find((c, i) => { // Check for equality - const nc = nextProps.fitBounds[i]; - return c[0] !== nc[0] || c[1] !== nc[1]; - }) + !!fitBounds.filter((c, i) => { // Check for equality + const nc = nextProps.fitBounds && nextProps.fitBounds[i]; + return c[0] !== (nc && nc[0]) || c[1] !== (nc && nc[1]); + })[0] ); if (didFitBoundsUpdate) { @@ -227,11 +249,12 @@ export default class ReactMapboxGl extends Component { } if (didZoomUpdate || didCenterUpdate || didBearingUpdate || didPitchUpdate) { - map[this.props.movingMethod]({ - zoom: didZoomUpdate ? nextProps.zoom[0] : zoom, + const mm: string = this.props.movingMethod || defaultMovingMethod; + map[mm]({ + zoom: (didZoomUpdate && nextProps.zoom) ? nextProps.zoom[0] : zoom, center: didCenterUpdate ? nextProps.center : center, bearing: didBearingUpdate ? nextProps.bearing : bearing, - pitch: didPitchUpdate ? nextProps.pitch : pitch, + pitch: didPitchUpdate ? nextProps.pitch : pitch }); } @@ -242,15 +265,17 @@ export default class ReactMapboxGl extends Component { return null; } - render() { + private setRef = (x: HTMLElement) => { + this.container = x; + } + + public render() { const { containerStyle, children } = this.props; const { map } = this.state; return ( -
{ this.container = x; }} style={containerStyle}> - { - map && children - } +
+ {map && children}
); } diff --git a/src/marker.js b/src/marker.js deleted file mode 100644 index 95d43f194..000000000 --- a/src/marker.js +++ /dev/null @@ -1,30 +0,0 @@ -import React, { PropTypes } from 'react'; -import ProjectedLayer from './projected-layer'; -import { - OverlayPropTypes, -} from './util/overlays'; - -const propsToRemove = { children: undefined }; - -export default class Marker extends React.Component { - static propTypes = { - coordinates: PropTypes.arrayOf(PropTypes.number).isRequired, - anchor: OverlayPropTypes.anchor, - offset: OverlayPropTypes.offset, - children: PropTypes.node, - style: PropTypes.object, - }; - - render() { - const { children } = this.props; - const nestedProps = Object.assign({}, this.props, propsToRemove); - - return ( - - { children } - - ); - } -} diff --git a/src/marker.tsx b/src/marker.tsx new file mode 100644 index 000000000..f86f1b89b --- /dev/null +++ b/src/marker.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import ProjectedLayer from './projected-layer'; +import * as GeoJSON from 'geojson'; + +export interface Props { + coordinates: GeoJSON.Position; + anchor?: any; + offset?: any; + children?: JSX.Element; + onClick?: React.MouseEventHandler; + onMouseEnter?: React.MouseEventHandler; + onMouseLeave?: React.MouseEventHandler; + style?: React.CSSProperties; +} + +const Marker: React.StatelessComponent = (props) => ( + + {props.children} + +); + +export default Marker; diff --git a/src/popup.js b/src/popup.js deleted file mode 100644 index a2a6eef2c..000000000 --- a/src/popup.js +++ /dev/null @@ -1,40 +0,0 @@ -import React, { PropTypes } from 'react'; -import ProjectedLayer from './projected-layer'; -import { - anchors, - OverlayPropTypes, -} from './util/overlays'; - -export default class Popup extends React.Component { - static propTypes = { - coordinates: PropTypes.arrayOf(PropTypes.number).isRequired, - anchor: OverlayPropTypes.anchor, - offset: OverlayPropTypes.offset, - children: PropTypes.node, - onClick: PropTypes.func, - style: PropTypes.object, - }; - - static defaultProps = { - anchor: anchors[0], - }; - - render() { - const { coordinates, anchor, offset, onClick, children, style } = this.props; - - return ( - -
-
- { children } -
-
- ); - } -} diff --git a/src/popup.tsx b/src/popup.tsx new file mode 100644 index 000000000..0079a29dc --- /dev/null +++ b/src/popup.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import ProjectedLayer from './projected-layer'; +import { + anchors +} from './util/overlays'; +import * as GeoJSON from 'geojson'; + +export interface Props { + coordinates: GeoJSON.Position; + anchor?: any; + offset?: any; + children?: JSX.Element; + onClick?: React.MouseEventHandler; + onMouseEnter?: React.MouseEventHandler; + onMouseLeave?: React.MouseEventHandler; + style?: React.CSSProperties; +} + +export default class Popup extends React.Component { + public static defaultProps = { + anchor: anchors[0] + }; + + public render() { + return ( + +
+
+ {this.props.children} +
+ + ); + } +} diff --git a/src/projected-layer.js b/src/projected-layer.js deleted file mode 100644 index 3ff04d420..000000000 --- a/src/projected-layer.js +++ /dev/null @@ -1,105 +0,0 @@ -import React, { PropTypes } from 'react'; -import { - OverlayPropTypes, - overlayState, - overlayTransform, - anchors, -} from './util/overlays'; - -const defaultStyle = { - zIndex: 3, -}; - -export default class ProjectedLayer extends React.Component { - static contextTypes = { - map: PropTypes.object, - }; - - static propTypes = { - coordinates: PropTypes.arrayOf(PropTypes.number).isRequired, - anchor: OverlayPropTypes.anchor, - offset: OverlayPropTypes.offset, - children: PropTypes.node, - onClick: PropTypes.func, - onMouseEnter: PropTypes.func, - onMouseLeave: PropTypes.func, - style: PropTypes.object, - }; - - static defaultProps = { - anchor: anchors[0], - offset: 0, - onClick: (...args) => args, - }; - - state = {}; - - setContainer = (el) => { - if (el) { - this.container = el; - } - }; - - handleMapMove = () => { - if (!this.prevent) { - this.setState(overlayState(this.props, this.context.map, this.container)); - } - }; - - componentDidMount() { - const { map } = this.context; - - map.on('move', this.handleMapMove); - // Now this.container is rendered and the size of container is known. - // Recalculate the anchor/position - this.handleMapMove(); - } - - componentWillReceiveProps(nextProps) { - const { coordinates } = this.props; - - if ( - coordinates[0] !== nextProps.coordinates[0] - || coordinates[1] !== nextProps.coordinates[1] - ) { - this.setState(overlayState(nextProps, this.context.map, this.container)); - } - } - - componentWillUnmount() { - const { map } = this.context; - - this.prevent = true; - - map.off('move', this.handleMapMove); - } - - render() { - const { - style, - children, - className, - onClick, - onMouseEnter, - onMouseLeave, - } = this.props; - - const finalStyle = { - ...defaultStyle, - ...style, - transform: overlayTransform(this.state).join(' '), - }; - - return ( -
- { children } -
- ); - } -} diff --git a/src/projected-layer.tsx b/src/projected-layer.tsx new file mode 100644 index 000000000..26e7ce935 --- /dev/null +++ b/src/projected-layer.tsx @@ -0,0 +1,118 @@ +import * as React from 'react'; +import { Map } from 'mapbox-gl'; +import { Anchor, PointDef, OverlayProps } from './util/overlays'; +import * as GeoJSON from 'geojson'; + +import { + overlayState, + overlayTransform, + anchors +} from './util/overlays'; + +const defaultStyle = { + zIndex: 3 +}; + +export interface Props { + coordinates: GeoJSON.Position; + anchor?: Anchor; + offset?: number | number[] | PointDef; + children?: JSX.Element; + onClick?: React.MouseEventHandler; + onMouseEnter?: React.MouseEventHandler; + onMouseLeave?: React.MouseEventHandler; + style?: React.CSSProperties; + className: string; +} + +export interface Context { + map: Map; +} + +export default class ProjectedLayer extends React.Component { + public context: Context; + private container: HTMLElement; + private prevent: boolean; + + public static contextTypes = { + map: React.PropTypes.object + }; + + public static defaultProps = { + anchor: anchors[0], + offset: 0, + onClick: (...args: any[]) => args + }; + + public state: OverlayProps = {}; + + private setContainer = (el: HTMLElement) => { + if (el) { + this.container = el; + } + } + + private handleMapMove = () => { + if (!this.prevent) { + this.setState(overlayState(this.props, this.context.map, this.container)); + } + } + + public componentDidMount() { + const { map } = this.context; + + map.on('move', this.handleMapMove); + // Now this.container is rendered and the size of container is known. + // Recalculate the anchor/position + this.handleMapMove(); + } + + public componentWillReceiveProps(nextProps: Props) { + const { coordinates } = this.props; + + if ( + coordinates[0] !== nextProps.coordinates[0] + || coordinates[1] !== nextProps.coordinates[1] + ) { + this.setState(overlayState(nextProps, this.context.map, this.container)); + } + } + + public componentWillUnmount() { + const { map } = this.context; + + this.prevent = true; + + map.off('move', this.handleMapMove); + } + + public render() { + const { + style, + children, + className, + onClick, + onMouseEnter, + onMouseLeave + } = this.props; + + const finalStyle = { + ...defaultStyle, + ...style, + transform: overlayTransform(this.state as OverlayProps).join(' ') + }; + + return ( +
+ {children} +
+ ); + } +} diff --git a/src/scale-control.js b/src/scale-control.tsx similarity index 59% rename from src/scale-control.js rename to src/scale-control.tsx index f7f42d400..62259b2f4 100644 --- a/src/scale-control.js +++ b/src/scale-control.tsx @@ -1,4 +1,5 @@ -import React, { Component, PropTypes } from 'react'; +import * as React from 'react'; +import { Map } from 'mapbox-gl'; const scales = [ 0.01, 0.02, 0.05, @@ -7,14 +8,14 @@ const scales = [ 10, 20, 50, 100, 200, 500, 1000, 2 * 1000, 5 * 1000, - 10 * 1000, + 10 * 1000 ]; const positions = { topRight: { top: 10, right: 10, bottom: 'auto', left: 'auto' }, topLeft: { top: 10, left: 10, bottom: 'auto', right: 'auto' }, bottomRight: { bottom: 10, right: 10, top: 'auto', left: 'auto' }, - bottomLeft: { bottom: 10, left: 10, top: 'auto', right: 'auto' }, + bottomLeft: { bottom: 10, left: 10, top: 'auto', right: 'auto' } }; const containerStyle = { @@ -28,7 +29,7 @@ const containerStyle = { display: 'flex', flexDirection: 'row', alignItems: 'baseline', - padding: '3px 7px', + padding: '3px 7px' }; const scaleStyle = { @@ -37,7 +38,7 @@ const scaleStyle = { borderTop: 'none', height: 7, borderBottomLeftRadius: 1, - borderBottomRightRadius: 1, + borderBottomRightRadius: 1 }; const POSITIONS = Object.keys(positions); @@ -50,47 +51,59 @@ const KILOMETER_IN_METERS = 1000; const MIN_WIDTH_SCALE = 40; -export default class ScaleControl extends Component { - static contextTypes = { - map: PropTypes.object, - }; +export type Measurement = 'km' | 'mi'; +export type Position = 'topRight' | 'topLeft' | 'bottomRight' | 'bottomLeft'; + +export interface Props { + measurement: Measurement; + position: Position; + style: React.CSSProperties; +} + +export interface State { + chosenScale: number; + scaleWidth: number; +} - static propTypes = { - measurement: PropTypes.oneOf(MEASUREMENTS), - style: PropTypes.object, - position: PropTypes.string, +export interface Context { + map: Map; +} + +export default class ScaleControl extends React.Component { + public context: Context; + + public static contextTypes = { + map: React.PropTypes.object }; - static defaultProps = { + public static defaultProps = { measurement: MEASUREMENTS[0], - position: POSITIONS[2], + position: POSITIONS[2] }; - state = { - chosenScale: false, - scaleWidth: MIN_WIDTH_SCALE, + public state = { + chosenScale: 0, + scaleWidth: MIN_WIDTH_SCALE }; - componentWillMount() { - const { map } = this.context; - this.setScale(map); + public componentWillMount() { + this.setScale(); - map.on('zoomend', () => { - this.setScale(map); - }); + this.context.map.on('zoomend', this.setScale); } - componentWillUnmount() { - if (this.state.map) { - this.state.map.off(); + public componentWillUnmount() { + if (this.context.map) { + this.context.map.off('zoomend', this.setScale); } } - setScale = (map) => { + private setScale = () => { + const { map } = this.context; const { measurement } = this.props; - const clientWidth = map._canvas.clientWidth; - const { _ne, _sw } = map.getBounds(); + const clientWidth = (map as any)._canvas.clientWidth; + const { _ne, _sw } = map.getBounds() as any; const totalWidth = this._getDistanceTwoPoints( [_sw.lng, _ne.lat], @@ -100,16 +113,23 @@ export default class ScaleControl extends Component { const relativeWidth = totalWidth / clientWidth * MIN_WIDTH_SCALE; - const chosenScale = scales.reduce((acc, curr) => acc || (curr > relativeWidth && curr), 0); - const scaleWidth = chosenScale / totalWidth * map._canvas.width; + const chosenScale = scales.reduce((acc, curr) => { + if (!acc && curr > relativeWidth) { + return curr; + } + + return acc; + }, 0); + + const scaleWidth = chosenScale / totalWidth * (map as any)._canvas.width; this.setState({ chosenScale, - scaleWidth, + scaleWidth }); - }; + } - _getDistanceTwoPoints(x, y, measurement = 'km') { + private _getDistanceTwoPoints(x: number[], y: number[], measurement = 'km') { const [lng1, lat1] = x; const [lng2, lat2] = y; @@ -129,11 +149,11 @@ export default class ScaleControl extends Component { return d; } - _deg2rad(deg) { + private _deg2rad(deg: number) { return deg * (Math.PI / 180); } - _displayMeasurement(measurement, chosenScale) { + private _displayMeasurement(measurement: Measurement, chosenScale: number) { if (chosenScale >= 1) { return `${chosenScale} ${measurement}`; } @@ -145,23 +165,15 @@ export default class ScaleControl extends Component { return `${Math.floor(chosenScale * KILOMETER_IN_METERS)} m`; } - render() { + public render() { const { measurement, style, position } = this.props; const { chosenScale, scaleWidth } = this.state; return ( -
+
-
- + style={{ ...scaleStyle, width: scaleWidth }} + />
{this._displayMeasurement(measurement, chosenScale)}
diff --git a/src/source.js b/src/source.js deleted file mode 100644 index 0c1b6d048..000000000 --- a/src/source.js +++ /dev/null @@ -1,54 +0,0 @@ -import React, { PropTypes } from 'react'; - -export default class Source extends React.Component { - - static contextTypes = { - map: PropTypes.object, - }; - - static propTypes = { - id: PropTypes.string.isRequired, - sourceOptions: PropTypes.object, - }; - - id = this.props.id; - - source = { - ...this.props.sourceOptions, - }; - - componentWillMount() { - const { map } = this.context; - if (!map.getSource(this.id)) { - map.addSource(this.id, this.source); - } - } - - componentWillUnmount() { - const { map } = this.context; - if (map.getSource(this.id)) { - map.removeSource(this.id); - } - } - - componentWillReceiveProps(props) { - const { id } = this; - const { sourceOptions } = this.props; - const { map } = this.context; - - if (props.sourceOptions.data !== sourceOptions.data) { - map - .getSource(id) - .setData(props.sourceOptions.data); - } - } - - shouldComponentUpdate(nextProps) { - return nextProps.sourceOptions.data !== this.props.sourceOptions.data; - } - - render() { - return null; - } - -} diff --git a/src/source.ts b/src/source.ts new file mode 100644 index 000000000..04487568e --- /dev/null +++ b/src/source.ts @@ -0,0 +1,62 @@ +import * as React from 'react'; +import { + Map, + GeoJSONSource, + GeoJSONSourceRaw +} from 'mapbox-gl/dist/mapbox-gl'; +import { SourceOptionData } from './util/types'; +export interface Context { + map: Map; +} + +export type Sources = GeoJSONSourceRaw; + +export interface Props { + id: string; + sourceOptions: Sources; +} + +export default class Source extends React.Component { + public context: Context; + + public static contextTypes = { + map: React.PropTypes.object + }; + + private id = this.props.id; + + public componentWillMount() { + const { map } = this.context; + if (!map.getSource(this.id)) { + map.addSource(this.id, this.props.sourceOptions); + } + } + + public componentWillUnmount() { + const { map } = this.context; + if (map.getSource(this.id)) { + map.removeSource(this.id); + } + } + + public componentWillReceiveProps(props: Props) { + const { id } = this; + const { sourceOptions } = this.props; + const { map } = this.context; + + if (props.sourceOptions.data !== sourceOptions.data) { + const source = map.getSource(id) as GeoJSONSource; + const data = props.sourceOptions.data as SourceOptionData; + source.setData(data); + } + } + + public shouldComponentUpdate(nextProps: Props) { + return nextProps.sourceOptions.data !== this.props.sourceOptions.data; + } + + public render() { + return null; + } + +} diff --git a/src/util/diff.js b/src/util/diff.js deleted file mode 100644 index 3058203ea..000000000 --- a/src/util/diff.js +++ /dev/null @@ -1,17 +0,0 @@ -import reduce from 'reduce-object'; - -const find = (obj, predicate) => ( - Object.keys(obj).find(key => predicate(obj[key], key)) -); - -const diff = (obj1, obj2) => ( - reduce(obj2, (res, value, key) => { - const toMutate = res; - if (find(obj1, (v, k) => key === k && value !== v)) { - toMutate[key] = value; - } - return toMutate; - }, {}) -); - -export default diff; diff --git a/src/util/diff.ts b/src/util/diff.ts new file mode 100644 index 000000000..2272adc68 --- /dev/null +++ b/src/util/diff.ts @@ -0,0 +1,17 @@ +const reduce = require('reduce-object'); // tslint:disable-line + +const find = (obj: any, predicate: (...args: any[]) => boolean) => ( + Object.keys(obj).filter((key) => predicate(obj[key], key))[0] +); + +const diff = (obj1: any, obj2: any) => ( + reduce(obj2, (res: any, value: any, key: string) => { + const toMutate = res; + if (find(obj1, (v, k) => key === k && value !== v)) { + toMutate[key] = value; + } + return toMutate; + }, {}) +); + +export default diff; diff --git a/src/util/inject-css.js b/src/util/inject-css.ts similarity index 83% rename from src/util/inject-css.js rename to src/util/inject-css.ts index a2e79e344..0d9db549a 100644 --- a/src/util/inject-css.js +++ b/src/util/inject-css.ts @@ -1,6 +1,6 @@ import cssRules from '../constants/css'; -export default function injectCSS(window) { +const injectCSS = (window: Window) => { if (window && typeof window === 'object' && window.document) { const { document } = window; const head = (document.head || document.getElementsByTagName('head')[0]); @@ -9,4 +9,6 @@ export default function injectCSS(window) { styleElement.innerHTML = cssRules; head.appendChild(styleElement); } -} +}; + +export default injectCSS; diff --git a/src/util/overlays.js b/src/util/overlays.js deleted file mode 100644 index 1914cc96b..000000000 --- a/src/util/overlays.js +++ /dev/null @@ -1,136 +0,0 @@ -import { LngLat, Point } from 'mapbox-gl/dist/mapbox-gl.js'; -import { PropTypes } from 'react'; - -export const anchors = [ - 'center', - 'top', - 'bottom', - 'left', - 'right', - 'top-left', - 'top-right', - 'bottom-left', - 'bottom-right', -]; - -const anchorTranslates = { - center: 'translate(-50%,-50%)', - top: 'translate(-50%,0)', - left: 'translate(0,-50%)', - right: 'translate(-100%,-50%)', - bottom: 'translate(-50%,-100%)', - 'top-left': 'translate(0,0)', - 'top-right': 'translate(-100%,0)', - 'bottom-left': 'translate(0,-100%)', - 'bottom-right': 'translate(-100%,-100%)', -}; - -const defaultElement = { offsetWidth: 0, offsetHeight: 0 }; - -const isPointLike = input => (input instanceof Point || Array.isArray(input)); - -const projectCoordinates = (map, coordinates) => map.project(LngLat.convert(coordinates)); - -const calculateAnchor = (map, offsets, position, { offsetHeight, offsetWidth }) => { - let anchor = null; - - if (position.y + offsets.bottom.y - offsetHeight < 0) { - anchor = [anchors[1]]; - } else if (position.y + offsets.top.y + offsetHeight > map.transform.height) { - anchor = [anchors[2]]; - } else { - anchor = []; - } - - if (position.x < offsetWidth / 2) { - anchor.push(anchors[3]); - } else if (position.x > map.transform.width - offsetWidth / 2) { - anchor.push(anchors[4]); - } - - if (anchor.length === 0) { - anchor = anchors[2]; - } else { - anchor = anchor.join('-'); - } - return anchor; -}; - -const normalizedOffsets = (offset) => { - if (!offset) { - return normalizedOffsets(new Point(0, 0)); - } - - if (typeof offset === 'number') { - // input specifies a radius from which to calculate offsets at all positions - const cornerOffset = Math.round(Math.sqrt(0.5 * Math.pow(offset, 2))); - return { - center: new Point(offset, offset), - top: new Point(0, offset), - bottom: new Point(0, -offset), - left: new Point(offset, 0), - right: new Point(-offset, 0), - 'top-left': new Point(cornerOffset, cornerOffset), - 'top-right': new Point(-cornerOffset, cornerOffset), - 'bottom-left': new Point(cornerOffset, -cornerOffset), - 'bottom-right': new Point(-cornerOffset, -cornerOffset), - }; - } - - if (isPointLike(offset)) { - // input specifies a single offset to be applied to all positions - return anchors.reduce((res, anchor) => { - const tmp = Object.assign({}, res); - tmp[anchor] = Point.convert(offset); - return tmp; - }, {}); - } - - // input specifies an offset per position - return anchors.reduce((res, anchor) => { - const tmp = Object.assign({}, res); - tmp[anchor] = Point.convert(offset[anchor] || [0, 0]); - return tmp; - }, {}); -}; - -export const OverlayPropTypes = { - anchor: PropTypes.oneOf(anchors), - offset: PropTypes.oneOfType([ - PropTypes.number, - PropTypes.arrayOf(PropTypes.number), - PropTypes.object, - ]), -}; - -export const overlayState = (props, map, { offsetWidth, offsetHeight } = defaultElement) => { - const position = projectCoordinates(map, props.coordinates); - const offsets = normalizedOffsets(props.offset); - const anchor = props.anchor - || calculateAnchor(map, offsets, position, { offsetWidth, offsetHeight }); - - return { - anchor, - position, - offset: offsets[anchor], - }; -}; - -const moveTranslate = point => ( - point ? `translate(${point.x.toFixed(0)}px,${point.y.toFixed(0)}px)` : '' -); - -export const overlayTransform = (args) => { - const { anchor, position, offset } = args; - const res = [moveTranslate(position)]; - - if (offset && offset.x !== undefined && offset.y !== undefined) { - res.push(moveTranslate(offset)); - } - - if (anchor) { - res.push(anchorTranslates[anchor]); - } - - return res; -}; diff --git a/src/util/overlays.ts b/src/util/overlays.ts new file mode 100644 index 000000000..001f8ca87 --- /dev/null +++ b/src/util/overlays.ts @@ -0,0 +1,146 @@ +import { LngLat, Point } from 'mapbox-gl/dist/mapbox-gl'; +import * as MapboxGL from 'mapbox-gl/dist/mapbox-gl'; +import { Props } from '../projected-layer'; + +export type Anchor = ( + 'center' | 'top' | 'bottom' | 'left' | 'right' | + 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' +); + +export interface PointDef { + x: number; + y: number; +} + +export interface OverlayProps { + anchor?: Anchor; + offset?: PointDef; + position?: PointDef; +} + +export const anchors = [ + 'center', + 'top', + 'bottom', + 'left', + 'right', + 'top-left', + 'top-right', + 'bottom-left', + 'bottom-right' +]; + +const anchorTranslates = { + 'center': 'translate(-50%,-50%)', + 'top': 'translate(-50%,0)', + 'left': 'translate(0,-50%)', + 'right': 'translate(-100%,-50%)', + 'bottom': 'translate(-50%,-100%)', + 'top-left': 'translate(0,0)', + 'top-right': 'translate(-100%,0)', + 'bottom-left': 'translate(0,-100%)', + 'bottom-right': 'translate(-100%,-100%)' +}; + +// Hack /o\ +const defaultElement = { offsetWidth: 0, offsetHeight: 0 } as HTMLElement; + +const isPointLike = (input: Point | any[]): boolean => (input instanceof Point || Array.isArray(input)); + +const projectCoordinates = (map: MapboxGL.Map, coordinates: number[]) => ( + map.project(LngLat.convert(coordinates)) +); + +const calculateAnchor = ( + map: MapboxGL.Map, + offsets: any, + position: PointDef, + { offsetHeight, offsetWidth }: HTMLElement = defaultElement +) => { + let anchor: string[] = []; + + if (position.y + offsets.bottom.y - offsetHeight < 0) { + anchor = [anchors[1]]; + } else if (position.y + offsets.top.y + offsetHeight > (map as any).transform.height) { + anchor = [anchors[2]]; + } + + if (position.x < offsetWidth / 2) { + anchor.push(anchors[3]); + } else if (position.x > (map as any).transform.width - offsetWidth / 2) { + anchor.push(anchors[4]); + } + + if (anchor.length === 0) { + return anchors[2]; + } + + return anchor.join('-'); +}; + +const normalizedOffsets = (offset: any): any => { + if (!offset) { + return normalizedOffsets(new (Point as any)(0, 0)); + } + + if (typeof offset === 'number') { + // input specifies a radius from which to calculate offsets at all positions + const cornerOffset = Math.round(Math.sqrt(0.5 * Math.pow(offset, 2))); + return { + 'center': new (Point as any)(offset, offset), + 'top': new (Point as any)(0, offset), + 'bottom': new (Point as any)(0, -offset), + 'left': new (Point as any)(offset, 0), + 'right': new (Point as any)(-offset, 0), + 'top-left': new (Point as any)(cornerOffset, cornerOffset), + 'top-right': new (Point as any)(-cornerOffset, cornerOffset), + 'bottom-left': new (Point as any)(cornerOffset, -cornerOffset), + 'bottom-right': new (Point as any)(-cornerOffset, -cornerOffset) + }; + } + + if (isPointLike(offset)) { + // input specifies a single offset to be applied to all positions + return anchors.reduce((res, anchor) => ({ + ...res, + [anchor]: (Point as any).convert(offset) + }), {}); + } + + // input specifies an offset per position + return anchors.reduce((res, anchor) => ({ + ...res, + [anchor]: (Point as any).convert(offset[anchor] || [0, 0]) + }), {}); +}; + +export const overlayState = (props: Props, map: MapboxGL.Map, container: HTMLElement) => { + const position = projectCoordinates(map, props.coordinates); + const offsets = normalizedOffsets(props.offset); + const anchor = props.anchor + || calculateAnchor(map, offsets, position as any, container); + + return { + anchor, + position, + offset: offsets[anchor] + }; +}; + +const moveTranslate = (point: PointDef ) => ( + point ? `translate(${point.x.toFixed(0)}px,${point.y.toFixed(0)}px)` : '' +); + +export const overlayTransform = ({ anchor, position, offset }: OverlayProps) => { + const res = [moveTranslate(position as any)]; + + if (offset && offset.x !== undefined && offset.y !== undefined) { + res.push(moveTranslate(offset)); + } + + if (anchor) { + res.push(anchorTranslates[anchor]); + } + + return res; +}; diff --git a/src/util/types.ts b/src/util/types.ts new file mode 100644 index 000000000..0a805c81e --- /dev/null +++ b/src/util/types.ts @@ -0,0 +1,23 @@ +import * as MapboxGL from 'mapbox-gl'; + +export type Sources = ( + MapboxGL.VectorSource | + MapboxGL.RasterSource | + MapboxGL.GeoJSONSource | + MapboxGL.GeoJSONSourceRaw +); + +export type SourceOptionData = ( + GeoJSON.Feature | + GeoJSON.FeatureCollection | + string +); + +export interface Feature { + type: string; + geometry: { + type: string; + coordinates: GeoJSON.Position; + }; + properties: any; +}; diff --git a/src/util/uid.ts b/src/util/uid.ts new file mode 100644 index 000000000..640152559 --- /dev/null +++ b/src/util/uid.ts @@ -0,0 +1,5 @@ +let index = 0; + +export const generateID = () => { + return index += 1; +}; diff --git a/src/zoom-control.js b/src/zoom-control.js deleted file mode 100644 index 580170fa2..000000000 --- a/src/zoom-control.js +++ /dev/null @@ -1,118 +0,0 @@ -import React, { Component, PropTypes } from 'react'; - -const containerStyle = { - position: 'absolute', - zIndex: 10, - display: 'flex', - flexDirection: 'column', - boxShadow: '0px 1px 4px rgba(0, 0, 0, .3)', - border: '1px solid rgba(0, 0, 0, 0.1)', -}; - -const positions = { - topRight: { top: 10, right: 10, bottom: 'auto', left: 'auto' }, - topLeft: { top: 10, left: 10, bottom: 'auto', right: 'auto' }, - bottomRight: { bottom: 10, right: 10, top: 'auto', left: 'auto' }, - bottomLeft: { bottom: 10, left: 10, top: 'auto', right: 'auto' }, -}; - -const buttonStyle = { - backgroundColor: '#f9f9f9', - opacity: 0.95, - transition: 'background-color 0.16s ease-out', - cursor: 'pointer', - border: 0, - height: 26, - width: 26, - backgroundImage: "url('https://api.mapbox.com/mapbox.js/v2.4.0/images/icons-000000@2x.png')", - backgroundPosition: '0px 0px', - backgroundSize: '26px 260px', - outline: 0, -}; - -const buttonStyleHovered = { - backgroundColor: '#fff', - opacity: 1, -}; - -const buttonStylePlus = { - borderBottom: '1px solid rgba(0, 0, 0, 0.1)', - borderTopLeftRadius: 2, - borderTopRightRadius: 2, -}; - -const buttonStyleMinus = { - backgroundPosition: '0px -26px', - borderBottomLeftRadius: 2, - borderBottomRightRadius: 2, -}; - -const [PLUS, MINUS] = [0, 1]; -const POSITIONS = Object.keys(positions); - -export default class ZoomControl extends Component { - static propTypes = { - zoomDiff: PropTypes.number, - onControlClick: PropTypes.func, - position: PropTypes.oneOf(POSITIONS), - style: PropTypes.object, - }; - - static defaultProps = { - position: POSITIONS[0], - zoomDiff: 0.5, - onControlClick: (map, zoomDiff) => { - map.zoomTo(map.getZoom() + zoomDiff); - }, - }; - - state = { - hover: undefined, - }; - - static contextTypes = { - map: PropTypes.object, - }; - - onMouseAction = (hover) => { - if (hover !== this.state.hover) { - this.setState({ hover }); - } - }; - - render() { - const { onControlClick, zoomDiff, position, style } = this.props; - const { hover } = this.state; - const { map } = this.context; - - return ( -
- - -
- ); - } -} diff --git a/src/zoom-control.tsx b/src/zoom-control.tsx new file mode 100644 index 000000000..2d080f183 --- /dev/null +++ b/src/zoom-control.tsx @@ -0,0 +1,138 @@ +import * as React from 'react'; +import { Map } from 'mapbox-gl'; + +const containerStyle = { + position: 'absolute', + zIndex: 10, + display: 'flex', + flexDirection: 'column', + boxShadow: '0px 1px 4px rgba(0, 0, 0, .3)', + border: '1px solid rgba(0, 0, 0, 0.1)' +}; + +const positions = { + topRight: { top: 10, right: 10, bottom: 'auto', left: 'auto' }, + topLeft: { top: 10, left: 10, bottom: 'auto', right: 'auto' }, + bottomRight: { bottom: 10, right: 10, top: 'auto', left: 'auto' }, + bottomLeft: { bottom: 10, left: 10, top: 'auto', right: 'auto' } +}; + +const buttonStyle = { + backgroundColor: '#f9f9f9', + opacity: 0.95, + transition: 'background-color 0.16s ease-out', + cursor: 'pointer', + border: 0, + height: 26, + width: 26, + backgroundImage: 'url(\'https://api.mapbox.com/mapbox.js/v2.4.0/images/icons-000000@2x.png\')', + backgroundPosition: '0px 0px', + backgroundSize: '26px 260px', + outline: 0 +}; + +const buttonStyleHovered = { + backgroundColor: '#fff', + opacity: 1 +}; + +const buttonStylePlus = { + borderBottom: '1px solid rgba(0, 0, 0, 0.1)', + borderTopLeftRadius: 2, + borderTopRightRadius: 2 +}; + +const buttonStyleMinus = { + backgroundPosition: '0px -26px', + borderBottomLeftRadius: 2, + borderBottomRightRadius: 2 +}; + +const [PLUS, MINUS] = [0, 1]; +const POSITIONS = Object.keys(positions); + +export interface Props { + zoomDiff: number; + onControlClick: (map: Map, zoomDiff: number) => void; + position: 'topRight' | 'topLeft' | 'bottomRight' | 'bottomLeft'; + style: React.CSSProperties; +} + +export interface State { + hover?: number; +} + +export interface Context { + map: Map; +} + +export default class ZoomControl extends React.Component { + + public context: Context; + + public static defaultProps = { + position: POSITIONS[0], + zoomDiff: 0.5, + onControlClick: (map: Map, zoomDiff: number) => { + map.zoomTo(map.getZoom() + zoomDiff); + } + }; + + public state = { + hover: undefined + }; + + public static contextTypes = { + map: React.PropTypes.object + }; + + private onMouseOut = () => { + if (!this.state.hover) { + this.setState({ hover: undefined }); + } + } + + private plusOver = () => { + if (PLUS !== this.state.hover) { + this.setState({ hover: PLUS }); + } + } + + private minusOver = () => { + if (MINUS !== this.state.hover) { + this.setState({ hover: MINUS }); + } + } + + private onClickPlus = () => { + this.props.onControlClick(this.context.map, this.props.zoomDiff); + } + + private onClickMinus = () => { + this.props.onControlClick(this.context.map, -this.props.zoomDiff); + } + + public render() { + const { position, style } = this.props; + const { hover } = this.state; + + return ( +
+
+ ); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..37ee1fe74 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "outDir": "lib", + "module": "commonjs", + "target": "es5", + "sourceMap": true, + "moduleResolution": "node", + "rootDirs": ["src"], + "jsx": "react", + "removeComments": true, + "forceConsistentCasingInFileNames": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedLocals": true, + "declaration": true, + "suppressImplicitAnyIndexErrors": true + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "lib", + "src/__tests__", + "example", + "lib" + ] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 000000000..f83b7ac6a --- /dev/null +++ b/tslint.json @@ -0,0 +1,13 @@ +{ + "extends": ["tslint:latest", "tslint-react"], + "rules": { + "quotemark": [true, "single", "jsx-double"], + "ordered-imports": false, + "trailing-comma": [true, {"multiline": "never", "singleline": "never"}], + "interface-name": [true, "never-prefix"], + "no-console": false, + "object-literal-sort-keys": false, + "member-ordering": false, + "object-literal-key-quotes": false + } +}