todos-contra-el-fuego-web/imports/ui/pages/FiresMap/FiresMap.js
vjrj bc778bfd97 deps: react-leaflet 1.8 -> 4.2 (+ leaflet 1.9) — the last legacy react-* lib
Ground-up hooks rewrite of the app's core map. Removes the final batch of
React-19-blocking warnings (legacy context on Map/LayersControl/TileLayer/
Marker/CircleMarker/Circle/Tooltip + ReactDOM.render from the old controls).

Core: <Map> -> <MapContainer>; .leafletElement refs (6 files) -> the map/
layer instances directly, via a <MapReady> child (useMap) + plain refs on
Marker/Circle/GeoJSON; controlled viewport (onViewportChanged + state.center/
zoom) -> <MapEvents> (useMapEvents moveend/zoomend) + imperative setView;
onClick -> eventHandlers={{click}}; Path styling -> pathOptions/style;
subsUnion takes the L.Map directly.

The 4 v1-only plugins reimplemented (new in-repo helpers under Maps/):
- react-leaflet-control -> MapControl (L.Control + createPortal)
- react-leaflet-google  -> GoogleMutantLayer (createLayerComponent +
  leaflet.gridlayer.googlemutant; Google Maps API already loaded by Gkeys)
- react-leaflet-fullscreen -> createControlComponent + leaflet.fullscreen
- leaflet-sleep / leaflet-graphicscale kept as vanilla (work on leaflet 1.9)

Browser-verified: /fires (tiles, OSM+Google layer switch, custom control,
fullscreen, graphic scale, pan/zoom -> re-fetch, no NaN), fire detail
(GeoJSON rect + fitBounds), home (3 maps coexist; SelectionMap draggable
marker -> updatePosition + distance circle). Console now shows 0 React
warnings on home/fires. REST smoke byte-identical.
2026-07-21 18:45:15 +02:00

512 lines
20 KiB
JavaScript

