big commit
commit
fe29399aad
|
@ -0,0 +1,19 @@
|
|||
FROM oven/bun:latest
|
||||
|
||||
# copy files
|
||||
COPY *.js .
|
||||
COPY *.html .
|
||||
COPY *.geojson .
|
||||
COPY package.json .
|
||||
|
||||
COPY public public
|
||||
|
||||
RUN bun install
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# remove root perms
|
||||
RUN useradd -u 8877 barman
|
||||
USER barman
|
||||
|
||||
CMD bun run start
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,138 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Projet BD</title>
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
background-color: whitesmoke;
|
||||
color: black;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 1em;
|
||||
margin-bottom: 50vh;
|
||||
}
|
||||
|
||||
header {
|
||||
height: calc(100vh - 2em);
|
||||
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: 90%;
|
||||
}
|
||||
|
||||
.last {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
header a {
|
||||
display: flex;
|
||||
padding: 1em;
|
||||
border-radius: 4px;
|
||||
width: fit-content;
|
||||
background-color: #b30000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
header a:hover {
|
||||
background-color: #7f0000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #7f0000;
|
||||
max-width: 50vw;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #b30000;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #7f0000;
|
||||
}
|
||||
|
||||
@media (max-width: 40em) {
|
||||
body {
|
||||
margin: 0.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>La quantité d'impôts que l'on paye est-elle corrélée avec la quantité de route construite chez nous ?</h1>
|
||||
|
||||
<a href="/map">
|
||||
Voir la réponse !
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="last">Scrollez pour plus d'info...</p>
|
||||
</header>
|
||||
|
||||
<h2>Étapes du projet</h2>
|
||||
|
||||
<p>Il a d'abord fallu récupérer les données spécifiques à notre question, c'est à dire: </p>
|
||||
<ul>
|
||||
<li>
|
||||
Les données sur les impôts de chaque zone, fournies par l'<a href="https://www.insee.fr/fr/accueil">INSEE</a>
|
||||
</li>
|
||||
<li>
|
||||
Les données sur la quantité de route dans chaque zone, fournies par <a href="https://www.openstreetmap.org">OpenStreetMap</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>Les données de l'INSEE</h3>
|
||||
|
||||
<p>Pour récupérer les données de l'insee, il faut passer par <a href="https://api.insee.fr/catalogue">leur api</a>, qui n'est pas très bien documentée, et comprendre comment faire une requête.</p>
|
||||
|
||||
<p>Après avoir surmonté cet obstacle, on peut se rendre compte que l'INSEE limite le nombre de requêtes à 30 par minute, ce qui est beaucoup trop lent pour parcourir les environ 36000 communes de France en un temps correct.</p>
|
||||
|
||||
<p>Une solution simple, bien que longue à mettre en place, est de créer 30 clés d'API et d'alterner à chaque requête.</p>
|
||||
|
||||
<img src="/public/api_keys.png" alt="30 clés d'API de l'INSEE"/>
|
||||
|
||||
<p>Les données sur les impôts sont masquées pour les zones de moins de 1000 ménages, par <a href="https://fr.wikipedia.org/wiki/Secret_statistique">secret statistique</a>, ce qui limite le nombre de communes ayant des données sur les impôts à seulement 4000.</p>
|
||||
|
||||
<p>La donnée sur les impôts se trouve dans le tableau "revenus", et est représenté par un nombre négatif ; ce nombre représente, pour le "ménage moyen" de la zone, la part des revenus qui partent dans leurs impôts.</p>
|
||||
|
||||
<h3>Les données d'OSM</h3>
|
||||
|
||||
<p>La récupération des données de l'insee s'est faite par une simple requête à <a href="https://overpass-api.de/">l'api overpass</a> d'OSM (instance principale), répétée une fois par zone. Pour les zones plus grandes, comme les départements, le serveur a mis trop de temps à calculer et a donc abandonné la requête.</p>
|
||||
<p>Les données ont directement pu être fusionnées avec les données de l'insee.</p>
|
||||
|
||||
<p>Une donnée supplémentaire non utilisée dans l'application est le type de routes ; en effet, l'application ne différencie actuellement pas une autoroute d'une ruelle. Prendre en compte cette donnée (qui est présente dans le dataset final) pourrait améliorer la visualisation.</p>
|
||||
|
||||
<h3>Les données des zones</h3>
|
||||
|
||||
<p>Une fois les données de la question récupérées, il a fallu les afficher sur une carte. Pour cela, il a fallu obtenir un <a href="https://geojson.org/">GeoJSON</a> des zones françaises, pour pouvoir les afficher dans <a href="https://leafletjs.com">Leaflet</a>.</p>
|
||||
|
||||
<p>
|
||||
Trouver ces données a été compliqué car les datasets trouvés sur <a href="data.gouv.fr">data.gouv.fr</a>
|
||||
sont soit dans un mauvais format (<a href="https://epsg.io/3575">EPSG:3575</a>, qui définit les points en mètres,
|
||||
par opposition à <a href="https://epsg.io/4326">EPSG:4326</a> qui définit les points en degrés), soit plus à jour
|
||||
(il y a eu des changements au <a href="https://www.insee.fr/fr/information/2560452">COG</a> en 2015 et en 2022.
|
||||
</p>
|
||||
|
||||
<p>Nous avons au final trouvé les données dans le bon format dans <a href="https://github.com/Juralexx/france-geojson-app/tree/master/files/geojson">un projet github</a>, qui ne semble pas indiquer leur provenance, mais qui sont complètes et bien structurées.</p>
|
||||
|
||||
<p>L'étape finale a été de tout fusionner en trois gros fichiers, contenant respectivement les données pour les communes, les départements ainsi que leurs arrondissements.</p>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,24 @@
|
|||
import express from "express";
|
||||
import path from "path";
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use('/public', express.static('public'))
|
||||
|
||||
app.get('/data.json', async (req, res) => {
|
||||
res.json({
|
||||
coms: await Bun.file('./_COMMUNES.geojson').json(),
|
||||
arrs: await Bun.file('./_ARRONDISSEMENTS.geojson').json(),
|
||||
deps: await Bun.file('./_DEPARTEMENTS.geojson').json(),
|
||||
});
|
||||
})
|
||||
|
||||
app.get('/map', (req, res) => {
|
||||
res.sendFile( path.resolve(__dirname, 'map.html') )
|
||||
})
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile( path.resolve(__dirname, 'index.html') )
|
||||
})
|
||||
|
||||
app.listen(8080, '0.0.0.0');
|
|
@ -0,0 +1,437 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mon super projet BD</title>
|
||||
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
|
||||
<style>
|
||||
body, html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
#map {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: 6px 8px;
|
||||
font: 14px/16px Arial, Helvetica, sans-serif;
|
||||
background: white;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 5px;
|
||||
|
||||
max-width: 40ch;
|
||||
}
|
||||
|
||||
.info p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info h4 {
|
||||
margin: 0 0 5px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
#hold_on {
|
||||
z-index: 9999;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #222;
|
||||
color: white;
|
||||
opacity: 0.75;
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#hold_on.active {
|
||||
visibility: visible;
|
||||
pointer-events: all;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="hold_on" class="">
|
||||
<h1>Veuillez patienter...</h1>
|
||||
<p>Chargement des données en cours</p>
|
||||
</div>
|
||||
<div id="map"></div>
|
||||
|
||||
|
||||
|
||||
<script>
|
||||
// contains the data from all the .geojsons
|
||||
let zones_geojson_data;
|
||||
|
||||
// the map (lol)
|
||||
let map;
|
||||
|
||||
// infobox in the top right corner
|
||||
let info;
|
||||
// legend in the bottom right corner
|
||||
let legend;
|
||||
|
||||
let current;
|
||||
|
||||
|
||||
|
||||
// https://colorbrewer2.org/#type=sequential&scheme=OrRd&n=9
|
||||
const map_colors = [
|
||||
"#fff7ec",
|
||||
"#fee8c8",
|
||||
"#fdd49e",
|
||||
"#fdbb84",
|
||||
"#fc8d59",
|
||||
"#ef6548",
|
||||
"#d7301f",
|
||||
"#b30000",
|
||||
"#7f0000",
|
||||
];
|
||||
|
||||
|
||||
function get_color(val) {
|
||||
if( val < 0) {
|
||||
return '#BBB';
|
||||
}
|
||||
|
||||
// => 0.15, 0.2, etc. the range between each color
|
||||
const class_range = 1 / map_colors.length;
|
||||
|
||||
const color_index = Math.max(0, Math.round(val * map_colors.length) - 1)
|
||||
const chosen_color = map_colors[color_index]
|
||||
|
||||
//console.log(`Picking color for ${val} => ${chosen_color}`)
|
||||
|
||||
return chosen_color;
|
||||
}
|
||||
|
||||
function style(feature) {
|
||||
const ratio = feature.properties.normalized_ratio ?? null;
|
||||
|
||||
if (ratio && ratio >= 0) {
|
||||
return {
|
||||
fillColor: get_color(ratio),
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
color: '#555',
|
||||
//color: get_color(ratio), //'white',
|
||||
dashArray: '3',
|
||||
fillOpacity: 0.8
|
||||
};
|
||||
}
|
||||
else {
|
||||
return {
|
||||
fillColor: 'grey',
|
||||
weight: 1,
|
||||
opacity: 1,
|
||||
color: 'grey',
|
||||
dashArray: '3',
|
||||
fillOpacity: 0.7
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function zoomToFeature(e) {
|
||||
map.fitBounds(e.target.getBounds());
|
||||
}
|
||||
|
||||
function highlightFeature(e) {
|
||||
var layer = e.target;
|
||||
|
||||
layer.setStyle({
|
||||
weight: 5,
|
||||
color: '#666',
|
||||
dashArray: '',
|
||||
fillOpacity: 0.7
|
||||
});
|
||||
|
||||
layer.bringToFront();
|
||||
info.update(layer.feature.properties);
|
||||
}
|
||||
|
||||
function resetHighlight(e) {
|
||||
if (current) zones_geojson_data[current].geo.resetStyle(e.target);
|
||||
info.update();
|
||||
}
|
||||
|
||||
function onEachFeature(feature, layer) {
|
||||
layer.on({
|
||||
mouseover: highlightFeature,
|
||||
mouseout: resetHighlight,
|
||||
click: zoomToFeature
|
||||
});
|
||||
}
|
||||
|
||||
function normalize_data(dataset, remove_empty=false) {
|
||||
console.log(dataset);
|
||||
|
||||
let lowest = 10e10;
|
||||
let highest = -1
|
||||
|
||||
dataset.features.forEach(a => {
|
||||
const r = a.properties.ratio;
|
||||
if (r) {
|
||||
if (r < lowest) {
|
||||
lowest = r;
|
||||
}
|
||||
else if (r > highest) {
|
||||
highest = r;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
dataset.features = dataset.features.map(el => {
|
||||
if (el.properties.ratio && el.properties.road_length !== 0) {
|
||||
const obj = el;
|
||||
obj.properties.normalized_ratio = (obj.properties.ratio - lowest) / (highest - lowest);
|
||||
if (obj.properties.normalized_ratio > 1) {
|
||||
//console.log(`${obj.properties.normalized_ratio} = (${obj.properties.ratio} - ${lowest}) / ${highest}`)
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
else if(remove_empty === false) {
|
||||
return el;
|
||||
}
|
||||
}).filter(el => el !== undefined);
|
||||
|
||||
dataset.properties = {
|
||||
ratio_limits: [lowest, highest],
|
||||
};
|
||||
|
||||
|
||||
return dataset
|
||||
}
|
||||
|
||||
function set_current_data(name) {
|
||||
console.log('active');
|
||||
document.getElementById('hold_on').classList.add('active');
|
||||
|
||||
if (current) {
|
||||
const old_data = zones_geojson_data[current];
|
||||
// remove current layer
|
||||
old_data.geo.remove();
|
||||
}
|
||||
|
||||
const new_data = zones_geojson_data[name];
|
||||
new_data.geo.addTo(map);
|
||||
|
||||
current = name;
|
||||
|
||||
generate_map_legend();
|
||||
|
||||
console.log('INactive');
|
||||
document.getElementById('hold_on').classList.remove('active');
|
||||
}
|
||||
|
||||
function generate_map_legend() {
|
||||
// remove old legend, if any
|
||||
if (legend) legend.remove();
|
||||
|
||||
legend = L.control({ position: 'bottomright' });
|
||||
|
||||
legend.onAdd = function (map) {
|
||||
const div = L.DomUtil.create('div', 'info legend');
|
||||
|
||||
const [lowest, highest] = zones_geojson_data[current].data.properties.ratio_limits;
|
||||
const step = (highest - lowest) / map_colors.length;
|
||||
|
||||
div.innerHTML += `
|
||||
<p>
|
||||
<span style="background: ${get_color(-1)}; width: 1em; height: 1em; display: inline-block"></span>
|
||||
<span> Pas de données</span>
|
||||
</p>
|
||||
`;
|
||||
|
||||
for (let i = 0; i < map_colors.length; i++) {
|
||||
const val = (i * step) / (highest - lowest);
|
||||
const text = Math.round(i * step)
|
||||
|
||||
div.innerHTML += `
|
||||
<p>
|
||||
<span style="background: ${get_color(val)}; width: 1em; height: 1em; display: inline-block"></span>
|
||||
<span> Plus de ${text}km par PI</span>
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
div.innerHTML += `
|
||||
<p>PI: Pourcentage Impôt</p>
|
||||
`
|
||||
|
||||
return div;
|
||||
};
|
||||
|
||||
legend.addTo(map);
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// CREATE THE MAP
|
||||
|
||||
map = L.map('map').setView([47, 2.7], 6);
|
||||
|
||||
const tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19,
|
||||
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(map);
|
||||
|
||||
///////////
|
||||
|
||||
info = L.control();
|
||||
|
||||
info.onAdd = function (map) {
|
||||
this._div = L.DomUtil.create('div', 'info'); // create a div with a class "info"
|
||||
this.update();
|
||||
return this._div;
|
||||
};
|
||||
|
||||
// method that we will use to update the control based on feature properties passed
|
||||
info.update = function (props) {
|
||||
if (props) {
|
||||
const road_km = Math.round(props.road_length / 1000);
|
||||
const road_per_pi = Math.round((props.road_length / 1000) / props.impots);
|
||||
|
||||
this._div.innerHTML = `
|
||||
<h4>Données pour:</h4>
|
||||
<p><b>${props.nom}</b></p>
|
||||
<p>Impôts: ${props.impots}% du revenu</p>
|
||||
<p>Routes: ${road_km}km</p>
|
||||
<hr/>
|
||||
<p>Kilomètre de route par pourcentage d'impôt:</p>
|
||||
<p>${ road_per_pi }</p>
|
||||
`;
|
||||
//<p>Ratio route/impôt:<br>${props.normalized_ratio}</p>
|
||||
}
|
||||
else {
|
||||
this._div.innerHTML = `
|
||||
<h4>Aucune sélection</h4>
|
||||
<p>Survoler une zone pour afficher les données</p>
|
||||
`;
|
||||
}
|
||||
};
|
||||
|
||||
info.addTo(map);
|
||||
|
||||
////////////////////
|
||||
// add dataset selector
|
||||
|
||||
const bl_selector = L.control({ position: 'bottomleft' });
|
||||
|
||||
bl_selector.onAdd = function (map) {
|
||||
const div = L.DomUtil.create('div', 'info bl_selector');
|
||||
|
||||
div.innerHTML += `
|
||||
<form>
|
||||
<fieldset>
|
||||
<legend>Sélectionnez un jeu de données</legend>
|
||||
|
||||
<div style="display: flex;">
|
||||
<input type="radio" id="dataset_dep" name="dataset_choice" checked onclick="set_current_data('deps')"/>
|
||||
<label for="dataset_dep">Départements</label>
|
||||
</div>
|
||||
|
||||
<div style="display: flex;">
|
||||
<input type="radio" id="dataset_arr" name="dataset_choice" onclick="set_current_data('arrs')"/>
|
||||
<label for="dataset_arr">Arrondissements</label>
|
||||
</div>
|
||||
|
||||
<div style="display: flex;">
|
||||
<input type="radio" id="dataset_com" name="dataset_choice" onclick="set_current_data('coms_noempty')"/>
|
||||
<label for="dataset_com" title="Beaucoup de communes (32k) n'ont pas de données ; cette option n'affiche que celles qui en ont.">Communes (réduites)</label>
|
||||
</div>
|
||||
|
||||
<div style="display: flex;">
|
||||
<input type="radio" id="dataset_com" name="dataset_choice" onclick="set_current_data('coms')"/>
|
||||
<label for="dataset_com">Communes (toutes, même sans données)</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
`
|
||||
|
||||
return div;
|
||||
};
|
||||
|
||||
bl_selector.addTo(map);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
// FETCH DATA
|
||||
|
||||
document.getElementById('hold_on').classList.add('active');
|
||||
|
||||
fetch("/data.json").then(res => res.json()).then(data => {
|
||||
// load all the data
|
||||
const coms = normalize_data(data.coms);
|
||||
com_geojson = L.geoJson(coms, {
|
||||
style: style,
|
||||
onEachFeature: onEachFeature,
|
||||
});
|
||||
|
||||
const arrs = normalize_data(data.arrs);
|
||||
arr_geojson = L.geoJson(arrs, {
|
||||
style: style,
|
||||
onEachFeature: onEachFeature,
|
||||
});
|
||||
|
||||
const deps = normalize_data(data.deps);
|
||||
dep_geojson = L.geoJson(deps, {
|
||||
style: style,
|
||||
onEachFeature: onEachFeature,
|
||||
});
|
||||
|
||||
const coms_noempty = normalize_data(data.coms, true);
|
||||
coms_noempty_geojson = L.geoJson(coms_noempty, {
|
||||
style: style,
|
||||
onEachFeature: onEachFeature,
|
||||
});
|
||||
|
||||
zones_geojson_data = {
|
||||
deps: {
|
||||
data: deps,
|
||||
geo: dep_geojson,
|
||||
},
|
||||
arrs: {
|
||||
data: arrs,
|
||||
geo: arr_geojson,
|
||||
},
|
||||
coms: {
|
||||
data: coms,
|
||||
geo: com_geojson,
|
||||
},
|
||||
coms_noempty: {
|
||||
data: coms_noempty,
|
||||
geo: coms_noempty_geojson,
|
||||
},
|
||||
};
|
||||
|
||||
document.getElementById('hold_on').classList.remove('active');
|
||||
|
||||
set_current_data('deps');
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "BD-Website",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"docker": "docker build --no-cache -t projet-bd-impots . && docker save projet-bd-impots > projet-bd-impots.tar",
|
||||
"start": "bun run index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
}
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 364 KiB |
Loading…
Reference in New Issue