Compare commits

..

No commits in common. "main" and "front-v1" have entirely different histories.

38 changed files with 760 additions and 2603 deletions

View file

@ -1,46 +0,0 @@
## Nuitka build (backend)
### Важно
- `gdal2tiles.py` и GDAL — это системные бинарники/библиотеки, Nuitka их не “впаяет” в один файл. На целевой машине GDAL всё равно должен быть установлен, если ты используешь `/tiles/slice` (нарезку).
- Для onefile-режима бинарник распаковывается во временную папку, поэтому проектные папки внутри пакета не должны использоваться для записи. В коде это решено через `GNSS_DATA_DIR`.
### Где лежат данные
По умолчанию:
- в обычном режиме (запуск из исходников) данные лежат рядом с пакетом (`gnss/`).
- в “замороженном” режиме (`nuitka --onefile`) данные идут в `~/.local/share/gnss`.
Можно переопределить:
- `GNSS_DATA_DIR=/path/to/data`
Структура:
- `$GNSS_DATA_DIR/tilesets/<tileset>/...`
- `$GNSS_DATA_DIR/uploads/...`
- `$GNSS_DATA_DIR/tiles/...` (опционально)
### Запуск без Nuitka (для проверки)
`poetry run gnss-backend --host 0.0.0.0 --port 8000`
### Установка Nuitka (в Poetry)
Рекомендовано держать Nuitka как dev-зависимость:
`poetry add --group dev nuitka`
### Сборка (standalone + onefile)
Пример:
`poetry run python -m nuitka --onefile --standalone --follow-imports --assume-yes-for-downloads --output-dir=dist --output-filename=gnss-backend gnss/__main__.py`
После сборки:
- бинарник будет в `dist/` (например `dist/gnss-backend.bin`)
### Запуск бинарника
`GNSS_DATA_DIR=./data ./dist/gnss-backend.bin --host 0.0.0.0 --port 8000`
Положи tileset’ы сюда:
- `./data/tilesets/<name>/...`

View file

@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GoAgro</title>
<title>Tauri + React + Typescript</title>
</head>
<body>

View file

@ -1,4 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.75 8.5C16.75 13.75 10 18.25 10 18.25C10 18.25 3.25 13.75 3.25 8.5C3.25 6.70979 3.96116 4.9929 5.22703 3.72703C6.4929 2.46116 8.20979 1.75 10 1.75C11.7902 1.75 13.5071 2.46116 14.773 3.72703C16.0388 4.9929 16.75 6.70979 16.75 8.5Z" fill="#2563EB" stroke="#2563EB" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 10.75C11.2426 10.75 12.25 9.74264 12.25 8.5C12.25 7.25736 11.2426 6.25 10 6.25C8.75736 6.25 7.75 7.25736 7.75 8.5C7.75 9.74264 8.75736 10.75 10 10.75Z" fill="white" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 708 B

View file

@ -1,5 +0,0 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.25 14.5H16.75" stroke="#1D1F1E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.25 10H16.75" stroke="#1D1F1E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M3.25 5.5H16.75" stroke="#1D1F1E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 436 B

View file

@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

