Initial import: WebAisMap
Closes TG-4
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.pem
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# WebAisMap
|
||||||
|
|
||||||
|
AIS map web application (Python backend + Leaflet frontend).
|
||||||
@@ -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`
|
||||||
|
- Скачивать тайлы в несколько этапов (разные уровни масштабирования отдельно)
|
||||||
|
- Делать перерывы между сессиями скачивания
|
||||||
|
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -0,0 +1 @@
|
|||||||
|
# gr-aistx-compatible PHY (phy.py) + optional encode_to_nrzi CLI
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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)}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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."
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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 ==="
|
||||||
@@ -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 ==="
|
||||||
@@ -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}")
|
||||||
@@ -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 в % (0–100), как в 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
|
||||||
@@ -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"}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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: '30–39 Особое судно' },
|
||||||
|
{ from: 40, to: 49, title: '40–49 Высокоскоростное судно (ВСС)' },
|
||||||
|
{ from: 50, to: 59, title: '50–59 Особое судно' },
|
||||||
|
{ from: 60, to: 69, title: '60–69 Пассажирское судно' },
|
||||||
|
{ from: 70, to: 79, title: '70–79 Грузовое судно' },
|
||||||
|
{ from: 80, to: 89, title: '80–89 Танкер' },
|
||||||
|
{ from: 90, to: 99, title: '90–99 Прочие типы' },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 / 0–99)';
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 618 B |
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
})());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 21 KiB |