From 946b77540fe3b6777839e7f50f0f1d2cab110fc2 Mon Sep 17 00:00:00 2001 From: "vl.arkhangelskii" Date: Mon, 21 Sep 2026 04:51:23 +0300 Subject: [PATCH] feat(sources): add multiple sources --- .../2026-09-21_04-50-00_multi-source-style.md | 5 + ...-21_04-55-00_web-demo-source-checkboxes.md | 3 + .../Controllers/PersonalDeliveryController.cs | 17 +- .../Accounts/IUserStyleService.cs | 6 +- .../Accounts/UserStyleService.cs | 5 +- .../Extracts/ExtractSourceQuery.cs | 55 + .../Styles/IStyleService.cs | 4 + .../Styles/StyleService.cs | 84 +- .../TileServer.UnitTests/StyleServiceTests.cs | 74 +- web-demo/src/App.tsx | 108 +- web-demo/src/access.ts | 7 +- web-demo/src/demo.css | 49 + web-demo/yarn.lock | 973 ++++++++++++++++++ web/src/features/guides/GuidesPage.tsx | 19 +- web/src/features/styles/LiveMap.tsx | 43 +- web/src/features/styles/StyleEditorPage.tsx | 61 +- web/src/features/styles/editor.css | 25 + web/src/features/styles/styleModel.ts | 66 +- 18 files changed, 1494 insertions(+), 110 deletions(-) create mode 100644 context/2026-09-21_04-50-00_multi-source-style.md create mode 100644 context/2026-09-21_04-55-00_web-demo-source-checkboxes.md create mode 100644 src/TileServer.Application/Extracts/ExtractSourceQuery.cs create mode 100644 web-demo/yarn.lock diff --git a/context/2026-09-21_04-50-00_multi-source-style.md b/context/2026-09-21_04-50-00_multi-source-style.md new file mode 100644 index 0000000..c3c353e --- /dev/null +++ b/context/2026-09-21_04-50-00_multi-source-style.md @@ -0,0 +1,5 @@ +# Несколько source в стиле + +`?source=central-fed-district,volga-fed-district` — сервер вешает отдельный vector source на каждый extract и клонирует слои (`water__volga-fed-district`). Один MapLibre style, несколько округов. + +Кабинет: чекбоксы округов в редакторе. Инструкции обновлены. diff --git a/context/2026-09-21_04-55-00_web-demo-source-checkboxes.md b/context/2026-09-21_04-55-00_web-demo-source-checkboxes.md new file mode 100644 index 0000000..d885c81 --- /dev/null +++ b/context/2026-09-21_04-55-00_web-demo-source-checkboxes.md @@ -0,0 +1,3 @@ +# web-demo: чекбоксы округов + +Селект источника заменён на чекбоксы. Стиль грузится с `?source=id1,id2`. Последний округ снять нельзя. Fit bounds объединяет выбранные extract. diff --git a/src/TileServer.Api/Controllers/PersonalDeliveryController.cs b/src/TileServer.Api/Controllers/PersonalDeliveryController.cs index 0fe556d..d2cbe9c 100644 --- a/src/TileServer.Api/Controllers/PersonalDeliveryController.cs +++ b/src/TileServer.Api/Controllers/PersonalDeliveryController.cs @@ -6,6 +6,7 @@ using TileServer.Api.Http; using TileServer.Application.Accounts; using TileServer.Application.Configuration; using TileServer.Application.Extracts; +using TileServer.Application.Styles; using TileServer.Application.Tiles; using TileServer.Domain.Exceptions; using TileServer.Domain.Tiles; @@ -72,17 +73,19 @@ public sealed class PersonalDeliveryController( { var access = await ResolveAsync(slug, ct).ConfigureAwait(false); var token = AccessToken.Read(Request) ?? string.Empty; - var extract = string.IsNullOrWhiteSpace(source) - ? extracts.GetAll().FirstOrDefault(e => e.Enabled) - : extracts.Find(source); - if (extract is null) + var selected = ExtractSourceQuery.Resolve(source, extracts); + if (selected.Count == 0) { throw new ResourceNotFoundException("Extract", source ?? "(none configured)"); } - var tilesUrl = - $"{PublicUrl.GetBase(Request, options)}/u/{access.User.Slug}/tiles/{extract.Id}/{{z}}/{{x}}/{{y}}.pbf?v=3&token={Uri.EscapeDataString(token)}"; - var style = await styles.GetForDeliveryAsync(access.User.Slug, name, extract.Id, tilesUrl, ct) + var root = PublicUrl.GetBase(Request, options); + var bound = selected + .Select(extract => new BoundExtract( + extract.Id, + $"{root}/u/{access.User.Slug}/tiles/{extract.Id}/{{z}}/{{x}}/{{y}}.pbf?v=3&token={Uri.EscapeDataString(token)}")) + .ToArray(); + var style = await styles.GetForDeliveryAsync(access.User.Slug, name, bound, ct) .ConfigureAwait(false); var json = style.ToJsonString(); usage.RecordStyle(access.User.Id, access.Token.Id, Encoding.UTF8.GetByteCount(json)); diff --git a/src/TileServer.Application/Accounts/IUserStyleService.cs b/src/TileServer.Application/Accounts/IUserStyleService.cs index e05c2b3..4149713 100644 --- a/src/TileServer.Application/Accounts/IUserStyleService.cs +++ b/src/TileServer.Application/Accounts/IUserStyleService.cs @@ -17,5 +17,9 @@ public interface IUserStyleService Task> ListForDeliveryAsync(string slug, string baseUrl, string token, CancellationToken ct); - Task GetForDeliveryAsync(string slug, string name, string extractId, string tilesUrl, CancellationToken ct); + Task GetForDeliveryAsync( + string slug, + string name, + IReadOnlyList extracts, + CancellationToken ct); } diff --git a/src/TileServer.Application/Accounts/UserStyleService.cs b/src/TileServer.Application/Accounts/UserStyleService.cs index 226f987..4408fdc 100644 --- a/src/TileServer.Application/Accounts/UserStyleService.cs +++ b/src/TileServer.Application/Accounts/UserStyleService.cs @@ -130,8 +130,7 @@ public sealed class UserStyleService( public async Task GetForDeliveryAsync( string slug, string name, - string extractId, - string tilesUrl, + IReadOnlyList extracts, CancellationToken ct) { var styleName = ResourceName.Require(name, nameof(name)); @@ -139,7 +138,7 @@ public sealed class UserStyleService( ?? throw new ResourceNotFoundException("User", slug); var own = await styles.FindAsync(user.Id, styleName, ct).ConfigureAwait(false); var raw = own is null ? catalog.GetRequired(styleName) : Parse(own.Json, styleName); - return styleService.Bind(raw, extractId, tilesUrl); + return styleService.Bind(raw, extracts); } private static JsonObject Parse(string json, string name) diff --git a/src/TileServer.Application/Extracts/ExtractSourceQuery.cs b/src/TileServer.Application/Extracts/ExtractSourceQuery.cs new file mode 100644 index 0000000..fcf7110 --- /dev/null +++ b/src/TileServer.Application/Extracts/ExtractSourceQuery.cs @@ -0,0 +1,55 @@ +using TileServer.Domain; +using TileServer.Domain.Exceptions; +using TileServer.Domain.Extracts; + +namespace TileServer.Application.Extracts; + +public static class ExtractSourceQuery +{ + public const int MaxCount = 16; + + public static IReadOnlyList Resolve(string? source, IExtractCatalog catalog) + { + var ids = ParseIds(source, catalog); + var result = new List(ids.Count); + foreach (var id in ids) + { + result.Add(catalog.Find(id) ?? throw new ResourceNotFoundException("Extract", id)); + } + + return result; + } + + private static IReadOnlyList ParseIds(string? source, IExtractCatalog catalog) + { + if (string.IsNullOrWhiteSpace(source)) + { + var first = catalog.GetAll().FirstOrDefault(item => item.Enabled); + return first is null ? [] : [first.Id]; + } + + var seen = new HashSet(StringComparer.Ordinal); + var ids = new List(); + foreach (var part in source.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var id = ResourceName.Require(part, nameof(source)); + if (!seen.Add(id)) + { + continue; + } + + ids.Add(id); + if (ids.Count >= MaxCount) + { + break; + } + } + + if (ids.Count == 0) + { + throw new DomainValidationException("At least one source is required."); + } + + return ids; + } +} diff --git a/src/TileServer.Application/Styles/IStyleService.cs b/src/TileServer.Application/Styles/IStyleService.cs index 3463752..f006197 100644 --- a/src/TileServer.Application/Styles/IStyleService.cs +++ b/src/TileServer.Application/Styles/IStyleService.cs @@ -4,6 +4,8 @@ namespace TileServer.Application.Styles; public sealed record StyleListItem(string Name, string Url); +public sealed record BoundExtract(string ExtractId, string TilesUrl); + public interface IStyleService { IReadOnlyList List(string baseUrl); @@ -12,5 +14,7 @@ public interface IStyleService JsonObject Bind(JsonObject style, string extractId, string tilesUrl); + JsonObject Bind(JsonObject style, IReadOnlyList extracts); + Task SaveAsync(string name, JsonObject style, CancellationToken ct); } diff --git a/src/TileServer.Application/Styles/StyleService.cs b/src/TileServer.Application/Styles/StyleService.cs index ba41a4e..952e81c 100644 --- a/src/TileServer.Application/Styles/StyleService.cs +++ b/src/TileServer.Application/Styles/StyleService.cs @@ -31,11 +31,26 @@ public sealed class StyleService( } public JsonObject Bind(JsonObject style, string extractId, string tilesUrl) + => Bind(style, [new BoundExtract(extractId, tilesUrl)]); + + public JsonObject Bind(JsonObject style, IReadOnlyList extracts) { - var extract = ResolveExtract(extractId); + if (extracts.Count == 0) + { + throw new DomainValidationException("At least one extract is required."); + } + var clone = JsonNode.Parse(style.ToJsonString())?.AsObject() ?? throw new DomainValidationException("Style must be a JSON object."); - BindRuntimeUrls(clone, extract, tilesUrl); + var first = extracts[0]; + BindRuntimeUrls(clone, ResolveExtract(first.ExtractId), first.TilesUrl); + var primaryKeys = VectorSourceKeys(clone); + for (var i = 1; i < extracts.Count; i++) + { + var extra = extracts[i]; + AppendExtract(clone, ResolveExtract(extra.ExtractId), extra.TilesUrl, primaryKeys); + } + return clone; } @@ -87,6 +102,71 @@ public sealed class StyleService( } } + private void AppendExtract( + JsonObject style, + Extract extract, + string tilesUrl, + IReadOnlyList primarySourceKeys) + { + if (style["sources"] is not JsonObject sources || style["layers"] is not JsonArray layers) + { + throw new DomainValidationException("Style must have sources and layers."); + } + + var metadata = tileStore.GetMetadata(extract.Id); + var minZoom = metadata?.MinZoom ?? extract.MinZoom; + var maxZoom = metadata?.MaxZoom ?? extract.MaxZoom; + var suffix = "__" + extract.Id; + var primary = primarySourceKeys.ToHashSet(StringComparer.Ordinal); + + foreach (var key in primarySourceKeys) + { + sources[key + suffix] = CreateVectorSource(tilesUrl, minZoom, maxZoom); + } + + var extras = new List(); + foreach (var node in layers) + { + if (node is not JsonObject layer) + { + continue; + } + + var sourceName = layer["source"]?.GetValue(); + if (sourceName is null || !primary.Contains(sourceName)) + { + continue; + } + + var copy = JsonNode.Parse(layer.ToJsonString())?.AsObject() + ?? throw new DomainValidationException("Layer clone failed."); + var id = layer["id"]?.GetValue() ?? "layer"; + copy["id"] = id + suffix; + copy["source"] = sourceName + suffix; + extras.Add(copy); + } + + foreach (var extra in extras) + { + layers.Add(extra); + } + } + + private static IReadOnlyList VectorSourceKeys(JsonObject style) + { + if (style["sources"] is not JsonObject sources) + { + return []; + } + + return sources + .Where(property => + property.Value is JsonObject source && + string.Equals(source["type"]?.GetValue(), "vector", StringComparison.OrdinalIgnoreCase)) + .Select(property => property.Key) + .ToArray(); + } + private static JsonObject CreateVectorSource(string tilesUrl, int minZoom, int maxZoom) { var source = new JsonObject diff --git a/tests/TileServer.UnitTests/StyleServiceTests.cs b/tests/TileServer.UnitTests/StyleServiceTests.cs index 6f5b70d..df0b14e 100644 --- a/tests/TileServer.UnitTests/StyleServiceTests.cs +++ b/tests/TileServer.UnitTests/StyleServiceTests.cs @@ -131,6 +131,74 @@ public sealed class StyleServiceTests Assert.Contains("token=ts_abc", tile, StringComparison.Ordinal); } + [Fact] + public void Bind_MultipleExtracts_AddsSourcesAndClonesLayers() + { + var central = SampleExtract("central-fed-district", 37.6, 55.7); + var volga = SampleExtract("volga-fed-district", 45, 57); + var template = new JsonObject + { + ["version"] = 8, + ["sources"] = new JsonObject + { + ["openmaptiles"] = new JsonObject { ["type"] = "vector" } + }, + ["layers"] = new JsonArray + { + new JsonObject + { + ["id"] = "background", + ["type"] = "background", + ["paint"] = new JsonObject { ["background-color"] = "#000" } + }, + new JsonObject + { + ["id"] = "water", + ["type"] = "fill", + ["source"] = "openmaptiles", + ["source-layer"] = "water" + } + } + }; + + var service = new StyleService( + new MemoryStyleCatalog(template), + new MemoryExtractCatalog(central, volga), + new MemoryTileStore(SampleMetadata(central)), + Options.Create(new TileServerOptions())); + + var style = service.Bind(template, [ + new BoundExtract(central.Id, "https://tile-server.ru/u/alice/tiles/central-fed-district/{z}/{x}/{y}.pbf?token=ts"), + new BoundExtract(volga.Id, "https://tile-server.ru/u/alice/tiles/volga-fed-district/{z}/{x}/{y}.pbf?token=ts") + ]); + + var sources = style["sources"]!.AsObject(); + Assert.True(sources.ContainsKey("openmaptiles")); + Assert.True(sources.ContainsKey("openmaptiles__volga-fed-district")); + Assert.Contains("volga-fed-district", sources["openmaptiles__volga-fed-district"]!["tiles"]![0]!.GetValue(), StringComparison.Ordinal); + + var layers = style["layers"]!.AsArray(); + Assert.Equal(3, layers.Count); + Assert.Equal("water__volga-fed-district", layers[2]!["id"]!.GetValue()); + Assert.Equal("openmaptiles__volga-fed-district", layers[2]!["source"]!.GetValue()); + Assert.Null(layers[0]!["source"]); + } + + private static Extract SampleExtract(string id, double lon, double lat) + => new() + { + Id = id, + Name = id, + Url = new Uri($"https://download.geofabrik.de/russia/{id}-latest.osm.pbf"), + CenterLon = lon, + CenterLat = lat, + MinZoom = 0, + MaxZoom = 14 + }; + + private static TilesetMetadata SampleMetadata(Extract extract) + => new(extract.Id, extract.Name, "pbf", 0, 0, 1, 1, 0, 0, 5, 0, 14, []); + private sealed class MemoryStyleCatalog(JsonObject style) : IStyleCatalog { public IReadOnlyList List() => [new("osm-bright", DateTimeOffset.UnixEpoch)]; @@ -143,11 +211,11 @@ public sealed class StyleServiceTests public Task SeedDefaultsAsync(CancellationToken ct) => Task.CompletedTask; } - private sealed class MemoryExtractCatalog(Extract extract) : IExtractCatalog + private sealed class MemoryExtractCatalog(params Extract[] extracts) : IExtractCatalog { - public IReadOnlyList GetAll() => [extract]; + public IReadOnlyList GetAll() => extracts; - public Extract? Find(string id) => id == extract.Id ? extract : null; + public Extract? Find(string id) => extracts.FirstOrDefault(item => item.Id == id); public Extract GetRequired(string id) => Find(id) ?? throw new InvalidOperationException(id); diff --git a/web-demo/src/App.tsx b/web-demo/src/App.tsx index bdbcf3f..527eeaa 100644 --- a/web-demo/src/App.tsx +++ b/web-demo/src/App.tsx @@ -62,7 +62,7 @@ export function App() { const [sources, setSources] = useState([]); const [styles, setStyles] = useState([]); const [sync, setSync] = useState(null); - const [sourceId, setSourceId] = useState(""); + const [selectedSourceIds, setSelectedSourceIds] = useState([]); 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(styleUrl(access, styleName, source.id)); + const style = await fetchJson(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 => 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() { ) : null} {personal ? "личный" : "публичный"} - +
+ Округа +
+ {sources.map((item) => { + const checked = selectedSourceIds.includes(item.id); + return ( + + ); + })} +
+