1
frontend/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,121 +1,78 @@
:root {
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
color-scheme: dark;
--bg: #050816;
--gradient-a: none;
--gradient-b: none;
--text: #f4f7fb;
--muted: #94a3b8;
--surface: rgba(255, 255, 255, 0.03);
--surface-strong: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.08);
--border-strong: rgba(255, 255, 255, 0.1);
--pill-bg: rgba(255, 255, 255, 0.06);
--pill-border: rgba(255, 255, 255, 0.1);
--panel-shadow: none;
--input-bg: rgba(255, 255, 255, 0.05);
--input-border: rgba(255, 255, 255, 0.08);
--btn-ghost-bg: rgba(255, 255, 255, 0.05);
--btn-ghost-border: rgba(255, 255, 255, 0.08);
--primary: #2563eb;
--glass: rgba(255, 255, 255, 0.04);
--glass-border: rgba(255, 255, 255, 0.08);
}
:root[data-theme="light"] {
color-scheme: light;
--bg: #f6f8ff;
--gradient-a: none;
--gradient-b: none;
--text: #0f172a;
--muted: #64748b;
--surface: rgba(255, 255, 255, 0.92);
--surface-strong: #ffffff;
--border: rgba(15, 23, 42, 0.08);
--border-strong: rgba(15, 23, 42, 0.12);
--pill-bg: rgba(15, 23, 42, 0.05);
--pill-border: rgba(15, 23, 42, 0.1);
--panel-shadow: none;
--input-bg: rgba(255, 255, 255, 0.98);
--input-border: rgba(15, 23, 42, 0.14);
--btn-ghost-bg: rgba(255, 255, 255, 0.94);
--btn-ghost-border: rgba(15, 23, 42, 0.12);
--glass: rgba(255, 255, 255, 0.9);
--glass-border: rgba(15, 23, 42, 0.08);
--primary: #3B82F6;
background: #050816;
color: #e8ecf4;
}
* {
box-sizing: border-box;
}
.maplibre .maplibregl-ctrl-attrib {
display: none !important;
}
body, #root {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
}
.layout {
min-height: 100vh;
height: 100vh;
background: var(--bg);
color: var(--text);
background: radial-gradient(circle at 20% 20%, rgba(126, 221, 252, 0.08), transparent 25%),
radial-gradient(circle at 80% 0%, rgba(255, 135, 252, 0.08), transparent 22%),
#050816;
color: #f4f7fb;
display: flex;
flex-direction: column;
transition: background 0.2s ease, color 0.2s ease;
}
.content {
width: 100%;
max-width: none;
margin: 0;
.topbar {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1 1 auto;
min-height: 0;
justify-content: space-between;
align-items: center;
padding: 18px 22px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.04);
}
.status-strip {
flex: 0 0 auto;
padding: 6px 10px;
background: var(--surface);
border-bottom: 1px solid var(--border);
font-size: 12px;
}
.mini-info {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
padding: 0;
}
.mini-info.compact {
align-items: stretch;
}
.chip {
padding: 4px 10px;
border-radius: 10px;
background: var(--surface-strong);
.brand {
display: flex;
flex-direction: column;
gap: 2px;
}
.leaflet-container{
height: 100% !important;
}
.chip.small span {
font-size: 12px;
gap: 12px;
align-items: center;
}
.chip.small strong {
font-size: 16px;
.pulse {
width: 12px;
height: 12px;
border-radius: 50%;
background: #f59e0b;
box-shadow: 0 0 0 8px rgba(245, 158, 11, 0.2);
animation: pulse 2.4s infinite;
}
.pulse.ok {
background: #34d399;
box-shadow: 0 0 0 8px rgba(52, 211, 153, 0.18);
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.2);
}
100% {
transform: scale(1);
}
}
.title {
font-weight: 700;
letter-spacing: 0.4px;
}
.subtitle {
color: #94a3b8;
font-size: 13px;
}
.pill {
@ -124,13 +81,14 @@ body, #root {
align-items: center;
padding: 8px 12px;
border-radius: 999px;
background: var(--pill-bg);
border: 1px solid var(--pill-border);
background: linear-gradient(135deg, rgba(125, 211, 252, 0.18), rgba(252, 165, 255, 0.18));
border: 1px solid rgba(255, 255, 255, 0.1);
}
.pill-label {
color: var(--muted);
color: #cbd5e1;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.6px;
}
@ -138,35 +96,37 @@ body, #root {
font-weight: 700;
}
.content {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 14px;
padding: 14px 18px 18px;
flex: 1;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
padding: 14px;
box-shadow: var(--panel-shadow);
transition: background 0.2s ease, border-color 0.2s ease;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35);
}
.info-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
padding-bottom: 32px;
}
.card {
width: 141px;
height: 141px;
padding: 12px;
border-radius: 12px;
background: var(--surface-strong);
border: 1px solid var(--border);
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.card-label {
color: var(--muted);
color: #94a3b8;
font-size: 13px;
margin-bottom: 4px;
}
@ -174,52 +134,38 @@ body, #root {
.card-value {
font-size: 20px;
font-weight: 700;
color: var(--text);
}
.map-shell {
flex: 1 1 auto;
min-height: 0;
padding: 0;
}
.map-panel {
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
flex: 1 1 auto;
min-height: 0;
background: transparent;
border: none;
}
.map {
height: 100%;
min-height: 0;
border-radius: 0;
height: 70vh;
border-radius: 12px;
overflow: hidden;
border: none;
width: 100%;
}
.maplibre {
height: 100%;
min-height: 0;
border-radius: 0;
height: 70vh;
border-radius: 12px;
overflow: hidden;
border: none;
width: 100%;
}
.map-switch {
display: flex;
gap: 10px;
padding: 12px;
}
.empty {
padding: 24px;
text-align: center;
color: var(--muted);
color: #94a3b8;
}
.leaflet-control-container {
filter: none;
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.25));
}
.slice-panel {
@ -232,8 +178,6 @@ body, #root {
display: flex;
justify-content: space-between;
align-items: center;
padding: 32px 0;
border-top: 1px solid var(--border);
}
.panel-title {
@ -242,15 +186,15 @@ body, #root {
}
.panel-subtitle {
color: var(--muted);
color: #94a3b8;
font-size: 13px;
}
.badge {
padding: 6px 10px;
border-radius: 999px;
background: var(--btn-ghost-bg);
border: 1px solid var(--border-strong);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
text-transform: uppercase;
font-size: 12px;
letter-spacing: 0.6px;
@ -267,44 +211,29 @@ body, #root {
}
.slice-grid {
display: flex;
flex-direction: column;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
padding-bottom: 80px;
}
.field {
display: grid;
grid-template-columns: 180px 1fr;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 12px;
background: var(--surface-strong);
border: 1px solid var(--border);
}
.field.compact {
grid-template-columns: 160px 1fr;
padding: 8px 10px;
background: var(--surface);
display: flex;
flex-direction: column;
gap: 6px;
}
.field span {
color: var(--muted);
color: #94a3b8;
font-size: 13px;
font-weight: 600;
}
.field input,
.field select,
.tileset-row select {
.field select {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--input-border);
background: var(--input-bg);
color: var(--text);
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: #f4f7fb;
}
.tileset-row {
@ -317,16 +246,9 @@ body, #root {
flex: 1;
}
.tileset-row.stacked {
flex-direction: row;
align-items: center;
}
.file-row {
display: flex;
gap: 8px;
align-items: center;
width: 100%;
}
.file-row input {
@ -334,51 +256,14 @@ body, #root {
}
.muted {
color: var(--muted);
color: #94a3b8;
font-size: 12px;
}
.field .muted {
grid-column: 2;
}
.actions-row {
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
justify-content: end;
}
.field.actions {
grid-template-columns: 1fr 1fr;
background: var(--surface);
}
.field.actions span {
color: var(--muted);
}
@media (max-width: 720px) {
.field,
.field.compact,
.field.actions {
grid-template-columns: 1fr;
align-items: flex-start;
}
.field span {
margin-bottom: 6px;
}
.field .muted {
grid-column: 1;
}
.tileset-row.stacked {
flex-direction: column;
align-items: stretch;
}
align-items: flex-end;
}
.btn {
@ -387,119 +272,29 @@ body, #root {
border: 1px solid transparent;
cursor: pointer;
font-weight: 700;
transition: transform 0.12s ease, border 0.12s ease, background 0.2s ease;
color: var(--text);
background: transparent;
}
.btn.small {
padding: 8px 12px;
font-size: 14px;
transition: transform 0.12s ease, border 0.12s ease, box-shadow 0.12s ease;
}
.btn.primary {
background: var(--primary);
color: var(--bg);
background: linear-gradient(135deg, #38bdf8, #8b5cf6);
color: #0b1020;
box-shadow: 0 10px 30px -12px rgba(56, 189, 248, 0.8);
}
.btn.ghost {
background: var(--btn-ghost-bg);
color: var(--text);
border-color: var(--btn-ghost-border);
background: rgba(255, 255, 255, 0.05);
color: #e8ecf4;
border-color: rgba(255, 255, 255, 0.08);
}
.bottom-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
width: 100%;
height: 10vh;
min-height: 64px;
max-height: 120px;
}
.bottom-settings{
display: grid;
grid-template-columns: 1fr;
gap: 0;
width: 100%;
height: 10vh;
min-height: 64px;
max-height: 120px;
position: absolute;
bottom: 0;
left: 0;
}
.bottom-settings .btn.block {
width: 100%;
height: 100%;
border-radius: 0;
font-size: 16px;
}
.bottom-actions .btn.block {
width: 100%;
height: 100%;
border-radius: 0;
font-size: 16px;
}
.bottom-actions .btn.ghost {
border-right: 1px solid var(--border);
}
.settings-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.3);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
z-index: 30;
}
.settings-backdrop.open {
opacity: 1;
pointer-events: auto;
z-index: 999;
}
.settings-panel {
position: fixed;
top: 0;
right: 0;
width: 100vw;
height: 100vh;
background: var(--bg);
border-left: 1px solid var(--border);
transform: translateX(100%);
transition: transform 0.25s ease;
z-index: 40;
display: flex;
flex-direction: column;
padding: 18px 0;
gap: 12px;
}
.settings-panel.open {
transform: translateX(0);
z-index: 1000;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 18px;
}
.settings-content {
overflow: auto;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
.btn:hover {
transform: translateY(-1px);
}
@media (max-width: 900px) {
.content {
grid-template-rows: auto 1fr auto;
}
.map {
height: 60vh;
}

View file

@ -1,66 +1,167 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { MapContainer, Marker, Polyline, TileLayer, useMap } from "react-leaflet";
import L, { LatLngExpression } from "leaflet";
import "./App.css";
import "leaflet/dist/leaflet.css";
import "maplibre-gl/dist/maplibre-gl.css";
import { MapPanel } from "./components/MapPanel";
import { SettingsPanel } from "./components/SettingsPanel";
import { TrackSummary } from "./components/TrackSummary";
import { useVectorMap } from "./hooks/useVectorMap";
import { getInitialTheme } from "./utils/theme";
import type { AppConfig, IncomingMessage, MapMode, SliceParams, TrackPoint, TrackPositions } from "./types";
type FixMessage = {
type: "fix";
lat: number;
lon: number;
alt: number | null;
timestamp: string;
source: string;
fresh: boolean;
detail?: string;
};
type StatusMessage = {
type: "status";
status: string;
timestamp: string;
detail?: string;
};
type IncomingMessage = FixMessage | StatusMessage;
type AppConfig = {
tiles_url: string;
tiles_url_tms?: string;
vector_tiles_url?: string;
vector_metadata_url?: string;
tilesets_url?: string;
tilesets_activate_url?: string;
ws_url: string;
gnss_source: string;
};
type TrackPoint = {
lat: number;
lon: number;
timestamp: string;
fresh: boolean;
};
const defaultCenter: LatLngExpression = [55.751244, 37.618423];
const backendBase =
(import.meta.env.VITE_BACKEND_URL as string | undefined) ?? "http://localhost:8000";
const initialSliceParams: SliceParams = {
const markerIcon = new L.Icon({
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
});
function FlyTo({ point }: { point: TrackPoint | null }) {
const map = useMap();
useEffect(() => {
if (point) {
map.flyTo([point.lat, point.lon], Math.max(map.getZoom(), 14), { duration: 0.6 });
}
}, [point, map]);
return null;
}
function formatTime(ts?: string) {
if (!ts) return "—";
try {
return new Date(ts).toLocaleTimeString();
} catch {
return ts;
}
}
export default function App() {
const [config, setConfig] = useState<AppConfig | null>(null);
const [status, setStatus] = useState<string>("connecting");
const [track, setTrack] = useState<TrackPoint[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const [mapMode, setMapMode] = useState<"raster" | "vector">("raster");
const [tilesets, setTilesets] = useState<string[]>([]);
const [activeTileset, setActiveTileset] = useState<string>("");
const [selectedFileName, setSelectedFileName] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [sliceParams, setSliceParams] = useState({
source: "",
destination: "",
tileset: "",
min_zoom: 10,
max_zoom: 16,
tile_format: "png",
scheme: "xyz",
source_format: "geotiff",
scheme: "xyz" as "xyz" | "tms",
source_format: "geotiff" as "geotiff" | "mbtiles" | "tms" | "xyz",
});
const [sliceStatus, setSliceStatus] = useState<string>("idle");
const maplibreContainerRef = useRef<HTMLDivElement | null>(null);
const maplibreRef = useRef<any>(null);
const uploadFileBlob = async (file: File) => {
setSliceStatus("uploading");
const form = new FormData();
form.append("file", file);
return fetch(`${backendBase}/tiles/upload`, {
method: "POST",
body: form,
})
.then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(j.detail || r.status))))
.then((res) => {
setSliceParams((p) => ({ ...p, source: res.path }));
setSelectedFileName(res.filename);
setSliceParams((p) => {
if (p.tileset) return p;
const proposed = String(res.filename || "")
.replace(/\.[^.]+$/, "")
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
return proposed ? { ...p, tileset: proposed } : p;
});
setSliceStatus("idle");
sendFrontLog(`uploaded source ${res.filename}`, "info");
if (res.filename?.toLowerCase?.().endsWith(".mbtiles")) {
activateVectorMbtiles(res.path).catch((err) => {
sendFrontLog(`vector activate failed: ${err}`, "warn");
});
}
})
.catch((err) => {
setSliceStatus("error");
sendFrontLog(`upload error: ${err}`, "error");
});
};
export default function App() {
const [theme, setTheme] = useState<"dark" | "light">(getInitialTheme);
const [settingsOpen, setSettingsOpen] = useState<boolean>(false);
const [config, setConfig] = useState<AppConfig | null>(null);
const [status, setStatus] = useState<string>("connecting");
const [track, setTrack] = useState<TrackPoint[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const [mapMode, setMapMode] = useState<MapMode>("raster");
const [gnssMode, setGnssMode] = useState<"simulated" | "serial" | "unknown">("unknown");
const [simCenterLat, setSimCenterLat] = useState<string>("");
const [simCenterLon, setSimCenterLon] = useState<string>("");
const [vectorSource, setVectorSource] = useState<"mbtiles" | "tileset">("mbtiles");
const [tilesets, setTilesets] = useState<string[]>([]);
const [activeTileset, setActiveTileset] = useState<string>("");
const [tilesetKinds, setTilesetKinds] = useState<Record<string, string>>({});
const [selectedFileName, setSelectedFileName] = useState<string>("");
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [sliceParams, setSliceParams] = useState<SliceParams>(initialSliceParams);
const [sliceStatus, setSliceStatus] = useState<string>("idle");
const triggerFileSelect = async () => {
try {
if ((window as any).__TAURI__) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
multiple: false,
filters: [
{ name: "Raster/MBTiles", extensions: ["tif", "tiff", "mbtiles", "jpg", "jpeg", "png"] },
],
});
if (typeof selected === "string") {
setSliceParams((p) => ({ ...p, source: selected }));
setSelectedFileName(selected.split("/").pop() || selected);
return;
}
}
fileInputRef.current?.click();
} catch (err) {
console.error("file dialog error", err);
}
};
const sendFrontLog = useCallback((message: string, level: "info" | "warn" | "error" = "info") => {
const sendFrontLog = (message: string, level: string = "info") => {
fetch(`${backendBase}/logs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, level, source: "frontend" }),
}).catch(() => {});
}, []);
useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.setAttribute("data-theme", theme);
try {
localStorage.setItem("theme", theme);
} catch {
// ignore storage errors (e.g. private mode)
}
}, [theme]);
}).catch((err) => console.error("log push failed", err));
};
useEffect(() => {
fetch(`${backendBase}/config`, { cache: "no-store" })
@ -70,93 +171,28 @@ export default function App() {
return cfg;
})
.catch((err) => {
console.error("Config error", err);
sendFrontLog(`config fetch failed: ${err}`, "error");
setStatus("config-error");
});
}, [sendFrontLog]);
}, []);
const refreshGnssState = useCallback(async () => {
const url = config?.gnss_state_url ? `${backendBase}${config.gnss_state_url}` : `${backendBase}/gnss/state`;
const res = await fetch(url, { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
const mode = (data?.gnss?.mode as string | undefined) || "unknown";
if (mode === "simulated" || mode === "serial") {
setGnssMode(mode);
} else {
setGnssMode("unknown");
}
if (typeof data?.gnss?.lat === "number") setSimCenterLat(String(data.gnss.lat));
if (typeof data?.gnss?.lon === "number") setSimCenterLon(String(data.gnss.lon));
}, [config]);
const refreshTilesets = useCallback(async () => {
const refreshTilesets = async () => {
const url = config?.tilesets_url ? `${backendBase}${config.tilesets_url}` : `${backendBase}/tilesets`;
const res = await fetch(url, { cache: "no-store" });
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
setTilesets(data.tilesets || []);
setActiveTileset(data.active || "");
setTilesetKinds(data.kinds || {});
}, [config]);
};
useEffect(() => {
if (!config) return;
refreshTilesets().catch((err) => sendFrontLog(`tilesets refresh failed: ${err}`, "warn"));
refreshGnssState().catch((err) => sendFrontLog(`gnss refresh failed: ${err}`, "warn"));
}, [config, refreshGnssState, refreshTilesets, sendFrontLog]);
useEffect(() => {
if (!config?.gnss_source) return;
if (config.gnss_source === "simulated" || config.gnss_source === "serial") setGnssMode(config.gnss_source);
}, [config]);
const setGnssModeRequest = useCallback(
async (mode: "simulated" | "serial") => {
const url = config?.gnss_mode_url ? `${backendBase}${config.gnss_mode_url}` : `${backendBase}/gnss/mode`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
sendFrontLog(`gnss mode set: ${mode}`, "info");
setGnssMode(mode);
setConfig((prev) => (prev ? { ...prev, gnss_source: mode } : prev));
if (mode === "simulated") {
if (typeof data?.gnss?.lat === "number") setSimCenterLat(String(data.gnss.lat));
if (typeof data?.gnss?.lon === "number") setSimCenterLon(String(data.gnss.lon));
}
},
[config, sendFrontLog],
);
const applySimCenter = useCallback(async () => {
const lat = Number(simCenterLat);
const lon = Number(simCenterLon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
sendFrontLog("invalid simulated center coordinates", "warn");
return;
}
const url = config?.gnss_sim_center_url
? `${backendBase}${config.gnss_sim_center_url}`
: `${backendBase}/gnss/sim/center`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lat, lon }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
sendFrontLog(`sim center updated: ${lat}, ${lon}`, "info");
}, [config, sendFrontLog, simCenterLat, simCenterLon]);
const activateTileset = useCallback(
async (name: string) => {
const url = config?.tilesets_activate_url
? `${backendBase}${config.tilesets_activate_url}`
: `${backendBase}/tilesets/activate`;
const activateTileset = async (name: string) => {
const url = config?.tilesets_activate_url ? `${backendBase}${config.tilesets_activate_url}` : `${backendBase}/tilesets/activate`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -166,19 +202,9 @@ export default function App() {
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
setActiveTileset(data.active || name);
sendFrontLog(`tileset activated: ${data.active || name}`, "info");
const kind = (data.kind as string | undefined) || tilesetKinds[name] || "";
if (kind.startsWith("vector")) {
setMapMode("vector");
setVectorSource(kind === "vector-dir" ? "tileset" : "mbtiles");
} else if (kind.startsWith("raster")) {
setMapMode("raster");
}
},
[config, sendFrontLog, tilesetKinds],
);
};
const createTileset = useCallback(
async (name: string) => {
const createTileset = async (name: string) => {
const res = await fetch(`${backendBase}/tilesets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -189,9 +215,7 @@ export default function App() {
await refreshTilesets();
setSliceParams((p) => ({ ...p, tileset: data.name || name }));
sendFrontLog(`tileset created: ${data.name || name}`, "info");
},
[refreshTilesets, sendFrontLog],
);
};
useEffect(() => {
if (!config) return;
@ -205,16 +229,17 @@ export default function App() {
try {
const msg: IncomingMessage = JSON.parse(event.data);
if (msg.type === "fix") {
const fix = msg;
const fix = msg as FixMessage;
setTrack((prev) => {
const next = [...prev, { lat: fix.lat, lon: fix.lon, timestamp: fix.timestamp, fresh: fix.fresh }];
return next.slice(-500);
});
setStatus(fix.fresh ? "ok" : "stale");
} else {
setStatus(msg.status);
setStatus((msg as StatusMessage).status);
}
} catch (err) {
console.error("WS parse error", err);
sendFrontLog(`ws parse error: ${err}`, "error");
}
};
@ -229,11 +254,11 @@ export default function App() {
};
return () => socket.close();
}, [config, sendFrontLog]);
}, [config]);
const lastPoint = useMemo(() => (track.length ? track[track.length - 1] : null), [track]);
const positions: TrackPositions = useMemo(
() => track.map((p) => [p.lat, p.lon]) as TrackPositions,
const lastPoint = track.length ? track[track.length - 1] : null;
const positions = useMemo(
() => track.map((p) => [p.lat, p.lon]) as LatLngExpression[],
[track],
);
@ -246,128 +271,285 @@ export default function App() {
}`;
const vectorTileUrl =
config &&
`${backendBase.replace(/\/+$/, "")}${
vectorSource === "tileset"
? config.vector_tileset_url || "/tilesets/active/vector/{z}/{x}/{y}.pbf"
: config.vector_tiles_url || "/tiles/vector/{z}/{x}/{y}.pbf"
}`;
config && `${backendBase.replace(/\/+$/, "")}${config.vector_tiles_url || "/tiles/vector/{z}/{x}/{y}.pbf"}`;
const vectorMetaUrl =
config &&
`${backendBase.replace(/\/+$/, "")}${
vectorSource === "tileset"
? config.vector_tileset_metadata_url || "/tilesets/active/vector/metadata"
: config.vector_metadata_url || "/tiles/vector/metadata"
}`;
config && `${backendBase.replace(/\/+$/, "")}${config.vector_metadata_url || "/tiles/vector/metadata"}`;
const ingestMap = useCallback(
async (path: string) => {
const res = await fetch(`${backendBase}/maps/ingest`, {
const activateVectorMbtiles = async (path: string) => {
const res = await fetch(`${backendBase}/tiles/vector/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path,
tileset: sliceParams.tileset || undefined,
min_zoom: Number(sliceParams.min_zoom),
max_zoom: Number(sliceParams.max_zoom),
}),
body: JSON.stringify({ path }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
if (data.kind === "vector") {
setVectorSource("mbtiles");
sendFrontLog(`vector mbtiles activated: ${path}`, "info");
setMapMode("vector");
} else if (data.kind === "raster") {
setMapMode("raster");
}
sendFrontLog(`map ingested: kind=${data.kind} driver=${data.driver}`, "info");
if (data.tileset) {
refreshTilesets().catch(() => {});
activateTileset(String(data.tileset)).catch(() => {});
}
return data;
},
[activateTileset, refreshTilesets, sendFrontLog, sliceParams.max_zoom, sliceParams.min_zoom, sliceParams.tileset],
);
};
const uploadFileBlob = useCallback(
async (file: File) => {
setSliceStatus("uploading");
const form = new FormData();
form.append("file", file);
return fetch(`${backendBase}/tiles/upload`, {
method: "POST",
body: form,
})
.then((r) => (r.ok ? r.json() : r.json().then((j) => Promise.reject(j.detail || r.status))))
.then((res) => {
setSliceParams((p) => ({ ...p, source: res.path }));
setSelectedFileName(res.filename);
const lower = String(res.filename || "").toLowerCase();
if (lower.endsWith(".mbtiles")) setSliceParams((p) => ({ ...p, source_format: "mbtiles" }));
if (lower.endsWith(".tif") || lower.endsWith(".tiff") || lower.endsWith(".vrt"))
setSliceParams((p) => ({ ...p, source_format: "geotiff" }));
setSliceParams((p) => {
if (p.tileset) return p;
const proposed = String(res.filename || "")
.replace(/\.[^.]+$/, "")
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
return proposed ? { ...p, tileset: proposed } : p;
});
setSliceStatus("idle");
sendFrontLog(`uploaded source ${res.filename}`, "info");
if (res.filename?.toLowerCase?.().endsWith(".mbtiles")) {
ingestMap(res.path).catch((err) => sendFrontLog(`ingest failed: ${err}`, "warn"));
}
})
.catch((err) => {
setSliceStatus("error");
sendFrontLog(`upload error: ${err}`, "error");
});
},
[ingestMap, sendFrontLog],
);
useEffect(() => {
if (mapMode !== "vector") return;
if (!maplibreContainerRef.current) return;
if (!vectorMetaUrl || !vectorTileUrl) return;
const triggerFileSelect = useCallback(async () => {
let canceled = false;
(async () => {
const metaRes = await fetch(vectorMetaUrl, { cache: "no-store" });
if (!metaRes.ok) return;
const meta = await metaRes.json();
const vectorLayersRaw = meta.vector_layers || meta["json"] || null;
let sourceLayers: string[] = [];
try {
if ((window as any).__TAURI__) {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
multiple: false,
filters: [
{ name: "Raster/MBTiles", extensions: ["tif", "tiff", "mbtiles", "jpg", "jpeg", "png"] },
],
});
if (typeof selected === "string") {
const lower = selected.toLowerCase();
setSliceParams((p) => ({ ...p, source: selected }));
if (lower.endsWith(".mbtiles")) setSliceParams((p) => ({ ...p, source_format: "mbtiles" }));
if (lower.endsWith(".tif") || lower.endsWith(".tiff") || lower.endsWith(".vrt"))
setSliceParams((p) => ({ ...p, source_format: "geotiff" }));
setSelectedFileName(selected.split("/").pop() || selected);
return;
const parsed = typeof vectorLayersRaw === "string" ? JSON.parse(vectorLayersRaw) : vectorLayersRaw;
const layers = parsed?.vector_layers ?? parsed?.layers ?? parsed ?? [];
if (Array.isArray(layers)) {
sourceLayers = layers.map((l: any) => l.id).filter(Boolean);
}
} catch {
sourceLayers = [];
}
fileInputRef.current?.click();
} catch (err) {
sendFrontLog(`file dialog error: ${err}`, "warn");
}
}, [sendFrontLog]);
const handleFileSelected = useCallback(
async (file: File | null | undefined) => {
if (!file) return;
setSelectedFileName(file.name);
await uploadFileBlob(file);
const { default: maplibregl } = await import("maplibre-gl");
if (canceled) return;
if (maplibreRef.current) {
try {
maplibreRef.current.remove();
} catch {}
maplibreRef.current = null;
}
const style: any = {
version: 8,
sources: {
osm: {
type: "vector",
tiles: [vectorTileUrl],
minzoom: Number(meta.minzoom ?? 0),
maxzoom: Number(meta.maxzoom ?? 14),
},
track: {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
},
},
layers: [
{ id: "bg", type: "background", paint: { "background-color": "#050816" } },
],
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
};
for (const id of sourceLayers) {
style.layers.push({
id: `fill-${id}`,
type: "fill",
source: "osm",
"source-layer": id,
filter: ["==", ["geometry-type"], "Polygon"],
paint: { "fill-color": "#1f2937", "fill-opacity": 0.35 },
});
style.layers.push({
id: `linepoly-${id}`,
type: "line",
source: "osm",
"source-layer": id,
filter: ["==", ["geometry-type"], "Polygon"],
paint: { "line-color": "#334155", "line-width": 1 },
});
style.layers.push({
id: `line-${id}`,
type: "line",
source: "osm",
"source-layer": id,
filter: ["==", ["geometry-type"], "LineString"],
paint: { "line-color": "#475569", "line-width": 1.2 },
});
style.layers.push({
id: `point-${id}`,
type: "circle",
source: "osm",
"source-layer": id,
filter: ["==", ["geometry-type"], "Point"],
paint: { "circle-radius": 2, "circle-color": "#94a3b8", "circle-opacity": 0.8 },
});
}
style.layers.push(
{
id: "track-line",
type: "line",
source: "track",
paint: { "line-color": "#7dd3fc", "line-width": 4, "line-opacity": 0.9 },
},
{
id: "track-point",
type: "circle",
source: "track",
paint: { "circle-radius": 6, "circle-color": "#a78bfa", "circle-stroke-color": "#ffffff", "circle-stroke-width": 1 },
},
[uploadFileBlob],
);
const handleSliceSubmit = useCallback(() => {
const map = new maplibregl.Map({
container: maplibreContainerRef.current!,
style,
center: [37.618423, 55.751244],
zoom: 11,
});
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), "top-right");
map.on("load", () => {
const bounds = meta.bounds;
if (typeof bounds === "string") {
const parts = bounds.split(",").map((p: string) => Number(p.trim()));
if (parts.length === 4 && parts.every((n: number) => Number.isFinite(n))) {
map.fitBounds(
[
[parts[0], parts[1]],
[parts[2], parts[3]],
],
{ padding: 30, duration: 0 },
);
}
}
});
maplibreRef.current = map;
})().catch((err) => {
sendFrontLog(`maplibre init error: ${err}`, "error");
});
return () => {
canceled = true;
};
}, [mapMode, vectorMetaUrl, vectorTileUrl]);
useEffect(() => {
if (mapMode !== "vector") return;
const map = maplibreRef.current;
if (!map) return;
const geojson = {
type: "FeatureCollection",
features: [
positions.length
? { type: "Feature", geometry: { type: "LineString", coordinates: positions.map((p: any) => [p[1], p[0]]) } }
: null,
lastPoint ? { type: "Feature", geometry: { type: "Point", coordinates: [lastPoint.lon, lastPoint.lat] } } : null,
].filter(Boolean),
};
try {
const src = map.getSource("track");
if (src && src.setData) {
src.setData(geojson);
}
} catch {}
}, [mapMode, positions, lastPoint]);
return (
<div className="layout">
<header className="topbar">
<div className="brand">
<div className={`pulse ${status === "ok" ? "ok" : ""}`} />
<div>
<div className="title">GNSS Offline Map</div>
<div className="subtitle">FastAPI · WebSocket · Tauri</div>
</div>
</div>
<div className="pill">
<span className="pill-label">Status</span>
<span className="pill-value">{status}</span>
</div>
</header>
<main className="content">
<section className="panel info">
<div className="info-grid">
<div className="card">
<div className="card-label">GNSS source</div>
<div className="card-value">{config?.gnss_source ?? "—"}</div>
</div>
<div className="card">
<div className="card-label">Tileset</div>
<div className="card-value">
<div className="tileset-row">
<select
value={activeTileset}
onChange={(e) => activateTileset(e.target.value).catch((err) => sendFrontLog(`${err}`, "error"))}
>
{tilesets.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<button type="button" className="btn ghost" onClick={() => refreshTilesets().catch(() => {})}>
Обновить
</button>
</div>
</div>
</div>
<div className="card">
<div className="card-label">Last fix</div>
<div className="card-value">{formatTime(lastPoint?.timestamp)}</div>
</div>
<div className="card">
<div className="card-label">Track points</div>
<div className="card-value">{track.length}</div>
</div>
</div>
</section>
<section className="panel map-panel">
<div className="map-switch">
<button
type="button"
className={`btn ${mapMode === "raster" ? "primary" : "ghost"}`}
onClick={() => setMapMode("raster")}
>
Raster
</button>
<button
type="button"
className={`btn ${mapMode === "vector" ? "primary" : "ghost"}`}
onClick={() => setMapMode("vector")}
>
Vector (MBTiles)
</button>
</div>
{mapMode === "raster" ? (
tileUrl ? (
<MapContainer center={defaultCenter} zoom={13} scrollWheelZoom className="map">
<TileLayer url={tileUrl} attribution="Local tiles" tms={sliceParams.scheme === "tms"} />
{positions.length > 0 && (
<>
<Polyline positions={positions} pathOptions={{ color: "#7dd3fc", weight: 5, opacity: 0.9 }} />
<Marker position={positions[positions.length - 1]} icon={markerIcon} />
</>
)}
<FlyTo point={lastPoint} />
</MapContainer>
) : (
<div className="empty">Нет конфигурации тайлов</div>
)
) : (
<div className="maplibre" ref={maplibreContainerRef} />
)}
</section>
<section className="panel slice-panel">
<div className="panel-header">
<div>
<div className="panel-title">Нарезка тайлов</div>
<div className="panel-subtitle">Запускает gdal2tiles на бэкенде</div>
</div>
<div className={`badge ${sliceStatus === "ok" ? "ok" : sliceStatus === "idle" ? "" : "warn"}`}>
{sliceStatus}
</div>
</div>
<form
className="slice-grid"
onSubmit={(e) => {
e.preventDefault();
setSliceStatus("running");
fetch(`${backendBase}/tiles/slice`, {
method: "POST",
@ -395,86 +577,156 @@ export default function App() {
setSliceStatus("error");
sendFrontLog(`tile slice error: ${err}`, "error");
});
}, [activateTileset, refreshTilesets, sendFrontLog, sliceParams]);
const handleSliceReset = useCallback(() => {
setSliceParams(initialSliceParams);
setSelectedFileName("");
}, []);
const { mapContainerRef } = useVectorMap({
mapMode,
vectorMetaUrl,
vectorTileUrl,
positions,
lastPoint,
onLog: sendFrontLog,
});
return (
<div className="layout">
<div className="status-strip">
<TrackSummary lastPoint={lastPoint} pointsCount={track.length} compact />
</div>
<main className="content map-shell">
<section className="panel map-panel flat">
<MapPanel
mapMode={mapMode}
tileUrl={tileUrl}
positions={positions}
lastPoint={lastPoint}
sliceScheme={sliceParams.scheme}
vectorContainerRef={mapContainerRef}
}}
>
<label className="field">
<span>Источник (GeoTIFF/MBTiles)</span>
<div className="file-row">
<input
type="text"
placeholder="/path/to/input.tif"
value={sliceParams.source}
onChange={(e) => setSliceParams((p) => ({ ...p, source: e.target.value }))}
required
/>
<button type="button" className="btn ghost" onClick={triggerFileSelect}>
Выбрать
</button>
<input
type="file"
accept=".tif,.tiff,.mbtiles,.jpg,.jpeg,.png"
ref={fileInputRef}
style={{ display: "none" }}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setSelectedFileName(file.name);
await uploadFileBlob(file);
}}
/>
</div>
{selectedFileName && <small className="muted">Файл: {selectedFileName}</small>}
</label>
<label className="field">
<span>Схема тайлов</span>
<select
value={sliceParams.scheme}
onChange={(e) => setSliceParams((p) => ({ ...p, scheme: e.target.value as "xyz" | "tms" }))}
>
<option value="xyz">XYZ (Y сверху, Leaflet)</option>
<option value="tms">TMS (Y снизу)</option>
</select>
</label>
<label className="field">
<span>Формат исходника</span>
<select
value={sliceParams.source_format}
onChange={(e) =>
setSliceParams((p) => ({
...p,
source_format: e.target.value as "geotiff" | "mbtiles" | "tms" | "xyz",
}))
}
>
<option value="geotiff">GeoTIFF/растровый TIFF</option>
<option value="mbtiles">MBTiles (растровый)</option>
<option value="tms">Папка TMS</option>
<option value="xyz">Папка XYZ</option>
</select>
</label>
<label className="field">
<span>Tileset (имя папки)</span>
<div className="file-row">
<input
type="text"
list="tilesets-list"
placeholder="например: moscow (пусто → активный)"
value={sliceParams.tileset}
onChange={(e) => setSliceParams((p) => ({ ...p, tileset: e.target.value }))}
/>
</section>
</main>
<div className="bottom-actions">
<button
type="button"
className="btn ghost block"
onClick={() => setMapMode((m) => (m === "raster" ? "vector" : "raster"))}
className="btn ghost"
onClick={() => {
const name = sliceParams.tileset.trim();
if (!name) return;
createTileset(name).catch((err) => sendFrontLog(`tileset create failed: ${err}`, "error"));
}}
>
Режим карты: {mapMode === "raster" ? "Растровые" : "Векторные"}
</button>
<button type="button" className="btn primary block" onClick={() => setSettingsOpen(true)}>
Меню настроек
Создать
</button>
</div>
<SettingsPanel
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
config={config}
status={status}
theme={theme}
onToggleTheme={() => setTheme((t) => (t === "dark" ? "light" : "dark"))}
gnssMode={gnssMode}
simCenterLat={simCenterLat}
simCenterLon={simCenterLon}
onGnssRefresh={() => refreshGnssState().catch((err) => sendFrontLog(`${err}`, "warn"))}
onGnssModeChange={(mode) => setGnssModeRequest(mode).catch((err) => sendFrontLog(`${err}`, "error"))}
onSimCenterLatChange={setSimCenterLat}
onSimCenterLonChange={setSimCenterLon}
onSimCenterApply={() => applySimCenter().catch((err) => sendFrontLog(`${err}`, "error"))}
vectorSource={vectorSource}
onVectorSourceChange={setVectorSource}
tilesets={tilesets}
activeTileset={activeTileset}
onActivateTileset={(name) => activateTileset(name).catch((err) => sendFrontLog(`${err}`, "error"))}
onRefreshTilesets={() => refreshTilesets().catch((err) => sendFrontLog(`${err}`, "warn"))}
onCreateTileset={(name) => createTileset(name).catch((err) => sendFrontLog(`tileset create failed: ${err}`, "error"))}
sliceParams={sliceParams}
onSliceParamsChange={setSliceParams}
sliceStatus={sliceStatus}
onSliceSubmit={handleSliceSubmit}
onSliceReset={handleSliceReset}
selectedFileName={selectedFileName}
onTriggerFileSelect={triggerFileSelect}
onFileSelected={handleFileSelected}
fileInputRef={fileInputRef}
<datalist id="tilesets-list">
{tilesets.map((t) => (
<option key={t} value={t} />
))}
</datalist>
<small className="muted">Активный: {activeTileset || "—"}</small>
</label>
<label className="field">
<span>Папка тайлов (опц.)</span>
<input
type="text"
placeholder="лучше не трогать (по умолчанию tileset)"
value={sliceParams.destination}
onChange={(e) => setSliceParams((p) => ({ ...p, destination: e.target.value }))}
/>
</label>
<label className="field">
<span>Min zoom</span>
<input
type="number"
min={0}
value={sliceParams.min_zoom}
onChange={(e) => setSliceParams((p) => ({ ...p, min_zoom: Number(e.target.value) }))}
required
/>
</label>
<label className="field">
<span>Max zoom</span>
<input
type="number"
min={0}
value={sliceParams.max_zoom}
onChange={(e) => setSliceParams((p) => ({ ...p, max_zoom: Number(e.target.value) }))}
required
/>
</label>
<label className="field">
<span>Формат тайлов (сейчас поддерживается PNG)</span>
<select
value={sliceParams.tile_format}
onChange={(e) => setSliceParams((p) => ({ ...p, tile_format: e.target.value }))}
>
<option value="png">png</option>
</select>
</label>
<div className="field actions">
<button type="submit" className="btn primary">
Запустить
</button>
<button
type="button"
className="btn ghost"
onClick={() =>
setSliceParams({
source: "",
destination: "",
tileset: "",
min_zoom: 10,
max_zoom: 16,
tile_format: "png",
scheme: "xyz",
source_format: "geotiff",
})
}
>
Сброс
</button>
</div>
</form>
</section>
</main>
</div>
);
}

