Create book and deploy it to the website (#6)

* Create book structure.
* Add workflow for linting the markdown using markdownlint.
* Add workflow for building the book with mdBook and deploying to the website.
* Restyle mdBook and support GitHub-style alerts.
* Add images zooming.
* Add index, firmware structure and gyroscope articles.
This commit is contained in:
Oleg Kalachev 2024-12-16 11:53:43 +03:00 committed by GitHub
parent 66ba9518ae
commit 8440ddd3ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1330 additions and 0 deletions

46
.github/workflows/docs.yml vendored Normal file
View File

@ -0,0 +1,46 @@
name: Docs
on:
push:
branches: [ '*' ]
pull_request:
branches: [ master ]
jobs:
markdownlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install markdownlint
run: npm install -g markdownlint-cli
- name: Run markdownlint
run: cd docs && markdownlint .
build_book:
runs-on: ubuntu-latest
needs: markdownlint
steps:
- uses: actions/checkout@v4
- name: Install mdBook
run: cargo install mdbook --vers 0.4.43 --locked
- name: Build book
run: cd docs && mdbook build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/build
deploy:
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' }}
concurrency:
group: "pages"
cancel-in-progress: true
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build_book
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1

10
docs/.markdownlint.json Normal file
View File

@ -0,0 +1,10 @@
{
"MD004": {
"style": "asterisk"
},
"MD010": false,
"MD013": false,
"MD024": false,
"MD033": false,
"MD045": false
}

10
docs/Makefile Normal file
View File

@ -0,0 +1,10 @@
build:
mdbook build
serve:
mdbook serve
clean:
mdbook clean
.PHONY: build serve clean

31
docs/alerts.py Normal file
View File

@ -0,0 +1,31 @@
# https://rust-lang.github.io/mdBook/format/configuration/preprocessors.html
# https://rust-lang.github.io/mdBook/for_developers/preprocessors.html
import json
import sys
import re
def transform_markdown_to_html(markdown_text):
def replace_blockquote(match):
tag = match.group(1).lower()
content = match.group(2).strip().replace('\n> ', ' ')
return f'<div class="alert alert-{tag}">{content}</div>\n'
pattern = re.compile(r'> \[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\n>(.*?)\n?(?=(\n[^>]|\Z))', re.DOTALL)
transformed_text = pattern.sub(replace_blockquote, markdown_text)
return transformed_text
if __name__ == '__main__':
if len(sys.argv) > 1:
if sys.argv[1] == 'supports':
sys.exit(0)
context, book = json.load(sys.stdin)
for section in book['sections']:
if 'Chapter' in section:
section['Chapter']['content'] = transform_markdown_to_html(section['Chapter']['content'])
print(json.dumps(book))

110
docs/book.css Normal file
View File

@ -0,0 +1,110 @@
.sidebar-resize-handle { display: none !important; }
footer {
contain: content;
border-top: 3px solid #f4f4f4;
}
footer a.telegram, footer a.github {
display: block;
margin-bottom: 10px;
margin-top: 10px;
display: flex;
align-items: center;
text-decoration: none;
}
.content .github, .content .telegram {
display: flex;
align-items: center;
text-align: center;
justify-content: center;
}
.telegram::before, .github::before {
font-family: FontAwesome;
margin-right: 0.3em;
font-size: 1.6em;
color: black;
}
.github::before {
content: "\f09b";
}
.telegram::before {
font-size: 1.4em;
color: #0084c5;
content: "\f2c6";
}
.content hr {
border: none;
border-top: 2px solid #c9c9c9;
margin: 2em 0;
}
.content img {
display: block;
margin: 0 auto;
}
.content img.border {
border: 1px solid #c9c9c9;
}
.firmware {
position: relative;
margin: 20px 0;
padding: 20px 20px;
padding-left: 60px;
color: var(--fg);
background-color: var(--quote-bg);
border-block-start: .1em solid var(--quote-border);
border-block-end: .1em solid var(--quote-border);
}
.firmware::before {
font-family: FontAwesome;
font-size: 1.5em;
content: "\f15b";
position: absolute;
width: 20px;
text-align: center;
left: 20px;
}
.alert {
margin-top: 20px;
margin-bottom: 20px;
position: relative;
border-left: 2px solid #0a69da;
padding: 20px;
padding-left: 60px;
}
.alert::before {
font-family: FontAwesome;
font-size: 1.5em;
color: #0a69da;
content: "\f05a";
position: absolute;
width: 20px;
text-align: center;
left: 20px;
}
.alert-tip { border-left-color: #1b7f37; }
.alert-tip::before { color: #1b7f37; content: '\f0eb'; }
.alert-caution { border-left-color: #cf212e; }
.alert-caution::before { color: #cf212e; content: '\f071'; }
.alert-important { border-left-color: #8250df; }
.alert-important::before { color: #8250df; content: '\f06a'; }
.alert-warning { border-left-color: #f0ad4e; }
.alert-warning::before { color: #f0ad4e; content: '\f071'; }
.alert-code { border-left-color: #333; }
.alert-code::before { color: #333; content: '\f121'; }

22
docs/book.toml Normal file
View File

@ -0,0 +1,22 @@
[book]
authors = ["Oleg Kalachev"]
language = "ru"
multilingual = false
src = "book"
title = "Полетный контроллер с нуля"
description = "Учебник по разработке полетного контроллера квадрокоптера"
[build]
build-dir = "build"
[output.html]
additional-css = ["book.css", "zoom.css"]
additional-js = ["zoom.js", "js.js"]
edit-url-template = "https://github.com/okalachev/flix/blob/master/docs/{path}?plain=1"
mathjax-support = true
[output.html.code.hidelines]
cpp = "//~"
[preprocessor.alerts]
command = "python3 alerts.py"

10
docs/book/README.md Normal file
View File

@ -0,0 +1,10 @@
# Flix
> [!IMPORTANT]
> Flix — это проект по созданию открытого квадрокоптера на базе ESP32 с нуля и учебника по разработке полетных контроллеров.
<img src="img/flix1.jpg" class="border" width=500 alt="Flix quadcopter">
<p class="github">GitHub:&nbsp;<a href="https://github.com/okalachev/flix">github.com/okalachev/flix</a>.</p>
<p class="telegram">Telegram-канал:&nbsp;<a href="https://t.me/opensourcequadcopter">@opensourcequadcopter</a>.</p>

22
docs/book/SUMMARY.md Normal file
View File

@ -0,0 +1,22 @@
<!-- markdownlint-disable MD041 -->
<!-- markdownlint-disable MD042 -->
[Главная](./README.md)
* [Архитектура прошивки](firmware.md)
# Учебник
* [Основы]()
* [Светодиод]()
* [Моторы]()
* [Радиоуправление]()
* [Гироскоп](gyro.md)
* [Акселерометр]()s
* [Оценка состояния]()
* [PID-регулятор]()
* [Режим ACRO]()
* [Режим STAB]()
* [Wi-Fi]()
* [MAVLink]()
* [Симуляция]()

32
docs/book/firmware.md Normal file
View File

@ -0,0 +1,32 @@
# Архитектура прошивки
<img src="img/dataflow.svg" width=800 alt="Firmware dataflow diagram">
Главный цикл работает на частоте 1000 Гц. Передача данных между подсистемами происходит через глобальные переменные:
* `t` *(float)* — текущее время шага, *с*.
* `dt` *(float)* — дельта времени между текущим и предыдущим шагами, *с*.
* `gyro` *(Vector)* — данные с гироскопа, *рад/с*.
* `acc` *(Vector)* — данные с акселерометра, *м/с<sup>2</sup>*.
* `rates` *(Vector)* — отфильтрованные угловые скорости, *рад/с*.
* `attitude` *(Quaternion)* — оценка ориентации (положения) дрона.
* `controls` *(float[])* — пользовательские управляющие сигналы с пульта, нормализованные в диапазоне [-1, 1].
* `motors` *(float[])* — выходные сигналы на моторы, нормализованные в диапазоне [-1, 1] (возможно вращение в обратную сторону).
## Исходные файлы
Исходные файлы прошивки находятся в директории `flix`. Ключевые файлы:
* [`flix.ino`](https://github.com/okalachev/flix/blob/canonical/flix/flix.ino) — основной входной файл, скетч Arduino. Включает определение глобальных переменных и главный цикл.
* [`imu.ino`](https://github.com/okalachev/flix/blob/canonical/flix/imu.ino) — чтение данных с датчика IMU (гироскоп и акселерометр), калибровка IMU.
* [`rc.ino`](https://github.com/okalachev/flix/blob/canonical/flix/rc.ino) — чтение данных с RC-приемника, калибровка RC.
* [`mavlink.ino`](https://github.com/okalachev/flix/blob/canonical/flix/mavlink.ino) — взаимодействие с QGroundControl через MAVLink.
* [`estimate.ino`](https://github.com/okalachev/flix/blob/canonical/flix/estimate.ino) — оценка ориентации дрона, комплементарный фильтр.
* [`control.ino`](https://github.com/okalachev/flix/blob/canonical/flix/control.ino) — управление ориентацией и угловыми скоростями дрона, трехмерный двухуровневый каскадный PID-регулятор.
* [`motors.ino`](https://github.com/okalachev/flix/blob/canonical/flix/motors.ino) — управление выходными сигналами на моторы через ШИМ.
Вспомогательные файлы включают:
* [`vector.h`](https://github.com/okalachev/flix/blob/canonical/flix/vector.h), [`quaternion.h`](https://github.com/okalachev/flix/blob/canonical/flix/quaternion.h) — реализация библиотек векторов и кватернионов проекта.
* [`pid.h`](https://github.com/okalachev/flix/blob/canonical/flix/pid.h) — реализация общего ПИД-регулятора.
* [`lpf.h`](https://github.com/okalachev/flix/blob/canonical/flix/lpf.h) — реализация общего фильтра нижних частот.

262
docs/book/gyro.md Normal file
View File

@ -0,0 +1,262 @@
# Гироскоп
<div class="firmware">
<strong>Файл прошивки Flix:</strong>
<a href="https://github.com/okalachev/flix/blob/canonical/flix/imu.ino"><code>imu.ino</code></a> <small>(каноничная версия)</small>.<br>
Текущая версия: <a href="https://github.com/okalachev/flix/blob/master/flix/imu.ino"><code>imu.ino</code></a>.
</div>
Поддержание стабильного полета квадрокоптера невозможно без датчиков обратной связи. Важнейший из них — это **MEMS-гироскоп**. MEMS-гироскоп это микроэлектромеханический аналог классического механического гироскопа.
Механический гироскоп состоит из вращающегося диска, который сохраняет свою ориентацию в пространстве. Благодаря этому эффекту возможно определить ориентацию объекта в пространстве.
В MEMS-гироскопе нет вращающихся частей, и он помещается в крошечную микросхему. Он может измерять только текущую угловую скорость вращения объекта вокруг трех осей: X, Y и Z.
|Механический гироскоп|MEMS-гироскоп|
|-|-|
|<img src="img/gyroscope.jpg" width="300" alt="Механический гироскоп">|<img src="img/mpu9250.jpg" width="100" alt="MEMS-гироскоп MPU-9250">|
MEMS-гироскоп обычно интегрирован в инерциальный модуль (IMU), в котором также находятся акселерометр и магнитометр. Модуль IMU часто называют 9-осевым датчиком, потому что он измеряет:
* Угловую скорость вращения по трем осям (гироскоп).
* Ускорение по трем осям (акселерометр).
* Магнитное поле по трем осям (магнитометр).
Flix поддерживает следующие модели IMU:
* InvenSense MPU-9250.
* InvenSense MPU-6500.
* InvenSense ICM-20948.
> [!NOTE]
> MEMS-гироскоп измеряет угловую скорость вращения объекта.
## Интерфейс подключения
Большинство модулей IMU подключаются к микроконтроллеру через интерфейсы I²C и SPI. Оба этих интерфейса являются *шинами данных*, то есть позволяют подключить к одному микроконтроллеру несколько устройств.
**Интерфейс I²C** использует два провода для передачи данных и тактового сигнала. Выбор устройства для коммуникации происходит при помощи передачи адреса устройства на шину. Разные устройства имеют разные адреса, и микроконтроллер может последовательно общаться с несколькими устройствами.
**Интерфейс SPI** использует два провода для передачи данных, еще один для тактового сигнала и еще один для выбора устройства. При этом для каждого устройства на шине выделяется отдельный GPIO-пин для выбора. В разных реализациях этот пин называется CS/NCS (Chip Select) или SS (Slave Select). Когда CS-пин устройства активен (напряжение на нем низкое), устройство выбрано для общения.
В полетных контроллерах IMU обычно подключают через SPI, потому что он обеспечивает значительно бо́льшую скорость передачи данных и меньшую задержку. Подключение IMU через интерфейс I²C (например, в случае нехватки пинов микроконтроллера) возможно, но не рекомендуется.
Подключение IMU к микроконтроллеру ESP32 через интерфейс SPI выглядит так:
|Пин платы IMU|Пин ESP32|
|-|-|
|VCC/3V3|3V3|
|GND|GND|
|SCL|IO18|
|SDA *(MOSI)*|IO23|
|SAO/AD0 *(MISO)*|IO19|
|NCS|IO5|
Кроме того, многие IMU могут «будить» микроконтроллер при наличии новых данных. Для этого используется пин INT, который подключается к любому GPIO-пину микроконтроллера. При такой конфигурации можно использовать прерывания для обработки новых данных с IMU, вместо периодического опроса датчика. Это позволяет снизить нагрузку на микроконтроллер в сложных алгоритмах управления.
> [!WARNING]
> На некоторых платах IMU, например, на ICM-20948, отсутствует стабилизатор напряжения, поэтому их нельзя подключать к пину VIN ESP32, который подает напряжение 5 В. Допустимо питание только от пина 3V3.
## Работа с гироскопом
Для взаимодействия с IMU, включая работу с гироскопом, в Flix используется библиотека *FlixPeriph*. Библиотека устанавливается через менеджер библиотек Arduino IDE:
<img src="img/flixperiph.png" width="300">
Чтобы работать с IMU, используется класс, соответствующий модели IMU: `MPU9250`, `MPU6500` или `ICM20948`. Классы для работы с разными IMU имеют единообразный интерфейс для основных операций, поэтому возможно легко переключаться между разными моделями IMU. Датчик MPU-6500 практически полностью совместим с MPU-9250, поэтому фактически класс `MPU9250` поддерживает обе модели.
## Ориентация осей гироскопа
Данные с гироскопа представляют собой угловую скорость вокруг трех осей: X, Y и Z. Ориентацию этих осей у IMU InvenSense можно легко определить по небольшой точке в углу чипа. Оси координат и направление вращения для измерений гироскопа обозначены на диаграмме:
<img src="img/imu-axes.svg" width="300" alt="Оси координат IMU">
Расположение осей координат в популярных платах IMU:
|GY-91|MPU-92/65|ICM-20948|
|-|-|-|
|<img src="https://github.com/okalachev/flixperiph/raw/refs/heads/master/img/gy91-axes.svg" width="200" alt="Оси координат платы GY-91">|<img src="https://github.com/okalachev/flixperiph/raw/refs/heads/master/img/mpu9265-axes.svg" width="200" alt="Оси координат платы MPU-9265">|<img src="https://github.com/okalachev/flixperiph/raw/refs/heads/master/img/icm20948-axes.svg" width="200" alt="Оси координат платы ICM-20948">|
Магнитометр IMU InvenSense обычно является отдельным устройством, интегрированным в чип, поэтому его оси координат могут отличаться. Библиотека FlixPeriph скрывает это различие и приводит данные с магнитометра к системе координат гироскопа и акселерометра.
## Чтение данных
Интерфейс библиотеки FlixPeriph соответствует стилю, принятому в Arduino. Для начала работы с IMU необходимо создать объект соответствующего класса и вызвать метод `begin()`. В конструктор класса передается интерфейс, по которому подключен IMU (SPI или I²C):
```cpp
#include <FlixPeriph.h>
#include <SPI.h>
MPU9250 IMU(SPI);
void setup() {
Serial.begin(115200);
bool success = IMU.begin();
if (!success) {
Serial.println("Failed to initialize IMU");
}
}
```
Для однократного считывания данных используется метод `read()`. Затем данные с гироскопа получаются при помощи метода `getGyro(x, y, z)`. Этот метод записывает в переменные `x`, `y` и `z` угловые скорости вокруг соответствующих осей в радианах в секунду.
Если нужно гарантировать, что будут считаны новые данные, можно использовать метод `waitForData()`. Этот метод блокирует выполнение программы до тех пор, пока в IMU не появятся новые данные. Метод `waitForData()` позволяет привязать частоту главного цикла `loop` к частоте обновления данных IMU. Это удобно для организации главного цикла управления квадрокоптером.
Программа для чтения данных с гироскопа и вывода их в консоль для построения графиков в Serial Plotter выглядит так:
```cpp
#include <FlixPeriph.h>
#include <SPI.h>
MPU9250 IMU(SPI);
void setup() {
Serial.begin(115200);
bool success = IMU.begin();
if (!success) {
Serial.println("Failed to initialize IMU");
}
}
void loop() {
IMU.waitForData();
float gx, gy, gz;
IMU.getGyro(gx, gy, gz);
Serial.printf("gx:%f gy:%f gz:%f\n", gx, gy, gz);
delay(50); // замедление вывода
}
```
После запуска программы в Serial Plotter можно увидеть графики угловых скоростей. Например, при вращениях IMU вокруг вертикальной оси Z графики будут выглядеть так:
<img src="img/gyro-plotter.png">
## Конфигурация гироскопа
В коде Flix настройка IMU происходит в функции `configureIMU`. В этой функции настраиваются три основных параметра гироскопа: диапазон измерений, частота сэмплов и частота LPF-фильтра.
### Частота сэмплов
Большинство IMU могут обновлять данные с разной частотой. В полетных контроллерах обычно используется частота обновления от 500 Гц до 8 кГц. Чем выше частота сэмплов, тем выше точность управления полетом, но и больше нагрузка на микроконтроллер. В Flix используется частота сэмплов 1 кГц.
Частота сэмплов устанавливается методом `setSampleRate()`. В Flix используется частота 1 кГц:
```cpp
IMU.setRate(IMU.RATE_1KHZ_APPROX);
```
Поскольку не все поддерживаемые IMU могут работать строго на частоте 1 кГц, в библиотеке FlixPeriph существует возможность приближенной настройки частоты сэмплов. Например, у IMU ICM-20948 при такой настройке реальная частота сэмплирования будет равна 1125 Гц.
Другие доступные для установки в библиотеке FlixPeriph частоты сэмплирования:
* `RATE_MIN` — минимальная частота сэмплов для конкретного IMU.
* `RATE_50HZ_APPROX` — значение, близкое к 50 Гц.
* `RATE_1KHZ_APPROX`  — значение, близкое к 1 кГц.
* `RATE_8KHZ_APPROX` — значение, близкое к 8 кГц.
* `RATE_MAX` — максимальная частота сэмплов для конкретного IMU.
#### Диапазон измерений
Большинство MEMS-гироскопов поддерживают несколько диапазонов измерений угловой скорости. Главное преимущество выбора меньшего диапазона — бо́льшая чувствительность. В полетных контроллерах обычно выбирается максимальный диапазон измерений от 2000 до 2000 градусов в секунду, чтобы обеспечить возможность динамичных маневров.
В библиотеке FlixPeriph диапазон измерений гироскопа устанавливается методом `setGyroRange()`:
```cpp
IMU.setGyroRange(IMU.GYRO_RANGE_2000DPS);
```
### LPF-фильтр
IMU InvenSense могут фильтровать измерения на аппаратном уровне при помощи фильтра нижних частот (LPF). Flix реализует собственный фильтр для гироскопа, чтобы иметь больше гибкости при поддержке разных IMU. Поэтому для встроенного LPF устанавливается максимальная частота среза:
```cpp
IMU.setDLPF(IMU.DLPF_MAX);
```
## Калибровка гироскопа
Как и любое измерительное устройство, гироскоп вносит искажения в измерения. Наиболее простая модель этих искажений делит их на статические смещения (*bias*) и случайный шум (*noise*):
\\[ gyro_{xyz}=rates_{xyz}+bias_{xyz}+noise \\]
Для качественной работы подсистемы оценки ориентации и управления дроном необходимо оценить *bias* гироскопа и учесть его в вычислениях. Для этого при запуске программы производится калибровка гироскопа, которая реализована в функции `calibrateGyro()`. Эта функция считывает данные с гироскопа в состоянии покоя 1000 раз и усредняет их. Полученные значения считаются *bias* гироскопа и в дальнейшем вычитаются из измерений.
Программа для вывода данных с гироскопа с калибровкой:
```cpp
#include <FlixPeriph.h>
#include <SPI.h>
MPU9250 IMU(SPI);
float gyroBiasX, gyroBiasY, gyroBiasZ; // bias гироскопа
void setup() {
Serial.begin(115200);
bool success = IMU.begin();
if (!success) {
Serial.println("Failed to initialize IMU");
}
calibrateGyro();
}
void loop() {
float gx, gy, gz;
IMU.waitForData();
IMU.getGyro(gx, gy, gz);
// Устранение bias гироскопа
gx -= gyroBiasX;
gy -= gyroBiasY;
gz -= gyroBiasZ;
Serial.printf("gx:%f gy:%f gz:%f\n", gx, gy, gz);
delay(50); // замедление вывода
}
void calibrateGyro() {
const int samples = 1000;
Serial.println("Calibrating gyro, stand still");
gyroBiasX = 0;
gyroBiasY = 0;
gyroBiasZ = 0;
// Получение 1000 измерений гироскопа
for (int i = 0; i < samples; i++) {
IMU.waitForData();
float gx, gy, gz;
IMU.getGyro(gx, gy, gz);
gyroBiasX += gx;
gyroBiasY += gy;
gyroBiasZ += gz;
}
// Усреднение значений
gyroBiasX = gyroBiasX / samples;
gyroBiasY = gyroBiasY / samples;
gyroBiasZ = gyroBiasZ / samples;
Serial.printf("Gyro bias X: %f\n", gyroBiasX);
Serial.printf("Gyro bias Y: %f\n", gyroBiasY);
Serial.printf("Gyro bias Z: %f\n", gyroBiasZ);
}
```
График данных с гироскопа в состоянии покоя без калибровки. Можно увидеть статическую ошибку каждой из осей:
<img src="img/gyro-uncalibrated-plotter.png">
График данных с гироскопа в состоянии покоя после калибровки:
<img src="img/gyro-calibrated-plotter.png">
Откалиброванные данные с гироскопа вместе с данными с акселерометра поступают в *подсистему оценки состояния*.
## Дополнительные материалы
* [MPU-9250 datasheet](https://invensense.tdk.com/wp-content/uploads/2015/02/PS-MPU-9250A-01-v1.1.pdf).
* [MPU-6500 datasheet](https://invensense.tdk.com/wp-content/uploads/2020/06/PS-MPU-6500A-01-v1.3.pdf).
* [ICM-20948 datasheet](https://invensense.tdk.com/wp-content/uploads/2016/06/DS-000189-ICM-20948-v1.3.pdf).

1
docs/book/img Symbolic link
View File

@ -0,0 +1 @@
../img

BIN
docs/img/flixperiph.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

BIN
docs/img/gyro-plotter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
docs/img/gyroscope.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

119
docs/img/imu-axes.svg Normal file
View File

@ -0,0 +1,119 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 544.13 637.15">
<defs>
<style>
.a {
fill: #dbe1e2;
}
.b {
fill: #c2c1c0;
}
.c {
fill: #c6c6c5;
}
.d {
fill: #ec7d23;
}
.e {
font-size: 50px;
font-family: Tahoma;
}
.e, .n {
fill: #010101;
}
.f {
opacity: 0.8;
}
.g, .i, .k, .m {
fill: none;
stroke-width: 10px;
}
.g {
stroke: #0577ba;
}
.g, .i, .k {
stroke-linejoin: bevel;
}
.h {
fill: #0577ba;
}
.i {
stroke: #76c043;
}
.j {
fill: #76c043;
}
.k {
stroke: #d71f26;
}
.l {
fill: #d71f26;
}
.m {
stroke: #010101;
stroke-miterlimit: 10;
}
</style>
</defs>
<g>
<g>
<rect class="a" x="51.25" y="538.09" width="111.96" height="44.06"/>
<polygon class="b" points="204.47 515.98 163.21 582.15 163.21 538.09 204.47 471.91 204.47 515.98"/>
<polygon class="c" points="163.21 538.19 51.25 538.19 92.46 471.91 204.42 471.91 163.21 538.19"/>
<ellipse class="d" cx="101.09" cy="480" rx="7.45" ry="3.7" transform="translate(-117.09 40.67) rotate(-14.52)"/>
</g>
<text class="e" transform="translate(166.62 107.43)">Z</text>
<g class="f">
<g>
<line class="g" x1="127.84" y1="505.05" x2="127.84" y2="70.04"/>
<polygon class="h" points="145.79 75.3 127.84 44.21 109.89 75.3 145.79 75.3"/>
</g>
</g>
<g class="f">
<g>
<line class="i" x1="127.84" y1="505.05" x2="315.74" y2="203.61"/>
<polygon class="j" points="328.2 217.57 329.41 181.69 297.73 198.57 328.2 217.57"/>
</g>
</g>
<text class="e" transform="translate(338.14 279.7)">Y</text>
<g class="f">
<g>
<line class="k" x1="127.94" y1="504.62" x2="467.04" y2="504.62"/>
<polygon class="l" points="461.79 522.58 492.87 504.62 461.79 486.67 461.79 522.58"/>
</g>
</g>
<text class="e" transform="translate(438.99 582.15)">X</text>
<g class="f">
<g>
<path class="m" d="M80,98.74a52.66,52.66,0,1,0,98.43,36.72"/>
<polygon class="n" points="190.29 140.9 180.45 116.91 164.59 137.41 190.29 140.9"/>
</g>
</g>
<g class="f">
<g>
<path class="m" d="M474,467.75a52.66,52.66,0,1,0-59.23,86.77"/>
<polygon class="n" points="406.68 564.7 432.32 560.9 416.21 540.59 406.68 564.7"/>
</g>
</g>
<g class="f">
<g>
<path class="m" d="M222.38,257.69a52.66,52.66,0,1,1,93.83,47.25"/>
<polygon class="n" points="308.22 293.44 303.95 319.01 328.23 309.93 308.22 293.44"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
docs/img/mpu9250.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

7
docs/js.js Normal file
View File

@ -0,0 +1,7 @@
// Enable zoom on images larger than 300px
document.querySelectorAll('.content img').forEach(function (img) {
var width = img.getAttribute('width');
if (!width || width >= 300) {
img.setAttribute('data-action', 'zoom');
}
});

336
docs/theme/index.hbs vendored Normal file
View File

@ -0,0 +1,336 @@
<!DOCTYPE HTML>
<html lang="{{ language }}" class="{{ default_theme }} sidebar-visible" dir="{{ text_direction }}">
<head>
<!-- Book generated using mdBook -->
<meta charset="UTF-8">
<title>{{ title }}</title>
{{#if is_print }}
<meta name="robots" content="noindex">
{{/if}}
{{#if base_url}}
<base href="{{ base_url }}">
{{/if}}
<!-- Custom HTML head -->
{{> head}}
<meta name="description" content="{{ description }}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#ffffff">
{{#if favicon_svg}}
<link rel="icon" href="{{ path_to_root }}favicon.svg">
{{/if}}
{{#if favicon_png}}
<link rel="shortcut icon" href="{{ path_to_root }}favicon.png">
{{/if}}
<link rel="stylesheet" href="{{ path_to_root }}css/variables.css">
<link rel="stylesheet" href="{{ path_to_root }}css/general.css">
<link rel="stylesheet" href="{{ path_to_root }}css/chrome.css">
{{#if print_enable}}
<link rel="stylesheet" href="{{ path_to_root }}css/print.css" media="print">
{{/if}}
<!-- Fonts -->
<link rel="stylesheet" href="{{ path_to_root }}FontAwesome/css/font-awesome.css">
{{#if copy_fonts}}
<link rel="stylesheet" href="{{ path_to_root }}fonts/fonts.css">
{{/if}}
<!-- Highlight.js Stylesheets -->
<link rel="stylesheet" href="{{ path_to_root }}highlight.css">
<link rel="stylesheet" href="{{ path_to_root }}tomorrow-night.css">
<link rel="stylesheet" href="{{ path_to_root }}ayu-highlight.css">
<!-- Custom theme stylesheets -->
{{#each additional_css}}
<link rel="stylesheet" href="{{ ../path_to_root }}{{ this }}">
{{/each}}
{{#if mathjax_support}}
<!-- MathJax -->
<script async src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script>
{{/if}}
<!-- Provide site root to javascript -->
<script>
var path_to_root = "{{ path_to_root }}";
var default_theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "{{ preferred_dark_theme }}" : "{{ default_theme }}";
</script>
<!-- Start loading toc.js asap -->
<script src="{{ path_to_root }}toc.js"></script>
<!-- Yandex.Metrika counter -->
<script type="text/javascript" > (function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)}; m[i].l=1*new Date(); for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }} k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)}) (window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym"); ym(97589916, "init", { clickmap:true, trackLinks:true, accurateTrackBounce:true }); </script> <noscript><div><img src="https://mc.yandex.ru/watch/97589916" style="position:absolute; left:-9999px;" alt="" /></div></noscript> <!-- /Yandex.Metrika counter -->
</head>
<body>
<div id="body-container">
<!-- Work around some values being stored in localStorage wrapped in quotes -->
<script>
try {
var theme = localStorage.getItem('mdbook-theme');
var sidebar = localStorage.getItem('mdbook-sidebar');
if (theme.startsWith('"') && theme.endsWith('"')) {
localStorage.setItem('mdbook-theme', theme.slice(1, theme.length - 1));
}
if (sidebar.startsWith('"') && sidebar.endsWith('"')) {
localStorage.setItem('mdbook-sidebar', sidebar.slice(1, sidebar.length - 1));
}
} catch (e) { }
</script>
<!-- Set the theme before any content is loaded, prevents flash -->
<script>
var theme;
try { theme = localStorage.getItem('mdbook-theme'); } catch(e) { }
if (theme === null || theme === undefined) { theme = default_theme; }
const html = document.documentElement;
html.classList.remove('{{ default_theme }}')
html.classList.add(theme);
html.classList.add("js");
</script>
<input type="checkbox" id="sidebar-toggle-anchor" class="hidden">
<!-- Hide / unhide sidebar before it is displayed -->
<script>
var sidebar = null;
var sidebar_toggle = document.getElementById("sidebar-toggle-anchor");
if (document.body.clientWidth >= 1080) {
try { sidebar = localStorage.getItem('mdbook-sidebar'); } catch(e) { }
sidebar = sidebar || 'visible';
} else {
sidebar = 'hidden';
}
sidebar_toggle.checked = sidebar === 'visible';
html.classList.remove('sidebar-visible');
html.classList.add("sidebar-" + sidebar);
</script>
<nav id="sidebar" class="sidebar" aria-label="Table of contents">
<!-- populated by js -->
<mdbook-sidebar-scrollbox class="sidebar-scrollbox">
<footer>
<a href="https://github.com/okalachev/flix" class="github">GitHub</a>
<a href="https://t.me/opensourcequadcopter" class="telegram">Telegram-канал</a>
💰 Поддержать проект:
<iframe style="margin-top: 0.4em;" src="https://yoomoney.ru/quickpay/fundraise/button?billNumber=16U9OH2S4IT.241205&" width="330" height="50" frameborder="0" allowtransparency="true" scrolling="no"></iframe>
&copy; 2024 Олег Калачев
</footer>
</mdbook-sidebar-scrollbox>
<noscript>
<iframe class="sidebar-iframe-outer" src="{{ path_to_root }}toc.html"></iframe>
</noscript>
<div id="sidebar-resize-handle" class="sidebar-resize-handle">
<div class="sidebar-resize-indicator"></div>
</div>
</nav>
<div id="page-wrapper" class="page-wrapper">
<div class="page">
{{> header}}
<div id="menu-bar-hover-placeholder"></div>
<div id="menu-bar" class="menu-bar sticky">
<div class="left-buttons">
<label id="sidebar-toggle" class="icon-button" for="sidebar-toggle-anchor" title="Toggle Table of Contents" aria-label="Toggle Table of Contents" aria-controls="sidebar">
<i class="fa fa-bars"></i>
</label>
<button id="theme-toggle" class="icon-button" type="button" title="Change theme" aria-label="Change theme" aria-haspopup="true" aria-expanded="false" aria-controls="theme-list">
<i class="fa fa-paint-brush"></i>
</button>
<ul id="theme-list" class="theme-popup" aria-label="Themes" role="menu">
<li role="none"><button role="menuitem" class="theme" id="light">Light</button></li>
<li role="none"><button role="menuitem" class="theme" id="rust">Rust</button></li>
<li role="none"><button role="menuitem" class="theme" id="coal">Coal</button></li>
<li role="none"><button role="menuitem" class="theme" id="navy">Navy</button></li>
<li role="none"><button role="menuitem" class="theme" id="ayu">Ayu</button></li>
</ul>
{{#if search_enabled}}
<button id="search-toggle" class="icon-button" type="button" title="Search. (Shortkey: s)" aria-label="Toggle Searchbar" aria-expanded="false" aria-keyshortcuts="S" aria-controls="searchbar">
<i class="fa fa-search"></i>
</button>
{{/if}}
</div>
<h1 class="menu-title">{{ book_title }}</h1>
<div class="right-buttons">
{{#if print_enable}}
<a href="{{ path_to_root }}print.html" title="Print this book" aria-label="Print this book">
<i id="print-button" class="fa fa-print"></i>
</a>
{{/if}}
{{#if git_repository_url}}
<a href="{{git_repository_url}}" title="Git repository" aria-label="Git repository">
<i id="git-repository-button" class="fa {{git_repository_icon}}"></i>
</a>
{{/if}}
{{#if git_repository_edit_url}}
<a href="{{git_repository_edit_url}}" title="Suggest an edit" aria-label="Suggest an edit">
<i id="git-edit-button" class="fa fa-edit"></i>
</a>
{{/if}}
</div>
</div>
{{#if search_enabled}}
<div id="search-wrapper" class="hidden">
<form id="searchbar-outer" class="searchbar-outer">
<input type="search" id="searchbar" name="searchbar" placeholder="Search this book ..." aria-controls="searchresults-outer" aria-describedby="searchresults-header">
</form>
<div id="searchresults-outer" class="searchresults-outer hidden">
<div id="searchresults-header" class="searchresults-header"></div>
<ul id="searchresults">
</ul>
</div>
</div>
{{/if}}
<!-- Apply ARIA attributes after the sidebar and the sidebar toggle button are added to the DOM -->
<script>
document.getElementById('sidebar-toggle').setAttribute('aria-expanded', sidebar === 'visible');
document.getElementById('sidebar').setAttribute('aria-hidden', sidebar !== 'visible');
Array.from(document.querySelectorAll('#sidebar a')).forEach(function(link) {
link.setAttribute('tabIndex', sidebar === 'visible' ? 0 : -1);
});
</script>
<div id="content" class="content">
<main>
{{{ content }}}
</main>
<nav class="nav-wrapper" aria-label="Page navigation">
<!-- Mobile navigation buttons -->
{{#previous}}
<a rel="prev" href="{{ path_to_root }}{{link}}" class="mobile-nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
{{/previous}}
{{#next}}
<a rel="next prefetch" href="{{ path_to_root }}{{link}}" class="mobile-nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
<i class="fa fa-angle-right"></i>
</a>
{{/next}}
<div style="clear: both"></div>
</nav>
</div>
</div>
<nav class="nav-wide-wrapper" aria-label="Page navigation">
{{#previous}}
<a rel="prev" href="{{ path_to_root }}{{link}}" class="nav-chapters previous" title="Previous chapter" aria-label="Previous chapter" aria-keyshortcuts="Left">
<i class="fa fa-angle-left"></i>
</a>
{{/previous}}
{{#next}}
<a rel="next prefetch" href="{{ path_to_root }}{{link}}" class="nav-chapters next" title="Next chapter" aria-label="Next chapter" aria-keyshortcuts="Right">
<i class="fa fa-angle-right"></i>
</a>
{{/next}}
</nav>
</div>
{{#if live_reload_endpoint}}
<!-- Livereload script (if served using the cli tool) -->
<script>
const wsProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsAddress = wsProtocol + "//" + location.host + "/" + "{{{live_reload_endpoint}}}";
const socket = new WebSocket(wsAddress);
socket.onmessage = function (event) {
if (event.data === "reload") {
socket.close();
location.reload();
}
};
window.onbeforeunload = function() {
socket.close();
}
</script>
{{/if}}
{{#if google_analytics}}
<!-- Google Analytics Tag -->
<script>
var localAddrs = ["localhost", "127.0.0.1", ""];
// make sure we don't activate google analytics if the developer is
// inspecting the book locally...
if (localAddrs.indexOf(document.location.hostname) === -1) {
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
ga('create', '{{google_analytics}}', 'auto');
ga('send', 'pageview');
}
</script>
{{/if}}
{{#if playground_line_numbers}}
<script>
window.playground_line_numbers = true;
</script>
{{/if}}
{{#if playground_copyable}}
<script>
window.playground_copyable = true;
</script>
{{/if}}
{{#if playground_js}}
<script src="{{ path_to_root }}ace.js"></script>
<script src="{{ path_to_root }}editor.js"></script>
<script src="{{ path_to_root }}mode-rust.js"></script>
<script src="{{ path_to_root }}theme-dawn.js"></script>
<script src="{{ path_to_root }}theme-tomorrow_night.js"></script>
{{/if}}
{{#if search_js}}
<script src="{{ path_to_root }}elasticlunr.min.js"></script>
<script src="{{ path_to_root }}mark.min.js"></script>
<script src="{{ path_to_root }}searcher.js"></script>
{{/if}}
<script src="{{ path_to_root }}clipboard.min.js"></script>
<script src="{{ path_to_root }}highlight.js"></script>
<script src="{{ path_to_root }}book.js"></script>
<!-- Custom JS scripts -->
{{#each additional_js}}
<script src="{{ ../path_to_root }}{{this}}"></script>
{{/each}}
{{#if is_print}}
{{#if mathjax_support}}
<script>
window.addEventListener('load', function() {
MathJax.Hub.Register.StartupHook('End', function() {
window.setTimeout(window.print, 100);
});
});
</script>
{{else}}
<script>
window.addEventListener('load', function() {
window.setTimeout(window.print, 100);
});
</script>
{{/if}}
{{/if}}
</div>
</body>
</html>

31
docs/zoom.css Normal file
View File

@ -0,0 +1,31 @@
img[data-action="zoom"] {
cursor: zoom-in;
}
.zoom-img,
.zoom-img-wrap {
position: relative;
z-index: 666;
transition: all 300ms;
}
img.zoom-img {
cursor: zoom-out;
}
.zoom-overlay {
cursor: zoom-out;
z-index: 420;
background: #fff;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
filter: "alpha(opacity=0)";
opacity: 0;
transition: opacity 300ms;
}
.zoom-overlay-open .zoom-overlay {
filter: "alpha(opacity=100)";
opacity: 1;
}
/*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uL2Nzcy96b29tLmNzcyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTtFQUNFLGdCQUFnQjtDQUNqQjtBQUNEOztFQUVFLG1CQUFtQjtFQUNuQixhQUFhO0VBQ2Isc0JBQXNCO0NBQ3ZCO0FBQ0Q7RUFDRSxpQkFBaUI7Q0FDbEI7QUFDRDtFQUNFLGlCQUFpQjtFQUNqQixhQUFhO0VBQ2IsaUJBQWlCO0VBQ2pCLGdCQUFnQjtFQUNoQixPQUFPO0VBQ1AsUUFBUTtFQUNSLFNBQVM7RUFDVCxVQUFVO0VBQ1YsMkJBQTJCO0VBQzNCLFdBQVc7RUFDWCwrQkFBK0I7Q0FDaEM7QUFDRDtFQUNFLDZCQUE2QjtFQUM3QixXQUFXO0NBQ1oiLCJmaWxlIjoiem9vbS5jc3MiLCJzb3VyY2VzQ29udGVudCI6WyJpbWdbZGF0YS1hY3Rpb249XCJ6b29tXCJdIHtcbiAgY3Vyc29yOiB6b29tLWluO1xufVxuLnpvb20taW1nLFxuLnpvb20taW1nLXdyYXAge1xuICBwb3NpdGlvbjogcmVsYXRpdmU7XG4gIHotaW5kZXg6IDY2NjtcbiAgdHJhbnNpdGlvbjogYWxsIDMwMG1zO1xufVxuaW1nLnpvb20taW1nIHtcbiAgY3Vyc29yOiB6b29tLW91dDtcbn1cbi56b29tLW92ZXJsYXkge1xuICBjdXJzb3I6IHpvb20tb3V0O1xuICB6LWluZGV4OiA0MjA7XG4gIGJhY2tncm91bmQ6ICNmZmY7XG4gIHBvc2l0aW9uOiBmaXhlZDtcbiAgdG9wOiAwO1xuICBsZWZ0OiAwO1xuICByaWdodDogMDtcbiAgYm90dG9tOiAwO1xuICBmaWx0ZXI6IFwiYWxwaGEob3BhY2l0eT0wKVwiO1xuICBvcGFjaXR5OiAwO1xuICB0cmFuc2l0aW9uOiAgICAgIG9wYWNpdHkgMzAwbXM7XG59XG4uem9vbS1vdmVybGF5LW9wZW4gLnpvb20tb3ZlcmxheSB7XG4gIGZpbHRlcjogXCJhbHBoYShvcGFjaXR5PTEwMClcIjtcbiAgb3BhY2l0eTogMTtcbn1cbiJdfQ== */

281
docs/zoom.js Normal file
View File

@ -0,0 +1,281 @@
/* https://github.com/spinningarrow/zoom-vanilla.js
The MIT License
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
+function () { "use strict";
var OFFSET = 80
// From http://youmightnotneedjquery.com/#offset
function offset(element) {
var rect = element.getBoundingClientRect()
var scrollTop = window.pageYOffset ||
document.documentElement.scrollTop ||
document.body.scrollTop ||
0
var scrollLeft = window.pageXOffset ||
document.documentElement.scrollLeft ||
document.body.scrollLeft ||
0
return {
top: rect.top + scrollTop,
left: rect.left + scrollLeft
}
}
function zoomListener() {
var activeZoom = null
var initialScrollPosition = null
var initialTouchPosition = null
function listen() {
document.body.addEventListener('click', function (event) {
if (event.target.getAttribute('data-action') !== 'zoom' ||
event.target.tagName !== 'IMG') return
zoom(event)
})
}
function zoom(event) {
event.stopPropagation()
if (document.body.classList.contains('zoom-overlay-open')) return
if (event.metaKey || event.ctrlKey) return openInNewWindow()
closeActiveZoom({ forceDispose: true })
activeZoom = vanillaZoom(event.target)
activeZoom.zoomImage()
addCloseActiveZoomListeners()
}
function openInNewWindow() {
window.open(event.target.getAttribute('data-original') ||
event.target.currentSrc ||
event.target.src,
'_blank')
}
function closeActiveZoom(options) {
options = options || { forceDispose: false }
if (!activeZoom) return
activeZoom[options.forceDispose ? 'dispose' : 'close']()
removeCloseActiveZoomListeners()
activeZoom = null
}
function addCloseActiveZoomListeners() {
// todo(fat): probably worth throttling this
window.addEventListener('scroll', handleScroll)
document.addEventListener('click', handleClick)
document.addEventListener('keyup', handleEscPressed)
document.addEventListener('touchstart', handleTouchStart)
document.addEventListener('touchend', handleClick)
}
function removeCloseActiveZoomListeners() {
window.removeEventListener('scroll', handleScroll)
document.removeEventListener('keyup', handleEscPressed)
document.removeEventListener('click', handleClick)
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchend', handleClick)
}
function handleScroll(event) {
if (initialScrollPosition === null) initialScrollPosition = window.pageYOffset
var deltaY = initialScrollPosition - window.pageYOffset
if (Math.abs(deltaY) >= 40) closeActiveZoom()
}
function handleEscPressed(event) {
if (event.keyCode == 27) closeActiveZoom()
}
function handleClick(event) {
event.stopPropagation()
event.preventDefault()
closeActiveZoom()
}
function handleTouchStart(event) {
initialTouchPosition = event.touches[0].pageY
event.target.addEventListener('touchmove', handleTouchMove)
}
function handleTouchMove(event) {
if (Math.abs(event.touches[0].pageY - initialTouchPosition) <= 10) return
closeActiveZoom()
event.target.removeEventListener('touchmove', handleTouchMove)
}
return { listen: listen }
}
var vanillaZoom = (function () {
var fullHeight = null
var fullWidth = null
var overlay = null
var imgScaleFactor = null
var targetImage = null
var targetImageWrap = null
var targetImageClone = null
function zoomImage() {
var img = document.createElement('img')
img.onload = function () {
fullHeight = Number(img.height)
fullWidth = Number(img.width)
zoomOriginal()
}
img.src = targetImage.currentSrc || targetImage.src
}
function zoomOriginal() {
targetImageWrap = document.createElement('div')
targetImageWrap.className = 'zoom-img-wrap'
targetImageWrap.style.position = 'absolute'
targetImageWrap.style.top = offset(targetImage).top + 'px'
targetImageWrap.style.left = offset(targetImage).left + 'px'
targetImageClone = targetImage.cloneNode()
targetImageClone.style.visibility = 'hidden'
targetImage.style.width = targetImage.offsetWidth + 'px'
targetImage.parentNode.replaceChild(targetImageClone, targetImage)
document.body.appendChild(targetImageWrap)
targetImageWrap.appendChild(targetImage)
targetImage.classList.add('zoom-img')
targetImage.setAttribute('data-action', 'zoom-out')
overlay = document.createElement('div')
overlay.className = 'zoom-overlay'
document.body.appendChild(overlay)
calculateZoom()
triggerAnimation()
}
function calculateZoom() {
targetImage.offsetWidth // repaint before animating
var originalFullImageWidth = fullWidth
var originalFullImageHeight = fullHeight
var maxScaleFactor = originalFullImageWidth / targetImage.width
var viewportHeight = window.innerHeight - OFFSET
var viewportWidth = window.innerWidth - OFFSET
var imageAspectRatio = originalFullImageWidth / originalFullImageHeight
var viewportAspectRatio = viewportWidth / viewportHeight
if (originalFullImageWidth < viewportWidth && originalFullImageHeight < viewportHeight) {
imgScaleFactor = maxScaleFactor
} else if (imageAspectRatio < viewportAspectRatio) {
imgScaleFactor = (viewportHeight / originalFullImageHeight) * maxScaleFactor
} else {
imgScaleFactor = (viewportWidth / originalFullImageWidth) * maxScaleFactor
}
}
function triggerAnimation() {
targetImage.offsetWidth // repaint before animating
var imageOffset = offset(targetImage)
var scrollTop = window.pageYOffset
var viewportY = scrollTop + (window.innerHeight / 2)
var viewportX = (window.innerWidth / 2)
var imageCenterY = imageOffset.top + (targetImage.height / 2)
var imageCenterX = imageOffset.left + (targetImage.width / 2)
var translateY = Math.round(viewportY - imageCenterY)
var translateX = Math.round(viewportX - imageCenterX)
var targetImageTransform = 'scale(' + imgScaleFactor + ')'
var targetImageWrapTransform =
'translate(' + translateX + 'px, ' + translateY + 'px) translateZ(0)'
targetImage.style.webkitTransform = targetImageTransform
targetImage.style.msTransform = targetImageTransform
targetImage.style.transform = targetImageTransform
targetImageWrap.style.webkitTransform = targetImageWrapTransform
targetImageWrap.style.msTransform = targetImageWrapTransform
targetImageWrap.style.transform = targetImageWrapTransform
document.body.classList.add('zoom-overlay-open')
}
function close() {
document.body.classList.remove('zoom-overlay-open')
document.body.classList.add('zoom-overlay-transitioning')
targetImage.style.webkitTransform = ''
targetImage.style.msTransform = ''
targetImage.style.transform = ''
targetImageWrap.style.webkitTransform = ''
targetImageWrap.style.msTransform = ''
targetImageWrap.style.transform = ''
if (!'transition' in document.body.style) return dispose()
targetImageWrap.addEventListener('transitionend', dispose)
targetImageWrap.addEventListener('webkitTransitionEnd', dispose)
}
function dispose() {
targetImage.removeEventListener('transitionend', dispose)
targetImage.removeEventListener('webkitTransitionEnd', dispose)
if (!targetImageWrap || !targetImageWrap.parentNode) return
targetImage.classList.remove('zoom-img')
targetImage.style.width = ''
targetImage.setAttribute('data-action', 'zoom')
targetImageClone.parentNode.replaceChild(targetImage, targetImageClone)
targetImageWrap.parentNode.removeChild(targetImageWrap)
overlay.parentNode.removeChild(overlay)
document.body.classList.remove('zoom-overlay-transitioning')
}
return function (target) {
targetImage = target
return { zoomImage: zoomImage, close: close, dispose: dispose }
}
}())
zoomListener().listen()
}()