How to toggle a rectangle grid using react-leaflet's LayerControl? - javascript

I want to show or hide a grid of rectangles (the overlay) over a map (the base layer).
I'm using the react Leaflet layers control : doc
Problem : My grid shows all the time even if I uncheck the check box
My grid :
class Grid extends MapControl {
createLeafletElement(props) {
const {
leaflet: { map },
} = props;
const minLng = -4.89;
const minLat = 41.29;
const maxLng = 9.65;
const maxLat = 51.22
const nbColumn = 10;
const nbRow = 10;
const rectWidth = maxLng - minLng;
const rectHeight = maxLat - minLat;
const incrLat = rectHeight / nbColumn;
const incrLng = rectWidth / nbRow;
let column = 0;
let lngTemp = minLng;
let latTemp = minLat;
let rect;
const arrRect = [];
while (column < nbColumn) {
let row = 0;
latTemp = minLat;
while (row < nbRow) {
const cellBounds = [[latTemp, lngTemp], [latTemp + incrLat, lngTemp + incrLng]];
rect = L.rectangle(cellBounds, {color: "#1EA0AA", weight: 1}).addTo(map);
arrRect.push(rect);
latTemp += incrLat;
row += 1;
}
lngTemp += incrLng;
column += 1;
}
return rect;
}
}
In my leaflet component :
class Leaflet extends Component {
...
render() {
return (
<Map
<LayersControl>
<LayersControl.BaseLayer name="Open Street Map" checked="true">
<TileLayer attribution='© OpenStreetMap
contributors'
url={this.state.url}
/>
</LayersControl.BaseLayer>
<LayersControl.Overlay name="Grid1">
<LayerGroup>
<Grid />
</LayerGroup>
</LayersControl.Overlay>
</LayersControl>

I did not manage to load your grid so I provide another simpler grid example.
To control the Grid's visibility you need to use react-leaflet's updateLeafletElement method to trigger prop changes on your custom react-leaflet component. Pass a showGrid prop to be able to control Grid's visibility.
updateLeafletElement(fromProps, toProps) {
const { map } = this.props.leaflet;
if (toProps.showGrid !== fromProps.showGrid) {
toProps.showGrid
? this.leafletElement.addTo(map)
: this.leafletElement.removeFrom(map);
}
}
then in your map component listen to leaflet's overlayadd & overlayremove to be able to toggle a local flag which will control the visibility of the grid using an effect:
useEffect(() => {
const map = mapRef.current.leafletElement;
map.on("overlayadd", (e) => {
if (e.name === "Grid1") setShowGrid(true);
});
map.on("overlayremove", (e) => {
if (e.name === "Grid1") setShowGrid(false);
});
}, []);
<LayersControl.Overlay
checked={showGrid}
name="Grid1"
>
<LayerGroup>
<Grid showGrid={showGrid} />
</LayerGroup>
</LayersControl.Overlay>
Edit:
The App component as class based component it will look like this:
export default class AppWithNoHooks extends Component {
state = {
showGrid: false
};
mapRef = createRef();
componentDidMount() {
const map = this.mapRef.current.leafletElement;
map.on("overlayadd", (e) => {
if (e.name === "Grid1") this.setState({ showGrid: true });
});
map.on("overlayremove", (e) => {
if (e.name === "Grid1") this.setState({ showGrid: false });
});
}
...
I don't get the error you mentioned.
Demo

Related

How to make Drag and Drop work Horizontally?

I'm trying to get Drag and Drop to work correctly
But in the image gallery, it only changes the position of the photo when dragging it under the other image
As if it only works vertically
Demo
Javascript Vanilla
const enableDragSort = className => {
// Gets all child elements of the .gallery or .datasheet class
[...document.getElementsByClassName(className)].forEach(enableDragList);
}
const enableDragList = className => {
// For each child of the class, add the draggable attribute
[...className.children].forEach(enableDragItem);
}
const enableDragItem = item => {
item.setAttribute('draggable', true);
item.ondrag = handleDrag;
item.ondragend = handleDrop;
}
const handleDrag = event => {
const item = event.target.closest('[draggable]');
item.classList.add('drag-sort-active');
// .gallery or .datasheet
const className = item.parentElement;
const x = event.clientX;
const y = event.clientY;
let swap = document.elementFromPoint(x, y) ?? item;
if (className === swap.parentElement) {
swap = (swap !== item.nextSibling) ? swap : swap.nextSibling;
className.insertBefore(item, swap);
}
}
const handleDrop = ({ target }) => {
const item = target.closest('[draggable]');
item.classList.remove('drag-sort-active');
}
// Drag Drop
enableDragSort('gallery'); // It does not work properly
enableDragSort('datasheet'); // It works

Automatically trigger an OnClick event of a specific component in React

I'm a beginner to React and Javascript and I'm attempting to create Minesweeper in React from scratch. I've run into an issue where I want to replicate the functionality of Minesweeper where if you click a tile with zero mines around it, the surrounding tiles will automatically reveal themselves. However, I'm having trouble figuring out how to do that. My instinct is to grab the surrounding tiles by an id, and somehow manually trigger the OnClick event, but as far as I'm aware my Tile component has no knowledge of other Tile components and thus no way of accessing them. And I don't see how I could do it from my Board component. Any advice would be greatly appreciated!
Board Component:
class Board extends React.Component {
constructor(props) {
super(props);
this.state = {
height: 8,
width: 8,
num_mines: 10
}
game_logic.initalizeGame(8,8,10);
game_logic.createGame();
}
render() {
var all_tiles = [];
for (var i = 0; i < this.state.height; i++) {
var row_tiles = [];
for (var j = 0; j < this.state.width; j++) {
row_tiles.push(<Tile key={'Tile ' + (i*this.state.height + j)} value={game_logic.tile_values[i][j]} />)
}
all_tiles[i] = row_tiles;
}
return (
<div>
{all_tiles.map((value, index) => {
return <div key={'Row ' + index}>{value}</div>
})}
</div>
)
}
}
export default Board;
Tile Component:
class Tile extends React.Component {
constructor(props) {
super(props);
this.state = {
imgSrc: null,
imgAlt: '',
value: '',
};
}
tileClick = (e) => {
//left click: unveil tile
if (e.type === "click" && this.state.imgAlt === '') {
if (this.props.value < 0) {
this.setState({value: '', imgSrc: mine, imgAlt: "mine"});
}
else {
this.setState({value: this.props.value})
if (this.props.value === 0) {
//automatically left click all 8 surrounding tiles
}
}
}
//right click: mark or unmark tile
else if (e.type === "contextmenu") {
e.preventDefault();
if (this.state.value === '' && this.state.imgAlt !== "mine") {
if (this.state.imgAlt !== '') {
this.setState({imgSrc: null, imgAlt: ''});
}
else {
this.setState({imgSrc: flag, imgAlt: "flag"});
}
}
}
}
render() {
return (
<button
className="tile"
onClick={this.tileClick} onContextMenu={this.tileClick}>
<img src={this.state.imgSrc} alt={this.state.imgAlt} />
{this.state.value}
</button>
);
}
}
export default Tile;
You need the child component to be able to see and do something with the parent's game_logic.tile_values. While it'd be possible to pass the values down from the parent and try to do something with them there, it'd be easier to put the click handlers in the parent so everything can be accessed from there easily.
You'll also need to change around the way you handle state. Having each individual component have its own state will make cross-component communication very difficult, but that's what you need in order for the change in one component (tile) to affect other tiles. Put the state of whether a tile's been revealed or not in the parent.
class Board extends React.Component {
constructor(props) {
super(props);
game_logic.initalizeGame(8,8,10);
game_logic.createGame();
this.state = {
height: 8,
width: 8,
num_mines: 10,
revealed: Array.from(
{ length: 8 },
() => new Array(8).fill(false)
);
}
}
handleReveal = (x, y, visited = new Set()) => {
const tileKey = x + '_' + y;
// if out of bounds, or if visited before, don't do anything
if (visited.has(tileKey) || game_logic.tile_values[x]?.[y] === undefined) return;
visited.add(tileKey);
const isZero = game_logic.tile_values[x][y] === 0;
this.setState({
...this.state,
revealed: this.state.map(
(row, i) => i !== x
? row
: row.map(
(tileState, j) => y === j ? true : tileState
)
)
});
if (isZero) {
handleReveal(x - 1, y);
handleReveal(x + 1, y);
handleReveal(x, y - 1);
handleReveal(x, y + 1);
}
};
Now that the parent component has handleReveal, pass it down to each tile. When there's a left click, call the passed down handleReveal, and have the children tiles decide what image to render by checking whether the parent's revealed state for that X and Y is true or false.
The Tiles probably shouldn't have state themselves except for whether they've been flagged or not. The imgSrc can be determined directly from the props, as can the value. Better not to duplicate state over both parents and children - doing it that way makes things messy and hard to deal with, especially when it comes to updates.
Hello You have to update line
{this.state.value}
with the below
{this.state.value}

Mapbox example as a React component

I am trying to use this fiddle code from Mapbox in my react app. Official code here
So far, this is my code, I am getting this error TypeError: Cannot set property 'accessToken' of undefined I am new to React so having a hard time converting this js code. Any resources/codelinks are appreciated for reference. And how can I make mapbox work as a react component.
import React, { useEffect } from "react";
// import * as d3 from 'd33';
import './StreamGraph.css';
// import 'https://api.mapbox.com/mapbox.js/v3.3.1/mapbox.js';
// import 'https://api.mapbox.com/mapbox.js/v3.3.1/mapbox.css';
// import 'https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js';
// import 'https://code.jquery.com/jquery-3.5.1.js';
// import 'https://api.mapbox.com/mapbox.js/plugins/arc.js/v0.1.0/arc.js';
// import 'https://api.mapbox.com/mapbox.js/plugins/leaflet-markercluster/v1.0.0/leaflet.markercluster.js';
// import 'https://api.mapbox.com/mapbox.js/plugins/leaflet-markercluster/v1.0.0/MarkerCluster.css';
// import 'https://api.mapbox.com/mapbox.js/plugins/leaflet-markercluster/v1.0.0/MarkerCluster.Default.css';
// import mapbox from 'mapbox';
import 'mapbox';
import 'leaflet';
import arc from 'arc';
import largeAirports from '../data/large_airports_only.csv';
import 'mapbox/lib/mapbox.js';
import 'jquery/dist/jquery.min.js';
import 'jquery/src/ajax.js';
import 'ajax/lib/ajax.js';
import 'arc/arc.js';
import 'jquery/src/jquery.js';
import 'mapbox/dist/mapbox-sdk.min.js';
import L from 'leaflet';
import 'leaflet.markercluster/dist/leaflet.markercluster.js';
import 'leaflet.markercluster/dist/MarkerCluster.css';
import 'leaflet.markercluster/dist/MarkerCluster.Default.css';
// import * from 'jquery';
// setting up via node
// var MapboxClient = require('mapbox');
// var client = new MapboxClient('pk.eyJ1Ijoic2FueWFzaW4iLCJhIjoiY2tnbzA2aW9mMGI4cTJybnFvOTVzYm84aCJ9.voOP8PnCpZrEsilFflPswg');
function Mapbox(props) {
useEffect(() => {
console.log(largeAirports);
// feb map for now
var pairs = [[[47.539123535, -122.30667114299999], [47.539238364, -122.30649508]], [[38.690728656, -121.60168457], [47.54095459, -122.311683655]], [[49.297393799, -123.105046199], [48.41784668, -123.388718825]], [[25.065261065, 121.22915591799999], [22.31350708, 113.906000311]], [[34.422586732, 135.237007141], [25.067138672, 121.23148600299999]], [[49.18676861300001, -123.16721866], [49.17910766600001, -123.14927321200001]], [[22.298858643000003, 113.90620006200001], [25.073966333, 121.21734043299999]]]
// declaring for the purpose of checking mapbox in react
var ident_lat = {"KLAX": 47.539123535}
var ident_lon = {"KLAX": -122.30252}
var origin_lat = {"KLAX": -122.30252}
var origin_lon = {"KLAX": -122.30252}
var dest_lat = {"KLAX": -122.30252}
var dest_lon = {"KLAX": -122.30252}
var container = L.DomUtil.get('map');
if(container != null){
container._leaflet_id = null;
}
L.mapbox.accessToken = 'pk.eyJ1Ijoic2FueWFzaW4iLCJhIjoiY2tnbzA2aW9mMGI4cTJybnFvOTVzYm84aCJ9.voOP8PnCpZrEsilFflPswg';
// Animation is non-geographical - lines interpolate in the same amount of time regardless of trip length.
// Show the whole world in this first view.
var map = L.mapbox.map('map')
.setView([20, 0], 2)
.addLayer(L.mapbox.styleLayer('mapbox://styles/mapbox/satellite-v9'));
var credits = L.control.attribution({
prefix: ''
}).addTo(map);
// Disable drag and zoom handlers.
// Making this effect work with zooming and panning
// would require a different technique with different
// tradeoffs.
map.dragging.disable();
map.touchZoom.disable();
map.doubleClickZoom.disable();
map.scrollWheelZoom.disable();
if (map.tap) map.tap.disable();
// Transform the short [lat,lng] format in our
// data into the {x, y} expected by arc.js.
function obj(ll) { return { y: ll[0], x: ll[1] }; }
// var select_id = document.getElementById('select');
// var get_value = select_id.value;
// var get_ident = name_ident.get(get_value)
// console.log("get_ident: ", get_ident);
var counter = 0;
var get_ident = undefined;
if (get_ident != undefined){
// adding marker - airport
var marker = L.marker(new L.LatLng(ident_lat.get(get_ident), ident_lon.get(get_ident)), {
icon: L.mapbox.marker.icon({'marker-symbol':'airport', 'marker-color': '4587f4'})
});
map.addLayer(marker);
}
for (var i = 0; i < pairs.length; i++) {
if (ident_lat.has(get_ident)){
// airport is selected
origin_lat = pairs[i][0][0].toString()
origin_lon = pairs[i][0][1].toString()
dest_lat = pairs[i][1][0].toString()
dest_lon = pairs[i][1][1].toString()
if (JSON.stringify([ident_lat.get(get_ident), ident_lon.get(get_ident)]) == JSON.stringify([origin_lat.slice(0,4), origin_lon.slice(0,4)]) || JSON.stringify([ident_lat.get(get_ident), ident_lon.get(get_ident)]) == JSON.stringify([dest_lat.slice(0,4), dest_lon.slice(0,4)])){
counter = counter + 1;
// filter it out
var generator = new arc.GreatCircle(
obj(pairs[i][0]),
obj(pairs[i][1]));
var line = generator.Arc(100, { offset: 100 });
var newLine = L.polyline(line.geometries[0].coords.map(function(c) {
return c.reverse();
}), {
color: '#fff',
weight: 1,
opacity: 0.5
})
.addTo(map);
var totalLength = newLine._path.getTotalLength();
newLine._path.classList.add('path-start');
newLine._path.style.strokeDashoffset = totalLength;
newLine._path.style.strokeDasharray = totalLength;
setTimeout((function(path) {
return function() {
path.style.strokeDashoffset = 2; // no animation == 0
};
})(newLine._path), i * 2); // no animation == 0
}
console.log("counter: ", counter);
}
else if (get_ident == undefined) {
console.log("else block");
// Transform each pair into a circle using the Arc.js plugin
var generator = new arc.GreatCircle(
obj(pairs[i][0]),
obj(pairs[i][1]));
var line = generator.Arc(100, { offset: 100 });
// Leaflet expects [lat,lng] arrays, but a lot of
// software does the opposite, including arc.js, so flip here.
var newLine = L.polyline(line.geometries[0].coords.map(function(c) {
return c.reverse();
}), {
color: '#fff',
weight: 1,
opacity: 0.5
})
.addTo(map);
var totalLength = newLine._path.getTotalLength();
newLine._path.classList.add('path-start');
// This pair of CSS properties hides the line initially
// See http://css-tricks.com/svg-line-animation-works/ for details on this trick.
newLine._path.style.strokeDashoffset = totalLength;
newLine._path.style.strokeDasharray = totalLength;
// Offset the timeout here: setTimeout makes a function run after a certain number of milliseconds
setTimeout((function(path) {
return function() {
// setting the strokeDashoffset to 0 triggers animation
path.style.strokeDashoffset = 2;
};
})(newLine._path), i * 2);
}
}
});
return (
<div className="mapbox_container">
<div id = "map" className="dark"></div>
</div>
)
}
export default Mapbox;
I'm not familiar with leaflet but, with mapbox-gl, you can set your map as following
import React from "react";
import "./styles.css";
import * as mapboxgl from "mapbox-gl";
mapboxgl.accessToken = "your-token-here";
export default function App() {
React.useEffect(() => {
const map = new mapboxgl.Map({
container: "map",
style: "mapbox://styles/mapbox/light-v9",
zoom: 12,
center: [-122.447303, 37.753574]
});
}, []);
return (
<>
<div id="map" style={{ height: "100vh" }}></div>
</>
);
}
or you can also use react-map-gl like this
import * as React from "react";
import ReactMapGL from "react-map-gl";
export default () => {
const [viewport, setViewport] = React.useState({
width: "100%",
height: "100%",
longitude: 139.63270819862225,
latitude: 35.458058995332536,
zoom: 13.5,
mapStyle: "mapbox://styles/mapbox/streets-v11",
mapboxApiAccessToken:
"your-token-here"
});
return (
<div style={{ height: "100vh" }}>
<ReactMapGL {...viewport} onViewportChange={setViewport} />
</div>
);
};
You can convert the provided example into a React component by:
including mapbox css & js via cdns on index.html
<link
href="https://api.mapbox.com/mapbox.js/v3.3.1/mapbox.css"
rel="stylesheet"
/>
arc.js to make paths curved via cdn on index.html
<script src="https://api.mapbox.com/mapbox.js/plugins/arc.js/v0.1.0/arc.js"></script>
normally you could import directly flights variable via this script <script src='/mapbox.js/assets/data/flights.js'></script>
but it does not seem to work so place the json inside a constant (you can try again if it works for you using the above script)
Now you have all the libraries you need create a custom Mapbox comp as you did in your example by placing the demo code inside a useEffect :
import React, { useEffect } from "react";
import pairs from "./flights.json";
L.mapbox.accessToken =
"pk.eyJ1Ijoic2FueWFzaW4iLCJhIjoiY2tnbzA2aW9mMGI4cTJybnFvOTVzYm84aCJ9.voOP8PnCpZrEsilFflPswg";
export default function Mapbox() {
useEffect(() => {
// console.log(L.mapbox);
const map = L.mapbox
.map("map")
.setView([20, 0], 2)
.addLayer(L.mapbox.styleLayer("mapbox://styles/mapbox/satellite-v9"));
L.control
.attribution({
prefix:
'Flight data from Open Flights, under the ODbL license'
})
.addTo(map);
// Disable drag and zoom handlers.
// Making this effect work with zooming and panning
// would require a different technique with different
// tradeoffs.
map.dragging.disable();
map.touchZoom.disable();
map.doubleClickZoom.disable();
map.scrollWheelZoom.disable();
if (map.tap) map.tap.disable();
// Transform the short [lat,lng] format in our
// data into the {x, y} expected by arc.js.
function obj(ll) {
return { y: ll[0], x: ll[1] };
}
for (var i = 0; i < pairs.length; i++) {
// Transform each pair of coordinates into a pretty
// great circle using the Arc.js plugin, as included above.
var generator = new arc.GreatCircle(obj(pairs[i][0]), obj(pairs[i][1]));
var line = generator.Arc(100, { offset: 10 });
// Leaflet expects [lat,lng] arrays, but a lot of
// software does the opposite, including arc.js, so
// we flip here.
var newLine = L.polyline(
line.geometries[0].coords.map(function (c) {
return c.reverse();
}),
{
color: "#fff",
weight: 1,
opacity: 0.5
}
).addTo(map);
var totalLength = newLine._path.getTotalLength();
newLine._path.classList.add("path-start");
// This pair of CSS properties hides the line initially
// See http://css-tricks.com/svg-line-animation-works/
// for details on this trick.
newLine._path.style.strokeDashoffset = totalLength;
newLine._path.style.strokeDasharray = totalLength;
// Offset the timeout here: setTimeout makes a function
// run after a certain number of milliseconds - in this
// case we want each flight path to be staggered a bit.
setTimeout(
(function (path) {
return function () {
// setting the strokeDashoffset to 0 triggers
// the animation.
path.style.strokeDashoffset = 0;
};
})(newLine._path),
i * 100
);
}
}, []);
return <div id="map" className="light"></div>;
}
Demo

How to add a custom geolocate me button in Mapbox GL JS?

I am trying to add a custom Geolocate me button to my map and it kind of works, however only if I also add the standard icon from Mapbox. The code below works, but if I remove the line map.addControl(geolocate, 'top-right');, my left button stops working.
// Initialize the geolocate control.
var geolocate = new mapboxgl.GeolocateControl({
positionOptions: {
enableHighAccuracy: true
},
trackUserLocation: true
});
// Add the control to the map.
map.addControl(geolocate, 'top-right');
class ToggleControl {
constructor(options) {
this._options = Object.assign({}, this._options, options)
}
onAdd(map, cs) {
this.map = map;
this.container = document.createElement('div');
this.container.className = `${this._options.className}`;
const button = this._createButton('monitor_button')
this.container.appendChild(button);
return this.container;
}
onRemove() {
this.container.parentNode.removeChild(this.container);
this.map = undefined;
}
_createButton(className) {
const el = window.document.createElement('button')
el.className = className;
el.textContent = 'Use my location';
el.addEventListener('click', (e) => {
geolocate.trigger();
}, false)
return el;
}
}
const toggleControl = new ToggleControl({
className: 'mapboxgl-ctrl'
})
map.addControl(toggleControl, 'top-left')
screenshot - in blue is what I want to keep, in Red to remove
Instead of creating new mapboxgl.GeolocateControl Instance, you can extend mapboxgl.GeolocateControl Class like below:
class ToggleControl extends mapboxgl.GeolocateControl {
_onSuccess(position) {
this.map.flyTo({
center: [position.coords.longitude, position.coords.latitude],
zoom: 17,
bearing: 0,
pitch: 0
});
}
onAdd(map, cs) {
this.map = map;
this.container = document.createElement('div');
this.container.className = `mapboxgl-ctrl`;
const button = this._createButton('monitor_button')
this.container.appendChild(button);
return this.container;
}
_createButton(className) {
const el = window.document.createElement('button')
el.className = className;
el.textContent = 'Use my location';
el.addEventListener('click', () => {
this.trigger();
});
this._setup = true;
return el;
}
}
const toggleControl = new ToggleControl({})
map.addControl(toggleControl, 'top-left')
Other Helpful links:
Mapbox gl Class URL: Have a look https://github.com/mapbox/mapbox-gl-js/blob/520a4ca3b9d260693f1e7f4af30d8b91c8f8bb08/src/ui/control/geolocate_control.js#L97-L565
How to wrap a ui control (mapbox geolocation control)
You can easily add custom button in mapBox .
add some css:
.map_btn{position: absolute; top: 25; width: 36px;height:36px;border-radius:50%;border:none;float:right;margin-right:5px;zindex:111;left:85%;background:#e8faa7;}
add the button
<button class="map_btn"><img src="loc1.png" width=30/>
</button>
<div id="map"></div>
Add your code to your button
$('.map_btn').click(function(e)
{
app.preloader.show();
getLocation() ;
});

How to correctly wait on state to update/render instead of using a delay/timeout function?

I will attempt to keep this brief, but I am not 100% sure of the correct method of achieving what I am aiming for. I have been thrown in the deep end with React with not much training, so I have most likely been going about most of this component incorrectly, a point in the right direction will definitely help, I don't really expect for someone to completely redo my component for me as it's quite long.
I have a navigation bar SubNav, that finds the currently active item based upon the url/path, this will then move an underline element that inherits the width of the active element. To do this, I find the position of the active item and position accordingly. The same goes for when a user hovers over another navigation item, or when the window resizes it adjusts the position accordingly.
I also have it when at lower resolutions, when the nav gets cut off to have arrows appear to scroll left/right on the navigation to view all navigation items.
Also, if on a lower resolution and the currently active navigation item is off screen, the navigation will scroll to that item and then position the underline correctly.
This, currently works as I have it in my component, this issue is, I don't believe I have done this correctly, I am using a lodash function delay to delay at certain points (I guess to get the correct position of certain navigation items, as it isn't correct at the time of the functions call), which I feel is not the way to go. This is all based on how fast the page loads etc and will not be the same for each user.
_.delay(
() => {
setSizes(getSizes()),
updateRightArrow(findItemInView(elsRef.length - 1)),
updateLeftArrow(findItemInView(0));
},
400,
setArrowStyle(styling)
);
Without using the delay, the values coming back from my state are incorrect as they haven't been set yet.
My question is, how do I go about this correctly? I know my code below is a bit of a read but I have provided a CODESANBOX to play about with.
I have 3 main functions, that all sort of rely on one another:
getPostion()
This function finds the active navigation item, checks if it's within the viewport, if it is not, then it changes the left position of the navigation so it's the leftmost navigation item on the screen, and via setSizes(getSizes()) moves the underline directly underneath.
getSizes()
This is called as an argument within setSizes to update the sizes state, which returns the left and right boundaries of all navigation items
getUnderlineStyle()
This is called as an argument within setUnderLineStyle within the getSizes() function to update the position of the underline object in relation to the position of active navigation item grabbed from the sizes state, but I have to pass the sizesObj as an argument in setSizes as the state has not been set. I think this is where my confusion began, I think I was under the impression, that when I set the state, I could then access it. So, I started using delay to combat.
Below is my whole Component, but can be seen working in CODESANBOX
import React, { useEffect, useState, useRef } from "react";
import _ from "lodash";
import { Link, Route } from "react-router-dom";
import "../../scss/partials/_subnav.scss";
const SubNav = props => {
const subNavLinks = [
{
section: "Link One",
path: "link1"
},
{
section: "Link Two",
path: "link2"
},
{
section: "Link Three",
path: "link3"
},
{
section: "Link Four",
path: "link4"
},
{
section: "Link Five",
path: "link5"
},
{
section: "Link Six",
path: "link6"
},
{
section: "Link Seven",
path: "link7"
},
{
section: "Link Eight",
path: "link8"
}
];
const currentPath =
props.location.pathname === "/"
? "link1"
: props.location.pathname.replace(/\//g, "");
const [useArrows, setUseArrows] = useState(false);
const [rightArrow, updateRightArrow] = useState(false);
const [leftArrow, updateLeftArrow] = useState(false);
const [sizes, setSizes] = useState({});
const [underLineStyle, setUnderLineStyle] = useState({});
const [arrowStyle, setArrowStyle] = useState({});
const [activePath, setActivePath] = useState(currentPath);
const subNavRef = useRef("");
const subNavListRef = useRef("");
const arrowRightRef = useRef("");
const arrowLeftRef = useRef("");
let elsRef = Array.from({ length: subNavLinks.length }, () => useRef(null));
useEffect(
() => {
const reposition = getPosition();
subNavArrows(window.innerWidth);
if (!reposition) {
setSizes(getSizes());
}
window.addEventListener(
"resize",
_.debounce(() => subNavArrows(window.innerWidth))
);
window.addEventListener("resize", () => setSizes(getSizes()));
},
[props]
);
const getPosition = () => {
const activeItem = findActiveItem();
const itemHidden = findItemInView(activeItem);
if (itemHidden) {
const activeItemBounds = elsRef[
activeItem
].current.getBoundingClientRect();
const currentPos = subNavListRef.current.getBoundingClientRect().left;
const arrowWidth =
arrowLeftRef.current !== "" && arrowLeftRef.current !== null
? arrowLeftRef.current.getBoundingClientRect().width
: arrowRightRef.current !== "" && arrowRightRef.current !== null
? arrowRightRef.current.getBoundingClientRect().width
: 30;
const activeItemPos =
activeItemBounds.left * -1 + arrowWidth + currentPos;
const styling = {
left: `${activeItemPos}px`
};
_.delay(
() => {
setSizes(getSizes()),
updateRightArrow(findItemInView(elsRef.length - 1)),
updateLeftArrow(findItemInView(0));
},
400,
setArrowStyle(styling)
);
return true;
}
return false;
};
const findActiveItem = () => {
let activeItem;
subNavLinks.map((i, index) => {
const pathname = i.path;
if (pathname === currentPath) {
activeItem = index;
return true;
}
return false;
});
return activeItem;
};
const getSizes = () => {
const rootBounds = subNavRef.current.getBoundingClientRect();
const sizesObj = {};
Object.keys(elsRef).forEach(key => {
const item = subNavLinks[key].path;
const el = elsRef[key];
const bounds = el.current.getBoundingClientRect();
const left = bounds.left - rootBounds.left;
const right = rootBounds.right - bounds.right;
sizesObj[item] = { left, right };
});
setUnderLineStyle(getUnderlineStyle(sizesObj));
return sizesObj;
};
const getUnderlineStyle = (sizesObj, active) => {
sizesObj = sizesObj.length === 0 ? sizes : sizesObj;
active = active ? active : currentPath;
if (active == null || Object.keys(sizesObj).length === 0) {
return { left: "0", right: "100%" };
}
const size = sizesObj[active];
const styling = {
left: `${size.left}px`,
right: `${size.right}px`,
transition: `left 300ms, right 300ms`
};
return styling;
};
const subNavArrows = windowWidth => {
let totalSize = sizeOfList();
_.delay(
() => {
updateRightArrow(findItemInView(elsRef.length - 1)),
updateLeftArrow(findItemInView(0));
},
300,
setUseArrows(totalSize > windowWidth)
);
};
const sizeOfList = () => {
let totalSize = 0;
Object.keys(elsRef).forEach(key => {
const el = elsRef[key];
const bounds = el.current.getBoundingClientRect();
const width = bounds.width;
totalSize = totalSize + width;
});
return totalSize;
};
const onHover = active => {
setUnderLineStyle(getUnderlineStyle(sizes, active));
setActivePath(active);
};
const onHoverEnd = () => {
setUnderLineStyle(getUnderlineStyle(sizes, currentPath));
setActivePath(currentPath);
};
const scrollRight = () => {
const currentPos = subNavListRef.current.getBoundingClientRect().left;
const arrowWidth = arrowRightRef.current.getBoundingClientRect().width;
const subNavOffsetWidth = subNavRef.current.clientWidth;
let nextElPos;
for (let i = 0; i < elsRef.length; i++) {
const bounds = elsRef[i].current.getBoundingClientRect();
if (bounds.right > subNavOffsetWidth) {
nextElPos = bounds.left * -1 + arrowWidth + currentPos;
break;
}
}
const styling = {
left: `${nextElPos}px`
};
_.delay(
() => {
setSizes(getSizes()),
updateRightArrow(findItemInView(elsRef.length - 1)),
updateLeftArrow(findItemInView(0));
},
500,
setArrowStyle(styling)
);
};
const scrollLeft = () => {
const windowWidth = window.innerWidth;
// const lastItemInView = findLastItemInView();
const firstItemInView = findFirstItemInView();
let totalWidth = 0;
const hiddenEls = elsRef
.slice(0)
.reverse()
.filter((el, index) => {
const actualPos = elsRef.length - 1 - index;
if (actualPos >= firstItemInView) return false;
const elWidth = el.current.getBoundingClientRect().width;
const combinedWidth = elWidth + totalWidth;
if (combinedWidth > windowWidth) return false;
totalWidth = combinedWidth;
return true;
});
const targetEl = hiddenEls[hiddenEls.length - 1];
const currentPos = subNavListRef.current.getBoundingClientRect().left;
const arrowWidth = arrowLeftRef.current.getBoundingClientRect().width;
const isFirstEl =
targetEl.current.getBoundingClientRect().left * -1 + currentPos === 0;
const targetElPos = isFirstEl
? targetEl.current.getBoundingClientRect().left * -1 + currentPos
: targetEl.current.getBoundingClientRect().left * -1 +
arrowWidth +
currentPos;
const styling = {
left: `${targetElPos}px`
};
_.delay(
() => {
setSizes(getSizes()),
updateRightArrow(findItemInView(elsRef.length - 1)),
updateLeftArrow(findItemInView(0));
},
500,
setArrowStyle(styling)
);
};
const findItemInView = pos => {
const rect = elsRef[pos].current.getBoundingClientRect();
return !(
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth
);
};
const findLastItemInView = () => {
let lastItem;
for (let i = 0; i < elsRef.length; i++) {
const isInView = !findItemInView(i);
if (isInView) {
lastItem = i;
}
}
return lastItem;
};
const findFirstItemInView = () => {
let firstItemInView;
for (let i = 0; i < elsRef.length; i++) {
const isInView = !findItemInView(i);
if (isInView) {
firstItemInView = i;
break;
}
}
return firstItemInView;
};
return (
<div
className={"SubNav" + (useArrows ? " SubNav--scroll" : "")}
ref={subNavRef}
>
<div className="SubNav-content">
<div className="SubNav-menu">
<nav className="SubNav-nav" role="navigation">
<ul ref={subNavListRef} style={arrowStyle}>
{subNavLinks.map((el, i) => (
<Route
key={i}
path="/:section?"
render={() => (
<li
ref={elsRef[i]}
onMouseEnter={() => onHover(el.path)}
onMouseLeave={() => onHoverEnd()}
>
<Link
className={
activePath === el.path
? "SubNav-item SubNav-itemActive"
: "SubNav-item"
}
to={"/" + el.path}
>
{el.section}
</Link>
</li>
)}
/>
))}
</ul>
</nav>
</div>
<div
key={"SubNav-underline"}
className="SubNav-underline"
style={underLineStyle}
/>
</div>
{leftArrow ? (
<div
className="SubNav-arrowLeft"
ref={arrowLeftRef}
onClick={scrollLeft}
/>
) : null}
{rightArrow ? (
<div
className="SubNav-arrowRight"
ref={arrowRightRef}
onClick={scrollRight}
/>
) : null}
</div>
);
};
export default SubNav;
You can make use of useLayoutEffect hook to determine whether the values have been updated and take an action. Since you want to determine whether all the values has been updated, you need to compare old and new values in useEffect. You can refer to the below post to know how to write a usePrevious custom hook
How to compare oldValues and newValues on React Hooks useEffect?
const oldData = usePrevious({ rightArrow, leftArrow, sizes});
useLayoutEffect(() => {
const {rightArrow: oldRightArrow, leftArrow: oldLeftArrow, sizes: oldSizes } = oldData;
if(oldRightArrow !== rightArrow && oldLeftArrow !== leftArrow and oldSizes !== sizes) {
setArrowStyle(styling)
}
}, [rightArrow, leftArrow, sizes])
I think the reason of your delay is necessary here since you calculate based on rectangles of the first and the last element which are affected when you click on button and do animation of scrolling 500ms. So as a result your calculation needs to wait for animation to be done. change the number of animation and delay you will see the relation.
the style I meant.
#include transition(all 500ms ease);
In short, I think what you are using is the right way as long as you have animations related to the calculation.
setState takes an optional second argument which is a callback that executes after the state has been updated and the component has been re-rendered.
Another option is the componentDidUpdate lifecycle method.

Categories

Resources