todos-contra-el-fuego-web/imports/ui/pages/FiresMap/FiresMap.js
vjrj bba2ad5523
All checks were successful
build-image / test (push) Successful in 2m22s
build-image / build (push) Successful in 11m25s
fix(fires): el mapa de /fires ya no se queda en gris
Causa raíz, encontrada reproduciéndolo con el bundle de producción en local
—con el servidor de desarrollo NO se ve, y por eso llevaba semanas dándose por
un artefacto del navegador sin pantalla:

    if ((centerStored !== [0, 0] || geolocation.get()) && geoInit) {
      center.set(centerStored || geolocation.get());
      geoInit = false;
    }

`centerStored !== [0, 0]` compara contra un array recién creado: es SIEMPRE
cierto. En un navegador nuevo —sin centro en localStorage y con la
geolocalización todavía sin resolver— eso hacía `center.set(undefined)` y dejaba
`geoInit` en false, o sea para siempre. Un `<MapContainer>` sin `center` no
recibe `setView`, y un mapa de Leaflet sin vista no carga capa base ni teselas
ni dispara `whenReady`, con lo que tampoco se creaba la suscripción por
viewport. En desarrollo no pasaba porque la geolocalización llegaba a tiempo.

Y de paso el "Actualizando…" que no se quitaba nunca: las suscripciones se
creaban dentro de un `Tracker.autorun` ANIDADO en el withTracker. La computación
de fuera no dependía de `mapSize`, así que `loading` se calculaba con
`subscription` a undefined y no volvía a recalcularse; además cada pasada del
withTracker dejaba otro autorun sin parar. Ahora las suscripciones se crean en
la propia computación reactiva, que es donde Meteor sabe pararlas al invalidarse.

MapReady, además, vigila el contenedor con un ResizeObserver: su comprobación de
tamaño solo podía reintentar con el evento `resize` de Leaflet, que no se emite
si nadie llama a invalidateSize(). No era la causa de esto, pero hacía que la
comprobación no sirviera de nada.

La suite e2e deja de usar selectores por id: TestUtils.testId() los devuelve solo
en desarrollo, así que contra el bundle de producción —lo que levanta el job
nocturno— no existían y la suite entera habría fallado esta noche.

Verificado contra bundle de producción: /fires pasa de 0 teselas y 0 capas a 12
teselas, capa base y las dos suscripciones por viewport, sin "Actualizando…".
90 tests de servidor, 16 e2e y smoke REST byte-idéntico.
2026-08-03 19:59:30 +02:00

529 lines
22 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 { 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">
{/* El contador debe contar lo mismo que se pinta en rojo: los `activefires`
del FireList `nasa`, mas los avisos de usuarias. Contaba `activefiresunion`,
coleccion que NO escribe nadie (el union del importador es un WIP que nunca
llego a produccion) y que ademas solo se dibuja bajo `Meteor.isDevelopment`:
salia "En rojo, 0 fuegos activos" con el mapa lleno de fuegos. La condicion
de aqui debajo ya usaba `activefires`. */}
{ (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.activefires.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');
// ⚠️ Antes esto vivía dentro de un `Tracker.autorun` ANIDADO en el propio
// withTracker, y eso tenía dos problemas. Uno: la computación de fuera no
// dependía de `mapSize`, así que cuando el mapa por fin publicaba su viewport
// y se creaban las suscripciones, `loading` se había calculado ya con
// `subscription` a undefined — y no volvía a recalcularse, con lo que la
// página se quedaba con el "Actualizando…" puesto para siempre. Dos: cada
// pasada del withTracker creaba OTRO autorun que nadie paraba.
//
// Aquí ya estamos dentro de una computación reactiva: leyendo `mapSize.get()`
// la dependencia queda registrada, y las suscripciones creadas dentro se
// paran solas cuando la computación se invalida, que es justo lo que se
// quiere al cambiar el viewport.
//
// La condición del centro era `(centerStored !== [0, 0] || geolocation.get()) && geoInit`,
// y `centerStored !== [0, 0]` compara contra un array recién creado: SIEMPRE
// cierto. En un navegador nuevo —sin centro guardado y sin geolocalización
// todavía— hacía `center.set(undefined)` y dejaba `geoInit` en false, o sea
// para siempre. Un `<MapContainer>` sin `center` no recibe `setView`, y un
// mapa de Leaflet sin vista no carga capa base ni teselas ni dispara
// `whenReady`. Ese era el "mapa gris" de /fires, que solo se veía en
// producción porque en desarrollo la geolocalización llegaba a tiempo.
const nextCenter = centerStored || geolocation.get();
if (geoInit && nextCenter) {
center.set(nextCenter);
geoInit = false;
}
const bounds = mapSize.get();
if (bounds && bounds[0].lng && bounds[1].lat) {
subscription = Meteor.subscribe(
'activefiresmyloc',
bounds[0].lng,
bounds[0].lat,
bounds[1].lng,
bounds[1].lat,
marks.get() && zoom.get() >= MAXZOOM
);
subscriptionUnion = Meteor.subscribe(
'activefiresunionmyloc',
bounds[0].lng,
bounds[0].lat,
bounds[1].lng,
bounds[1].lat,
false
);
alertSubscription = Meteor.subscribe(
'fireAlerts',
bounds[0].lng,
bounds[0].lat,
bounds[1].lng,
bounds[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));