View file

@ -1,60 +0,0 @@
import { MapContainer, Marker, Polyline, TileLayer, useMap } from "react-leaflet";
import L from "leaflet";
import type { MapMode, TrackPoint, TrackPositions } from "../types";
import { useEffect } from "react";
import type { MutableRefObject, RefObject } from "react";
const defaultCenter: [number, number] = [55.751244, 37.618423];
const markerIcon = new L.Icon({
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
iconSize: [25, 41],
iconAnchor: [12, 41],
});
type MapPanelProps = {
mapMode: MapMode;
tileUrl?: string | null;
positions: TrackPositions;
lastPoint: TrackPoint | null;
sliceScheme: "xyz" | "tms";
vectorContainerRef: RefObject<HTMLDivElement | null> | MutableRefObject<HTMLDivElement | null>;
};
const FlyTo = ({ point }: { point: TrackPoint | null }) => {
const map = useMap();
useEffect(() => {
if (point) {
map.flyTo([point.lat, point.lon], Math.max(map.getZoom(), 14), { duration: 0.6 });
}
}, [point, map]);
return null;
};
export const MapPanel = ({ mapMode, tileUrl, positions, lastPoint, sliceScheme, vectorContainerRef }: MapPanelProps) => {
if (mapMode === "raster") {
return tileUrl ? (
<MapContainer
center={defaultCenter}
zoom={13}
scrollWheelZoom
className="map"
attributionControl={false}
>
<TileLayer url={tileUrl} attribution="Local tiles" tms={sliceScheme === "tms"} />
{positions.length > 0 && (
<>
<Polyline positions={positions} pathOptions={{ color: "#7dd3fc", weight: 25, opacity: 0.9 }} />
<Marker position={positions[positions.length - 1]} icon={markerIcon} />
</>
)}
<FlyTo point={lastPoint} />
</MapContainer>
) : (
<div className="empty">Нет конфигурации тайлов</div>
);
}
return <div className="maplibre" ref={vectorContainerRef} />;
};

