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.
Related
I wanted to create drawing app in React and to achieve that I came across something called Konvajs. Then, I started and somehow achieved that I can draw non-straight lines like this
But what I wanted to draw is straight lines like this
and rectangle. Here is my code:
import React, { Component } from "react";
import { Stage, Layer, Line, Rect, Text } from "react-konva";
export default class Canvas extends Component {
state = {
lines: [],
};
handleMouseDown = () => {
this._drawing = true;
this.setState({
lines: [...this.state.lines, []],
});
};
handleMouseUp = () => {
this._drawing = false;
};
handleMouseMove = (e) => {
if (!this._drawing) {
return;
}
const stage = this.stageRef.getStage();
const point = stage.getPointerPosition();
const { lines } = this.state;
let lastLine = lines[lines.length - 1];
lastLine = lastLine.concat([point.x, point.y]);
lines.splice(lines.length - 1, 1, lastLine);
this.setState({
lines: lines.concat(),
});
};
render() {
return (
<Stage
width={window.innerWidth}
height={window.innerHeight}
onContentMousedown={this.handleMouseDown}
onContentMousemove={this.handleMouseMove}
onContentMouseup={this.handleMouseUp}
ref={(node) => {
this.stageRef = node;
}}
>
<Layer>
<Text text="Draw a thing!" />
{this.state.lines.map((line, i) => (
<Line key={i} points={line} stroke="red" />
))}
</Layer>
</Stage>
);
}
}
For straight lines replace this
lastLine = lastLine.concat([point.x, point.y]);
With this
lastLine = [lastLine[0], lastLine[1], point.x, point.y]
Basically, you should have maximum four points. The first two are created OnMouseDown and the last two should be updated as you are moving the cursor - OnMouseMove
My drag and drop is very slow because of too many re-renders.
React.memo doesn't seem to help although I passed all items as primitives.
My list looks as follows:
const TabList = ({ selectedTabsState, handleItemSelect, windowId, windowIndex, tabs, actions }) => {
const { dragTabs } = actions;
const moveTabs = ({ dragWindowId, dragTabIndex, hoverWindowId, hoverTabIndex, draggedTabs }) => {
dragTabs({
fromWindowId: dragWindowId,
dragTabIndex,
toWindowId: hoverWindowId,
hoverTabIndex,
draggedTabs
});
};
const ref = useRef(null);
// We need this to fix the bug that results from moving tabs from one window to a previous
const [, drop] = useDrop({
accept: ItemTypes.TAB,
hover(item, monitor) {
if (!ref.current) {
return
}
const dragWindowId = item.windowId;
const dragTabIndex = item.tabIndex;
const hoverWindowId = windowId;
if (hoverWindowId > dragWindowId) {
return;
}
const hoverTabIndex = tabs.length;
moveTabs({ dragWindowId, dragTabIndex, hoverWindowId, hoverTabIndex, draggedTab: item.tab });
item.windowId = hoverWindowId;
item.tabIndex = hoverTabIndex;
}
});
drop(ref);
const renderTab = (tab, index) => {
const isSelected = selectedTabsState.selectedTabs.find(selectedTab => selectedTab.id === tab.id);
return (
<TabListItem
key={`tab_${windowId}_${tab.id}`}
windowId={windowId}
windowIndex={windowIndex}
tabIndex={index}
isSelected={ isSelected }
moveTabs={moveTabs}
handleItemSelection={ handleItemSelect }
tabId={ tab.id }
tabUrl={ tab.url }
tabTitle={ tab.title }
/>)
};
return (
<li>
<ul className="nested-list">
{ tabs.map((tab, index) => renderTab(tab, index)) }
</ul>
<div ref={ ref } className='nested-list-bottom'></div>
</li>
);
};
const mapDispatchToProps = (dispatch) => {
return {
actions: bindActionCreators(
Object.assign({}, CurrentWindowsActions)
, dispatch)
}
};
const mapStateToProps = state => {
return {
selectedTabsState: state.selectedTabs
};
};
export default connect(mapStateToProps, mapDispatchToProps)(TabList);
My list item looks as follows:
const collect = (connect, monitor) => ({
// Call this function inside render()
// to let React DnD handle the drag events:
connectDragSource: connect.dragSource(),
// You can ask the monitor about the current drag preview
connectDragPreview: connect.dragPreview(),
// You can ask the monitor about the current drag state:
isDragging: monitor.isDragging(),
});
// We use dragSource to add custom isDragging
/* const tabSource = {
beginDrag({ selectedTabsState }) {
return { selectedTabs: selectedTabsState.selectedTabs };
}
}; */ // --> this is also problematic... I can never pass selectedTabsState to the item to be used in the drag layer, because it will re-render all items as well, and it is required to be passed as parameter to DragSource.
const tabSource = {
beginDrag() {
return {selectedTabs: [{id: 208}]};
}
};
const TabListItem = React.memo(
({ connectDragPreview, isSelected, handleItemSelection, connectDragSource, isDragging, windowId, windowIndex, tabId, tabUrl, tabTitle, tabIndex, moveTabs }) => {
useEffect(() => {
// Use empty image as a drag preview so browsers don't draw it
// and we can draw whatever we want on the custom drag layer instead.
connectDragPreview(getEmptyImage(), {
// IE fallback: specify that we'd rather screenshot the node
// when it already knows it's being dragged so we can hide it with CSS.
captureDraggingState: true
});
}, []);
const ref = useRef(null);
const [, drop] = useDrop({
accept: ItemTypes.TAB,
hover(item, monitor) {
if (!ref.current) {
return
}
const dragWindowId = item.windowId;
const dragTabIndex = item.tabIndex;
const hoverWindowId = windowId;
const hoverTabIndex = tabIndex;
// Don't replace items with themselves
if (dragTabIndex === hoverTabIndex && dragWindowId === hoverWindowId) {
return
}
// Determine rectangle on screen
const hoverBoundingRect = ref.current.getBoundingClientRect();
// Get vertical middle
const hoverMiddleY =
(hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
// Determine mouse position
const clientOffset = monitor.getClientOffset();
// Get pixels to the top
const hoverClientY = clientOffset.y - hoverBoundingRect.top;
// Only perform the move when the mouse has crossed half of the items height
// When dragging downwards, only move when the cursor is below 50%
// When dragging upwards, only move when the cursor is above 50%
// Dragging downwards
if (dragTabIndex < hoverTabIndex && hoverClientY < hoverMiddleY) {
return
}
// Dragging upwards
if (dragTabIndex > hoverTabIndex && hoverClientY > hoverMiddleY) {
return
}
// Time to actually perform the action
moveTabs({ dragWindowId, dragTabIndex, hoverWindowId, hoverTabIndex, draggedTabs: item.selectedTabs });
// Note: we're mutating the monitor item here!
// Generally it's better to avoid mutations,
// but it's good here for the sake of performance
// to avoid expensive index searches.
item.tabIndex = hoverTabIndex;
}
});
drop(ref);
console.log('render');
return connectDragSource(
<li ref={ ref }
style={ getTabStyle(isDragging, isSelected) }
onClick={(e) => handleItemSelection(e.metaKey, e.shiftKey, tabId, tabIndex)}
>
<div className='nested-list-item'>
<div>{ tabTitle }</div>
<a className='url' target="_blank" href={tabUrl}>{tabUrl}</a>
</div>
</li>
);
});
export default DragSource(ItemTypes.TAB, tabSource, collect)(TabListItem);
The code only drags selected items at a time (this must be shown in the custom drag layer); it doesn't throw exceptions (it works), but it is slow as hell.
In my console I can see that the item is rendered 48 times, which is the number of list items I have. This makes the drag very choppy and would become increasingly choppy with more list items.
Any idea why React.memo doesn't work in my case?
Edit: I found that part of the choppiness comes from the fact that drop-hover code is not correctly calculated anymore when it concerns multiple list items being dragged. Doesn't take away the fact that it shouldn't need to re-render all list-items on dragging just a few.
I'm not sure why the re-renders of all items still happen despite using React.memo, but I got rid of the choppiness by fixing the following code:
const tabSource = {
beginDrag({ selectedTabsState, windowId, tabIndex }) {
return { windowId, tabIndex, selectedTabs: selectedTabsState.selectedTabs };
}
};
This caused the drag-hover calculation to trigger too many state updates, followed by a ton of re-renders when redux updated the state.
I am attempting to use React hooks to run a canvas animation that depends on mouse position. I am using a custom hook for mouse position, and wrote another custom hook to animate the canvas.
Placing an empty dependancy array in the animation hook keeps it from unmounting and remounting the animation loop anytime the mouse moves, as is mentioned in this tutorial and suggested in a note in the React docs.
So the code below works, in that I can access coords inside the drawNow() function, but mounting and unmounting the animation loop every time the mouse moves does not seem like an acceptable way to do things.
How does one access event listeners inside React hooks that are purposely set to have no dependancies?
Here is the animation and the draw function....
const drawNow = (context,coords) => {
context.fillStyle = '#fff';
context.beginPath();
context.arc(coords.x,coords.y,50,0,2*Math.PI); // need coords here
context.fill();
}
export const Canvas = () => {
let ref = React.useRef();
// custom hook that returns mouse position
const coords = useMouseMove();
React.useEffect(() => {
let canvas = ref.current;
let context = canvas.getContext('2d');
const render = () => {
aId = requestAnimationFrame(render);
drawNow(context,coords); // requires current mouse coordinates
};
let aId = requestAnimationFrame(render);
return () => cancelAnimationFrame(aId);
}, [coords]); // dependancy array should be left blank so requestAnimationFrame mounts only once?
return (
<canvas ref={ref}/>
style={{
width: '100%',
height: '100%',
}}
);
};
Here is the custom hook for the mouse coordinates (references this useEventListener)
export const useMouseMove = () => {
function getCoords(clientX,clientY) {
return {
x: clientX || 0,
y: clientY || 0
};
}
const [coords, setCoords] = useState(getCoords);
useEventListener('mousemove', ({ clientX, clientY }) => {
setCoords(getCoords(clientX,clientY));
});
return coords;
};
Thanks, and looking forward to understanding more about hooks and event listeners.
Okay, I figured out my problem. The issue is the useMouseMove() hook was updating the coordinates with useState, when really I wanted to be using useRef, allowing me to imperatively "modify a child outside the typical data flow", as mentioned here.
First, I combined the useMouseMove() hook with the more general useEventListener so I didn't have to navigate unnecessary abstractions:
// a function that keeps track of mouse coordinates with useState()
export const useMouseMove = () => {
function getCoords(clientX,clientY) {
return {
x: clientX || 0,
y: clientY || 0
};
}
const [coords, setCoords] = useState(getCoords);
useEffect(
() => {
function handleMove(e) {
setCoords(getCoords(e.clientX,e.clientY));
}
global.addEventListener('mousemove', handleMove);
return () => {
global.removeEventListener('mousemove', handleMove);
};
}
);
return coords;
};
The next step was to "convert" the above function from useState() to useRef():
// a function that keeps track of mouse coordinates with useRef()
export const useMouseMove = () => {
function getCoords(clientX,clientY) {
return {
x: clientX || 0,
y: clientY || 0
};
}
const coords = useRef(getCoords); // ref not state!
useEffect(
() => {
function handleMove(e) {
coords.current = getCoords(e.clientX,e.clientY);
}
global.addEventListener('mousemove', handleMove);
return () => {
global.removeEventListener('mousemove', handleMove);
};
}
);
return coords;
};
Finally, I can access the mouse coordinates inside the animation loop while also keeping the dependancy array blank, preventing the animation component from remounting every time the mouse moves.
// the animation loop that mounts only once with mouse coordinates
export const Canvas = () => {
let ref = React.useRef();
// custom hook that returns mouse position
const coords = useMouseMove();
React.useEffect(() => {
let canvas = ref.current;
let context = canvas.getContext('2d');
const render = () => {
aId = requestAnimationFrame(render);
drawNow(context,coords.current); // mouse coordinates from useRef()
};
let aId = requestAnimationFrame(render);
return () => cancelAnimationFrame(aId);
}, []); // dependancy array is blank, the animation loop mounts only once
return (
<canvas ref={ref}/>
style={{
width: '100%',
height: '100%',
}}
);
};
Thanks to this escape hatch, I am able to create endless web fun without remounting the animation loop.
I have created a function to detect scroll status, means if the user has scrolled to the bottom of the page then 'console.log(true)' and setting state. The function name is handleScroll and I am calling that function from helper file. And in my view file, I'm calling event listener to detect scroll change using the handleScroll function inside componentDidMount & later removing event listener by unmounting.
However, when I run the code initially state is set inside 'atBottom: false'. But later if I scroll down the page the function is not called again and I can't detect whether I am bottom of the page or not.
----> View file
import { handleScroll } from 'components/Helper.jsx'
class ScrollStatus extends Component {
constructor(props) {
super(props);
this.state = {
height: window.innerHeight,
scrollBottomStatus: false,
}
}
componentDidMount() {
window.addEventListener("scroll", handleScroll(this,
this.stateHandler));
}
componentWillUnmount() {
window.removeEventListener("scroll", handleScroll(this,
this.stateHandler));
}
stateHandler = (state) => {
this.setState(state);
}
render() {
return ( <div> Long text ... </div> ) }
}
export default ScrollStatus
----> helper file
export const handleScroll = (obj, stateHandler) => {
const windowHeight = "innerHeight" in window ? window.innerHeight :
document.documentElement.offsetHeight;
const body = document.body;
const html = document.documentElement;
const docHeight = Math.max(body.scrollHeight, body.offsetHeight,
html.clientHeight, html.scrollHeight, html.offsetHeight);
const windowBottom = Math.round(windowHeight + window.pageYOffset);
if (windowBottom >= docHeight) {
console.log(true)
stateHandler({
scrollBottomStatus: true
});
} else {
console.log(false)
stateHandler({
scrollBottomStatus: false
});
}
}
I want the function to keeping checking window height as I scroll down or up and keep updating the state 'isBottom' while scrolling.
I would appreciate the help.
When I check scrolling I always add a throttle (via lodash or ...) to throttle down the actions.
What I would do in your case.
1. Add eventlistener on mount, also remove on unmount.
componentDidMount = () => {
window.addEventListener('scroll', () => _.throttle(this.handleScroll, 100));
}
2. In the same component I'd handle the state update.
handleScroll = () => {
let scrollY = window.pageYOffset;
if(scrollY < 100) { this.setState({ // BLA })
}
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.