Первая версия дизайна
This commit is contained in:
parent
aa36dea08e
commit
ae1229d550
15 changed files with 1170 additions and 690 deletions
60
frontend/src/components/MapPanel.tsx
Normal file
60
frontend/src/components/MapPanel.tsx
Normal 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} />;
|
||||
};
|
||||
293
frontend/src/components/SettingsPanel.tsx
Normal file
293
frontend/src/components/SettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
import type { MutableRefObject, RefObject } from "react";
|
||||
import { FormEvent } from "react";
|
||||
import type { AppConfig, MapMode, SliceParams } from "../types";
|
||||
|
||||
type SettingsPanelProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
config: AppConfig | null;
|
||||
status: string;
|
||||
theme: "dark" | "light";
|
||||
onToggleTheme: () => void;
|
||||
mapMode: MapMode;
|
||||
onMapModeChange: (mode: MapMode) => 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,
|
||||
mapMode,
|
||||
onMapModeChange,
|
||||
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>
|
||||
<button type="button" className="btn primary small" onClick={onClose}>
|
||||
Закрыть
|
||||
</button>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section className="panel info">
|
||||
<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>
|
||||
<label className="field compact">
|
||||
<span>Режим карты</span>
|
||||
<select value={mapMode} onChange={(e) => onMapModeChange(e.target.value as MapMode)}>
|
||||
<option value="raster">Растровые</option>
|
||||
<option value="vector">Векторные</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<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>
|
||||
</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={(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>
|
||||
|
||||
<div className="field actions">
|
||||
<span>Действия</span>
|
||||
<div className="actions-row">
|
||||
<button type="submit" className="btn primary">
|
||||
Запустить
|
||||
</button>
|
||||
<button type="button" className="btn ghost" onClick={onSliceReset}>
|
||||
Сброс
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
21
frontend/src/components/Topbar.tsx
Normal file
21
frontend/src/components/Topbar.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
type TopbarProps = {
|
||||
status: string;
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
export const Topbar = ({ status, onOpenSettings }: TopbarProps) => (
|
||||
<header className="topbar">
|
||||
<div className="brand">
|
||||
<div className={`pulse ${status === "ok" ? "ok" : ""}`} />
|
||||
<div>
|
||||
<div className="title">GoAgro</div>
|
||||
<div className="subtitle">Тестовая версия</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="top-actions">
|
||||
<button type="button" className="btn primary small" onClick={onOpenSettings}>
|
||||
Настройки
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
20
frontend/src/components/TrackSummary.tsx
Normal file
20
frontend/src/components/TrackSummary.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { formatTime } from "../utils/time";
|
||||
import type { TrackPoint } from "../types";
|
||||
|
||||
type TrackSummaryProps = {
|
||||
lastPoint: TrackPoint | null;
|
||||
pointsCount: number;
|
||||
};
|
||||
|
||||
export const TrackSummary = ({ lastPoint, pointsCount }: TrackSummaryProps) => (
|
||||
<div className="mini-info">
|
||||
<div className="chip">
|
||||
<span>Последняя фиксация</span>
|
||||
<strong>{formatTime(lastPoint?.timestamp)}</strong>
|
||||
</div>
|
||||
<div className="chip">
|
||||
<span>Точек трека</span>
|
||||
<strong>{pointsCount}</strong>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue