feat(proj): init

This commit is contained in:
vl.arkhangelskii
2026-09-21 03:53:15 +03:00
commit 3e34f391b3
258 changed files with 20968 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tile Server — demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2105
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "tile-server-demo",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"maplibre-gl": "^5.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}
+481
View File
@@ -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}
</>
);
}
+78
View File
@@ -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}`;
}
+43
View File
@@ -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;
}
+476
View File
@@ -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;
}
}
+10
View File
@@ -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>
);
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_ORIGIN?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5174
},
build: {
outDir: "dist",
emptyOutDir: true
}
});