/* global Counter */
/* eslint-disable import/no-absolute-path */
/* eslint-disable react/jsx-indent-props */
/* eslint-disable react/jsx-indent */
import React, { Fragment } from 'react';
import PropTypes from 'prop-types';
import { ButtonGroup, Row, Col, Form } from 'react-bootstrap';
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
import { withTracker } from 'meteor/react-meteor-data';
import { Tracker } from 'meteor/tracker';
import { Helmet } from 'react-helmet-async';
import { Trans, withTranslation } from 'react-i18next';
import { MapContainer } from 'react-leaflet';
import MapControl from '/imports/ui/components/Maps/MapControl';
import { MapReady, MapEvents } from '/imports/ui/components/Maps/MapBridge';
import LoadingBar from '/imports/ui/components/Loading/LoadingBar';
import _ from 'lodash';
import store from 'store';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet-graphicscale/dist/Leaflet.GraphicScale.min.css';
import 'leaflet-graphicscale/dist/Leaflet.GraphicScale.min.js';
import 'leaflet-sleep/Leaflet.Sleep.js';
import geolocation from '/imports/startup/client/geolocation';
import CenterInMyPosition from '/imports/ui/components/CenterInMyPosition/CenterInMyPosition';
import FireList from '/imports/ui/components/Maps/FireList';
import FireListUnion from '/imports/ui/components/Maps/FireListUnion';
import subsUnion from '/imports/ui/components/Maps/SubsUnion/SubsUnion';
import DefMapLayers from '/imports/ui/components/Maps/DefMapLayers';
import ActiveFiresCollection from '/imports/api/ActiveFires/ActiveFires';
import ActiveFiresUnion from '/imports/api/ActiveFiresUnion/ActiveFiresUnion';
import FireAlertsCollection from '/imports/api/FireAlerts/FireAlerts';
import IndustriesCollection, { industriesRemap } from '/imports/api/Industries/Industries';
import FalsePositivesCollection, { falsePositivesRemap } from '/imports/api/FalsePositives/FalsePositives';
import SiteSettings from '/imports/api/SiteSettings/SiteSettings';
import { isNotHomeAndMobile, isChrome, isAnyMobile } from '/imports/ui/components/Utils/isMobile';
import { isHome } from '/imports/ui/components/Utils/location';
import ShareIt from '/imports/ui/components/ShareIt/ShareIt';
import FullScreenMap from '/imports/ui/components/Maps/FullScreenMap';
import LocationAutocomplete from '/imports/ui/components/LocationAutocomplete/LocationAutocomplete';
import Gkeys from '/imports/startup/client/Gkeys';
import FireStats from './FireStats';
import './FiresMap.scss';
const MAXZOOM = 6;
const MAXZOOMREACTIVE = 6;
const DEFZOOM = 8;
const zoom = new ReactiveVar(DEFZOOM);
// https://en.wikipedia.org/wiki/Geographical_midpoint_of_Europe
const center = new ReactiveVar([53.5775, 3.106111]);
const mapSize = new ReactiveVar();
const marks = new ReactiveVar(true);
const showUnion = new ReactiveVar(true);
// Remove map in subscription
class FiresMap extends React.Component {
constructor(props) {
super(props);
this.state = {
viewport: {
center: props.center,
zoom: props.zoom
},
init: false,
useMarkers: props.marks,
scaleAdded: false,
moving: false,
showSubsUnion: props.showUnion
};
const self = this;
// viewportchange
// https://stackoverflow.com/questions/23123138/perform-debounce-in-react-js
this.debounceView = _.debounce((viewport) => {
self.handleViewportChange(viewport);
}, 1500);
this.onViewportChanged = this.onViewportChanged.bind(this);
this.onMoveEnd = this.onMoveEnd.bind(this);
this.onMoveStart = this.onMoveStart.bind(this);
this.handleMoveEnd = this.handleMoveEnd.bind(this);
this.handleMapReady = this.handleMapReady.bind(this);
}
// v4: combined moveend/zoomend handler fed by <MapEvents> — clears the
// moving flag and reports the new viewport (was the v1 onMoveend +
// onViewportChanged props on <Map>).
handleMoveEnd() {
this.onMoveEnd();
if (!this.map) return;
const c = this.map.getCenter();
this.onViewportChanged({ center: [c.lat, c.lng], zoom: this.map.getZoom() });
}
// v4: MapReady hands us the ready Leaflet map (replaces ref/.leafletElement)
handleMapReady(map) {
this.map = map;
this.handleLeafletLoad(map);
}
componentDidMount() {
const self = this;
Gkeys.load(() => {
self.setState({ init: true });
});
}
shouldComponentUpdate(nextProps, nextState) {
const notMoving = !nextState.moving;
return notMoving;
}
onMoveStart() {
// this.setState({ moving: true });
this.state.moving = true;
}
onMoveEnd() {
// this.setState({ moving: false });
this.state.moving = false;
}
onViewportChanged(viewport) {
this.debounceView.cancel();
this.debounceView(viewport);
}
onAutocompleteChange(value) {
this.handleViewportChange({ center: [value.lat, value.lng], zoom: DEFZOOM - 1 });
}
getMap() {
return this.map;
}
setShowSubsUnion(showSubsUnion) {
this.setState({ showSubsUnion });
store.set('firesmap_showunion', showSubsUnion);
}
componentDidUnMount() {
// this.setState({ init: true });
}
handleViewportChange(viewport) {
console.log(`Viewport changed: ${JSON.stringify(viewport)}`);
if (this.map) {
const bounds = this.getMap().getBounds();
// console.log(bounds);
mapSize.set([bounds.getNorthEast(), bounds.getSouthWest()]);
store.set('firesmap_center', viewport.center);
store.set('firesmap_zoom', viewport.zoom);
if (viewport.zoom > this.state.viewport.zoom) {
this.state.viewport = viewport;
if (Meteor.isDevelopment) console.log('Don\'t query we are in the same point');
return;
}
zoom.set(viewport.zoom);
center.set(viewport.center);
this.state.viewport = viewport;
}
}
centerOnUserLocation(viewport) {
this.setState({ viewport: { center: viewport.center, zoom: 10 } });
// v4 map is uncontrolled: move it imperatively
if (this.map) this.map.setView(viewport.center, 10);
}
useMarkers(use) {
this.setState({ useMarkers: use });
store.set('firesmap_marks', use);
marks.set(use);
}
addScale(map) {
// https://www.npmjs.com/package/leaflet-graphicscale
const options = {
fill: 'fill',
showSubunits: true
};
L.control.graphicScale([options]).addTo(map);
}
handleLeafletLoad(map) {
if (map && !this.state.moving) {
const lmap = map;
try {
const bounds = lmap.getBounds();
mapSize.set([bounds.getNorthEast(), bounds.getSouthWest()]);
if (!this.state.scaleAdded) {
this.addScale(lmap);
this.state.scaleAdded = true;
}
} catch (e) {
console.warn('Failed to set map bounds and scale');
}
this.state.union = subsUnion(this.state.union, {
map,
subs: this.props.userSubs,
show: this.state.showSubsUnion,
bounds: this.props.userSubsBounds,
fromServer: true,
fit: false
});
}
}
render() {
const { t } = this.props;
const loading = this.props.loading || !this.props.subsready;
console.log(`Rendering ${loading ? 'loading' : 'LOADED'}, zoom ${this.state.viewport.zoom}, map ${this.props.activefires.length + this.props.firealerts.length} of ${this.props.activefirestotal} total. False positives: ${this.props.falsePositives.length}. Reactive ${this.state.viewport.zoom >= MAXZOOMREACTIVE}`);
const title = `${t('AppName')}: ${t('Fuegos activos')}`;
if (Meteor.isDevelopment && this.props.activefires.length === 1) console.log(`Active fire: ${JSON.stringify(this.props.activefires[0])}`);
if (Meteor.isDevelopment) console.log(`Active fires total: ${this.props.activefires.length}`);
if (Meteor.isDevelopment) console.log(`Active fires union total: ${this.props.activefiresunion.length}`);
if (Meteor.isDevelopment) console.log(`False positives total: ${this.props.falsePositives.length}`);
if (Meteor.isDevelopment) console.log(`Fire alerts total: ${this.props.firealerts.length}`);
if (Meteor.isDevelopment) console.log(`Industries total: ${this.props.industries.length}`);
if (!this.state.init) {
return <div />;
}
return (
/* Large number of markers:
https://stackoverflow.com/questions/43015854/large-dataset-of-markers-or-dots-in-leaflet/43019740#43019740 */
<div
ref={(divElement) => { this.divElement = divElement; }}
>
{ !isHome() &&
<Helmet>
<title>{title}</title>
<meta name="description" content={t('Fuegos activos en el mundo actualizados en tiempo real')} />
</Helmet> }
<h4 className="page-header"><Trans parent="span">Fuegos activos</Trans></h4>
<Row>
<Col xs={12} sm={6} md={6} lg={6}>
{isNotHomeAndMobile() &&
<Fragment>
<LocationAutocomplete
focusInput={false}
label=""
placeHolder="Escribe un lugar para centrar el mapa"
helpText=""
onChange={value => this.onAutocompleteChange(value)}
/>
<Form.Check
type="checkbox"
id="showSubsUnion"
defaultChecked={this.state.showSubsUnion}
onClick={e => this.setShowSubsUnion(e.target.checked)}
label={<span><Trans className="mark-checkbox" parent="span">Resaltar en verde el área vigilada por nuestros usuarios/as</Trans>&nbsp;(*)</span>}
/>
{(this.state.viewport.zoom >= MAXZOOM) &&
<Form.Check
type="checkbox"
id="useMarkers"
defaultChecked={this.state.useMarkers}
onClick={e => this.useMarkers(e.target.checked)}
label={<Trans className="mark-checkbox" parent="span">Resaltar los fuegos con un marcador</Trans>}
/>}
</Fragment>}
</Col>
<Col xs={12} sm={6} md={6} lg={6} >
<p className="firesmap-legend">
{ (this.props.activefires.length + this.props.firealerts.length) === 0 ?
<Fragment><Trans parent="span" i18nKey="noActiveFireInMapCount">No hay fuegos activos en esta zona del mapa. <strong>{{ countTotal: this.props.activefirestotal }}</strong> fuegos activos en el mundo.</Trans> <FireStats loadingAll={loading} {... this.props} /></Fragment> :
<Fragment><Trans parent="span" i18nKey="activeFireInMapCount">En rojo, <strong>{{ inMap: this.props.activefiresunion.length + this.props.firealerts.length }}</strong> fuegos activos. <strong>{{ countTotal: this.props.activefirestotal }}</strong> fuegos activos en el mundo.</Trans> <FireStats loadingAll={loading} {... this.props} /></Fragment>
}
</p>
{isNotHomeAndMobile() && this.props.firealerts.length > 0 &&
<p className="firesmap-legend"><Trans parent="span" i18nKey="activeNeigFireInMapCount">En naranja, los fuegos notificados por nuestros usuarios/as recientemente.</Trans></p> }
{ /* disabled */ false && isNotHomeAndMobile() && this.props.firealerts.length === 0 && !isAnyMobile &&
<p className="firesmap-legend"><Trans parent="span" i18nKey="noActiveNeigFireInMap">No hay fuegos notificados recientemente por nuestros usuarios/as en esta zona.</Trans></p> }
<p className="firesmap-note">
<em>{ this.state.viewport.zoom >= MAXZOOMREACTIVE ?
<Trans>Los fuegos activos se actualizan en tiempo cuasi real.</Trans> :
<Trans>Haga zoom en una zona de su interés si quiere que los fuegos se actualicen en tiempo real.</Trans>
}
</em>
</p>
</Col>
</Row>
{loading ?
<LoadingBar progress={this.props.loading ? 0.9 : 1} />
: ''}
{/* https://github.com/CliffCloud/Leaflet.Sleep */}
<MapContainer
className="firesmap-leaflet-container"
minZoom={5}
center={this.state.viewport.center}
zoom={this.state.viewport.zoom}
preferCanvas
sleep={isHome() && !isChrome}
sleepTime={10750}
wakeTime={750}
sleepNote
hoverToWake={false}
wakeMessage={this.props.t('Pulsa para activar')}
wakeMessageTouch={this.props.t('Pulsa para activar')}
sleepOpacity={0.6}
>
<MapReady onReady={this.handleMapReady} />
<MapEvents handlers={{
movestart: this.onMoveStart,
zoomstart: this.onMoveStart,
moveend: this.handleMoveEnd,
zoomend: this.handleMoveEnd
}}
/>
{/* http://wiki.openstreetmap.org/wiki/Tile_servers */}
{!this.props.loading &&
<Fragment>
<FireList
t={t}
history={this.props.history}
fires={this.props.falsePositives}
scale={this.state.viewport.zoom >= MAXZOOM}
useMarkers={this.state.useMarkers}
nasa={false}
falsePositives
neighbour={false}
industries={false}
/>
{ Meteor.isDevelopment &&
<FireListUnion
t={t}
history={this.props.history}
firesUnion={this.props.activefiresunion}
nasa
/>
}
<FireList
t={t}
history={this.props.history}
fires={this.props.industries}
scale={this.state.viewport.zoom >= MAXZOOM}
useMarkers={this.state.useMarkers}
nasa={false}
neighbour={false}
falsePositives={false}
industries
/>
<FireList
t={t}
history={this.props.history}
fires={this.props.activefires}
scale={this.state.viewport.zoom >= MAXZOOM}
useMarkers={this.state.useMarkers}
nasa
neighbour={false}
falsePositives={false}
industries={false}
/>
<FireList
t={t}
history={this.props.history}
fires={this.props.firealerts}
scale={this.state.viewport.zoom >= MAXZOOM}
useMarkers={this.state.useMarkers}
nasa={false}
neighbour
falsePositives={false}
industries={false}
/>
</Fragment> }
<DefMapLayers gray />
<MapControl position="topright" >
<ButtonGroup>
<CenterInMyPosition onClick={viewport => this.centerOnUserLocation(viewport)} onlyIcon {... this.props} />
</ButtonGroup>
</MapControl>
<FullScreenMap />
</MapContainer>
<Row>
<Col xs={12} sm={12} md={12} lg={12}>
<p className="firesmap-footnote"><span style={{ paddingRight: '5px' }}>(*)</span><Trans i18nKey="mapPrivacy" parent="span"><em>Para preservar la privacidad de nuestros usuarios/as, los datos reflejados están aleatoriamente alterados y son solo orientativos.</em></Trans></p>
<p className="firesmap-footnote"><em><Trans>Nota: Las nubes pueden entorpecer la detección de fuegos activos.</Trans></em></p>
</Col>
</Row>
{ !isHome() &&
<ShareIt title={title} />
}
</div>
);
}
}
FiresMap.propTypes = {
loading: PropTypes.bool.isRequired,
subsready: PropTypes.bool.isRequired,
userSubs: PropTypes.string,
userSubsBounds: PropTypes.string,
activefires: PropTypes.arrayOf(PropTypes.object).isRequired,
activefiresunion: PropTypes.arrayOf(PropTypes.object).isRequired,
lastFireDetected: PropTypes.object,
firealerts: PropTypes.arrayOf(PropTypes.object).isRequired,
falsePositives: PropTypes.arrayOf(PropTypes.object).isRequired,
industries: PropTypes.arrayOf(PropTypes.object).isRequired,
lastCheck: PropTypes.instanceOf(Date),
activefirestotal: PropTypes.number.isRequired,
activefiresuniontotal: PropTypes.number.isRequired,
center: PropTypes.arrayOf(PropTypes.number),
zoom: PropTypes.number,
marks: PropTypes.bool.isRequired,
showUnion: PropTypes.bool.isRequired,
history: PropTypes.object.isRequired,
t: PropTypes.func.isRequired
};
let geoInit = true;
export default withTranslation()(withTracker(() => {
// const firesType = match.params.type;
// const withIndustries = firesType === 'with-industries';
// console.log(`With industries: ${withIndustries}`);
let subscription;
let subscriptionUnion;
let alertSubscription;
const centerStored = store.get('firesmap_center');
const zoomStored = store.get('firesmap_zoom');
const marksStored = store.get('firesmap_marks');
const showUnionStored = store.get('firesmap_showunion');
zoom.set(zoomStored || DEFZOOM);
center.set(centerStored || [0, 0]);
if (typeof marksStored === 'boolean') {
marks.set(marksStored);
}
if (typeof showUnionStored === 'boolean') {
showUnion.set(showUnionStored);
}
// Disable, because this increase the number of fires by one
// Meteor.subscribe('lastFireDetected');
Tracker.autorun(() => {
if ((centerStored !== [0, 0] || geolocation.get()) && geoInit) {
center.set(centerStored || geolocation.get());
// console.log(`Geolocation ${geolocation.get()}`);
geoInit = false;
}
if (mapSize.get() && mapSize.get()[0].lng && mapSize.get()[1].lat) {
subscription = Meteor.subscribe(
'activefiresmyloc',
mapSize.get()[0].lng,
mapSize.get()[0].lat,
mapSize.get()[1].lng,
mapSize.get()[1].lat,
marks.get() && zoom.get() >= MAXZOOM
);
subscriptionUnion = Meteor.subscribe(
'activefiresunionmyloc',
mapSize.get()[0].lng,
mapSize.get()[0].lat,
mapSize.get()[1].lng,
mapSize.get()[1].lat,
false
);
alertSubscription = Meteor.subscribe(
'fireAlerts',
mapSize.get()[0].lng,
mapSize.get()[0].lat,
mapSize.get()[1].lng,
mapSize.get()[1].lat
);
/* if (withIndustries) {
Meteor.subscribe(
'industriesMyloc',
mapSize.get()[0].lng,
mapSize.get()[0].lat,
mapSize.get()[1].lng,
mapSize.get()[1].lat
);
} */
}
});
Meteor.subscribe('activefirestotal');
Meteor.subscribe('activefiresuniontotal');
const settingsSubs = Meteor.subscribe('settings');
const lastCheck = SiteSettings.findOne({ name: 'last-fire-check' });
const userSubs = SiteSettings.findOne({ name: 'subs-public-union' });
const userSubsBounds = SiteSettings.findOne({ name: 'subs-public-union-bounds' });
const fireAlerts = FireAlertsCollection.find().fetch();
const falsePositives = FalsePositivesCollection.find().fetch().map(falsePositivesRemap);
const industries = IndustriesCollection.find().fetch().map(industriesRemap);
const lastFireDetected = ActiveFiresCollection.findOne({}, { sort: { when: -1 } });
return {
loading: Meteor.status().status !== 'connected' || !subscription ? true : !(subscription.ready() && settingsSubs.ready() && alertSubscription.ready() && settingsSubs.ready() && subscriptionUnion.ready()),
userSubs: userSubs ? userSubs.value : null,
userSubsBounds: userSubs ? userSubsBounds.value : null,
subsready: settingsSubs.ready(),
// Not reactive query depending on zoom level
activefires: ActiveFiresCollection.find({}, { reactive: zoom.get() >= MAXZOOMREACTIVE }).fetch(),
activefiresunion: ActiveFiresUnion.find({}, { reactive: zoom.get() >= MAXZOOMREACTIVE }).fetch(),
activefirestotal: Counter.get('countActiveFires') + fireAlerts.length,
activefiresuniontotal: Counter.get('countActiveFiresUnion') + fireAlerts.length,
firealerts: fireAlerts,
falsePositives,
industries,
lastCheck: lastCheck ? lastCheck.value : null,
lastFireDetected,
center: center.get(),
marks: marks.get(),
showUnion: showUnion.get(),
zoom: zoom.get()
};
})(FiresMap));