feat(sources): add multiple sources

This commit is contained in:
vl.arkhangelskii
2026-09-21 04:51:23 +03:00
parent 1c187ed56b
commit 946b77540f
18 changed files with 1494 additions and 110 deletions
+62 -46
View File
@@ -62,7 +62,7 @@ export function App() {
const [sources, setSources] = useState<SourceItem[]>([]);
const [styles, setStyles] = useState<StyleItem[]>([]);
const [sync, setSync] = useState<SyncStatus | null>(null);
const [sourceId, setSourceId] = useState("");
const [selectedSourceIds, setSelectedSourceIds] = useState<string[]>([]);
const [styleName, setStyleName] = useState("osm-bright");
const [coords, setCoords] = useState("—");
const [gotoError, setGotoError] = useState("");
@@ -79,7 +79,8 @@ export function App() {
accessRef.current = access;
const personal = isPersonal(access);
const source = sources.find((item) => item.id === sourceId) ?? sources[0];
const selectedSources = sources.filter((item) => selectedSourceIds.includes(item.id));
const source = selectedSources[0] ?? sources[0];
const updateHud = useCallback((map: MapLibreMap) => {
const center = map.getCenter();
@@ -132,18 +133,18 @@ export function App() {
return;
}
if (!source || !styleName || !mapEl.current) {
if (selectedSourceIds.length === 0 || !styleName || !mapEl.current) {
return;
}
const styleKey = `${personal ? access.slug : "public"}|${source.id}|${styleName}|${source.status}|${source.builtAt ?? ""}`;
const styleKey = `${access.slug}|${selectedSourceIds.join(",")}|${styleName}|${selectedSources.map((item) => `${item.status}:${item.builtAt ?? ""}`).join(";")}`;
if (mapRef.current && styleKeyRef.current === styleKey) {
return;
}
const style = await fetchJson<StyleSpecification>(styleUrl(access, styleName, source.id));
const style = await fetchJson<StyleSpecification>(styleUrl(access, styleName, selectedSourceIds));
const hashed = cameraFromHash();
const center = hashed ? [hashed.lon, hashed.lat] : (source.center ?? [37.6173, 55.7558]);
const center = hashed ? [hashed.lon, hashed.lat] : (source?.center ?? [37.6173, 55.7558]);
const zoom = hashed ? hashed.zoom : 6;
if (!mapRef.current) {
@@ -177,13 +178,13 @@ export function App() {
}
styleKeyRef.current = styleKey;
const ready = source.status === "Ready";
const pending = selectedSources.find((item) => item.status !== "Ready");
setBanner(
ready
? ""
: `Тайлы источника «${source.name}» ещё не готовы (${formatStatus(source.status)}). Первый прогон может занять часы.`
pending
? `Тайлы источника «${pending.name}» ещё не готовы (${formatStatus(pending.status)}). Первый прогон может занять часы.`
: ""
);
}, [access, personal, source, styleName, syncGotoFromMap, updateHud]);
}, [access, personal, selectedSourceIds, sources, source, styleName, syncGotoFromMap, updateHud]);
const refresh = useCallback(async () => {
const [sourceList, syncStatus] = await Promise.all([
@@ -196,9 +197,10 @@ export function App() {
setSources(sourceList);
setStyles(styleList);
setSync(syncStatus);
setSourceId((current) =>
sourceList.some((item) => item.id === current) ? current : (sourceList[0]?.id ?? "")
);
setSelectedSourceIds((current) => {
const kept = current.filter((id) => sourceList.some((item) => item.id === id));
return kept.length > 0 ? kept : sourceList[0] ? [sourceList[0].id] : [];
});
setStyleName((current) =>
styleList.some((item) => item.name === current)
? current
@@ -254,16 +256,16 @@ export function App() {
};
const fitBounds = () => {
const bounds = source?.bounds;
const boxes = selectedSources.map((item) => item.bounds).filter((item): item is NonNullable<typeof item> => Boolean(item));
const map = mapRef.current;
if (!map || !bounds) {
setGotoError("У источника ещё нет bounds.");
if (!map || boxes.length === 0) {
setGotoError("У выбранных источников ещё нет bounds.");
return;
}
map.fitBounds(
[
[bounds.minLon, bounds.minLat],
[bounds.maxLon, bounds.maxLat]
[Math.min(...boxes.map((b) => b.minLon)), Math.min(...boxes.map((b) => b.minLat))],
[Math.max(...boxes.map((b) => b.maxLon)), Math.max(...boxes.map((b) => b.maxLat))]
],
{ padding: 48, maxZoom: 10 }
);
@@ -272,10 +274,13 @@ export function App() {
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" });
const ids = selectedSourceIds.length > 0 ? selectedSourceIds : source ? [source.id] : [];
for (const id of ids) {
await fetchJson(serviceUrl(`/api/v1/sync/${encodeURIComponent(id)}`), { method: "POST" });
}
if (ids.length === 0) {
await fetchJson(serviceUrl("/api/v1/sync"), { method: "POST" });
}
await refresh();
} catch (err: unknown) {
setBanner(err instanceof Error ? err.message : "Не удалось запустить синхронизацию");
@@ -284,7 +289,12 @@ export function App() {
}
};
const status = source?.status;
const mixedStatus = selectedSources.some((item) => item.status === "Failed")
? "Failed"
: selectedSources.every((item) => item.status === "Ready")
? "Ready"
: selectedSources[0]?.status;
const status = mixedStatus;
const pillClass =
status === "Ready" ? "pill pill--ok" : status === "Failed" ? "pill pill--fail" : "pill pill--work";
const workerClass = [
@@ -365,26 +375,32 @@ export function App() {
</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>
<fieldset className="field field--sources">
<legend>Округа</legend>
<div className="source-checks">
{sources.map((item) => {
const checked = selectedSourceIds.includes(item.id);
return (
<label key={item.id}>
<input
type="checkbox"
checked={checked}
onChange={() => {
setSelectedSourceIds((current) => {
if (checked) {
const next = current.filter((id) => id !== item.id);
return next.length > 0 ? next : current;
}
return [...current, item.id];
});
}}
/>
{item.name}
</label>
);
})}
</div>
</fieldset>
<label className="field field--inline">
<span>Стиль</span>
<select value={styleName} onChange={(e) => setStyleName(e.target.value)}>
@@ -455,8 +471,8 @@ export function App() {
<dd>{sync?.isRunning ? "идёт синхронизация" : "ожидание"}</dd>
</div>
<div>
<dt>Источник</dt>
<dd>{formatStatus(status)}</dd>
<dt>Округа</dt>
<dd>{selectedSources.length > 0 ? `${selectedSources.length}: ${formatStatus(status)}` : formatStatus(status)}</dd>
</div>
<div>
<dt>Скачан</dt>
+4 -3
View File
@@ -54,13 +54,14 @@ export function stylesListUrl(access: Access): string {
return serviceUrl("/api/v1/styles");
}
export function styleUrl(access: Access, name: string, sourceId: string): string {
export function styleUrl(access: Access, name: string, sourceIds: string[]): string {
const source = sourceIds.join(",");
if (isPersonal(access)) {
return serviceUrl(
`/u/${encodeURIComponent(access.slug)}/styles/${encodeURIComponent(name)}?source=${encodeURIComponent(sourceId)}&token=${encodeURIComponent(access.token)}`
`/u/${encodeURIComponent(access.slug)}/styles/${encodeURIComponent(name)}?source=${encodeURIComponent(source)}&token=${encodeURIComponent(access.token)}`
);
}
return serviceUrl(`/api/v1/styles/${encodeURIComponent(name)}?source=${encodeURIComponent(sourceId)}`);
return serviceUrl(`/api/v1/styles/${encodeURIComponent(name)}?source=${encodeURIComponent(source)}`);
}
export function rewriteServiceUrl(url: string, access: Access): string {
+49
View File
@@ -123,6 +123,55 @@ body {
.field--inline { min-width: 10.5rem; }
.field--sources {
min-width: 16rem;
margin: 0;
padding: 0;
border: 0;
}
.field--sources legend {
padding: 0;
margin-bottom: 0.28rem;
}
.source-checks {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem 0.75rem;
min-height: 2.75rem;
padding: 0.4rem 0.65rem;
border-radius: var(--radius-sm);
border: 1px solid var(--line-strong);
background: rgba(8, 11, 16, 0.72);
}
.source-checks label {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0;
padding: 0;
border: 0;
background: none;
min-height: 0;
color: var(--text);
font-size: 0.78rem;
letter-spacing: 0;
text-transform: none;
cursor: pointer;
}
.source-checks input {
width: 0.95rem;
height: 0.95rem;
min-height: 0;
margin: 0;
padding: 0;
accent-color: var(--accent);
}
.field--token { min-width: 14rem; }
.field--token input {