Compare commits

...

2 commits

Author SHA1 Message Date
 Илья Шалонин
3d65d2b7e8 Крутой дизайн 2025-12-19 14:11:32 +05:00
 Илья Шалонин
ae1229d550 Первая версия дизайна 2025-12-19 13:24:56 +05:00
14 changed files with 1213 additions and 736 deletions

View file

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

4
frontend/public/logo.svg Normal file
View file

@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 708 B

5
frontend/public/menu.svg Normal file
View file

@ -0,0 +1,5 @@
<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>

After

Width:  |  Height:  |  Size: 436 B

View file

@ -1,6 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -1,78 +1,121 @@
:root { :root {
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
background: #050816; color-scheme: dark;
color: #e8ecf4; --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;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
.maplibre .maplibregl-ctrl-attrib {
display: none !important;
}
body, #root { body, #root {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
background: var(--bg);
color: var(--text);
} }
.layout { .layout {
min-height: 100vh; min-height: 100vh;
background: radial-gradient(circle at 20% 20%, rgba(126, 221, 252, 0.08), transparent 25%), height: 100vh;
radial-gradient(circle at 80% 0%, rgba(255, 135, 252, 0.08), transparent 22%), background: var(--bg);
#050816; color: var(--text);
color: #f4f7fb;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
transition: background 0.2s ease, color 0.2s ease;
} }
.topbar { .content {
width: 100%;
max-width: none;
margin: 0;
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; overflow: hidden;
padding: 18px 22px; flex: 1 1 auto;
border-bottom: 1px solid rgba(255, 255, 255, 0.08); min-height: 0;
backdrop-filter: blur(10px);
background: rgba(255, 255, 255, 0.04);
} }
.brand { .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);
display: flex; display: flex;
gap: 12px; flex-direction: column;
align-items: center; gap: 2px;
}
.leaflet-container{
height: 100% !important;
}
.chip.small span {
font-size: 12px;
} }
.pulse { .chip.small strong {
width: 12px; font-size: 16px;
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 { .pill {
@ -81,14 +124,13 @@ body, #root {
align-items: center; align-items: center;
padding: 8px 12px; padding: 8px 12px;
border-radius: 999px; border-radius: 999px;
background: linear-gradient(135deg, rgba(125, 211, 252, 0.18), rgba(252, 165, 255, 0.18)); background: var(--pill-bg);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--pill-border);
} }
.pill-label { .pill-label {
color: #cbd5e1; color: var(--muted);
font-size: 13px; font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.6px; letter-spacing: 0.6px;
} }
@ -96,37 +138,35 @@ body, #root {
font-weight: 700; font-weight: 700;
} }
.content {
display: grid;
grid-template-rows: auto 1fr auto;
gap: 14px;
padding: 14px 18px 18px;
flex: 1;
}
.panel { .panel {
background: rgba(255, 255, 255, 0.03); background: var(--surface);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--border);
border-radius: 14px;
padding: 14px; padding: 14px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35); box-shadow: var(--panel-shadow);
transition: background 0.2s ease, border-color 0.2s ease;
} }
.info-grid { .info-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(3, 1fr);
gap: 12px; gap: 12px;
padding-bottom: 32px;
} }
.card { .card {
padding: 12px; width: 141px;
height: 141px;
border-radius: 12px; border-radius: 12px;
background: rgba(255, 255, 255, 0.04); background: var(--surface-strong);
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid var(--border);
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
} }
.card-label { .card-label {
color: #94a3b8; color: var(--muted);
font-size: 13px; font-size: 13px;
margin-bottom: 4px; margin-bottom: 4px;
} }
@ -134,38 +174,52 @@ body, #root {
.card-value { .card-value {
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
color: var(--text);
}
.map-shell {
flex: 1 1 auto;
min-height: 0;
padding: 0;
} }
.map-panel { .map-panel {
padding: 0; padding: 0;
display: flex;
flex-direction: column;
gap: 0;
flex: 1 1 auto;
min-height: 0;
background: transparent;
border: none;
} }
.map { .map {
height: 70vh; height: 100%;
border-radius: 12px; min-height: 0;
border-radius: 0;
overflow: hidden; overflow: hidden;
border: none;
width: 100%;
} }
.maplibre { .maplibre {
height: 70vh; height: 100%;
border-radius: 12px; min-height: 0;
border-radius: 0;
overflow: hidden; overflow: hidden;
} border: none;
width: 100%;
.map-switch {
display: flex;
gap: 10px;
padding: 12px;
} }
.empty { .empty {
padding: 24px; padding: 24px;
text-align: center; text-align: center;
color: #94a3b8; color: var(--muted);
} }
.leaflet-control-container { .leaflet-control-container {
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.25)); filter: none;
} }
.slice-panel { .slice-panel {
@ -178,6 +232,8 @@ body, #root {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 32px 0;
border-top: 1px solid var(--border);
} }
.panel-title { .panel-title {
@ -186,15 +242,15 @@ body, #root {
} }
.panel-subtitle { .panel-subtitle {
color: #94a3b8; color: var(--muted);
font-size: 13px; font-size: 13px;
} }
.badge { .badge {
padding: 6px 10px; padding: 6px 10px;
border-radius: 999px; border-radius: 999px;
background: rgba(255, 255, 255, 0.08); background: var(--btn-ghost-bg);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--border-strong);
text-transform: uppercase; text-transform: uppercase;
font-size: 12px; font-size: 12px;
letter-spacing: 0.6px; letter-spacing: 0.6px;
@ -211,29 +267,44 @@ body, #root {
} }
.slice-grid { .slice-grid {
display: grid; display: flex;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); flex-direction: column;
gap: 12px; gap: 12px;
padding-bottom: 80px;
} }
.field { .field {
display: flex; display: grid;
flex-direction: column; grid-template-columns: 180px 1fr;
gap: 6px; 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);
} }
.field span { .field span {
color: #94a3b8; color: var(--muted);
font-size: 13px; font-size: 13px;
font-weight: 600;
} }
.field input, .field input,
.field select { .field select,
.tileset-row select {
padding: 10px 12px; padding: 10px 12px;
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid var(--input-border);
background: rgba(255, 255, 255, 0.05); background: var(--input-bg);
color: #f4f7fb; color: var(--text);
width: 100%;
} }
.tileset-row { .tileset-row {
@ -246,9 +317,16 @@ body, #root {
flex: 1; flex: 1;
} }
.tileset-row.stacked {
flex-direction: row;
align-items: center;
}
.file-row { .file-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center;
width: 100%;
} }
.file-row input { .file-row input {
@ -256,14 +334,51 @@ body, #root {
} }
.muted { .muted {
color: #94a3b8; color: var(--muted);
font-size: 12px; font-size: 12px;
} }
.actions { .field .muted {
grid-column: 2;
}
.actions-row {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: flex-end; 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;
}
} }
.btn { .btn {
@ -272,29 +387,119 @@ body, #root {
border: 1px solid transparent; border: 1px solid transparent;
cursor: pointer; cursor: pointer;
font-weight: 700; font-weight: 700;
transition: transform 0.12s ease, border 0.12s ease, box-shadow 0.12s ease; 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;
} }
.btn.primary { .btn.primary {
background: linear-gradient(135deg, #38bdf8, #8b5cf6); background: var(--primary);
color: #0b1020; color: var(--bg);
box-shadow: 0 10px 30px -12px rgba(56, 189, 248, 0.8);
} }
.btn.ghost { .btn.ghost {
background: rgba(255, 255, 255, 0.05); background: var(--btn-ghost-bg);
color: #e8ecf4; color: var(--text);
border-color: rgba(255, 255, 255, 0.08); border-color: var(--btn-ghost-border);
} }
.btn:hover { .bottom-actions {
transform: translateY(-1px); 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;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.content {
grid-template-rows: auto 1fr auto;
}
.map { .map {
height: 60vh; height: 60vh;
} }

View file

@ -1,103 +1,199 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MapContainer, Marker, Polyline, TileLayer, useMap } from "react-leaflet";
import L, { LatLngExpression } from "leaflet";
import "./App.css"; import "./App.css";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
type FixMessage = { import { MapPanel } from "./components/MapPanel";
type: "fix"; import { SettingsPanel } from "./components/SettingsPanel";
lat: number; import { TrackSummary } from "./components/TrackSummary";
lon: number; import { useVectorMap } from "./hooks/useVectorMap";
alt: number | null; import { getInitialTheme } from "./utils/theme";
timestamp: string; import type { AppConfig, IncomingMessage, MapMode, SliceParams, TrackPoint, TrackPositions } from "./types";
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 = const backendBase =
(import.meta.env.VITE_BACKEND_URL as string | undefined) ?? "http://localhost:8000"; (import.meta.env.VITE_BACKEND_URL as string | undefined) ?? "http://localhost:8000";
const markerIcon = new L.Icon({ const initialSliceParams: SliceParams = {
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: "", source: "",
destination: "", destination: "",
tileset: "", tileset: "",
min_zoom: 10, min_zoom: 10,
max_zoom: 16, max_zoom: 16,
tile_format: "png", tile_format: "png",
scheme: "xyz" as "xyz" | "tms", scheme: "xyz",
source_format: "geotiff" as "geotiff" | "mbtiles" | "tms" | "xyz", source_format: "geotiff",
}); };
const [sliceStatus, setSliceStatus] = useState<string>("idle");
const maplibreContainerRef = useRef<HTMLDivElement | null>(null);
const maplibreRef = useRef<any>(null);
const uploadFileBlob = async (file: File) => { 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 [tilesets, setTilesets] = useState<string[]>([]);
const [activeTileset, setActiveTileset] = useState<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 sendFrontLog = useCallback((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(() => {});
}, []);
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]);
useEffect(() => {
fetch(`${backendBase}/config`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
.then((cfg) => {
setConfig(cfg);
return cfg;
})
.catch((err) => {
sendFrontLog(`config fetch failed: ${err}`, "error");
setStatus("config-error");
});
}, [sendFrontLog]);
const refreshTilesets = useCallback(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 || "");
}, [config]);
useEffect(() => {
if (!config) return;
refreshTilesets().catch((err) => sendFrontLog(`tilesets refresh failed: ${err}`, "warn"));
}, [config, refreshTilesets, sendFrontLog]);
const activateTileset = useCallback(
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" },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
setActiveTileset(data.active || name);
sendFrontLog(`tileset activated: ${data.active || name}`, "info");
},
[config, sendFrontLog],
);
const createTileset = useCallback(
async (name: string) => {
const res = await fetch(`${backendBase}/tilesets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
await refreshTilesets();
setSliceParams((p) => ({ ...p, tileset: data.name || name }));
sendFrontLog(`tileset created: ${data.name || name}`, "info");
},
[refreshTilesets, sendFrontLog],
);
useEffect(() => {
if (!config) return;
const base = backendBase.replace(/\/+$/, "");
const wsUrl = `${base}${config.ws_url}`.replace(/^http/, "ws");
setStatus("connecting");
const socket = new WebSocket(wsUrl);
wsRef.current = socket;
socket.onmessage = (event) => {
try {
const msg: IncomingMessage = JSON.parse(event.data);
if (msg.type === "fix") {
const fix = msg;
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);
}
} catch (err) {
sendFrontLog(`ws parse error: ${err}`, "error");
}
};
socket.onclose = () => {
setStatus("disconnected");
sendFrontLog("ws disconnected", "warn");
};
socket.onerror = () => {
setStatus("ws-error");
sendFrontLog("ws error", "error");
};
return () => socket.close();
}, [config, sendFrontLog]);
const lastPoint = useMemo(() => (track.length ? track[track.length - 1] : null), [track]);
const positions: TrackPositions = useMemo(
() => track.map((p) => [p.lat, p.lon]) as TrackPositions,
[track],
);
const tileUrl =
config &&
`${backendBase.replace(/\/+$/, "")}${
sliceParams.scheme === "tms"
? config.tiles_url_tms || "/tiles/tms/{z}/{x}/{y}.png"
: config.tiles_url || "/tiles/{z}/{x}/{y}.png"
}`;
const vectorTileUrl =
config && `${backendBase.replace(/\/+$/, "")}${config.vector_tiles_url || "/tiles/vector/{z}/{x}/{y}.pbf"}`;
const vectorMetaUrl =
config && `${backendBase.replace(/\/+$/, "")}${config.vector_metadata_url || "/tiles/vector/metadata"}`;
const activateVectorMbtiles = useCallback(
async (path: string) => {
const res = await fetch(`${backendBase}/tiles/vector/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
sendFrontLog(`vector mbtiles activated: ${path}`, "info");
setMapMode("vector");
return data;
},
[sendFrontLog],
);
const uploadFileBlob = useCallback(
async (file: File) => {
setSliceStatus("uploading"); setSliceStatus("uploading");
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
@ -131,9 +227,11 @@ export default function App() {
setSliceStatus("error"); setSliceStatus("error");
sendFrontLog(`upload error: ${err}`, "error"); sendFrontLog(`upload error: ${err}`, "error");
}); });
}; },
[activateVectorMbtiles, sendFrontLog],
);
const triggerFileSelect = async () => { const triggerFileSelect = useCallback(async () => {
try { try {
if ((window as any).__TAURI__) { if ((window as any).__TAURI__) {
const { open } = await import("@tauri-apps/plugin-dialog"); const { open } = await import("@tauri-apps/plugin-dialog");
@ -151,405 +249,20 @@ export default function App() {
} }
fileInputRef.current?.click(); fileInputRef.current?.click();
} catch (err) { } catch (err) {
console.error("file dialog error", err); sendFrontLog(`file dialog error: ${err}`, "warn");
} }
}; }, [sendFrontLog]);
const sendFrontLog = (message: string, level: string = "info") => { const handleFileSelected = useCallback(
fetch(`${backendBase}/logs`, { async (file: File | null | undefined) => {
method: "POST", if (!file) return;
headers: { "Content-Type": "application/json" }, setSelectedFileName(file.name);
body: JSON.stringify({ message, level, source: "frontend" }), await uploadFileBlob(file);
}).catch((err) => console.error("log push failed", err)); },
}; [uploadFileBlob],
useEffect(() => {
fetch(`${backendBase}/config`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
.then((cfg) => {
setConfig(cfg);
return cfg;
})
.catch((err) => {
console.error("Config error", err);
sendFrontLog(`config fetch failed: ${err}`, "error");
setStatus("config-error");
});
}, []);
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 || "");
};
useEffect(() => {
if (!config) return;
refreshTilesets().catch((err) => sendFrontLog(`tilesets refresh failed: ${err}`, "warn"));
}, [config]);
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" },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
setActiveTileset(data.active || name);
sendFrontLog(`tileset activated: ${data.active || name}`, "info");
};
const createTileset = async (name: string) => {
const res = await fetch(`${backendBase}/tilesets`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
await refreshTilesets();
setSliceParams((p) => ({ ...p, tileset: data.name || name }));
sendFrontLog(`tileset created: ${data.name || name}`, "info");
};
useEffect(() => {
if (!config) return;
const base = backendBase.replace(/\/+$/, "");
const wsUrl = `${base}${config.ws_url}`.replace(/^http/, "ws");
setStatus("connecting");
const socket = new WebSocket(wsUrl);
wsRef.current = socket;
socket.onmessage = (event) => {
try {
const msg: IncomingMessage = JSON.parse(event.data);
if (msg.type === "fix") {
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 as StatusMessage).status);
}
} catch (err) {
console.error("WS parse error", err);
sendFrontLog(`ws parse error: ${err}`, "error");
}
};
socket.onclose = () => {
setStatus("disconnected");
sendFrontLog("ws disconnected", "warn");
};
socket.onerror = () => {
setStatus("ws-error");
sendFrontLog("ws error", "error");
};
return () => socket.close();
}, [config]);
const lastPoint = track.length ? track[track.length - 1] : null;
const positions = useMemo(
() => track.map((p) => [p.lat, p.lon]) as LatLngExpression[],
[track],
); );
const tileUrl = const handleSliceSubmit = useCallback(() => {
config &&
`${backendBase.replace(/\/+$/, "")}${
sliceParams.scheme === "tms"
? config.tiles_url_tms || "/tiles/tms/{z}/{x}/{y}.png"
: config.tiles_url || "/tiles/{z}/{x}/{y}.png"
}`;
const vectorTileUrl =
config && `${backendBase.replace(/\/+$/, "")}${config.vector_tiles_url || "/tiles/vector/{z}/{x}/{y}.pbf"}`;
const vectorMetaUrl =
config && `${backendBase.replace(/\/+$/, "")}${config.vector_metadata_url || "/tiles/vector/metadata"}`;
const activateVectorMbtiles = async (path: string) => {
const res = await fetch(`${backendBase}/tiles/vector/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.detail || `status ${res.status}`);
sendFrontLog(`vector mbtiles activated: ${path}`, "info");
setMapMode("vector");
};
useEffect(() => {
if (mapMode !== "vector") return;
if (!maplibreContainerRef.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 (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 },
},
);
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"); setSliceStatus("running");
fetch(`${backendBase}/tiles/slice`, { fetch(`${backendBase}/tiles/slice`, {
method: "POST", method: "POST",
@ -577,156 +290,76 @@ export default function App() {
setSliceStatus("error"); setSliceStatus("error");
sendFrontLog(`tile slice error: ${err}`, "error"); sendFrontLog(`tile slice error: ${err}`, "error");
}); });
}} }, [activateTileset, refreshTilesets, sendFrontLog, sliceParams]);
>
<label className="field"> const handleSliceReset = useCallback(() => {
<span>Источник (GeoTIFF/MBTiles)</span> setSliceParams(initialSliceParams);
<div className="file-row"> setSelectedFileName("");
<input }, []);
type="text"
placeholder="/path/to/input.tif" const { mapContainerRef } = useVectorMap({
value={sliceParams.source} mapMode,
onChange={(e) => setSliceParams((p) => ({ ...p, source: e.target.value }))} vectorMetaUrl,
required vectorTileUrl,
/> positions,
<button type="button" className="btn ghost" onClick={triggerFileSelect}> lastPoint,
Выбрать onLog: sendFrontLog,
</button> });
<input
type="file" return (
accept=".tif,.tiff,.mbtiles,.jpg,.jpeg,.png" <div className="layout">
ref={fileInputRef} <div className="status-strip">
style={{ display: "none" }} <TrackSummary lastPoint={lastPoint} pointsCount={track.length} compact />
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
setSelectedFileName(file.name);
await uploadFileBlob(file);
}}
/>
</div> </div>
{selectedFileName && <small className="muted">Файл: {selectedFileName}</small>}
</label> <main className="content map-shell">
<label className="field"> <section className="panel map-panel flat">
<span>Схема тайлов</span> <MapPanel
<select mapMode={mapMode}
value={sliceParams.scheme} tileUrl={tileUrl}
onChange={(e) => setSliceParams((p) => ({ ...p, scheme: e.target.value as "xyz" | "tms" }))} positions={positions}
> lastPoint={lastPoint}
<option value="xyz">XYZ (Y сверху, Leaflet)</option> sliceScheme={sliceParams.scheme}
<option value="tms">TMS (Y снизу)</option> vectorContainerRef={mapContainerRef}
</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 }))}
/> />
<button
type="button"
className="btn ghost"
onClick={() => {
const name = sliceParams.tileset.trim();
if (!name) return;
createTileset(name).catch((err) => sendFrontLog(`tileset create failed: ${err}`, "error"));
}}
>
Создать
</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) => 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> </section>
</main> </main>
<div className="bottom-actions">
<button
type="button"
className="btn ghost block"
onClick={() => setMapMode((m) => (m === "raster" ? "vector" : "raster"))}
>
Режим карты: {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"))}
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}
/>
</div> </div>
); );
} }

View file

@ -0,0 +1,60 @@
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: 5, 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

@ -0,0 +1,278 @@
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;
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,
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">Тема</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>
<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

@ -0,0 +1,25 @@
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

@ -0,0 +1,202 @@
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 };
};

54
frontend/src/types.ts Normal file
View file

@ -0,0 +1,54 @@
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;
tilesets_url?: string;
tilesets_activate_url?: string;
ws_url: string;
gnss_source: 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

@ -0,0 +1,10 @@
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

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