big commit

main
Guilian Celin--Davanture 2023-12-10 21:31:59 +01:00
commit fe29399aad
Signed by: Guilian
GPG Key ID: C063AC8F4FC34489
9 changed files with 11753194 additions and 0 deletions

19
Dockerfile Normal file
View File

@ -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

1258791
_ARRONDISSEMENTS.geojson Normal file

File diff suppressed because it is too large Load Diff

9707436
_COMMUNES.geojson Normal file

File diff suppressed because it is too large Load Diff

786336
_DEPARTEMENTS.geojson Normal file

File diff suppressed because it is too large Load Diff

138
index.html Normal file
View File

@ -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>

24
index.js Normal file
View File

@ -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');

437
map.html Normal file
View File

@ -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: '&copy; <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>

13
package.json Normal file
View File

@ -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"
}
}

BIN
public/api_keys.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB