feat(sources): add multiple sources
This commit is contained in:
+62
-46
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user