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
Pipeline #40463 passed with stages
in 4 minutes and 7 seconds
...@@ -13,3 +13,4 @@ database.db ...@@ -13,3 +13,4 @@ database.db
database.db-journal database.db-journal
.idea .idea
npm-debug.log npm-debug.log
.env
...@@ -34,7 +34,7 @@ check_back: ...@@ -34,7 +34,7 @@ check_back:
check_front: check_front:
<<: *only-default <<: *only-default
stage: check 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: before_script:
- cd frontend && cp -R /usr/src/deps/node_modules . - cd frontend && cp -R /usr/src/deps/node_modules .
script: script:
...@@ -70,7 +70,7 @@ test_back: ...@@ -70,7 +70,7 @@ test_back:
test_frontend: test_frontend:
<<: *only-default <<: *only-default
stage: test 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: before_script:
- cd frontend && cp -R /usr/src/deps/node_modules . - cd frontend && cp -R /usr/src/deps/node_modules .
script: script:
...@@ -90,7 +90,7 @@ flake8: ...@@ -90,7 +90,7 @@ flake8:
eslint: eslint:
<<: *only-default <<: *only-default
stage: lint 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: before_script:
- cd frontend && cp -R /usr/src/deps/node_modules . - cd frontend && cp -R /usr/src/deps/node_modules .
script: script:
......
...@@ -69,6 +69,9 @@ prod: setup ...@@ -69,6 +69,9 @@ prod: setup
down_prod: down_prod:
docker-compose $(prod_yml) down docker-compose $(prod_yml) down
prod_docker_logs:
docker-compose $(prod_yml) logs
shell_prod_logs: shell_prod_logs:
docker-compose $(prod_yml) exec logs_rotation /bin/sh -c "cd /var/log && /bin/sh" docker-compose $(prod_yml) exec logs_rotation /bin/sh -c "cd /var/log && /bin/sh"
......
...@@ -38,9 +38,9 @@ ...@@ -38,9 +38,9 @@
var __AllBackendEndPointsRoutes = {{ endpoints|safe }}; var __AllBackendEndPointsRoutes = {{ endpoints|safe }};
</script> </script>
<link rel="stylesheet" href="{{ front_bundle_loc }}leaflet-dist/leaflet.css"/>
{% render_bundle 'main' %} {% render_bundle 'main' %}
{% render_bundle 'vendor' %} {% 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> <noscript>Cette application nécessite Javascript... Merci de l'activer ou d'utiliser un navigateur approprié.</noscript>
</html> </html>
...@@ -14,6 +14,7 @@ volumes: ...@@ -14,6 +14,7 @@ volumes:
networks: networks:
backend-nginx: backend-nginx:
backend-db: backend-db:
map-nginx:
services: services:
...@@ -43,13 +44,12 @@ services: ...@@ -43,13 +44,12 @@ services:
volumes: volumes:
- ./backend/static/:/usr/src/static:ro - ./backend/static/:/usr/src/static:ro
- ./backend/media/:/usr/src/media:ro - ./backend/media/:/usr/src/media:ro
networks: [backend-nginx] networks: [backend-nginx, map-nginx]
ports: ports:
# The port 8000 of the host is redirected to the port 80 of the container # 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 # which then redirect it to the port 8000 of the backend container
- 8000:80 - 8000:80
depends_on: depends_on: [backend, map]
- backend
# Service for the postgres database # Service for the postgres database
database: database:
...@@ -63,7 +63,7 @@ services: ...@@ -63,7 +63,7 @@ services:
# Service to handle frontend live developpments and building # Service to handle frontend live developpments and building
frontend: frontend:
# Get the image from the registry # 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. # To use a locally built one, comment above, uncomment bellow.
# build: ./frontend # build: ./frontend
# On startup, we retrieve the dependencies from the image and start the developpement server # On startup, we retrieve the dependencies from the image and start the developpement server
...@@ -79,6 +79,14 @@ services: ...@@ -79,6 +79,14 @@ services:
# replicate the view stats port # replicate the view stats port
- "8888:8888" - "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 # Service to provide a local documentation
documentation: documentation:
build: ./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 ...@@ -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). 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: This is done once again with redux `connect` function:
TODO example outdated with the new map system.
```js ```js
import React, { Component } from "react"; import React, { Component } from "react";
......
...@@ -8,7 +8,7 @@ Deploying the app is fairly simple: all you need is `docker` and `docker-compose ...@@ -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. 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. - 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. - 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). `make prod` will start all the services described in `docker-compose.prod.yml` (in the `server` directory).
......
...@@ -28,6 +28,7 @@ ...@@ -28,6 +28,7 @@
* [Use of Redux](Application/Frontend/redux.md) * [Use of Redux](Application/Frontend/redux.md)
* [Tests](Application/Frontend/tests.md) * [Tests](Application/Frontend/tests.md)
* [Notifications](Application/Frontend/notifications.md) * [Notifications](Application/Frontend/notifications.md)
* [Map](Application/Frontend/map.md)
- [**Deploy**](Application/deploy.md) - [**Deploy**](Application/deploy.md)
......
This diff is collapsed.
...@@ -30,14 +30,14 @@ ...@@ -30,14 +30,14 @@
"downshift": "^3.2.3", "downshift": "^3.2.3",
"fuzzysort": "^1.1.4", "fuzzysort": "^1.1.4",
"keycode": "^2.2.0", "keycode": "^2.2.0",
"leaflet": "^1.4.0",
"lodash": "^4.17.11", "lodash": "^4.17.11",
"mapbox-gl": "^0.54.0",
"material-ui-pickers": "^2.2.1", "material-ui-pickers": "^2.2.1",
"notistack": "^0.4.3", "notistack": "^0.4.3",
"react": "^16.8.6", "react": "^16.8.6",
"react-awesome-slider": "^0.5.2", "react-awesome-slider": "^0.5.2",
"react-dom": "^16.8.6", "react-dom": "^16.8.6",
"react-leaflet": "^2.2.1", "react-mapbox-gl": "^4.3.2",
"react-markdown": "^4.0.6", "react-markdown": "^4.0.6",
"react-redux": "^6.0.1", "react-redux": "^6.0.1",
"react-router-dom": "^5.0.0", "react-router-dom": "^5.0.0",
...@@ -46,7 +46,8 @@ ...@@ -46,7 +46,8 @@
"redux": "^4.0.1", "redux": "^4.0.1",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"redux-thunk": "^2.3.0", "redux-thunk": "^2.3.0",
"typeface-roboto": "0.0.54" "typeface-roboto": "0.0.54",
"uuid": "^3.3.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.4.3", "@babel/core": "^7.4.3",
......
...@@ -9,7 +9,7 @@ import CustomError from "../common/CustomError"; ...@@ -9,7 +9,7 @@ import CustomError from "../common/CustomError";
* Function to build a chached version of the fields mapping to cascade update. * Function to build a chached version of the fields mapping to cascade update.
* *
* @param {Array.<FormLevelError>} formLevelErrors * @param {Array.<FormLevelError>} formLevelErrors
* @returns {Map<string, Set>} * @returns {ReactMapboxGl<string, Set>}
*/ */
function buildFieldUpdateCache(formLevelErrors) { function buildFieldUpdateCache(formLevelErrors) {
let out = new Map(); let out = new Map();
...@@ -219,7 +219,7 @@ class Form extends Component { ...@@ -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 * 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. * that should be updated when the previous one is updated.
* @returns {Map<string, Set>} * @returns {ReactMapboxGl<string, Set>}
*/ */
getFieldsToUpdate() { getFieldsToUpdate() {
if (!this.fieldsToUpdate) { 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 React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import {connect} from "react-redux"; import {connect} from "react-redux";
import {Marker, Popup} from "react-leaflet";
import UnivPopupContent from "./UnivPopupContent";
import getActions from "../../redux/api/getActions"; import getActions from "../../redux/api/getActions";
import CustomComponentForAPI from "../common/CustomComponentForAPI"; import CustomComponentForAPI from "../common/CustomComponentForAPI";
import arrayOfInstancesToMap from "../../utils/arrayOfInstancesToMap"; 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. * Main map of the application (map tab)
*
* Each main campus and associated relevant information is added to the map.
*
* @class UnivMarkers
* @extends {CustomComponentForAPI}
* @extends React.Component
*/ */
class UnivMarkers extends CustomComponentForAPI { class MainMap extends CustomComponentForAPI {
static id = uuid();
customRender() { customRender() {
const { const {
...@@ -29,6 +25,7 @@ class UnivMarkers extends CustomComponentForAPI { ...@@ -29,6 +25,7 @@ class UnivMarkers extends CustomComponentForAPI {
cities cities
} = this.getAllLatestReadData(); } = this.getAllLatestReadData();
// some conversions for optimization (faster search of element in a map) // some conversions for optimization (faster search of element in a map)
const universitiesMap = arrayOfInstancesToMap(universities), const universitiesMap = arrayOfInstancesToMap(universities),
countriesMap = arrayOfInstancesToMap(countries), countriesMap = arrayOfInstancesToMap(countries),
...@@ -50,12 +47,12 @@ class UnivMarkers extends CustomComponentForAPI { ...@@ -50,12 +47,12 @@ class UnivMarkers extends CustomComponentForAPI {
mainCampusesSelection.push({ mainCampusesSelection.push({
univName: univ.name, univName: univ.name,
univLogo: univ.logo, univLogoUrl: univ.logo,
univCity: city.name, cityName: city.name,
univCountry: country.name, countryName: country.name,
lat: campus.lat, lat: parseFloat(campus.lat),
lon: campus.lon, lon: parseFloat(campus.lon),
id: univ.id univId: univ.id
}); });
} }
...@@ -64,25 +61,12 @@ class UnivMarkers extends CustomComponentForAPI { ...@@ -64,25 +61,12 @@ class UnivMarkers extends CustomComponentForAPI {
// create all the markers // create all the markers
return ( return (
mainCampusesSelection.map((el, idx) => ( <BaseMap id={MainMap.id} campuses={mainCampusesSelection}/>
<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>
))
); );
} }
} }
UnivMarkers.propTypes = { MainMap.propTypes = {
universities: PropTypes.object.isRequired, universities: PropTypes.object.isRequired,
mainCampuses: PropTypes.object.isRequired, mainCampuses: PropTypes.object.isRequired,
cities: PropTypes.object.isRequired, cities: PropTypes.object.isRequired,
...@@ -111,4 +95,4 @@ const mapDispatchToProps = (dispatch) => { ...@@ -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}}