Deck.gl how to show popup onClick - reactjs

Setup:
Basic react app using react-map-gl to show a map with a deck.gl ScatterplotLayer over the top to visualise the data
Goal:
1) To show points on a map as circles of a given radius and colour.
2) When a user clicks on a circle, a tooltip/popup should show with more data about it (included in the data provided) until the user clicks away (essentially the same as this graph but for click instead of hover, http://uber.github.io/deck.gl/#/documentation/layer-catalog/scatterplot-layer. FYI I looked at the code for this and the hover logic has been removed, I assume for simplicity).
Issue:
I have completed point 1 but I cannot get point 2 to work. The furthest I have gotten to prove the data is there is to log to the console.
To note:
I'm not married to react-tooltip - I don't mind taking it out entirely if there's a better way of doing this. I only need to keep mapbox and deck.gl.
Data: https://gist.github.com/NikkiChristofi/bf79ca37028b29b50cffb215360db999
deckgl-overlay.js
import React, {Component} from 'react';
import ReactTooltip from 'react-tooltip';
import DeckGL, {ScatterplotLayer} from 'deck.gl';
export default class DeckGLOverlay extends Component {
static get defaultViewport() {
return {
longitude: 0,
latitude: 0,
zoom: 2,
maxZoom: 16,
pitch: 0,
bearing: 0
};
}
# in this method I want to update the variable tooltipText with
# whatever object data has been clicked.
# The console log successfully logs the right data (i.e. the third
# element in the array), but the tooltip doesn't even show
onClickHandler = (info) => {
let dataToShow = info ? info.object[2] : "not found";
this.tooltipText = dataToShow;
console.log(dataToShow);
}
render() {
const {viewport, lowPerformerColor, highPerformerColor, data, radius, smallRadius, largeRadius} = this.props;
if (!data) {
return null;
}
const layer = new ScatterplotLayer({
id: 'scatter-plot',
data,
radiusScale: radius,
radiusMinPixels: 0.25,
getPosition: d => [d[1], d[0], 0],
getColor: d => d[2] > 50 ? lowPerformerColor : highPerformerColor,
getRadius: d => d[2] < 25 || d[2] > 75 ? smallRadius : largeRadius,
updateTriggers: {
getColor: [lowPerformerColor, highPerformerColor]
},
pickable: true,
onClick: info => this.onClickHandler(info),
opacity: 0.3
});
return (
<DeckGL {...viewport} layers={ [layer] } data-tip={this.tooltipText}>
<ReactTooltip />
</DeckGL>
);
}
}
app.js
import React, {Component} from 'react';
import {render} from 'react-dom';
import MapGL from 'react-map-gl';
import DeckGLOverlay from './deckgl-overlay.js';
import {json as requestJson} from 'd3-request';
const MAPBOX_TOKEN = process.env.MAPBOX_TOKEN; // eslint-disable-line
const lowPerformerColor = [204, 0, 0];
const highPerformerColor = [0, 255, 0];
const smallRadius = 500;
const largeRadius = 1000;
const DATA_URL = 'https://gist.github.com/NikkiChristofi/bf79ca37028b29b50cffb215360db999';
export default class App extends Component {
constructor(props) {
super(props);
this.state = {
viewport: {
...DeckGLOverlay.defaultViewport,
width: 500,
height: 500
},
data: null
};
requestJson(DATA_URL, (error, response) => {
if (!error) {
console.log(response);
this.setState({data: response});
}
else{
console.log(error);
}
});
}
componentDidMount() {
window.addEventListener('resize', this._resize.bind(this));
this._resize();
}
_resize() {
this._onViewportChange({
width: window.innerWidth,
height: window.innerHeight
});
}
_onViewportChange(viewport) {
this.setState({
viewport: {...this.state.viewport, ...viewport}
});
}
render() {
const {viewport, data} = this.state;
return (
<MapGL
{...viewport}
onViewportChange={this._onViewportChange.bind(this)}
mapboxApiAccessToken={MAPBOX_TOKEN}
mapStyle='mapbox://styles/mapbox/dark-v9'>
<DeckGLOverlay viewport={viewport}
data={data}
lowPerformerColor={lowPerformerColor}
highPerformerColor={highPerformerColor}
smallRadius={smallRadius}
largeRadius={largeRadius}
radius={300}
/>
</MapGL>
);
}
}

Figured out a way to do it.
Solution
I bubbled up the onClick event to the MapGL layer, and used the Popup element to display the data.
so in app.js:
1) import the Popup element from react-map-gl
import MapGL, { Popup } from 'react-map-gl';
2) Set coordinates state and "info" (to show in the popup)
constructor(props) {
super(props);
this.state = {
viewport: {
...DeckGLOverlay.defaultViewport,
width: 500,
height: 500
},
data: null,
coordinates: [-0.13235092163085938,51.518250335096376],
info: "Hello"
};
3) Create callback method that sets the state with the new data (info will just be an element from the data, can be anything you want to display in the popup though)
myCallback = (info) => {
console.log(info);
if(info){
this.setState({coordinates: info.lngLat, info: info.object[2]});
}
}
4) Render the popup and reference the callback method in the DeckGL layer
return (
<MapGL
{...viewport}
{...this.props}
onViewportChange={this._onViewportChange.bind(this)}
mapboxApiAccessToken={MAPBOX_TOKEN}
mapStyle='mapbox://styles/mapbox/dark-v9'>
<Popup
longitude={this.state.coordinates[0]}
latitude={this.state.coordinates[1]}>
<div style={style}>
<p>{this.state.info}</p>
</div>
</Popup>
<DeckGLOverlay viewport={viewport}
data={data}
lowPerformerColor={lowPerformerColor}
highPerformerColor={highPerformerColor}
smallRadius={smallRadius}
largeRadius={largeRadius}
radius={300}
callbackFromParent={this.myCallback}
/>
</MapGL>
);
and in deckgl-overlay.js:
1) Feed data information into the parent's (app.js) method
onClick: info => this.props.callbackFromParent(info),
(obviously delete the React-tooltip element and onClick event handler in deckoverlay.js to clean up)

