Initial commit

This commit is contained in:
dbroqua 2020-03-01 20:40:51 +01:00
commit 80c1729b2f
26 changed files with 311019 additions and 0 deletions

3
.eslintignore Normal file
View File

@ -0,0 +1,3 @@
build/
node_modules/
serviceWorker.js

16
.eslintrc.js Normal file
View File

@ -0,0 +1,16 @@
module.exports = {
parser: 'babel-eslint',
extends: ['airbnb', 'prettier', 'plugin:flowtype/recommended'],
plugins: ['react', 'jsx-a11y', 'import', 'flowtype'],
rules: {
'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx'] }],
'no-underscore-dangle': ["error", { "allow": ["_ne", "_sw"] }],
"react/jsx-props-no-spreading": [1, {
"exceptions": ["ReactMapGL"]
}]
},
env: {
browser: true,
es6: true,
}
};

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

3
README.md Normal file
View File

@ -0,0 +1,3 @@
Ce projet a simplement pour but de répertorier l'ensemble des stations essences en France.
Les données sont basées sur les [Open Data](https://www.prix-carburants.gouv.fr/rubrique/opendata/).

64
package.json Normal file
View File

@ -0,0 +1,64 @@
{
"name": "e85map",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"bootstrap": "^4.4.1",
"eslint-config-prettier": "^6.10.0",
"eslint-plugin-prettier": "^3.1.2",
"mapbox-gl": "^1.8.1",
"moment": "^2.24.0",
"prop-types": "^15.7.2",
"react": "^16.13.0",
"react-bootstrap": "^1.0.0-beta.17",
"react-dom": "^16.13.0",
"react-map-gl": "^5.2.3",
"react-moment": "^0.9.7",
"react-scripts": "3.4.0",
"react-toast-notifications": "^2.4.0",
"xml-reader": "^2.4.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build && ssh porto 'rm -r www/darkou.fr/carburants/static' && scp -r build/* porto:www/darkou.fr/carburants",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.js": [
"./node_modules/.bin/eslint --fix",
"git add"
]
},
"devDependencies": {
"eslint": "^6.8.0",
"eslint-config-airbnb": "^18.0.1",
"eslint-plugin-import": "^2.20.1",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.18.3",
"eslint-plugin-react-hooks": "^1.7.0"
}
}

BIN
public/car.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

BIN
public/gas-station.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 B

298705
public/gasStations.xml Normal file

File diff suppressed because it is too large Load Diff

21
public/index.html Normal file
View File

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>Carte des stations e85 - un service proposé par DarKou.fr</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

25
public/manifest.json Normal file
View File

