Commit 7dc6e615 authored by Florent Chehab's avatar Florent Chehab

Feature(self hosted map tiles):

* Added map tile server to docker-compose dev and prod
* Moved from leaflet to mapbox gl for vector tiles (changed npm dependencies)
* Custom map styles for light and dark mode
* Changed frontend map status saving (dropped redux / simpler static data saving)

Closes #117
parent 6e2273e3
......@@ -13,3 +13,4 @@ database.db
database.db-journal
.idea
npm-debug.log
.env
......@@ -34,7 +34,7 @@ check_back:
check_front:
<<: *only-default
stage: check
image: registry.gitlab.utc.fr/rex-dri/rex-dri/frontend:v0.2.0
image: registry.gitlab.utc.fr/rex-dri/rex-dri/frontend:v0.3.0
before_script:
- cd frontend && cp -R /usr/src/deps/node_modules .
script:
......@@ -70,7 +70,7 @@ test_back:
test_frontend:
<<: *only-default
stage: test
image: registry.gitlab.utc.fr/rex-dri/rex-dri/frontend:v0.2.0
image: registry.gitlab.utc.fr/rex-dri/rex-dri/frontend:v0.3.0
before_script:
- cd frontend && cp -R /usr/src/deps/node_modules .
script:
......@@ -90,7 +90,7 @@ flake8:
eslint:
<<: *only-default
stage: lint
image: registry.gitlab.utc.fr/rex-dri/rex-dri/frontend:v0.2.0
image: registry.gitlab.utc.fr/rex-dri/rex-dri/frontend:v0.3.0
before_script:
- cd frontend && cp -R /usr/src/deps/node_modules .
script:
......
......@@ -69,6 +69,9 @@ prod: setup
down_prod:
docker-compose $(prod_yml) down
prod_docker_logs:
docker-compose $(prod_yml) logs
shell_prod_logs:
docker-compose $(prod_yml) exec logs_rotation /bin/sh -c "cd /var/log && /bin/sh"
......
......@@ -38,9 +38,9 @@
var __AllBackendEndPointsRoutes = {{ endpoints|safe }};
</script>
<link rel="stylesheet" href="{{ front_bundle_loc }}leaflet-dist/leaflet.css"/>
{% render_bundle 'main' %}
{% render_bundle 'vendor' %}
<link rel="stylesheet" href="{{ front_bundle_loc }}mapbox-gl-dist/mapbox-gl.css"/>
<noscript>Cette application nécessite Javascript... Merci de l'activer ou d'utiliser un navigateur approprié.</noscript>
</html>
......@@ -14,6 +14,7 @@ volumes:
networks:
backend-nginx:
backend-db:
map-nginx:
services:
......@@ -43,13 +44,12 @@ services:
volumes:
- ./backend/static/:/usr/src/static:ro
- ./backend/media/:/usr/src/media:ro
networks: [backend-nginx]
networks: [backend-nginx, map-nginx]
ports:
# The port 8000 of the host is redirected to the port 80 of the container
# which then redirect it to the port 8000 of the backend container
- 8000:80
depends_on:
- backend
depends_on: [backend, map]
# Service for the postgres database
database:
......@@ -63,7 +63,7 @@ services:
# Service to handle frontend live developpments and building
frontend:
# Get the image from the registry
image: registry.gitlab.utc.fr/rex-dri/rex-dri/frontend:v0.2.0
image: registry.gitlab.utc.fr/rex-dri/rex-dri/frontend:v0.3.0
# To use a locally built one, comment above, uncomment bellow.
# build: ./frontend
# On startup, we retrieve the dependencies from the image and start the developpement server
......@@ -79,6 +79,14 @@ services:
# replicate the view stats port
- "8888:8888"
# Service to host map tiles
map:
image: floawfloaw/light-world-tileserver:2019-05-21--zoom-8
networks: [map-nginx]
volumes: ["./server/map:/data/custom:ro"]
entrypoint: ["node", "/usr/src/app/", "-p", "8080", "--config", "/data/custom/config.json", "--public_url", "http://localhost:8000/map-server/", "--silent"]
restart: always
# Service to provide a local documentation
documentation:
build: ./documentation
......
Map -- info
===========
## Why hosting our own maps ?
We decided to host our own maps in order to provide this service for free for ever.
This has several drawbacks:
* The tiles are not really up to date (unless we update them regurlarly but this process is complicated and costly),
* We will provide only a reasonable level zoom so that we don't store a huge volumetry of data.
And some awesome advantages:
* Custom styling,
* Vector styles,
* Free for ever :)
## Custom styling
Two styles have been derived from the ones available [here](http://editor.openmaptiles.org/).
You can import the one in the project `server/map/styles` to update them.
!> :warning: Before editing them, on the service mentioned above, you must replace some lines.
In `light/styles.json`, replace:
```json
"sources": {
"openmaptiles": {
"type": "vector",
"url": "mbtiles://{v3}"
}
},
"sprite": "{styleJsonFolder}/sprite",
"glyphs": "{fontstack}/{range}.pbf",
```
by:
```json
"sources": {
"openmaptiles": {
"type": "vector",
"url": "https://free.tilehosting.com/data/v3.json?key={key}"
}
},
"sprite": "https://openmaptiles.github.io/osm-bright-gl-style/sprite",
"glyphs": "https://free.tilehosting.com/fonts/{fontstack}/{range}.pbf?key={key}",
```
-----
In `dark/styles.json`, replace:
```json
"sources": {
"openmaptiles": {
"type": "vector",
"url": "mbtiles://{v3}"
}
},
"sprite": "{styleJsonFolder}/sprite",
"glyphs": "{fontstack}/{range}.pbf",
```
by:
```json
"sources": {
"openmaptiles": {
"type": "vector",
"url": "https://free.tilehosting.com/data/v3.json?key={key}"
}
},
"sprite": "https://openmaptiles.github.io/dark-matter-gl-style/sprite",
"glyphs": "https://free.tilehosting.com/fonts/{fontstack}/{range}.pbf?key={key}",
```
?> Don't forget to make the reverse process when using them in our project.
......@@ -144,6 +144,7 @@ There comes in the actions (and indirectly the reducers) you have defined earlie
The idea is that you want your component to be able to *dispatch* actions (that will be handled by the reducers and that will update the state of the redux store).
This is done once again with redux `connect` function:
TODO example outdated with the new map system.
```js
import React, { Component } from "react";
......
......@@ -8,7 +8,7 @@ Deploying the app is fairly simple: all you need is `docker` and `docker-compose
If you don't know how to install them, you should look in [this section](GettingStarted/set-up.md) of the documentation.
- Once this is done clone the repository of the project.
- Then, run `make setup` (this will generate the required `.env` files) and set their correct values in the directory `server/envs`. Take care especially with the files `django.env` and `external_data.env`.
- Then, run `make setup` (this will generate the required `.env` files) and set their correct values in both `server/envs` directory and `.env` (at the root of the repo -- for the map to work appropriately). Take care especially with the files `django.env` and `external_data.env`.
- Once this is done, you can simply run `make prod` and then `make init_dev_data` (TODO create one for prod) to get going fast.
`make prod` will start all the services described in `docker-compose.prod.yml` (in the `server` directory).
......
......@@ -28,6 +28,7 @@
* [Use of Redux](Application/Frontend/redux.md)
* [Tests](Application/Frontend/tests.md)
* [Notifications](Application/Frontend/notifications.md)
* [Map](Application/Frontend/map.md)
- [**Deploy**](Application/deploy.md)
......
This diff is collapsed.
......@@ -30,14 +30,14 @@
"downshift": "^3.2.3",
"fuzzysort": "^1.1.4",
"keycode": "^2.2.0",
"leaflet": "^1.4.0",
"lodash": "^4.17.11",
"mapbox-gl": "^0.54.0",
"material-ui-pickers": "^2.2.1",
"notistack": "^0.4.3",
"react": "^16.8.6",
"react-awesome-slider": "^0.5.2",
"react-dom": "^16.8.6",
"react-leaflet": "^2.2.1",
"react-mapbox-gl": "^4.3.2",
"react-markdown": "^4.0.6",
"react-redux": "^6.0.1",
"react-router-dom": "^5.0.0",
......@@ -46,7 +46,8 @@
"redux": "^4.0.1",
"redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0",
"typeface-roboto": "0.0.54"
"typeface-roboto": "0.0.54",
"uuid": "^3.3.2"
},
"devDependencies": {
"@babel/core": "^7.4.3",
......
......@@ -9,7 +9,7 @@ import CustomError from "../common/CustomError";
* Function to build a chached version of the fields mapping to cascade update.
*
* @param {Array.<FormLevelError>} formLevelErrors
* @returns {Map<string, Set>}
* @returns {ReactMapboxGl<string, Set>}
*/
function buildFieldUpdateCache(formLevelErrors) {
let out = new Map();
......@@ -219,7 +219,7 @@ class Form extends Component {
*
* Helper function to cache the map containin the mapping between a field(name) and the set of fields
* that should be updated when the previous one is updated.
* @returns {Map<string, Set>}
* @returns {ReactMapboxGl<string, Set>}
*/
getFieldsToUpdate() {
if (!this.fieldsToUpdate) {
......
import React, {Component} from "react";
import withStyles from "@material-ui/core/styles/withStyles";
import {compose} from "recompose";
import PropTypes from "prop-types";
import ReactMapboxGl, {Feature, Layer, Popup} from "react-mapbox-gl";
import UnivMapPopup from "./UnivMapPopup";
const Map = ReactMapboxGl({
dragRotate: false,
pitchWithRotate: false,
maxZoom: 12,
});
/**
* Custom react-wrapper on top of MapBoxGlJs to handle our maps in a generic manner.
*
* If an id is provided, the state of the map will be automatically saved and regenerated.
*/
class BaseMap extends Component {
// Static variable to hold the map center in a generic way without redux
static allMaps = {};
// campusesMarkers = [];
/**
* @static
* @private
* @constant
* @type {{center: number[], zoom: number[]}}
*/
static DEFAULTS = {
center: [53.94, 41.13],
zoom: [0.86]
};
state = {
popup: undefined
};
saveStatus(map) {
if (typeof this.props.id !== "undefined") {
const center = map.getCenter();
BaseMap.allMaps[this.props.id] = {
center: [center.lng, center.lat],
zoom: [map.getZoom()],
};
}
}
openPopup(campusInfo) {
this.setState({popup: campusInfo});
}
closePopup() {
this.setState({popup: undefined});
}
toggleHover(map, cursor) {
map.getCanvas().style.cursor = cursor;
}
render() {
const {theme} = this.props,
style = this.props.theme.palette.type === "light" ?
"light"
:
"dark";
let mapStatus = Object.assign({}, BaseMap.DEFAULTS);
if (typeof this.props.id !== "undefined") {
const previousData = BaseMap.allMaps[this.props.id];
if (typeof previousData !== "undefined") {
Object.assign(mapStatus, previousData);
}
}
const {popup} = this.state,
{campuses} = this.props;
return (
<Map style={`/map-server/styles/${style}/style.json`}
containerStyle={{
height: "60vh",
width: "100%"
}}
zoom={mapStatus.zoom}
center={mapStatus.center}
onMoveEnd={(map) => this.saveStatus(map)}
>
{
campuses ?
<Layer
type="circle"
id="campuses"
paint={{"circle-color": theme.palette.primary.main, "circle-opacity": 0.8, "circle-radius": 8}}>
{
campuses.map(
(campusInfo, key) =>
<Feature key={key}
coordinates={[campusInfo.lon, campusInfo.lat]}
onClick={() => this.openPopup(campusInfo)}
onMouseEnter={({map}) => this.toggleHover(map, "pointer")}
onMouseLeave={({map}) => this.toggleHover(map, "")}
/>)
}
</Layer>
:
<></>
}
{popup && (
<Popup key={popup.univId} coordinates={[popup.lon, popup.lat]}>
<UnivMapPopup {...popup} handleClose={() => this.closePopup()}/>
</Popup>
)}
</Map>
);
}
}
BaseMap.propTypes = {
classes: PropTypes.object.isRequired,
theme: PropTypes.object.isRequired,
id: PropTypes.string,
campuses: PropTypes.array,
};
// eslint-disable-next-line no-unused-vars
const styles = theme => ({});
export default compose(
withStyles(styles, {withTheme: true}),
)(BaseMap);
import React from "react";
import PropTypes from "prop-types";
import {connect} from "react-redux";
import {Marker, Popup} from "react-leaflet";
import UnivPopupContent from "./UnivPopupContent";
import getActions from "../../redux/api/getActions";
import CustomComponentForAPI from "../common/CustomComponentForAPI";
import arrayOfInstancesToMap from "../../utils/arrayOfInstancesToMap";
import BaseMap from "./BaseMap";
import uuid from "uuid/v4";
import "./map.scss";
/**
* Class that renders the markers for the map of the universities.
*
* Each main campus and associated relevant information is added to the map.
*
* @class UnivMarkers
* @extends {CustomComponentForAPI}
* @extends React.Component
* Main map of the application (map tab)
*/
class UnivMarkers extends CustomComponentForAPI {
class MainMap extends CustomComponentForAPI {
static id = uuid();
customRender() {
const {
......@@ -29,6 +25,7 @@ class UnivMarkers extends CustomComponentForAPI {
cities
} = this.getAllLatestReadData();
// some conversions for optimization (faster search of element in a map)
const universitiesMap = arrayOfInstancesToMap(universities),
countriesMap = arrayOfInstancesToMap(countries),
......@@ -50,12 +47,12 @@ class UnivMarkers extends CustomComponentForAPI {
mainCampusesSelection.push({
univName: univ.name,
univLogo: univ.logo,
univCity: city.name,
univCountry: country.name,
lat: campus.lat,
lon: campus.lon,
id: univ.id
univLogoUrl: univ.logo,
cityName: city.name,
countryName: country.name,
lat: parseFloat(campus.lat),
lon: parseFloat(campus.lon),
univId: univ.id
});
}
......@@ -64,25 +61,12 @@ class UnivMarkers extends CustomComponentForAPI {
// create all the markers
return (
mainCampusesSelection.map((el, idx) => (
<Marker key={idx} position={[el.lat, el.lon]}>
<Popup closeButton={false}>
<UnivPopupContent
name={el.univName}
logo={el.univLogo}
city={el.univCity}
country={el.univCountry}
univId={el.id}
/>
</Popup>
</Marker>
))
<BaseMap id={MainMap.id} campuses={mainCampusesSelection}/>
);
}
}
UnivMarkers.propTypes = {
MainMap.propTypes = {
universities: PropTypes.object.isRequired,
mainCampuses: PropTypes.object.isRequired,
cities: PropTypes.object.isRequired,
......@@ -111,4 +95,4 @@ const mapDispatchToProps = (dispatch) => {
};
};
export default connect(mapStateToProps, mapDispatchToProps)(UnivMarkers);
export default connect(mapStateToProps, mapDispatchToProps)(MainMap);
import React, {Component} from "react";
import {connect} from "react-redux";
import PropTypes from "prop-types";
import {LayerGroup, LayersControl, Map, TileLayer} from "react-leaflet";
import UnivMarkers from "./UnivMakers";
import {saveMainMapStatus} from "../../redux/actions/map";
import "./custom_leaflet.css";
/**
* Component to create the map of universities
*
* @class UnivMap
* @extends {Component}
*/
class UnivMap extends Component {
// Initial state
state = {
leafletInstance: null,
height: 800,
};
constructor(props) {
super(props);
// Make sure to set the correct height on mount
this.updateDimensions();
}
/**
* Custom function to update the appropriate height of the map
*
* @memberof UnivMap
*/
updateDimensions() {
try {
const height = window.innerHeight - document.getElementById("MySuperMap").getBoundingClientRect().y;
this.setState({height: Math.round(0.9 * height)});
}
// eslint-disable-next-line no-empty
catch (err) {
}
}
componentDidMount() {
// add an event listener to resize the map when needed
window.addEventListener("resize", this.updateDimensions.bind(this));
this.updateDimensions();
}
componentWillUnmount() {
// Save the state of the map to the redux store so that it is restored easily
const {leafletInstance} = this.state;
if (leafletInstance) {
let selectedLayer = "";
if (this.state.selectedLayer) {
selectedLayer = this.state.selectedLayer;
} else {
selectedLayer = this.props.map.selectedLayer;
}
let center = [leafletInstance.getCenter().lat, leafletInstance.getCenter().lng];
this.props.saveMainMap({
zoom: leafletInstance.getZoom(),
center,
selectedLayer
});
}
window.removeEventListener("resize", this.updateDimensions.bind(this));
}
/**
* Function to save the leafletInstance to the state of the component
*
* @param {object} leafletInstance
* @memberof UnivMap
*/
saveLeafletInstance(leafletInstance) {
this.setState(Object.assign({}, this.state, {
leafletInstance: leafletInstance,
}));
}
/**
* Function to save the current selected layer of the map to the state
*
* @param {object} layer
* @memberof UnivMap
*/
saveSelectedLayer(layer) {
this.setState(Object.assign({}, this.state, {
selectedLayer: layer.name,
}));
}
render() {
const stamenName = "Stamen Watercolor",
osmFrName = "OpenStreetMap France",
esriName = "Esri WorldImagery",
{selectedLayer, zoom, center} = this.props.map,
{height} = this.state;
// Create the map and add the layers and markers
return (
<>
<Map id={"MySuperMap"} center={center} zoom={zoom} style={{height}}
whenReady={(e) => this.saveLeafletInstance(e.target)} onBaselayerchange={(e) => this.saveSelectedLayer(e)}>
<LayersControl position="topright">
<LayersControl.BaseLayer name={osmFrName} checked={selectedLayer === osmFrName}>
<TileLayer
attribution='&copy; Openstreetmap France | &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.fr/osmfr/{z}/{x}/{y}.png"
maxZoom={20}
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name={stamenName} checked={selectedLayer === stamenName}>
{/* Need to overlay 2 layers for this one */}
<LayerGroup>
<TileLayer
attribution='Map tiles by <a href="http://stamen.com">Stamen Design</a>, <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a> &mdash; Map data &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://stamen-tiles-{s}.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.png"
minZoom={1}
maxZoom={18}
subdomains='abcd'
/>
<TileLayer
attribution='Map tiles by <a href="http://stamen.com">Stamen Design</a>, <a href="http://creativecommons.org/licenses/by/3.0">CC BY 3.0</a> &mdash; Map data &copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://stamen-tiles-{s}.a.ssl.fastly.net/toner-labels/{z}/{x}/{y}.png"
minZoom={1}
subdomains='abcd'
maxZoom={18}
/>
</LayerGroup>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name={esriName} checked={selectedLayer === esriName}>
<TileLayer
attribution="Tiles &copy; Esri &mdash; Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community"
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
/>
</LayersControl.BaseLayer>
</LayersControl>
{/* Add the markers for the universities */}
<UnivMarkers/>
</Map>
</>
);
}
}
UnivMap.propTypes = {
map: PropTypes.object.isRequired,
saveMainMap: PropTypes.func.isRequired
};
const mapStateToProps = (state) => {
return {
map: state.app.mainMap
};
};
const mapDispatchToProps = (dispatch) => {
return {
saveMainMap: (mapState) => dispatch(saveMainMapStatus(mapState)),
};
};
export default connect(mapStateToProps, mapDispatchToProps)(UnivMap);
import React, { Component } from "react";
import React, {Component} from "react";
import PropTypes from "prop-types";
import withStyles from "@material-ui/core/styles/withStyles";
import Card from "@material-ui/core/Card";
......@@ -10,55 +10,53 @@ import Typography from "@material-ui/core/Typography";
import Divider from "@material-ui/core/Divider";
import IconAdd from "@material-ui/icons/Add";
import IconClose from "@material-ui/icons/Close";
import { Link } from "react-router-dom";
import {Link} from "react-router-dom";
import MyCardMedia from "./MyCardMedia";
import { withLeaflet } from "react-leaflet";
import {APP_ROUTES} from "../../config/appRoutes";
/**
* Custom component to create the overlayed popup on the map with university info
*
* @class UnivPopupContent
* @class UnivMapPopup
* @extends {Component}
*/
class UnivPopupContent extends Component {
class UnivMapPopup extends Component {
render() {
const { classes, logo, name, city, country, leaflet, univId } = this.props,
height = this.props.logo !== "" ? "140" : "0",
closeIt = () => leaflet.map.closePopup();
const {classes, univLogoUrl, univName, cityName, countryName, univId, handleClose} = this.props,
height = "50";// this.props.univLogoUrl !== "" ? "140" : "0";
return (
<Card className={classes.card}>
<CardActionArea>
<MyCardMedia