For anyone reading this who wants to use a custom popover or one from a third party library like antd that doesn't support exact position as a prop I got around this problem by just creating a <div style={{ position: 'absolute', left: x, top: y}} /> to act as a child node for the popover to reference. X and Y are initially set to 0:
const [selectedPoint, setSelectedPoint] = useState({});
const [x, setX] = useState(0);
const [y, setY] = useState(0);
and then are set onClick in the GeoJsonLayer:
const onClick = ({ x, y, object }) => {
setSelectedPoint(object);
setX(x);
setY(y);
};
const layer = new GeoJsonLayer({
id: "geojson-layer",
data,
pickable: true,
stroked: false,
filled: true,
extruded: true,
lineWidthScale: 20,
lineWidthMinPixels: 2,
getFillColor: [0, 0, 0, 255],
getRadius: 50,
getLineWidth: 1,
getElevation: 30,
onClick
});
The downside to this approach is that the popover won't stay with the point if the map is zoomed/panned because X and Y are viewport coordinates vs lat and long.

Related

Mapbox geocoder in React

I recently made my first app on React. I had a d3 map that looks a bit old now, so I decided to switch to Mapbox.
The data transmitted is a list of country names and a percentage. The goal is to visualize each country and its percentage.
I wrote all my code, and everything seemed to go well.
However, I now get this error on line 19 : "Geocoder is not defined".
I specify that I have installed :
mapbox-gl
react-mapbox-gl
react-map-gl-geocoder
Can someone explain to me where my mistake came from?
import React, { useRef, useEffect } from "react";
import mapboxgl from "mapbox-gl";
import MapGeocoder from "react-map-gl-geocoder";
const MapChart = ({ data }) => {
const mapContainer = useRef(null);
useEffect(() => {
mapboxgl.accessToken =
"**********";
const map = new mapboxgl.Map({
container: mapContainer.current,
style: "mapbox://styles/mapbox/streets-v12",
center: [0, 0],
zoom: 1
});
// Geocode the data points to get the latitude and longitude
const geocoder = new MapGeocoder({
accessToken: mapboxgl.accessToken
});
data.forEach((datapoint) => {
geocoder.geocode(datapoint.country, (error, result) => {
if (error) {
console.error(error);
return;
}
const [longitude, latitude] = result.features[0].geometry.coordinates;
const marker = new mapboxgl.Marker({
color: "#" + ((Math.random() * 0xffffff) << 0).toString(16)
}).setLngLat([longitude, latitude]);
marker.setPopup(
new mapboxgl.Popup().setHTML(`
<h3>${datapoint.country}</h3>
<p>${datapoint.percentage}%</p>
`)
);
marker.addTo(map);
});
});
}, [data]);
return <div ref={mapContainer} style={{ width: "100%", height: "400px" }} />;
};
export default MapChart;
You are actually using the MapboxGeocoder from '#mapbox/mapbox-gl-geocoder'. If you intend to use the one provided by react-map-gl-geocoder, it would work slightly differently.

How do I change the size of a React DnD Preview Image?

I am making a chess game, and I am using React Drag n Drop for the piece movement. I have a problem where the drag image is not scaling with the window size. The image stays at a static 60x60px (the same as the image I am serving from my static folder to the client).
Here is a gif of the problem so you can see:
Drag image not scaling with original image
Here is a code snippet of the "Piece" component:
import React, { useState, useEffect } from "react";
// React DnD Imports
import { useDrag, DragPreviewImage, DragLayer } from "react-dnd";
function Piece({ piece }) {
const { imageFile } = piece;
// drag and drop configuration
const [{ isDragging }, drag, preview] = useDrag({
item: { type: "piece", piece: piece },
collect: (monitor) => {
return { isDragging: !!monitor.isDragging() };
},
});
const opacityStyle = {
opacity: isDragging ? 0.5 : 1,
};
return (
<React.Fragment>
<DragPreviewImage connect={preview} src={imageFile} />
<img style={{ ...opacityStyle }} ref={drag} src={imageFile}></img>
</React.Fragment>
);
}
I've tried the following, but it doesn't work exactly right:
useEffect(() => {
const img = new Image();
img.src = imageFile;
const ctx = document.createElement("canvas").getContext("2d");
ctx.canvas.width = "100%";
ctx.canvas.height = "100%";
img.onload = () => {
ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height);
img.src = ctx.canvas.toDataURL();
preview(img);
};
}, []);
Drag Image is transparent
If you start dragging between UseEffect calls, you start dragging the whole tile behind the image
I really would love a way to easily tell my DragPreviewImage to scale with the original image... Thank you!
EDIT:
I have discovered the previewOptions property! I got my preview image to center on drag by doing this, but I can't get it to increase in size. Code below:
function Piece({ piece }) {
const { imageFile } = piece;
const previewOptions = {
offsetX: 27,
offsetY: 28,
};
// drag and drop configuration
const [{ isDragging }, drag, preview] = useDrag({
item: { type: "piece", piece: piece },
previewOptions,
collect: (monitor) => {
return { isDragging: !!monitor.isDragging() };
},
});
const img = new Image();
img.src = imageFile;
preview(img, previewOptions);
const callbackRef = useCallback(
(node) => {
drag(node);
preview(node, previewOptions);
},
[drag, preview],
);
const opacityStyle = {
opacity: isDragging ? 0.5 : 1,
};
return (
<React.Fragment>
<DragPreviewImage connect={preview} src={imageFile} />
<img style={{ ...opacityStyle }} ref={drag} src={imageFile} />
</React.Fragment>
);
}
I've looked through the documentation for react-dnd and what I found is that the only way to make the preview image the size you want is to edit the source image itself. In my case the images are 500x500 while the board tile is 75x75, so by shrinking the image in Photoshop / GIMP it got to the size I wanted. However if you want it to be responsive I have no idea. I tried to add style={{height: , width: }} to the DraggablePreview but that had no effect, tried with scale() also no effect.
But if the chess board you have is static (width and height) editing the images would be best.

How to refactor React mousemove Class to functional Component?

