feat(proj): init
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import {
|
||||
API_ORIGIN,
|
||||
isPersonal,
|
||||
persistAccess,
|
||||
readAccess,
|
||||
rewriteServiceUrl,
|
||||
serviceUrl,
|
||||
styleUrl,
|
||||
stylesListUrl,
|
||||
type Access
|
||||
} from "./access";
|
||||
import { fetchJson, type SourceItem, type StyleItem, type SyncStatus } from "./api";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
Pending: "ожидание",
|
||||
Downloading: "скачивание PBF",
|
||||
PreparingSources: "данные Planetiler",
|
||||
Building: "сборка тайлов",
|
||||
Ready: "готово",
|
||||
Failed: "ошибка"
|
||||
};
|
||||
|
||||
function formatStatus(value?: string): string {
|
||||
return STATUS_LABELS[value ?? ""] ?? value ?? "—";
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
return value ? new Date(value).toLocaleString("ru-RU") : "нет";
|
||||
}
|
||||
|
||||
function parseNumber(value: string): number | null {
|
||||
const parsed = Number(value.trim().replace(",", "."));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function cameraFromHash(): { zoom: number; lat: number; lon: number } | null {
|
||||
const match = location.hash.match(/^#([\d.]+)\/(-?[\d.]+)\/(-?[\d.]+)/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const zoom = Number(match[1]);
|
||||
const lat = Number(match[2]);
|
||||
const lon = Number(match[3]);
|
||||
if (![zoom, lat, lon].every(Number.isFinite)) {
|
||||
return null;
|
||||
}
|
||||
return { zoom, lat, lon };
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const mapEl = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
const styleKeyRef = useRef<string | null>(null);
|
||||
const suppressGoto = useRef(false);
|
||||
const latRef = useRef<HTMLInputElement | null>(null);
|
||||
const lonRef = useRef<HTMLInputElement | null>(null);
|
||||
const zoomRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [sources, setSources] = useState<SourceItem[]>([]);
|
||||
const [styles, setStyles] = useState<StyleItem[]>([]);
|
||||
const [sync, setSync] = useState<SyncStatus | null>(null);
|
||||
const [sourceId, setSourceId] = useState("");
|
||||
const [styleName, setStyleName] = useState("osm-bright");
|
||||
const [coords, setCoords] = useState("—");
|
||||
const [gotoError, setGotoError] = useState("");
|
||||
const [banner, setBanner] = useState("");
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [access, setAccess] = useState<Access>(() => {
|
||||
const stored = readAccess();
|
||||
persistAccess(stored);
|
||||
return stored;
|
||||
});
|
||||
const [slugDraft, setSlugDraft] = useState(access.slug);
|
||||
const [tokenDraft, setTokenDraft] = useState(access.token);
|
||||
const accessRef = useRef(access);
|
||||
accessRef.current = access;
|
||||
const personal = isPersonal(access);
|
||||
|
||||
const source = sources.find((item) => item.id === sourceId) ?? sources[0];
|
||||
|
||||
const updateHud = useCallback((map: MapLibreMap) => {
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
setCoords(`${center.lat.toFixed(5)}° · ${center.lng.toFixed(5)}° z ${zoom.toFixed(2)}`);
|
||||
}, []);
|
||||
|
||||
const syncGotoFromMap = useCallback((map: MapLibreMap) => {
|
||||
if (suppressGoto.current) {
|
||||
return;
|
||||
}
|
||||
const active = document.activeElement;
|
||||
if (active === latRef.current || active === lonRef.current || active === zoomRef.current) {
|
||||
return;
|
||||
}
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
if (zoom < 2 && Math.abs(center.lat) < 1 && Math.abs(center.lng) < 1) {
|
||||
return;
|
||||
}
|
||||
if (latRef.current) {
|
||||
latRef.current.value = center.lat.toFixed(5);
|
||||
}
|
||||
if (lonRef.current) {
|
||||
lonRef.current.value = center.lng.toFixed(5);
|
||||
}
|
||||
if (zoomRef.current) {
|
||||
zoomRef.current.value = zoom.toFixed(2);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const flyTo = (lat: number, lon: number, zoom: number) => {
|
||||
const map = mapRef.current;
|
||||
if (!map) {
|
||||
return;
|
||||
}
|
||||
setGotoError("");
|
||||
suppressGoto.current = true;
|
||||
map.flyTo({ center: [lon, lat], zoom });
|
||||
map.once("moveend", () => {
|
||||
suppressGoto.current = false;
|
||||
updateHud(map);
|
||||
syncGotoFromMap(map);
|
||||
});
|
||||
};
|
||||
|
||||
const loadStyle = useCallback(async () => {
|
||||
if (!personal) {
|
||||
setBanner("Публичная карта закрыта. Вставь slug и токен из кабинета.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!source || !styleName || !mapEl.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const styleKey = `${personal ? access.slug : "public"}|${source.id}|${styleName}|${source.status}|${source.builtAt ?? ""}`;
|
||||
if (mapRef.current && styleKeyRef.current === styleKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = await fetchJson<StyleSpecification>(styleUrl(access, styleName, source.id));
|
||||
const hashed = cameraFromHash();
|
||||
const center = hashed ? [hashed.lon, hashed.lat] : (source.center ?? [37.6173, 55.7558]);
|
||||
const zoom = hashed ? hashed.zoom : 6;
|
||||
|
||||
if (!mapRef.current) {
|
||||
const map = new maplibregl.Map({
|
||||
container: mapEl.current,
|
||||
style,
|
||||
center: center as [number, number],
|
||||
zoom,
|
||||
maxZoom: 18,
|
||||
hash: true,
|
||||
transformRequest: (url) => ({ url: rewriteServiceUrl(url, accessRef.current) })
|
||||
});
|
||||
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
||||
map.addControl(new maplibregl.ScaleControl(), "bottom-right");
|
||||
map.on("move", () => updateHud(map));
|
||||
map.on("moveend", () => syncGotoFromMap(map));
|
||||
map.on("load", () => {
|
||||
map.resize();
|
||||
updateHud(map);
|
||||
syncGotoFromMap(map);
|
||||
});
|
||||
map.on("error", (event) => {
|
||||
const message = event.error?.message;
|
||||
if (message) {
|
||||
setBanner(message);
|
||||
}
|
||||
});
|
||||
mapRef.current = map;
|
||||
} else {
|
||||
mapRef.current.setStyle(style, { diff: false });
|
||||
}
|
||||
|
||||
styleKeyRef.current = styleKey;
|
||||
const ready = source.status === "Ready";
|
||||
setBanner(
|
||||
ready
|
||||
? ""
|
||||
: `Тайлы источника «${source.name}» ещё не готовы (${formatStatus(source.status)}). Первый прогон может занять часы.`
|
||||
);
|
||||
}, [access, personal, source, styleName, syncGotoFromMap, updateHud]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [sourceList, syncStatus] = await Promise.all([
|
||||
fetchJson<SourceItem[]>(serviceUrl("/api/v1/sources")),
|
||||
fetchJson<SyncStatus>(serviceUrl("/api/v1/sync/status"))
|
||||
]);
|
||||
const styleList = personal
|
||||
? await fetchJson<StyleItem[]>(stylesListUrl(access))
|
||||
: [];
|
||||
setSources(sourceList);
|
||||
setStyles(styleList);
|
||||
setSync(syncStatus);
|
||||
setSourceId((current) =>
|
||||
sourceList.some((item) => item.id === current) ? current : (sourceList[0]?.id ?? "")
|
||||
);
|
||||
setStyleName((current) =>
|
||||
styleList.some((item) => item.name === current)
|
||||
? current
|
||||
: styleList.some((item) => item.name === "osm-bright")
|
||||
? "osm-bright"
|
||||
: (styleList[0]?.name ?? "")
|
||||
);
|
||||
}, [access, personal]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh().catch((err: unknown) => setBanner(err instanceof Error ? err.message : "Ошибка загрузки"));
|
||||
const timer = window.setInterval(() => {
|
||||
void refresh().catch(() => undefined);
|
||||
}, 15000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStyle().catch((err: unknown) => setBanner(err instanceof Error ? err.message : "Ошибка стиля"));
|
||||
}, [loadStyle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mapRef.current?.remove();
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onGoto = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
const map = mapRef.current;
|
||||
if (!map) {
|
||||
setGotoError("Карта ещё не загружена.");
|
||||
return;
|
||||
}
|
||||
const lat = parseNumber(latRef.current?.value ?? "");
|
||||
const lon = parseNumber(lonRef.current?.value ?? "");
|
||||
const zoom = parseNumber(zoomRef.current?.value ?? "");
|
||||
const maxZoom = map.getMaxZoom();
|
||||
if (lat === null || lat < -90 || lat > 90) {
|
||||
setGotoError("Широта: число от -90 до 90.");
|
||||
return;
|
||||
}
|
||||
if (lon === null || lon < -180 || lon > 180) {
|
||||
setGotoError("Долгота: число от -180 до 180.");
|
||||
return;
|
||||
}
|
||||
if (zoom === null || zoom < 0 || zoom > maxZoom) {
|
||||
setGotoError(`Зум: число от 0 до ${maxZoom}.`);
|
||||
return;
|
||||
}
|
||||
flyTo(lat, lon, zoom);
|
||||
};
|
||||
|
||||
const fitBounds = () => {
|
||||
const bounds = source?.bounds;
|
||||
const map = mapRef.current;
|
||||
if (!map || !bounds) {
|
||||
setGotoError("У источника ещё нет bounds.");
|
||||
return;
|
||||
}
|
||||
map.fitBounds(
|
||||
[
|
||||
[bounds.minLon, bounds.minLat],
|
||||
[bounds.maxLon, bounds.maxLat]
|
||||
],
|
||||
{ padding: 48, maxZoom: 10 }
|
||||
);
|
||||
};
|
||||
|
||||
const startSync = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const url = source
|
||||
? serviceUrl(`/api/v1/sync/${encodeURIComponent(source.id)}`)
|
||||
: serviceUrl("/api/v1/sync");
|
||||
await fetchJson(url, { method: "POST" });
|
||||
await refresh();
|
||||
} catch (err: unknown) {
|
||||
setBanner(err instanceof Error ? err.message : "Не удалось запустить синхронизацию");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const status = source?.status;
|
||||
const pillClass =
|
||||
status === "Ready" ? "pill pill--ok" : status === "Failed" ? "pill pill--fail" : "pill pill--work";
|
||||
const workerClass = [
|
||||
"hud",
|
||||
"hud--status",
|
||||
sync?.isRunning ? "is-running" : "",
|
||||
!sync?.isRunning && status === "Ready" ? "is-ready" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="map" ref={mapEl} role="application" aria-label="Карта" />
|
||||
|
||||
<header className="hud hud--top">
|
||||
<div className="brand">
|
||||
<span className="brand__mark" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22">
|
||||
<rect x="2" y="2" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.95" />
|
||||
<rect x="13" y="2" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.55" />
|
||||
<rect x="2" y="13" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.55" />
|
||||
<rect x="13" y="13" width="9" height="9" rx="1.6" fill="currentColor" opacity="0.28" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="brand__text">
|
||||
<strong>Tile Server</strong>
|
||||
<span>{personal ? `личный ${new URL(API_ORIGIN).host}` : new URL(API_ORIGIN).host}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<label className="field field--inline">
|
||||
<span>Slug</span>
|
||||
<input
|
||||
value={slugDraft}
|
||||
onChange={(e) => setSlugDraft(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="login"
|
||||
/>
|
||||
</label>
|
||||
<label className="field field--inline field--token">
|
||||
<span>Токен</span>
|
||||
<input
|
||||
type="password"
|
||||
value={tokenDraft}
|
||||
onChange={(e) => setTokenDraft(e.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="ts_…"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--accent"
|
||||
onClick={() => {
|
||||
const next = { slug: slugDraft.trim(), token: tokenDraft.trim() };
|
||||
persistAccess(next);
|
||||
styleKeyRef.current = null;
|
||||
setAccess(next);
|
||||
}}
|
||||
>
|
||||
Подключить
|
||||
</button>
|
||||
{personal ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setSlugDraft("");
|
||||
setTokenDraft("");
|
||||
persistAccess({ slug: "", token: "" });
|
||||
styleKeyRef.current = null;
|
||||
setAccess({ slug: "", token: "" });
|
||||
}}
|
||||
>
|
||||
Сброс
|
||||
</button>
|
||||
) : null}
|
||||
<span className={personal ? "pill pill--ok" : "pill"}>{personal ? "личный" : "публичный"}</span>
|
||||
<label className="field field--inline">
|
||||
<span>Источник</span>
|
||||
<select
|
||||
value={sourceId}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setSourceId(next);
|
||||
const item = sources.find((s) => s.id === next);
|
||||
if (mapRef.current && item?.center) {
|
||||
flyTo(item.center[1], item.center[0], Math.max(mapRef.current.getZoom(), 6));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{sources.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field field--inline">
|
||||
<span>Стиль</span>
|
||||
<select value={styleName} onChange={(e) => setStyleName(e.target.value)}>
|
||||
{styles.map((item) => (
|
||||
<option key={item.name} value={item.name}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<span className={pillClass}>{formatStatus(status)}</span>
|
||||
<button type="button" className="btn btn--accent" disabled={syncing} onClick={() => void startSync()}>
|
||||
Синхронизировать
|
||||
</button>
|
||||
<a className="link" href={serviceUrl("/swagger")} target="_blank" rel="noreferrer">
|
||||
OpenAPI
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form className="hud hud--goto" onSubmit={onGoto}>
|
||||
<div className="goto__head">
|
||||
<div>
|
||||
<h1>Камера</h1>
|
||||
<p className="coords">{coords}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="presets" role="group" aria-label="Быстрый переход">
|
||||
<button type="button" className="preset" onClick={() => flyTo(55.7558, 37.6173, 14)}>
|
||||
Москва
|
||||
</button>
|
||||
<button type="button" className="preset" onClick={() => flyTo(54.2255, 38.4692, 6)}>
|
||||
ЦФО
|
||||
</button>
|
||||
<button type="button" className="preset preset--ghost" onClick={fitBounds}>
|
||||
По выгрузке
|
||||
</button>
|
||||
</div>
|
||||
<div className="goto__row">
|
||||
<label className="field">
|
||||
<span>Широта</span>
|
||||
<input ref={latRef} inputMode="decimal" autoComplete="off" spellCheck={false} placeholder="55.7558" required />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Долгота</span>
|
||||
<input ref={lonRef} inputMode="decimal" autoComplete="off" spellCheck={false} placeholder="37.6173" required />
|
||||
</label>
|
||||
<label className="field field--zoom">
|
||||
<span>Зум</span>
|
||||
<input ref={zoomRef} inputMode="decimal" autoComplete="off" spellCheck={false} placeholder="14" required />
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" className="btn btn--accent btn--block">
|
||||
Перейти
|
||||
</button>
|
||||
{gotoError ? <p className="error">{gotoError}</p> : null}
|
||||
</form>
|
||||
|
||||
<aside className={workerClass}>
|
||||
<details>
|
||||
<summary>
|
||||
<span className="worker__pulse" aria-hidden="true" />
|
||||
Воркер
|
||||
</summary>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Задача</dt>
|
||||
<dd>{sync?.isRunning ? "идёт синхронизация" : "ожидание"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Источник</dt>
|
||||
<dd>{formatStatus(status)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Скачан</dt>
|
||||
<dd>{formatDate(source?.downloadedAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Собран</dt>
|
||||
<dd>{formatDate(source?.builtAt)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Следующий запуск</dt>
|
||||
<dd>{formatDate(sync?.nextScheduledAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{sync?.lastError ? <p className="error">{sync.lastError}</p> : null}
|
||||
</details>
|
||||
</aside>
|
||||
|
||||
{banner ? <div className="banner">{banner}</div> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
const SLUG_KEY = "ts.demo.slug";
|
||||
const TOKEN_KEY = "ts.demo.token";
|
||||
|
||||
export const API_ORIGIN = (import.meta.env.VITE_API_ORIGIN ?? "https://tile-server.ru").replace(/\/$/, "");
|
||||
|
||||
export function serviceUrl(path: string): string {
|
||||
if (/^https?:\/\//i.test(path)) {
|
||||
return path;
|
||||
}
|
||||
return `${API_ORIGIN}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
export type Access = {
|
||||
slug: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
export function isPersonal(access: Access): boolean {
|
||||
return access.slug.length > 0 && access.token.length > 0;
|
||||
}
|
||||
|
||||
export function readAccess(): Access {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const slug = (params.get("slug") ?? sessionStorage.getItem(SLUG_KEY) ?? "").trim();
|
||||
const token = (params.get("token") ?? sessionStorage.getItem(TOKEN_KEY) ?? "").trim();
|
||||
return { slug, token };
|
||||
}
|
||||
|
||||
export function persistAccess(access: Access): void {
|
||||
if (access.slug) {
|
||||
sessionStorage.setItem(SLUG_KEY, access.slug);
|
||||
} else {
|
||||
sessionStorage.removeItem(SLUG_KEY);
|
||||
}
|
||||
|
||||
if (access.token) {
|
||||
sessionStorage.setItem(TOKEN_KEY, access.token);
|
||||
} else {
|
||||
sessionStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has("slug") || url.searchParams.has("token")) {
|
||||
url.searchParams.delete("slug");
|
||||
url.searchParams.delete("token");
|
||||
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function stylesListUrl(access: Access): string {
|
||||
if (isPersonal(access)) {
|
||||
return serviceUrl(`/u/${encodeURIComponent(access.slug)}/styles?token=${encodeURIComponent(access.token)}`);
|
||||
}
|
||||
return serviceUrl("/api/v1/styles");
|
||||
}
|
||||
|
||||
export function styleUrl(access: Access, name: string, sourceId: string): string {
|
||||
if (isPersonal(access)) {
|
||||
return serviceUrl(
|
||||
`/u/${encodeURIComponent(access.slug)}/styles/${encodeURIComponent(name)}?source=${encodeURIComponent(sourceId)}&token=${encodeURIComponent(access.token)}`
|
||||
);
|
||||
}
|
||||
return serviceUrl(`/api/v1/styles/${encodeURIComponent(name)}?source=${encodeURIComponent(sourceId)}`);
|
||||
}
|
||||
|
||||
export function rewriteServiceUrl(url: string, access: Access): string {
|
||||
const resolved = new URL(url, `${API_ORIGIN}/`);
|
||||
const isService = resolved.pathname.startsWith("/u/") || resolved.pathname.startsWith("/api/");
|
||||
if (!isService) {
|
||||
return resolved.href;
|
||||
}
|
||||
|
||||
if (isPersonal(access) && resolved.pathname.startsWith("/u/") && !resolved.searchParams.has("token")) {
|
||||
resolved.searchParams.set("token", access.token);
|
||||
}
|
||||
|
||||
return `${API_ORIGIN}${resolved.pathname}${resolved.search}`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export type TilesetBounds = {
|
||||
minLon: number;
|
||||
minLat: number;
|
||||
maxLon: number;
|
||||
maxLat: number;
|
||||
};
|
||||
|
||||
export type SourceItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
center: number[];
|
||||
status: string;
|
||||
downloadedAt?: string | null;
|
||||
builtAt?: string | null;
|
||||
lastError?: string | null;
|
||||
bounds?: TilesetBounds | null;
|
||||
};
|
||||
|
||||
export type StyleItem = {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type SyncStatus = {
|
||||
isRunning: boolean;
|
||||
lastStartedAt?: string | null;
|
||||
lastFinishedAt?: string | null;
|
||||
nextScheduledAt?: string | null;
|
||||
lastError?: string | null;
|
||||
};
|
||||
|
||||
export async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(body || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
:root {
|
||||
--bg: #0b0f14;
|
||||
--panel: rgba(14, 18, 26, 0.78);
|
||||
--panel-strong: rgba(10, 13, 18, 0.92);
|
||||
--text: #f4f1ea;
|
||||
--muted: #9aa6b4;
|
||||
--line: rgba(244, 241, 234, 0.1);
|
||||
--line-strong: rgba(244, 241, 234, 0.16);
|
||||
--accent: #e8923d;
|
||||
--accent-press: #f3a85a;
|
||||
--accent-ink: #1a1208;
|
||||
--danger: #ff8a7a;
|
||||
--ok: #7dcea0;
|
||||
--warn: #f5c16c;
|
||||
--radius: 16px;
|
||||
--radius-sm: 10px;
|
||||
--font: "Segoe UI Variable Display", "Segoe UI", "SF Pro Display", ui-sans-serif, system-ui, sans-serif;
|
||||
--hud-z: 3;
|
||||
--shadow: 0 18px 50px rgba(0, 0, 0, 0.38), 0 1px 0 rgba(255, 255, 255, 0.04) inset;
|
||||
--space: 12px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html, body, #root, #map {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.hud {
|
||||
position: absolute;
|
||||
z-index: var(--hud-z);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.045), transparent 42%),
|
||||
var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(22px) saturate(1.35);
|
||||
-webkit-backdrop-filter: blur(22px) saturate(1.35);
|
||||
}
|
||||
|
||||
.hud--top {
|
||||
top: var(--space);
|
||||
left: var(--space);
|
||||
max-width: min(70rem, calc(100vw - 4.5rem));
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.85rem 1rem;
|
||||
padding: 0.7rem 0.8rem 0.75rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
padding: 0 0.2rem 0.15rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.brand__mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 11px;
|
||||
color: var(--accent);
|
||||
background: rgba(232, 146, 61, 0.12);
|
||||
border: 1px solid rgba(232, 146, 61, 0.28);
|
||||
}
|
||||
|
||||
.brand__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
.brand strong {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.brand__text span {
|
||||
color: var(--muted);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.55rem 0.65rem;
|
||||
padding-left: 0.85rem;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.28rem;
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.field--inline { min-width: 10.5rem; }
|
||||
|
||||
.field--token { min-width: 14rem; }
|
||||
|
||||
.field--token input {
|
||||
font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.field--zoom { min-width: 0; }
|
||||
|
||||
select, button, input {
|
||||
font: inherit;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--line-strong);
|
||||
background: rgba(8, 11, 16, 0.72);
|
||||
color: var(--text);
|
||||
padding: 0.48rem 0.7rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
select, input {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, var(--muted) 50%),
|
||||
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 16px) calc(50% - 3px),
|
||||
calc(100% - 11px) calc(50% - 3px);
|
||||
background-size: 5px 5px, 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 1.7rem;
|
||||
}
|
||||
|
||||
.btn,
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.btn--accent,
|
||||
#sync-button,
|
||||
.hud--goto button[type="submit"] {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
box-shadow: 0 8px 20px rgba(232, 146, 61, 0.22);
|
||||
}
|
||||
|
||||
.btn--accent:hover,
|
||||
#sync-button:hover,
|
||||
.hud--goto button[type="submit"]:hover {
|
||||
background: var(--accent-press);
|
||||
}
|
||||
|
||||
.btn--block {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button:disabled { opacity: 0.55; cursor: wait; }
|
||||
|
||||
.preset,
|
||||
.hud--status summary,
|
||||
.preset--ghost {
|
||||
background: rgba(8, 11, 16, 0.55);
|
||||
color: var(--text);
|
||||
font-weight: 550;
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.preset:hover,
|
||||
.preset--ghost:hover,
|
||||
.hud--status summary:hover {
|
||||
border-color: rgba(232, 146, 61, 0.45);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
align-self: center;
|
||||
padding: 0.45rem 0.35rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover { color: var(--accent); }
|
||||
|
||||
.pill {
|
||||
align-self: end;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.42rem 0.75rem 0.42rem 0.65rem;
|
||||
min-height: 2.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(8, 11, 16, 0.72);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pill::before {
|
||||
content: "";
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
box-shadow: 0 0 0 4px rgba(154, 166, 180, 0.12);
|
||||
}
|
||||
|
||||
.pill--ok { color: var(--ok); border-color: rgba(125, 206, 160, 0.32); }
|
||||
.pill--ok::before { background: var(--ok); box-shadow: 0 0 0 4px rgba(125, 206, 160, 0.16); }
|
||||
.pill--work { color: var(--warn); border-color: rgba(245, 193, 108, 0.32); }
|
||||
.pill--work::before { background: var(--warn); box-shadow: 0 0 0 4px rgba(245, 193, 108, 0.16); }
|
||||
.pill--fail { color: var(--danger); border-color: rgba(255, 138, 122, 0.4); }
|
||||
.pill--fail::before { background: var(--danger); box-shadow: 0 0 0 4px rgba(255, 138, 122, 0.16); }
|
||||
|
||||
.hud--goto {
|
||||
left: var(--space);
|
||||
bottom: 1.75rem;
|
||||
width: min(22.75rem, calc(100vw - 24px));
|
||||
padding: 0.95rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.goto__head h1 {
|
||||
margin: 0;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 650;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.coords {
|
||||
margin: 0.28rem 0 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.98rem;
|
||||
font-weight: 560;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.coords__sep {
|
||||
color: var(--muted);
|
||||
margin: 0 0.35rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.presets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.presets button {
|
||||
min-height: 2.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.goto__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 4.8rem;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(8, 11, 16, 0.45);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.goto__row input {
|
||||
min-height: 2.5rem;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
padding-inline: 0.35rem;
|
||||
}
|
||||
|
||||
.goto__row input:focus {
|
||||
border-color: rgba(232, 146, 61, 0.45);
|
||||
background: rgba(232, 146, 61, 0.06);
|
||||
}
|
||||
|
||||
.hud--status {
|
||||
right: var(--space);
|
||||
bottom: 3.4rem;
|
||||
top: auto;
|
||||
width: min(19.5rem, calc(100vw - 24px));
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hud--status summary {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
min-height: 2.75rem;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hud--status summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.worker__pulse {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.hud--status.is-running .worker__pulse {
|
||||
background: var(--warn);
|
||||
box-shadow: 0 0 0 0 rgba(245, 193, 108, 0.7);
|
||||
animation: pulse 1.6s ease-out infinite;
|
||||
}
|
||||
|
||||
.hud--status.is-ready .worker__pulse {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.hud--status dl,
|
||||
.hud--status .error {
|
||||
padding: 0 0.9rem 0.85rem;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
dl div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
dt { color: var(--muted); }
|
||||
dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--danger);
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.banner {
|
||||
position: absolute;
|
||||
z-index: var(--hud-z);
|
||||
left: var(--space);
|
||||
top: 5.4rem;
|
||||
max-width: min(34rem, calc(100vw - 24px));
|
||||
background: rgba(42, 31, 18, 0.92);
|
||||
color: #ffe0c2;
|
||||
border: 1px solid #6a4a22;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem 1rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
select:focus-visible,
|
||||
input:focus-visible,
|
||||
a:focus-visible,
|
||||
summary:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-top-right { top: var(--space); right: var(--space); }
|
||||
.maplibregl-ctrl-bottom-right { right: var(--space); bottom: var(--space); }
|
||||
|
||||
.maplibregl-ctrl-group {
|
||||
background: var(--panel-strong);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-group button {
|
||||
background: transparent;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-attrib {
|
||||
background: rgba(10, 13, 18, 0.62) !important; /* MapLibre default is opaque white */
|
||||
color: var(--muted);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.maplibregl-ctrl-scale {
|
||||
background: rgba(10, 13, 18, 0.62);
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(245, 193, 108, 0.55); }
|
||||
100% { box-shadow: 0 0 0 10px rgba(245, 193, 108, 0); }
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.hud--top {
|
||||
right: 3.6rem;
|
||||
max-width: none;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field--inline { min-width: 8.5rem; flex: 1; }
|
||||
.hud--status { display: none; }
|
||||
.banner { top: auto; bottom: 13.5rem; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
scroll-behavior: auto;
|
||||
animation: none !important; /* third-party MapLibre fade + our pulse */
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./demo.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_ORIGIN?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user