todos-contra-el-fuego-web/imports/ui/pages/Fires/Fires.js
vjrj 9ff9abacc3 bootstrap: lang/type dropdowns jQuery -> react-bootstrap <Dropdown>
The false-positive-type selector (Fires.js) and the language selector
(Profile.js) used Bootstrap's jQuery dropdown (`data-toggle="dropdown"`), the
last widgets depending on the BS4 jQuery JS that goes away with the CSS swap.
Replace both with react-bootstrap <Dropdown>/<Dropdown.Toggle>/<Dropdown.Item>
(open/close in React, no jQuery). Keeps the `.lang-selector` hook and `.btn-group`
layout; works on the current BS4 CSS.

Full-app build boots clean.
2026-07-22 01:01:59 +02:00

322 lines
13 KiB
JavaScript

/* 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 { withTracker } from 'meteor/react-meteor-data';
import { withTranslation, Trans } from 'react-i18next';
import { Row, Col, Alert, Form, Dropdown } from 'react-bootstrap';
import { Meteor } from 'meteor/meteor';
import { Bert } from 'meteor/themeteorchef:bert';
import { Helmet } from 'react-helmet-async';
import { MapContainer, GeoJSON } from 'react-leaflet';
import { MapReady } from '/imports/ui/components/Maps/MapBridge';
import { rectangleAround } from 'map-common-utils';
import DefMapLayers from '/imports/ui/components/Maps/DefMapLayers';
import NotFound from '/imports/ui/pages/NotFound/NotFound';
import FiresCollection from '/imports/api/Fires/Fires';
import FireList from '/imports/ui/components/Maps/FireList';
import FromNow from '/imports/ui/components/FromNow/FromNow';
import { dateLongFormat, dateYYYYMMDD } from '/imports/api/Common/dates';
import { hexId } from '/imports/api/Common/id';
import CommentsBox from '/imports/ui/components/Comments/CommentsBox';
import FalsePositiveTypes from '/imports/api/FalsePositives/FalsePositiveTypes';
import FalsePositivesCollection, { falsePositivesRemap } from '/imports/api/FalsePositives/FalsePositives';
import IndustriesCollection, { industriesRemap } from '/imports/api/Industries/Industries';
import ShareIt from '/imports/ui/components/ShareIt/ShareIt';
import FullScreenMap from '/imports/ui/components/Maps/FullScreenMap';
import './Fires.scss';
class Fire extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: props.loading,
notfound: props.notfound,
when: props.when
};
}
// state mirrors props purely for shouldComponentUpdate (render reads props
// directly). Was the derived-state half of UNSAFE_componentWillReceiveProps.
static getDerivedStateFromProps(props, state) {
if (props.when !== state.when || props.loading !== state.loading || props.notfound !== state.notfound) {
return { loading: props.loading, notfound: props.notfound, when: props.when };
}
return null;
}
componentDidUpdate(prevProps) {
// side effect (must not run in getDerivedStateFromProps): once the fire is
// resolved via alert/active/hash, canonicalize the URL to /fire/archive/:id
if (prevProps.when !== this.props.when || prevProps.loading !== this.props.loading || prevProps.notfound !== this.props.notfound) {
const {
fire, alert, active, fromHash, history
} = this.props;
if (fire && (alert || active || fromHash)) {
history.replace(`/fire/archive/${hexId(fire._id)}`);
}
}
}
shouldComponentUpdate(nextProps, nextState) {
return !(nextState.when === this.state.when && nextState.loading === this.state.loading && this.state.notfound === nextState.notfound && nextState.nasaLink === this.state.nasaLink);
}
onTypeSelect(key) {
// console.log(key);
Meteor.call('falsePositives.insert', this.props.fire._id, key, (error) => {
if (error) {
// console.log(error);
Bert.alert(this.props.t(error.reason), 'danger');
} else {
Bert.alert(this.props.t('Tomamos nota, ¡gracias por colaborar!'), 'success');
}
});
}
// v4: MapReady hands us the ready Leaflet map directly (no ref/.leafletElement)
handleMapReady(lmap) {
this.fireMap = lmap;
this.handleLeafletMapLoad(lmap);
if (this.circle) lmap.fitBounds(this.circle.getBounds());
}
handleLeafletMapLoad(lmap) {
if (lmap) {
const bounds = lmap.getBounds();
const ne = bounds.getNorthEast();
const sw = bounds.getSouthWest();
// console.log(`${sw},${ne}`);
// const nasaLinkBase = 'https://worldview.earthdata.nasa.gov/?p=geographic&l=VIIRS_SNPP_CorrectedReflectance_TrueColor(hidden),MODIS_Aqua_CorrectedReflectance_TrueColor(hidden),MODIS_Terra_CorrectedReflectance_TrueColor,MODIS_Fires_All,MODIS_Fires_Aqua,VIIRS_SNPP_Fires_375m_Night,VIIRS_SNPP_Fires_375m_Day,MODIS_Fires_Terra,Reference_Labels(hidden),Reference_Features(hidden),Coastlines&t='
const nasaLinkBase = 'https://worldview.earthdata.nasa.gov/?p=geographic&l=' +
'VIIRS_SNPP_CorrectedReflectance_TrueColor,' +
'MODIS_Aqua_CorrectedReflectance_TrueColor,' +
'MODIS_Terra_CorrectedReflectance_TrueColor,' +
'MODIS_Terra_Data_No_Data(hidden),' +
'MODIS_Fires_All,' +
'MODIS_Fires_Aqua,' +
'VIIRS_SNPP_Fires_375m_Night,' +
'VIIRS_SNPP_Fires_375m_Day,' +
'MODIS_Fires_Terra,' +
'Reference_Labels,' +
'Reference_Features,' +
'Coastlines' +
'&t=';
const nasaZoom = 5;
this.setState({ nasaLink: `${nasaLinkBase}${this.dateYYYYMMDD}&z=3&v=${sw.lng},${sw.lat},${ne.lng},${ne.lat}&ab=off&as=${this.dateYYYYMMDD}&ae=${this.dateYYYYMMDD}&av=${nasaZoom}&al=true` });
}
}
handleLeafletCircleLoad(circle) {
// v4: circle is the Leaflet GeoJSON layer; this.fireMap is the Leaflet map
if (this.fireMap && circle) {
this.fireMap.fitBounds(circle.getBounds());
}
}
render() {
const {
notfound, loading, fire, t
} = this.props;
if (Meteor.isDevelopment) console.log(`False positives total: ${this.props.falsePositives.length}`);
if (Meteor.isDevelopment) console.log(`Industries total: ${this.props.industries.length}`);
/* console.log(`loading fire: ${loading}`);
* console.log(`Not found fire: ${notfound}`); */
if (fire && fire.when) {
this.dateLongFormat = dateLongFormat(fire.when);
this.dateYYYYMMDD = dateYYYYMMDD(fire.when);
this.title = fire.address ?
t('Información adicional sobre fuego detectado en {{where}} el {{when}}', { where: fire.address, when: this.dateLongFormat }) :
t('Información adicional sobre fuego detectado el {{when}}', { when: this.dateLongFormat });
}
const ready = fire && !loading;
const rect = ready ? rectangleAround({ lat: fire.lat, lon: fire.lon }, fire.track, fire.track) : null;
return (ready ? (
<div className="ViewFire">
<Helmet>
<title>{t('AppName')}: {t('Información adicional sobre fuego')}</title>
<meta name="description" content={this.title} />
</Helmet>
{!loading &&
<Fragment>
<h4 className="page-header">{this.title}</h4>
<MapContainer
center={[fire.lat, fire.lon]}
className="fire-leaflet-container"
zoom={13}
>
<MapReady onReady={map => this.handleMapReady(map)} />
<GeoJSON
ref={(circle) => { this.circle = circle; this.handleLeafletCircleLoad(circle); }}
data={rect}
style={{ color: 'red', stroke: true, weight: 1, fillOpacity: 0 }}
/>
<FireList
t={t}
history={this.props.history}
fires={this.props.falsePositives}
scale
useMarkers
nasa={false}
falsePositives
neighbour={false}
industries={false}
/>
<FireList
t={t}
history={this.props.history}
fires={this.props.industries}
scale
useMarkers
nasa={false}
falsePositives={false}
neighbour={false}
industries
/>
<DefMapLayers satellite />
<FullScreenMap />
</MapContainer>
<p>{t('Coordenadas:')} {fire.lat}, {fire.lon}</p>
{(fire.type === 'modis' || fire.type === 'viirs') &&
<Fragment>
<p><a target="_blank" href={`${this.state.nasaLink}`}><Trans>Fuego detectado por satélites de la NASA <FromNow {...this.props} /></Trans></a>
</p>
</Fragment>
}
{(fire.type === 'vecinal') &&
<p><Trans>Fuego notificado por uno de nuestros usuarios/as <FromNow {...this.props} /></Trans></p>
}
<ShareIt title={this.title} />
{(fire.type !== 'vecinal') &&
<Fragment>
{ (this.props.falsePositives.length > 0 || this.props.industries.length > 0) &&
<Row>
<Col>
<Alert variant="success"><Trans>Parece que este fuego no es un fuego forestal.</Trans></Alert>
</Col>
</Row> }
<h5>{t('¿No es un fuego forestal?')}</h5>
<div>
<Trans>Indícanos de que tipo de fuego se trata y ayúdanos así a mejorar nuestras notificaciones:</Trans>
</div>
<Form.Group>
<Dropdown className="btn-group">
<Dropdown.Toggle variant="secondary" size="sm" className="lang-selector">
{t('Elige un tipo')}
</Dropdown.Toggle>
<Dropdown.Menu>
{Object.keys(FalsePositiveTypes).map(key => (
<Dropdown.Item as="button" key={key} onClick={() => this.onTypeSelect(key)}>
<Trans>{FalsePositiveTypes[key]}</Trans>
</Dropdown.Item>
))
}
</Dropdown.Menu>
</Dropdown>
</Form.Group>
</Fragment> }
<h4>{t('Comentarios')}</h4>
<div className="comments-info">
{t('Puedes añadir un comentario si tienes información adicional sobre este fuego.')}
{' '}
{t('Por ejemplo:')}
<ul>
<li>{t('si conoces esta zona y cómo acceder al fuego (esto puede de ser de ayuda para apagarlo si sigue activo o para investigarlo en un futuro)')}</li>
<li>{t('si conoces el motivo por el que comenzó el fuego')}</li>
<li>{t('si quieres denunciar algún tipo de ilegalidad, incluso anónimamente')}</li>
<li>{t('o cualquier otra información')}</li>
</ul>
</div>
<div className="comments-section">
<CommentsBox referenceId={`fire-${hexId(fire._id)}`} />
</div>
</Fragment>
}
</div>
) : <Fragment>{ notfound && <NotFound /> }</Fragment>);
}
}
Fire.propTypes = {
t: PropTypes.func.isRequired,
history: PropTypes.object.isRequired,
loading: PropTypes.bool.isRequired,
notfound: PropTypes.bool.isRequired,
falsePositives: PropTypes.arrayOf(PropTypes.object).isRequired,
industries: PropTypes.arrayOf(PropTypes.object).isRequired,
fromHash: PropTypes.bool.isRequired,
active: PropTypes.bool.isRequired,
alert: PropTypes.bool.isRequired,
when: PropTypes.instanceOf(Date),
fire: PropTypes.object
};
Fire.defaultProps = {
};
// export default withTranslation()(withTracker((props) => {
const FireContainer = withTracker(({ match }) => {
const id = match.params.id;
const fireType = match.params.type;
let subscription;
const active = fireType === 'active';
const archive = fireType === 'archive';
const alert = fireType === 'alert';
let fromHash = false;
if (active) {
subscription = Meteor.subscribe('fireFromActiveId', id);
} else if (alert) {
subscription = Meteor.subscribe('fireFromAlertId', id);
} else if (archive) {
subscription = Meteor.subscribe('fireFromId', id);
} else {
console.log('Seems a fire from enc hash');
fromHash = true;
subscription = Meteor.subscribe('fireFromHash', id, match.params);
}
// console.log(`Type of '${fireType}' fire, active: ${active}, archive: ${archive}, fromHash: ${fromHash}`);
// console.log(`Subs ready: ${subscription.ready()}, fire: ${JSON.stringify(FiresCollection.findOne())}`);
const loading = !subscription.ready();
// Scope the client-side read to the fire named in the URL. Only the archive
// route's param is a Fires `_id` (as 24-char hex, per hexId()); active/alert
// params are ActiveFire/Alert ids and hash is an encrypted blob, so their
// publications each put exactly one Fires doc in minimongo and an empty
// selector is the correct read. Scoping archive by _id stops a lingering doc
// from a previously-viewed fire being returned by a selector-less findOne().
let selector = {};
if (archive && id) {
try {
selector = new Meteor.Collection.ObjectID(id);
} catch (e) {
selector = {};
}
}
const fire = FiresCollection.findOne(selector);
const notfound = !loading && !fire;
/* console.log(`loading fire: ${loading}`);
* console.log(`Not found fire: ${notfound}`); */
const falsePositives = FalsePositivesCollection.find().fetch().map(falsePositivesRemap);
const industries = IndustriesCollection.find().fetch().map(industriesRemap);
return {
loading,
active,
alert,
fromHash,
falsePositives,
industries,
fire,
notfound,
when: fire ? fire.when : null
};
})(Fire);
// export default FireContainer;
export default withTranslation()(FireContainer);