View file

@ -1,344 +0,0 @@
import type { MutableRefObject, RefObject } from "react";
import { FormEvent } from "react";
import type { AppConfig, SliceParams } from "../types";
type SettingsPanelProps = {
open: boolean;
onClose: () => void;
config: AppConfig | null;
status: string;
theme: "dark" | "light";
onToggleTheme: () => void;
gnssMode: "simulated" | "serial" | "unknown";
simCenterLat: string;
simCenterLon: string;
onGnssRefresh: () => void;
onGnssModeChange: (mode: "simulated" | "serial") => void;
onSimCenterLatChange: (value: string) => void;
onSimCenterLonChange: (value: string) => void;
onSimCenterApply: () => void;
vectorSource: "mbtiles" | "tileset";
onVectorSourceChange: (source: "mbtiles" | "tileset") => void;
tilesets: string[];
activeTileset: string;
onActivateTileset: (name: string) => void;
onRefreshTilesets: () => void;
onCreateTileset: (name: string) => void;
sliceParams: SliceParams;
onSliceParamsChange: (params: SliceParams) => void;
sliceStatus: string;
onSliceSubmit: (event?: FormEvent<HTMLFormElement>) => void;
onSliceReset: () => void;
selectedFileName: string;
onTriggerFileSelect: () => void;
onFileSelected: (file: File | null | undefined) => void;
fileInputRef: RefObject<HTMLInputElement | null> | MutableRefObject<HTMLInputElement | null>;
};
export const SettingsPanel = ({
open,
onClose,
config,
status,
theme,
onToggleTheme,
gnssMode,
simCenterLat,
simCenterLon,
onGnssRefresh,
onGnssModeChange,
onSimCenterLatChange,
onSimCenterLonChange,
onSimCenterApply,
vectorSource,
onVectorSourceChange,
tilesets,
activeTileset,
onActivateTileset,
onRefreshTilesets,
onCreateTileset,
sliceParams,
onSliceParamsChange,
sliceStatus,
onSliceSubmit,
onSliceReset,
selectedFileName,
onTriggerFileSelect,
onFileSelected,
fileInputRef,
}: SettingsPanelProps) => (
<>
<div className={`settings-backdrop ${open ? "open" : ""}`} onClick={onClose} />
<aside className={`settings-panel ${open ? "open" : ""}`}>
<div className="settings-header">
<div>
<div className="panel-title">Настройки</div>
<div className="panel-subtitle">Управление источниками и нарезкой</div>
</div>
</div>
<div className="settings-content">
<section className="panel info">
<div className="info-grid">
<div className="card">
<div className="card-label">GNSS источник</div>
<div className="card-value">{config?.gnss_source ?? "—"}</div>
</div>
<div className="card">
<div className="card-label">Набор тайлов</div>
<div className="card-value">{activeTileset || "—"}</div>
</div>
<div className="card">
<div className="card-label">Статус</div>
<div className="card-value">{status}</div>
</div>
</div>
<div className="panel-header">
<div>
<div className="panel-title">GNSS</div>
<div className="panel-subtitle">Режим и точка симуляции</div>
</div>
<button type="button" className="btn ghost small" onClick={onGnssRefresh}>
Обновить
</button>
</div>
<label className="field">
<span>Режим</span>
<select
value={gnssMode}
onChange={(e) => onGnssModeChange(e.target.value as "simulated" | "serial")}
disabled={gnssMode === "unknown"}
>
<option value="simulated">Симуляция</option>
<option value="serial">Реальный (Serial NMEA)</option>
</select>
</label>
{gnssMode === "simulated" && (
<div className="slice-grid">
<label className="field">
<span>Lat</span>
<input value={simCenterLat} onChange={(e) => onSimCenterLatChange(e.target.value)} />
</label>
<label className="field">
<span>Lon</span>
<input value={simCenterLon} onChange={(e) => onSimCenterLonChange(e.target.value)} />
</label>
<button type="button" className="btn ghost" onClick={onSimCenterApply}>
Применить
</button>
</div>
)}
<div className="panel-header">
<div>
<div className="panel-title">Тема</div>
<div className="panel-subtitle">Светлая или тёмная</div>
</div>
<button type="button" className="btn ghost small" onClick={onToggleTheme}>
{theme === "dark" ? "Включить светлую" : "Включить тёмную"}
</button>
</div>
<div className="panel-header">
<div>
<div className="panel-title">Наборы тайлов</div>
<div className="panel-subtitle">Активация и обновление</div>
</div>
</div>
<div className="tileset-row stacked">
<select value={activeTileset} onChange={(e) => onActivateTileset(e.target.value)}>
{tilesets.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<button type="button" className="btn ghost" onClick={onRefreshTilesets}>
Обновить
</button>
</div>
<label className="field">
<span>Векторный источник</span>
<select value={vectorSource} onChange={(e) => onVectorSourceChange(e.target.value as "mbtiles" | "tileset")}>
<option value="mbtiles">MBTiles (/tiles/vector)</option>
<option value="tileset">Tileset папка (/tilesets/active/vector)</option>
</select>
</label>
<div className="panel-header">
<div>
<div className="panel-title">Нарезка тайлов</div>
<div className="panel-subtitle">Запускает gdal2tiles на бэкенде</div>
</div>
<div className={`badge ${sliceStatus === "ok" ? "ok" : sliceStatus === "idle" ? "" : "warn"}`}>
{sliceStatus}
</div>
</div>
<form
className="slice-grid"
onSubmit={(event) => {
event.preventDefault();
onSliceSubmit(event);
}}
>
<label className="field">
<span>Источник (GeoTIFF/MBTiles)</span>
<div className="file-row">
<input
type="text"
placeholder="/path/to/input.tif"
value={sliceParams.source}
onChange={(e) => onSliceParamsChange({ ...sliceParams, source: e.target.value })}
required
/>
<button type="button" className="btn ghost" onClick={onTriggerFileSelect}>
Выбрать
</button>
<input
type="file"
accept=".tif,.tiff,.mbtiles,.jpg,.jpeg,.png"
ref={fileInputRef}
style={{ display: "none" }}
onChange={(e) => onFileSelected(e.target.files?.[0])}
/>
</div>
{selectedFileName && <small className="muted">Файл: {selectedFileName}</small>}
</label>
<label className="field">
<span>Схема тайлов</span>
<select
value={sliceParams.scheme}
onChange={(e) => onSliceParamsChange({ ...sliceParams, scheme: e.target.value as "xyz" | "tms" })}
>
<option value="xyz">XYZ (Y сверху, Leaflet)</option>
<option value="tms">TMS (Y снизу)</option>
</select>
</label>
<label className="field">
<span>Формат исходника</span>
<select
value={sliceParams.source_format}
onChange={(e) =>
onSliceParamsChange({
...sliceParams,
source_format: e.target.value as "geotiff" | "mbtiles" | "tms" | "xyz",
})
}
>
<option value="geotiff">GeoTIFF/растровый TIFF</option>
<option value="mbtiles">MBTiles (растровый)</option>
<option value="tms">Папка TMS</option>
<option value="xyz">Папка XYZ</option>
</select>
</label>
<label className="field">
<span>Tileset (имя папки)</span>
<div className="file-row">
<input
type="text"
list="tilesets-list"
placeholder="например: moscow (пусто → активный)"
value={sliceParams.tileset}
onChange={(e) => onSliceParamsChange({ ...sliceParams, tileset: e.target.value })}
/>
<button
type="button"
className="btn ghost"
onClick={() => {
const name = sliceParams.tileset.trim();
if (!name) return;
onCreateTileset(name);
}}
>
Создать
</button>
</div>
<datalist id="tilesets-list">
{tilesets.map((t) => (
<option key={t} value={t} />
))}
</datalist>
<small className="muted">Активный: {activeTileset || "—"}</small>
</label>
<label className="field">
<span>Папка тайлов (опц.)</span>
<input
type="text"
placeholder="лучше не трогать (по умолчанию tileset)"
value={sliceParams.destination}
onChange={(e) => onSliceParamsChange({ ...sliceParams, destination: e.target.value })}
/>
</label>
<label className="field">
<span>Min zoom</span>
<input
type="number"
min={0}
value={sliceParams.min_zoom}
onChange={(e) =>
onSliceParamsChange({
...sliceParams,
min_zoom: Number(e.target.value),
})
}
required
/>
</label>
<label className="field">
<span>Max zoom</span>
<input
type="number"
min={0}
value={sliceParams.max_zoom}
onChange={(e) =>
onSliceParamsChange({
...sliceParams,
max_zoom: Number(e.target.value),
})
}
required
/>
</label>
<label className="field">
<span>Формат тайлов (сейчас поддерживается PNG)</span>
<select
value={sliceParams.tile_format}
onChange={(e) => onSliceParamsChange({ ...sliceParams, tile_format: e.target.value })}
>
<option value="png">png</option>
</select>
</label>
<divz>
<div className="actions-row">
<button type="submit" className="btn primary">
Запустить
</button>
<button type="button" className="btn ghost" onClick={onSliceReset}>
Сброс
</button>
</div>
</divz>
</form>
</section>
<div className="bottom-settings">
<button type="button" className="btn primary block" onClick={onClose}>
Закрыть
</button>
</div>
</div>
</aside>
</>
);

View file

@ -1,25 +0,0 @@
import { formatTime } from "../utils/time";
import type { TrackPoint } from "../types";
type TrackSummaryProps = {
lastPoint: TrackPoint | null;
pointsCount: number;
compact?: boolean;
};
export const TrackSummary = ({ lastPoint, pointsCount, compact }: TrackSummaryProps) => (
<div className={`mini-info ${compact ? "compact" : ""}`}>
<div className="chip small">
<span>Последняя фиксация</span>
<strong>{formatTime(lastPoint?.timestamp)}</strong>
</div>
<div className="chip small">
<span>Точек трека</span>
<strong>{pointsCount}</strong>
</div>
<div className="chip small">
<span>Статус</span>
<strong>ок</strong>
</div>
</div>
);

View file

@ -1,202 +0,0 @@
import { useEffect, useRef } from "react";
import type { MapMode, TrackPoint, TrackPositions } from "../types";
type UseVectorMapOptions = {
mapMode: MapMode;
vectorTileUrl?: string | null;
vectorMetaUrl?: string | null;
positions: TrackPositions;
lastPoint: TrackPoint | null;
onLog?: (message: string, level?: "info" | "warn" | "error") => void;
};
export const useVectorMap = ({
mapMode,
vectorMetaUrl,
vectorTileUrl,
positions,
lastPoint,
onLog,
}: UseVectorMapOptions) => {
const mapContainerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<any>(null);
useEffect(() => {
if (mapMode !== "vector" && mapRef.current) {
try {
mapRef.current.remove();
} catch {
// ignore
} finally {
mapRef.current = null;
}
}
}, [mapMode]);
useEffect(() => {
if (mapMode !== "vector") return;
if (!mapContainerRef.current) return;
if (!vectorMetaUrl || !vectorTileUrl) return;
let canceled = false;
(async () => {
const metaRes = await fetch(vectorMetaUrl, { cache: "no-store" });
if (!metaRes.ok) return;
const meta = await metaRes.json();
const vectorLayersRaw = meta.vector_layers || meta["json"] || null;
let sourceLayers: string[] = [];
try {
const parsed = typeof vectorLayersRaw === "string" ? JSON.parse(vectorLayersRaw) : vectorLayersRaw;
const layers = parsed?.vector_layers ?? parsed?.layers ?? parsed ?? [];
if (Array.isArray(layers)) {
sourceLayers = layers.map((l: any) => l.id).filter(Boolean);
}
} catch {
sourceLayers = [];
}
const { default: maplibregl } = await import("maplibre-gl");
if (canceled) return;
if (mapRef.current) {
try {
mapRef.current.remove();
} catch {
// ignore
}
mapRef.current = null;
}
const style: any = {
version: 8,
sources: {
osm: {
type: "vector",
tiles: [vectorTileUrl],
minzoom: Number(meta.minzoom ?? 0),
maxzoom: Number(meta.maxzoom ?? 14),
},
track: {
type: "geojson",
data: { type: "FeatureCollection", features: [] },
},
},
layers: [{ id: "bg", type: "background", paint: { "background-color": "#050816" } }],
glyphs: "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
};
for (const id of sourceLayers) {
style.layers.push(
{
id: `fill-${id}`,
type: "fill",
source: "osm",
"source-layer": id,
filter: ["==", ["geometry-type"], "Polygon"],
paint: { "fill-color": "#1f2937", "fill-opacity": 0.35 },
},
{
id: `linepoly-${id}`,
type: "line",
source: "osm",
"source-layer": id,
filter: ["==", ["geometry-type"], "Polygon"],
paint: { "line-color": "#334155", "line-width": 1 },
},
{
id: `line-${id}`,
type: "line",
source: "osm",
"source-layer": id,
filter: ["==", ["geometry-type"], "LineString"],
paint: { "line-color": "#475569", "line-width": 1.2 },
},
{
id: `point-${id}`,
type: "circle",
source: "osm",
"source-layer": id,
filter: ["==", ["geometry-type"], "Point"],
paint: { "circle-radius": 2, "circle-color": "#94a3b8", "circle-opacity": 0.8 },
},
);
}
style.layers.push(
{
id: "track-line",
type: "line",
source: "track",
paint: { "line-color": "#7dd3fc", "line-width": 4, "line-opacity": 0.9 },
},
{
id: "track-point",
type: "circle",
source: "track",
paint: {
"circle-radius": 6,
"circle-color": "#a78bfa",
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1,
},
},
);
const map = new maplibregl.Map({
container: mapContainerRef.current!,
style,
center: [37.618423, 55.751244],
zoom: 11,
});
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), "top-right");
map.on("load", () => {
const bounds = meta.bounds;
if (typeof bounds === "string") {
const parts = bounds.split(",").map((p: string) => Number(p.trim()));
if (parts.length === 4 && parts.every((n: number) => Number.isFinite(n))) {
map.fitBounds(
[
[parts[0], parts[1]],
[parts[2], parts[3]],
],
{ padding: 30, duration: 0 },
);
}
}
});
mapRef.current = map;
})().catch((err) => onLog?.(`maplibre init error: ${err}`, "error"));
return () => {
canceled = true;
};
}, [mapMode, vectorMetaUrl, vectorTileUrl, onLog]);
useEffect(() => {
if (mapMode !== "vector") return;
const map = mapRef.current;
if (!map) return;
const geojson = {
type: "FeatureCollection",
features: [
positions.length
? { type: "Feature", geometry: { type: "LineString", coordinates: positions.map((p: any) => [p[1], p[0]]) } }
: null,
lastPoint ? { type: "Feature", geometry: { type: "Point", coordinates: [lastPoint.lon, lastPoint.lat] } } : null,
].filter(Boolean),
};
try {
const src = map.getSource("track");
if (src && src.setData) {
src.setData(geojson);
}
} catch (err) {
onLog?.(`maplibre track update error: ${err}`, "warn");
}
}, [mapMode, positions, lastPoint, onLog]);
return { mapContainerRef };
};