@ -0,0 +1,25 @@
{
"short_name": "Prix Carburant",
"name": "Prix des carburant - un service DarKou.fr",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

BIN
public/waze.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

24
src/App.css Normal file
View File

@ -0,0 +1,24 @@
.mapContainer {
position: absolute;
top: 0;
right: 0;
left: 0;
bottom: 0;
}
.sidebarStyle {
display: inline-block;
position: absolute;
top: 0;
left: 0;
margin: 12px;
background-color: #404040;
color: #ffffff;
z-index: 1 !important;
padding: 6px;
font-weight: bold;
}
.locationIcon {
width: 32px;
}

90
src/App.js Normal file
View File

@ -0,0 +1,90 @@
import React from 'react';
import { ToastProvider } from 'react-toast-notifications';
import Footer from './Components/Footer';
import GasStation from "./Components/GasStation";
import Map from "./Components/Map";
import 'mapbox-gl/dist/mapbox-gl.css';
import './App.css';
class Application extends React.Component {
constructor(props) {
super(props);
this.state = {
showModal: false,
selectedGasStation: {},
selectedGasType: 'E85'
};
}
/**
* Méthode permettant de sélectionner le type de carburant à afficher
* @param {Object} e
*/
selectGasType = (e) => {
const {
value,
} = e.target;
this.setState(prevState => ({
...prevState,
selectedGasType: value
}));
}
/**
* Méthode permettant de fermer la modale contenant les détails de la station
* @param {Boolean} goToWaze
*/
hideModal = (goToWaze) => {
const {
selectedGasStation,
} = this.state;
if (goToWaze) {
window.open(`https://www.waze.com/livemap/directions?navigate=yes&latlng=${selectedGasStation.latitude}%2C${selectedGasStation.longitude}&zoom=17`);
}
this.setState(prevState => ({
...prevState,
selectedGasStation: {},
showModal: false
}));
}
/**
* Méthode permettant de sélectionner et d'afficher le détails d'une station
* @param {Object} selectedGasStation
*/
showGasStation = (selectedGasStation) => {
this.setState(prevState => ({
...prevState,
showModal: true,
selectedGasStation
}));
}
/**
* Méthode gérant le rendu de la vue
*/
render() {
const {
selectedGasType,
showModal,
selectedGasStation,
} = this.state;
return (
<ToastProvider>
<GasStation
showModal={showModal}
hideModal={this.hideModal}
selectedGasStation={selectedGasStation}
/>
<Map selectedGasType={selectedGasType} showGasStation={this.showGasStation} />
<Footer selectedGasType={selectedGasType} selectGasType={this.selectGasType} />
</ToastProvider>
);
}
}
export default Application;

25
src/Components/Footer.js Normal file
View File

@ -0,0 +1,25 @@
import React from 'react';
import { Navbar } from "react-bootstrap";
import PropTypes from 'prop-types';
import GasTypes from "./GasTypes";
const Footer = (props) => {
const {
selectGasType,
selectedGasType,
} = props;
return (
<Navbar bg="light" variant="light" fixed="bottom">
<GasTypes selectedGasType={selectedGasType} selectGasType={selectGasType} />
</Navbar>
);
};
Footer.propTypes = {
selectedGasType: PropTypes.string.isRequired,
selectGasType: PropTypes.func.isRequired,
};
export default Footer;

View File

@ -0,0 +1,79 @@
import React from 'react';
import { Modal, Button, ListGroup } from "react-bootstrap";
import PropTypes from 'prop-types';
class GasStation extends React.Component {
renderPrices = () => {
const {
selectedGasStation,
} = this.props;
return (
<ListGroup variant="flush">
{selectedGasStation.prices ? selectedGasStation.prices.map(price => {
return (
<ListGroup.Item key={price.type}>
{`${price.type} : ${price.price}`}
</ListGroup.Item>
);
}) : (null)}
</ListGroup>
)
}
render () {
const {
showModal,
hideModal,
selectedGasStation,
} = this.props;
return (
<Modal
size="xl"
aria-labelledby="contained-modal-title-vcenter"
centered
show={showModal}
onHide={hideModal}
>
<Modal.Header closeButton>
<Modal.Title>
{`${selectedGasStation.address} - ${selectedGasStation.city}`}
</Modal.Title>
</Modal.Header>
<Modal.Body>
{this.renderPrices()}
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onClick={() => hideModal(true)}>
<img
className="locationIcon"
src="/waze.png"
alt="S'y rendre"
/>
</Button>
</Modal.Footer>
</Modal>
);
}
}
GasStation.defaultProps = {
selectedGasStation: {
address: null,
city: null,
prices: []
}
};
GasStation.propTypes = {
showModal: PropTypes.bool.isRequired,
hideModal: PropTypes.func.isRequired,
selectedGasStation: PropTypes.shape({
address: PropTypes.string,
city: PropTypes.string,
prices: PropTypes.array
}),
};
export default GasStation;

View File

@ -0,0 +1,64 @@
import React from 'react';
import { Form, Row, Col } from "react-bootstrap";
import PropTypes from 'prop-types';
class GasTypes extends React.Component {
constructor(props) {
super(props);
this.state = {
gasTypes: [
{
name: "Ethanol e85",
type: 'E85',
},
{
name: "Sans plomb 95 E10",
type: "E10"
},
{
name: "Sans plomb 95",
type: "SP95"
},
{
name: "Sans plomb 98",
type: "SP98"
},
{
name: "Gazole",
type: "Gazole"
},
{
name: "GPL",
type: "GPLc"
}
]
};
}
render() {
const {
gasTypes,
} = this.state;
const {
selectGasType,
selectedGasType,
} = this.props;
return (
<Row style={{ width: "100%" }}>
<Col>
<Form.Control as="select" value={selectedGasType} onChange={selectGasType}>
{gasTypes.map(gasType => (<option key={gasType.type} value={gasType.type}>{gasType.name}</option>))}
</Form.Control>
</Col>
</Row>
);
}
}
GasTypes.propTypes = {
selectedGasType: PropTypes.string.isRequired,
selectGasType: PropTypes.func.isRequired,
};
export default GasTypes;

187
src/Components/Map.js Normal file
View File

@ -0,0 +1,187 @@
import React from 'react';
import ReactMapGL, { Marker } from 'react-map-gl';
import { Button } from "react-bootstrap";
import XmlReader from 'xml-reader';
import { withToastManager } from 'react-toast-notifications';
import PropTypes from 'prop-types';
import { haveSelectedGas, extractGasStationFromXml } from '../helpers';
import 'mapbox-gl/dist/mapbox-gl.css';
const mapboxToken = 'pk.eyJ1IjoiZGFya291IiwiYSI6ImNrNzkwdmlsdTBtMmwzZnM0ZmI4Z3h4czIifQ.GU2CdcMiKiApHNhI0ylGtQ';
class Map extends React.Component {
constructor(props) {
super(props);
this.state = {
viewport: {
width: '100vw',
height: '100vh',
latitude: 44.837789,
longitude: -0.57918,
zoom: 11,
},
userLocation: {
// latitude: 44.837789,
// longitude: -0.57918,
},
gasStations: [],
};
this.mapRef = React.createRef();
this.loadGasStations();
}
componentDidMount() {
this.setUserLocation();
}
/**
* Méthode permettant de mettre à jour la position de la map
* @param {Object} viewport
*/
onViewportChange = (viewport) => {
this.setState({ viewport });
}
/**
* Méthode permettant de sauvegarder la position de l'utilisateur
*/
setUserLocation() {
navigator.geolocation.getCurrentPosition((position) => {
const setUserLocation = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
};
const newViewport = {
height: '100vh',
width: '100vw',
latitude: position.coords.latitude,
longitude: position.coords.longitude,
zoom: 10,
};
this.setState({
viewport: newViewport,
userLocation: setUserLocation,
});
});
}
/**
* Méthode permettant de filter sur la liste des stations affichables sur la carte
* @param {Object} gasStation
* @return {Boolean}
*/
displayThisGasStation = (gasStation) => {
const mapGL = this.mapRef.getMap();
const bounds = mapGL.getBounds();
return (bounds._ne.lat > gasStation.latitude && bounds._sw.lat < gasStation.latitude)
&& (bounds._ne.lng > gasStation.longitude && bounds._sw.lng < gasStation.longitude);
}
/**
* Méthode permettant de charger le fichier xml contenant la liste des stations
*/
loadGasStations() {
const {
toastManager,
} = this.props;
fetch('/gasStations.xml')
.then((response) => response.text())
.then((response) => {
const reader = XmlReader.create();
reader.on('done', (data) => {
const pdv = data.children;
const gasStations = [];
for (let i = 0; i < pdv.length; i += 1) {
const currentPdv = pdv[i];
gasStations.push(extractGasStationFromXml(currentPdv));
}
this.setState((prevState) => ({
...prevState,
gasStations,
}));
});
reader.parse(response);
}).catch(() => {
toastManager.add('Erreur lors du chargement de la liste des stations', { appearance: 'error', autoDismiss: true });
});
}
/**
* Méthode gérant le rendu de la vue
*/
render() {
const {
viewport,
userLocation,
gasStations,
} = this.state;
const {
showGasStation,
selectedGasType,
} = this.props;
return (
<>
<ReactMapGL
{...viewport}
mapStyle="mapbox://styles/mapbox/outdoors-v11"
onViewportChange={this.onViewportChange}
mapboxApiAccessToken={mapboxToken}
ref={(map) => { if (map) this.mapRef = map; }}
>
{
Object.keys(userLocation).length !== 0 ? (
<Marker
latitude={userLocation.latitude}
longitude={userLocation.longitude}
>
<img className="locationIcon" src="/car.png" alt="My position" />
</Marker>
) : (null)
}
{gasStations.filter(this.displayThisGasStation).filter((station) => haveSelectedGas(station, selectedGasType)).map((gasStation) => (
<Marker
key={gasStation.id}
latitude={gasStation.latitude}
longitude={gasStation.longitude}
>
<Button
variant="link"
onClick={() => showGasStation(gasStation)}
onFocus={() => { }}
onBlur={() => { }}
>
<img
className="locationIcon"
src="/gas-station.png"
alt={`${gasStation.id}`}
/>
</Button>
</Marker>
))}
</ReactMapGL>
</>
);
}
}
Map.propTypes = {
selectedGasType: PropTypes.string.isRequired,
showGasStation: PropTypes.func.isRequired,
toastManager: PropTypes.shape({
add: PropTypes.func,
}).isRequired,
};
export default withToastManager(Map);

