master
gman 1 year ago
parent d75642491e
commit 7e2b629c92

@ -5,4 +5,7 @@
"files.associations": {
// "*.mdx": "markdown"
},
"[mdx]": {
"editor.wordWrap": "on"
}
}

@ -1,6 +1,6 @@
// @ts-check
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
@ -24,5 +24,8 @@ export default defineConfig({
autogenerate: { directory: 'chapters' },
}
],
}), react()],
components: {
SocialIcons: './src/components/CustomSocialIcons.astro',
}
}), react()],
});

@ -0,0 +1,5 @@
---
import type { Props } from '@astrojs/starlight/props';
---
Герман Титов

@ -2,29 +2,10 @@
const { answer, ballast, explanation } = Astro.props;
---
<style>
input {
vertical-align: middle;
}
.option {
position: relative;
}
#answer-check {
color: white;
position: absolute;
transform: scale(-1, 1);
rotate: 95deg;
left: 6px;
top: 3px;
display: none;
}
</style>
<fieldset style={{ border: "none" }}>
{
[
<div class="option">
<i id="answer-check"></i>
<input type="radio" id="answer" name={answer} />
<label for="answer">{answer}</label>
</div>,

@ -1,320 +0,0 @@
### Инициализация карты
Создадим файл разметки `index.html`.
```html title="index.html"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Население мира</title>
<!-- Запрашиваем стили 👇 -->
<link rel="stylesheet" href="style.css">
<!-- Запрашиваем библиотеку Maplibre 👇 -->
<script src="https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css" rel="stylesheet" />
</head>
<body>
<!-- Размечаем контейнер для карты 👇 -->
<div id="map"></div>
<!-- Запрашиваем логику карты 👇 -->
<script src="main.js"></script>
</body>
</html>
```
Библиотеку Maplibre мы запрашиваем из внешнего ресурса, а вот стили и логику карты нам нужно создать.
Создадим файл стилей `style.css`.
```css title="style.css"
/* Объявляем, что контейнер карты должен занимать всю страницу */
#map {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
```
И файл с логикой карты `main.js`.
```js title="main.js"
// Инициализируем карту
const map = new maplibregl.Map({
container: 'map',
style: "https://raw.githubusercontent.com/gtitov/basemaps/refs/heads/master/positron-nolabels.json",
center: [51, 0],
zoom: 4
});
```
После этого запустим Live Server, перейдём по адресу локального сервера и увидим карту.
> Наименование файла `index.html` важно тем, что именно страница `index.html` загружается при обращении к корневому URL. Наименования файлов CSS и JavaScript особой роли не играют.
>
> Страница HTML является ключевой. Ей необходимо дать информацию о том, какие внешние библиотеки и файлы будут использоваться. В частности `style.css` и `main.js` являются внешними файлами. Для локальных файлов мы можем ввести относительные адреса. Удалённые (находящиеся на внешнем сервере) файлы необходимо подключать по URL.
>
> В роли сервера может выступать компьютер, за которым вы работаете. Веб-сервер, запущенный на компьютере, достуен с этого комьютера по IP-адресу `127.0.0.1` или `localhost`. Это внутренний адрес. Он будет одним и тем же у всех компьютеров. И он недоступен для запросов снаружи.
### Добавление слоёв
Создадим подпапку `data` и загрузим в неё данные о [странах](https://raw.githubusercontent.com/gtitov/geojson-maplibre-map/refs/heads/master/data/countries.geojson), [городах](https://raw.githubusercontent.com/gtitov/geojson-maplibre-map/refs/heads/master/data/cities.geojson), [реках](https://raw.githubusercontent.com/gtitov/geojson-maplibre-map/refs/heads/master/data/rivers.geojson) и [озёрах](https://raw.githubusercontent.com/gtitov/geojson-maplibre-map/refs/heads/master/data/lakes.geojson).
Должна получится такая структура. HTML отвечает за структуру веб-страницы, CSS за оформление веб-страницы, JavaScript за логику работы веб-страницы. GeoJSON файлы хранят пространственные данные.
<FileTree>
- data/ # данные
- cities.geojson # города
- countries.geojson # страны
- lakes.geojson # озёра
- rivers.geojson # реки
- index.html # разметка
- style.css # стили
- main.js # логика
</FileTree>
Все действия с картой выполняются после первичной загрузки исходной карты.
Добавление картографических слоёв включает два шага: добавление источника данных `addSource` и добавление слоя `addLayer`. На первом шаге указываем, откуда мы будем брать данные, а на втором, как их оформить. Из одного источника можно создать несколько слоёв.
```js title="main.js"
// Инициализируем карту
...
map.on('load', () => {
// Выполняется после загрузки карты
// Добавление источника данных
map.addSource('countries', {
type: 'geojson',
data: './data/countries.geojson',
attribution: 'Natural Earth'
})
// Добавление слоя
map.addLayer({
id: 'countries-layer',
type: 'fill',
source: 'countries',
paint: {
'fill-color': 'lightgray',
}
})
})
```
Мы добавили полигональный слой (`type: 'fill'`). Аналогично добавляем слой линий и слой точек.
```js title="main.js"
// Инициализируем карту
...
map.on('load', () => {
// Выполняется после загрузки карты
...
map.addSource('rivers', {
type: 'geojson',
data: './data/rivers.geojson'
})
map.addLayer({
id: 'rivers-layer',
type: 'line',
source: 'rivers',
paint: {
'line-color': '#00BFFF'
}
})
map.addSource('lakes', {
type: 'geojson',
data: './data/lakes.geojson'
})
map.addLayer({
id: 'lakes-layer',
type: 'fill',
source: 'lakes',
paint: {
'fill-color': 'lightblue',
'fill-outline-color': '#00BFFF'
}
})
map.addSource('cities', {
type: 'geojson',
data: './data/cities.geojson'
})
map.addLayer({
id: 'cities-layer',
type: 'circle',
source: 'cities',
paint: {
'circle-color': 'rgb(123, 12, 234)',
'circle-radius': 3
}
})
})
```
В MapLibre слои можно фильтровать и оформлять на основе атрибутов с помощью [выражений](https://maplibre.org/maplibre-style-spec/expressions/).
Например, оставим только города с численностью населения больше 1 000 000
```diff lang="js" title="main.js"
// Инициализируем карту
...
map.on('load', () => {
// Выполняется после загрузки карты
...
map.addLayer({
id: 'cities-layer',
type: 'circle',
source: 'cities',
paint: {
'circle-color': 'rgb(123, 12, 234)',
'circle-radius': 3
},
+ filter: ['>', ['get', 'POP_MAX'], 1000000]
})
})
```
Изобразим красным (`red`) цветом страны, у которых атрибут `MAPCOLOR7` равен 1, а остальные изобразим светло-серым (`lightgray`)
```diff lang="js" title="main.js"
// Инициализируем карту
...
map.on('load', () => {
// Выполняется после загрузки карты
...
map.addLayer({
id: 'countries-layer',
type: 'fill',
source: 'countries',
paint: {
- 'fill-color': 'lightgray',
+ 'fill-color': ['match', ['get', 'MAPCOLOR7'], 1, 'red', 'lightgray']
}
})
...
})
```
### Расширение интерактивности
Созданная нами карта сразу даёт пользователю возможности перемещения, зума и даже наклона (попробуйте зажать правую кнопку мыши). Однако чтобы, например, выводить атрибутивные сведения о слое по клику, надо указать это в коде.
Отследим событие клика по слою `cities-layer`. Назовём событие клика переменной `e`. Посмотрим в консоли браузера, что собой представляет это событие. Если мы отслеживаем событие клика по конкретному слою, а не по всей карте, то мы можем обратиться к набору объектов, по которым был выполнен клик `e.features`
```js title="main.js"
// Инициализируем карту
...
map.on('load', () => {
// Выполняется после загрузки карты
...
map.on('click', ['cities-layer'], (e) => {
console.log(e)
console.log(e.features)
})
})
```
Закомментируем вывод в консоль и выведем по клику на слой попап.
```js title="main.js"
// Инициализируем карту
...
map.on('load', () => {
// Выполняется после загрузки карты
...
map.on('click', ['cities-layer'], (e) => {
// console.log(e)
// console.log(e.features)
new maplibregl.Popup() // создадим попап
.setLngLat(e.features[0].geometry.coordinates) // установим на координатах объекта
.setHTML(e.features[0].properties.NAME) // заполним текстом из атрибута с именем объекта
.addTo(map); // добавим на карту
})
})
```
Попап отображается, но надо показать пользователю, что на объект можно кликать. При попадании мыши на слой `cities-layer` поменяем курсор на pointer, а при покидании слоя `cities-layer` вернём значение по умолчанию.
```js title="main.js"
// Инициализируем карту
...
map.on('load', () => {
// Выполняется после загрузки карты
...
map.on('mouseenter', 'cities-layer', () => {
map.getCanvas().style.cursor = 'pointer'
})
map.on('mouseleave', 'cities-layer', () => {
map.getCanvas().style.cursor = ''
})
})
```
В качестве завершающего штриха уберём карту подложку и добавим фон. При этом фон добавляем перед всеми слоями, так как все слои должны рисоваться после фона, поверх него.
```diff lang="js" title="main.js"
// Инициализируем карту
const map = new maplibregl.Map({
container: 'map',
- style: "https://raw.githubusercontent.com/gtitov/basemaps/refs/heads/master/positron-nolabels.json",
+ style: {
+ "version": 8,
+ "sources": {},
+ "layers": []
+ },
center: [51, 0],
zoom: 4
});
map.on('load', () => {
// Выполняется после загрузки карты
+ map.addLayer({
+ id: 'background',
+ type: 'background',
+ paint: {
+ 'background-color': 'lightblue'
+ }
+ })
...
})
```
У нас получилась отличная карта!
При желании посмотрите [полный код](https://github.com/gtitov/geojson-maplibre-map) и [возможный результат](https://gtitov.github.io/geojson-maplibre-map/).
## Что мы получили
Откроем вкладку Сеть в инструментах разработчика и ещё разок проследим поток данных
![geojson-network-tab](../../../assets/geojson-network-tab.png)
1. Пользователь вводит адрес карты в браузере (в клиенте)
1. Клиент выполняет запрос к серверу по введённому адресу
1. Сервер обрабатывает запрос и возвращает разметку (HTML) (1)
1. В разметке содержаться запросы к офомлению (CSS), картографической библиотеке (MapLibre) и программной логике работы (JavaScript) веб-страницы (2)
1. Клиент (браузер), получив все необходимые сведения, отображает веб-страницу
1. Программная логика работы полученной веб-страницы выполняется и в соотстветвии с кодом инициирует запросы к данным (GeoJSON) для составления карты (3)
1. Полученные данные оформляются на веб-карте в рамках описанной разработчиком на языке JavaScript логики с использованием функций библиотеки MapLibre
1. Пользователь получает веб-карту
1. Веб-карта обогащается дополнительной интерактивностью в рамках описанной разработчиком логики
Такая карта удобна, когда немного данных, потому что мы всё переправляем пользователю данные как есть. Когда мы отправляем пользователю данные как есть, почти не требуется серверных мощностей, поэтому для таких карт есть варианты бесплатного размещения в Интернете.
## Упражнения
1. Покрасьте Москву в красный цвет
2. Выведите в попап один из атрибутов стран
3. Добавьте слой с границами озёр, установите им толщину в 2 пикселя
4. Замените курсор на перекрестие (`crosshair`) при расположении поверх стран

@ -3,7 +3,7 @@ title: Веб-карта
---
import { Card, FileTree, LinkCard } from '@astrojs/starlight/components';
import Question from '../../../components/Question.astro'
import Question from '../../../components/Question.astro';
В этой главе мы рассмотрим
@ -150,13 +150,13 @@ const map = new maplibregl.Map({
});
```
После этого запустим Live Server, перейдём по адресу локального сервера и увидим карту.
> Наименование файла `index.html` важно тем, что именно страница `index.html` загружается при обращении к корневому URL. Наименования файлов CSS и JavaScript особой роли не играют.
>
> Страница HTML является ключевой. Ей необходимо дать информацию о том, какие внешние библиотеки и файлы будут использоваться. В частности `style.css` и `main.js` являются внешними файлами. Для локальных файлов мы можем ввести относительные адреса. Удалённые (находящиеся на внешнем сервере) файлы необходимо подключать по URL.
>
> В роли сервера может выступать компьютер, за которым вы работаете. Веб-сервер, запущенный на компьютере, достуен с этого комьютера по IP-адресу `127.0.0.1` или `localhost`. Это внутренний адрес. Он будет одним и тем же у всех компьютеров. И он недоступен для запросов снаружи.
> Страница HTML является корневой. Ей необходимо дать информацию о том, какие внешние библиотеки и файлы будут использоваться. Например, `style.css` и `main.js` являются внешними файлами, а MapLibre является внешней библиотекой. Находящиеся на сервере файлы необходимо подключать по URL.
После этого запустим Live Server, перейдём по адресу локального сервера и увидим карту.
> Live Server обычно запускается по адресу `127.0.0.1:5500`. `127.0.0.1` или `localhost` -- это внутренний адрес сервера на нашем компьютере. Он будет одним и тем же у всех компьютеров. И он недоступен для запросов снаружи. На одном веб-сервере может быть запущено несколько приложений. Для их разграничения используется порт, в нашем случае `5500`.
### Добавление слоёв
@ -258,6 +258,12 @@ map.on('load', () => {
})
```
:::tip
Порядок отрисовки слоёв соответсвует порядку их объявления в коде. Последующие слои перекрывают предыдущие.
:::
В MapLibre слои можно фильтровать и оформлять на основе атрибутов с помощью [выражений](https://maplibre.org/maplibre-style-spec/expressions/).
Например, оставим только города с численностью населения больше 1 000 000
@ -414,3 +420,7 @@ map.on('load', () => {
2. Выведите в попап один из атрибутов стран
3. Добавьте слой с границами озёр, установите им толщину в 2 пикселя
4. Замените курсор на перекрестие (`crosshair`) при расположении поверх стран
---
Титов Г. С. Введение в веб-картографию

@ -1,332 +0,0 @@
## Заготовка для карты
Попробуем обратиться к публично доступным методам API Google-таблиц, а именно загрузить данные таблицы в формате CSV.
По аналогии с первым упражнением создадим заготовку для карты из файлов `index.html`, `style.css`, `main.js`.
<Tabs>
<TabItem label="HTML">
```html title="index.html"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Карта вакансий</title>
<link rel="stylesheet" href="style.css">
<script src="https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css" rel="stylesheet" />
</head>
<body>
<div id="map"></div>
<script src="main.js"></script>
</body>
</html>
```
</TabItem>
<TabItem label="CSS">
```css title="style.css"
#map {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
```
</TabItem>
<TabItem label="JavaScript">
```js title="main.js"
const map = new maplibregl.Map({
container: 'map',
style: "https://raw.githubusercontent.com/gtitov/basemaps/refs/heads/master/positron-nolabels.json",
center: [51, 37],
zoom: 4
});
```
</TabItem>
</Tabs>
<Card title={'Строка <code>&lt;div id="map">&lt;/div></code> это'}>
<Question answer="контейнер для карты" ballast={['инициализация карты', 'стиль карты']} explanation="Это разметка контейнера карты"/>
</Card>
Удостоверимся, что карта отображается на локальном сервере.
## Обращение к API
Выполним запрос и выведем в консоль ответ
```js title="main.js"
map.on("load", () => {
...
const response = fetch("https://docs.google.com/spreadsheets/d/1f0waZduz5CXdNig_WWcJDWWntF-p5gN2-P-CNTLxEa0/export?format=csv")
console.log(response)
})
```
В консоль вывелся Promise -- обещание того, что браузер уже занимается нашим запросом. Ответ от сервера не вывелся, хотя во вкладке Сеть мы видим, что данные загружены. Дело в том, что вывод в консоль был выполнен раньше, чем мы получили ответ от сервера.
> Браузер начал исполнять наш код, увидел запрос к внешему ресурсу и подумал: "Здесь можно завязнуть. Ещё неизвестно, сколько этот внешний ресурс будет отвечать. Я верну пока обещание, что когда будет ответ, я его предоставлю, и отправлю запрос. А пока жду ответа буду дальше код выполнять."
Запрос к внешнему ресурсу выполняется асинхронно, то есть изымается из последовательного выполнения программного кода и выполняется отдельно. Поэтому вывод в консоль выполняется раньше того, как данные получены.
Чтобы этого не происходило, мы должны в явном виде указать, что код, использующий ответ на запрос, должен выполняться после выполнения запроса. Для этого используем конструкцию `fetch...then`
```js title="main.js"
map.on("load", () => {
...
fetch("https://docs.google.com/spreadsheets/d/1f0waZduz5CXdNig_WWcJDWWntF-p5gN2-P-CNTLxEa0/export?format=csv")
.then((response) => response.text())
.then((csv) => {
console.log(csv)
})
})
```
<details>
<summary>В первой карте тоже был асинхронный код</summary>
Сама карта создаётся асинхронно, поэтому все действия по добавлению слоёв мы выполняем после загрузки карты `map.on('load', () => {})`. Функция, которая вызывается после успешного завершения события называется callback-функцией. Это ещё один вариант работы с асинхронностью. А ещё асинхронно выполняется добавление источников данных `map.addSource`, они же тоже фактически загружаются с сервера. В этом случае библиотека MapLibre сама отслеживает, что код по добавлению источника должен завершиться, прежде чем мы сможем создавать картографические слои `map.addLayer` из этого источника.
</details>
## Преобразование данных
MapLibre не может работать с форматом CSV. Мы должны преобразовать данные в знакомый формат GeoJSON. Сделаем это!
Подключим библиотеку для чтения CSV данных в JS-объект.
```html title="index.html"
<head>
...
<script src="https://unpkg.com/papaparse@5.4.1/papaparse.min.js"></script>
</head>
```
Выполним чтение CSV данных c использованием подключенной библиотеки и сконструируем GeoJSON-объект.
```js title="main.js"
.then((csv) => {
const rows = Papa.parse(csv, { header: true }) // читаем CSV
// console.log(rows) // любуемся
// Формируем объекты GeoJSON
const geojsonFeatures = rows.data.map((row) => {
return {
type: "Feature",
properties: row,
geometry: {
type: "Point",
coordinates: [row.lon, row.lat],
}
}
})
const geojson = {
type: "FeatureCollection",
features: geojsonFeatures
}
})
```
GeoJSON уже можно использовать в качестве источника для MapLibre.
## Работа над картой
У нас есть заготовка, есть данные, самое время заняться картой!
```js title="main.js"
.then((csv) => {
...
const geojson = {
type: "FeatureCollection",
features: geojsonFeatures
}
map.addSource("vacancies", {
type: "geojson",
data: geojson,
cluster: true, // точки будем объединять в кластеры
clusterRadius: 20, // радиус поиска 20 пикселей
});
map.addLayer({
id: "clusters",
source: "vacancies",
type: "circle",
paint: {
"circle-color": "#7EC8E3",
"circle-stroke-width": 1,
"circle-stroke-color": "#FFFFFF",
"circle-radius": [
"step", ["get", "point_count"],
12, // до 3 точек в кластере
3, // --- первое граничное значение
20, // от 3 точек до 6
6, // --- второе граничное значение
30 // больше 6 точек в кластере
],
},
});
map.addLayer({
id: "clusters-labels",
type: "symbol",
source: "vacancies",
layout: {
"text-field": ["get", "point_count"],
"text-size": 10,
},
});
})
```
## Сопутствующие элементы
Чаще всего карту сопровождают дополнительные элементы веб-страницы. Для этой карты мы приведём список всех вакансий и список вакансий, которые пользователь видит на карте при текущем охвате.
Разметим этим спискам место на веб-странице.
```html title="index.html"
<body>
<div id="map"></div>
<div id="list-selected"><h2>Сейчас на карте</h2></div>
<div id="list-all"><h2>Все вакансии</h2></div>
<script src="main.js"></script>
</body>
```
И зададим оформление.
```css title="style.css"
h2 {
margin: 10px;
}
.list-item {
padding: 10px;
}
#map {
position: absolute;
top: 0;
bottom: 0;
right: 300px;
left: 300px;
}
#list-selected {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 300px;
overflow-y: auto;
}
#list-all {
position: absolute;
top: 0;
bottom: 0;
right: 0;
width: 300px;
overflow-y: auto;
}
```
Теперь на нашей веб-странице выделено место под списки. Плюс мы добавили оформление для заголовков второго уровня `h2` и создали класс `.list-item` для будущих элементов списка.
Сначала наполним список всех вакансий. Это нужно сделать единожды.
```js title="main.js"
.then((csv) => {
...
geojson.features.map((f) => {
document.getElementById(
"list-all"
).innerHTML += `<div class="list-item">
<h4>${f.properties["Вакансия"]}</h4>
<a href='#' onclick="map.flyTo({center: [${f.geometry.coordinates}], zoom: 10})">Найти на карте</a>
</div><hr>`;
});
})
```
А список вакансий, которые видит пользователь при заданном охвате карты, надо будет обновлять при каждом перемещении по карте. Мы будем реагировать на окончание перемещения. Ещё одной сложностью является необходимость извлечь из каждого кластера сведения о том, какие объекты в него входят. Со всем этим мы прекрасно справимся.
```js title="main.js"
.then((csv) => {
...
map.on('moveend', () => {
const features = map.queryRenderedFeatures({
layers: ["clusters"]
});
document.getElementById("list-selected").innerHTML = "<h2>Сейчас на карте</h2>"
features.map(f => {
if (f.properties.cluster) {
const clusterId = f.properties.cluster_id;
const pointCount = f.properties.point_count;
map.getSource("vacancies").getClusterLeaves(clusterId, pointCount, 0)
.then((clusterFeatures) => {
clusterFeatures.map((feature) => document.getElementById("list-selected")
.innerHTML += `<div class="list-item">
<h4>${feature.properties["Вакансия"]}</h4>
<a target="blank_" href='${feature.properties["Ссылка на сайте Картетики"]}'>Подробнее</a>
</div><hr>`)
});
} else {
document.getElementById("list-selected")
.innerHTML += `<div class="list-item">
<h4>${f.properties["Вакансия"]}</h4>
<a target="blank_" href='${f.properties["Ссылка на сайте Картетики"]}'>Подробнее</a>
</div><hr>`
}
})
})
})
```
## Пара UX-штрихов
Для удобства пользования картой добавим приближение к карте по клику на объект и изменение курсора при наведении на слой.
```js title="main.js"
map.on("load", () => {
...
map.on("click", "clusters", function (e) {
map.flyTo({ center: e.lngLat, zoom: 8 });
})
map.on("mouseenter", "clusters", function () {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "clusters", function () {
map.getCanvas().style.cursor = "";
});
})
```
Проделана прекрасная работа!
При желании посмотрите [полный код](https://github.com/gtitov/sheet-maplibre-map) и [возможный результат](https://gtitov.github.io/sheet-maplibre-map/).
## Упражнения
1. Сделайте, чтобы до первого перемещения карты список вакансий "Сейчас на карте" тоже был заполнен
1. Поменяйте местами списки (CSS)
1. Сделайте так, чтобы цвет кластера зависел от количества элементов внутри него
## Чтение
1. Что такое API / Дока [ссылка](https://doka.guide/tools/api/)
1. Асинхронность в JavaScript / Дока [ссылка](https://doka.guide/js/async-in-js/)
1. fetch() / Дока [ссылка](https://doka.guide/js/fetch/)
1. Promise / Дока [ссылка](https://doka.guide/js/promise/)

@ -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/)
Loading…
Cancel
Save