View file

@ -1,46 +1,4 @@
import ReactDOM from "react-dom/client";
import App from "./App";
const backendBase =
(import.meta.env.VITE_BACKEND_URL as string | undefined) ?? "http://localhost:8000";
const sendFrontLog = (message: string, level: "info" | "warn" | "error" = "info") => {
fetch(`${backendBase}/logs`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message, level, source: "frontend" }),
}).catch(() => {});
};
window.addEventListener("error", (event) => {
const err = event.error instanceof Error ? event.error : null;
const details = err?.stack || err?.message || String(event.message || "unknown error");
sendFrontLog(`window.error: ${details}`, "error");
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason instanceof Error ? event.reason.stack || event.reason.message : String(event.reason);
sendFrontLog(`unhandledrejection: ${reason}`, "error");
});
const _consoleError = console.error.bind(console);
console.error = (...args: unknown[]) => {
try {
sendFrontLog(`console.error: ${args.map(String).join(" ")}`, "error");
} catch {
// ignore
}
_consoleError(...args);
};
const _consoleWarn = console.warn.bind(console);
console.warn = (...args: unknown[]) => {
try {
sendFrontLog(`console.warn: ${args.map(String).join(" ")}`, "warn");
} catch {
// ignore
}
_consoleWarn(...args);
};
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(<App />);

View file

@ -1,59 +0,0 @@
import type { LatLngExpression } from "leaflet";
export type MapMode = "raster" | "vector";
export type FixMessage = {
type: "fix";
lat: number;
lon: number;
alt: number | null;
timestamp: string;
source: string;
fresh: boolean;
detail?: string;
};
export type StatusMessage = {
type: "status";
status: string;
timestamp: string;
detail?: string;
};
export type IncomingMessage = FixMessage | StatusMessage;
export type AppConfig = {
tiles_url: string;
tiles_url_tms?: string;
vector_tiles_url?: string;
vector_metadata_url?: string;
vector_tileset_url?: string;
vector_tileset_metadata_url?: string;
tilesets_url?: string;
tilesets_activate_url?: string;
ws_url: string;
gnss_source: string;
gnss_state_url?: string;
gnss_mode_url?: string;
gnss_sim_center_url?: string;
};
export type TrackPoint = {
lat: number;
lon: number;
timestamp: string;
fresh: boolean;
};
export type SliceParams = {
source: string;
destination: string;
tileset: string;
min_zoom: number;
max_zoom: number;
tile_format: string;
scheme: "xyz" | "tms";
source_format: "geotiff" | "mbtiles" | "tms" | "xyz";
};
export type TrackPositions = LatLngExpression[];

View file

@ -1,10 +0,0 @@
export const getInitialTheme = (): "dark" | "light" => {
if (typeof window === "undefined") return "dark";
try {
const stored = localStorage.getItem("theme");
if (stored === "light" || stored === "dark") return stored;
} catch {
// ignore storage errors
}
return window.matchMedia?.("(prefers-color-scheme: light)")?.matches ? "light" : "dark";
};

View file

@ -1,8 +0,0 @@
export function formatTime(ts?: string) {
if (!ts) return "—";
try {
return new Date(ts).toLocaleTimeString();
} catch {
return ts;
}
}

View file

@ -1,97 +0,0 @@
import argparse
import os
import sys
from pathlib import Path
from typing import Optional
import uvicorn
from gnss.main import app
def _env(name: str, default: str) -> str:
v = os.getenv(name)
return v if v is not None and v != "" else default
class _TeeStream:
def __init__(self, primary, secondary):
self._primary = primary
self._secondary = secondary
def write(self, data):
try:
self._primary.write(data)
except Exception:
pass
try:
self._secondary.write(data)
except Exception:
pass
return len(data)
def flush(self):
try:
self._primary.flush()
except Exception:
pass
try:
self._secondary.flush()
except Exception:
pass
def isatty(self):
try:
return self._primary.isatty()
except Exception:
return False
def _ensure_uvicorn_logs_to_file(log_path: Path) -> None:
import logging
log_path.parent.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s"))
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
if not any(
isinstance(h, logging.FileHandler) and getattr(h, "baseFilename", None) == str(log_path)
for h in logger.handlers
):
logger.addHandler(file_handler)
logger.propagate = True
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(prog="gnss", description="GNSS Backend (FastAPI)")
parser.add_argument("--host", default=_env("GNSS_HOST", "0.0.0.0"))
parser.add_argument("--port", type=int, default=int(_env("GNSS_PORT", "8000")))
parser.add_argument("--log-level", default=_env("GNSS_LOG_LEVEL", "info"))
parser.add_argument("--log-file", default=_env("GNSS_BACKEND_LOG_FILE", "logs/backend.log"))
args = parser.parse_args(argv)
log_path = Path(args.log_file).expanduser()
log_path.parent.mkdir(parents=True, exist_ok=True)
backend_log_file = log_path.open("a", encoding="utf-8", buffering=1)
sys.stdout = _TeeStream(sys.stdout, backend_log_file) # type: ignore[assignment]
sys.stderr = _TeeStream(sys.stderr, backend_log_file) # type: ignore[assignment]
_ensure_uvicorn_logs_to_file(log_path)
uvicorn.run(
app,
host=args.host,
port=args.port,
log_level=args.log_level,
access_log=True,
log_config=None,
workers=1,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,15 +1,3 @@
from .state import (
get_gnss_service,
get_log_service,
get_raster_mbtiles_service,
get_tile_service,
get_vector_mbtiles_service,
)
from .state import get_gnss_service, get_log_service, get_tile_service, get_vector_mbtiles_service
__all__ = [
"get_gnss_service",
"get_tile_service",
"get_log_service",
"get_raster_mbtiles_service",
"get_vector_mbtiles_service",
]
__all__ = ["get_gnss_service", "get_tile_service", "get_log_service", "get_vector_mbtiles_service"]

View file

@ -1,29 +1,19 @@
from fastapi import APIRouter
from ... import config
from .. import state
router = APIRouter(tags=["config"])
@router.get("/config")
async def get_config() -> dict:
try:
gnss_source = state.get_gnss_service().source_kind()
except Exception:
gnss_source = config.GNSS_SOURCE
return {
"tiles_url": "/tiles/{z}/{x}/{y}.png",
"tiles_url_tms": "/tiles/tms/{z}/{x}/{y}.png",
"vector_tiles_url": "/tiles/vector/{z}/{x}/{y}.pbf",
"vector_metadata_url": "/tiles/vector/metadata",
"vector_tileset_url": "/tilesets/active/vector/{z}/{x}/{y}.pbf",
"vector_tileset_metadata_url": "/tilesets/active/vector/metadata",
"tilesets_url": "/tilesets",
"tilesets_activate_url": "/tilesets/activate",
"ws_url": "/ws/track",
"gnss_source": gnss_source,
"gnss_state_url": "/gnss/state",
"gnss_mode_url": "/gnss/mode",
"gnss_sim_center_url": "/gnss/sim/center",
"gnss_source": config.GNSS_SOURCE,
}

View file

@ -1,61 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from ..dependencies import get_gnss_service
from ... import config
from ...services.gnss import GNSSService, RectangularPathSource, SerialNMEASource, SimulatedPathSource
router = APIRouter(tags=["gnss"])
@router.get("/gnss/state")
async def gnss_state(gnss: GNSSService = Depends(get_gnss_service)) -> dict:
return {"status": "ok", "gnss": gnss.describe()}
class SetGNSSModeRequest(BaseModel):
mode: str = Field(pattern="^(simulated|serial)$")
@router.post("/gnss/mode")
async def set_gnss_mode(payload: SetGNSSModeRequest, gnss: GNSSService = Depends(get_gnss_service)) -> dict:
mode = payload.mode.lower()
if mode == "serial":
source = SerialNMEASource(port=config.GNSS_PORT, baudrate=config.GNSS_BAUD, timeout=config.GNSS_TIMEOUT)
await gnss.set_source(source)
return {"status": "ok", "gnss": gnss.describe()}
start_lat, start_lon = config.SIM_START_POINT
path = (getattr(config, "SIM_PATH", "circle") or "circle").lower()
if path == "rectangle":
source = RectangularPathSource(
center_lat=start_lat,
center_lon=start_lon,
width_m=config.SIM_RECT_WIDTH_METERS,
height_m=config.SIM_RECT_HEIGHT_METERS,
step_m=config.SIM_STEP_METERS,
alt_m=config.SIM_ALT_METERS,
)
else:
source = SimulatedPathSource(center_lat=start_lat, center_lon=start_lon, radius_m=config.SIM_RADIUS_METERS)
await gnss.set_source(source)
return {"status": "ok", "gnss": gnss.describe()}
class UpdateSimulatedCenterRequest(BaseModel):
lat: float = Field(ge=-90, le=90)
lon: float = Field(ge=-180, le=180)
@router.post("/gnss/sim/center")
async def update_simulated_center(
payload: UpdateSimulatedCenterRequest,
gnss: GNSSService = Depends(get_gnss_service),
) -> dict:
try:
await gnss.update_simulated_center(lat=payload.lat, lon=payload.lon)
except TypeError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"status": "ok", "gnss": gnss.describe()}

