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
@@ -0,0 +1,131 @@
using System.Security.Claims;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using TileServer.Application.Accounts;
using TileServer.Application.Configuration;
using TileServer.Domain.Exceptions;
namespace TileServer.Api.Controllers;
[ApiController]
[Route("api/v1/auth")]
public sealed class AuthController(
IAccountService accounts,
IOptions<TileServerOptions> options) : ControllerBase
{
private const string OAuthStateCookie = "ts_oauth_state";
[HttpGet("providers")]
[AllowAnonymous]
public IActionResult Providers()
{
var yandex = options.Value.Yandex;
var configured = yandex.IsConfigured;
return Ok(new AuthProvidersResponse(
new YandexProviderResponse(
configured,
configured ? yandex.ClientId : null,
configured ? yandex.RedirectUri : null)));
}
[HttpGet("yandex/start")]
[AllowAnonymous]
public IActionResult StartYandex()
{
var yandex = options.Value.Yandex;
if (!yandex.IsConfigured)
{
throw new DomainValidationException("Yandex OAuth is not configured.");
}
var state = Convert.ToHexString(RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
Response.Cookies.Append(OAuthStateCookie, state, new CookieOptions
{
HttpOnly = true,
Secure = Request.IsHttps,
SameSite = SameSiteMode.Lax,
MaxAge = TimeSpan.FromMinutes(15),
Path = "/"
});
var url =
"https://oauth.yandex.ru/authorize" +
"?response_type=code" +
$"&client_id={Uri.EscapeDataString(yandex.ClientId)}" +
$"&redirect_uri={Uri.EscapeDataString(yandex.RedirectUri)}" +
$"&state={Uri.EscapeDataString(state)}";
return Redirect(url);
}
[HttpPost("yandex")]
[AllowAnonymous]
public async Task<IActionResult> ExchangeYandex([FromBody] YandexCodeRequest request, CancellationToken ct)
{
var yandex = options.Value.Yandex;
if (!yandex.IsConfigured)
{
throw new DomainValidationException("Yandex OAuth is not configured.");
}
var redirectUri = string.IsNullOrWhiteSpace(request.RedirectUri)
? yandex.RedirectUri
: request.RedirectUri.Trim();
if (!IsAllowedRedirect(yandex.RedirectUri, redirectUri))
{
throw new DomainValidationException("Invalid Yandex redirect_uri.");
}
if (Request.Cookies.TryGetValue(OAuthStateCookie, out var expected) &&
!string.IsNullOrEmpty(expected) &&
!string.Equals(expected, request.State, StringComparison.Ordinal))
{
throw new UnauthenticatedException("OAuth state mismatch.");
}
var (me, cabinetToken) = await accounts.SignInFromYandexAsync(request.Code, redirectUri, ct)
.ConfigureAwait(false);
var identity = new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, me.Id.ToString()),
new Claim(ClaimTypes.Name, me.Login),
new Claim("slug", me.Slug)
],
CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
new ClaimsPrincipal(identity),
new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddDays(30)
}).ConfigureAwait(false);
Response.Cookies.Delete(OAuthStateCookie);
return Ok(new AuthResponse(me, cabinetToken));
}
[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme).ConfigureAwait(false);
return NoContent();
}
private static bool IsAllowedRedirect(string configured, string incoming)
{
static string Normalize(string value) => value.Trim().TrimEnd('/');
return string.Equals(Normalize(configured), Normalize(incoming), StringComparison.OrdinalIgnoreCase);
}
}
public sealed record YandexCodeRequest(string Code, string? State, string? RedirectUri);
public sealed record AuthResponse(MeResponse User, string? CabinetToken);
public sealed record YandexProviderResponse(bool Enabled, string? ClientId, string? RedirectUri);
public sealed record AuthProvidersResponse(YandexProviderResponse Yandex);
@@ -0,0 +1,12 @@
using Microsoft.AspNetCore.Mvc;
namespace TileServer.Api.Controllers;
[Route("demo")]
[ApiExplorerSettings(IgnoreApi = true)]
public sealed class DemoController : Controller
{
[HttpGet("")]
[HttpGet("index")]
public IActionResult Index() => View();
}
@@ -0,0 +1,88 @@
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using TileServer.Api.Http;
using TileServer.Application.Accounts;
using TileServer.Domain.Exceptions;
namespace TileServer.Api.Controllers;
[ApiController]
[Authorize]
[Route("api/v1/me")]
public sealed class MeController(
IAccountService accounts,
IApiTokenService tokens,
IUserStyleService styles,
IUsageQuery usage) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get(CancellationToken ct)
=> Ok(await accounts.GetMeAsync(SessionUser.GetId(User), ct).ConfigureAwait(false));
[HttpGet("tokens")]
public async Task<IActionResult> ListTokens(CancellationToken ct)
=> Ok(await tokens.ListAsync(SessionUser.GetId(User), ct).ConfigureAwait(false));
[HttpPost("tokens")]
public async Task<IActionResult> CreateToken([FromBody] CreateTokenRequest request, CancellationToken ct)
=> Ok(await tokens.CreateAsync(SessionUser.GetId(User), request.Name ?? "Token", ct).ConfigureAwait(false));
[HttpDelete("tokens/{id:guid}")]
public async Task<IActionResult> RevokeToken(Guid id, CancellationToken ct)
{
await tokens.RevokeAsync(SessionUser.GetId(User), id, ct).ConfigureAwait(false);
return NoContent();
}
[HttpGet("styles")]
public async Task<IActionResult> ListStyles(CancellationToken ct)
=> Ok(await styles.ListMineAsync(SessionUser.GetId(User), ct).ConfigureAwait(false));
[HttpGet("styles/{name}")]
public async Task<IActionResult> GetStyle(string name, CancellationToken ct)
=> new JsonResult(await styles.GetMineAsync(SessionUser.GetId(User), name, ct).ConfigureAwait(false));
[HttpPost("styles")]
public async Task<IActionResult> CloneStyle([FromBody] CloneStyleRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.From))
{
throw new DomainValidationException("Style name and source preset are required.");
}
return Ok(await styles.CloneAsync(SessionUser.GetId(User), request.Name, request.From, ct)
.ConfigureAwait(false));
}
[HttpPut("styles/{name}")]
public async Task<IActionResult> SaveStyle(string name, [FromBody] JsonObject style, CancellationToken ct)
{
await styles.SaveMineAsync(SessionUser.GetId(User), name, style, ct).ConfigureAwait(false);
return NoContent();
}
[HttpDelete("styles/{name}")]
public async Task<IActionResult> DeleteStyle(string name, CancellationToken ct)
{
await styles.DeleteMineAsync(SessionUser.GetId(User), name, ct).ConfigureAwait(false);
return NoContent();
}
[HttpGet("usage")]
public async Task<IActionResult> GetUsage([FromQuery] DateOnly? from, [FromQuery] DateOnly? to, CancellationToken ct)
{
var end = to ?? DateOnly.FromDateTime(DateTime.UtcNow);
var start = from ?? end.AddDays(-6);
if (start > end)
{
throw new DomainValidationException("'from' must be on or before 'to'.");
}
return Ok(await usage.GetAsync(SessionUser.GetId(User), start, end, ct).ConfigureAwait(false));
}
}
public sealed record CreateTokenRequest(string? Name);
public sealed record CloneStyleRequest(string Name, string From);
@@ -0,0 +1,94 @@
using System.Text;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using TileServer.Api.Http;
using TileServer.Application.Accounts;
using TileServer.Application.Configuration;
using TileServer.Application.Extracts;
using TileServer.Application.Tiles;
using TileServer.Domain.Exceptions;
using TileServer.Domain.Tiles;
namespace TileServer.Api.Controllers;
[ApiController]
[AllowAnonymous]
[Route("u/{slug}")]
public sealed class PersonalDeliveryController(
IApiTokenService tokens,
ITileQueryService tiles,
IUserStyleService styles,
IExtractCatalog extracts,
IUsageRecorder usage,
IOptions<TileServerOptions> options) : ControllerBase
{
[HttpGet("tiles/{source}/{z:int}/{x:int}/{y:int}.pbf")]
public async Task<IActionResult> GetTile(string slug, string source, int z, int x, int y, CancellationToken ct)
{
var access = await ResolveAsync(slug, ct).ConfigureAwait(false);
var result = await tiles.GetTileAsync(source, new TileCoordinate(z, x, y), ct).ConfigureAwait(false);
if (result is null)
{
Response.Headers.CacheControl = "no-store";
usage.RecordTile(access.User.Id, access.Token.Id, 0);
return NoContent();
}
var payload = result.Tile.Uncompressed();
usage.RecordTile(access.User.Id, access.Token.Id, payload.Length);
Response.Headers.CacheControl = "private, max-age=604800, immutable";
return File(payload, "application/vnd.mapbox-vector-tile");
}
[HttpGet("tiles/{source}.json")]
[Produces("application/json")]
public async Task<IActionResult> GetTileJson(string slug, string source, CancellationToken ct)
{
var access = await ResolveAsync(slug, ct).ConfigureAwait(false);
var token = AccessToken.Read(Request) ?? string.Empty;
var tilesUrl =
$"{PublicUrl.GetBase(Request, options)}/u/{access.User.Slug}/tiles/{source}/{{z}}/{{x}}/{{y}}.pbf?v=3&token={Uri.EscapeDataString(token)}";
return Ok(tiles.GetTileJson(source, tilesUrl));
}
[HttpGet("styles")]
public async Task<IActionResult> ListStyles(string slug, CancellationToken ct)
{
var access = await ResolveAsync(slug, ct).ConfigureAwait(false);
var token = AccessToken.Read(Request) ?? string.Empty;
var list = await styles.ListForDeliveryAsync(
access.User.Slug,
PublicUrl.GetBase(Request, options),
token,
ct)
.ConfigureAwait(false);
return Ok(list);
}
[HttpGet("styles/{name}")]
[Produces("application/json")]
public async Task<IActionResult> GetStyle(string slug, string name, [FromQuery] string? source, CancellationToken ct)
{
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)
{
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)
.ConfigureAwait(false);
var json = style.ToJsonString();
usage.RecordStyle(access.User.Id, access.Token.Id, Encoding.UTF8.GetByteCount(json));
return new JsonResult(style);
}
private Task<ResolvedAccess> ResolveAsync(string slug, CancellationToken ct)
=> tokens.ResolveAsync(slug, AccessToken.Read(Request), bearer: null, ct);
}
@@ -0,0 +1,38 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using TileServer.Api.Http;
using TileServer.Application.Configuration;
using TileServer.Application.Extracts;
namespace TileServer.Api.Controllers;
[ApiController]
[Route("api/v1/sources")]
public sealed class SourcesController(IExtractService extracts, IOptions<TileServerOptions> options) : ControllerBase
{
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<ExtractResponse>), StatusCodes.Status200OK)]
public async Task<IActionResult> List(CancellationToken ct)
=> Ok(await extracts.ListAsync(PublicUrl.GetBase(Request, options), ct).ConfigureAwait(false));
[HttpGet("{id}")]
[ProducesResponseType(typeof(ExtractResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> Get(string id, CancellationToken ct)
=> Ok(await extracts.GetAsync(id, PublicUrl.GetBase(Request, options), ct).ConfigureAwait(false));
[HttpPut("{id}")]
[ProducesResponseType(typeof(ExtractResponse), StatusCodes.Status200OK)]
public async Task<IActionResult> Upsert(string id, [FromBody] CreateExtractRequest request, CancellationToken ct)
{
var payload = request with { Id = string.IsNullOrWhiteSpace(request.Id) ? id : request.Id };
return Ok(await extracts.AddOrUpdateAsync(payload, PublicUrl.GetBase(Request, options), ct).ConfigureAwait(false));
}
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Delete(string id, CancellationToken ct)
{
await extracts.RemoveAsync(id, ct).ConfigureAwait(false);
return NoContent();
}
}
@@ -0,0 +1,22 @@
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
using TileServer.Api.Http;
namespace TileServer.Api.Controllers;
[ApiController]
[Route("api/v1/styles")]
public sealed class StylesController : ControllerBase
{
[HttpGet]
public IActionResult List() => Closed();
[HttpGet("{name}")]
public IActionResult Get(string name, [FromQuery] string? source) => Closed();
[HttpPut("{name}")]
public IActionResult Put(string name, [FromBody] JsonObject style) => Closed();
private static IActionResult Closed()
=> PublicDelivery.Closed("Public styles are disabled. Use /u/{slug}/styles/{name}?token= or POST /api/v1/me/styles while signed in.");
}
@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.Mvc;
using TileServer.Application.Sync;
using TileServer.Domain.Exceptions;
namespace TileServer.Api.Controllers;
[ApiController]
[Route("api/v1/sync")]
public sealed class SyncController(IMapSyncService sync, ILogger<SyncController> logger) : ControllerBase
{
[HttpGet("status")]
[ProducesResponseType(typeof(SyncJobStatus), StatusCodes.Status200OK)]
public IActionResult Status() => Ok(sync.GetStatus());
[HttpPost]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public IActionResult RunAll()
=> Start(() => sync.RunAllAsync(CancellationToken.None), "Synchronization started for all extracts.");
[HttpPost("{extractId}")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public IActionResult RunOne(string extractId)
=> Start(() => sync.RunOneAsync(extractId, CancellationToken.None), $"Synchronization started for '{extractId}'.");
private IActionResult Start(Func<Task> work, string message)
{
if (sync.GetStatus().IsRunning)
{
return Conflict(new { message = "A synchronization job is already running." });
}
_ = RunInBackground(work);
return Accepted(new { message });
}
private async Task RunInBackground(Func<Task> work)
{
try
{
await work().ConfigureAwait(false);
}
catch (ResourceConflictException)
{
logger.LogInformation("Sync request ignored because another job is running.");
}
catch (Exception ex)
{
logger.LogError(ex, "Background synchronization failed.");
}
}
}
@@ -0,0 +1,22 @@
using Microsoft.AspNetCore.Mvc;
using TileServer.Api.Http;
namespace TileServer.Api.Controllers;
[ApiController]
[Route("api/v1/tiles")]
public sealed class TilesController : ControllerBase
{
[HttpGet("{extractId}/{z:int}/{x:int}/{y:int}.pbf")]
[HttpGet("/tiles/{extractId}/{z:int}/{x:int}/{y:int}.pbf")]
public IActionResult GetTile(string extractId, int z, int x, int y)
=> Closed();
[HttpGet("{extractId}.json")]
[HttpGet("/tiles/{extractId}.json")]
public IActionResult GetTileJson(string extractId)
=> Closed();
private static IActionResult Closed()
=> PublicDelivery.Closed("Public tiles are disabled. Use /u/{slug}/tiles/{source}/{z}/{x}/{y}.pbf?token=");
}
+18
View File
@@ -0,0 +1,18 @@
namespace TileServer.Api.Http;
public static class AccessToken
{
public static string? Read(HttpRequest request)
{
if (request.Query.TryGetValue("token", out var query) && !string.IsNullOrWhiteSpace(query))
{
return query.ToString();
}
var header = request.Headers.Authorization.ToString();
const string bearer = "Bearer ";
return header.StartsWith(bearer, StringComparison.OrdinalIgnoreCase)
? header[bearer.Length..].Trim()
: null;
}
}
+17
View File
@@ -0,0 +1,17 @@
using Microsoft.AspNetCore.Mvc;
namespace TileServer.Api.Http;
internal static class PublicDelivery
{
public static IActionResult Closed(string detail)
=> new ObjectResult(new ProblemDetails
{
Status = StatusCodes.Status401Unauthorized,
Title = "Unauthorized",
Detail = detail
})
{
StatusCode = StatusCodes.Status401Unauthorized
};
}
+37
View File
@@ -0,0 +1,37 @@
using Microsoft.Extensions.Options;
using TileServer.Application.Configuration;
namespace TileServer.Api.Http;
public static class PublicUrl
{
public static string GetBase(HttpRequest request, IOptions<TileServerOptions> options)
=> GetBase(request, options.Value.PublicBaseUrl);
public static string GetBase(HttpRequest request, string? publicBaseUrl)
{
if (!string.IsNullOrWhiteSpace(publicBaseUrl))
{
return publicBaseUrl.TrimEnd('/');
}
var scheme = FirstForwarded(request.Headers["X-Forwarded-Proto"]) ?? request.Scheme;
var host = FirstForwarded(request.Headers["X-Forwarded-Host"]) ?? request.Host.Value;
var pathBase = request.PathBase.HasValue
? request.PathBase.Value!.TrimEnd('/')
: string.Empty;
return $"{scheme}://{host}{pathBase}";
}
private static string? FirstForwarded(string? header)
{
if (string.IsNullOrWhiteSpace(header))
{
return null;
}
var value = header.Split(',')[0].Trim();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
}
+17
View File
@@ -0,0 +1,17 @@
using System.Security.Claims;
namespace TileServer.Api.Http;
public static class SessionUser
{
public static Guid GetId(ClaimsPrincipal user)
{
var raw = user.FindFirstValue(ClaimTypes.NameIdentifier);
if (!Guid.TryParse(raw, out var id))
{
throw new InvalidOperationException("Authenticated user id is missing.");
}
return id;
}
}
@@ -0,0 +1,47 @@
using System.Net;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
using TileServer.Domain.Exceptions;
namespace TileServer.Api.Middleware;
public sealed class DomainExceptionHandler(ILogger<DomainExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
{
var (status, title) = exception switch
{
ResourceNotFoundException => (HttpStatusCode.NotFound, "Resource not found"),
DomainValidationException => (HttpStatusCode.BadRequest, "Validation failed"),
ResourceConflictException => (HttpStatusCode.Conflict, "Conflict"),
UnauthenticatedException => (HttpStatusCode.Unauthorized, "Unauthorized"),
_ => (HttpStatusCode.InternalServerError, "Unexpected error")
};
if (status == HttpStatusCode.InternalServerError)
{
logger.LogError(exception, "Unhandled exception");
}
else
{
logger.LogInformation(exception, "Domain exception mapped to {Status}", (int)status);
}
var problem = new ProblemDetails
{
Status = (int)status,
Title = title,
Detail = status == HttpStatusCode.InternalServerError ? "An unexpected error occurred." : exception.Message,
Instance = httpContext.Request.Path
};
httpContext.Response.StatusCode = (int)status;
if (status == HttpStatusCode.Unauthorized)
{
httpContext.Response.Headers.CacheControl = "no-store";
}
await httpContext.Response.WriteAsJsonAsync(problem, cancellationToken).ConfigureAwait(false);
return true;
}
}
+133
View File
@@ -0,0 +1,133 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.FileProviders;
using TileServer.Api.Middleware;
using TileServer.Application;
using TileServer.Infrastructure;
using TileServer.Infrastructure.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddApplication()
.AddInfrastructure(builder.Configuration);
var dataDirectory = builder.Configuration["TileServer:DataDirectory"] ?? "data";
var keyPath = Path.Combine(dataDirectory, "aspnet-keys");
Directory.CreateDirectory(keyPath);
builder.Services
.AddDataProtection()
.SetApplicationName("TileServer")
.PersistKeysToFileSystem(new DirectoryInfo(keyPath));
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor
| ForwardedHeaders.XForwardedProto
| ForwardedHeaders.XForwardedHost;
// Docker / nginx source IPs are not in the default known-proxy list.
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<DomainExceptionHandler>();
builder.Services.AddResponseCaching();
builder.Services.AddHealthChecks();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new()
{
Title = "Tile Server API",
Version = "v1",
Description = "Vector tiles, MapLibre styles and OSM extract synchronization."
});
});
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
policy.AllowAnyOrigin()
.AllowAnyHeader()
.WithMethods("GET", "HEAD", "PUT", "POST", "DELETE"));
});
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "ts_session";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
};
});
builder.Services.AddAuthorization();
builder.Services
.AddControllersWithViews()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});
var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
}
app.UseForwardedHeaders();
app.UseExceptionHandler();
app.UseSwagger();
app.UseSwaggerUI();
var spaRoot = Path.Combine(app.Environment.WebRootPath, "app");
if (Directory.Exists(spaRoot))
{
var spaFiles = new PhysicalFileProvider(spaRoot);
app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = spaFiles });
app.UseStaticFiles(new StaticFileOptions { FileProvider = spaFiles });
}
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseResponseCaching();
app.UseCors();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHealthChecks("/health/live");
app.MapHealthChecks("/health/ready");
if (File.Exists(Path.Combine(spaRoot, "index.html")))
{
app.MapFallbackToFile("app/index.html");
}
else
{
app.MapGet("/", () => Results.Redirect("/demo"));
}
app.Run();
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "",
"applicationUrl": "http://localhost:5088",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TileServer.Application\TileServer.Application.csproj" />
<ProjectReference Include="..\TileServer.Infrastructure\TileServer.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="..\TileServer.Infrastructure\Seed\styles\*.json" Link="Seed\styles\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
</Project>
+6
View File
@@ -0,0 +1,6 @@
@TileServer.Api_HostAddress = http://localhost:5005
GET {{TileServer.Api_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Tile Server</title>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@5.6.1/dist/maplibre-gl.css" />
<link rel="stylesheet" href="~/css/demo.css" asp-append-version="true" />
</head>
<body>
<div id="map" role="application" aria-label="Карта"></div>
<header class="hud hud--top">
<div class="brand">
<span class="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 class="brand__text">
<strong>Tile Server</strong>
<span>OSM · MapLibre · Geofabrik</span>
</span>
</div>
<div class="toolbar">
<label class="field field--inline">
<span>Источник</span>
<select id="source-select"></select>
</label>
<label class="field field--inline">
<span>Стиль</span>
<select id="style-select"></select>
</label>
<span class="pill" id="status-pill">—</span>
<button type="button" class="btn btn--accent" id="sync-button">Синхронизировать</button>
<a class="link" href="/swagger" target="_blank" rel="noreferrer">OpenAPI</a>
</div>
</header>
<form class="hud hud--goto" id="goto-form">
<div class="goto__head">
<div>
<h1>Камера</h1>
<p class="coords" id="live-coords">—</p>
</div>
</div>
<div class="presets" role="group" aria-label="Быстрый переход">
<button type="button" class="preset" data-lat="55.7558" data-lon="37.6173" data-zoom="14">Москва</button>
<button type="button" class="preset" data-lat="54.2255" data-lon="38.4692" data-zoom="6">ЦФО</button>
<button type="button" class="preset preset--ghost" id="fit-bounds">По выгрузке</button>
</div>
<div class="goto__row">
<label class="field">
<span>Широта</span>
<input id="goto-lat" name="lat" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="55.7558" required />
</label>
<label class="field">
<span>Долгота</span>
<input id="goto-lon" name="lon" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="37.6173" required />
</label>
<label class="field field--zoom">
<span>Зум</span>
<input id="goto-zoom" name="zoom" inputmode="decimal" autocomplete="off" spellcheck="false" placeholder="14" required />
</label>
</div>
<button type="submit" class="btn btn--accent btn--block">Перейти</button>
<p class="error" id="goto-error" hidden></p>
</form>
<aside class="hud hud--status" id="worker-panel">
<details>
<summary>
<span class="worker__pulse" aria-hidden="true"></span>
Воркер
</summary>
<dl>
<div><dt>Задача</dt><dd id="sync-running">—</dd></div>
<div><dt>Источник</dt><dd id="source-state">—</dd></div>
<div><dt>Скачан</dt><dd id="source-downloaded">—</dd></div>
<div><dt>Собран</dt><dd id="source-built">—</dd></div>
<div><dt>Следующий запуск</dt><dd id="sync-next">—</dd></div>
</dl>
<p class="error" id="sync-error" hidden></p>
</details>
</aside>
<div id="banner" class="banner" hidden></div>
<script src="https://unpkg.com/maplibre-gl@5.6.1/dist/maplibre-gl.js"></script>
<script src="~/js/demo.js" asp-append-version="true"></script>
</body>
</html>
@@ -0,0 +1 @@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Information"
}
}
}
+64
View File
@@ -0,0 +1,64 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"Accounts": "Host=127.0.0.1;Port=5433;Database=tile_server;Username=tiles;Password=tiles"
},
"TileServer": {
"DataDirectory": "../../data",
"JavaPath": "java",
"JvmMaxHeap": "8g",
"PlanetilerJarUrl": "https://github.com/openmaptiles/planetiler-openmaptiles/releases/download/v3.16/planetiler-openmaptiles.jar",
"PlanetilerHttpTimeout": "15m",
"DownloadStallTimeoutSeconds": 45,
"NaturalEarthUrls": [
"https://naciscdn.org/naturalearth/packages/natural_earth_vector.sqlite.zip",
"https://naturalearth.s3.amazonaws.com/packages/natural_earth_vector.sqlite.zip"
],
"LakeCenterlinesUrls": [
"https://github.com/acalcutt/osm-lakelines/releases/download/latest/lake_centerline.shp.zip",
"https://acalcutt.github.io/osm-lakelines/lake_centerline.shp.zip"
],
"WaterPolygonsUrls": [
"https://osmdata.openstreetmap.de/download/water-polygons-split-3857.zip"
],
"DefaultMinZoom": 0,
"DefaultMaxZoom": 15,
"OverzoomMaxZoom": 18,
"GlyphsUrl": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf",
"Yandex": {
"RedirectUri": "https://tile-server.ru"
},
"Sync": {
"Cron": "0 3 * * *",
"TimeZone": "UTC",
"RunOnStartup": true,
"RebuildIfTilesMissing": true
},
"Extracts": [
{
"Id": "central-fed-district",
"Name": "Центральный федеральный округ",
"Url": "https://download.geofabrik.de/russia/central-fed-district-latest.osm.pbf",
"Center": [ 37.6173, 55.7558 ],
"MinZoom": 0,
"MaxZoom": 15,
"Enabled": true
},
{
"Id": "volga-fed-district",
"Name": "Волго-Вятский федеральный округ",
"Url": "https://download.geofabrik.de/russia/volga-fed-district-latest.osm.pbf",
"Center": [ 45.0000, 57.0000 ],
"MinZoom": 0,
"MaxZoom": 15,
"Enabled": true
}
]
}
}
+469
View File
@@ -0,0 +1,469 @@
: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, #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--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;
}
}
+323
View File
@@ -0,0 +1,323 @@
const sourceSelect = document.getElementById("source-select");
const styleSelect = document.getElementById("style-select");
const syncButton = document.getElementById("sync-button");
const gotoForm = document.getElementById("goto-form");
const gotoError = document.getElementById("goto-error");
const latInput = document.getElementById("goto-lat");
const lonInput = document.getElementById("goto-lon");
const zoomInput = document.getElementById("goto-zoom");
const liveCoords = document.getElementById("live-coords");
const banner = document.getElementById("banner");
const statusPill = document.getElementById("status-pill");
let map;
let sources = [];
let styles = [];
let activeStyleKey = null;
let suppressGotoSync = false;
function formatStatus(value) {
const labels = {
Pending: "ожидание",
Downloading: "скачивание PBF",
PreparingSources: "данные Planetiler",
Building: "сборка тайлов",
Ready: "готово",
Failed: "ошибка"
};
return labels[value] ?? value ?? "—";
}
function formatDate(value) {
if (!value) {
return "нет";
}
return new Date(value).toLocaleString("ru-RU");
}
function showBanner(text, visible) {
banner.hidden = !visible;
banner.textContent = text ?? "";
}
function showGotoError(text) {
gotoError.hidden = !text;
gotoError.textContent = text ?? "";
}
function parseNumber(value) {
const parsed = Number(String(value).trim().replace(",", "."));
return Number.isFinite(parsed) ? parsed : null;
}
function cameraFromHash() {
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 };
}
function isGotoInputFocused() {
const el = document.activeElement;
return el === latInput || el === lonInput || el === zoomInput;
}
function isUninitializedCamera(center, zoom) {
return zoom < 2 && Math.abs(center.lat) < 1 && Math.abs(center.lng) < 1;
}
async function fetchJson(url, options) {
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 null;
}
return response.json();
}
function currentSource() {
return sources.find((item) => item.id === sourceSelect.value) ?? sources[0];
}
function updateHud() {
if (!map) {
return;
}
const center = map.getCenter();
const zoom = map.getZoom();
liveCoords.innerHTML = `${center.lat.toFixed(5)}°<span class="coords__sep">·</span>${center.lng.toFixed(5)}°<span class="coords__sep">z</span>${zoom.toFixed(2)}`;
}
function syncGotoFromMap() {
if (!map || suppressGotoSync || isGotoInputFocused()) {
return;
}
const center = map.getCenter();
const zoom = map.getZoom();
if (isUninitializedCamera(center, zoom)) {
return;
}
latInput.value = center.lat.toFixed(5);
lonInput.value = center.lng.toFixed(5);
zoomInput.value = zoom.toFixed(2);
}
function flyTo(lat, lon, zoom) {
showGotoError("");
suppressGotoSync = true;
map.flyTo({ center: [lon, lat], zoom });
map.once("moveend", () => {
suppressGotoSync = false;
updateHud();
syncGotoFromMap();
});
}
function readGoto() {
const lat = parseNumber(latInput.value);
const lon = parseNumber(lonInput.value);
const zoom = parseNumber(zoomInput.value);
const maxZoom = map.getMaxZoom();
if (lat === null || lat < -90 || lat > 90) {
return { error: "Широта: число от -90 до 90." };
}
if (lon === null || lon < -180 || lon > 180) {
return { error: "Долгота: число от -180 до 180." };
}
if (zoom === null || zoom < 0 || zoom > maxZoom) {
return { error: `Зум: число от 0 до ${maxZoom}.` };
}
return { lat, lon, zoom };
}
function bindMapEvents() {
map.on("move", updateHud);
map.on("moveend", syncGotoFromMap);
map.on("load", () => {
map.resize();
updateHud();
syncGotoFromMap();
});
map.on("error", (event) => {
const message = event?.error?.message;
if (message) {
showBanner(message, true);
}
});
}
async function loadStyle() {
const source = currentSource();
const styleName = styleSelect.value;
if (!source || !styleName) {
return;
}
const styleKey = `${source.id}|${styleName}|${source.status}|${source.builtAt ?? ""}`;
if (map && activeStyleKey === styleKey) {
return;
}
const style = await fetchJson(`/api/v1/styles/${encodeURIComponent(styleName)}?source=${encodeURIComponent(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 (!map) {
map = new maplibregl.Map({
container: "map",
style,
center,
zoom,
maxZoom: 18,
hash: true,
attributionControl: true,
transformRequest: (url) => ({
url: new URL(url, window.location.origin).href
})
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
map.addControl(new maplibregl.ScaleControl(), "bottom-right");
bindMapEvents();
} else {
map.setStyle(style, { diff: false });
}
activeStyleKey = styleKey;
const ready = source.status === "Ready";
showBanner(
ready
? ""
: `Тайлы источника «${source.name}» ещё не готовы (${formatStatus(source.status)}). Первый прогон может занять часы.`,
!ready
);
}
function renderStatus() {
const source = currentSource();
const status = source?.status;
document.getElementById("source-state").textContent = formatStatus(status);
document.getElementById("source-downloaded").textContent = formatDate(source?.downloadedAt);
document.getElementById("source-built").textContent = formatDate(source?.builtAt);
statusPill.textContent = formatStatus(status);
statusPill.className = "pill";
if (status === "Ready") {
statusPill.classList.add("pill--ok");
} else if (status === "Failed") {
statusPill.classList.add("pill--fail");
} else {
statusPill.classList.add("pill--work");
}
}
async function refresh() {
const [sourceList, styleList, sync] = await Promise.all([
fetchJson("/api/v1/sources"),
fetchJson("/api/v1/styles"),
fetchJson("/api/v1/sync/status")
]);
sources = sourceList;
styles = styleList;
const selectedSource = sourceSelect.value;
const selectedStyle = styleSelect.value;
sourceSelect.innerHTML = sources.map((item) => `<option value="${item.id}">${item.name}</option>`).join("");
styleSelect.innerHTML = styles.map((item) => `<option value="${item.name}">${item.name}</option>`).join("");
if (sources.some((item) => item.id === selectedSource)) {
sourceSelect.value = selectedSource;
}
if (styles.some((item) => item.name === selectedStyle)) {
styleSelect.value = selectedStyle;
} else if (styles.some((item) => item.name === "osm-bright")) {
styleSelect.value = "osm-bright";
}
document.getElementById("sync-running").textContent = sync.isRunning ? "идёт синхронизация" : "ожидание";
document.getElementById("sync-next").textContent = formatDate(sync.nextScheduledAt);
const error = document.getElementById("sync-error");
error.hidden = !sync.lastError;
error.textContent = sync.lastError ?? "";
const workerPanel = document.getElementById("worker-panel");
workerPanel.classList.toggle("is-running", Boolean(sync.isRunning));
workerPanel.classList.toggle("is-ready", !sync.isRunning && currentSource()?.status === "Ready");
renderStatus();
await loadStyle();
}
sourceSelect.addEventListener("change", async () => {
renderStatus();
await loadStyle();
const source = currentSource();
if (map && source?.center) {
flyTo(source.center[1], source.center[0], Math.max(map.getZoom(), 6));
}
});
styleSelect.addEventListener("change", () => loadStyle());
gotoForm.addEventListener("submit", (event) => {
event.preventDefault();
if (!map) {
showGotoError("Карта ещё не загружена.");
return;
}
const result = readGoto();
if (result.error) {
showGotoError(result.error);
return;
}
flyTo(result.lat, result.lon, result.zoom);
});
document.querySelectorAll(".preset").forEach((button) => {
button.addEventListener("click", () => {
if (!map) {
return;
}
flyTo(Number(button.dataset.lat), Number(button.dataset.lon), Number(button.dataset.zoom));
});
});
document.getElementById("fit-bounds").addEventListener("click", () => {
const bounds = currentSource()?.bounds;
if (!map || !bounds) {
showGotoError("У источника ещё нет bounds.");
return;
}
map.fitBounds(
[
[bounds.minLon, bounds.minLat],
[bounds.maxLon, bounds.maxLat]
],
{ padding: 48, maxZoom: 10 }
);
});
syncButton.addEventListener("click", async () => {
const source = currentSource();
syncButton.disabled = true;
try {
const url = source ? `/api/v1/sync/${encodeURIComponent(source.id)}` : "/api/v1/sync";
await fetchJson(url, { method: "POST" });
await refresh();
} catch (error) {
showBanner(error.message, true);
} finally {
syncButton.disabled = false;
}
});
refresh().catch((error) => showBanner(error.message, true));
setInterval(() => {
refresh().catch(() => undefined);
}, 15000);