Initial import: WebAisMap

Closes TG-4
This commit is contained in:
2026-05-04 07:56:45 +03:00
commit 5df38bad2d
1460 changed files with 16334 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
__pycache__/
*.py[cod]
.venv/
venv/
*.pem
+3
View File
@@ -0,0 +1,3 @@
# WebAisMap
AIS map web application (Python backend + Leaflet frontend).
+97
View File
@@ -0,0 +1,97 @@
# Работа в офлайн режиме
Приложение настроено для работы без подключения к интернету. Все необходимые файлы (библиотека Leaflet и тайлы карты) хранятся локально.
## Структура файлов
- `static/leaflet/` - библиотека Leaflet (CSS, JS и изображения)
- `static/xterm/` - терминал в браузере (вкладка «Консоль»), без CDN
(не удаляйте комментарий `sourceMappingURL` через `splitlines()` в Python — в `xterm.min.js` есть символ U+0085, из‑за него строка «ломается» и появляется SyntaxError в браузере)
- `static/tiles/` - тайлы карты OpenStreetMap
## Скачивание тайлов карты
Для работы в офлайн режиме необходимо предварительно скачать тайлы карты для нужной области.
### Использование скрипта download_tiles.py
```bash
# Скачать тайлы для области Москвы (по умолчанию)
python download_tiles.py
# Скачать тайлы для конкретной области
python download_tiles.py --bounds 55.5,37.5,56.0,38.0 --min-zoom 5 --max-zoom 12
# Скачать тайлы вокруг центра с радиусом
python download_tiles.py --center 55.75,37.62,50 --min-zoom 5 --max-zoom 12
# Указать выходную папку
python download_tiles.py --output static/tiles --min-zoom 5 --max-zoom 12
```
### Параметры скрипта
- `--min-zoom` - минимальный уровень масштабирования (по умолчанию: 5)
- `--max-zoom` - максимальный уровень масштабирования (по умолчанию: 12)
- `--bounds` - границы области в формате: `min_lat,min_lon,max_lat,max_lon`
- `--center` - центр области и радиус в формате: `lat,lon,radius_km`
- `--output` - выходная папка для тайлов (по умолчанию: `static/tiles`)
- `--delay` - задержка между запросами в секундах (по умолчанию: 1.0, минимум рекомендуется 1.0)
### Примеры использования
```bash
# Москва и окрестности, уровни 5-12
python download_tiles.py --bounds 55.5,37.3,56.0,37.9 --min-zoom 5 --max-zoom 12
# Санкт-Петербург, уровни 5-14
python download_tiles.py --center 59.934,30.306,30 --min-zoom 5 --max-zoom 14
# Большая область (например, весь регион), уровни 3-10
python download_tiles.py --bounds 50.0,20.0,60.0,40.0 --min-zoom 3 --max-zoom 10
```
### Рекомендации по уровням масштабирования
- **Уровни 0-5**: Весь мир / континенты (очень мало тайлов, быстро скачивается)
- **Уровни 5-10**: Регионы / крупные города (умеренное количество тайлов)
- **Уровни 10-14**: Города / районы (много тайлов, может занять время)
- **Уровни 14-19**: Детальные карты (очень много тайлов, долгое скачивание)
**Важно**: Чем больше уровней масштабирования и область, тем больше тайлов нужно скачать. Для начала рекомендуется использовать уровни 5-12 для нужной области.
## Запуск приложения
После скачивания тайлов приложение будет работать полностью офлайн:
```bash
python main.py
```
Приложение будет доступно по адресу `http://localhost:8000`
## Примечания
- Если тайл не найден локально, карта покажет пустое место (серый квадрат)
- Для полного покрытия области рекомендуется скачать тайлы заранее
- Размер тайлов зависит от области и уровней масштабирования (может быть от нескольких МБ до нескольких ГБ)
## Политика использования тайлов OpenStreetMap
Скрипт настроен для соблюдения [политики использования тайлов OpenStreetMap](https://operations.osmfoundation.org/policies/tiles/):
- ✅ Используется корректный User-Agent в заголовках запросов
- ✅ Соблюдается минимальная задержка 1 секунда между запросами
- ✅ Обрабатываются ошибки 429 (Too Many Requests) с автоматическим увеличением задержки
- ✅ Используются разные поддомены для распределения нагрузки
**Важно**: Если вы получаете ошибку "Access blocked", убедитесь, что:
1. Используется задержка не менее 1 секунды (`--delay 1.0` или больше)
2. Не запускаете несколько экземпляров скрипта одновременно
3. Не скачиваете слишком много тайлов за короткое время
Для больших областей рекомендуется:
- Использовать задержку 1.5-2 секунды: `--delay 1.5`
- Скачивать тайлы в несколько этапов (разные уровни масштабирования отдельно)
- Делать перерывы между сессиями скачивания
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 232.51 232.77">
<defs>
<clipPath id="clippath">
<polygon points="153.63 0 153.63 0 232.51 0 232.51 232.77 153.63 232.77 153.63 0" style="fill: none;"/>
</clipPath>
<clipPath id="clippath-1">
<polygon points="153.63 0 153.63 0 232.51 0 232.51 79.69 153.63 79.69 153.63 0" style="fill: none;"/>
</clipPath>
<clipPath id="clippath-2">
<polygon points="153.63 232.77 153.63 232.77 232.51 232.77 232.51 153.08 153.63 153.08 153.63 232.77" style="fill: none;"/>
</clipPath>
<clipPath id="clippath-3">
<polygon points="78.87 0 78.87 0 0 0 0 232.77 78.87 232.77 78.87 0" style="fill: none;"/>
</clipPath>
<clipPath id="clippath-4">
<polygon points="0 79.69 0 79.69 78.87 79.69 78.87 0 0 0 0 79.69" style="fill: none;"/>
</clipPath>
<clipPath id="clippath-5">
<polygon points="0 153.08 0 153.08 78.87 153.08 78.87 232.77 0 232.77 0 153.08" style="fill: none;"/>
</clipPath>
</defs>
<g id="_Слой_4" data-name="Слой_4">
<g>
<g id="_x3C_Зеркальный_повтор_x3E_">
<g style="clip-path: url(#clippath);">
<g>
<g id="_x3C_Зеркальный_повтор_x3E_-2" data-name="_x3C_Зеркальный_повтор_x3E_">
<g style="clip-path: url(#clippath-1);">
<g>
<line x1="153.63" y1="4.5" x2="232.51" y2="4.5" style="fill: none; stroke: #000; stroke-miterlimit: 10; stroke-width: 9px;"/>
<line x1="227.77" y1=".82" x2="227.77" y2="79.69" style="fill: none; stroke: #000; stroke-miterlimit: 10; stroke-width: 9px;"/>
</g>
</g>
</g>
<g id="_x3C_Зеркальный_повтор_x3E_-3" data-name="_x3C_Зеркальный_повтор_x3E_">
<g style="clip-path: url(#clippath-2);">
<g>
<line x1="153.63" y1="228.27" x2="232.51" y2="228.27" style="fill: none; stroke: #000; stroke-miterlimit: 10; stroke-width: 9px;"/>
<line x1="227.77" y1="231.95" x2="227.77" y2="153.08" style="fill: none; stroke: #000; stroke-miterlimit: 10; stroke-width: 9px;"/>
</g>
</g>
</g>
</g>
</g>
</g>
<g id="_x3C_Зеркальный_повтор_x3E_-4" data-name="_x3C_Зеркальный_повтор_x3E_">
<g style="clip-path: url(#clippath-3);">
<g>
<g id="_x3C_Зеркальный_повтор_x3E_-5" data-name="_x3C_Зеркальный_повтор_x3E_">
<g style="clip-path: url(#clippath-4);">
<g>
<line x1="78.87" y1="4.5" x2="0" y2="4.5" style="fill: none; stroke: #000; stroke-miterlimit: 10; stroke-width: 9px;"/>
<line x1="4.73" y1=".82" x2="4.73" y2="79.69" style="fill: none; stroke: #000; stroke-miterlimit: 10; stroke-width: 9px;"/>
</g>
</g>
</g>
<g id="_x3C_Зеркальный_повтор_x3E_-6" data-name="_x3C_Зеркальный_повтор_x3E_">
<g style="clip-path: url(#clippath-5);">
<g>
<line x1="78.87" y1="228.27" x2="0" y2="228.27" style="fill: none; stroke: #000; stroke-miterlimit: 10; stroke-width: 9px;"/>
<line x1="4.73" y1="231.95" x2="4.73" y2="153.08" style="fill: none; stroke: #000; stroke-miterlimit: 10; stroke-width: 9px;"/>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 62.24 62.18">
<defs>
<style>
.cls-1 {
stroke-width: 4px;
}
.cls-1, .cls-2 {
fill: #fff;
stroke: #000;
stroke-miterlimit: 10;
}
.cls-2 {
stroke-width: 4px;
}
</style>
</defs>
<g id="_Слой_9" data-name="Слой_9">
<circle class="cls-2" cx="30.66" cy="31.22" r="6"/>
<g>
<path class="cls-2" d="M27.25,41.64l-9.4,7.49c-2.27,1.81-5.4,2.07-7.94.66l-1.42-.78c-2.91-1.61-2.83-5.83.15-7.32l7.01-3.52,11.59,3.47Z"/>
<path class="cls-2" d="M19.94,31.19l-10.03-6.62c-2.42-1.6-3.64-4.5-3.08-7.34l.31-1.59c.63-3.27,4.67-4.49,7.01-2.12l5.51,5.58.28,12.1Z"/>
<path class="cls-2" d="M27.61,21l3.2-11.58c.77-2.79,3.15-4.85,6.03-5.2l1.61-.2c3.3-.41,5.71,3.05,4.18,6.01l-3.6,6.97-11.42,4.01Z"/>
<path class="cls-2" d="M39.68,25.15l12.01-.54c2.9-.13,5.59,1.5,6.81,4.13l.68,1.47c1.41,3.02-1.14,6.37-4.42,5.83l-7.74-1.27-7.34-9.62Z"/>
<path class="cls-2" d="M39.46,37.91l4.22,11.25c1.02,2.71.3,5.78-1.82,7.75l-1.18,1.1c-2.43,2.27-6.41.89-6.92-2.4l-1.18-7.75,6.88-9.95Z"/>
</g>
<g>
<g id="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M57.93,41.9l-2.71,4.16s3.31-2.54,2.71-4.16Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-2" data-name="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M29.15,59.9l-4.8-1.29s3.44,2.36,4.8,1.29Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-3" data-name="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M3.13,38.09l-.25-4.96s-1.19,4,.25,4.96Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-4" data-name="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M15.84,6.6l4.64-1.77s-4.17.11-4.64,1.77Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-5" data-name="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M49.71,8.96l3.12,3.87s-1.39-3.93-3.12-3.87Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 239.36 239.36">
<g id="_Слой_6" data-name="Слой_6">
<line x1="3.18" y1="3.18" x2="236.18" y2="236.18" style="fill: none; stroke: #000; stroke-miterlimit: 10; stroke-width: 9px;"/>
<line x1="236.18" y1="3.18" x2="3.18" y2="236.18" style="fill: none; stroke: #000; stroke-miterlimit: 10; stroke-width: 9px;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 471 B

+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37.67 44.99">
<defs>
<style>
.cls-1 {
fill: #fff;
stroke: #000;
stroke-miterlimit: 10;
stroke-width: 2px;
}
</style>
</defs>
<g id="_Слой_11" data-name="Слой_11">
<path class="cls-1" d="M6.71,43.99V8.86H2.32l6.3-6.67c.7-.74,1.66-1.16,2.68-1.16l14.07-.04c1.5,0,2.93.6,3.97,1.68l5.97,6.18h-4.6l1,35.13H6.71Z"/>
<line class="cls-1" x1="4.21" y1="16.49" x2="33.21" y2="20.49"/>
<line class="cls-1" x1="4.21" y1="20.49" x2="33.21" y2="24.12"/>
<line class="cls-1" x1="4.21" y1="24.12" x2="33.21" y2="27.84"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 731 B

+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 62.24 62.18">
<defs>
<style>
.cls-1 {
stroke-width: 4px;
}
.cls-1, .cls-2 {
fill: #fff;
stroke: #000;
stroke-miterlimit: 10;
}
.cls-2 {
stroke-width: 4px;
}
</style>
</defs>
<g id="_Слой_9" data-name="Слой_9">
<circle class="cls-2" cx="30.66" cy="31.22" r="6"/>
<g>
<path class="cls-2" d="M27.25,41.64l-9.4,7.49c-2.27,1.81-5.4,2.07-7.94.66l-1.42-.78c-2.91-1.61-2.83-5.83.15-7.32l7.01-3.52,11.59,3.47Z"/>
<path class="cls-2" d="M19.94,31.19l-10.03-6.62c-2.42-1.6-3.64-4.5-3.08-7.34l.31-1.59c.63-3.27,4.67-4.49,7.01-2.12l5.51,5.58.28,12.1Z"/>
<path class="cls-2" d="M27.61,21l3.2-11.58c.77-2.79,3.15-4.85,6.03-5.2l1.61-.2c3.3-.41,5.71,3.05,4.18,6.01l-3.6,6.97-11.42,4.01Z"/>
<path class="cls-2" d="M39.68,25.15l12.01-.54c2.9-.13,5.59,1.5,6.81,4.13l.68,1.47c1.41,3.02-1.14,6.37-4.42,5.83l-7.74-1.27-7.34-9.62Z"/>
<path class="cls-2" d="M39.46,37.91l4.22,11.25c1.02,2.71.3,5.78-1.82,7.75l-1.18,1.1c-2.43,2.27-6.41.89-6.92-2.4l-1.18-7.75,6.88-9.95Z"/>
</g>
<g>
<g id="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M57.93,41.9l-2.71,4.16s3.31-2.54,2.71-4.16Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-2" data-name="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M29.15,59.9l-4.8-1.29s3.44,2.36,4.8,1.29Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-3" data-name="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M3.13,38.09l-.25-4.96s-1.19,4,.25,4.96Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-4" data-name="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M15.84,6.6l4.64-1.77s-4.17.11-4.64,1.77Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-5" data-name="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M49.71,8.96l3.12,3.87s-1.39-3.93-3.12-3.87Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 37.67 44.99">
<defs>
<style>
.cls-1 {
fill: #fff;
stroke: #000;
stroke-miterlimit: 10;
stroke-width: 2px;
}
</style>
</defs>
<g id="_Слой_11" data-name="Слой_11">
<path class="cls-1" d="M6.71,43.99V8.86H2.32l6.3-6.67c.7-.74,1.66-1.16,2.68-1.16l14.07-.04c1.5,0,2.93.6,3.97,1.68l5.97,6.18h-4.6l1,35.13H6.71Z"/>
<line class="cls-1" x1="4.21" y1="16.49" x2="33.21" y2="20.49"/>
<line class="cls-1" x1="4.21" y1="20.49" x2="33.21" y2="24.12"/>
<line class="cls-1" x1="4.21" y1="24.12" x2="33.21" y2="27.84"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 731 B

+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 62.13 77.5">
<defs>
<style>
.cls-1 {
fill: #fff;
stroke: #000;
stroke-miterlimit: 10;
stroke-width: 7px;
}
</style>
</defs>
<g id="_Слой_8" data-name="Слой_8">
<circle class="cls-1" cx="31.09" cy="13" r="9.5"/>
<path class="cls-1" d="M3.09,45.5l4.79,9.05c14.4,27.2,34.78,25.73,48.48-3.49l2.6-5.56"/>
<line class="cls-1" x1="31.09" y1="22.5" x2="31.09" y2="71"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 585 B

+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 236 236">
<defs>
<style>
.cls-1 {
fill: #666;
stroke: #000;
stroke-miterlimit: 10;
stroke-width: 3px;
}
</style>
</defs>
<g id="_Слой_22" data-name="Слой_22">
<path class="cls-1" d="M226.29,89.5h-19.67c-7.31,0-10.97-8.84-5.8-14.01l13.91-13.91c3.21-3.21,3.21-8.4,0-11.61l-28.7-28.7c-3.21-3.21-8.4-3.21-11.61,0l-13.91,13.91c-5.17,5.17-14.01,1.51-14.01-5.8V9.71c0-4.53-3.67-8.21-8.21-8.21h-40.58c-4.53,0-8.21,3.67-8.21,8.21v19.67c0,7.31-8.84,10.97-14.01,5.8l-13.91-13.91c-3.21-3.21-8.4-3.21-11.61,0l-28.7,28.7c-3.21,3.21-3.21,8.4,0,11.61l13.91,13.91c5.17,5.17,1.51,14.01-5.8,14.01H9.71c-4.53,0-8.21,3.67-8.21,8.21v40.58c0,4.53,3.67,8.21,8.21,8.21h19.67c7.31,0,10.97,8.84,5.8,14.01l-13.91,13.91c-3.21,3.21-3.21,8.4,0,11.61l28.7,28.7c3.21,3.21,8.4,3.21,11.61,0l13.91-13.91c5.17-5.17,14.01-1.51,14.01,5.8v19.67c0,4.53,3.67,8.21,8.21,8.21h40.58c4.53,0,8.21-3.67,8.21-8.21v-19.67c0-7.31,8.84-10.97,14.01-5.8l13.91,13.91c3.21,3.21,8.4,3.21,11.61,0l28.7-28.7c3.21-3.21,3.21-8.4,0-11.61l-13.91-13.91c-5.17-5.17-1.51-14.01,5.8-14.01h19.67c4.53,0,8.21-3.67,8.21-8.21v-40.58c0-4.53-3.67-8.21-8.21-8.21ZM118.5,158.5c-22.09,0-40-17.91-40-40s17.91-40,40-40,40,17.91,40,40-17.91,40-40,40Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+60
View File
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 137.43 137.43">
<defs>
<style>
.cls-1 {
fill: none;
stroke: #000;
stroke-miterlimit: 10;
}
</style>
</defs>
<g id="_Слой_16" data-name="Слой_16">
<circle class="cls-1" cx="69.22" cy="68.97" r="15.5"/>
<g>
<g id="_x3C_Радиальное_повторение_x3E_">
<g>
<polygon points="50.01 71.14 44.01 70.86 43.99 66.46 49.99 66.14 50.01 71.14"/>
<polygon points="31.34 70.27 18.67 69.68 18.66 67.82 31.33 67.14 31.34 70.27"/>
<polygon points="6 69.09 0 68.82 6 68.49 6 69.09"/>
</g>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-2" data-name="_x3C_Радиальное_повторение_x3E_">
<g>
<polygon points="66.29 50.01 66.57 44.01 70.97 43.99 71.29 49.99 66.29 50.01"/>
<polygon points="67.16 31.34 67.75 18.67 69.62 18.66 70.3 31.33 67.16 31.34"/>
<polygon points="68.34 6 68.62 0 68.94 6 68.34 6"/>
</g>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-3" data-name="_x3C_Радиальное_повторение_x3E_">
<g>
<polygon points="87.43 66.29 93.43 66.57 93.44 70.97 87.44 71.29 87.43 66.29"/>
<polygon points="106.1 67.16 118.76 67.75 118.77 69.62 106.11 70.3 106.1 67.16"/>
<polygon points="131.43 68.34 137.43 68.62 131.44 68.94 131.43 68.34"/>
</g>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-4" data-name="_x3C_Радиальное_повторение_x3E_">
<g>
<polygon points="71.14 87.43 70.86 93.43 66.46 93.44 66.14 87.44 71.14 87.43"/>
<polygon points="70.27 106.1 69.68 118.76 67.82 118.77 67.14 106.11 70.27 106.1"/>
<polygon points="69.09 131.43 68.82 137.43 68.49 131.44 69.09 131.43"/>
</g>
</g>
</g>
<path d="M69.26,70.79c1.93,0,1.93-3,0-3s-1.93,3,0,3h0Z"/>
<g>
<g id="_x3C_Радиальное_повторение_x3E_-5" data-name="_x3C_Радиальное_повторение_x3E_">
<path d="M80.01,134.2c-.42-4.29-.71-8.58-1.01-12.86.01-6.42,0-19.31,0-25.73,0,0,0-3.22,0-3.22v-1.61c-.03-2.95,1.19-5.88,3.31-7.94,1.69-1.64,3.92-2.74,6.27-3.02,2.05-.21,4.54-.04,6.55-.09,0,0,12.86,0,12.86,0h12.86c4.29.34,8.58.59,12.86,1-4.29.41-8.58.66-12.86,1-6.42,0-19.31,0-25.73,0-1.77.05-4.64-.1-6.3.08-1.92.23-3.73,1.12-5.12,2.47-1.74,1.7-2.72,4.08-2.7,6.51,0,0,0,1.61,0,1.61,0,0,0,3.22,0,3.22,0,6.41,0,19.32,0,25.73-.29,4.29-.59,8.58-1.01,12.86h0Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-6" data-name="_x3C_Радиальное_повторение_x3E_">
<path d="M4.26,79.75c4.29-.42,8.58-.71,12.86-1.01,6.42.01,19.31,0,25.73,0,0,0,3.22,0,3.22,0h1.61c2.95-.03,5.88,1.19,7.94,3.31,1.64,1.69,2.74,3.92,3.02,6.27.21,2.05.04,4.54.09,6.55,0,0,0,12.86,0,12.86v12.86c-.34,4.29-.59,8.58-1,12.86-.41-4.29-.66-8.58-1-12.86,0-6.42,0-19.31,0-25.73-.05-1.77.1-4.64-.08-6.3-.23-1.92-1.12-3.73-2.47-5.12-1.7-1.74-4.08-2.72-6.51-2.7,0,0-1.61,0-1.61,0,0,0-3.22,0-3.22,0-6.41,0-19.32,0-25.73,0-4.29-.29-8.58-.59-12.86-1.01h0Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-7" data-name="_x3C_Радиальное_повторение_x3E_">
<path d="M58.72,4c.42,4.29.71,8.58,1.01,12.86-.01,6.42,0,19.31,0,25.73,0,0,0,3.22,0,3.22v1.61c.03,2.95-1.19,5.88-3.31,7.94-1.69,1.64-3.92,2.74-6.27,3.02-2.05.21-4.54.04-6.55.09,0,0-12.86,0-12.86,0h-12.86c-4.29-.34-8.58-.59-12.86-1,4.29-.41,8.58-.66,12.86-1,6.42,0,19.31,0,25.73,0,1.77-.05,4.64.1,6.3-.08,1.92-.23,3.73-1.12,5.12-2.47,1.74-1.7,2.72-4.08,2.7-6.51,0,0,0-1.61,0-1.61,0,0,0-3.22,0-3.22,0-6.41,0-19.32,0-25.73.29-4.29.59-8.58,1.01-12.86h0Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-8" data-name="_x3C_Радиальное_повторение_x3E_">
<path d="M134.46,58.46c-4.29.42-8.58.71-12.86,1.01-6.42-.01-19.31,0-25.73,0,0,0-3.22,0-3.22,0h-1.61c-2.95.03-5.88-1.19-7.94-3.31-1.64-1.69-2.74-3.92-3.02-6.27-.21-2.05-.04-4.54-.09-6.55,0,0,0-12.86,0-12.86v-12.86c.34-4.29.59-8.58,1-12.86.41,4.29.66,8.58,1,12.86,0,6.42,0,19.31,0,25.73.05,1.77-.1,4.64.08,6.3.23,1.92,1.12,3.73,2.47,5.12,1.7,1.74,4.08,2.72,6.51,2.7,0,0,1.61,0,1.61,0,0,0,3.22,0,3.22,0,6.41,0,19.32,0,25.73,0,4.29.29,8.58.59,12.86,1.01h0Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.83 62.87">
<defs>
<style>
.cls-1 {
fill: #fff;
}
</style>
</defs>
<g id="_Слой_10" data-name="Слой_10">
<g>
<path class="cls-1" d="M41.75,62.87V4.78l-3.87,2.32-6.48,3.89c-9.59,5.76-17.51,13.94-22.94,23.72l-6.71,12.07"/>
<path d="M41.56,62.87c0-16.54.05-34.31-.59-50.83-.1-2.42-.17-4.84-1.23-7.26,0,0,3.03,1.71,3.03,1.71-3.66,2.26-9.09,5.36-12.6,7.63-7.99,5.26-14.73,12.41-19.49,20.71,0,0-7.19,12.91-7.19,12.91,0,0-3.5-1.94-3.5-1.94,0,0,7.22-12.97,7.22-12.97,5.07-8.83,12.25-16.45,20.75-22.05,5.53-3.51,12.26-7.38,17.85-10.79,0,0-2.08,4.78-2.08,4.78-1.8,4.72-1.15,9.64-1.49,14.52-.19,8.41-.24,20.51-.33,29.04-.02,4.84-.03,9.68.01,14.52h-.37Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 859 B

+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.83 65.94">
<defs>
<style>
.cls-1, .cls-2 {
fill: #fff;
}
.cls-2 {
stroke: #000;
stroke-miterlimit: 10;
stroke-width: .5px;
}
</style>
</defs>
<g id="_Слой_10" data-name="Слой_10">
<g>
<path class="cls-1" d="M41.75,62.87V4.78l-3.87,2.32-6.48,3.89c-9.59,5.76-17.51,13.94-22.94,23.72l-6.71,12.07"/>
<path d="M41.56,62.87c0-16.54.05-34.31-.59-50.83-.1-2.42-.17-4.84-1.23-7.26,0,0,3.03,1.71,3.03,1.71-3.66,2.26-9.09,5.36-12.6,7.63-7.99,5.26-14.73,12.41-19.49,20.71,0,0-7.19,12.91-7.19,12.91,0,0-3.5-1.94-3.5-1.94,0,0,7.22-12.97,7.22-12.97,5.07-8.83,12.25-16.45,20.75-22.05,5.53-3.51,12.26-7.38,17.85-10.79,0,0-2.08,4.78-2.08,4.78-1.8,4.72-1.15,9.64-1.49,14.52-.19,8.41-.24,20.51-.33,29.04-.02,4.84-.03,9.68.01,14.52h-.37Z"/>
</g>
<circle class="cls-2" cx="41.47" cy="62.28" r="1"/>
<path class="cls-2" d="M41.47,63.28v1.8h0c-.51.74-1.58.81-2.18.14l-.27-.31"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 92.09 199.81">
<defs>
<style>
.cls-1 {
fill: #d2ff1a;
stroke: #a8cc14;
stroke-miterlimit: 10;
stroke-width: 3px;
}
</style>
</defs>
<g id="_Слой_23" data-name="Слой_23">
<path class="cls-1" d="M44.03,168.47c15.02,0,27.22,12.04,27.49,27l15.45,2.61,3.19-23.41c.89-6.53.41-13.17-1.39-19.5L46.03,5.47,3.33,155.13c-1.81,6.36-2.28,13.02-1.38,19.57l3.2,23.26,11.4-2.5c.27-14.96,12.47-27,27.49-27Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 608 B

+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50.57 51.98">
<defs>
<style>
.cls-1, .cls-2 {
fill: #fff;
stroke: #000;
stroke-miterlimit: 10;
}
.cls-2 {
stroke-width: 2px;
}
</style>
</defs>
<g id="_Слой_12" data-name="Слой_12">
<polyline class="cls-2" points="21.49 2.23 21.49 22.58 21.79 51.97"/>
<line class="cls-2" x1=".79" y1="41.97" x2="50.57" y2="41.97"/>
<polygon class="cls-1" points="25.68 2.23 25.68 38.03 48.81 38.03 25.68 2.23"/>
<polygon class="cls-1" points="17.7 2.23 17.7 38.03 .79 38.03 17.7 2.23"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 711 B

+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 62.13 77.5">
<defs>
<style>
.cls-1 {
fill: #fff;
stroke: #000;
stroke-miterlimit: 10;
stroke-width: 7px;
}
</style>
</defs>
<g id="_Слой_8" data-name="Слой_8">
<circle class="cls-1" cx="31.09" cy="13" r="9.5"/>
<path class="cls-1" d="M3.09,45.5l4.79,9.05c14.4,27.2,34.78,25.73,48.48-3.49l2.6-5.56"/>
<line class="cls-1" x1="31.09" y1="22.5" x2="31.09" y2="71"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 585 B

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 165.04 282">
<g id="_Слой_5" data-name="Слой_5">
<polygon points="120.41 4.5 43.14 4.5 4.5 92.81 4.5 277.5 160.5 277.5 159.04 92.81 120.41 4.5" style="fill: none; stroke: #000; stroke-miterlimit: 10; stroke-width: 9px;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 381 B

+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 91.38 162.6">
<g id="_Слой_2-2" data-name="Слой_2">
<polygon points="45.69 16.63 5.94 158.1 85.44 158.1 45.69 16.63" style="fill: none; stroke: #000; stroke-miterlimit: 10; stroke-width: 9px;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 354 B

+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88.5 152.73">
<defs>
<style>
.cls-1 {
fill: none;
stroke: #000;
stroke-miterlimit: 10;
stroke-width: 9px;
}
</style>
</defs>
<g id="_Слой_7" data-name="Слой_7">
<polygon class="cls-1" points="44.25 6.77 4.5 51.46 4.5 148.23 84 148.23 84 51.46 44.25 6.77"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 472 B

+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 62.13 77.5">
<defs>
<style>
.cls-1 {
fill: #fff;
stroke: #000;
stroke-miterlimit: 10;
stroke-width: 7px;
}
</style>
</defs>
<g id="_Слой_8" data-name="Слой_8">
<circle class="cls-1" cx="31.09" cy="13" r="9.5"/>
<path class="cls-1" d="M3.09,45.5l4.79,9.05c14.4,27.2,34.78,25.73,48.48-3.49l2.6-5.56"/>
<line class="cls-1" x1="31.09" y1="22.5" x2="31.09" y2="71"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 585 B

+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 129 129">
<defs>
<style>
.cls-1 {
fill: none;
stroke: #000;
stroke-miterlimit: 10;
stroke-width: 2px;
}
</style>
</defs>
<g id="_Слой_15" data-name="Слой_15">
<g>
<g id="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M62.39,72.13L12.02,122.94c-1.86,1.87-.53,5.06,2.11,5.06h100.75c2.64,0,3.97-3.19,2.11-5.06l-50.38-50.81c-1.16-1.17-3.06-1.17-4.22,0Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-2" data-name="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M56.87,62.39L6.06,12.02c-1.87-1.86-5.06-.53-5.06,2.11v100.75c0,2.64,3.19,3.97,5.06,2.11l50.81-50.38c1.17-1.16,1.17-3.06,0-4.22Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-3" data-name="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M66.61,56.87L116.98,6.06c1.86-1.87.53-5.06-2.11-5.06H14.12c-2.64,0-3.97,3.19-2.11,5.06l50.38,50.81c1.16,1.17,3.06,1.17,4.22,0Z"/>
</g>
<g id="_x3C_Радиальное_повторение_x3E_-4" data-name="_x3C_Радиальное_повторение_x3E_">
<path class="cls-1" d="M72.13,66.61l50.81,50.38c1.87,1.86,5.06.53,5.06-2.11V14.12c0-2.64-3.19-3.97-5.06-2.11l-50.81,50.38c-1.17,1.16-1.17,3.06,0,4.22Z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+30
View File
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 107 127.1">
<defs>
<style>
.cls-1 {
fill: #fff;
}
.cls-1, .cls-2, .cls-3 {
stroke: #000;
stroke-miterlimit: 10;
stroke-width: 2px;
}
.cls-2 {
fill: none;
}
.cls-3 {
fill: red;
}
</style>
</defs>
<g id="_Слой_13" data-name="Слой_13">
<line class="cls-1" y1="115.1" x2="43" y2="115.1"/>
<line class="cls-1" x1="64" y1="115.1" x2="107" y2="115.1"/>
<path class="cls-3" d="M95,8.1l-12.08-6.7-35.92,80.7L15,115.1s11.69-.03,28,0c0,0,1-10,11-10,9,0,10,10,10,10,9.54.02,15.06,0,15,0l-5-29L95,8.1Z"/>
<circle class="cls-2" cx="53.5" cy="115.6" r="10.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 828 B

+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 66.46 176.77">
<defs>
<style>
.cls-1 {
fill: silver;
stroke: #f3f3f3;
}
.cls-1, .cls-2 {
stroke-miterlimit: 10;
}
.cls-2 {
fill: #e32636;
stroke: #961923;
}
</style>
</defs>
<g id="_Слой_24" data-name="Слой_24">
<path class="cls-2" d="M33.73,64.39c12.24,0,22.33,9.16,23.81,21h8.19L33.23,1.39.73,85.39h9.19c1.48-11.84,11.57-21,23.81-21Z"/>
<path class="cls-1" d="M57.04,91.39c-1.48,11.84-11.57,21-23.81,21s-22.33-9.16-23.81-21H.73l32.5,84,32.5-84h-8.69Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 709 B

+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.83 65.94">
<defs>
<style>
.cls-1, .cls-2 {
fill: #fff;
}
.cls-2 {
stroke: #000;
stroke-miterlimit: 10;
stroke-width: .5px;
}
</style>
</defs>
<g id="_Слой_10" data-name="Слой_10">
<g>
<path class="cls-1" d="M41.75,62.87V4.78l-3.87,2.32-6.48,3.89c-9.59,5.76-17.51,13.94-22.94,23.72l-6.71,12.07"/>
<path d="M41.56,62.87c0-16.54.05-34.31-.59-50.83-.1-2.42-.17-4.84-1.23-7.26,0,0,3.03,1.71,3.03,1.71-3.66,2.26-9.09,5.36-12.6,7.63-7.99,5.26-14.73,12.41-19.49,20.71,0,0-7.19,12.91-7.19,12.91,0,0-3.5-1.94-3.5-1.94,0,0,7.22-12.97,7.22-12.97,5.07-8.83,12.25-16.45,20.75-22.05,5.53-3.51,12.26-7.38,17.85-10.79,0,0-2.08,4.78-2.08,4.78-1.8,4.72-1.15,9.64-1.49,14.52-.19,8.41-.24,20.51-.33,29.04-.02,4.84-.03,9.68.01,14.52h-.37Z"/>
</g>
<circle class="cls-2" cx="41.47" cy="62.28" r="1"/>
<path class="cls-2" d="M41.47,63.28v1.8h0c-.51.74-1.58.81-2.18.14l-.27-.31"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+132
View File
@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<defs>
<style>
.cls-1 {
fill: #d6ec5b;
}
.cls-2 {
fill: #d8ed5d;
}
.cls-3 {
fill: #fff;
}
.cls-4 {
stroke-dasharray: 4.83 4.83;
}
.cls-4, .cls-5, .cls-6, .cls-7 {
fill: none;
stroke: #fff;
stroke-miterlimit: 10;
}
.cls-8 {
fill: #d8ee5d;
}
.cls-9 {
fill: #fefdea;
}
.cls-10 {
fill: #d4ea59;
}
.cls-11 {
fill: #d5eb5a;
}
.cls-12 {
fill: #dcf160;
}
.cls-13 {
fill: #fefce9;
}
.cls-14 {
fill: #dbf15f;
}
.cls-6 {
stroke-dasharray: 10.74 10.74;
}
.cls-7 {
stroke-width: 2px;
}
.cls-15 {
fill: #b1cc36;
}
.cls-16 {
fill: #bcd542;
}
.cls-17 {
fill: #fdfce9;
}
.cls-18 {
fill: #d8ed5c;
}
</style>
</defs>
<g id="_Слой_4" data-name="Слой_4">
<rect class="cls-16" x="99.21" y="86.8" width="850.39" height="850.39"/>
<g id="_Слой_2-2">
<polygon class="cls-7" points="750.88 341.78 744.68 354.12 752.02 355.55 750.88 341.78"/>
</g>
</g>
<g id="_Слой_3" data-name="Слой_3">
<path class="cls-11" d="M755.8,302.69c-2.2,8.2-70.6,228.5-72.1,233-4.9-1.2-9.9-2.5-14.8-3.7-.9-.2-1.7-.4-2.6-.7-1.6-.4-3.2-.8-4.8-1.2-5.6-1.5-10.7-1.8-16.5-1.8h-2.8c-12,0-21.8,4.8-32.1,10.5-14.5,8-29.4,12.9-45.9,8.4-11.9-4.1-18.7-11.3-25.3-21.8-1.9-2.9-3.9-5.8-5.9-8.7-.7-1-1.4-2-2.1-3.1-14.7-20.4-36.9-25.8-59.8-32.3-8.4-2.4-23.6-11.6-24.2-12-13.3-10.1-19.4-28.1-25-43.1-10.8-28.9-27.3-50.3-55.1-64.6,2.3-2.5,30.3-18.4,37.9-25,.7-.6,1.3-1.1,2-1.7,10.6-9.6,16.7-24.2,23.1-36.7,9.5-18.5,21.6-34.2,41.9-41.6,6.1-1.7,12.2-2.7,18.4-3.7,19-2.9,34.8-9.5,48.6-23.3.6-.6,1.1-1.1,1.7-1.7,5.7-6.1,9.7-13.8,13.3-21.3,8.6,6.7,12.7,14.1,16.9,23.9,5.9,13.7,14.6,23.7,28.1,30.1,4.2,1.5,153.8,41.2,157.1,42.1Z"/>
<path class="cls-11" d="M648.7,255.59v2c-7.7-1.4-15.3-3.2-22.9-5.2-1.3-.3-2.6-.7-3.9-1-15.3-3.8-28-8.8-36.5-22.6-1.8-3.3-3.2-6.7-4.7-10.2-2.7-6.7-6.3-11.5-11.1-16.9-1.3-1.6-2.6-3.2-3.9-4.8-.6-.7-1.1-1.4-1.7-2.1-4-5.8-4.2-11.3-3.4-18.1,1.2-5.6,2.8-11.1,4.3-16.6,1-3.7,1.9-7.4,2.7-11.2,1.2-5.3,2.5-10.5,3.9-15.7,1.8-6.5,3.3-13.1,4.7-19.7.8-3.73,1.57-6.33,2.3-7.8h.3c28.3,0,56.6,0,84.9-.1h115.4c38.6-.1,71.8,9.9,99.7,37.2.7.6,1.3,1.2,2,1.9,5,4.7,9.8,10.1,13,16.1-.7,5.5-3,10.6-5.1,15.8-.4,1-.8,2.03-1.2,3.1-3.4,8.6-7.3,16.9-11.7,25.1-.5.9-1,1.9-1.5,2.8-3.7,6.5-8.3,11.8-13.5,17.2-.6.67-1.23,1.3-1.9,1.9-9.4,9-20.3,12.4-32.7,14.7-24.2,4.4-43.4,16.2-57.5,36.4-2.1,3.3-4.1,6.6-6,10-4.3-.4-8.1-.9-12.1-2.4-5.2-1.8-10.6-3.1-16-4.3-1-.2-1.9-.4-2.9-.7-2.3-.5-4.7-1.1-7-1.6"/>
<path class="cls-10" d="M770.8,306.69c9.8,2.4,96.5,26,100.1,27.1,10,2.8,41.69,10.9,45.19,11.8.2,22.8,0,159.9,0,161.6.13,8.53-.26,14.2-1.99,17-1.7,1.8-3.4,3.1-5.5,4.5-.9.7-1.7,1.5-2.6,2.3-2.8,2.3-5.6,4.5-8.4,6.7-.6.5-1.1.9-1.7,1.4-13.8,11.13-24.9,16.33-33.3,15.6-12.1-2.1-19.5-13.7-26.3-22.8-9.2-12.2-19.4-20.8-34.7-24.2-19.2-2.3-33.7,2.8-49,14-1.9,1.7-3.6,3.4-5.3,5.2-9.2,9.5-19.7,15.8-33.1,16-5.5,0-10.3-.8-15.6-2.2.5-7.4,69.3-223.7,72-233.9l.2-.1Z"/>
<path class="cls-18" d="M578.8,105.69c28.3,0,198.2-.1,200.3-.1,38.6-.1,71.8,9.9,99.7,37.2.67.6,1.33,1.23,2,1.9,5,4.7,9.8,10.1,13,16.1-.7,5.5-3,10.6-5.1,15.8-.4,1-.8,2-1.2,3.1-3.4,8.6-7.3,16.9-11.7,25.1-.5.9-1,1.9-1.5,2.8-3.7,6.5-8.3,11.8-13.5,17.2-.6.67-1.23,1.3-1.9,1.9-9.4,9-20.3,12.4-32.7,14.7-24.2,4.4-43.4,16.2-57.5,36.4-2.1,3.3-4.1,6.6-6,10-4.2-.4-8-.9-11.9-2.3-5.3-1.9-117.5-30.7-125.1-32.7-1.3-.3-31.9-9.8-40.4-23.6-1.8-3.3-3.2-6.7-4.7-10.2-2.7-6.7-6.3-11.5-11.1-16.9-1.3-1.6-5-6.2-5.6-6.9-4-5.8-4.2-11.3-3.4-18.1,1.2-5.6,17.17-69.53,17.9-71l.4-.4Z"/>
<path class="cls-2" d="M338.8,105.69h227c-1.3,5.3-5.2,20.93-5.8,23.4-2.1,8.4-4.3,16.7-6.7,25-2.7,9.3-5.2,18.7-7.6,28.1-5.7,21.5-12.8,37.3-32.5,49.8-8.6,4.5-17.8,6.9-27.5,7.4-14.5,1.5-32.6,7.7-42.9,18.4-2.3,2.3-3.9,3.7-6.9,4.9-6.3-.4-132-33.67-133-34,.6-5.4,34.9-120,35.8-122.8l.1-.2Z"/>
<path class="cls-1" d="M107.7,366.69c0-18.4-.2-111.3-.2-115.2-.1-18.8.1-36.8,6.4-54.8h-.1c12.3,2.6,46.6,11.4,51.7,12.8,1,.3,84.3,21.8,91.4,23.4,8.4,1.9,16.7,4.3,24.9,6.8-.7,6.8-45.7,157-51,175-12.6-1.7-25.4-15-32.9-24.8-.5-.7-1.1-1.5-1.7-2.2-1.9-2.6-4.1-4.8-6.5-7-.6-.6-1.3-1.2-2-1.9-10.3-9.6-22.7-16.6-37-17.1-1.1,0-2.2-.1-3.3-.2-13.5-.3-26.7,1.4-39.7,5.2"/>
<path class="cls-8" d="M246,105.39c2.2,0,64.3,0,75.8.1-.6,2.6-32.5,110.1-34.9,117.9-6-.6-157.2-39.2-166.8-41.9,4.9-12.5,12.3-23.9,21.7-33.6,1.6-1.6,3.13-3.37,4.6-5.3,3.1-3.8,6.8-6.6,10.7-9.5.8-.6,1.5-1.1,2.3-1.7,12.1-9,24.5-16.2,38.9-20.5.6-.2,1.3-.4,1.9-.6,15.5-4.5,30.1-5.2,46.1-5.1l-.3.2Z"/>
<path class="cls-1" d="M297.8,243.69c.9.2,1.7.4,2.6.7,6.4,1.6,115.7,30.5,116.8,30.7,1.9.5,3.8,1.1,5.6,1.7-.9,6.4-4.2,11.9-7,17.6l-3,6c-9.9,19.9-22.2,32.6-42.5,41.8-18,8.2-33.8,22.3-46.6,37.4-1.8,2-3.6,3.9-5.4,5.8-.7.7-7.1,7.1-9.7,9.7l-8.2,8.2c-14.6,13.9-34.7,14.9-53.6,14.5h-1.9c1.8-6.7,52.5-171.4,53-174.1h-.1Z"/>
<path class="cls-1" d="M902.8,181.69h2c1.1,2.3,2.2,4.6,3.2,6.9.33.6.63,1.23.9,1.9,7.6,16.6,7.39,34.1,7.19,52,0,.44,0,3.03,0,7.06-.03,18.76.08,68.88,0,80.24-1.7-.3-136.96-36.57-138.29-37.9,10-16.3,22.7-29.7,41.9-34.8,4.1-.9,8.3-1.7,12.5-2.5,19.7-3.7,37.4-13.1,49.4-29.5,8.8-13.6,15-28.4,21.2-43.2v-.2Z"/>
<path class="cls-14" d="M458.8,263.69l2,1c-.67.53-1.33,1.07-2,1.6-6.7,5.5-13,11.3-19,17.4,0-4.2,1.9-5.7,4.6-8.7,4.4-4.2,9.4-7.7,14.4-11.3h0Z"/>
<path class="cls-12" d="M553.8,206.69l2,1c-1.5,3.1-3.2,6.1-5,9-.3-.7-.7-1.3-1-2,.53-1.27,1.17-2.63,1.9-4.1.73-1.47,1.13-2.2,1.2-2.2.3-.6.6-1.1.9-1.7h0ZM548.8,216.69l1,2-3,3c.7-1.6,1.3-3.3,2-5Z"/>
<g>
<path class="cls-5" d="M747.72,356.89c-.3,1.57-.67,3.38-1.13,5.38"/>
<path class="cls-6" d="M743.87,372.66c-.7,2.38-1.51,4.9-2.42,7.52-5.47,15.71-10.57,22.75-15.46,34.24-4.06,9.54-6.01,21.12-8.46,33.94"/>
<path class="cls-5" d="M716.52,453.63c-.1.49-.2.99-.3,1.48-.33,1.65-.6,3-.78,3.91"/>
</g>
</g>
<g id="_Слой_5" data-name="Слой_5">
<path class="cls-9" d="M531.76,588.64c7.39,5.63,12.6,13.02,14.46,22.21.21,1.96.26,3.87.21,5.78v1.55c-.15,7.23-2.43,13.17-5.84,19.42-.57,1.08-1.14,2.17-1.7,3.25-3.2,5.99-6.97,11.57-10.85,17.15-1.08,1.55-2.17,3.15-3.2,4.7-2.07,3-4.13,5.99-6.2,8.94-.21.31-.41.62-.62.93-.98,1.39-1.96,2.69-3.25,3.82-1.76-.57-1.96-.93-3-2.43-.28-.41-.55-.83-.83-1.24-.31-.46-.62-.88-.93-1.34-.67-.93-1.29-1.91-1.96-2.84-.36-.52-.67-.98-1.03-1.5-1.03-1.5-2.12-3.05-3.15-4.55-16.12-22.97-23.38-40.2-21.8-51.7,1.14-6.04,3.56-10.8,7.39-15.55.26-.31.46-.67.72-.98,9.4-12.34,29.18-13.69,41.63-5.73l-.05.1Z"/>
<path class="cls-16" d="M521.95,603.62c3.41,2.22,5.84,5.37,7.02,9.3.88,4.65-.05,8.99-2.53,12.96-2.22,2.89-5.53,4.96-9.14,5.63-5.01.57-8.73-.26-12.91-3.1-3.2-2.63-4.86-6.35-5.37-10.43-.21-4.96,1.14-8.52,4.39-12.19,5.53-5.06,11.98-5.48,18.54-2.17h0Z"/>
</g>
<g id="_Слой_2" data-name="Слой_2">
<path class="cls-13" d="M219.47,612.1c-11.99-6.07-27.68-7.1-40.54-3.79-4.5,1.5-8.68,3.63-12.78,6.07-5.13,3-10.09,4.02-16.01,3.79l-20.95-42.86-2.63-10.76,137.62-.32c16.96,0,28.79-3.55,42.19-14.27,16.01-12.7,34.94-19.01,55.13-20.74.79,0,19.01-.95,27.37-.95l-.08-.08h17.59c.58,0,1.87.58,3.86,1.74-.32,4.97-6.23,14.59-7.02,15.85-.39.63-7.33,12.62-10.25,18.06-.39.71-18.3,36.67-26.03,54.42-4.5.16-7.97,0-12.15-1.74-.87-.39-7.1-3.15-9.31-4.34-12.38-6.39-27.76-6.86-41.01-2.92-5.52,1.81-10.65,4.57-15.69,7.49-9.15,5.13-19.48,5.68-29.65,5.68h-2.84c-9.23-.08-17.9-1.66-26.34-5.36l-10.49-4.97Z"/>
<path class="cls-17" d="M220.18,454.21h16.56v33.52c3.89-.05,43.06,0,44.24,0,7.26-.16,12.54.16,18.06,5.21.47.39,1.03.87,1.5,1.26,10.17,8.91,16.96,25.63,21.37,38.17-.95.32-1.89.55-2.84.87-10.65,3.63-19.01,9.62-27.92,16.17-9.7,7.1-19.56,9.54-31.55,9.38-1.03,0-83.04-.08-96.21-.16v-14.2h20.5c0-3.08.08-35.73,0-36.67,0-5.68.32-8.99,4.18-13.25,4.65-4.5,8.52-6.55,15.06-6.78,2.05,0,4.1-.08,6.15,0,3.63.08,7.26-.16,10.96,0v-33.28l-.08-.24Z"/>
<path class="cls-17" d="M214.81,622.51c4.18,1.81,14.2,5.6,15.06,5.91,19.4,7.33,42.43,7.41,61.67-.63,3.39-1.58,6.7-3.23,10.02-4.97,10.88-5.44,23.26-6.55,34.86-2.84,3.63,1.26,7.18,2.68,10.73,4.26,13.96,5.91,30.76,6.15,44.95,1.1,3.08-1.26,11.83-5.78,13.41-6.31.63,5.26.32,8.96-.95,11.12-9.7,9.15-24.05,12.93-37.07,12.78-9.78-.32-17.82-3-26.81-6.86-12.07-5.13-22.16-5.99-34.62-1.1-3.23,1.34-6.39,2.76-9.54,4.18-4.81,2.13-16.9,5.68-17.9,5.99-5.68,1.26-18.61,1.03-19.24,1.03-9.7,0-29.42-3.79-30.05-4.02-5.91-1.81-11.51-4.1-17.11-6.78-11.28-5.36-22.16-4.65-33.91-.63-1.74.79-3.47,1.58-5.21,2.44-12.7,6.07-29.26,7.18-42.67,2.52-4.89-1.89-14.83-7.97-15.38-8.2-.89-.58-1.92-1.6-3.08-3.08-.39-3.15-.24-6.23,0-9.46,2.84,1.1,5.52,2.29,8.28,3.55,14.83,6.7,30.36,8.2,46.14,3.55,3.15-1.26,6.15-2.68,9.15-4.26,12.3-5.84,26.89-4.73,39.12.55l.16.16Z"/>
<path class="cls-15" d="M241.31,511.23c1.5,2.29,1.03,13.56,0,15.69-3.08,3-24.5,1.21-25.71,0-1.74-2.84-.63-14.8,0-15.69,2.21-1.5,24.76-.68,25.71,0Z"/>
<path class="cls-15" d="M285.42,511.23c1.5,2.29,1.03,13.56,0,15.69-3.08,3-24.5,1.21-25.71,0-1.74-2.84-.63-14.8,0-15.69,2.21-1.5,24.76-.68,25.71,0Z"/>
</g>
<g id="_Слой_6" data-name="Слой_6">
<g>
<path class="cls-3" d="M431.23,700.66l22.72,175.86h-40.75l-4.69-43.22h-22.23l-6.67,43.22h-34.58l29.89-175.86h56.31ZM405.54,806.62l-7.16-78.3h-.49l-7.16,78.3h14.82Z"/>
<path class="cls-3" d="M491.5,876.52v-175.86h41v175.86h-41Z"/>
<path class="cls-3" d="M635.25,763.65v-24.7c0-8.65-2.72-13.58-10.87-13.58-8.89,0-10.87,4.94-10.87,13.58,0,29.64,62.74,38.28,62.74,92.62,0,33.1-17.78,48.41-52.12,48.41-26.18,0-51.62-8.89-51.62-39.27v-32.6h41v30.38c0,10.37,3.21,13.34,10.87,13.34,6.67,0,10.87-2.96,10.87-13.34,0-39.77-62.74-40.51-62.74-97.32,0-31.86,21-43.96,52.61-43.96,27.66,0,51.13,9.39,51.13,38.78v27.66h-41Z"/>
</g>
</g>
<g id="_Слой_8" data-name="Слой_8">
<g id="_Слой_7-2" data-name="_Слой_7">
<path class="cls-7" d="M418.41,308.67l-5,1.22-4.73,6.85s2.65,1.83,2.66,1.84c.01.01,2.95,2.04,2.97,2.05,1.58-2.28,3.16-4.57,4.73-6.85l-.63-5.11Z"/>
</g>
<g>
<path class="cls-5" d="M410.61,320.62c-.46.6-.97,1.25-1.55,1.96"/>
<path class="cls-4" d="M405.87,326.2c-2.11,2.24-4.69,4.7-7.78,7.14-10,7.88-15.36,7.7-29.35,16.24-12.46,7.61-18.69,11.42-20.15,18-1.32,5.94,1.49,10.94-1.96,18.39-.53,1.15-1.13,2.18-1.74,3.09"/>
<path class="cls-5" d="M343.44,390.99c-.61.73-1.19,1.34-1.7,1.83"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50.57 51.98">
<defs>
<style>
.cls-1, .cls-2 {
fill: #fff;
stroke: #000;
stroke-miterlimit: 10;
}
.cls-2 {
stroke-width: 2px;
}
</style>
</defs>
<g id="_Слой_12" data-name="Слой_12">
<polyline class="cls-2" points="21.49 2.23 21.49 22.58 21.79 51.97"/>
<line class="cls-2" x1=".79" y1="41.97" x2="50.57" y2="41.97"/>
<polygon class="cls-1" points="25.68 2.23 25.68 38.03 48.81 38.03 25.68 2.23"/>
<polygon class="cls-1" points="17.7 2.23 17.7 38.03 .79 38.03 17.7 2.23"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 711 B

+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 233.17 233.87">
<defs>
<style>
.cls-1 {
fill: #fff;
font-family: Roboto-Black, Roboto;
font-size: 31px;
font-weight: 800;
}
.cls-2 {
fill: none;
}
.cls-3 {
stroke: #000;
stroke-miterlimit: 10;
}
.cls-4 {
clip-path: url(#clippath);
}
</style>
<clipPath id="clippath">
<rect class="cls-2" width="233.17" height="233.87"/>
</clipPath>
</defs>
<g id="_Слой_17" data-name="Слой_17">
<g class="cls-4">
<g>
<rect class="cls-3" x=".5" y=".5" width="233" height="53" rx="26.2" ry="26.2"/>
<rect class="cls-3" x=".5" y="60.39" width="233" height="53" rx="26.2" ry="26.2"/>
<rect class="cls-3" x=".5" y="120.28" width="233" height="53" rx="26.2" ry="26.2"/>
<rect class="cls-3" x=".5" y="180.17" width="233" height="53" rx="26.2" ry="26.2"/>
</g>
</g>
<text class="cls-1" transform="translate(78.07 36.01)"><tspan x="0" y="0">MMSI</tspan></text>
<text class="cls-1" transform="translate(80.32 95.1)"><tspan x="0" y="0">COG</tspan></text>
<text class="cls-1" transform="translate(81.32 157.1)"><tspan x="0" y="0">SOG</tspan></text>
<text class="cls-1" transform="translate(98.98 208.1)"><tspan x="0" y="0">...</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_Слой_2" data-name="Слой_2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 233.17 233.87">
<defs>
<style>
.cls-1 {
fill: #fff;
}
.cls-2 {
fill: none;
}
.cls-3 {
stroke: #000;
stroke-miterlimit: 10;
}
.cls-4 {
clip-path: url(#clippath);
}
</style>
<clipPath id="clippath">
<rect class="cls-2" width="233.17" height="233.87"/>
</clipPath>
</defs>
<g id="_Слой_17" data-name="Слой_17">
<g class="cls-4">
<g>
<rect class="cls-3" x=".5" y=".5" width="233" height="53" rx="26.2" ry="26.2"/>
<rect class="cls-3" x=".5" y="60.39" width="233" height="53" rx="26.2" ry="26.2"/>
<rect class="cls-3" x=".5" y="120.28" width="233" height="53" rx="26.2" ry="26.2"/>
<rect class="cls-3" x=".5" y="180.17" width="233" height="53" rx="26.2" ry="26.2"/>
</g>
</g>
<g>
<path class="cls-1" d="M89.21,29.73h.09l4.98-15.68h6.77v22.41h-5.12v-14.44l-.09-.02-4.86,14.45h-3.44l-4.77-14.24-.09.02v14.22h-5.12V14.05h6.71l4.93,15.68Z"/>
<path class="cls-1" d="M116.2,29.73h.09l4.98-15.68h6.77v22.41h-5.12v-14.44l-.09-.02-4.86,14.45h-3.44l-4.77-14.24-.09.02v14.22h-5.12V14.05h6.71l4.93,15.68Z"/>
<path class="cls-1" d="M142.92,30.52c0-.8-.26-1.41-.77-1.84s-1.45-.88-2.8-1.36c-2.73-.9-4.77-1.86-6.09-2.89-1.33-1.02-1.99-2.49-1.99-4.41s.77-3.4,2.32-4.56c1.54-1.16,3.51-1.74,5.89-1.74,2.51,0,4.54.6,6.07,1.79,1.53,1.2,2.28,2.88,2.23,5.06l-.03.09h-4.96c0-1.06-.28-1.82-.85-2.3-.57-.48-1.42-.72-2.56-.72-.93,0-1.66.23-2.19.69-.54.46-.8,1.03-.8,1.71s.27,1.18.83,1.58c.55.4,1.58.89,3.08,1.48,2.55.77,4.49,1.71,5.8,2.82,1.31,1.11,1.97,2.63,1.97,4.56s-.76,3.51-2.27,4.62-3.52,1.67-6.02,1.67-4.6-.6-6.33-1.79c-1.73-1.2-2.57-3.08-2.52-5.64l.03-.09h4.98c0,1.3.32,2.23.95,2.78.63.55,1.6.82,2.9.82,1.07,0,1.87-.22,2.39-.65.52-.43.79-1,.79-1.69Z"/>
<path class="cls-1" d="M156.24,36.46h-5.1V14.05h5.1v22.41Z"/>
</g>
<g>
<path class="cls-1" d="M104.15,87.67l.03.09c.04,2.51-.67,4.42-2.13,5.71-1.46,1.3-3.52,1.94-6.2,1.94s-4.91-.83-6.56-2.5c-1.65-1.67-2.48-3.84-2.48-6.54v-4.6c0-2.68.79-4.86,2.38-6.53,1.59-1.67,3.69-2.51,6.3-2.51,2.79,0,4.96.65,6.49,1.95,1.53,1.3,2.27,3.19,2.23,5.68l-.05.09h-4.98c0-1.35-.29-2.32-.86-2.91-.58-.58-1.52-.88-2.83-.88-1.15,0-2.03.46-2.65,1.39-.62.92-.92,2.15-.92,3.69v4.63c0,1.54.34,2.78,1.01,3.71.68.93,1.64,1.39,2.91,1.39,1.17,0,2.02-.29,2.54-.88.52-.58.78-1.56.78-2.94h4.98Z"/>
<path class="cls-1" d="M125.32,86.07c0,2.71-.86,4.95-2.58,6.71-1.72,1.76-3.97,2.64-6.74,2.64s-5.06-.88-6.8-2.64c-1.74-1.76-2.6-4-2.6-6.71v-3.97c0-2.7.87-4.94,2.6-6.71,1.73-1.77,3.99-2.65,6.77-2.65s5.02.88,6.75,2.65c1.74,1.77,2.6,4,2.6,6.71v3.97ZM120.22,82.07c0-1.57-.37-2.87-1.11-3.88-.74-1.01-1.79-1.51-3.14-1.51s-2.44.5-3.17,1.51c-.73,1-1.1,2.3-1.1,3.88v4c0,1.59.37,2.9,1.11,3.91.74,1.01,1.8,1.51,3.19,1.51s2.38-.5,3.12-1.51c.74-1.01,1.1-2.31,1.1-3.91v-4Z"/>
<path class="cls-1" d="M145.85,92.06c-.77.93-1.85,1.72-3.24,2.38-1.39.66-3.18.98-5.37.98-2.73,0-4.96-.84-6.66-2.51-1.71-1.67-2.56-3.85-2.56-6.52v-4.6c0-2.65.83-4.82,2.49-6.51,1.66-1.69,3.8-2.53,6.41-2.53,2.82,0,4.94.63,6.38,1.88,1.44,1.26,2.13,2.97,2.08,5.15l-.03.09h-4.8c0-1.08-.29-1.88-.86-2.41-.58-.52-1.44-.79-2.6-.79s-2.15.47-2.88,1.41-1.09,2.16-1.09,3.66v4.63c0,1.53.36,2.77,1.08,3.7.72.93,1.73,1.4,3.03,1.4.94,0,1.68-.08,2.22-.23.54-.15.97-.35,1.28-.61v-3.94h-3.91v-3.39h9.02v8.73Z"/>
</g>
<g>
<path class="cls-1" d="M98.85,151.26c0-.79-.26-1.39-.77-1.81-.51-.42-1.45-.87-2.8-1.34-2.73-.89-4.77-1.83-6.09-2.84-1.33-1-1.99-2.45-1.99-4.34s.77-3.34,2.32-4.48c1.54-1.14,3.51-1.71,5.89-1.71,2.51,0,4.54.59,6.07,1.76,1.53,1.18,2.28,2.83,2.23,4.97l-.03.09h-4.96c0-1.04-.28-1.79-.85-2.26-.57-.47-1.42-.7-2.56-.7-.93,0-1.66.23-2.19.68-.54.45-.8,1.01-.8,1.68s.27,1.16.83,1.55c.55.39,1.58.88,3.08,1.46,2.55.76,4.49,1.68,5.8,2.77,1.31,1.09,1.97,2.58,1.97,4.48s-.76,3.45-2.27,4.55c-1.51,1.09-3.52,1.64-6.02,1.64s-4.6-.59-6.33-1.76c-1.73-1.18-2.57-3.02-2.52-5.55l.03-.09h4.98c0,1.28.32,2.19.95,2.73.63.54,1.6.81,2.9.81,1.07,0,1.87-.21,2.39-.64.52-.42.79-.98.79-1.67Z"/>
<path class="cls-1" d="M125.07,148.07c0,2.71-.86,4.95-2.58,6.71-1.72,1.76-3.97,2.64-6.74,2.64s-5.06-.88-6.8-2.64c-1.74-1.76-2.6-4-2.6-6.71v-3.97c0-2.7.87-4.94,2.6-6.71,1.73-1.77,3.99-2.65,6.77-2.65s5.02.88,6.75,2.65c1.74,1.77,2.6,4,2.6,6.71v3.97ZM119.97,144.07c0-1.57-.37-2.87-1.11-3.88-.74-1.01-1.79-1.51-3.14-1.51s-2.44.5-3.17,1.51c-.73,1-1.1,2.3-1.1,3.88v4c0,1.59.37,2.9,1.11,3.91.74,1.01,1.8,1.51,3.19,1.51s2.38-.5,3.12-1.51c.74-1.01,1.1-2.31,1.1-3.91v-4Z"/>
<path class="cls-1" d="M145.59,154.06c-.77.93-1.85,1.72-3.24,2.38-1.39.66-3.18.98-5.37.98-2.73,0-4.96-.84-6.66-2.51-1.71-1.67-2.56-3.85-2.56-6.52v-4.6c0-2.65.83-4.82,2.49-6.51,1.66-1.69,3.8-2.53,6.41-2.53,2.82,0,4.94.63,6.38,1.88,1.44,1.26,2.13,2.97,2.08,5.15l-.03.09h-4.8c0-1.08-.29-1.88-.86-2.41-.58-.52-1.44-.79-2.6-.79s-2.15.47-2.88,1.41-1.09,2.16-1.09,3.66v4.63c0,1.53.36,2.77,1.08,3.7.72.93,1.73,1.4,3.03,1.4.94,0,1.68-.08,2.22-.23.54-.15.97-.35,1.28-.61v-3.94h-3.91v-3.39h9.02v8.73Z"/>
</g>
<g>
<path class="cls-1" d="M104.08,208.1h-5.1v-4.3h5.1v4.3Z"/>
<path class="cls-1" d="M113.45,208.1h-5.1v-4.3h5.1v4.3Z"/>
<path class="cls-1" d="M122.82,208.1h-5.1v-4.3h5.1v4.3Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

+1
View File
@@ -0,0 +1 @@
# gr-aistx-compatible PHY (phy.py) + optional encode_to_nrzi CLI
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CLI: AIS fields (type, MMSI, lat/lon, …) -> NRZI bit stream (and optional packed bytes).
Uses AIVDM_Encoder.py in the parent directory for the PDU, then phy.build_nrzi_frame
(same stages as gr-aistx Build_Frame: CRC, reverse, stuff, flags, NRZI).
"""
from __future__ import annotations
import argparse
import os
import sys
# Каталог со скриптом (phy.py) и корень репозитория (опционально AIVDM_Encoder.py)
_SCRIPT_DIR = os.path.abspath(os.path.dirname(__file__))
_ROOT = os.path.abspath(os.path.join(_SCRIPT_DIR, ".."))
for _p in (_SCRIPT_DIR, _ROOT):
if _p not in sys.path:
sys.path.insert(0, _p)
from phy import build_nrzi_frame, nrzi_bits_to_bytes # noqa: E402
try:
import AIVDM_Encoder as enc # noqa: E402
except ImportError:
enc = None # type: ignore
def build_payload(options) -> str:
if enc is None:
raise SystemExit(
"AIVDM_Encoder.py не найден в корне репозитория. "
"Для веб-транспондера используйте pyais + опцию «Кодер: phy.py»."
)
t = options.type
if t == 1:
return enc.encode_1(
int(options.mmsi), int(options.status), float(options.speed),
float(options.lon), float(options.lat), float(options.course), int(options.ts),
)
if t == 4:
return enc.encode_4(
int(options.mmsi), float(options.speed),
float(options.lon), float(options.lat), float(options.course), int(options.ts),
)
if t == 14:
return enc.encode_14(int(options.mmsi), options.sart_msg)
if t == 18:
return enc.encode_18(
int(options.mmsi), float(options.speed),
float(options.lon), float(options.lat), float(options.course), int(options.ts),
)
if t == 20:
return enc.encode_20(
int(options.mmsi), int(options.fatdmaoffset), int(options.fatdmaslots),
int(options.fatdmatimeout), int(options.fatdmaincrement),
)
if t == 21:
v = 1 if options.v_AtoN else 0
return enc.encode_21(
int(options.mmsi), int(options.aid_type), options.aid_name,
float(options.lon), float(options.lat), options.vsize, v,
)
if t == 22:
return enc.encode_22(
int(options.mmsi), int(options.channel_a), int(options.channel_b),
float(options.ne_lon), float(options.ne_lat), float(options.sw_lon), float(options.sw_lat),
)
if t == 23:
return enc.encode_23(
int(options.mmsi), float(options.ne_lon), float(options.ne_lat),
float(options.sw_lon), float(options.sw_lat), int(options.interval), int(options.quiet),
)
if t == 24:
if options.part.upper() == "A":
return enc.encode_24(int(options.mmsi), "A", __vname=options.vname.upper())
return enc.encode_24(
int(options.mmsi), "B",
__callsign=options.callsign.upper(), __vsize=options.vsize, __vtype=int(options.vtype),
)
raise SystemExit("Unsupported type %r" % (t,))
def main() -> None:
p = argparse.ArgumentParser(description="AIS parameters -> NRZI bit frame (gr-aistx-compatible chain).")
p.add_argument("--type", type=int, required=True, help="AIS message type (1,4,14,18,20-24)")
p.add_argument("--mmsi", type=int, default=247320162)
p.add_argument("--lat", type=float, default=45.6910166666667, help="Latitude (types 1,4,18,21,22,23)")
p.add_argument("--lon", type=float, default=9.72357833333333, help="Longitude (alias for encoder --long)")
p.add_argument("--speed", type=float, default=0.1)
p.add_argument("--course", type=float, default=83.4)
p.add_argument("--ts", type=int, default=38)
p.add_argument("--status", type=int, default=15)
p.add_argument("--sart-msg", dest="sart_msg", default="SART ACTIVE")
p.add_argument("--fatdmaoffset", type=int, default=0)
p.add_argument("--fatdmaslots", type=int, default=0)
p.add_argument("--fatdmatimeout", type=int, default=0)
p.add_argument("--fatdmaincrement", type=int, default=0)
p.add_argument("--v_AtoN", action="store_true")
p.add_argument("--aid_type", type=int, default=0)
p.add_argument("--aid_name", default="@@@@@@@@@@@@@@@@@@@@")
p.add_argument("--vsize", default="90x14")
p.add_argument("--channel_a", type=int, default=2087)
p.add_argument("--channel_b", type=int, default=2088)
p.add_argument("--ne_lon", type=float, default=9.9)
p.add_argument("--ne_lat", type=float, default=45.8)
p.add_argument("--sw_lon", type=float, default=9.5)
p.add_argument("--sw_lat", type=float, default=45.5)
p.add_argument("--interval", type=int, default=1)
p.add_argument("--quiet", type=int, default=15)
p.add_argument("--part", default="A")
p.add_argument("--vname", default="NAN")
p.add_argument("--callsign", default="KC9CAF")
p.add_argument("--vtype", type=int, default=60)
p.add_argument("--no-nrzi", action="store_true", help="Output NRZ frame before NRZI (debug)")
p.add_argument("--bytes", action="store_true", help="Write packed bytes (MSB-first) to stdout (binary)")
p.add_argument("--print-payload", action="store_true", help="Print 0/1 PDU line before the frame")
args = p.parse_args()
if enc is None:
p.error("AIVDM_Encoder.py не найден; CLI encode_to_nrzi недоступен.")
payload = build_payload(args)
if args.print_payload:
print(payload, file=sys.stderr)
bits = build_nrzi_frame(payload, enable_nrzi=not args.no_nrzi)
if args.bytes:
sys.stdout.buffer.write(nrzi_bits_to_bytes(bits))
else:
print("".join(str(b) for b in bits))
if __name__ == "__main__":
main()
+229
View File
@@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
"""
Physical-layer AIS frame: bit padding, CRC-16 (ITU, as in gr-aistx Build_Frame),
HDLC-style bit stuffing, flags, NRZI — ported from gr-aistx/lib/Build_Frame_impl.cc
"""
from __future__ import annotations
LEN_PREAMBLE = 24
LEN_START = 8
LEN_CRC = 16
LEN_FRAME_MAX = 256
# CRC-16-CCITT table (same as Build_frame_impl.cc)
_CRC_ITU16_TABLE = (
0x0000, 0x1189, 0x2312, 0x329B, 0x4624, 0x57AD, 0x6536, 0x74BF,
0x8C48, 0x9DC1, 0xAF5A, 0xBED3, 0xCA6C, 0xDBE5, 0xE97E, 0xF8F7,
0x1081, 0x0108, 0x3393, 0x221A, 0x56A5, 0x472C, 0x75B7, 0x643E,
0x9CC9, 0x8D40, 0xBFDB, 0xAE52, 0xDAED, 0xCB64, 0xF9FF, 0xE876,
0x2102, 0x308B, 0x0210, 0x1399, 0x6726, 0x76AF, 0x4434, 0x55BD,
0xAD4A, 0xBCC3, 0x8E58, 0x9FD1, 0xEB6E, 0xFAE7, 0xC87C, 0xD9F5,
0x3183, 0x200A, 0x1291, 0x0318, 0x77A7, 0x662E, 0x54B5, 0x453C,
0xBDCB, 0xAC42, 0x9ED9, 0x8F50, 0xFBEF, 0xEA66, 0xD8FD, 0xC974,
0x4204, 0x538D, 0x6116, 0x709F, 0x0420, 0x15A9, 0x2732, 0x36BB,
0xCE4C, 0xDFC5, 0xED5E, 0xFCD7, 0x8868, 0x99E1, 0xAB7A, 0xBAF3,
0x5285, 0x430C, 0x7197, 0x601E, 0x14A1, 0x0528, 0x37B3, 0x263A,
0xDECD, 0xCF44, 0xFDDF, 0xEC56, 0x98E9, 0x8960, 0xBBFB, 0xAA72,
0x6306, 0x728F, 0x4014, 0x519D, 0x2522, 0x34AB, 0x0630, 0x17B9,
0xEF4E, 0xFEC7, 0xCC5C, 0xDDD5, 0xA96A, 0xB8E3, 0x8A78, 0x9BF1,
0x7387, 0x620E, 0x5095, 0x411C, 0x35A3, 0x242A, 0x16B1, 0x0738,
0xFFCF, 0xEE46, 0xDCDD, 0xCD54, 0xB9EB, 0xA862, 0x9AF9, 0x8B70,
0x8408, 0x9581, 0xA71A, 0xB693, 0xC22C, 0xD3A5, 0xE13E, 0xF0B7,
0x0840, 0x19C9, 0x2B52, 0x3ADB, 0x4E64, 0x5FED, 0x6D76, 0x7CFF,
0x9489, 0x8500, 0xB79B, 0xA612, 0xD2AD, 0xC324, 0xF1BF, 0xE036,
0x18C1, 0x0948, 0x3BD3, 0x2A5A, 0x5EE5, 0x4F6C, 0x7DF7, 0x6C7E,
0xA50A, 0xB483, 0x8618, 0x9791, 0xE32E, 0xF2A7, 0xC03C, 0xD1B5,
0x2942, 0x38CB, 0x0A50, 0x1BD9, 0x6F66, 0x7EEF, 0x4C74, 0x5DFD,
0xB58B, 0xA402, 0x9699, 0x8710, 0xF3AF, 0xE226, 0xD0BD, 0xC134,
0x39C3, 0x284A, 0x1AD1, 0x0B58, 0x7FE7, 0x6E6E, 0x5CF5, 0x4D7C,
0xC60C, 0xD785, 0xE51E, 0xF497, 0x8028, 0x91A1, 0xA33A, 0xB2B3,
0x4A44, 0x5BCD, 0x6956, 0x78DF, 0x0C60, 0x1DE9, 0x2F72, 0x3EFB,
0xD68D, 0xC704, 0xF59F, 0xE416, 0x90A9, 0x8120, 0xB3BB, 0xA232,
0x5AC5, 0x4B4C, 0x79D7, 0x685E, 0x1CE1, 0x0D68, 0x3FF3, 0x2E7A,
0xE70E, 0xF687, 0xC41C, 0xD595, 0xA12A, 0xB0A3, 0x8238, 0x93B1,
0x6B46, 0x7ACF, 0x4854, 0x59DD, 0x2D62, 0x3CEB, 0x0E70, 0x1FF9,
0xF78F, 0xE606, 0xD49D, 0xC514, 0xB1AB, 0xA022, 0x92B9, 0x8330,
0x7BC7, 0x6A4E, 0x58D5, 0x495C, 0x3DE3, 0x2C6A, 0x1EF1, 0x0F78,
)
def bits_from_payload_string(s: str) -> list[int]:
"""ASCII '0'/'1' string -> list of 0/1 (same as Build_Frame ctor)."""
s = s.strip().replace(" ", "").replace("\n", "")
out: list[int] = []
for c in s:
if c == "0":
out.append(0)
elif c == "1":
out.append(1)
else:
raise ValueError("Payload must be only 0 and 1 characters")
return out
def _pad_to_multiple_of_8(bits: list[int]) -> tuple[list[int], int]:
r = len(bits) % 8
if r == 0:
return bits, 0
pad = 8 - r
return bits + [0] * pad, pad
def reverse_bit_order(bits: list[int]) -> None:
"""In-place: reverse bit order within each byte (8-bit group)."""
n = len(bits)
assert n % 8 == 0
for i in range(n // 8):
base = i * 8
for j in range(4):
a = base + j
b = base + 7 - j
bits[a], bits[b] = bits[b], bits[a]
def _compute_crc_bits(buffer_bits: list[int]) -> list[int]:
"""
Match Build_Frame_impl::compute_crc + int2bin/reverse/swap (16-bit FCS bits).
buffer_bits length must be multiple of 8.
"""
datalen = len(buffer_bits) // 8
data = []
for j in range(datalen):
v = 0
for k in range(8):
v = (v << 1) | (buffer_bits[j * 8 + k] & 1)
data.append(v)
crc = 0xFFFF
for b in data:
crc = ((crc >> 8) ^ _CRC_ITU16_TABLE[(crc ^ b) & 0xFF]) & 0xFFFF
crc = (crc & 0xFFFF) ^ 0xFFFF
ret = ["0"] * 16
buf_idx = 15
a = crc & 0xFFFF
for _ in range(16):
ret[buf_idx] = "1" if (a & 1) else "0"
a >>= 1
buf_idx -= 1
rb = [1 if c == "1" else 0 for c in ret]
reverse_bit_order(rb)
ret = ["1" if x else "0" for x in rb]
buf_idx = 15
a = crc & 0xFFFF
for _ in range(16):
ret[buf_idx] = "1" if (a & 1) else "0"
a >>= 1
buf_idx -= 1
temp = ret[8:16]
ret[8:16] = ret[0:8]
ret[0:8] = temp
return [1 if c == "1" else 0 for c in ret]
def bit_stuff(bits: list[int]) -> list[int]:
"""HDLC-style: after five consecutive 1s insert a 0."""
out: list[int] = []
consecutive = 0
for b in bits:
out.append(b)
if b & 1:
consecutive += 1
if consecutive == 5:
out.append(0)
consecutive = 0
else:
consecutive = 0
return out
def nrz_to_nrzi(data: list[int]) -> None:
"""In-place NRZI (same rule as nrz_to_nrzi_impl.cc / Build_Frame_impl)."""
prev = 0
for i in range(len(data)):
nrz = data[i] & 1
if nrz == 0:
nrzi = prev ^ 1
else:
nrzi = prev
data[i] = nrzi
prev = nrzi
def _preamble_bits() -> list[int]:
return [1, 0] * (LEN_PREAMBLE // 2)
def _start_flag_bits() -> list[int]:
return [0, 1, 1, 1, 1, 1, 1, 0]
def build_nrzi_frame(payload_ascii: str, enable_nrzi: bool = True) -> list[int]:
"""
:param payload_ascii: AIS PDU as '0'/'1' string (output style of AIVDM_Encoder.py)
:param enable_nrzi: if False, return NRZ frame bits (before NRZI), for debugging
:return: list of 0/1 (length 256 for short PDUs, or dynamic for long)
"""
payload = bits_from_payload_string(payload_ascii)
payload, _ = _pad_to_multiple_of_8(payload)
len_payload = len(payload)
crc_bits = _compute_crc_bits(payload)
full = payload + crc_bits
reverse_bit_order(full)
stuffed = bit_stuff(full)
preamble = _preamble_bits()
start = _start_flag_bits()
end = _start_flag_bits()
if len_payload <= 168:
frame = [0] * LEN_FRAME_MAX
idx = 0
frame[idx:idx + LEN_PREAMBLE] = preamble
idx += LEN_PREAMBLE
frame[idx:idx + LEN_START] = start
idx += LEN_START
frame[idx:idx + len(stuffed)] = stuffed
idx += len(stuffed)
frame[idx:idx + 8] = end
idx += 8
# padding to 256
assert idx <= LEN_FRAME_MAX
if enable_nrzi:
nrz_to_nrzi(frame)
return frame
len_frame = LEN_PREAMBLE + LEN_START * 2 + len(stuffed)
while len_frame % 8 != 0:
len_frame += 1
frame = [0] * len_frame
idx = 0
frame[idx:idx + LEN_PREAMBLE] = preamble
idx += LEN_PREAMBLE
frame[idx:idx + LEN_START] = start
idx += LEN_START
frame[idx:idx + len(stuffed)] = stuffed
idx += len(stuffed)
frame[idx:idx + 8] = end
if enable_nrzi:
nrz_to_nrzi(frame)
return frame
def nrzi_bits_to_bytes(bits: list[int]) -> bytes:
"""Pack MSB-first within each byte (same as byte_packing in Build_Frame)."""
assert len(bits) % 8 == 0
out = bytearray()
for i in range(len(bits) // 8):
b = 0
for k in range(8):
b = b * 2 + (bits[i * 8 + k] & 1)
out.append(b & 0xFF)
return bytes(out)
+178
View File
@@ -0,0 +1,178 @@
"""
Формирование кадра VDL AIS (флаги HDLC, bit stuffing, CRC-16-CCITT) и NRZI.
Опционально: выравнивание payload до октета и добор NRZ до N бит перед NRZI (аналог padd_frame в GNU Radio).
"""
from __future__ import annotations
from typing import Optional
from bitarray import bitarray
FLAG = bitarray("01111110")
def _bits_to_bytes_msb(bits: bitarray) -> bytes:
out = bytearray()
for i in range(0, len(bits), 8):
chunk = bits[i : i + 8]
if len(chunk) < 8:
pad = bitarray(8 - len(chunk))
pad.setall(0)
chunk = chunk + pad
v = 0
for b in chunk:
v = (v << 1) | int(b)
out.append(v)
return bytes(out)
def crc16_ccitt_fcs(data_bits: bitarray) -> int:
"""FCS по данным payload (без FCS), длина кратна 8 бит."""
b = _bits_to_bytes_msb(data_bits)
crc = 0xFFFF
poly = 0x1021
for byte in b:
crc ^= byte << 8
for _ in range(8):
if crc & 0x8000:
crc = ((crc << 1) ^ poly) & 0xFFFF
else:
crc = (crc << 1) & 0xFFFF
return crc ^ 0xFFFF
def _int_to_bits(val: int, n: int) -> bitarray:
out = bitarray()
for i in range(n - 1, -1, -1):
out.append((val >> i) & 1)
return out
def bit_stuff(bits: bitarray) -> bitarray:
out = bitarray()
count = 0
for b in bits:
out.append(b)
if b:
count += 1
if count == 5:
out.append(0)
count = 0
else:
count = 0
return out
def _pad_payload_to_octet_boundary(payload_bits: bitarray) -> bitarray:
r = len(payload_bits) % 8
if r == 0:
return payload_bits
z = bitarray(8 - r)
z.setall(0)
return payload_bits + z
def _pad_nrz_to_min_bits(nrz_bits: bitarray, min_len: int) -> bitarray:
if min_len <= 0 or len(nrz_bits) >= min_len:
return nrz_bits
tail = bitarray(min_len - len(nrz_bits))
tail.setall(0)
return nrz_bits + tail
def build_hdlc_frame(
payload_bits: bitarray, *, pad_payload_to_octet: bool = False
) -> bitarray:
"""payload_bits — двоичное тело AIS (как pyais Payload.to_bitarray())."""
pl = payload_bits
if len(pl) % 8 != 0:
if pad_payload_to_octet:
pl = _pad_payload_to_octet_boundary(pl)
else:
raise ValueError("AIS payload length must be multiple of 8 bits for this CRC path")
fcs = crc16_ccitt_fcs(pl)
fcs_bits = _int_to_bits(fcs, 16)
data = pl + fcs_bits
stuffed = bit_stuff(data)
return FLAG + stuffed + FLAG
def nrzi_encode(bits: bitarray) -> bitarray:
"""NRZI: 1 — уровень без изменения, 0 — переключение."""
level = 0
out = bitarray()
for b in bits:
if not int(b):
level = 1 - level
out.append(level)
return out
def preamble_alternating(num_bits: int = 24) -> bitarray:
"""
ITU-style dotting: чередование 1/0 (101010…), как gr-aistx LEN_PREAMBLE и phy._preamble_bits.
Вариант 010101… дал бы после NRZI (packed, старт уровня 0) байты 0xCC… вместо 0x66… — та же частота,
другая фаза; к стаффингу это не относится.
"""
out = bitarray()
for i in range(num_bits):
out.append(1 - (i & 1))
return out
def hdlc_nrzi_bytes_no_preamble(
payload_bits: bitarray,
*,
nrzi_byte_mode: str = "packed",
pad_payload_to_octet: bool = False,
pad_nrz_total_bits: Optional[int] = None,
) -> bytes:
"""Только HDLC+NRZI без преамбулы. pad_nrz_total_bits — добор нулей в NRZ до длины (как GNU Radio padd_frame)."""
frame = build_hdlc_frame(payload_bits, pad_payload_to_octet=pad_payload_to_octet)
frame = _pad_nrz_to_min_bits(frame, pad_nrz_total_bits or 0)
nrz = nrzi_encode(frame)
if nrzi_byte_mode == "expanded":
return bytes(0xFF if int(b) else 0x00 for b in nrz)
return _bits_to_bytes_msb(nrz)
def phy_frame_bit_counts(
payload_bits: bitarray, *, pad_payload_to_octet: bool = False
) -> dict:
"""Длины для отладки: payload, поле между флагами (со стаффингом), полный HDLC."""
pl_in = len(payload_bits)
pl = payload_bits
if len(pl) % 8 != 0 and pad_payload_to_octet:
pl = _pad_payload_to_octet_boundary(pl)
frame = build_hdlc_frame(pl, pad_payload_to_octet=pad_payload_to_octet)
inner = frame[8:-8]
return {
"payload_bits_input": pl_in,
"payload_bits_after_pad": len(pl),
"bits_between_flags_stuffed": len(inner),
"hdlc_frame_bits": len(frame),
}
def ais_channel_to_nrzi_bytes(
payload_bits: bitarray,
*,
preamble_bits: int = 24,
nrzi_byte_mode: str = "packed",
pad_payload_to_octet: bool = False,
pad_nrz_total_bits: Optional[int] = None,
) -> bytes:
"""
nrzi_byte_mode:
'packed' — 8 NRZI-сэмплов в байт (MSB первый);
'expanded' — один байт на NRZI-бит: 0x00 / 0xFF.
pad_nrz_total_bits — после преамбулы+кадра дописать нули в NRZ до этой длины (типично 200 для GNU Radio).
"""
frame = build_hdlc_frame(payload_bits, pad_payload_to_octet=pad_payload_to_octet)
pre = preamble_alternating(preamble_bits) if preamble_bits > 0 else bitarray()
combined = pre + frame
combined = _pad_nrz_to_min_bits(combined, pad_nrz_total_bits or 0)
nrzi = nrzi_encode(combined)
if nrzi_byte_mode == "expanded":
return bytes(0xFF if int(b) else 0x00 for b in nrzi)
return _bits_to_bytes_msb(nrzi)
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
Скрипт для скачивания тайлов карты OpenStreetMap для офлайн использования.
Соблюдает политику использования тайлов OpenStreetMap:
- Использует корректный User-Agent
- Соблюдает минимальную задержку 1 секунда между запросами
- Обрабатывает ошибки rate limiting
Использование:
python download_tiles.py --min-zoom 5 --max-zoom 12 --bounds 55.5,37.5,56.0,38.0
"""
import os
import math
import argparse
import requests
from pathlib import Path
import time
def deg2num(lat_deg, lon_deg, zoom):
"""Преобразует координаты в номер тайла"""
lat_rad = math.radians(lat_deg)
n = 2.0 ** zoom
xtile = int((lon_deg + 180.0) / 360.0 * n)
ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
return (xtile, ytile)
def num2deg(xtile, ytile, zoom):
"""Преобразует номер тайла в координаты"""
n = 2.0 ** zoom
lon_deg = xtile / n * 360.0 - 180.0
lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
lat_deg = math.degrees(lat_rad)
return (lat_deg, lon_deg)
def download_tile(z, x, y, output_dir, retries=3, delay=1.0):
"""Скачивает один тайл с соблюдением политики использования OpenStreetMap"""
tile_dir = os.path.join(output_dir, str(z), str(x))
os.makedirs(tile_dir, exist_ok=True)
tile_path = os.path.join(tile_dir, f"{y}.png")
# Если тайл уже существует, пропускаем
if os.path.exists(tile_path):
return True
# URL для скачивания тайла
# Используем разные поддомены для распределения нагрузки
subdomains = ['a', 'b', 'c']
subdomain = subdomains[(x + y) % len(subdomains)]
url = f"https://{subdomain}.tile.openstreetmap.org/{z}/{x}/{y}.png"
# Заголовки согласно политике использования OpenStreetMap
headers = {
'User-Agent': 'PythonAisMap/1.0 (Offline Map Tile Downloader; contact: localhost)',
'Referer': 'http://localhost:8000/'
}
for attempt in range(retries):
try:
response = requests.get(url, headers=headers, timeout=30)
if response.status_code == 200:
with open(tile_path, 'wb') as f:
f.write(response.content)
time.sleep(delay) # Задержка между запросами (минимум 1 сек согласно политике)
return True
elif response.status_code == 404:
# Тайл не существует (например, для океана)
return False
elif response.status_code == 429:
# Too Many Requests - нужно увеличить задержку
wait_time = delay * (2 ** attempt)
print(f" Превышен лимит запросов для {z}/{x}/{y}, ожидание {wait_time} сек...")
time.sleep(wait_time)
continue
elif response.status_code == 403:
# Forbidden - возможно, нарушена политика использования
print(f" Доступ запрещен для {z}/{x}/{y}. Проверьте User-Agent и задержки.")
return False
else:
print(f" Неожиданный статус {response.status_code} для {z}/{x}/{y}")
if attempt < retries - 1:
time.sleep(delay * (attempt + 1))
else:
return False
except requests.exceptions.Timeout:
if attempt < retries - 1:
print(f" Таймаут для {z}/{x}/{y}, повторная попытка...")
time.sleep(delay * (attempt + 1))
else:
print(f" Ошибка таймаута при скачивании тайла {z}/{x}/{y}")
return False
except Exception as e:
if attempt < retries - 1:
time.sleep(delay * (attempt + 1))
else:
print(f" Ошибка при скачивании тайла {z}/{x}/{y}: {e}")
return False
return False
def download_tiles(min_lat, min_lon, max_lat, max_lon, min_zoom, max_zoom, output_dir="static/tiles", delay=1.0):
"""Скачивает тайлы для указанной области и уровней масштабирования"""
print(f"Начинаем скачивание тайлов...")
print(f"Область: ({min_lat}, {min_lon}) - ({max_lat}, {max_lon})")
print(f"Уровни масштабирования: {min_zoom} - {max_zoom}")
print(f"Выходная папка: {output_dir}")
total_tiles = 0
downloaded_tiles = 0
skipped_tiles = 0
for z in range(min_zoom, max_zoom + 1):
print(f"\nОбработка уровня масштабирования {z}...")
# Определяем диапазон тайлов для данного уровня
min_tile_x, max_tile_y = deg2num(min_lat, min_lon, z)
max_tile_x, min_tile_y = deg2num(max_lat, max_lon, z)
# Убеждаемся, что координаты в правильном порядке
if min_tile_x > max_tile_x:
min_tile_x, max_tile_x = max_tile_x, min_tile_x
if min_tile_y > max_tile_y:
min_tile_y, max_tile_y = max_tile_y, min_tile_y
level_tiles = (max_tile_x - min_tile_x + 1) * (max_tile_y - min_tile_y + 1)
total_tiles += level_tiles
print(f" Тайлов на уровне {z}: {level_tiles} ({min_tile_x}-{max_tile_x}, {min_tile_y}-{max_tile_y})")
level_downloaded = 0
level_skipped = 0
for x in range(min_tile_x, max_tile_x + 1):
for y in range(min_tile_y, max_tile_y + 1):
if download_tile(z, x, y, output_dir, delay=delay):
level_downloaded += 1
downloaded_tiles += 1
else:
level_skipped += 1
skipped_tiles += 1
# Показываем прогресс каждые 10 тайлов
if (level_downloaded + level_skipped) % 10 == 0:
print(f" Прогресс: {level_downloaded + level_skipped}/{level_tiles} тайлов")
print(f" Уровень {z} завершен: скачано {level_downloaded}, пропущено {level_skipped}")
print(f"\n=== Итоги ===")
print(f"Всего тайлов: {total_tiles}")
print(f"Скачано: {downloaded_tiles}")
print(f"Пропущено: {skipped_tiles}")
print(f"Готово!")
def main():
parser = argparse.ArgumentParser(
description="Скачивание тайлов OpenStreetMap для офлайн использования"
)
parser.add_argument(
"--min-zoom",
type=int,
default=5,
help="Минимальный уровень масштабирования (по умолчанию: 5)"
)
parser.add_argument(
"--max-zoom",
type=int,
default=12,
help="Максимальный уровень масштабирования (по умолчанию: 12)"
)
parser.add_argument(
"--delay",
type=float,
default=1.0,
help="Задержка между запросами в секундах (по умолчанию: 1.0, минимум рекомендуется 1.0)"
)
parser.add_argument(
"--bounds",
type=str,
help="Границы области в формате: min_lat,min_lon,max_lat,max_lon (например: 55.5,37.5,56.0,38.0)"
)
parser.add_argument(
"--center",
type=str,
help="Центр области и радиус в формате: lat,lon,radius_km (например: 55.75,37.62,50)"
)
parser.add_argument(
"--output",
type=str,
default="static/tiles",
help="Выходная папка для тайлов (по умолчанию: static/tiles)"
)
args = parser.parse_args()
# Определяем границы области
if args.bounds:
parts = args.bounds.split(",")
if len(parts) != 4:
print("Ошибка: --bounds должен содержать 4 значения: min_lat,min_lon,max_lat,max_lon")
return
min_lat, min_lon, max_lat, max_lon = map(float, parts)
elif args.center:
parts = args.center.split(",")
if len(parts) != 3:
print("Ошибка: --center должен содержать 3 значения: lat,lon,radius_km")
return
center_lat, center_lon, radius_km = map(float, parts)
# Приблизительно вычисляем границы на основе радиуса
# 1 градус широты ≈ 111 км
lat_delta = radius_km / 111.0
# 1 градус долготы зависит от широты
lon_delta = radius_km / (111.0 * math.cos(math.radians(center_lat)))
min_lat = center_lat - lat_delta
max_lat = center_lat + lat_delta
min_lon = center_lon - lon_delta
max_lon = center_lon + lon_delta
else:
# По умолчанию: Москва и окрестности
print("Используются границы по умолчанию: Москва и окрестности")
min_lat, min_lon = 55.5, 37.3
max_lat, max_lon = 56.0, 37.9
# Проверяем валидность границ
if min_lat >= max_lat or min_lon >= max_lon:
print("Ошибка: неверные границы области")
return
if args.min_zoom < 0 or args.max_zoom > 19 or args.min_zoom > args.max_zoom:
print("Ошибка: неверные уровни масштабирования (должны быть от 0 до 19, min <= max)")
return
# Создаем выходную папку
os.makedirs(args.output, exist_ok=True)
# Проверяем задержку (рекомендуется минимум 1 секунда)
if args.delay < 1.0:
print("ВНИМАНИЕ: Задержка менее 1 секунды может привести к блокировке!")
print("Рекомендуется использовать задержку не менее 1.0 секунды.")
response = input("Продолжить с текущей задержкой? (y/n): ")
if response.lower() != 'y':
return
# Скачиваем тайлы
download_tiles(
min_lat, min_lon, max_lat, max_lon,
args.min_zoom, args.max_zoom,
args.output,
args.delay
)
if __name__ == "__main__":
main()
+60
View File
@@ -0,0 +1,60 @@
import os
import threading
from urllib.request import urlopen
from urllib.error import URLError
from routes import app, AIS_HUB_URL
from ssl_utils import get_ssl_context, run_http_redirect, _get_local_ips
def _check_ais_hub():
"""Однократная проверка доступности ais_hub на старте (не критично)."""
try:
resp = urlopen(f"{AIS_HUB_URL}/api/v1/health", timeout=2.0)
if getattr(resp, "status", 200) == 200:
print(f"[ais_hub] OK: {AIS_HUB_URL}")
return
print(f"[ais_hub] unexpected status {resp.status} from {AIS_HUB_URL}")
except URLError as e:
print(f"[ais_hub] WARNING: {AIS_HUB_URL} недоступен ({e.reason}). "
f"AIS-данные и /ws не будут работать, пока ais_hub не поднят.")
except Exception as e:
print(f"[ais_hub] WARNING: проверка {AIS_HUB_URL} не удалась: {e}")
if __name__ == "__main__":
_check_ais_hub()
# Геолокация в браузере (телефон) требует secure context (HTTPS или localhost).
# Поэтому HTTPS можно включать/выключать флагом окружения.
use_https = os.environ.get("AISMAP_HTTPS", "1").strip().lower() not in ("0", "false", "no", "off")
if use_https:
ssl_ctx = get_ssl_context()
else:
ssl_ctx = None
if ssl_ctx:
https_port = int(os.environ.get("AISMAP_HTTPS_PORT", "443"))
enable_redirect = os.environ.get("AISMAP_HTTP_REDIRECT", "1").strip().lower() not in ("0", "false", "no", "off")
if enable_redirect:
redir_thread = threading.Thread(target=run_http_redirect, args=(https_port,), daemon=True)
redir_thread.start()
local_ips = _get_local_ips()
print(f"[HTTPS] Сервер запускается на порту {https_port}")
for ip in local_ips:
port_suffix = f":{https_port}" if https_port != 443 else ""
print(f"[HTTPS] Карта: https://{ip}{port_suffix}/")
print(f"[HTTPS] Сертификат: https://{ip}{port_suffix}/cert")
print("[HTTPS] Чтобы убрать предупреждение — откройте /cert и установите CA-сертификат")
app.run(host="0.0.0.0", port=https_port, debug=True,
use_reloader=False, threaded=True, ssl_context=ssl_ctx)
else:
print("[HTTP] Запуск без HTTPS (геолокация телефона в браузере работать не будет)")
local_ips = _get_local_ips()
for ip in local_ips:
print(f"[HTTP] Карта: http://{ip}/")
app.run(host="0.0.0.0", port=80, debug=True,
use_reloader=False, threaded=True)
+16
View File
@@ -0,0 +1,16 @@
[
{
"lat": 59.898667,
"lon": 29.866833,
"mmsi": 2734450,
"msg_type": 4,
"timestamp": 1776335118
},
{
"lat": 59.873513,
"lon": 30.197913,
"mmsi": 352006264,
"msg_type": 4,
"timestamp": 1776334710
}
]
+38
View File
@@ -0,0 +1,38 @@
[
{
"aton_type": 25,
"lat": 59.968,
"lon": 29.781333,
"mmsi": 992736009,
"msg_type": 21,
"name": "21",
"timestamp": 1776335021
},
{
"aton_type": 14,
"lat": 59.9765,
"lon": 29.764833,
"mmsi": 992736007,
"msg_type": 21,
"name": "17",
"timestamp": 1776335020
},
{
"aton_type": 14,
"lat": 59.973333,
"lon": 29.771167,
"mmsi": 992736008,
"msg_type": 21,
"name": "19",
"timestamp": 1776335019
},
{
"aton_type": 14,
"lat": 59.980333,
"lon": 29.752667,
"mmsi": 992736006,
"msg_type": 21,
"name": "13",
"timestamp": 1776335020
}
]
+81
View File
@@ -0,0 +1,81 @@
[
{
"callsign": "UCUA6",
"course": 360.0,
"heading": 511.0,
"lat": 91.0,
"lon": 181.0,
"mmsi": 273251120,
"rot": -128.0,
"shipname": "VALENTIN RYKOV",
"shiptype": 89,
"speed": 102.3,
"timestamp": 1776335052,
"to_bow": 15,
"to_port": 2,
"to_starboard": 12,
"to_stern": 59,
"vessel_class": "A"
},
{
"course": 360.0,
"heading": 86.0,
"lat": 91.0,
"lon": 181.0,
"mmsi": 671575100,
"rot": 0.0,
"speed": 102.3,
"timestamp": 1776335119,
"vessel_class": "A"
},
{
"callsign": "UAQN",
"course": 360.0,
"heading": 511.0,
"lat": 91.0,
"lon": 181.0,
"mmsi": 273315900,
"rot": -128.0,
"shipname": "KAPITAN PLAKHIN",
"shiptype": 90,
"speed": 102.3,
"timestamp": 1776335046,
"to_bow": 25,
"to_port": 7,
"to_starboard": 9,
"to_stern": 53,
"vessel_class": "A"
},
{
"callsign": "UAPB6",
"course": 360.0,
"heading": 511.0,
"lat": 59.943828,
"lon": 30.189773,
"mmsi": 273262700,
"rot": -128.0,
"shipname": "CASTOR",
"shiptype": 30,
"speed": 0.0,
"timestamp": 1776335125,
"to_bow": 46,
"to_port": 6,
"to_starboard": 8,
"to_stern": 24,
"vessel_class": "A"
},
{
"callsign": null,
"course": 360.0,
"heading": 511.0,
"lat": 91.0,
"lon": 181.0,
"mmsi": 0,
"rot": -128.0,
"shipname": null,
"shiptype": 37,
"speed": 102.3,
"timestamp": 1776335124,
"vessel_class": "A"
}
]
+150
View File
@@ -0,0 +1,150 @@
import os
import json
import subprocess
import threading
NETWORK_CONFIG_PATH = "/etc/aismap/network.json"
NETWORK_SCRIPTS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scripts")
NETWORK_DEFAULTS = {
"mode": "ap",
"wifi_ssid": "",
"wifi_psk": "",
"wifi_ip": "192.168.22.50/24",
"wifi_gw": "192.168.22.1",
"wifi_dns": "8.8.8.8",
"ap_ip": "192.168.4.1/24",
"ap_ssid": "",
"ap_psk": "",
"iface": "wlan0",
}
HOSTAPD_CONF = "/etc/hostapd/hostapd.conf"
network_config_lock = threading.Lock()
def _read_hostapd_conf() -> dict:
"""Reads SSID, passphrase and interface from the real hostapd.conf."""
result = {}
try:
with open(HOSTAPD_CONF, "r") as f:
for line in f:
line = line.strip()
if line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
k, v = k.strip(), v.strip()
if k == "ssid":
result["ap_ssid"] = v
elif k == "wpa_passphrase":
result["ap_psk"] = v
elif k == "interface":
result["iface"] = v
except (FileNotFoundError, PermissionError):
pass
return result
def load_network_config() -> dict:
merged = dict(NETWORK_DEFAULTS)
hostapd_vals = _read_hostapd_conf()
merged.update(hostapd_vals)
try:
with open(NETWORK_CONFIG_PATH, "r") as f:
cfg = json.load(f)
merged.update(cfg)
except (FileNotFoundError, json.JSONDecodeError, PermissionError):
pass
return merged
def save_network_config(cfg: dict):
os.makedirs(os.path.dirname(NETWORK_CONFIG_PATH), exist_ok=True)
with open(NETWORK_CONFIG_PATH, "w") as f:
json.dump(cfg, f, indent=2)
def get_current_network_info() -> dict:
"""Reads live network state from the OS (Linux only)."""
info = {"ip": None, "ssid": None, "mode": None, "iface": None}
cfg = load_network_config()
iface = cfg.get("iface", "wlan0")
info["iface"] = iface
try:
out = subprocess.check_output(
["ip", "-4", "addr", "show", iface], timeout=5, stderr=subprocess.DEVNULL
).decode()
for line in out.splitlines():
line = line.strip()
if line.startswith("inet "):
info["ip"] = line.split()[1]
break
except Exception:
pass
try:
out = subprocess.check_output(
["iw", "dev", iface, "info"], timeout=5, stderr=subprocess.DEVNULL
).decode()
for line in out.splitlines():
line = line.strip()
if line.startswith("ssid"):
info["ssid"] = line.split(None, 1)[1]
if line.startswith("type"):
tp = line.split(None, 1)[1].lower()
if "ap" in tp:
info["mode"] = "ap"
elif "managed" in tp or "station" in tp:
info["mode"] = "wifi"
except Exception:
pass
if info["mode"] is None:
try:
subprocess.check_output(
["pgrep", "-x", "hostapd"], timeout=3, stderr=subprocess.DEVNULL
)
info["mode"] = "ap"
except Exception:
try:
subprocess.check_output(
["pgrep", "-x", "wpa_supplicant"], timeout=3, stderr=subprocess.DEVNULL
)
info["mode"] = "wifi"
except Exception:
info["mode"] = cfg.get("mode", "ap")
return info
def switch_network_mode(target_mode: str) -> dict:
"""Runs the appropriate script to switch AP<->WiFi. Returns result dict."""
if target_mode not in ("ap", "wifi"):
return {"ok": False, "error": "Invalid mode, must be 'ap' or 'wifi'"}
script_name = "to_ap.sh" if target_mode == "ap" else "to_wifi.sh"
script_path = os.path.join(NETWORK_SCRIPTS_DIR, script_name)
if not os.path.isfile(script_path):
return {"ok": False, "error": f"Script not found: {script_path}"}
try:
proc = subprocess.run(
["bash", script_path],
capture_output=True, text=True, timeout=30,
)
if proc.returncode == 0:
cfg = load_network_config()
cfg["mode"] = target_mode
save_network_config(cfg)
return {"ok": True, "output": proc.stdout[-500:] if proc.stdout else ""}
else:
return {
"ok": False,
"error": f"Script exited with code {proc.returncode}",
"output": (proc.stdout or "") + (proc.stderr or ""),
}
except subprocess.TimeoutExpired:
return {"ok": False, "error": "Script timed out (30s)"}
except Exception as e:
return {"ok": False, "error": str(e)}
+5
View File
@@ -0,0 +1,5 @@
Flask>=2.3.0
flask-sock>=0.7.0
websocket-client>=1.6.0
requests>=2.31.0
cryptography>=41.0.0
+937
View File
@@ -0,0 +1,937 @@
import os
import time
import socket
import subprocess
import json
import threading
import hashlib
from urllib.request import urlopen, Request
from urllib.parse import urlencode
from urllib.error import URLError, HTTPError
from flask import Flask, jsonify, render_template, send_from_directory, send_file, request, Response
try:
from flask_sock import Sock
except ImportError:
Sock = None
try:
from websocket import create_connection as _ws_create_connection
from websocket import WebSocketConnectionClosedException as _WsClosed
except ImportError:
_ws_create_connection = None
_WsClosed = Exception
from state import get_cpu_usage_percent
from network_manager import (
load_network_config, save_network_config, get_current_network_info,
switch_network_mode, network_config_lock, NETWORK_DEFAULTS,
)
from terminal import terminal_session
from transponder import (
load_transponder_config,
normalize_transponder_config,
save_transponder_config,
merge_transponder_request,
build_preview,
send_transmission,
build_slot_udp_payload,
is_aistx_phy_available,
parse_nrzi_hex,
send_raw_nrzi_packet,
tx_gpio_pulse,
)
app = Flask(__name__)
sock = Sock(app) if Sock else None
# ==================== ais_hub upstream ====================
AIS_HUB_URL = os.environ.get("AIS_HUB_URL", "http://127.0.0.1:8081").rstrip("/")
def _ais_hub_ws_url():
base = AIS_HUB_URL
if base.startswith("https://"):
return "wss://" + base[len("https://"):] + "/ws"
if base.startswith("http://"):
return "ws://" + base[len("http://"):] + "/ws"
return "ws://" + base + "/ws"
# ==================== helpers ====================
def _append_client_log_line(line: str):
"""Append a single line to client_errors.log (best-effort)."""
try:
path = os.path.join(os.path.dirname(__file__), "client_errors.log")
with open(path, "a", encoding="utf-8") as f:
f.write(line.rstrip("\n") + "\n")
except Exception:
pass
# ==================== WebSocket terminal ====================
if sock is not None and os.name == "posix":
@sock.route("/ws/terminal")
def ws_terminal(ws):
terminal_session(ws)
# ==================== WebSocket proxy to ais_hub /ws ====================
if sock is not None:
@sock.route("/ws")
def ws_ais_hub_proxy(ws):
"""Прозрачный WS-прокси к ais_hub /ws (localhost:8081)."""
if _ws_create_connection is None:
# Не закрываем WS сразу: иначе браузер будет переподключаться в цикле и шуметь в консоли.
# Держим соединение открытым и периодически напоминаем про отсутствующую зависимость.
while True:
try:
ws.send(json.dumps({"type": "error", "ts": time.time(),
"data": {"error": "websocket-client not installed on server"}}))
except Exception:
return
try:
time.sleep(5)
except Exception:
return
upstream_url = _ais_hub_ws_url()
# Если ais_hub временно недоступен, не рвём соединение с браузером:
# держим WS открытым и периодически ретраим подключение к upstream.
upstream = None
backoff = 1.0
while upstream is None:
try:
upstream = _ws_create_connection(upstream_url, timeout=5)
break
except Exception as e:
try:
ws.send(json.dumps({"type": "error", "ts": time.time(),
"data": {"error": f"ais_hub unreachable: {e}"}}))
except Exception:
# Клиент ушёл — дальше ретраить нет смысла.
return
try:
time.sleep(backoff)
except Exception:
return
backoff = min(backoff * 2.0, 10.0)
stop = threading.Event()
def upstream_to_client():
try:
while not stop.is_set():
try:
msg = upstream.recv()
except _WsClosed:
break
except Exception:
break
if msg is None or msg == "":
break
if isinstance(msg, bytes):
try:
msg = msg.decode("utf-8", errors="ignore")
except Exception:
continue
try:
ws.send(msg)
except Exception:
break
finally:
stop.set()
t = threading.Thread(target=upstream_to_client, daemon=True)
t.start()
try:
while not stop.is_set():
try:
# flask_sock/simple_websocket have slightly different receive() signatures
# across versions; some don't support timeout=, and some raise on timeout.
try:
data = ws.receive(timeout=30)
except TypeError:
data = ws.receive()
except Exception as e:
# Don't tear down the whole proxy on a benign receive timeout.
if "timeout" in str(e).lower():
continue
break
if data is None:
break
try:
if isinstance(data, bytes):
upstream.send_binary(data)
else:
upstream.send(data)
except Exception:
break
finally:
stop.set()
try:
upstream.close()
except Exception:
pass
# ==================== Pages ====================
@app.route("/")
def index():
return render_template("index.html")
@app.route("/cert")
def cert_install_page():
return render_template("cert.html")
# ==================== REST proxy to ais_hub /api/v1/* ====================
_HOP_HEADERS = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade", "content-encoding",
"content-length", "host",
}
@app.route("/api/v1/<path:rest>", methods=["GET", "POST", "PUT", "DELETE"])
def proxy_ais_hub(rest):
"""Прозрачный прокси REST /api/v1/* на ais_hub (127.0.0.1:8081)."""
url = f"{AIS_HUB_URL}/api/v1/{rest}"
qs = request.query_string.decode("utf-8") if request.query_string else ""
if qs:
url = f"{url}?{qs}"
method = request.method.upper()
body = None
if method in ("POST", "PUT"):
body = request.get_data() or b""
headers = {}
ct = request.headers.get("Content-Type")
if ct:
headers["Content-Type"] = ct
req = Request(url, data=body, method=method, headers=headers)
timeout = 10.0 if method in ("POST", "PUT") else 5.0
try:
resp = urlopen(req, timeout=timeout)
payload = resp.read()
status = getattr(resp, "status", 200) or 200
out_headers = {}
for k, v in resp.headers.items():
if k.lower() in _HOP_HEADERS:
continue
out_headers[k] = v
out_headers.setdefault("Cache-Control", "no-store")
return Response(payload, status=status, headers=out_headers)
except HTTPError as e:
try:
payload = e.read()
except Exception:
payload = b""
out_headers = {}
try:
for k, v in e.headers.items():
if k.lower() in _HOP_HEADERS:
continue
out_headers[k] = v
except Exception:
pass
out_headers.setdefault("Cache-Control", "no-store")
return Response(payload, status=e.code, headers=out_headers)
except URLError as e:
return jsonify({"error": f"ais_hub unreachable: {e.reason}"}), 503
except Exception as e:
return jsonify({"error": f"proxy error: {e}"}), 502
# ==================== API ====================
@app.route("/api/terminal")
def api_terminal():
"""Доступность веб-консоли (PTY + WebSocket)."""
return jsonify({"pty": os.name == "posix", "ws": sock is not None})
@app.route("/api/version")
def api_version():
"""Версия/сборка для отладки кэша фронта и деплоя."""
try:
here = os.path.dirname(__file__)
def _mtime(p):
try:
return int(os.path.getmtime(os.path.join(here, p)))
except Exception:
return None
return jsonify({
"server_time": int(time.time()),
"routes_py_mtime": _mtime("routes.py"),
"app_js_mtime": _mtime(os.path.join("static", "js", "app.js")),
"index_html_mtime": _mtime(os.path.join("templates", "index.html")),
})
except Exception as e:
return jsonify({"server_time": int(time.time()), "error": str(e)})
@app.route("/api/sysinfo")
def api_sysinfo():
"""Локальная система: CPU %, температура, память, uptime. ais_hub этого не знает."""
now = int(time.time())
out = {"server_now": now}
try:
with open("/proc/uptime", "r") as f:
out["sys_uptime"] = int(float(f.read().split()[0]))
except Exception:
out["sys_uptime"] = None
try:
with open("/sys/class/thermal/thermal_zone0/temp", "r") as f:
out["cpu_temp"] = round(int(f.read().strip()) / 1000.0, 1)
except Exception:
out["cpu_temp"] = None
out["cpu_percent"] = get_cpu_usage_percent()
try:
mi = {}
with open("/proc/meminfo", "r") as f:
for line in f:
k, v = line.split(":", 1)
mi[k.strip()] = int(v.strip().split()[0])
total = mi.get("MemTotal", 0)
avail = mi.get("MemAvailable", mi.get("MemFree", 0))
out["mem_total_mb"] = round(total / 1024)
out["mem_used_mb"] = round((total - avail) / 1024)
out["mem_pct"] = round((total - avail) / total * 100, 1) if total else 0
except Exception:
out["mem_total_mb"] = None
out["mem_used_mb"] = None
out["mem_pct"] = None
return jsonify(out)
@app.route("/api/client_log", methods=["POST"])
def api_client_log():
"""
Приём логов/ошибок из браузера (front-end).
Пишет в client_errors.log рядом с routes.py.
"""
try:
data = request.get_json(force=True, silent=True) or {}
except Exception:
data = {}
level = str(data.get("level") or "info").lower()
msg = str(data.get("msg") or "")
ctx = data.get("ctx")
ts = data.get("ts")
url = data.get("url")
ua = data.get("ua")
try:
now = time.strftime("%Y-%m-%d %H:%M:%S")
line = f"[{now}] level={level} msg={msg} url={url} ua={ua} ctx={ctx} ts={ts}"
_append_client_log_line(line)
except Exception:
pass
return jsonify({"ok": True})
@app.route("/api/client_log_tail")
def api_client_log_tail():
"""Хвост client_errors.log для диагностики без SSH."""
try:
n = request.args.get("n", "200")
try:
n = int(n)
except Exception:
n = 200
n = max(1, min(2000, n))
path = os.path.join(os.path.dirname(__file__), "client_errors.log")
if not os.path.exists(path):
return jsonify({"ok": True, "exists": False, "lines": []})
with open(path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
tail = [ln.rstrip("\n") for ln in lines[-n:]]
return jsonify({"ok": True, "exists": True, "lines": tail})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/transponder", methods=["GET"])
def api_transponder_get():
"""Настройки Class B. ownship тянется фронтом отдельно через /api/v1/ownship."""
try:
cfg = normalize_transponder_config(load_transponder_config())
return jsonify(
{
"ok": True,
"config": cfg,
"aistx_phy_available": is_aistx_phy_available(),
}
)
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
def _fetch_ownship_from_hub():
"""Best-effort: последний GPS-fix из ais_hub. Возвращает dict (возможно пустой)."""
try:
resp = urlopen(f"{AIS_HUB_URL}/api/v1/ownship", timeout=2.0)
raw = resp.read()
own = json.loads(raw.decode("utf-8", errors="ignore"))
if not isinstance(own, dict):
return {}
# Адаптируем имена полей к тем, что ожидает transponder (course/speed/...).
return {
"lat": own.get("lat"),
"lon": own.get("lon"),
"course": own.get("cog"),
"speed": own.get("sog"),
"heading": None,
"timestamp": own.get("ts"),
"satellites": own.get("sats"),
"fix_quality": own.get("fix_quality"),
}
except Exception:
return {}
@app.route("/api/transponder", methods=["POST"])
def api_transponder_save():
"""Сохранить настройки транспондера в transponder_config.json."""
data = request.get_json(force=True) or {}
base = load_transponder_config()
merged = merge_transponder_request(base, data)
saved = save_transponder_config(merged)
return jsonify({"ok": True, "config": saved})
@app.route("/api/transponder/preview", methods=["POST"])
def api_transponder_preview():
"""NRZI hex для типов 18/19/24 без отправки (отправка — 127.0.0.1:6010)."""
data = request.get_json(force=True) or {}
base = load_transponder_config()
cfg = merge_transponder_request(base, data)
own = _fetch_ownship_from_hub()
try:
prev = build_preview(cfg, own)
except RuntimeError as e:
return jsonify({"ok": False, "error": str(e)}), 503
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 400
prev.pop("dictionaries", None)
return jsonify({"ok": True, "preview": prev})
@app.route("/api/transponder/send", methods=["POST"])
def api_transponder_send():
"""NRZI по UDP на 127.0.0.1:6010 с заголовком канал+слот (как тест слота)."""
data = request.get_json(force=True) or {}
which = data.get("which")
if which not in ("18", "19", "24A", "24B", "broadcast"):
return jsonify(
{"ok": False, "error": "which must be 18, 19, 24A, 24B, broadcast"}
), 400
cfg_body = {k: v for k, v in data.items() if k != "which"}
base = load_transponder_config()
cfg = merge_transponder_request(base, cfg_body)
own = _fetch_ownship_from_hub()
try:
result = send_transmission(cfg, own, which)
except RuntimeError as e:
return jsonify({"ok": False, "error": str(e)}), 503
except ValueError as e:
return jsonify({"ok": False, "error": str(e)}), 400
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
return jsonify({"ok": True, "result": result})
@app.route("/api/transponder/gpio_pulse", methods=["POST"])
def api_transponder_gpio_pulse():
"""Один импульс TX (GPIO), без UDP. Настройки скрипта — из тела запроса или transponder_config."""
data = request.get_json(force=True) or {}
base = load_transponder_config()
cfg = merge_transponder_request(base, data)
pulse = tx_gpio_pulse(cfg, after_udp=False)
if not pulse.get("ok"):
err = pulse.get("error") or pulse.get("stderr") or "GPIO pulse failed"
return jsonify({"ok": False, "error": err, "pulse": pulse}), 400
return jsonify({"ok": True, "pulse": pulse})
@app.route("/api/transponder/send_raw", methods=["POST"])
def api_transponder_send_raw():
"""Сырой NRZI (hex) + канал + слот → UDP 127.0.0.1:6010."""
data = request.get_json(force=True) or {}
hx = data.get("nrzi_hex") or data.get("hex")
if not isinstance(hx, str) or not hx.strip():
return jsonify({"ok": False, "error": "nrzi_hex (hex строка) обязателен"}), 400
channel = data.get("channel", "A")
try:
slot = int(data.get("slot", 0))
except (TypeError, ValueError):
return jsonify({"ok": False, "error": "slot must be integer 0..2249"}), 400
if channel not in ("A", "B"):
return jsonify({"ok": False, "error": "channel must be A or B"}), 400
if slot < 0 or slot > 2249:
return jsonify({"ok": False, "error": "slot must be 0..2249"}), 400
try:
body = parse_nrzi_hex(hx)
except ValueError as e:
return jsonify({"ok": False, "error": str(e)}), 400
if not body:
return jsonify({"ok": False, "error": "пустой payload после hex"}), 400
cfg_body = {
k: v
for k, v in data.items()
if k not in ("nrzi_hex", "hex", "channel", "slot")
}
base = load_transponder_config()
cfg = merge_transponder_request(base, cfg_body)
try:
result = send_raw_nrzi_packet(channel, slot, body, cfg=cfg)
except ValueError as e:
return jsonify({"ok": False, "error": str(e)}), 400
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
return jsonify({"ok": True, "result": result})
# Аппаратно проверенная NRZI-последовательность для «Тест слота».
TEST_AIS_NRZI = bytes([
102, 102, 102, 254, 149, 61, 224, 94, 245, 171, 174, 169,
74, 84, 87, 105, 51, 82, 202, 166, 141, 99, 170, 170,
170, 253, 236, 63, 170, 170, 170, 170,
])
@app.route("/api/send_test_slot", methods=["POST"])
def api_send_test_slot():
"""Отправляет тестовую датаграмму AIS NRZI в указанный слот/канал (UDP 127.0.0.1:6010)."""
data = request.get_json(force=True)
if not data:
return jsonify({"ok": False, "error": "Empty payload"}), 400
channel = data.get("channel")
slot = data.get("slot")
if channel not in ("A", "B"):
return jsonify({"ok": False, "error": "channel must be 'A' or 'B'"}), 400
if not isinstance(slot, int) or slot < 0 or slot > 2249:
return jsonify({"ok": False, "error": "slot must be 0..2249"}), 400
dest = ("127.0.0.1", 6010)
datagram = build_slot_udp_payload(channel, slot, TEST_AIS_NRZI)
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(datagram, dest)
s.close()
return jsonify({"ok": True, "dest": f"{dest[0]}:{dest[1]}", "size": len(datagram)})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
# ==================== Network API ====================
@app.route("/api/network", methods=["GET"])
def api_network_get():
"""Returns current network configuration and live status."""
with network_config_lock:
cfg = load_network_config()
live = get_current_network_info()
return jsonify({"config": cfg, "live": live})
@app.route("/api/network", methods=["POST"])
def api_network_save():
"""Saves network configuration (does NOT switch mode immediately)."""
data = request.get_json(force=True)
if not data:
return jsonify({"ok": False, "error": "Empty payload"}), 400
with network_config_lock:
cfg = load_network_config()
for key in NETWORK_DEFAULTS:
if key in data:
cfg[key] = data[key]
save_network_config(cfg)
return jsonify({"ok": True, "config": cfg})
@app.route("/api/network/switch", methods=["POST"])
def api_network_switch():
"""Switches network mode to AP or WiFi by running the shell script."""
data = request.get_json(force=True)
target = data.get("mode") if data else None
if target not in ("ap", "wifi"):
return jsonify({"ok": False, "error": "Provide 'mode': 'ap' or 'wifi'"}), 400
with network_config_lock:
cfg = load_network_config()
for key in NETWORK_DEFAULTS:
if key in data and key != "mode":
cfg[key] = data[key]
save_network_config(cfg)
result = switch_network_mode(target)
return jsonify(result)
AIS_MINI_CONF = "/ais-mini.conf"
AIS_MINI_SERVICE = "aisMini.service"
AIS_HUB_CONF = "/opt/aishub/config/config.yaml"
AIS_HUB_SERVICE = "ais_hub.service"
@app.route("/api/config", methods=["GET"])
def api_config_get():
"""Returns the content of the AIS-catcher Mini configuration file."""
try:
with open(AIS_MINI_CONF, "r") as f:
text = f.read()
return jsonify({"ok": True, "text": text})
except FileNotFoundError:
return jsonify({"ok": False, "error": f"File not found: {AIS_MINI_CONF}"}), 404
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/config", methods=["POST"])
def api_config_save():
"""Saves the AIS-catcher Mini configuration file."""
data = request.get_json(force=True)
text = data.get("text") if data else None
if text is None:
return jsonify({"ok": False, "error": "No 'text' field"}), 400
try:
with open(AIS_MINI_CONF, "w") as f:
f.write(text)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/service/restart", methods=["POST"])
def api_service_restart():
"""Restarts the aisMini.service via systemctl."""
try:
result = subprocess.run(
["systemctl", "restart", AIS_MINI_SERVICE],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
return jsonify({"ok": True, "message": f"{AIS_MINI_SERVICE} restarted"})
else:
return jsonify({"ok": False, "error": result.stderr.strip() or f"Exit code {result.returncode}"}), 500
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "Restart timed out"}), 500
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/service/status", methods=["GET"])
def api_service_status():
"""Returns the status of aisMini.service."""
try:
result = subprocess.run(
["systemctl", "is-active", AIS_MINI_SERVICE],
capture_output=True, text=True, timeout=5,
)
state = result.stdout.strip()
return jsonify({"ok": True, "state": state})
except Exception as e:
return jsonify({"ok": True, "state": "unknown", "error": str(e)})
@app.route("/api/config/aishub", methods=["GET"])
def api_aishub_config_get():
"""Returns the content of the ais_hub configuration YAML."""
try:
with open(AIS_HUB_CONF, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
return jsonify({"ok": True, "text": text})
except FileNotFoundError:
return jsonify({"ok": False, "error": f"File not found: {AIS_HUB_CONF}"}), 404
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/config/aishub", methods=["POST"])
def api_aishub_config_save():
"""Saves the ais_hub configuration YAML."""
data = request.get_json(force=True)
text = data.get("text") if data else None
if text is None:
return jsonify({"ok": False, "error": "No 'text' field"}), 400
try:
with open(AIS_HUB_CONF, "w", encoding="utf-8") as f:
f.write(text)
return jsonify({"ok": True})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/service/aishub/status", methods=["GET"])
def api_aishub_service_status():
"""Returns the status of ais_hub.service."""
try:
result = subprocess.run(
["systemctl", "is-active", AIS_HUB_SERVICE],
capture_output=True, text=True, timeout=5,
)
state = result.stdout.strip()
return jsonify({"ok": True, "state": state})
except Exception as e:
return jsonify({"ok": True, "state": "unknown", "error": str(e)})
@app.route("/api/service/aishub/restart", methods=["POST"])
def api_aishub_service_restart():
"""Restarts ais_hub.service via systemctl."""
try:
result = subprocess.run(
["systemctl", "restart", AIS_HUB_SERVICE],
capture_output=True, text=True, timeout=30,
)
if result.returncode == 0:
return jsonify({"ok": True, "message": f"{AIS_HUB_SERVICE} restarted"})
else:
return jsonify({"ok": False, "error": result.stderr.strip() or f"Exit code {result.returncode}"}), 500
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "Restart timed out"}), 500
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/network/scan", methods=["GET"])
def api_network_scan():
"""Scans for available WiFi networks (Linux/iw only)."""
cfg = load_network_config()
iface = cfg.get("iface", "wlan0")
try:
out = subprocess.check_output(
["iw", "dev", iface, "scan", "ap-force"],
timeout=15, stderr=subprocess.DEVNULL,
).decode()
networks = []
current = {}
for line in out.splitlines():
line = line.strip()
if line.startswith("BSS "):
if current.get("ssid"):
networks.append(current)
current = {"bssid": line.split()[1].rstrip("("), "ssid": "", "signal": None}
elif line.startswith("SSID:"):
current["ssid"] = line.split(":", 1)[1].strip()
elif line.startswith("signal:"):
try:
current["signal"] = float(line.split(":")[1].strip().split()[0])
except (ValueError, IndexError):
pass
if current.get("ssid"):
networks.append(current)
seen = set()
unique = []
for n in networks:
if n["ssid"] not in seen:
seen.add(n["ssid"])
unique.append(n)
unique.sort(key=lambda x: x.get("signal") or -999, reverse=True)
return jsonify({"ok": True, "networks": unique})
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "Scan timed out"})
except Exception as e:
return jsonify({"ok": False, "error": str(e), "networks": []})
# ==================== Certificate downloads ====================
@app.route("/ca.pem")
def download_ca():
"""Отдаёт корневой CA-сертификат для установки на клиентские устройства."""
ca_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ca.pem")
if not os.path.exists(ca_path):
return "CA certificate not generated", 404
return send_file(ca_path, mimetype="application/x-pem-file",
as_attachment=True, download_name="AISMap_CA.pem")
@app.route("/ca.crt")
def download_ca_crt():
"""Тот же CA, но с расширением .crt — Android открывает установщик автоматически."""
ca_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "ca.pem")
if not os.path.exists(ca_path):
return "CA certificate not generated", 404
return send_file(ca_path, mimetype="application/x-x509-ca-cert",
as_attachment=True, download_name="AISMap_CA.crt")
# ==================== Static files with caching ====================
IMMUTABLE_YEAR = "public, max-age=31536000, immutable"
STATIC_MONTH = "public, max-age=2592000"
SHORT_REVALIDATE = "public, max-age=60, must-revalidate"
def _etag_for_path(path):
try:
st = os.stat(path)
h = hashlib.md5(f"{st.st_mtime_ns}:{st.st_size}".encode("ascii")).hexdigest()
return f'W/"{h}"'
except Exception:
return None
def _serve_with_cache(directory, filename, cache_control):
"""send_from_directory + заголовок Cache-Control + ETag/304."""
abs_path = os.path.join(directory, filename)
etag = _etag_for_path(abs_path)
inm = request.headers.get("If-None-Match")
if etag and inm and inm == etag:
resp = Response(status=304)
resp.headers["ETag"] = etag
resp.headers["Cache-Control"] = cache_control
return resp
resp = send_from_directory(directory, filename)
resp.headers["Cache-Control"] = cache_control
if etag:
resp.headers["ETag"] = etag
return resp
@app.route("/svg/<path:filename>")
def serve_svg(filename):
"""SVG-иконки — редко меняются, долгий кеш."""
return _serve_with_cache("SVG", filename, STATIC_MONTH)
@app.route("/static/leaflet/<path:filename>")
def serve_leaflet(filename):
"""Leaflet (версионирован по содержимому) — immutable."""
return _serve_with_cache("static/leaflet", filename, IMMUTABLE_YEAR)
@app.route("/static/xterm/<path:filename>")
def serve_xterm(filename):
"""xterm.js — immutable."""
return _serve_with_cache("static/xterm", filename, IMMUTABLE_YEAR)
@app.route("/static/js/<path:filename>")
def serve_static_js(filename):
"""app.js, модули — SWR: всегда проверяем обновление, но отдаём из кеша если не изменилось."""
return _serve_with_cache("static/js", filename, SHORT_REVALIDATE)
@app.route("/static/css/<path:filename>")
def serve_static_css(filename):
return _serve_with_cache("static/css", filename, SHORT_REVALIDATE)
@app.route("/sw.js")
def serve_sw():
"""Service Worker должен быть в корне для scope '/'."""
path = os.path.join(os.path.dirname(__file__), "static", "sw.js")
if not os.path.exists(path):
return "sw.js not found", 404
with open(path, "r", encoding="utf-8") as f:
body = f.read()
resp = Response(body, mimetype="application/javascript")
resp.headers["Cache-Control"] = "no-store"
resp.headers["Service-Worker-Allowed"] = "/"
return resp
VECTOR_TILE_URL = os.environ.get("AISMAP_VECTOR_TILE_URL", "http://127.0.0.1:8080").rstrip("/")
VECTOR_TILE_SERVICE = os.environ.get("AISMAP_VECTOR_TILE_SERVICE", "planet_small_z14").strip().strip("/")
@app.route("/vtiles/<int:z>/<int:x>/<int:y>.pbf")
def proxy_vector_tile(z, x, y):
"""Проксирует PBF-тайлы с локального векторного тайлсервера. immutable: тайл не меняется."""
try:
candidates = [
f"{VECTOR_TILE_URL}/services/{VECTOR_TILE_SERVICE}/tiles/{z}/{x}/{y}.pbf",
f"{VECTOR_TILE_URL}/services/{VECTOR_TILE_SERVICE}/{z}/{x}/{y}.pbf",
f"{VECTOR_TILE_URL}/data/{VECTOR_TILE_SERVICE}/{z}/{x}/{y}.pbf",
f"{VECTOR_TILE_URL}/{VECTOR_TILE_SERVICE}/{z}/{x}/{y}.pbf",
]
last_err = None
for url in candidates:
try:
resp = urlopen(url, timeout=5)
data = resp.read()
code = getattr(resp, "status", 200) or 200
headers = {
"Content-Type": resp.headers.get("Content-Type", "application/x-protobuf"),
"Cache-Control": IMMUTABLE_YEAR,
"Access-Control-Allow-Origin": "*",
"X-AISMap-Upstream": url,
}
ce = resp.headers.get("Content-Encoding")
if ce:
headers["Content-Encoding"] = ce
return data, code, headers
except HTTPError as e:
last_err = e
if getattr(e, "code", None) != 404:
raise
except URLError as e:
last_err = e
break
if isinstance(last_err, HTTPError) and getattr(last_err, "code", None) == 404:
return "Vector tile not found", 404
return "Vector tile upstream unavailable", 503
except (URLError, HTTPError):
return "Vector tile upstream error", 503
@app.route("/tiles/<int:z>/<int:x>/<int:y>.png")
def serve_tile(z, x, y):
"""Отдаёт растровые тайлы из static/tiles с вечным кешем (immutable)."""
max_tile = 2 ** z
if x < 0 or x >= max_tile or y < 0 or y >= max_tile:
return "Invalid tile coordinates", 404
rel_dir = os.path.join("static", "tiles", str(z), str(x))
tile_path = os.path.join(rel_dir, f"{y}.png")
if not os.path.exists(tile_path):
return "Tile not found", 404
etag = _etag_for_path(tile_path)
inm = request.headers.get("If-None-Match")
if etag and inm and inm == etag:
resp = Response(status=304)
resp.headers["ETag"] = etag
resp.headers["Cache-Control"] = IMMUTABLE_YEAR
return resp
resp = send_from_directory(rel_dir, f"{y}.png")
resp.headers["Cache-Control"] = IMMUTABLE_YEAR
if etag:
resp.headers["ETag"] = etag
return resp
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=AIS Map Network Init (AP/WiFi with rollback)
After=network-pre.target
Before=network.target hostapd.service
Wants=network-pre.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/opt/aismap/scripts/network_init.sh
TimeoutStartSec=60
[Install]
WantedBy=multi-user.target
+80
View File
@@ -0,0 +1,80 @@
#!/bin/bash
#
# Install AIS Map network scripts and systemd service.
# Run as root on the target device.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
INSTALL_DIR="/opt/aismap/scripts"
CONFIG_DIR="/etc/aismap"
CONFIG_FILE="$CONFIG_DIR/network.json"
HOSTAPD_CONF="/etc/hostapd/hostapd.conf"
echo "=== AIS Map network scripts installer ==="
mkdir -p "$INSTALL_DIR"
mkdir -p "$CONFIG_DIR"
echo "[1] Copying scripts to $INSTALL_DIR ..."
cp "$SCRIPT_DIR/to_wifi.sh" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/to_ap.sh" "$INSTALL_DIR/"
cp "$SCRIPT_DIR/network_init.sh" "$INSTALL_DIR/"
chmod +x "$INSTALL_DIR"/*.sh
echo "[2] Creating default config (if not exists) ..."
if [ ! -f "$CONFIG_FILE" ]; then
# Read current AP settings from hostapd.conf
AP_SSID=""
AP_PSK=""
AP_IFACE="wlan0"
if [ -f "$HOSTAPD_CONF" ]; then
AP_SSID=$(grep -E '^ssid=' "$HOSTAPD_CONF" | head -1 | cut -d= -f2 || echo "")
AP_PSK=$(grep -E '^wpa_passphrase=' "$HOSTAPD_CONF" | head -1 | cut -d= -f2 || echo "")
AP_IFACE=$(grep -E '^interface=' "$HOSTAPD_CONF" | head -1 | cut -d= -f2 || echo "wlan0")
echo " Read from hostapd.conf: SSID=$AP_SSID, iface=$AP_IFACE"
fi
cat > "$CONFIG_FILE" <<EOF
{
"mode": "ap",
"wifi_ssid": "",
"wifi_psk": "",
"wifi_ip": "192.168.22.50/24",
"wifi_gw": "192.168.22.1",
"wifi_dns": "8.8.8.8",
"ap_ip": "192.168.4.1/24",
"ap_ssid": "$AP_SSID",
"ap_psk": "$AP_PSK",
"iface": "$AP_IFACE"
}
EOF
echo " Created $CONFIG_FILE"
else
echo " Config already exists, skipping"
fi
echo "[3] Installing systemd service ..."
cp "$SCRIPT_DIR/aismap-network.service" /etc/systemd/system/
systemctl daemon-reload
systemctl enable aismap-network.service
echo " Service enabled: aismap-network.service"
echo "[4] Disabling conflicting services (optional) ..."
# We manage hostapd manually — prevent it from auto-starting
systemctl disable hostapd 2>/dev/null || true
echo " hostapd auto-start disabled (we start it from to_ap.sh)"
echo ""
echo "=== Installation complete ==="
echo ""
echo "Usage:"
echo " - Web UI: open Settings tab -> 'Сеть / Режим работы'"
echo " - Manual switch to WiFi: bash $INSTALL_DIR/to_wifi.sh"
echo " - Manual switch to AP: bash $INSTALL_DIR/to_ap.sh"
echo " - Config file: $CONFIG_FILE"
echo " - Boot init logs: /var/log/aismap_network_init.log"
echo " - Hostapd backup: ${HOSTAPD_CONF}.orig (created on first AP switch)"
echo ""
echo "On next reboot, network_init.sh will run automatically."
echo "If WiFi fails, it will roll back to AP mode."
+75
View File
@@ -0,0 +1,75 @@
#!/bin/bash
#
# AIS Map network init — runs at boot.
# Reads /etc/aismap/network.json, tries the configured mode.
# If mode=wifi and connection fails — rolls back to AP.
#
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
CONFIG="/etc/aismap/network.json"
LOG="/var/log/aismap_network_init.log"
exec >"$LOG" 2>&1
echo "=== $(date) network_init start ==="
if [ ! -f "$CONFIG" ]; then
echo "Config not found ($CONFIG), defaulting to AP mode"
bash "$SCRIPT_DIR/to_ap.sh"
echo "=== $(date) network_init done (default AP) ==="
exit 0
fi
MODE=$(python3 -c "import json; print(json.load(open('$CONFIG')).get('mode','ap'))" 2>/dev/null || echo "ap")
echo "Configured mode: $MODE"
if [ "$MODE" = "wifi" ]; then
echo "--- Attempting WiFi connection ---"
bash "$SCRIPT_DIR/to_wifi.sh"
WIFI_EXIT=$?
if [ $WIFI_EXIT -ne 0 ]; then
echo "to_wifi.sh failed (exit $WIFI_EXIT) — rolling back to AP"
bash "$SCRIPT_DIR/to_ap.sh"
echo "=== $(date) network_init done (rollback to AP) ==="
exit 0
fi
GW=$(python3 -c "import json; print(json.load(open('$CONFIG')).get('wifi_gw',''))" 2>/dev/null || echo "")
IFACE=$(python3 -c "import json; print(json.load(open('$CONFIG')).get('iface','wlan0'))" 2>/dev/null || echo "wlan0")
echo "Verifying WiFi connectivity (gateway: $GW) ..."
CONNECTED=0
for ATTEMPT in 1 2 3; do
sleep 3
if [ -n "$GW" ] && ping -c 2 -W 3 "$GW" >/dev/null 2>&1; then
echo "Attempt $ATTEMPT: gateway reachable"
CONNECTED=1
break
fi
# Also check wpa_supplicant state
if wpa_cli -i "$IFACE" status 2>/dev/null | grep -q "wpa_state=COMPLETED"; then
echo "Attempt $ATTEMPT: wpa_supplicant COMPLETED"
CONNECTED=1
break
fi
echo "Attempt $ATTEMPT: not connected yet..."
done
if [ $CONNECTED -eq 1 ]; then
echo "WiFi connection confirmed"
echo "=== $(date) network_init done (wifi) ==="
exit 0
else
echo "WiFi NOT reachable after 3 attempts — rolling back to AP"
bash "$SCRIPT_DIR/to_ap.sh"
echo "=== $(date) network_init done (rollback to AP) ==="
exit 0
fi
else
echo "--- Starting AP mode ---"
bash "$SCRIPT_DIR/to_ap.sh"
echo "=== $(date) network_init done (ap) ==="
exit 0
fi
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Короткий импульс на GPIO (Linux + gpiod). Пример для Orange Pi: скопируйте на устройство и укажите путь в настройках транспондера."""
import os
import time
try:
import gpiod
except ImportError:
raise SystemExit("Нужен пакет gpiod (python3-libgpiod)") from None
CHIP = os.environ.get("AIS_TX_GPIO_CHIP", "/dev/gpiochip1")
LINE = int(os.environ.get("AIS_TX_GPIO_LINE", "0"))
PULSE_US = int(os.environ.get("AIS_TX_PULSE_US", "1000"))
with gpiod.request_lines(
CHIP,
consumer="ais-tx-pulse",
config={
LINE: gpiod.LineSettings(
direction=gpiod.line.Direction.OUTPUT,
output_value=gpiod.line.Value.INACTIVE,
)
},
) as req:
req.set_value(LINE, gpiod.line.Value.ACTIVE)
time.sleep(PULSE_US / 1_000_000.0)
req.set_value(LINE, gpiod.line.Value.INACTIVE)
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
set -euo pipefail
CONFIG="/etc/aismap/network.json"
HOSTAPD_CONF="/etc/hostapd/hostapd.conf"
LOG="/var/log/aismap_to_ap.log"
exec >"$LOG" 2>&1
echo "=== $(date) start to_ap ==="
IFACE="wlan0"
AP_IP="192.168.4.1/24"
AP_SSID=""
AP_PSK=""
if [ -f "$CONFIG" ]; then
IFACE=$(python3 -c "import json; print(json.load(open('$CONFIG')).get('iface','wlan0'))" 2>/dev/null || echo "wlan0")
AP_IP=$(python3 -c "import json; print(json.load(open('$CONFIG')).get('ap_ip','192.168.4.1/24'))" 2>/dev/null || echo "192.168.4.1/24")
AP_SSID=$(python3 -c "import json; print(json.load(open('$CONFIG')).get('ap_ssid',''))" 2>/dev/null || echo "")
AP_PSK=$(python3 -c "import json; print(json.load(open('$CONFIG')).get('ap_psk',''))" 2>/dev/null || echo "")
fi
echo "[1] stop wifi client"
killall wpa_supplicant 2>/dev/null || true
sleep 1
echo "[2] reset iface $IFACE"
ip addr flush dev "$IFACE" 2>/dev/null || true
ip route del default 2>/dev/null || true
ip link set "$IFACE" down 2>/dev/null || true
sleep 1
ip link set "$IFACE" up
sleep 2
echo "[3] restore AP IP: $AP_IP"
ip addr add "$AP_IP" dev "$IFACE"
echo "[4] update hostapd.conf (if new SSID/PSK provided)"
if [ -n "$AP_SSID" ] && [ -f "$HOSTAPD_CONF" ]; then
# Back up the original before first modification
if [ ! -f "${HOSTAPD_CONF}.orig" ]; then
cp "$HOSTAPD_CONF" "${HOSTAPD_CONF}.orig"
echo " backed up original to ${HOSTAPD_CONF}.orig"
fi
sed -i "s/^ssid=.*/ssid=${AP_SSID}/" "$HOSTAPD_CONF"
echo " ssid -> $AP_SSID"
if [ -n "$AP_PSK" ]; then
sed -i "s/^wpa_passphrase=.*/wpa_passphrase=${AP_PSK}/" "$HOSTAPD_CONF"
echo " wpa_passphrase -> (updated)"
fi
# Keep interface in sync
sed -i "s/^interface=.*/interface=${IFACE}/" "$HOSTAPD_CONF"
else
echo " no SSID in config or hostapd.conf missing — using existing hostapd.conf"
fi
echo "[5] start hostapd"
hostapd -B "$HOSTAPD_CONF"
echo "[6] restart dnsmasq (if used)"
systemctl restart dnsmasq 2>/dev/null || true
echo "[7] status"
ip addr show dev "$IFACE"
iw dev 2>/dev/null || true
echo "=== $(date) done to_ap ==="
+86
View File
@@ -0,0 +1,86 @@
#!/bin/bash
set -euo pipefail
CONFIG="/etc/aismap/network.json"
LOG="/var/log/aismap_to_wifi.log"
exec >"$LOG" 2>&1
echo "=== $(date) start to_wifi ==="
if [ ! -f "$CONFIG" ]; then
echo "ERROR: config not found: $CONFIG"
exit 1
fi
SSID=$(python3 -c "import json; print(json.load(open('$CONFIG'))['wifi_ssid'])")
PSK=$(python3 -c "import json; print(json.load(open('$CONFIG'))['wifi_psk'])")
WIFI_IP=$(python3 -c "import json; print(json.load(open('$CONFIG'))['wifi_ip'])")
GW=$(python3 -c "import json; print(json.load(open('$CONFIG')).get('wifi_gw',''))")
DNS=$(python3 -c "import json; print(json.load(open('$CONFIG')).get('wifi_dns','8.8.8.8'))")
IFACE=$(python3 -c "import json; print(json.load(open('$CONFIG')).get('iface','wlan0'))")
if [ -z "$SSID" ]; then
echo "ERROR: wifi_ssid is empty"
exit 1
fi
echo "[1] stop AP services"
killall hostapd 2>/dev/null || true
killall wpa_supplicant 2>/dev/null || true
sleep 1
echo "[2] reset iface $IFACE"
ip link set "$IFACE" down 2>/dev/null || true
sleep 1
ip link set "$IFACE" up
sleep 2
echo "[3] scan for SSID: $SSID"
iw dev "$IFACE" scan 2>/dev/null | grep -F "SSID: $SSID" || echo "WARN: SSID not found in scan, trying anyway"
echo "[4] build wpa config"
wpa_passphrase "$SSID" "$PSK" > /tmp/aismap_wifi.conf
echo "[5] connect to wifi"
wpa_supplicant -B -i "$IFACE" -c /tmp/aismap_wifi.conf
sleep 5
echo "[6] flush old IPs"
ip addr flush dev "$IFACE"
echo "[7] set static IP: $WIFI_IP"
ip addr add "$WIFI_IP" dev "$IFACE"
echo "[8] set default route"
ip route del default 2>/dev/null || true
if [ -n "$GW" ]; then
ip route add default via "${GW}" dev "$IFACE"
fi
echo "[9] set DNS"
if [ -n "$DNS" ]; then
echo "nameserver $DNS" > /etc/resolv.conf
fi
echo "[10] verify connectivity"
sleep 2
CURRENT_IP=$(ip -4 addr show "$IFACE" | grep -oP 'inet \K[0-9./]+' || echo "none")
echo "IP on $IFACE: $CURRENT_IP"
if [ -n "$GW" ]; then
if ping -c 2 -W 3 "${GW}" >/dev/null 2>&1; then
echo "Gateway $GW reachable — WiFi OK"
else
echo "WARN: gateway $GW unreachable"
fi
else
echo "No gateway configured, skipping ping check"
fi
echo "[11] wpa_supplicant status"
wpa_cli -i "$IFACE" status 2>/dev/null || true
ip addr show dev "$IFACE"
iw dev "$IFACE" link 2>/dev/null || true
echo "=== $(date) done to_wifi ==="
+148
View File
@@ -0,0 +1,148 @@
import os
import ssl
import socket
import datetime
def _get_local_ips():
"""Возвращает список локальных IP-адресов машины."""
ips = []
try:
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
ip = info[4][0]
if ip not in ips:
ips.append(ip)
except Exception:
pass
return ips
def get_ssl_context():
"""
Создаёт собственный мини-CA и подписывает серверный сертификат.
CA-сертификат можно установить на телефон один раз — и предупреждения исчезнут.
Файлы: ca.pem (корневой, для установки на клиенты), cert.pem + key.pem (сервер).
"""
cert_dir = os.path.dirname(os.path.abspath(__file__))
ca_cert_file = os.path.join(cert_dir, "ca.pem")
ca_key_file = os.path.join(cert_dir, "ca_key.pem")
cert_file = os.path.join(cert_dir, "cert.pem")
key_file = os.path.join(cert_dir, "key.pem")
need_regen = not all(os.path.exists(f) for f in [ca_cert_file, ca_key_file, cert_file, key_file])
if need_regen:
try:
from cryptography import x509
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
import ipaddress
except ImportError:
print("[SSL] Библиотека cryptography не установлена.")
print("[SSL] Установите: pip install cryptography")
print("[SSL] Без HTTPS GPS телефона работать не будет.")
return None
print("[SSL] Генерация корневого CA и серверного сертификата...")
# === 1. Корневой CA ===
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
ca_name = x509.Name([
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "AIS Map CA"),
x509.NameAttribute(NameOID.COMMON_NAME, "AIS Map Root CA"),
])
ca_cert = (
x509.CertificateBuilder()
.subject_name(ca_name)
.issuer_name(ca_name)
.public_key(ca_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.utcnow())
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
.add_extension(
x509.KeyUsage(
digital_signature=True, key_cert_sign=True, crl_sign=True,
content_commitment=False, key_encipherment=False,
data_encipherment=False, key_agreement=False,
encipher_only=False, decipher_only=False,
),
critical=True,
)
.sign(ca_key, hashes.SHA256())
)
with open(ca_cert_file, "wb") as f:
f.write(ca_cert.public_bytes(serialization.Encoding.PEM))
with open(ca_key_file, "wb") as f:
f.write(ca_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
))
print(f"[SSL] CA сертификат: {ca_cert_file}")
# === 2. Серверный сертификат, подписанный CA ===
srv_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
srv_name = x509.Name([
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "AIS Map"),
x509.NameAttribute(NameOID.COMMON_NAME, "aismap.local"),
])
san_entries = [x509.DNSName("aismap.local"), x509.DNSName("localhost")]
for iface_ip in _get_local_ips():
try:
san_entries.append(x509.IPAddress(ipaddress.IPv4Address(iface_ip)))
except Exception:
pass
san_entries.append(x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")))
srv_cert = (
x509.CertificateBuilder()
.subject_name(srv_name)
.issuer_name(ca_name)
.public_key(srv_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(datetime.datetime.utcnow())
.not_valid_after(datetime.datetime.utcnow() + datetime.timedelta(days=3650))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.add_extension(x509.SubjectAlternativeName(san_entries), critical=False)
.add_extension(
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),
critical=False,
)
.sign(ca_key, hashes.SHA256())
)
with open(cert_file, "wb") as f:
f.write(srv_cert.public_bytes(serialization.Encoding.PEM))
with open(key_file, "wb") as f:
f.write(srv_key.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
))
print(f"[SSL] Серверный сертификат: {cert_file}")
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(cert_file, key_file)
return ctx
def run_http_redirect(https_port):
"""Простой HTTP-сервер на порту 80, который редиректит на HTTPS."""
from flask import Flask, redirect, request as flask_request
redir_app = Flask("redirect")
@redir_app.route("/", defaults={"path": ""})
@redir_app.route("/<path:path>")
def redir(path):
host = flask_request.host.split(":")[0]
port_suffix = f":{https_port}" if https_port != 443 else ""
return redirect(f"https://{host}{port_suffix}/{path}", code=301)
try:
redir_app.run(host="0.0.0.0", port=80, threaded=True)
except Exception as e:
print(f"[HTTP] Не удалось запустить редирект на порту 80: {e}")
+49
View File
@@ -0,0 +1,49 @@
import threading
# Все структуры AIS-данных (vessels/base_stations/buoys/stats/slots/...) вынесены в
# централизованный процесс ais_hub (localhost:8081). Здесь остаётся только функция
# измерения загрузки CPU для эндпоинта /api/sysinfo — ais_hub системные метрики не отдаёт.
_cpu_prev_jiffies = None
_cpu_jiffy_lock = threading.Lock()
def _read_cpu_aggregated_jiffies():
"""Суммарные jiffies по всем ядрам: первая строка cpu в /proc/stat."""
with open("/proc/stat", "r") as f:
line = f.readline()
parts = line.split()
if not parts or parts[0] != "cpu":
return None
nums = [int(x) for x in parts[1:]]
idle = nums[3] + nums[4] # idle + iowait
total = sum(nums)
return total, idle
def get_cpu_usage_percent():
"""
Загрузка CPU в % (0100), как в htop: разница двух замеров /proc/stat.
Первый запрос после старта может вернуть None — ещё нет предыдущего замера.
"""
global _cpu_prev_jiffies
try:
cur = _read_cpu_aggregated_jiffies()
if cur is None:
return None
total, idle = cur
with _cpu_jiffy_lock:
prev = _cpu_prev_jiffies
_cpu_prev_jiffies = (total, idle)
if prev is None:
return None
p_total, p_idle = prev
dt = total - p_total
di = idle - p_idle
if dt <= 0:
return None
busy = dt - di
pct = max(0.0, min(100.0, 100.0 * busy / dt))
return round(pct, 1)
except Exception:
return None
+1359
View File
File diff suppressed because it is too large Load Diff
+6077
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"273":"RU","275":"LV","277":"LT","276":"EE","272":"UA","261":"PL","211":"DE","218":"DE","226":"FR","227":"FR","228":"FR","247":"IT","257":"NO","258":"NO","259":"NO","265":"SE","266":"SE","230":"FI","219":"DK","220":"DK","244":"NL","245":"NL","246":"NL","205":"BE","224":"ES","225":"ES","237":"GR","239":"GR","240":"GR","241":"GR","271":"TR","264":"RO","207":"BG","238":"HR","232":"GB","233":"GB","234":"GB","235":"GB","250":"IE","251":"IS","215":"MT","229":"MT","248":"MT","249":"MT","256":"MT","209":"CY","210":"CY","212":"CY","338":"US","366":"US","367":"US","368":"US","369":"US","316":"CA","412":"CN","413":"CN","414":"CN","431":"JP","432":"JP","440":"KR","441":"KR","563":"SG","564":"SG","565":"SG","566":"SG","419":"IN","503":"AU","710":"BR","701":"AR","351":"PA","352":"PA","353":"PA","354":"PA","355":"PA","356":"PA","357":"PA","370":"PA","371":"PA","372":"PA","373":"PA","374":"PA","636":"LR","637":"LR","538":"MH","308":"BS","309":"BS","311":"BS","477":"HK","416":"TW","574":"VN","525":"ID","533":"MY","548":"PH","567":"TH","403":"SA","470":"AE","471":"AE","428":"IL","622":"EG","601":"ZA","512":"NZ","345":"MX","725":"CL","263":"PT","269":"CH","203":"AT","270":"CZ","243":"HU","279":"RS","278":"SI","267":"SK","262":"ME","201":"AL","213":"GE","436":"KZ","422":"IR"}
+473
View File
@@ -0,0 +1,473 @@
/**
* Редактор габаритов (ITU Fig. 38).
* Корма — L, правый борт — W (доли A/L и C/W фиксируются на время жеста); точка GPS — перетаскивание.
* Для роста L/W координаты могут выходить за контур корпуса (опорный масштаб wPxRef/hPxRef с pointerdown).
*/
(function () {
'use strict';
var NS = 'http://www.w3.org/2000/svg';
var MIN_LEN = 20;
var MIN_BEAM = 6;
var MAX_AB = 511;
var MAX_CD = 63;
var MAX_L = MAX_AB + MAX_AB;
var MAX_W = MAX_CD + MAX_CD;
/** Макс. размер корпуса в пикселях (пропорции сохраняются) */
var MAX_DRAW_W = 175;
var MAX_DRAW_H = 210;
/** Минимум по меньшей стороне корпуса в px — иначе ручки и клампы «схлопываются» */
var MIN_HULL_PX = 48;
var PAD_L = 12;
var PAD_T = 48;
var PAD_R = 78;
var PAD_B = 42;
var svg, inner, hull, marker, handles;
var gridH, gridV, lblBow, lblStern, dimBeam, dimLength;
var drag = null;
/** Текущая геометрия (обновляется в refresh) */
var layout = {
wPx: 100,
hPx: 160,
dispL: MIN_LEN,
dispW: MIN_BEAM,
};
function clamp(n, lo, hi) {
return Math.max(lo, Math.min(hi, n));
}
function el(name, attrs) {
var e = document.createElementNS(NS, name);
if (attrs) {
Object.keys(attrs).forEach(function (k) {
e.setAttribute(k, attrs[k]);
});
}
return e;
}
function readDims() {
var A = parseInt(document.getElementById('tp-to-bow').value, 10) || 0;
var B = parseInt(document.getElementById('tp-to-stern').value, 10) || 0;
var C = parseInt(document.getElementById('tp-to-port').value, 10) || 0;
var D = parseInt(document.getElementById('tp-to-starboard').value, 10) || 0;
A = clamp(A, 0, MAX_AB);
B = clamp(B, 0, MAX_AB);
C = clamp(C, 0, MAX_CD);
D = clamp(D, 0, MAX_CD);
return { A: A, B: B, C: C, D: D, L: A + B, W: C + D };
}
function writeDims(A, B, C, D) {
A = clamp(Math.round(A), 0, MAX_AB);
B = clamp(Math.round(B), 0, MAX_AB);
C = clamp(Math.round(C), 0, MAX_CD);
D = clamp(Math.round(D), 0, MAX_CD);
document.getElementById('tp-to-bow').value = A;
document.getElementById('tp-to-stern').value = B;
document.getElementById('tp-to-port').value = C;
document.getElementById('tp-to-starboard').value = D;
}
function displayLW(d) {
var L = d.L > 0 ? d.L : MIN_LEN;
var W = d.W > 0 ? d.W : MIN_BEAM;
return { L: L, W: W, template: d.L === 0 && d.W === 0 };
}
function hullPath(w, h) {
var bow = Math.min(20, h * 0.11);
var tipX = w / 2;
return (
'M ' + tipX + ',0 L ' + w + ',' + bow + ' L ' + w + ',' + h + ' L 0,' + h + ' L 0,' + bow + ' Z'
);
}
function computeLayout(d, disp) {
var scale = Math.min(MAX_DRAW_W / disp.W, MAX_DRAW_H / disp.L);
if (!isFinite(scale) || scale <= 0) scale = 1;
var wPx0 = disp.W * scale;
var hPx0 = disp.L * scale;
var bump = 1;
if (wPx0 < MIN_HULL_PX) bump = Math.max(bump, MIN_HULL_PX / Math.max(wPx0, 1e-6));
if (hPx0 < MIN_HULL_PX) bump = Math.max(bump, MIN_HULL_PX / Math.max(hPx0, 1e-6));
scale *= bump;
var wPx = disp.W * scale;
var hPx = disp.L * scale;
layout.wPx = wPx;
layout.hPx = hPx;
layout.dispL = disp.L;
layout.dispW = disp.W;
layout.scale = scale;
return { sx: scale, sy: scale, wPx: wPx, hPx: hPx };
}
function scales(d, disp, geo) {
var wPx = geo.wPx;
var hPx = geo.hPx;
var padX = Math.max(1, Math.min(8, wPx * 0.1));
var padY = Math.max(1, Math.min(8, hPx * 0.1));
var aVis = d.L > 0 ? d.A : disp.L / 2;
var cVis = d.W > 0 ? d.C : disp.W / 2;
var mx = clamp(cVis * geo.sx, padX, wPx - padX);
var my = clamp(aVis * geo.sy, padY, hPx - padY);
return { mx: mx, my: my };
}
function ensureNonZeroDims() {
var d = readDims();
if (d.L === 0 && d.W === 0) {
writeDims(MIN_LEN / 2, MIN_LEN / 2, MIN_BEAM / 2, MIN_BEAM / 2);
}
}
function clearG(g) {
while (g.firstChild) g.removeChild(g.firstChild);
}
function rebuildBeamDimension(g, wPx, hPx, Wm) {
clearG(g);
var bow = Math.min(20, hPx * 0.11);
var off = 24;
var y = -off;
var yPast = y - 1.5;
g.appendChild(
el('line', { class: 'ship-dim-ext', x1: 0, y1: bow, x2: 0, y2: yPast })
);
g.appendChild(
el('line', { class: 'ship-dim-ext', x1: wPx, y1: bow, x2: wPx, y2: yPast })
);
g.appendChild(
el('line', {
class: 'ship-dim-main',
x1: 0,
y1: y,
x2: wPx,
y2: y,
'marker-start': 'url(#ship-dim-arrow)',
'marker-end': 'url(#ship-dim-arrow)',
})
);
var t = el('text', {
class: 'ship-dim-txt',
x: wPx / 2,
y: y - 8,
'text-anchor': 'middle',
});
t.textContent = 'W = ' + Wm + ' м';
g.appendChild(t);
}
function rebuildLengthDimension(g, wPx, hPx, Lm) {
clearG(g);
var bow = Math.min(20, hPx * 0.11);
var off = 22;
var x = wPx + off;
var xPast = x + 1.5;
g.appendChild(
el('line', { class: 'ship-dim-ext', x1: wPx, y1: bow, x2: x, y2: bow })
);
g.appendChild(
el('line', { class: 'ship-dim-ext', x1: x, y1: bow, x2: x, y2: 0 })
);
g.appendChild(
el('line', { class: 'ship-dim-ext', x1: wPx, y1: hPx, x2: xPast, y2: hPx })
);
g.appendChild(
el('line', {
class: 'ship-dim-main',
x1: x,
y1: 0,
x2: x,
y2: hPx,
'marker-start': 'url(#ship-dim-arrow)',
'marker-end': 'url(#ship-dim-arrow)',
})
);
var t = el('text', {
class: 'ship-dim-txt',
x: x + 14,
y: hPx / 2,
'text-anchor': 'middle',
transform: 'rotate(-90 ' + (x + 14) + ' ' + hPx / 2 + ')',
});
t.textContent = 'L = ' + Lm + ' м';
g.appendChild(t);
}
function positionHandles(wPx, hPx) {
if (!handles) return;
var stern = handles.querySelector('[data-ship-edge="stern"]');
var sb = handles.querySelector('[data-ship-edge="starboard"]');
if (stern) stern.setAttribute('transform', 'translate(' + wPx / 2 + ',' + hPx + ')');
if (sb) sb.setAttribute('transform', 'translate(' + wPx + ',' + hPx / 2 + ')');
}
function refreshFromInputs() {
if (!svg || !inner || !hull || !marker) return;
var d = readDims();
var disp = displayLW(d);
var geo = computeLayout(d, disp);
var sc = scales(d, disp, geo);
var wPx = geo.wPx;
var hPx = geo.hPx;
inner.setAttribute('transform', 'translate(' + PAD_L + ',' + PAD_T + ')');
var vbW = PAD_L + wPx + PAD_R;
var vbH = PAD_T + hPx + PAD_B;
svg.setAttribute('viewBox', '0 0 ' + vbW + ' ' + vbH);
rebuildBeamDimension(dimBeam, wPx, hPx, disp.W);
rebuildLengthDimension(dimLength, wPx, hPx, disp.L);
hull.setAttribute('d', hullPath(wPx, hPx));
if (gridH && gridV) {
gridH.setAttribute('x1', 0);
gridH.setAttribute('y1', sc.my);
gridH.setAttribute('x2', wPx);
gridH.setAttribute('y2', sc.my);
gridV.setAttribute('x1', sc.mx);
gridV.setAttribute('y1', 0);
gridV.setAttribute('x2', sc.mx);
gridV.setAttribute('y2', hPx);
}
if (lblBow) {
lblBow.setAttribute('x', wPx / 2);
lblBow.setAttribute('y', -38);
}
if (lblStern) {
lblStern.setAttribute('x', wPx / 2);
lblStern.setAttribute('y', hPx + 22);
}
marker.setAttribute('transform', 'translate(' + sc.mx + ',' + sc.my + ')');
marker.setAttribute('class', 'ship-gps-group' + (disp.template ? ' ship-gps-group--template' : ''));
positionHandles(wPx, hPx);
}
function svgPointFromClient(clientX, clientY) {
var pt = svg.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
var ctm = inner.getScreenCTM();
if (!ctm) return null;
var p = pt.matrixTransform(ctm.inverse());
return { x: p.x, y: p.y };
}
function splitLength(u, Ls) {
u = clamp(u, 0, 1);
var A = Math.round(u * Ls);
A = clamp(A, 0, MAX_AB);
var B = Ls - A;
if (B > MAX_AB) {
B = MAX_AB;
A = clamp(Ls - B, 0, MAX_AB);
}
if (B < 0) {
B = 0;
A = clamp(Ls, 0, MAX_AB);
}
return { A: A, B: B };
}
function splitBeam(v, Ws) {
v = clamp(v, 0, 1);
var C = Math.round(v * Ws);
C = clamp(C, 0, MAX_CD);
var D = Ws - C;
if (D > MAX_CD) {
D = MAX_CD;
C = clamp(Ws - D, 0, MAX_CD);
}
if (D < 0) {
D = 0;
C = clamp(Ws, 0, MAX_CD);
}
return { C: C, D: D };
}
/** Новая длина L; сохраняем долю носа A/L ≈ rA. */
function abFromLength(Ln, rA) {
Ln = clamp(Math.round(Ln), 1, MAX_L);
var r = clamp(isFinite(rA) ? rA : 0.5, 0, 1);
var A = Math.round(r * Ln);
A = clamp(A, 0, MAX_AB);
var B = Ln - A;
if (B > MAX_AB) {
B = MAX_AB;
A = clamp(Ln - B, 0, MAX_AB);
}
if (B < 0) {
B = 0;
A = clamp(Ln, 0, MAX_AB);
}
return { A: A, B: B };
}
/** Новая ширина W; сохраняем долю порта C/W ≈ rC. */
function cdFromWidth(Wn, rC) {
Wn = clamp(Math.round(Wn), 1, MAX_W);
var r = clamp(isFinite(rC) ? rC : 0.5, 0, 1);
var C = Math.round(r * Wn);
C = clamp(C, 0, MAX_CD);
var D = Wn - C;
if (D > MAX_CD) {
D = MAX_CD;
C = clamp(Wn - D, 0, MAX_CD);
}
if (D < 0) {
D = 0;
C = clamp(Wn, 0, MAX_CD);
}
return { C: C, D: D };
}
function startDrag(ev, kind, captureEl) {
ev.preventDefault();
captureEl.setPointerCapture(ev.pointerId);
document.body.classList.add('ship-editor-dragging');
document.addEventListener('pointermove', onDocumentPointerMove);
document.addEventListener('pointerup', onDocumentPointerEnd);
document.addEventListener('pointercancel', onDocumentPointerEnd);
return { kind: kind, pid: ev.pointerId, el: captureEl };
}
function stopDragListeners() {
document.removeEventListener('pointermove', onDocumentPointerMove);
document.removeEventListener('pointerup', onDocumentPointerEnd);
document.removeEventListener('pointercancel', onDocumentPointerEnd);
}
function onInnerPointerDown(ev) {
if (!inner) return;
var edgeG = ev.target.closest ? ev.target.closest('[data-ship-edge]') : null;
if (edgeG) {
ensureNonZeroDims();
var d0 = readDims();
var disp0 = displayLW(d0);
var geo0 = computeLayout(d0, disp0);
var kind = edgeG.getAttribute('data-ship-edge');
drag = startDrag(ev, kind, edgeG);
drag.L0 = d0.L;
drag.W0 = d0.W;
drag.rA = d0.L > 0 ? d0.A / d0.L : 0.5;
drag.rC = d0.W > 0 ? d0.C / d0.W : 0.5;
drag.wPxRef = geo0.wPx;
drag.hPxRef = geo0.hPx;
return;
}
if (marker && marker.contains(ev.target)) {
ensureNonZeroDims();
var dg = readDims();
var Ls = dg.L > 0 ? Math.min(dg.L, MAX_L) : MIN_LEN;
var Ws = dg.W > 0 ? Math.min(dg.W, MAX_W) : MIN_BEAM;
Ls = clamp(Ls, 1, MAX_L);
Ws = clamp(Ws, 1, MAX_W);
drag = startDrag(ev, 'gps', marker);
drag.Ls = Ls;
drag.Ws = Ws;
}
}
function onDocumentPointerMove(ev) {
if (!drag || ev.pointerId !== drag.pid) return;
var p = svgPointFromClient(ev.clientX, ev.clientY);
if (!p) return;
var d = readDims();
var disp = displayLW(d);
var geo = computeLayout(d, disp);
var wPx = geo.wPx;
var hPx = geo.hPx;
if (drag.kind === 'gps') {
var u = p.y / hPx;
var v = p.x / wPx;
var ab = splitLength(u, drag.Ls);
var cd = splitBeam(v, drag.Ws);
writeDims(ab.A, ab.B, cd.C, cd.D);
refreshFromInputs();
return;
}
var wRef = drag.wPxRef;
var hRef = drag.hPxRef;
if (drag.kind === 'stern' && wRef > 0 && hRef > 0) {
var yMin = (hRef * 1) / Math.max(drag.L0, 1);
var yMax = (hRef * MAX_L) / Math.max(drag.L0, 1);
var yS = clamp(p.y, yMin, yMax);
var L1 = Math.round((drag.L0 * yS) / hRef);
L1 = clamp(L1, 1, MAX_L);
var abS = abFromLength(L1, drag.rA);
writeDims(abS.A, abS.B, d.C, d.D);
} else if (drag.kind === 'starboard' && wRef > 0 && hRef > 0) {
var xMin = (wRef * 1) / Math.max(drag.W0, 1);
var xMax = (wRef * MAX_W) / Math.max(drag.W0, 1);
var xSb = clamp(p.x, xMin, xMax);
var W1s = Math.round((drag.W0 * xSb) / wRef);
W1s = clamp(W1s, 1, MAX_W);
var cd2 = cdFromWidth(W1s, drag.rC);
writeDims(d.A, d.B, cd2.C, cd2.D);
}
refreshFromInputs();
}
function onDocumentPointerEnd(ev) {
if (!drag || ev.pointerId !== drag.pid) return;
stopDragListeners();
try {
drag.el.releasePointerCapture(ev.pointerId);
} catch (e) { /* ignore */ }
drag = null;
document.body.classList.remove('ship-editor-dragging');
}
function bindInputs() {
['tp-to-bow', 'tp-to-stern', 'tp-to-port', 'tp-to-starboard'].forEach(function (id) {
var eln = document.getElementById(id);
if (eln) {
eln.addEventListener('input', refreshFromInputs);
eln.addEventListener('change', refreshFromInputs);
}
});
}
function boot() {
svg = document.getElementById('ship-editor-svg');
inner = document.getElementById('ship-editor-inner');
hull = document.getElementById('ship-hull');
marker = document.getElementById('ship-gps-marker');
handles = document.getElementById('ship-edge-handles');
gridH = document.getElementById('ship-grid-h');
gridV = document.getElementById('ship-grid-v');
lblBow = document.getElementById('ship-lbl-bow');
lblStern = document.getElementById('ship-lbl-stern');
dimBeam = document.getElementById('ship-dim-beam');
dimLength = document.getElementById('ship-dim-length');
if (!svg || !inner || !hull || !marker) return;
inner.addEventListener('pointerdown', onInnerPointerDown);
bindInputs();
refreshFromInputs();
}
window.shipDimsEditorRefresh = refreshFromInputs;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();
+179
View File
@@ -0,0 +1,179 @@
/**
* ITU-R M.1371-6, табл. 51 — тип судна (идентификатор 0–99).
* Подписи на русском по официальным формулировкам (сокращ.: ОПГ — опасные грузы;
* ОН — опасные только насыпью; ВВ — вредные вещества; МЗ — морские загрязнители).
*/
(function () {
'use strict';
var LABELS = [
'Не указано',
'Научно-исследовательское судно',
'Учебное судно',
'Судно, принадлежащее или эксплуатируемое государственным органом',
'Ледокол',
'Судно обслуживания буёв (навигационных знаков)',
'Кабелеукладчик',
'Трубоукладчик',
'Зарезервировано (на будущее)',
'Судно специального назначения, дополнительные сведения не указаны',
'Зарезервировано (на будущее)',
'Судно FPSO (добыча, хранение и отгрузка нефти на месторождении)',
'Рыбозавод (плавучий рыбоперерабатывающий завод)',
'Судно обеспечения рыбоводческого хозяйства',
'Судно оффшорного обеспечения и т. п.',
'Зарезервировано (на будущее)',
'Зарезервировано (на будущее)',
'Судно для строительных работ',
'Катер доставки экипажа (crew boat)',
'Судно обеспечения, дополнительные сведения не указаны',
'Судно на воздушной подушке (WIG), все суда этого типа',
'WIG с ОПГ и/или ОН, ВВ или МЗ, категория опасности X (ИМО)',
'WIG с ОПГ и/или ОН, ВВ или МЗ, категория опасности Y (ИМО)',
'WIG с ОПГ и/или ОН, ВВ или МЗ, категория опасности Z (ИМО)',
'WIG с ОПГ и/или ОН, ВВ или МЗ, категория OS (ИМО)',
'Зарезервировано (на будущее)',
'Зарезервировано (на будущее)',
'Зарезервировано (на будущее)',
'Зарезервировано (на будущее)',
'WIG, дополнительные сведения не указаны',
'Рыболовное судно',
'Буксир',
'Буксир; длина буксира >200 м или ширина >25 м',
'Земснаряд',
'Водолазное судно',
'Военный корабль или вспомогательное судно ВМС',
'Парусное судно',
'Прогулочное моторное судно',
'Тральщик',
'Патрульное судно',
'Высокоскоростное судно (ВСС), все суда этого типа',
'ВСС с ОПГ и/или ОН, ВВ или МЗ, категория X (ИМО)',
'ВСС с ОПГ и/или ОН, ВВ или МЗ, категория Y (ИМО)',
'ВСС с ОПГ и/или ОН, ВВ или МЗ, категория Z (ИМО)',
'ВСС с ОПГ и/или ОН, ВВ или МЗ, категория OS (ИМО)',
'ВСС, перевозка пассажиров',
'ВСС Ro-Ro (автомобили / ж/д)',
'Зарезервировано (на будущее)',
'Зарезервировано (на будущее)',
'ВСС, дополнительные сведения не указаны',
'Лоцманское судно',
'Поисково-спасательное судно',
'Буксиры',
'Портовое или рыболовное вспомогательное судно',
'Противозагрязнение или пожарный резерв',
'Судно правоохранительных органов',
'Резерв 1 — для назначений местным судам',
'Резерв 2 — для назначений местным судам',
'Медицинский транспорт (Женевские конвенции 1949 г. и Доп. протоколы)',
'Судна государств, не участвующих в вооружённом конфликте',
'Пассажирское судно, все суда этого типа',
'Пассажирское с ОПГ и/или ОН, ВВ или МЗ, категория X (ИМО)',
'Пассажирское с ОПГ и/или ОН, ВВ или МЗ, категория Y (ИМО)',
'Пассажирское с ОПГ и/или ОН, ВВ или МЗ, категория Z (ИМО)',
'Пассажирское с ОПГ и/или ОН, ВВ или МЗ, категория OS (ИМО)',
'Пассажирский лайнер (круизное судно)',
'Пассажирский паром',
'Пассажирское прогулочное (напр. по гавани, наблюдение за китами)',
'Зарезервировано (на будущее)',
'Пассажирское судно, дополнительные сведения не указаны',
'Грузовое судно, все суда этого типа',
'Грузовое с ОПГ и/или ОН, ВВ или МЗ, категория X (ИМО)',
'Грузовое с ОПГ и/или ОН, ВВ или МЗ, категория Y (ИМО)',
'Грузовое с ОПГ и/или ОН, ВВ или МЗ, категория Z (ИМО)',
'Грузовое с ОПГ и/или ОН, ВВ или МЗ, категория OS (ИМО)',
'Грузовое судно, балкер',
'Грузовое судно, контейнеровоз',
'Грузовое судно, Ro-Ro',
'Грузовое судно, десантная баржа',
'Грузовое судно, дополнительные сведения не указаны',
'Танкер, все суда этого типа',
'Танкер с ОПГ и/или ОН, ВВ или МЗ, категория X (ИМО)',
'Танкер с ОПГ и/или ОН, ВВ или МЗ, категория Y (ИМО)',
'Танкер с ОПГ и/или ОН, ВВ или МЗ, категория Z (ИМО)',
'Танкер с ОПГ и/или ОН, ВВ или МЗ, категория OS (ИМО)',
'Танкер, неопасный груз / не загрязняющий',
'Составной буксир и танкерная баржа (A–D — буксир и баржа)',
'Зарезервировано (на будущее)',
'Зарезервировано (на будущее)',
'Танкер, дополнительные сведения не указаны',
'Прочий тип судна',
'Прочий тип с ОПГ и/или ОН, ВВ или МЗ, категория X (ИМО)',
'Прочий тип с ОПГ и/или ОН, ВВ или МЗ, категория Y (ИМО)',
'Прочий тип с ОПГ и/или ОН, ВВ или МЗ, категория Z (ИМО)',
'Прочий тип с ОПГ и/или ОН, ВВ или МЗ, категория OS (ИМО)',
'Зарезервировано (на будущее)',
'Зарезервировано (на будущее)',
'Зарезервировано (на будущее)',
'Зарезервировано (на будущее)',
'Прочий тип судна, дополнительные сведения не указаны',
];
var GROUPS = [
{ from: 1, to: 9, title: '01–09 Судно специального назначения' },
{ from: 10, to: 19, title: '10–19 Судно обеспечения' },
{ from: 20, to: 29, title: '20–29 Судно на воздушной подушке (WIG)' },
{ from: 30, to: 39, title: '3039 Особое судно' },
{ from: 40, to: 49, title: '40–49 Высокоскоростное судно (ВСС)' },
{ from: 50, to: 59, title: '5059 Особое судно' },
{ from: 60, to: 69, title: '60–69 Пассажирское судно' },
{ from: 70, to: 79, title: '70–79 Грузовое судно' },
{ from: 80, to: 89, title: '8089 Танкер' },
{ from: 90, to: 99, title: '9099 Прочие типы' },
];
function addOption(sel, value, text) {
var o = document.createElement('option');
o.value = String(value);
o.textContent = String(value) + ' — ' + text;
sel.appendChild(o);
}
function fillShipTypeSelect() {
var sel = document.getElementById('tp-ship-type');
if (!sel || sel.getAttribute('data-table51') === '1') return;
sel.setAttribute('data-table51', '1');
sel.innerHTML = '';
addOption(sel, 0, LABELS[0]);
GROUPS.forEach(function (g) {
var og = document.createElement('optgroup');
og.label = g.title;
for (var c = g.from; c <= g.to; c++) {
var o = document.createElement('option');
o.value = String(c);
var pad = c < 10 ? '0' + c : String(c);
o.textContent = pad + ' — ' + LABELS[c];
og.appendChild(o);
}
sel.appendChild(og);
});
}
function ensureShipTypeOption(value) {
var sel = document.getElementById('tp-ship-type');
if (!sel) return;
var v = Math.max(0, Math.min(255, parseInt(value, 10) || 0));
var s = String(v);
for (var i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === s) return;
}
var o = document.createElement('option');
o.value = s;
o.textContent = s + ' — (из конфигурации, вне табл. 51 / 099)';
sel.appendChild(o);
}
window.fillShipTypeSelect = fillShipTypeSelect;
window.ensureShipTypeOption = ensureShipTypeOption;
window.AIS_TABLE51_SHIP_TYPE_LABEL = function (code) {
var c = parseInt(code, 10);
if (isNaN(c) || c < 0 || c > 99) return null;
return LABELS[c] || null;
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fillShipTypeSelect);
} else {
fillShipTypeSelect();
}
})();
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

File diff suppressed because it is too large Load Diff
+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
File diff suppressed because one or more lines are too long
+151
View File
@@ -0,0 +1,151 @@
// AIS Map Service Worker — кеширование тайлов и ассетов.
// Цели: моментальная отрисовка карты после первой загрузки, офлайн-доступ к ранее
// просмотренным тайлам, минимум сетевых запросов в slow-networks и при pan/zoom.
const CACHE_VERSION = 'v2';
const TILE_CACHE = 'aismap-tiles-' + CACHE_VERSION;
const ASSET_CACHE = 'aismap-assets-' + CACHE_VERSION;
const APP_CACHE = 'aismap-app-' + CACHE_VERSION;
// Ограничиваем, сколько тайлов держим на устройстве, чтобы не забить storage на телефоне.
const TILE_CACHE_MAX = 4000;
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(
keys
.filter((k) => ![TILE_CACHE, ASSET_CACHE, APP_CACHE].includes(k))
.map((k) => caches.delete(k))
);
await self.clients.claim();
})());
});
function isTileRequest(url) {
return url.pathname.startsWith('/tiles/') || url.pathname.startsWith('/vtiles/');
}
function isLongLivedAsset(url) {
if (url.pathname.startsWith('/static/leaflet/')) return true;
if (url.pathname.startsWith('/static/xterm/')) return true;
if (url.pathname.startsWith('/svg/')) return true;
return false;
}
function isAppShell(url) {
if (url.pathname === '/' || url.pathname === '/cert') return true;
if (url.pathname.startsWith('/static/js/')) return true;
if (url.pathname.startsWith('/static/css/')) return true;
return false;
}
function isApiRequest(url) {
return url.pathname.startsWith('/api/') || url.pathname === '/ws' || url.pathname.startsWith('/ws/');
}
async function trimCache(cacheName, max) {
try {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
if (keys.length <= max) return;
const excess = keys.length - max;
for (let i = 0; i < excess; i += 1) {
await cache.delete(keys[i]);
}
} catch (e) {}
}
// Cache-first с фоновым обновлением: отдаём из кеша если есть, в фоне освежаем.
async function cacheFirstWithRefresh(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetchAndUpdate = fetch(request)
.then((resp) => {
if (resp && resp.ok) {
cache.put(request, resp.clone()).catch(() => {});
if (cacheName === TILE_CACHE) {
trimCache(TILE_CACHE, TILE_CACHE_MAX);
}
}
return resp;
})
.catch(() => null);
if (cached) {
// Обновление в фоне, клиенту отдаём кеш немедленно.
fetchAndUpdate.catch(() => {});
return cached;
}
const fresh = await fetchAndUpdate;
if (fresh) return fresh;
return new Response('', { status: 504, statusText: 'Gateway Timeout (offline)' });
}
// Stale-while-revalidate: мгновенно из кеша, параллельно обновляем кеш.
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const networkFetch = fetch(request)
.then((resp) => {
if (resp && resp.ok) {
cache.put(request, resp.clone()).catch(() => {});
}
return resp;
})
.catch(() => null);
if (cached) {
networkFetch.catch(() => {});
return cached;
}
const fresh = await networkFetch;
if (fresh) return fresh;
return new Response('', { status: 504, statusText: 'Gateway Timeout (offline)' });
}
self.addEventListener('fetch', (event) => {
const req = event.request;
if (req.method !== 'GET') return;
let url;
try {
url = new URL(req.url);
} catch (e) {
return;
}
// Не трогаем кросс-оригинальные запросы (OSM, CARTO и т.п.) — пусть идут напрямую.
if (url.origin !== self.location.origin) return;
// API и WebSocket — без кеша. WebSocket вообще не проходит через fetch, но на всякий случай.
if (isApiRequest(url)) return;
if (isTileRequest(url)) {
event.respondWith(cacheFirstWithRefresh(req, TILE_CACHE));
return;
}
if (isLongLivedAsset(url)) {
event.respondWith(cacheFirstWithRefresh(req, ASSET_CACHE));
return;
}
if (isAppShell(url)) {
event.respondWith(staleWhileRevalidate(req, APP_CACHE));
return;
}
});
// Принудительный сброс кешей по команде со страницы.
self.addEventListener('message', (event) => {
if (!event.data || typeof event.data !== 'object') return;
if (event.data.type === 'aismap:clear-caches') {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
})());
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Some files were not shown because too many files have changed in this diff Show More