View file

@ -1,110 +0,0 @@
import json
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from ..dependencies import get_raster_mbtiles_service, get_tile_service, get_vector_mbtiles_service
from ...services.raster_mbtiles import RasterMBTilesError, RasterMBTilesService
from ...services.tiles import TileCutError, TileService
from ...services.vector_mbtiles import VectorMBTilesError, VectorMBTilesService
from ...services.tilesets import TilesetError, TilesetService
from ... import config
router = APIRouter(tags=["maps"])
class MapIngestRequest(BaseModel):
path: str
tileset: str | None = None
min_zoom: int = Field(default=10, ge=0)
max_zoom: int = Field(default=16, ge=0)
@router.post("/maps/ingest")
async def ingest_map(
payload: MapIngestRequest,
tile_service: TileService = Depends(get_tile_service),
raster_mbtiles: RasterMBTilesService = Depends(get_raster_mbtiles_service),
vector_mbtiles: VectorMBTilesService = Depends(get_vector_mbtiles_service),
) -> dict:
p = Path(payload.path).expanduser().resolve()
if not p.exists():
raise HTTPException(status_code=400, detail=f"path not found: {p}")
tileset_svc = TilesetService(tilesets_dir=config.TILESSETS_DIR, default_tileset=config.DEFAULT_TILESET)
tileset_svc.ensure()
suggested_tileset = (payload.tileset or p.stem or "").strip()
tileset_name: str | None = None
if suggested_tileset:
try:
tileset_name = tileset_svc.create(suggested_tileset)
tileset_svc.set_active(tileset_name)
tile_service.set_tiles_dir(tileset_svc.resolve(tileset_name))
except TilesetError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if p.suffix.lower() == ".mbtiles":
try:
info = vector_mbtiles.activate(p)
if tileset_name:
manifest = {"kind": "vector-mbtiles", "source": str(info.path), "format": info.metadata.get("format")}
(tileset_svc.resolve(tileset_name) / "gnss_tileset.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return {
"status": "ok",
"kind": "vector",
"driver": "mbtiles",
"path": str(info.path),
"format": info.metadata.get("format"),
"tileset": tileset_name,
}
except VectorMBTilesError:
pass
try:
info = raster_mbtiles.activate(p)
except RasterMBTilesError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if tileset_name:
manifest = {"kind": "raster-mbtiles", "source": str(info.path), "format": info.metadata.get("format")}
(tileset_svc.resolve(tileset_name) / "gnss_tileset.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return {
"status": "ok",
"kind": "raster",
"driver": "mbtiles",
"path": str(info.path),
"format": info.metadata.get("format"),
"tileset": tileset_name,
}
if p.suffix.lower() in {".tif", ".tiff", ".vrt"}:
if payload.max_zoom < payload.min_zoom:
raise HTTPException(status_code=400, detail="max_zoom must be >= min_zoom")
if tileset_name:
tile_service.set_tiles_dir(tileset_svc.resolve(tileset_name))
try:
tile_service.slice_to_tiles(
source=p,
destination=tile_service.tiles_dir,
min_zoom=payload.min_zoom,
max_zoom=payload.max_zoom,
scheme="xyz",
)
except TileCutError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {
"status": "ok",
"kind": "raster",
"driver": "gdal2tiles",
"path": str(p),
"tiles_dir": str(tile_service.tiles_dir),
"tileset": tileset_name,
}
raise HTTPException(status_code=400, detail="unsupported input; expected .mbtiles or GeoTIFF/VRT")

View file

@ -1,51 +0,0 @@
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response
from pydantic import BaseModel
from ..dependencies import get_raster_mbtiles_service
from ...services.raster_mbtiles import RasterMBTilesError, RasterMBTilesService
router = APIRouter(tags=["raster-mbtiles"])
class ActivateRasterMBTiles(BaseModel):
path: str
@router.post("/tiles/raster/activate")
async def activate_raster_mbtiles(
payload: ActivateRasterMBTiles,
service: RasterMBTilesService = Depends(get_raster_mbtiles_service),
):
try:
info = service.activate(Path(payload.path))
except RasterMBTilesError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {"status": "ok", "path": str(info.path), "format": info.metadata.get("format")}
@router.get("/tiles/raster/metadata")
async def raster_metadata(service: RasterMBTilesService = Depends(get_raster_mbtiles_service)):
try:
return service.get_metadata()
except RasterMBTilesError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.get("/tiles/raster/{z}/{x}/{y}")
async def raster_tile(
z: int,
x: int,
y: int,
service: RasterMBTilesService = Depends(get_raster_mbtiles_service),
):
try:
data, media_type = service.get_tile(z=z, x=x, y_xyz=y)
except KeyError:
raise HTTPException(status_code=404, detail="tile not found")
except RasterMBTilesError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return Response(content=data, media_type=media_type)

View file

