diff --git a/src/content/docs/chapters/2-webmap.mdx b/src/content/docs/chapters/2-webmap.mdx index c572c49..0d3f3f8 100644 --- a/src/content/docs/chapters/2-webmap.mdx +++ b/src/content/docs/chapters/2-webmap.mdx @@ -12,6 +12,8 @@ import Question from '../../../components/Question.astro' - клиент и сервер - HTML, CSS, JavaScript +В рамках практической части создадим карту мира на основе статических GeoJSON-файлов. + ## Карты в Интернете diff --git a/src/content/docs/chapters/3-api.mdx b/src/content/docs/chapters/3-api.mdx index 92d6555..a0f61e2 100644 --- a/src/content/docs/chapters/3-api.mdx +++ b/src/content/docs/chapters/3-api.mdx @@ -14,6 +14,8 @@ import Question from '../../../components/Question.astro' - асинхронность JavaScript - запрос `fetch...then` +В рамках практической части создадим карту вакансий на основе изменяющегося содержания Google-таблицы. + ## API API обычно переводят как прикладной программный интерфейс или программный интерфейс приложений. На практике чаще говорят просто "апи". diff --git a/src/content/docs/chapters/4-backend.mdx b/src/content/docs/chapters/4-backend.mdx new file mode 100644 index 0000000..4cf3506 --- /dev/null +++ b/src/content/docs/chapters/4-backend.mdx @@ -0,0 +1,351 @@ +--- +title: Бэкенд +--- + +import { Card, FileTree, LinkCard, Tabs, TabItem } from '@astrojs/starlight/components'; + +В этой главе + +- понятия бэкенда и фронтенда +- SQL +- Flask +- CORS + +В рамках практической части создадим карту индекса качества городской среды по базе данных SQLite. + +## Бэкенд + +Мы уже знаем, что веб-приложения можно разделить на клиентскую и серверную части. Разработку клиентской части называют фронтендом. Разработку серверной части называют бэкендом. Фронтенд общается с бэкендом через API. Бэкенд предоставляет метода API, а фронтенд к ним обращается. + +Когда происходит вызов метода API -- запрос определённого URL -- выполняется соответствующая серверная функция. Для программирования серверных функций могут использоваться различные языки программирования Python, Go, Rust и даже JavaScript (NodeJS). + +В предыдущем занятии мы обращались к бэкенду через API, а в этот раз разработаем бэкенд сами. Наш бэкенд мы разработаем на языке Python с использованием библиотеки Flask. + +## Подготовка + +Создадим папку с заготовкой для нашей карты. + +Добавим туда папку `backend`. Загрузим [отсюда](https://github.com/gtitov/flask-maplibre-map/raw/refs/heads/main/backend/cities_index.sqlite) и положим туда базу данных. И создадим в этой папке файл с нашим бэкендом `app.py`. + + +- backend/ + - app.py + - cities_index.sqlite +- index.html +- style.css +- main.js + + +
+Что это за база данных такая -- SQLite + +SQLite -- встраиваемая база данных, которая хранит все свои данные в одном файле. В одном файле хранятся все таблицы, индексы и другая информация о базе данных, что упрощает управление и резервное копирование. Благодаря этому, она не требует отдельного сервера и легко интегрируется в различные приложения. + +
+ +## Разработка бэкенда + +### Установка 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/") # путь 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="text/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/") +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="text/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/") # путь API, к которому обращается пользователь +def cities_by_year(year): # функция, которая будет выполняться при обращении + ... + r = Response( # формируем ответ + json.dumps(geojson, ensure_ascii=False), # ensure_ascii=False, чтобы нормально отображалась кириллица + mimetype="text/json", # указываем тип данных ++ headers={"Access-Control-Allow-Origin": "*"} + ) + # print("--- %s seconds ---" % (time.time() - start_time)) + return r + +@app.route("/city/") +def city_by_id(id): + ... + r = Response( + json.dumps(dict(city), ensure_ascii=False), + mimetype="text/json", ++ headers={"Access-Control-Allow-Origin": "*"} + ) + # print("--- %s seconds ---" % (time.time() - start_time)) + return r +``` + +После добавления заголовков о том, что API может обслуживать любые веб-страницы, в ответ мы получаем наш список городов в формате GeoJSON. + +### Выбор года + +Дадим пользователю возможность выбирать год, за который он хочет видеть индекс городов. + +Разметим элемент с выпадающем списком. + +```diff lang=html title=index.html + +
+ ++
++