I have a button which closes a navigation. This button follows the mouse. Everything is working, but I have a depricationwarning, which I wanna get rid of, but don't know exactly how. (I only know that useEffect is playing a certain role:
Here is the class:
import React from "react"
class NavigationCloseMouseButton extends React.Component {
static defaultProps = {
visible: true,
offsetX: 0,
offsetY: 0,
}
state = {
xPosition: 0,
yPosition: 0,
mouseMoved: false,
listenerActive: false,
}
componentDidMount() {
this.addListener()
}
componentDidUpdate() {
this.updateListener()
}
componentWillUnmount() {
this.removeListener()
}
getTooltipPosition = ({ clientX: xPosition, clientY: yPosition }) => {
this.setState({
xPosition,
yPosition,
mouseMoved: true,
})
}
addListener = () => {
window.addEventListener("mousemove", this.getTooltipPosition)
this.setState({ listenerActive: true })
}
removeListener = () => {
window.removeEventListener("mousemove", this.getTooltipPosition)
this.setState({ listenerActive: false })
}
updateListener = () => {
if (!this.state.listenerActive && this.props.visible) {
this.addListener()
}
if (this.state.listenerActive && !this.props.visible) {
this.removeListener()
}
}
render() {
return (
<div
onClick={this.props.toggleNavigation}
className="tooltip color-bg"
style={{
display:
this.props.visible && this.state.mouseMoved ? "block" : "none",
opacity: this.props.visible && this.state.mouseMoved ? "1" : "0",
top: this.state.yPosition + this.props.offsetY,
left: this.state.xPosition + this.props.offsetX,
}}
>
Close Menu
</div>
)
}
}
export default NavigationCloseMouseButton
And this is what I've so far, but results with errors:
ReferenceError: getTooltipPosition is not defined
import React, { useState, useEffect } from "react"
const NavigationCloseMouseButton = () => {
const defaults = {
visible: true,
offsetX: 0,
offsetY: 0,
}
const defaultState = {
xPosition: 0,
yPosition: 0,
mouseMoved: false,
listenerActive: false,
}
const [defaultProps, setDefaultProps] = useState(defaults)
const [state, setState] = useState(defaultState)
useEffect(() => {
// Update the document title using the browser API
addListener()
}, [])
getTooltipPosition = ({ clientX: xPosition, clientY: yPosition }) => {
setState({
xPosition,
yPosition,
mouseMoved: true,
})
}
addListener = () => {
window.addEventListener("mousemove", getTooltipPosition)
setState({ listenerActive: true })
}
removeListener = () => {
window.removeEventListener("mousemove", getTooltipPosition)
setState({ listenerActive: false })
}
updateListener = () => {
if (!state.listenerActive && props.visible) {
addListener()
}
if (state.listenerActive && !props.visible) {
removeListener()
}
}
return (
<div
onClick={props.toggleNavigation}
className="tooltip color-bg"
style={{
display: props.visible && state.mouseMoved ? "block" : "none",
opacity: props.visible && state.mouseMoved ? "1" : "0",
top: state.yPosition + props.offsetY,
left: state.xPosition + props.offsetX,
}}
>
Close Menu
</div>
)
}
export default NavigationCloseMouseButton
Setting Defaults
You can destructure individual props from the props object (the argument of the function component). While destructuring, you can use the = operator to set a default value for when this prop is not set.
const NavigationCloseMouseButton = ({ visible = true, offsetX = 0, offsetY = 0, toggleNavigation }) => {
Updating a Listener
I'm sure there a lots of great answers about this so I won't go into too much detail.
You want to handle adding and removing the listener from inside your useEffect. You should use a useEffect cleanup function for the final remove. We don't want to be adding and removing the same listener so we can memoize it with useCallback.
I'm not sure what you are trying to do with listenerActive. This could be a prop, but it also seems a bit redundant with visible. I don't know that we need this at all.
Calculating Offset
I also don't know that it makes sense to pass offsetX and offsetY as props. We need the mouse to be on top of the tooltip in order for it to be clickable. We can measure the tooltip div inside this component and deal with it that way.
// ref to DOM node for measuring
const divRef = useRef<HTMLDivElement>(null);
// can caluculate offset instead of passing in props
const offsetX = -.5 * (divRef.current?.offsetWidth || 0);
const offsetY = -.5 * (divRef.current?.offsetHeight || 0);
Animation
Setting the style property display as "block" or "none" makes it hard to do any sort of CSS transition. Instead, I recommend that you handle style switching by changing the className. You could still set display: block and display: none on those classes, but I am choosing to use transform: scale(0); instead.
Code
const NavigationCloseMouseButton = ({
visible = true,
toggleNavigation
}) => {
// state of the movement
const [state, setState] = useState({
xPosition: 0,
yPosition: 0,
mouseMoved: false
});
// memoized event listener
const getTooltipPosition = useCallback(
// plain event, not a React synthetic event
({ clientX: xPosition, clientY: yPosition }) => {
setState({
xPosition,
yPosition,
mouseMoved: true
});
},
[]
); // never re-creates
useEffect(() => {
// don't need to listen when it's not visible
if (visible) {
window.addEventListener("mousemove", getTooltipPosition);
} else {
window.removeEventListener("mousemove", getTooltipPosition);
}
// clean-up function to remove on unmount
return () => {
window.removeEventListener("mousemove", getTooltipPosition);
};
}, [visible, getTooltipPosition]); // re-run the effect if prop `visible` changes
// ref to DOM node for measuring
const divRef = useRef(null);
// can caluculate offset instead of passing in props
const offsetX = -.5 * (divRef.current?.offsetWidth || 0);
const offsetY = -.5 * (divRef.current?.offsetHeight || 0);
// don't show until after mouse is moved
const isVisible = visible && state.mouseMoved;
return (
<div
ref={divRef}
onClick={toggleNavigation}
// control most styling through className
className={`tooltip ${isVisible ? "tooltip-visible" : "tooltip-hidden"}`}
style={{
// need absolute position to use top and left
position: "absolute",
top: state.yPosition + offsetY,
left: state.xPosition + offsetX
}}
>
Close Menu
</div>
);
};
Other Uses
We can easily make this NavigationCloseMouseButton into a more flexible MovingTooltip by removing some of the hard-coded specifics.
Get the contents from props.children instead of always using "Close Menu"
Accept a className as a prop
Change the name of toggleNavigation to onClick
Code Sandbox Demo

Updating the state in a react component with canvas object fails

We have a few regions on a world map that will highlight when the user puts their mouse over that region. My approach to the problem is to use the canvas element and draw rough polygons around the regions and using some MIT code detect whether the user has their mouse inside the polygon. If they do then the image will switch to the appropriate graphic that shows the highlighted and labeled region.
Everything seemed to be going okay, but when I try to change the state it bombs out. I'm figuring it has something to do with the vitual dom somewhere.
Here is the component
class HomeMap extends Component {
constructor(props) {
super(props);
this.onMouseMove = this.onMouseMove.bind(this);
this.state = {
placeName: 'default',
};
}
onMouseMove(canvas, eX, eY) {
const { top, left } = canvas.getBoundingClientRect();
const [x, y] = [eX - left, eY - top];
const mouseOver = mapImages.map(image => ({
...image,
isInside: inside([x, y], image.polygon),
}))
.filter(({ isInside }) => isInside);
const stateChange = {
placeName: mouseOver.length === 0
? 'default'
: mouseOver[0].name,
};
console.log(stateChange);
this.setState(stateChange);
}
componentDidMount() {
this.ctx.drawImage(getImage('default').image, 0, 0, 656, 327);
}
componentDidUpdate() {
console.log(getImage('default'))
console.log(this.state);
this.ctx.drawImage(getImage(this.state.placeName).image, 0, 0, 656, 327);
}
render() {
return (
<div>
<canvas
onMouseMove={(e) => { this.onMouseMove(this.canvas, e.clientX, e.clientY); } }
width={626}
height={327}
ref={(ref) => {
this.ctx = ref.getContext('2d');
this.canvas = ref;
} }
/>
</div>
);
}
};
here is a fairly minimal reproduction running on codesandbox
When I run it in production when the component updates the canvas ref becomes null, I believe that is what throws the error.
I found the cause of the problem: caching the 2d context.
<canvas
onMouseMove={(e) => { this.onMouseMove(this.canvas, e.clientX, e.clientY); } }
width={626}
height={327}
ref={(ref) => {
this.ctx = ref.getContext('2d'); // <=== remove this
this.canvas = ref;
} }
/>
So rather than saving the reference to the getContext, I just cached the canvas element and called canvas.getContext('2d') when I needed to access the drawing functions.

How to get current touch coordinates of PanResponder relative to parent view?

Using React-Native, I'm trying to capture swipe events on a list view item. Everything works well but i'm having difficulties acquiring the current coordinate of the touch gesture, relative to the list view item.
In my _handlePanResponderMove, I use the following snippet:
let relativeY = event.nativeEvent.locationY;
Unfortunately, the Y coordinate I'm getting is relative to the "child view" the swipe occurred on, and not relative to the list view item (as I was expecting, since I attached the PanResponder to the list view item)
How can I get the gestures' Y coordinate relative to the list view parent?
Set the child view to pointerEvents="none" like so:
<View pointerEvents="none">
...
</View>
That way, the event you receive on your event.nativeEvent.locationY will not take into account the child view coordinates at all, and that will do exactly what you want.
Try to add a ref to the child and then measure its relative distances. This code represents the wrapper with a widget (child component). It works for me!
Notice that this.refs.self.measure is trigger after a setTimeout, it was not working without it. Could be a bug of measure or that the references are not updated after an instant.
import React, { Component } from 'react'
import TimerMixin from 'react-timer-mixin'
import {
StyleSheet,
View,
TouchableHighlight,
Text,
Alert,
PanResponder,
Animated,
Dimensions } from 'react-native'
import BulbWidget from './bulb-widget'
const COLUMNS = 3
export default class Widget extends Component {
constructor (props) {
super()
this.state = {
pan: new Animated.ValueXY(),
touches: 1
}
this.panResponder = PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderMove: Animated.event([null, {
dx: this.state.pan.x,
dy: this.state.pan.y
}]),
onPanResponderRelease: (e, gesture) => {
this.state.pan.flattenOffset()
this._setPosition(gesture)
Animated.spring(
this.state.pan,
{toValue: {x: 0, y: 0}}
).start()
},
onPanResponderGrant: (e, gestureState) => {
this.state.pan.setOffset({x: this.state.pan.x._value, y: this.state.pan.y._value})
this.state.pan.setValue({x: 0, y: 0})
this._onPressWidget()
}
})
}
render () {
let styleWidget = {}
this.props.type === 'L' ? styleWidget = styles.widgetL : styleWidget = styles.widget
return (
<View ref='self' style={this.state.placed}>
<Animated.View {...this.panResponder.panHandlers}
style={[this.state.pan.getLayout(), styleWidget]} >
<BulbWidget ref='child'/>
</Animated.View>
</View>
)
}
componentDidMount () {
// Print component dimensions to console
setTimeout(() => {
this.refs.self.measure((fx, fy, width, height, px, py) => {
console.log('Component width is: ' + width)
console.log('Component height is: ' + height)
console.log('X offset to frame: ' + fx)
console.log('Y offset to frame: ' + fy)
this.offset = { fx, fy, width, height }
})
}, 0)
}
_onPressWidget () {
this.refs.child.onPressAction()
this.setState({touches: this.state.touches + 1})
TimerMixin.setTimeout(() => {
this.setState({ touches: 1 })
console.log('Reset')
}, 1000)
if (this.state.touches === 2) {
this.setState({touches: 1})
}
}
_setPosition (gesture) {
const dx = gesture.moveX - gesture.x0
const dy = gesture.moveY - gesture.y0
const { width, height } = this.offset
const x = Math.abs(this.offset.fx + dx)
const y = Math.abs(this.offset.fy + dy)
const idTo = (Math.floor(x / width) + Math.floor(y / height) * COLUMNS)
this.props.setPosition(gesture, this.props.idx, idTo)
}
}

Resources