63
src/helpers.js Normal file
View File

@ -0,0 +1,63 @@
export const getFuelPrices = (pdv) => {
const prices = []
if (!pdv.children || pdv.children.length === 0) {
return false;
}
for (let i = 0; i < pdv.children.length; i += 1) {
const currentChildren = pdv.children[i];
if (currentChildren.type === 'element' && currentChildren.name === 'prix') {
prices.push({
type: currentChildren.attributes.nom,
price: parseInt(currentChildren.attributes.valeur, 10) / 1000,
updatedAt: currentChildren.attributes.maj,
});
}
}
return prices;
};
export const getPlvInformation = (pdv, name) => {
if (!pdv.children || pdv.children.length === 0) {
return false;
}
for (let i = 0; i < pdv.children.length; i += 1) {
const currentChildren = pdv.children[i];
if (currentChildren.type === 'element' && currentChildren.name === name) {
if ( currentChildren.children && currentChildren.children.length > 0 ) {
return currentChildren.children[0].value;
}
return null;
}
}
return false;
};
export const formatPosition = (value) => value / 100000;
export const haveSelectedGas = (station, gas) => {
if (!station.prices || station.prices.length === 0 ) {
return false;
}
for (let i = 0 ; i < station.prices.length ; i +=1 ){
if (station.prices[i].type === gas ) {
return true;
}
}
return false;
}
export const extractGasStationFromXml = (currentPdv ) => {
return {
id: currentPdv.attributes.id,
latitude: formatPosition(currentPdv.attributes.latitude),
longitude: formatPosition(currentPdv.attributes.longitude),
prices: getFuelPrices(currentPdv),
postCode: currentPdv.attributes.cp,
address: getPlvInformation(currentPdv, 'adresse'),
city: getPlvInformation(currentPdv, 'ville')
}
}

12
src/index.js Normal file
View File

@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import * as serviceWorker from './serviceWorker';
import 'bootstrap/dist/css/bootstrap.min.css';
ReactDOM.render(<App />, document.getElementById('root'));
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.register();

129
src/serviceWorker.js Normal file
View File

@ -0,0 +1,129 @@
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' }
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then(registration => {
registration.unregister();
})
.catch(error => {
console.error(error.message);
});
}
}

5
src/setupTests.js Normal file
View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom/extend-expect';

11478
yarn.lock Normal file

File diff suppressed because it is too large Load Diff