|
|
|
|
@ -1,328 +0,0 @@
|
|
|
|
|
## Подготовка
|
|
|
|
|
|
|
|
|
|
Создадим папку с заготовкой для нашей карты.
|
|
|
|
|
|
|
|
|
|
Добавим туда папку `backend`. Загрузим [отсюда](https://github.com/gtitov/flask-maplibre-map/raw/refs/heads/main/backend/cities_index.sqlite) и положим туда базу данных. И создадим в этой папке файл с нашим бэкендом `app.py`.
|
|
|
|
|
|
|
|
|
|
<FileTree>
|
|
|
|
|
- backend/
|
|
|
|
|
- app.py
|
|
|
|
|
- cities_index.sqlite
|
|
|
|
|
- index.html
|
|
|
|
|
- style.css
|
|
|
|
|
- main.js
|
|
|
|
|
</FileTree>
|
|
|
|
|
|
|
|
|
|
<details>
|
|
|
|
|
<summary>Что это за база данных такая -- SQLite</summary>
|
|
|
|
|
|
|
|
|
|
SQLite -- встраиваемая база данных, которая хранит все свои данные в одном файле. В одном файле хранятся все таблицы, индексы и другая информация о базе данных, что упрощает управление и резервное копирование. Благодаря этому, она не требует отдельного сервера и легко интегрируется в различные приложения.
|
|
|
|
|
|
|
|
|
|
</details>
|
|
|
|
|
|
|
|
|
|
## Разработка бэкенда
|
|
|
|
|
|
|
|
|
|
### Установка Flask
|
|
|
|
|
|
|
|
|
|
Установим Python и после установки через терминал загрузим в Python библиотеку Flask.
|
|
|
|
|
|
|
|
|
|
```sh title=Терминал
|
|
|
|
|
pip install Flask
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Теперь откроем ранее созданный нами файл `backend/app.py`. Подключим необходимые библиотеки и создадим объект `app`, в рамках которого мы будем определять доступные методы API.
|
|
|
|
|
|
|
|
|
|
```py title="app.py"
|
|
|
|
|
from flask import Flask, Response
|
|
|
|
|
import sqlite3
|
|
|
|
|
import json
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
|
|
|
|
DB_LOCATION = "cities_index.sqlite"
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Список городов по году
|
|
|
|
|
|
|
|
|
|
Добавим первый метод API. Он возвращает пользователю все города за выбранный год. При обращении к этому методу бэкенд выполняет запрос к базе данных и формирует на основе ответа GeoJSON файл, который мы сможем сразу отправить на карту.
|
|
|
|
|
|
|
|
|
|
> Мы могли бы отправить на фронтенд и неподготовленный файл. Собрать GeoJSON на клиентской стороне, как в карте вакансий, когда Google возвращал нам CSV. Но то был чужой API. А этот наш. И в нашем мы можем сделать так, как будет удобнее нам!
|
|
|
|
|
|
|
|
|
|
```py title=app.py
|
|
|
|
|
...
|
|
|
|
|
@app.route("/cities/<year>") # путь API, к которому обращается пользователь
|
|
|
|
|
def cities_by_year(year): # функция, которая будет выполняться при обращении
|
|
|
|
|
# start_time = time.time()
|
|
|
|
|
db = sqlite3.connect(DB_LOCATION) # подключение к базе данных
|
|
|
|
|
db.row_factory = sqlite3.Row # указание, что в строках мы будем сохранять название колонки и значение
|
|
|
|
|
cursor = db.execute("SELECT * FROM cities WHERE year = ?", (year,)) # выполняем запрос к базе, подставляя год, введённый пользователем
|
|
|
|
|
cities = cursor.fetchall() # забираем результат запроса
|
|
|
|
|
cursor.close() # закрываем запрос
|
|
|
|
|
db.close() # закрываем подключение
|
|
|
|
|
geojson = { # приводим к формату GeoJSON
|
|
|
|
|
"type": "FeatureCollection",
|
|
|
|
|
"features": [
|
|
|
|
|
{
|
|
|
|
|
"type": "Feature",
|
|
|
|
|
"geometry": {
|
|
|
|
|
"type": "Point",
|
|
|
|
|
"coordinates": [row["longitude"], row["latitude"]],
|
|
|
|
|
},
|
|
|
|
|
"properties": dict(row),
|
|
|
|
|
}
|
|
|
|
|
for row in cities
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
r = Response( # формируем ответ
|
|
|
|
|
json.dumps(geojson, ensure_ascii=False), # ensure_ascii=False, чтобы нормально отображалась кириллица
|
|
|
|
|
mimetype="application/json" # указываем тип данных
|
|
|
|
|
)
|
|
|
|
|
# print("--- %s seconds ---" % (time.time() - start_time))
|
|
|
|
|
return r
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Запускаем бэкенд на локальном сервере для проверки. Открываем терминал в папке `backend` и выполняем команду
|
|
|
|
|
|
|
|
|
|
```sh title=Терминал
|
|
|
|
|
flask run --debug
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
После чего увидим что-то вроде
|
|
|
|
|
|
|
|
|
|
```sh title=Терминал
|
|
|
|
|
flask run --debug
|
|
|
|
|
* Debug mode: on
|
|
|
|
|
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
|
|
|
|
|
* Running on http://127.0.0.1:5000
|
|
|
|
|
Press CTRL+C to quit
|
|
|
|
|
* Restarting with stat
|
|
|
|
|
* Debugger is active!
|
|
|
|
|
* Debugger PIN: 106-994-678
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Теперь мы можем обратиться к нашему бэкенду через API по адресу [`http://127.0.0.1:5000/cities/2020`](http://127.0.0.1:5000/cities/2020).
|
|
|
|
|
|
|
|
|
|
### Сведения о городе по идентификатору
|
|
|
|
|
|
|
|
|
|
Перейдём ко второму методу. Получим подробные сведения о городе по его идентификатору.
|
|
|
|
|
|
|
|
|
|
```py title=app.py
|
|
|
|
|
@app.route("/city/<id>")
|
|
|
|
|
def city_by_id(id):
|
|
|
|
|
start_time = time.time()
|
|
|
|
|
db = sqlite3.connect(DB_LOCATION)
|
|
|
|
|
db.row_factory = sqlite3.Row
|
|
|
|
|
cursor = db.execute("SELECT * FROM cities WHERE id = ?", (id,))
|
|
|
|
|
city = cursor.fetchone()
|
|
|
|
|
cursor.close()
|
|
|
|
|
db.close()
|
|
|
|
|
r = Response(
|
|
|
|
|
json.dumps(dict(city), ensure_ascii=False),
|
|
|
|
|
mimetype="application/json",
|
|
|
|
|
)
|
|
|
|
|
# print("--- %s seconds ---" % (time.time() - start_time))
|
|
|
|
|
return r
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Проверим этот метод [`http://127.0.0.1:5000/city/1000`](http://127.0.0.1:5000/city/1000).
|
|
|
|
|
|
|
|
|
|
## Разработка фронтенда
|
|
|
|
|
|
|
|
|
|
### Подключение данных и CORS
|
|
|
|
|
|
|
|
|
|
Наш бэкенд возвращает данные в формате GeoJSON, поэтому мы можем сразу подключить их в нашу карту.
|
|
|
|
|
|
|
|
|
|
```js title=main.js
|
|
|
|
|
map.on("load", () => {
|
|
|
|
|
map.addSource('cities', {
|
|
|
|
|
type: 'geojson',
|
|
|
|
|
data: "http://localhost:5000/cities/2020" // бэкенд должен быть запущен
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
map.addLayer({
|
|
|
|
|
'id': 'cities-layer',
|
|
|
|
|
'source': 'cities',
|
|
|
|
|
'type': 'circle',
|
|
|
|
|
'paint': {
|
|
|
|
|
'circle-stroke-width': 1,
|
|
|
|
|
'circle-stroke-color': '#FFFFFF',
|
|
|
|
|
// SELECT MIN(total_points), MAX(total_points) FROM cities
|
|
|
|
|
'circle-color': [
|
|
|
|
|
'interpolate',
|
|
|
|
|
['linear'],
|
|
|
|
|
['get', 'total_points'],
|
|
|
|
|
50,
|
|
|
|
|
'#d7191c',
|
|
|
|
|
150,
|
|
|
|
|
'#ffffbf',
|
|
|
|
|
250,
|
|
|
|
|
'#1a9641'
|
|
|
|
|
],
|
|
|
|
|
'circle-opacity': 0.8,
|
|
|
|
|
// SELECT DISTINCT group_name FROM cities
|
|
|
|
|
'circle-radius': [
|
|
|
|
|
"match",
|
|
|
|
|
['get', 'group_name'],
|
|
|
|
|
'Малый город', 3,
|
|
|
|
|
'Средний город', 6,
|
|
|
|
|
'Большой город', 6,
|
|
|
|
|
'Крупный город', 8,
|
|
|
|
|
'Крупнейший город', 12,
|
|
|
|
|
0 // остальные
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Однако на карте мы не увидим искомых городов. Чтобы узнать почему, проверим вкладку "Сеть" в инструментах разработчика. У запроса к списку городов мы увидим надпись **Ошибка CORS**.
|
|
|
|
|
|
|
|
|
|
Механизм CORS -- Cross-Origin Resource Sharing -- призван повысить безопасность веб-страницы. Нам, чтобы избежать ошибки CORS, надо указать, что API может отвечать на запросы любых веб-страниц.
|
|
|
|
|
|
|
|
|
|
> CORS -- это история про *веб-страницы*, поэтому выполняя запросы к API напрямую мы с ней не сталкивались.
|
|
|
|
|
|
|
|
|
|
```diff lang=py title=app.py
|
|
|
|
|
...
|
|
|
|
|
@app.route("/cities/<int:year>") # путь API, к которому обращается пользователь
|
|
|
|
|
def cities_by_year(year): # функция, которая будет выполняться при обращении
|
|
|
|
|
...
|
|
|
|
|
r = Response( # формируем ответ
|
|
|
|
|
json.dumps(geojson, ensure_ascii=False), # ensure_ascii=False, чтобы нормально отображалась кириллица
|
|
|
|
|
mimetype="application/json", # указываем тип данных
|
|
|
|
|
+ headers={"Access-Control-Allow-Origin": "*"}
|
|
|
|
|
)
|
|
|
|
|
# print("--- %s seconds ---" % (time.time() - start_time))
|
|
|
|
|
return r
|
|
|
|
|
|
|
|
|
|
@app.route("/city/<int:id>")
|
|
|
|
|
def city_by_id(id):
|
|
|
|
|
...
|
|
|
|
|
r = Response(
|
|
|
|
|
json.dumps(dict(city), ensure_ascii=False),
|
|
|
|
|
mimetype="application/json",
|
|
|
|
|
+ headers={"Access-Control-Allow-Origin": "*"}
|
|
|
|
|
)
|
|
|
|
|
# print("--- %s seconds ---" % (time.time() - start_time))
|
|
|
|
|
return r
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
После добавления заголовков о том, что API может обслуживать любые веб-страницы, в ответ мы получаем наш список городов в формате GeoJSON.
|
|
|
|
|
|
|
|
|
|
### Выбор года
|
|
|
|
|
|
|
|
|
|
Дадим пользователю возможность выбирать год, за который он хочет видеть индекс городов.
|
|
|
|
|
|
|
|
|
|
Разметим элемент с выпадающем списком.
|
|
|
|
|
|
|
|
|
|
```diff lang=html title=index.html
|
|
|
|
|
<body>
|
|
|
|
|
<div id='map'></div>
|
|
|
|
|
<dialog id="city-details-modal" onmousedown="this.close()"></dialog>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <p class="text-center">Год</p>
|
|
|
|
|
+ <select id="year-selector">
|
|
|
|
|
+ <option value="2020" selected>2020</option>
|
|
|
|
|
+ <option value="2019">2019</option>
|
|
|
|
|
+ <option value="2018">2018</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
<script src="main.js"></script>
|
|
|
|
|
</body>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Сделаем так, чтобы он выводился поверх карты.
|
|
|
|
|
|
|
|
|
|
```diff lang=css title=style.css
|
|
|
|
|
#map {
|
|
|
|
|
position: absolute;
|
|
|
|
|
top: 0;
|
|
|
|
|
bottom: 0;
|
|
|
|
|
left: 0;
|
|
|
|
|
right: 0;
|
|
|
|
|
+ z-index: -1;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Запрограммируем реакцию карты на выбор года. Асинхронный запрос за нас выполнит картографическая библиотека.
|
|
|
|
|
|
|
|
|
|
```js title=main.js
|
|
|
|
|
map.on("load", () => {
|
|
|
|
|
...
|
|
|
|
|
document.getElementById("year-selector").addEventListener(
|
|
|
|
|
'change',
|
|
|
|
|
(e) => {
|
|
|
|
|
const year = e.target.value // фиксируем выбранный год
|
|
|
|
|
map.getSource('cities').setData(`http://localhost:5000/cities/${year}`) // меняем источник данных
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Сведения о городе
|
|
|
|
|
|
|
|
|
|
Выведем по клику на город подробные сведения о нём. Для отображения информации используем модальное окно, то есть диалоговое окно вспывающее поверх страницы.
|
|
|
|
|
|
|
|
|
|
```diff lang=html title=index.html
|
|
|
|
|
<body>
|
|
|
|
|
<div id='map'></div>
|
|
|
|
|
...
|
|
|
|
|
+ <dialog id="city-details-modal" onmousedown="this.close()"></dialog>
|
|
|
|
|
<script src="main.js"></script>
|
|
|
|
|
</body>
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
При клике будем выполнять запрос к методу API, который возвращает подробную информацию о городе. Здесь мы работаем с асинхронностью самостоятельно.
|
|
|
|
|
|
|
|
|
|
```js title=main.js
|
|
|
|
|
map.on("load", () => {
|
|
|
|
|
...
|
|
|
|
|
map.on('click', 'cities-layer', (e) => {
|
|
|
|
|
// console.log(e.features[0].properties.id)
|
|
|
|
|
fetch(`http://localhost:5000/city/${e.features[0].properties.id}`)
|
|
|
|
|
.then(response => response.json())
|
|
|
|
|
.then(cityProperties => {
|
|
|
|
|
// console.log(cityProperties)
|
|
|
|
|
document.getElementById("city-details-modal").innerHTML = `<h1>${cityProperties.name}</h1>
|
|
|
|
|
<img src="${cityProperties.emblem_url}" height="200">
|
|
|
|
|
<h3>Численность населения</h3><h2>${cityProperties.people_count} тыс. чел</h2>
|
|
|
|
|
<h3>Индекс качества городской среды</h3><h2>${cityProperties.total_points} / 360</h2>
|
|
|
|
|
<hr>
|
|
|
|
|
<h3>Жилье и прилегающие пространства</h3><h2>${cityProperties.house_points} / 60</h2>
|
|
|
|
|
<h3>Озелененные пространства</h3><h2>${cityProperties.park_points} / 60</h2>
|
|
|
|
|
<h3>Общественно-деловая инфраструктура</h3><h2>${cityProperties.business_points} / 60</h2>
|
|
|
|
|
<h3>Социально-досуговая инфраструктура</h3><h2>${cityProperties.social_points} / 60</h2>
|
|
|
|
|
<h3>Улично-дорожная</h3><h2>${cityProperties.street_points} / 60</h2>
|
|
|
|
|
<h3>Общегородское пространство</h3><h2>${cityProperties.common_points} / 60</h2>`
|
|
|
|
|
document.getElementById("city-details-modal").showModal() // showModal() -- встроенный метод элемента <dialog>
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
map.on('mouseenter', 'cities-layer', () => {
|
|
|
|
|
map.getCanvas().style.cursor = 'pointer';
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
map.on('mouseleave', 'cities-layer', () => {
|
|
|
|
|
map.getCanvas().style.cursor = '';
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
> Если вы уже заметили кое-какую нестыковку в наших запросах, посмотрите упражнения в конце главы
|
|
|
|
|
|
|
|
|
|
## Упражнения
|
|
|
|
|
|
|
|
|
|
1. Создайте метод, который вернёт список доступных годов
|
|
|
|
|
1. Выведите модальное окно слева и выполните подлёт к точке клика
|
|
|
|
|
1. Вы могли заметить, что то, что мы получаем методом запроса деталей о городе уже содержится в полному списке городов: вам предлагается избавиться от этой избыточности
|
|
|
|
|
<details>
|
|
|
|
|
<summary>Есть два варианта -- подумайте над ними. Это тест на то, что вам ближе, бэкенд или фронтенд. Когда подумаете, можно посмотреть разгадку 👀</summary>
|
|
|
|
|
Бэкендер: можно убрать из метода для списка городов лишние атрибуты<br/>Фронтендер: на клик по объекту можно не обращаться к серверу, а использовать данные из атрибутов объекта
|
|
|
|
|
</details>
|
|
|
|
|
|
|
|
|
|
## Чтение
|
|
|
|
|
|
|
|
|
|
1. Что такое CORS / Дока [ссылка](https://doka.guide/tools/cors/)
|
|
|
|
|
1. Безопасность веб-приложений и распространённые атаки / Дока [ссылка](https://doka.guide/tools/web-security/)
|