@ -3,13 +3,9 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile
from fastapi.responses import JSONResponse
import json
from ...api.dependencies import get_raster_mbtiles_service, get_tile_service, get_vector_mbtiles_service
from ...api.dependencies import get_tile_service
from ...models.tiles import TileSliceRequest
from ...services.raster_mbtiles import RasterMBTilesError, RasterMBTilesService
from ...services.tiles import TileCutError, TileService
from ...services.vector_mbtiles import VectorMBTilesError, VectorMBTilesService
from ... import config
from ...services.tilesets import TilesetService, TilesetError
@ -17,49 +13,17 @@ router = APIRouter(tags=["tiles"])
@router.get("/tiles/{zoom}/{x}/{y}.png")
async def serve_tile(
zoom: int,
x: int,
y: int,
tile_service: TileService = Depends(get_tile_service),
raster_mbtiles: RasterMBTilesService = Depends(get_raster_mbtiles_service),
):
hit = tile_service._tiles_dir_tile_path(zoom, x, y)
if hit:
async def serve_tile(zoom: int, x: int, y: int, tile_service: TileService = Depends(get_tile_service)):
return tile_service.get_tile_response(zoom, x, y)
try:
data, media_type = raster_mbtiles.get_tile(z=zoom, x=x, y_xyz=y)
except Exception:
return tile_service.get_tile_response(zoom, x, y)
return tile_service.bytes_response(data=data, media_type=media_type)
@router.get("/tiles/tms/{zoom}/{x}/{y}.png")
async def serve_tile_tms(
zoom: int,
x: int,
y: int,
tile_service: TileService = Depends(get_tile_service),
raster_mbtiles: RasterMBTilesService = Depends(get_raster_mbtiles_service),
):
y_converted = (2**zoom - 1) - y
hit = tile_service._tiles_dir_tile_path(zoom, x, y_converted)
if hit:
return tile_service.get_tile_response(zoom, x, y_converted)
try:
data, media_type = raster_mbtiles.get_tile(z=zoom, x=x, y_xyz=y_converted)
except Exception:
async def serve_tile_tms(zoom: int, x: int, y: int, tile_service: TileService = Depends(get_tile_service)):
return tile_service.get_tile_response_tms(zoom, x, y)
return tile_service.bytes_response(data=data, media_type=media_type)
@router.post("/tiles/slice")
async def slice_tiles(
payload: TileSliceRequest,
tile_service: TileService = Depends(get_tile_service),
raster_mbtiles: RasterMBTilesService = Depends(get_raster_mbtiles_service),
vector_mbtiles: VectorMBTilesService = Depends(get_vector_mbtiles_service),
) -> dict:
async def slice_tiles(payload: TileSliceRequest, tile_service: TileService = Depends(get_tile_service)) -> dict:
source = payload.source_path()
tileset_svc = TilesetService(tilesets_dir=config.TILESSETS_DIR, default_tileset=config.DEFAULT_TILESET)
tileset_svc.ensure()
@ -72,28 +36,6 @@ async def slice_tiles(
destination_default = tileset_svc.resolve(tileset_name)
destination = payload.destination_path(destination_default)
try:
if payload.source_format == "mbtiles":
try:
info = vector_mbtiles.activate(source)
kind = "vector"
manifest = {"kind": "vector-mbtiles", "source": str(info.path), "format": info.metadata.get("format")}
except VectorMBTilesError:
info = raster_mbtiles.activate(source)
kind = "raster"
manifest = {"kind": "raster-mbtiles", "source": str(info.path), "format": info.metadata.get("format")}
active_tileset = tileset_name or tileset_svc.get_active()
(tileset_svc.resolve(active_tileset) / "gnss_tileset.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return {
"status": "ok",
"kind": kind,
"driver": "mbtiles",
"path": str(source),
"scheme": payload.scheme,
"tileset": active_tileset,
}
if payload.source_format in {"tms", "xyz"}:
src = tile_service.unpack_if_archive(source)
tile_service.copy_tile_tree(src, destination)
@ -111,37 +53,12 @@ async def slice_tiles(
)
except TileCutError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except (RasterMBTilesError, VectorMBTilesError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
active_tileset = tileset_name or tileset_svc.get_active()
try:
tileset_root = tileset_svc.resolve(active_tileset)
(tileset_root / "gnss_tileset.json").write_text(
json.dumps(
{
"tileset": active_tileset,
"scheme": payload.scheme,
"source_format": payload.source_format,
"tile_format": payload.tile_format,
"min_zoom": payload.min_zoom,
"max_zoom": payload.max_zoom,
"source": str(source),
"destination": str(destination),
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
except Exception:
pass
return {
"status": "ok",
"tiles_dir": str(destination),
"source": str(source),
"scheme": payload.scheme,
"tileset": active_tileset,
"tileset": tileset_name or tileset_svc.get_active(),
}

View file

@ -1,16 +1,10 @@
import json
import math
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Response
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from ..dependencies import get_raster_mbtiles_service, get_tile_service, get_vector_mbtiles_service
from ..dependencies import get_tile_service
from ... import config
from ...services.raster_mbtiles import RasterMBTilesError, RasterMBTilesService
from ...services.tiles import TileService
from ...services.tilesets import TilesetError, TilesetService
from ...services.vector_mbtiles import VectorMBTilesError, VectorMBTilesService
router = APIRouter(tags=["tilesets"])
@ -28,194 +22,10 @@ class ActivateTileset(BaseModel):
name: str
def _is_gzipped(data: bytes) -> bool:
return len(data) >= 2 and data[0] == 0x1F and data[1] == 0x8B
def _detect_kind(path: Path) -> str:
manifest = path / "gnss_tileset.json"
if manifest.is_file():
try:
parsed = json.loads(manifest.read_text(encoding="utf-8"))
kind = str(parsed.get("kind") or "").lower()
if kind in {"vector-mbtiles", "raster-mbtiles"}:
return kind
except Exception:
pass
meta = path / "metadata.json"
if meta.is_file():
try:
parsed = json.loads(meta.read_text(encoding="utf-8"))
fmt = str(parsed.get("format") or "").lower()
if fmt in {"pbf", "mvt"}:
return "vector-dir"
except Exception:
pass
if any(path.rglob("*.png")) or any(path.rglob("*.jpg")) or any(path.rglob("*.jpeg")) or any(path.rglob("*.webp")):
return "raster"
if any(path.rglob("*.pbf")):
return "vector-dir"
return "unknown"
def _tile_y_to_lat(y: float, z: int) -> float:
n = math.pi - (2.0 * math.pi * y) / (2**z)
return math.degrees(math.atan(math.sinh(n)))
def _tile_x_to_lon(x: float, z: int) -> float:
return (x / (2**z)) * 360.0 - 180.0
def _scan_xyz_tile_matrix_extent(path: Path, z_value: int) -> tuple[int, int, int, int] | None:
z_dir = path / str(z_value)
if not z_dir.is_dir():
return None
min_x = None
max_x = None
min_y = None
max_y = None
for xdir in z_dir.iterdir():
if not xdir.is_dir():
continue
try:
x = int(xdir.name)
except Exception:
continue
min_x = x if min_x is None else min(min_x, x)
max_x = x if max_x is None else max(max_x, x)
for f in xdir.iterdir():
if not f.is_file():
continue
if f.suffix.lower() not in {".png", ".jpg", ".jpeg", ".webp"}:
continue
try:
y = int(f.stem)
except Exception:
continue
min_y = y if min_y is None else min(min_y, y)
max_y = y if max_y is None else max(max_y, y)
if min_x is None or max_x is None or min_y is None or max_y is None:
return None
return min_x, min_y, max_x, max_y
def _tile_matrix_to_bounds(x_min: int, y_min: int, x_max: int, y_max: int, z: int) -> tuple[float, float, float, float]:
west = _tile_x_to_lon(x_min, z)
east = _tile_x_to_lon(x_max + 1, z)
north = _tile_y_to_lat(y_min, z)
south = _tile_y_to_lat(y_max + 1, z)
return west, south, east, north
def _parse_mapml_extent(path: Path) -> dict | None:
mapml = path / "mapml.mapml"
if not mapml.is_file():
return None
text = mapml.read_text(encoding="utf-8", errors="ignore")
import re
z_tag = re.search(r'<input[^>]*name="z"[^>]*>', text)
x_tag = re.search(r'<input[^>]*name="x"[^>]*>', text)
y_tag = re.search(r'<input[^>]*name="y"[^>]*>', text)
if not z_tag or not x_tag or not y_tag:
return None
def _attrs(tag: str) -> dict[str, str]:
return {m.group(1): m.group(2) for m in re.finditer(r'(\w+)="([^"]+)"', tag)}
z_attrs = _attrs(z_tag.group(0))
x_attrs = _attrs(x_tag.group(0))
y_attrs = _attrs(y_tag.group(0))
try:
z_value = int(z_attrs.get("value", z_attrs.get("max", "0")))
z_min = int(z_attrs.get("min", "0"))
z_max = int(z_attrs.get("max", str(z_value)))
x_min = int(x_attrs["min"])
x_max = int(x_attrs["max"])
y_min = int(y_attrs["min"])
y_max = int(y_attrs["max"])
except Exception:
return None
scanned = _scan_xyz_tile_matrix_extent(path, z_value)
if scanned:
x_min, y_min, x_max, y_max = scanned
west, south, east, north = _tile_matrix_to_bounds(x_min, y_min, x_max, y_max, z_value)
return {
"scheme": "xyz",
"z_value": z_value,
"minzoom": z_min,
"maxzoom": z_max,
"bounds": [west, south, east, north],
"center": [(west + east) / 2.0, (south + north) / 2.0],
"tile_matrix": {"z": z_value, "x_min": x_min, "x_max": x_max, "y_min": y_min, "y_max": y_max},
}
def _parse_leaflet_bounds(path: Path) -> dict | None:
leaflet = path / "leaflet.html"
if not leaflet.is_file():
return None
text = leaflet.read_text(encoding="utf-8", errors="ignore")
import re
m = re.search(
r"map\\.fitBounds\\(\\[\\[([0-9.+-]+),\\s*([0-9.+-]+)\\],\\s*\\[([0-9.+-]+),\\s*([0-9.+-]+)\\]\\]\\)",
text,
)
if not m:
return None
lat1, lon1, lat2, lon2 = map(float, m.groups())
south = min(lat1, lat2)
north = max(lat1, lat2)
west = min(lon1, lon2)
east = max(lon1, lon2)
mzoom = re.search(r"minZoom:\\s*(\\d+)", text)
mxzoom = re.search(r"maxZoom:\\s*(\\d+)", text)
mcenter = re.search(r"center:\\s*\\[([0-9.+-]+),\\s*([0-9.+-]+)\\]", text)
minzoom = int(mzoom.group(1)) if mzoom else None
maxzoom = int(mxzoom.group(1)) if mxzoom else None
center = (
[float(mcenter.group(2)), float(mcenter.group(1))]
if mcenter
else [(west + east) / 2.0, (south + north) / 2.0]
)
return {
"scheme": "xyz",
"bounds": [west, south, east, north],
"center": center,
"minzoom": minzoom,
"maxzoom": maxzoom,
}
@router.get("/tilesets")
async def list_tilesets() -> dict:
svc = _tileset_service()
tilesets = svc.list_tilesets()
kinds = {name: _detect_kind(svc.resolve(name)) for name in tilesets}
extents = {}
for name in tilesets:
p = svc.resolve(name)
e = _parse_mapml_extent(p) or _parse_leaflet_bounds(p)
if e:
extents[name] = e
return {"tilesets": tilesets, "active": svc.get_active(), "kinds": kinds, "extents": extents}
@router.get("/tilesets/active/extent")
async def active_tileset_extent() -> dict:
svc = _tileset_service()
tileset_dir = svc.resolve(None)
extent = _parse_mapml_extent(tileset_dir) or _parse_leaflet_bounds(tileset_dir)
if not extent:
raise HTTPException(status_code=404, detail="no extent info for active tileset")
extent["tileset"] = svc.get_active()
return extent
return {"tilesets": svc.list_tilesets(), "active": svc.get_active()}
@router.post("/tilesets")
@ -229,66 +39,11 @@ async def create_tileset(payload: CreateTileset) -> dict:
@router.post("/tilesets/activate")
async def activate_tileset(
payload: ActivateTileset,
tile_service: TileService = Depends(get_tile_service),
raster_mbtiles: RasterMBTilesService = Depends(get_raster_mbtiles_service),
vector_mbtiles: VectorMBTilesService = Depends(get_vector_mbtiles_service),
) -> dict:
async def activate_tileset(payload: ActivateTileset, tile_service: TileService = Depends(get_tile_service)) -> dict:
svc = _tileset_service()
try:
active = svc.set_active(payload.name)
except TilesetError as exc:
raise HTTPException(status_code=400, detail=str(exc))
tile_service.set_tiles_dir(svc.resolve(active))
tileset_dir = svc.resolve(active)
kind = _detect_kind(tileset_dir)
activated: dict | None = None
manifest_path = tileset_dir / "gnss_tileset.json"
if manifest_path.is_file() and kind in {"vector-mbtiles", "raster-mbtiles"}:
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
src = manifest.get("source") or manifest.get("path") or manifest.get("mbtiles")
if not src:
raise ValueError("gnss_tileset.json missing 'source'")
mbtiles_path = Path(str(src)).expanduser().resolve()
if kind == "vector-mbtiles":
info = vector_mbtiles.activate(mbtiles_path)
activated = {"kind": "vector", "driver": "mbtiles", "path": str(info.path)}
else:
info = raster_mbtiles.activate(mbtiles_path)
activated = {"kind": "raster", "driver": "mbtiles", "path": str(info.path)}
except (VectorMBTilesError, RasterMBTilesError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
except Exception as exc:
raise HTTPException(status_code=400, detail=f"failed to activate mbtiles tileset: {exc}")
return {"status": "ok", "active": active, "kind": kind, "activated": activated}
@router.get("/tilesets/active/vector/metadata")
async def active_vector_metadata() -> dict:
svc = _tileset_service()
tileset_dir = svc.resolve(None)
meta_path = tileset_dir / "metadata.json"
if not meta_path.is_file():
raise HTTPException(status_code=400, detail="active tileset has no metadata.json (not a MapTiler vector folder)")
try:
parsed = json.loads(meta_path.read_text(encoding="utf-8"))
except Exception as exc:
raise HTTPException(status_code=400, detail=f"failed to parse metadata.json: {exc}")
parsed["tileset"] = svc.get_active()
return parsed
@router.get("/tilesets/active/vector/{z}/{x}/{y}.pbf")
async def active_vector_tile(z: int, x: int, y: int) -> Response:
svc = _tileset_service()
tileset_dir = svc.resolve(None)
tile_path = tileset_dir / str(z) / str(x) / f"{y}.pbf"
if not tile_path.is_file():
raise HTTPException(status_code=404, detail="tile not found")
data = tile_path.read_bytes()
headers = {}
if _is_gzipped(data):
headers["Content-Encoding"] = "gzip"
return Response(content=data, media_type="application/x-protobuf", headers=headers)
return {"status": "ok", "active": active}

View file

@ -3,28 +3,21 @@ from typing import Optional
from ..services.gnss import GNSSService
from ..services.logs import LogService
from ..services.tiles import TileService
from ..services.raster_mbtiles import RasterMBTilesService
from ..services.vector_mbtiles import VectorMBTilesService
_gnss_service: Optional[GNSSService] = None
_tile_service: Optional[TileService] = None
_log_service: Optional[LogService] = None
_raster_mbtiles_service: Optional[RasterMBTilesService] = None
_vector_mbtiles_service: Optional[VectorMBTilesService] = None
def set_services(
gnss: GNSSService,
tiles: TileService,
logger: LogService,
raster_mbtiles: RasterMBTilesService,
vector_mbtiles: VectorMBTilesService,
gnss: GNSSService, tiles: TileService, logger: LogService, vector_mbtiles: VectorMBTilesService
) -> None:
global _gnss_service, _tile_service, _log_service, _raster_mbtiles_service, _vector_mbtiles_service
global _gnss_service, _tile_service, _log_service, _vector_mbtiles_service
_gnss_service = gnss
_tile_service = tiles
_log_service = logger
_raster_mbtiles_service = raster_mbtiles
_vector_mbtiles_service = vector_mbtiles
@ -50,9 +43,3 @@ def get_vector_mbtiles_service() -> VectorMBTilesService:
if _vector_mbtiles_service is None:
raise RuntimeError("Vector MBTiles service is not initialized")
return _vector_mbtiles_service
def get_raster_mbtiles_service() -> RasterMBTilesService:
if _raster_mbtiles_service is None:
raise RuntimeError("Raster MBTiles service is not initialized")
return _raster_mbtiles_service

View file

@ -1,28 +1,16 @@
import os
import sys
from pathlib import Path
BASE_DIR = Path(__file__).parent
def _default_data_dir() -> Path:
if getattr(sys, "frozen", False):
return Path.home() / ".local" / "share" / "gnss"
return BASE_DIR
DATA_DIR = Path(os.getenv("GNSS_DATA_DIR", str(_default_data_dir()))).expanduser()
TILES_DIR = DATA_DIR / "tiles"
TILESSETS_DIR = DATA_DIR / "tilesets"
TILES_DIR = BASE_DIR / "tiles"
TILESSETS_DIR = BASE_DIR / "tilesets"
DEFAULT_TILESET = "default"
TILE_PLACEHOLDER = TILES_DIR / "placeholder.png"
UPLOAD_DIR = DATA_DIR / "uploads"
TILE_PLACEHOLDER = BASE_DIR / "tiles" / "placeholder.png"
UPLOAD_DIR = BASE_DIR / "uploads"
GNSS_SOURCE = "simulated"
GNSS_PORT = "/dev/ttyAMA0"
GNSS_BAUD = 9600
GNSS_TIMEOUT = 1.0
SIM_START_POINT = (55.1644, 61.4368)
SIM_START_POINT = (55.75222, 37.61556)
SIM_PATH = "circle" # circle | rectangle
# Для симуляции движения по прямоугольнику раскомментируй строку ниже:
# SIM_PATH = "rectangle"

View file

@ -6,11 +6,10 @@ from fastapi.middleware.cors import CORSMiddleware
from . import config
from .api import state
from .api.routes import config_route, gnss, health, logs, maps, raster_mbtiles, tiles, tilesets, vector_tiles, ws
from .api.routes import config_route, health, logs, tiles, tilesets, vector_tiles, ws
from .services.gnss import GNSSService, RectangularPathSource, SerialNMEASource, SimulatedPathSource
from .services.logs import LogService
from .services.tiles import TileService
from .services.raster_mbtiles import RasterMBTilesService
from .services.vector_mbtiles import VectorMBTilesService
from .services.tilesets import TilesetService
@ -53,16 +52,9 @@ tiles_dir = tileset_service.resolve(None)
placeholder_tile = Path(config.TILE_PLACEHOLDER)
tile_service = TileService(tiles_dir=tiles_dir, placeholder=placeholder_tile, upload_dir=Path(config.UPLOAD_DIR))
log_service = LogService(base_dir=Path("logs"))
raster_mbtiles_service = RasterMBTilesService(state_file=Path(config.DATA_DIR) / "raster_mbtiles.active")
vector_mbtiles_service = VectorMBTilesService(state_file=Path(config.DATA_DIR) / "vector_mbtiles.active")
vector_mbtiles_service = VectorMBTilesService()
gnss_service = create_gnss_service()
state.set_services(
gnss=gnss_service,
tiles=tile_service,
logger=log_service,
raster_mbtiles=raster_mbtiles_service,
vector_mbtiles=vector_mbtiles_service,
)
state.set_services(gnss=gnss_service, tiles=tile_service, logger=log_service, vector_mbtiles=vector_mbtiles_service)
app = FastAPI(title="GNSS Backend", version="0.2.0")
@ -84,9 +76,6 @@ app.include_router(config_route.router)
app.include_router(tiles.router)
app.include_router(ws.router)
app.include_router(logs.router)
app.include_router(gnss.router)
app.include_router(maps.router)
app.include_router(raster_mbtiles.router)
app.include_router(vector_tiles.router)
app.include_router(tilesets.router)

View file

@ -180,43 +180,6 @@ class GNSSService:
async def stop(self) -> None:
await self.source.stop()
def source_kind(self) -> str:
if isinstance(self.source, SerialNMEASource):
return "serial"
if isinstance(self.source, (SimulatedPathSource, RectangularPathSource)):
return "simulated"
return "unknown"
def describe(self) -> dict:
kind = self.source_kind()
payload: dict = {"mode": kind}
if kind == "simulated":
payload["lat"] = getattr(self.source, "center_lat", None)
payload["lon"] = getattr(self.source, "center_lon", None)
payload["path"] = "rectangle" if isinstance(self.source, RectangularPathSource) else "circle"
if kind == "serial":
payload["port"] = getattr(self.source, "port", None)
payload["baudrate"] = getattr(self.source, "baudrate", None)
return payload
async def set_source(self, source: BaseGNSSSource) -> None:
async with self._lock:
try:
await self.source.stop()
except Exception:
pass
self.source = source
self._last_fix = None
await self.source.start()
async def update_simulated_center(self, lat: float, lon: float) -> None:
async with self._lock:
if not isinstance(self.source, (SimulatedPathSource, RectangularPathSource)):
raise TypeError("current GNSS source is not simulated")
self.source.center_lat = lat
self.source.center_lon = lon
self._last_fix = None
async def _fetch_fix(self) -> Optional[GNSSPoint]:
async with self._lock:
fix = await self.source.next_point()

View file

@ -1,148 +0,0 @@
import sqlite3
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
class RasterMBTilesError(RuntimeError):
pass
@dataclass(frozen=True)
class RasterMBTilesInfo:
path: Path
metadata: dict[str, Any]
def _read_metadata(conn: sqlite3.Connection) -> dict[str, str]:
cur = conn.execute("SELECT name, value FROM metadata")
return {name: value for name, value in cur.fetchall()}
def _has_table(conn: sqlite3.Connection, table: str) -> bool:
cur = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=? LIMIT 1",
(table,),
)
return cur.fetchone() is not None
def _format_to_media_type(fmt: str) -> str:
f = (fmt or "").lower()
if f in {"jpg", "jpeg"}:
return "image/jpeg"
if f == "png":
return "image/png"
if f == "webp":
return "image/webp"
return "application/octet-stream"
class RasterMBTilesService:
def __init__(self, state_file: Optional[Path] = None):
self._lock = threading.Lock()
self._active: Optional[RasterMBTilesInfo] = None
self._state_file = Path(state_file).expanduser().resolve() if state_file else None
self._restore()
def _restore(self) -> None:
if not self._state_file:
return
try:
raw = self._state_file.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return
except Exception:
return
if not raw:
return
try:
self.activate(Path(raw))
except Exception:
return
def _persist(self, info: RasterMBTilesInfo) -> None:
if not self._state_file:
return
try:
self._state_file.parent.mkdir(parents=True, exist_ok=True)
self._state_file.write_text(str(info.path), encoding="utf-8")
except Exception:
return
def activate(self, path: Path) -> RasterMBTilesInfo:
p = Path(path).expanduser().resolve()
if not p.exists():
raise RasterMBTilesError(f"mbtiles not found: {p}")
if p.suffix.lower() != ".mbtiles":
raise RasterMBTilesError("expected .mbtiles file")
conn = sqlite3.connect(f"file:{p}?mode=ro", uri=True)
try:
metadata = _read_metadata(conn)
finally:
conn.close()
fmt = (metadata.get("format") or "").lower()
if fmt in {"pbf", "mvt"}:
raise RasterMBTilesError("this is a vector mbtiles; use /tiles/vector/activate")
if fmt not in {"png", "jpg", "jpeg", "webp"}:
raise RasterMBTilesError(f"mbtiles format is '{fmt or 'unknown'}'; expected raster png/jpg/webp")
info = RasterMBTilesInfo(path=p, metadata=metadata)
with self._lock:
self._active = info
self._persist(info)
return info
def get_active(self) -> RasterMBTilesInfo:
with self._lock:
if self._active is None:
raise RasterMBTilesError("no active raster mbtiles; upload/activate first")
return self._active
def get_metadata(self) -> dict[str, Any]:
info = self.get_active()
meta = dict(info.metadata)
meta["path"] = str(info.path)
meta["media_type"] = _format_to_media_type(str(info.metadata.get("format") or ""))
return meta
def get_tile(self, z: int, x: int, y_xyz: int) -> tuple[bytes, str]:
info = self.get_active()
if z < 0:
raise RasterMBTilesError("invalid z")
max_index = (2**z) - 1
if x < 0 or x > max_index or y_xyz < 0 or y_xyz > max_index:
raise RasterMBTilesError("invalid x/y for z")
y_tms = max_index - y_xyz
conn = sqlite3.connect(f"file:{info.path}?mode=ro", uri=True)
try:
if _has_table(conn, "tiles"):
cur = conn.execute(
"SELECT tile_data FROM tiles WHERE zoom_level=? AND tile_column=? AND tile_row=? LIMIT 1",
(z, x, y_tms),
)
row = cur.fetchone()
if row is None:
raise KeyError("tile not found")
data = row[0]
elif _has_table(conn, "map") and _has_table(conn, "images"):
cur = conn.execute(
"SELECT images.tile_data FROM map JOIN images ON map.tile_id = images.tile_id "
"WHERE map.zoom_level=? AND map.tile_column=? AND map.tile_row=? LIMIT 1",
(z, x, y_tms),
)
row = cur.fetchone()
if row is None:
raise KeyError("tile not found")
data = row[0]
else:
raise RasterMBTilesError("unsupported mbtiles schema (no tiles table and no map/images tables)")
finally:
conn.close()
fmt = str(info.metadata.get("format") or "").lower()
return data, _format_to_media_type(fmt)

View file

@ -79,31 +79,14 @@ class TileService:
raise TileCutError(f"gdal2tiles failed: {result.stderr or result.stdout}")
return destination
def _tiles_dir_tile_path(self, zoom: int, x: int, y: int) -> tuple[Path, str] | None:
base = self.tiles_dir / str(zoom) / str(x) / f"{y}"
for ext, media_type in (
(".png", "image/png"),
(".jpg", "image/jpeg"),
(".jpeg", "image/jpeg"),
(".webp", "image/webp"),
):
candidate = base.with_suffix(ext)
if candidate.is_file():
return candidate, media_type
return None
def get_tile_response(self, zoom: int, x: int, y: int):
hit = self._tiles_dir_tile_path(zoom, x, y)
if hit:
tile_path, media_type = hit
return FileResponse(tile_path, media_type=media_type)
tile_path = self.tiles_dir / str(zoom) / str(x) / f"{y}.png"
if tile_path.is_file():
return FileResponse(tile_path, media_type="image/png")
if self.placeholder.is_file():
return FileResponse(self.placeholder, media_type="image/png")
return Response(content=DEFAULT_PLACEHOLDER_PNG, media_type="image/png")
def bytes_response(self, data: bytes, media_type: str):
return Response(content=data, media_type=media_type)
def get_tile_response_tms(self, zoom: int, x: int, y: int):
y_converted = (2**zoom - 1) - y
return self.get_tile_response(zoom, x, y_converted)

View file

@ -29,36 +29,9 @@ def _has_table(conn: sqlite3.Connection, table: str) -> bool:
class VectorMBTilesService:
def __init__(self, state_file: Optional[Path] = None):
def __init__(self):
self._lock = threading.Lock()
self._active: Optional[VectorMBTilesInfo] = None
self._state_file = Path(state_file).expanduser().resolve() if state_file else None
self._restore()
def _restore(self) -> None:
if not self._state_file:
return
try:
raw = self._state_file.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return
except Exception:
return
if not raw:
return
try:
self.activate(Path(raw))
except Exception:
return
def _persist(self, info: VectorMBTilesInfo) -> None:
if not self._state_file:
return
try:
self._state_file.parent.mkdir(parents=True, exist_ok=True)
self._state_file.write_text(str(info.path), encoding="utf-8")
except Exception:
return
def activate(self, path: Path) -> VectorMBTilesInfo:
p = Path(path).expanduser().resolve()
@ -82,7 +55,6 @@ class VectorMBTilesService:
info = VectorMBTilesInfo(path=p, metadata=metadata)
with self._lock:
self._active = info
self._persist(info)
return info
def get_active(self) -> VectorMBTilesInfo:

149
poetry.lock generated
View file

@ -194,43 +194,6 @@ files = [
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "nuitka"
version = "2.8.9"
description = "Python compiler with full language support and CPython compatibility"
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "nuitka-2.8.9.tar.gz", hash = "sha256:b178cd437f2110c46943b368db51d20d57d586a13f8f6323ab1be4e51e2fabf8"},
]
[package.dependencies]
ordered-set = ">=4.1.0"
zstandard = ">=0.15"
[package.extras]
all = ["imageio", "setuptools (>=42)", "toml", "zstandard (>=0.15)"]
app = ["zstandard (>=0.15)"]
build-wheel = ["setuptools (>=42)", "toml", "wheel"]
icon-conversion = ["imageio"]
onefile = ["zstandard (>=0.15)"]
[[package]]
name = "ordered-set"
version = "4.1.0"
description = "An OrderedSet is a custom MutableSet that remembers its order, so that every"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8"},
{file = "ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562"},
]
[package.extras]
dev = ["black", "mypy", "pytest"]
[[package]]
name = "pydantic"
version = "2.12.5"
@ -889,118 +852,6 @@ files = [
{file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"},
]
[[package]]
name = "zstandard"
version = "0.25.0"
description = "Zstandard bindings for Python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "zstandard-0.25.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e59fdc271772f6686e01e1b3b74537259800f57e24280be3f29c8a0deb1904dd"},
{file = "zstandard-0.25.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4d441506e9b372386a5271c64125f72d5df6d2a8e8a2a45a0ae09b03cb781ef7"},
{file = "zstandard-0.25.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:ab85470ab54c2cb96e176f40342d9ed41e58ca5733be6a893b730e7af9c40550"},
{file = "zstandard-0.25.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e05ab82ea7753354bb054b92e2f288afb750e6b439ff6ca78af52939ebbc476d"},
{file = "zstandard-0.25.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:78228d8a6a1c177a96b94f7e2e8d012c55f9c760761980da16ae7546a15a8e9b"},
{file = "zstandard-0.25.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b6bd67528ee8b5c5f10255735abc21aa106931f0dbaf297c7be0c886353c3d0"},
{file = "zstandard-0.25.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b6d83057e713ff235a12e73916b6d356e3084fd3d14ced499d84240f3eecee0"},
{file = "zstandard-0.25.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9174f4ed06f790a6869b41cba05b43eeb9a35f8993c4422ab853b705e8112bbd"},
{file = "zstandard-0.25.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:25f8f3cd45087d089aef5ba3848cd9efe3ad41163d3400862fb42f81a3a46701"},
{file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3756b3e9da9b83da1796f8809dd57cb024f838b9eeafde28f3cb472012797ac1"},
{file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81dad8d145d8fd981b2962b686b2241d3a1ea07733e76a2f15435dfb7fb60150"},
{file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a5a419712cf88862a45a23def0ae063686db3d324cec7edbe40509d1a79a0aab"},
{file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e7360eae90809efd19b886e59a09dad07da4ca9ba096752e61a2e03c8aca188e"},
{file = "zstandard-0.25.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:75ffc32a569fb049499e63ce68c743155477610532da1eb38e7f24bf7cd29e74"},
{file = "zstandard-0.25.0-cp310-cp310-win32.whl", hash = "sha256:106281ae350e494f4ac8a80470e66d1fe27e497052c8d9c3b95dc4cf1ade81aa"},
{file = "zstandard-0.25.0-cp310-cp310-win_amd64.whl", hash = "sha256:ea9d54cc3d8064260114a0bbf3479fc4a98b21dffc89b3459edd506b69262f6e"},
{file = "zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c"},
{file = "zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f"},
{file = "zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431"},
{file = "zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a"},
{file = "zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc"},
{file = "zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6"},
{file = "zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072"},
{file = "zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277"},
{file = "zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313"},
{file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097"},
{file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778"},
{file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065"},
{file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa"},
{file = "zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7"},
{file = "zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4"},
{file = "zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2"},
{file = "zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137"},
{file = "zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b"},
{file = "zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00"},
{file = "zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64"},
{file = "zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea"},
{file = "zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb"},
{file = "zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a"},
{file = "zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902"},
{file = "zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f"},
{file = "zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b"},
{file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6"},
{file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91"},
{file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708"},
{file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512"},
{file = "zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa"},
{file = "zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd"},
{file = "zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01"},
{file = "zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9"},
{file = "zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94"},
{file = "zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1"},
{file = "zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f"},
{file = "zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea"},
{file = "zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e"},
{file = "zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551"},
{file = "zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a"},
{file = "zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611"},
{file = "zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3"},
{file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b"},
{file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851"},
{file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250"},
{file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98"},
{file = "zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf"},
{file = "zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09"},
{file = "zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5"},
{file = "zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049"},
{file = "zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3"},
{file = "zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f"},
{file = "zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c"},
{file = "zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439"},
{file = "zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043"},
{file = "zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859"},
{file = "zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0"},
{file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7"},
{file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2"},
{file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344"},
{file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c"},
{file = "zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088"},
{file = "zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12"},
{file = "zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2"},
{file = "zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d"},
{file = "zstandard-0.25.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b9af1fe743828123e12b41dd8091eca1074d0c1569cc42e6e1eee98027f2bbd0"},
{file = "zstandard-0.25.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b14abacf83dfb5c25eb4e4a79520de9e7e205f72c9ee7702f91233ae57d33a2"},
{file = "zstandard-0.25.0-cp39-cp39-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:a51ff14f8017338e2f2e5dab738ce1ec3b5a851f23b18c1ae1359b1eecbee6df"},
{file = "zstandard-0.25.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3b870ce5a02d4b22286cf4944c628e0f0881b11b3f14667c1d62185a99e04f53"},
{file = "zstandard-0.25.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:05353cef599a7b0b98baca9b068dd36810c3ef0f42bf282583f438caf6ddcee3"},
{file = "zstandard-0.25.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19796b39075201d51d5f5f790bf849221e58b48a39a5fc74837675d8bafc7362"},
{file = "zstandard-0.25.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53e08b2445a6bc241261fea89d065536f00a581f02535f8122eba42db9375530"},
{file = "zstandard-0.25.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1f3689581a72eaba9131b1d9bdbfe520ccd169999219b41000ede2fca5c1bfdb"},
{file = "zstandard-0.25.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d8c56bb4e6c795fc77d74d8e8b80846e1fb8292fc0b5060cd8131d522974b751"},
{file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:53f94448fe5b10ee75d246497168e5825135d54325458c4bfffbaafabcc0a577"},
{file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c2ba942c94e0691467ab901fc51b6f2085ff48f2eea77b1a48240f011e8247c7"},
{file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:07b527a69c1e1c8b5ab1ab14e2afe0675614a09182213f21a0717b62027b5936"},
{file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:51526324f1b23229001eb3735bc8c94f9c578b1bd9e867a0a646a3b17109f388"},
{file = "zstandard-0.25.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89c4b48479a43f820b749df49cd7ba2dbc2b1b78560ecb5ab52985574fd40b27"},
{file = "zstandard-0.25.0-cp39-cp39-win32.whl", hash = "sha256:1cd5da4d8e8ee0e88be976c294db744773459d51bb32f707a0f166e5ad5c8649"},
{file = "zstandard-0.25.0-cp39-cp39-win_amd64.whl", hash = "sha256:37daddd452c0ffb65da00620afb8e17abd4adaae6ce6310702841760c2c26860"},
{file = "zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b"},
]
[package.extras]
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<4.0"

View file

@ -16,14 +16,6 @@ uvicorn = {extras = ["standard"], version = ">=0.30.0,<0.31.0"}
python-multipart = ">=0.0.21,<0.0.22"
gdal2tiles = "^0.1.9"
[tool.poetry.scripts]
gnss-backend = "gnss.__main__:main"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[dependency-groups]
dev = [
"nuitka (>=2.8.9,<3.0.0)"
]