Год

++ ++
+ + +``` + +Сделаем так, чтобы он выводился поверх карты. + +```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 + +
+ ... ++ + + +``` + +При клике будем выполнять запрос к методу 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 = `

${cityProperties.name}

+ +

Численность населения

${cityProperties.people_count} тыс. чел

+

Индекс качества городской среды

${cityProperties.total_points} / 360

+
+

Жилье и прилегающие пространства

${cityProperties.house_points} / 60

+

Озелененные пространства

${cityProperties.park_points} / 60

+

Общественно-деловая инфраструктура

${cityProperties.business_points} / 60

+

Социально-досуговая инфраструктура

${cityProperties.social_points} / 60

+

Улично-дорожная

${cityProperties.street_points} / 60

+

Общегородское пространство

${cityProperties.common_points} / 60

` + document.getElementById("city-details-modal").showModal() // showModal() -- встроенный метод элемента + }) + }) + + map.on('mouseenter', 'cities-layer', () => { + map.getCanvas().style.cursor = 'pointer'; + }); + + map.on('mouseleave', 'cities-layer', () => { + map.getCanvas().style.cursor = ''; + }); +}) + +``` + +> Если вы уже заметили кое-какую нестыковку в наших запросах, посмотрите упражнения в конце главы + +## Упражнения + +1. Создайте метод, который вернёт список доступных годов +1. Выведите модальное окно слева и выполните подлёт к точке клика +1. Вы могли заметить, что то, что мы получаем методом запроса деталей о городе уже содержится в полному списке городов: вам предлагается избавиться от этой избыточности +
+Есть два варианта -- подумайте над ними. Это тест на то, что вам ближе, бэкенд или фронтент +Бэкендер: можно убрать из метода для списка городов лишние атрибуты
Фронтендер: на клик по объекту можно не обращаться к серверу, а использовать данные из атрибутов объекта +
+ +## Чтение + +1. Что такое CORS / Дока [ссылка](https://doka.guide/tools/cors/) +1. Безопасность веб-приложений и распространённые атаки / Дока [ссылка](https://doka.guide/tools/web-security/) \ No newline at end of file diff --git a/src/content/docs/chapters/7-extra.md b/src/content/docs/chapters/7-extra.md index ee38590..1e43310 100644 --- a/src/content/docs/chapters/7-extra.md +++ b/src/content/docs/chapters/7-extra.md @@ -12,4 +12,6 @@ title: Внеклассное чтение Отдельно стоит упомянуть геопортал и картографические веб-сервисы. Их мы формируем от родовых IT-терминов приземляя к картографии и пространственным данным. *Геопортал* — это веб-ресурс, предоставляющий доступ к каталогам пространственных данных, наборам пространственных данных, веб-сервисам, публикующим пространственные данные. *Картографический веб-сервис* — это веб-ресурс, предоставляющий возможности обращения к пространственным данным или метаданным, в т. ч. по стандартизированным протоколам обмена (WMS, WFS, WCS и т. д.). Картографические веб-сервисы обычно не имеют графического интерфейса пользователя, к этим сервисам обращаются программно через API (прикладной программный интерфейс). -Этими терминами злоупотребляют по отношению к любым веб-ресурсам, связанным с пространственными данными и картами. Картографические продукты, публикуемые в сети, правильнее объединить под названием *картографические веб-ресурсы*, так как они являются веб-ресурсами, основным назначением которых является предоставление доступа к картографической информации. \ No newline at end of file +Этими терминами злоупотребляют по отношению к любым веб-ресурсам, связанным с пространственными данными и картами. Картографические продукты, публикуемые в сети, правильнее объединить под названием *картографические веб-ресурсы*, так как они являются веб-ресурсами, основным назначением которых является предоставление доступа к картографической информации. + + \ No newline at end of file