Библиотека для создания интерактивных 3D географических приложений сообщества людей, объединенных стремлением сделать мир добрее и гармоничнее.
- 3D глобус сообщества - Трехмерная визуализация планеты с интерактивными точками участников
- Настройки глобуса - Полная настройка параметров глобуса (размеры, освещение, атмосфера, облака)
- Динамическое управление - Включение/выключение атмосферы и облаков в реальном времени
- Множественные глобусы - Поддержка нескольких независимых 3D глобусов на одной странице
- Именованные контейнеры гео-данных - Любые точки (
GeoPoint: офисы, события, датчики, участники) в именованных наборах для разных глобусов и карт, в памяти или в БД - Управление состоянием - Централизованное управление состоянием всех глобусов
- Загрузка/сохранение данных - Удобные интерфейсы для инициализации и сохранения данных из JSON и других источников
- In-Memory репозиторий - Простое хранение данных участников в памяти
- Модульная архитектура - Четкое разделение ответственности между компонентами
- Адаптивный дизайн - Адаптация под различные размеры экрана и устройства
dotnet add package ZealousMindedPeopleGeoВ репозитории есть хост hosts/ZealousMindedPeopleGeo.Showcase, чтобы посмотреть
компоненты без боевого сайта. Библиотека подключена к нему ссылкой на проект,
поэтому правки компонентов, CSS и JS видны после пересборки.
dotnet watch --project hosts/ZealousMindedPeopleGeo.Showcasehttp://localhost:5290 — обзор, /map — 2D карта с переключением проекции,
центрального меридиана и набора данных, /globe — 3D глобус, /showcase — все
компоненты на одной странице.
Хост настроен так, как достаточно любому сайту: ссылка на библиотеку и
AddZealousMindedPeopleGeo() в Program.cs, в Components/App.razor подключений
библиотеки нет (см. «Встраивание в проект»).
Два набора: .NET-тесты библиотеки и Node-тесты JavaScript, CSS и разметки.
dotnet test tests/ZealousMindedPeopleGeo.Tests
node --test experiments/*.test.mjsnode --test experiments/ без маски на Node 22 не работает: Node принимает папку за модуль.
Что проверяют .NET-тесты (tests/ZealousMindedPeopleGeo.Tests):
GeoData/— общий контракт хранилищ гео-данных. Одни и те же тесты прогоняются на хранилище в памяти и в БД (SQLite in-memory), поэтому реализации не расходятся в результатах операций и событияхOnDataChanged;Components/— Razor-компоненты через bUnit: какие параметры уходят в JavaScript и что видит пользователь;Validation/— правилаParticipantValidatorи связанных валидаторов;Models/— демонстрационные наборы, в том числе что они проходят валидацию;Services/— регистрация сервисов иPwaService.
Покрытие кода (отчёт Cobertura появится в tests/ZealousMindedPeopleGeo.Tests/TestResults/):
dotnet test tests/ZealousMindedPeopleGeo.Tests --collect:"XPlat Code Coverage"Достаточно двух шагов.
-
Установите пакет
dotnet add package ZealousMindedPeopleGeo
-
Зарегистрируйте сервисы в
Program.csbuilder.Services.AddZealousMindedPeopleGeo();
Этого хватает всем компонентам. Варианты с настройками регистрируют тот же набор сервисов:
// секция "ZealousMindedPeopleGeo" в appsettings.json
builder.Services.AddZealousMindedPeopleGeo(builder.Configuration);
// настройки в коде
builder.Services.AddZealousMindedPeopleGeo(options => options.EnableGeocoding = false);Какие настройки есть и когда они нужны — в разделе «Конфигурация».
Участники хранятся в памяти, пока в настройках не указан GoogleSheetId, тогда — в Google
Sheets. Свой IParticipantRepository или хранилище гео-данных в БД (AddGeoDataDatabase)
можно зарегистрировать до или после: библиотека их не перезапишет, а повторный вызов
AddZealousMindedPeopleGeo ничего не дублирует.
Компоненты интерактивные, поэтому приложению нужен интерактивный рендеринг Blazor
(AddInteractiveServerComponents() и @rendermode="InteractiveServer", как в шаблоне
Blazor Web App).
- Стили (
zealous-geo.css). Blazor сам загружает JS-инициализатор библиотекиZealousMindedPeopleGeo.lib.module.js, и тот добавляет стили в<head>после Bootstrap и до стилей приложения. Чтобы стили были уже при первой отрисовке, без мелькания, их можно подключить и вручную, второй раз они не добавятся:<link rel="stylesheet" href="_content/ZealousMindedPeopleGeo/css/zealous-geo.css" />
- Скрипты карты, глобуса и PWA: компоненты загружают их модулями.
- Манифест PWA, цвет темы и иконку добавляет
PwaManagerComponent, если у приложения нет своего манифеста.
- Bootstrap 5. Компоненты размечены его классами и с ним выглядят как задумано; без него остаются рабочими, но оформлены проще.
- Офлайн-кэш. Сервис-воркер библиотеки регистрируется, только если сервер отдаёт
sw.jsс заголовкомService-Worker-Allowed(см. «PWA и сервис-воркер»). Без заголовка сайт и установка приложения работают.
LibraryShowcaseComponent — единый компонент, который демонстрирует всю
функциональность библиотеки на одной странице: два независимых 3D глобуса, 2D карту,
панели управления и настроек, форму регистрации участника, PWA-менеджер и экспорт данных.
Каждое представление настроено собственным изолированным набором демонстрационных данных (именованный контейнер гео-данных или явный список участников), поэтому изменения в одном инстансе не влияют на другие.
@using ZealousMindedPeopleGeo.Components
<PageTitle>Возможности библиотеки</PageTitle>
<LibraryShowcaseComponent />Готовые наборы данных доступны через DemoDataSets (RussianCities, WorldCapitals,
TechHubs) — каждый вызов возвращает свежую независимую копию участников.
Изоляция обеспечивается двумя новыми параметрами:
CommunityGlobeViewer.DataContainerId— загружает участников из указанного именованного контейнера вместо общего репозитория;CommunityMapComponent.Participants— отображает явный список участников без обращения к общему репозиторию.
<!-- Два глобуса с независимыми данными -->
<CommunityGlobeViewer GlobeId="globe-a" DataContainerId="data-a" Width="600" Height="400" />
<CommunityGlobeViewer GlobeId="globe-b" DataContainerId="data-b" Width="600" Height="400" />
<!-- Карта с явным набором участников -->
<CommunityMapComponent MapId="map-c" Participants="@myParticipants" />CommunityMapComponent рисует карту в равновеликой проекции
Equal Earth: площади материков
сохраняются, а карта выглядит привычно. При zoom = 1 мир целиком вписан в окно;
при приближении карту можно двигать, пока её край не упрётся в край окна.
Приближают карту колесом мыши, кнопками «+» и «−», а на телефоне и тачпаде — щипком двумя пальцами: место между пальцами остаётся под ними, и двумя же пальцами карту можно двигать. Страница при этом не масштабируется.
Как опция доступна прежняя равнопромежуточная проекция (Equirectangular) —
прямоугольная карта, которая бесконечно прокручивается по горизонтали.
Центральный меридиан задаётся в градусах: 0 — Гринвич, 150 — Тихий океан в центре.
@using ZealousMindedPeopleGeo.Models
<!-- Equal Earth с Тихим океаном в центре -->
<CommunityMapComponent MapId="map-pacific" CentralMeridian="150" />
<!-- Прежняя прямоугольная проекция -->
<CommunityMapComponent MapId="map-flat" Projection="MapProjection.Equirectangular" />
<!-- Начальный вид: весь мир -->
<CommunityMapComponent MapId="map-world" Zoom="1" />Начальный вид задают параметры Zoom, CenterLatitude и CenterLongitude; без них
центр по долготе совпадает с центральным меридианом.
Список точек стоит справа от карты, а в узком контейнере (до 640 px) — под ней, и
карту не перекрывает. Кнопка в шапке («Members», подпись задаёт ListTitle) скрывает и
показывает его, карта при этом занимает освободившееся место. ParticipantsListOpen="false"
открывает карту со свёрнутым списком, ShowParticipantsList="false" убирает список и
кнопку совсем.
Карта показывает не только участников, а любые точки (GeoPoint, см. «Именованные
контейнеры гео-данных»). Источник — первый заданный из Points, Participants,
DataContainerId, иначе общий репозиторий участников.
<CommunityMapComponent MapId="offices-map"
Points="_offices"
Title="Офисы"
ListTitle="Офисы"
OnPointClick="OpenOffice" />
<!-- Точки из контейнера: карта сама обновится, когда в нём что-то изменится -->
<CommunityMapComponent MapId="events-map" DataContainerId="events" />Новые Points или Participants карта принимает без пересоздания.
- Цвета. Маркер красится собственным
Colorточки, иначе цветом её категории. Цвета категорий можно задать словарёмCategoryColors, иначе первые три категории в порядке появления получают синий, оранжевый и бирюзовый. Только три цвета одновременно на тёмной карте остаются различимыми попарно для людей с любым цветовосприятием. Остальные категории и точки без категории — серые. Если категорий нет совсем, все маркеры фирменного бирюзового цвета. Те же цвета у глобуса (GeoPointPalette— одна логика на оба). - Легенда. Над картой, если у точек есть категории, с числом точек в каждой.
Щелчок по категории скрывает и показывает её точки на карте и в списке, цвета
остальных при этом не меняются.
ShowLegend="false"убирает легенду. - Маркер. Внутри — короткая
Icon(символ, эмодзи) или первая букваTitle. - Карточка. Открывается по клику на маркер или строку списка. Встроенная показывает
категорию, описание, свойства, координаты и ссылку
Url(толькоhttp,httpsиmailto). Свою разметку задаётPointTemplate:
<CommunityMapComponent Points="_offices">
<PointTemplate Context="point">
<h5>@point.Title</h5>
<p>Сотрудников: @point.Properties.GetValueOrDefault("staff")</p>
</PointTemplate>
</CommunityMapComponent>OnPointClick получает копию точки, OnMarkerClick — её же в виде участника.
Маркеры, которые на текущем масштабе налезли бы друг на друга, сливаются в значок с числом точек. Кольцо значка поделено между цветами его точек, а подсказка перечисляет первые восемь заголовков. Клик по значку приближает карту ровно настолько, чтобы группа распалась. Точки с одинаковыми или почти одинаковыми координатами не разойдутся и на наибольшем приближении: клик раскладывает их веером вокруг значка, клик мимо сворачивает веер. При перетаскивании группы не пересчитываются и не прыгают.
<!-- Сливать маркеры, центры которых ближе 60 пикселей -->
<CommunityMapComponent Points="_offices" ClusterRadius="60" />
<!-- Без групп -->
<CommunityMapComponent Points="_offices" ClusterPoints="false" />ClusterRadius — расстояние между центрами маркеров в пикселях (0–200). По
умолчанию 24: сливаются только маркеры, которые иначе соприкоснулись бы, и меньшее
значение этого не отменяет. Оба параметра меняются на лету, без пересоздания
карты. Точка, на которую сфокусирована карта (focusOnParticipant), в группу не
прячется.
Значения по умолчанию для всех карт задаются в ZealousMindedPeopleGeoOptions.Map.
Центр и зум стоит указать явно: у MapConfiguration они по умолчанию равны
Москве и DefaultZoom = 10.
builder.Services.Configure<ZealousMindedPeopleGeoOptions>(options =>
{
options.Map = new MapConfiguration
{
Projection = MapProjection.EqualEarth,
CentralMeridian = 150,
DefaultLatitude = 20,
DefaultLongitude = 150,
DefaultZoom = 1
};
});Без Blazor карта инициализируется напрямую из JavaScript:
initializeCommunityMap('', 20, 0, 1, 'map', { projection: 'equalEarth', centralMeridian: 150, clusterRadius: 40 });
loadPointsOnMap([{ id: 'berlin', latitude: 52.52, longitude: 13.405, title: 'Berlin', color: '#3987e5' }], 'map');
setCommunityMapClustering({ clustering: false }, 'map');loadPointsOnMap принимает и GeoJSON (FeatureCollection или Feature) по тем же
правилам, что GeoPointGeoJson.Read на сервере. loadParticipantsOnMap по-прежнему
принимает участников в прежнем формате.
@using ZealousMindedPeopleGeo.Components
<PageTitle>3D глобус сообщества</PageTitle>
<CommunityGlobeComponent
Width="800"
Height="600"
CurrentLatitude="@CurrentLatitude"
CurrentLongitude="@CurrentLongitude"
ShowControls="true"
ShowParticipantManagement="true" />
@code {
private double? CurrentLatitude = 55.7558;
private double? CurrentLongitude = 37.6176;
}Глобус читает точки контейнера и красит маркеры по категориям теми же цветами, что и 2D-карта. Если у точек нет ни категорий, ни своих цветов, маркеры остаются цвета из настроек глобуса.
<CommunityGlobeViewer GlobeId="offices" DataContainerId="offices"
CategoryColors="@(new Dictionary<string, string> { ["hq"] = "#ffcf5a" })" />Подписи точек на глобусе не налезают друг на друга. Подпись встаёт над маркером, а если
там занято — под ним или сбоку; если места нет нигде, плавно гаснет и появляется снова,
когда место освободится. Подпись маркера под курсором показывается всегда, подписи на
видимой стороне Земли важнее подписей у горизонта и за ним. Уже стоящая подпись держит
своё место, поэтому при вращении подписи не прыгают. Включено по умолчанию; выключить
или изменить зазор между подписями можно опциями JS-глобуса
participantLabelCollisionAvoidance (true) и participantLabelCollisionPadding
(в пикселях, 4).
@using ZealousMindedPeopleGeo.Components
<PageTitle>Множественные 3D глобусы</PageTitle>
<div style="display: flex; gap: 20px;">
<div>
<h4>Глобус Европы</h4>
<CommunityGlobeComponent
GlobeId="europe"
Width="400"
Height="300"
ShowControls="true" />
</div>
<div>
<h4>Глобус Азии</h4>
<CommunityGlobeComponent
GlobeId="asia"
Width="400"
Height="300"
ShowControls="true" />
</div>
</div>@using ZealousMindedPeopleGeo.Components
<PageTitle>Кастомная компоновка</PageTitle>
<div style="display: flex; flex-direction: column; gap: 20px;">
<!-- Компонент отображения глобуса -->
<CommunityGlobeViewer GlobeId="main" Width="800" Height="600" />
<!-- Панель управления -->
<CommunityGlobeControls
GlobeId="main"
CurrentLatitude="@CurrentLatitude"
CurrentLongitude="@CurrentLongitude" />
<!-- Панель управления участниками -->
<CommunityGlobeParticipantManager GlobeId="main" />
<!-- Панель настроек глобуса -->
<CommunityGlobeSettings GlobeId="main" />
</div>
@code {
private double? CurrentLatitude = 55.7558;
private double? CurrentLongitude = 37.6176;
}@using ZealousMindedPeopleGeo.Components
<PageTitle>Настройки 3D глобуса</PageTitle>
<!-- Глобус с панелью настроек -->
<CommunityGlobeComponent
GlobeId="configurable"
Width="800"
Height="600"
ShowSettings="true" />Настройки необязательны: без них работают глобус, карта, контейнеры гео-данных и PWA, участники хранятся в памяти. Ключ Google нужен только для поиска координат по адресу, таблица Google — только чтобы хранить участников в ней.
Библиотека читает секцию ZealousMindedPeopleGeo, если сервисы зарегистрированы с
конфигурацией:
builder.Services.AddZealousMindedPeopleGeo(builder.Configuration);{
"ZealousMindedPeopleGeo": {
"GoogleMapsApiKey": "",
"EnableGeocoding": true,
"GoogleSheetId": "",
"Map": {
"Projection": "EqualEarth",
"CentralMeridian": 0,
"DefaultLatitude": 20,
"DefaultLongitude": 0,
"DefaultZoom": 1
}
}
}| Ключ | По умолчанию | Что делает |
|---|---|---|
GoogleMapsApiKey |
пусто | Ключ Google Maps Platform с включённым Geocoding API. По нему GeoDataParticipantForm и IParticipantService.RegisterParticipantAsync находят координаты адреса. Без ключа форма сообщает, что ключ не настроен, и точку не добавляет. Карта и глобус ключ не используют. |
EnableGeocoding |
true |
false выключает запросы к Google, даже если ключ задан. |
GoogleSheetId |
пусто | ID таблицы Google — часть адреса между /d/ и /edit. Если задан, участники (IParticipantRepository) хранятся в этой таблице, иначе в памяти. |
GoogleServiceAccountKey |
пусто | Содержимое JSON-ключа сервисного аккаунта Google (сам JSON, не путь к файлу). Обязателен вместе с GoogleSheetId. |
Map:Projection |
EqualEarth |
Проекция 2D-карты: EqualEarth или Equirectangular. |
Map:CentralMeridian |
0 |
Центральный меридиан 2D-карты в градусах, от −180 до 180. |
Map:DefaultLatitude, Map:DefaultLongitude, Map:DefaultZoom |
см. ниже | Начальный вид 2D-карты; DefaultZoom = 1 — мир целиком. |
Map:ClusterPoints |
true |
Сливать близкие маркеры 2D-карты в группы (см. «Группы близких точек»). |
Map:ClusterRadius |
24 |
Расстояние между центрами маркеров в пикселях (0–200), ближе которого они сливаются. |
Без секции Map карта открывается на широте 20 и центральном меридиане с зумом 2. Если
секция Map задана, укажите в ней центр и зум явно: иначе действуют значения класса
MapConfiguration — Москва и DefaultZoom = 10. Параметры CommunityMapComponent
(Projection, CentralMeridian, Zoom, CenterLatitude, CenterLongitude,
ClusterPoints, ClusterRadius) важнее настроек.
Глобус настраивается параметрами компонентов и панелью настроек (см. «Настройки глобуса»), длительность кэша — в коде (см. «Кэширование»).
Не храните ключ Google и JSON сервисного аккаунта в appsettings.json в репозитории.
Для разработки подойдут user secrets, на сервере — переменные окружения (двоеточие в
имени ключа заменяется на __):
dotnet user-secrets init
dotnet user-secrets set "ZealousMindedPeopleGeo:GoogleMapsApiKey" "<ключ>"
dotnet user-secrets set "ZealousMindedPeopleGeo:GoogleServiceAccountKey" "$(cat service-account.json)"
export ZealousMindedPeopleGeo__GoogleMapsApiKey="<ключ>"Ключ используется только на сервере: компоненты не передают его в браузер. В Blazor WebAssembly настройки загружает сам браузер, поэтому ключ из них виден пользователям.
- Создайте сервисный аккаунт в Google Cloud, включите для проекта Google Sheets API и скачайте JSON-ключ аккаунта.
- Откройте таблицу на редактирование для адреса аккаунта — поле
client_emailв JSON. - Задайте
GoogleSheetIdиGoogleServiceAccountKey.
Участники пишутся на лист Sheet1, столбцы A–I: время регистрации, имя, адрес, широта,
долгота, город, страна, соцсети, сообщение. В русской локали Google называет первый лист
«Лист1» — переименуйте его в Sheet1. Первая строка — заголовки, данные читаются со
второй. Заголовки записывает IParticipantService.InitializeStorageAsync(); без его
вызова оставьте первую строку под заголовки сами. Таблица поддерживает только добавление
и чтение: изменение и удаление участников возвращают ошибку.
Офлайн-кэш необязателен. PwaManagerComponent регистрирует сервис-воркер библиотеки
/_content/ZealousMindedPeopleGeo/sw.js со scope приложения (/ или <base href>), иначе он
не обслуживал бы страницы. Браузер разрешает такой scope, только если сервер отдаёт sw.js
с заголовком Service-Worker-Allowed; библиотека проверяет заголовок заранее и без него
воркер не регистрирует. Чтобы включить офлайн-кэш, добавьте в Program.cs до
UseStaticFiles/MapStaticAssets:
app.Use(async (context, next) =>
{
if (context.Request.Path.Equals("/_content/ZealousMindedPeopleGeo/sw.js", StringComparison.OrdinalIgnoreCase))
{
context.Response.Headers["Service-Worker-Allowed"] = "/";
context.Response.Headers.CacheControl = "no-cache";
}
await next();
});Без заголовка в консоли браузера одно информационное сообщение, а сайт и установка приложения работают как обычно.
Браузер предлагает установить сайт как приложение без дополнительной настройки:
PwaManagerComponent добавляет в <head> манифест библиотеки, цвет темы и иконку для iOS,
если у приложения нет своего манифеста. Сервис-воркер для установки не нужен. Свой манифест
приложение подключает как обычно, и тогда библиотека его не трогает. Подключить манифест
библиотеки можно и вручную, чтобы он был на всех страницах, а не только там, где есть
PwaManagerComponent:
<meta name="theme-color" content="#0e1013" />
<link rel="manifest" href="_content/ZealousMindedPeopleGeo/manifest.json" />
<link rel="apple-touch-icon" href="_content/ZealousMindedPeopleGeo/icons/icon-192x192.png" />Манифест ссылается на иконки из wwwroot/icons/: icon-192x192.png и icon-512x512.png
(обычные), icon-maskable-512x512.png (для круглых и других масок Android) и
badge-72x72.png (значок уведомлений). PNG отрисованы из SVG-исходников в той же папке.
start_url и scope манифеста равны /, как и scope воркера. Ярлыки ведут на /map
и /globe; если в вашем приложении других маршрутов, подключите собственный манифест.
Что все картинки из манифеста, sw.js и PwaService существуют и совпадают по размеру,
проверяет node --test experiments/pwa-assets.test.mjs.
| Запросы | Стратегия |
|---|---|
| JS, CSS и данные библиотеки, скрипты и стили сайта | Network First: изменённый файл приходит при следующей загрузке страницы, кэш нужен только без сети |
| Картинки и шрифты библиотеки (текстуры Земли, иконки) | Cache First, обновляются со сменой версии кэша |
Страницы, /api/... и прочие GET-запросы |
Network First, без сети — из кэша |
Не-GET запросы, соединение Blazor Server (/_blazor) и SSE воркер не перехватывает.
При каждом запуске PwaService проверяет, не изменился ли sw.js. Новый воркер активируется сразу (skipWaiting), удаляет кэши прошлой версии и предлагает перезагрузить страницу.
Кэши называются zealous-geo-static-v<версия> и zealous-geo-dynamic-v<версия>, где версия — константа SW_VERSION в wwwroot/sw.js.
Правило: SW_VERSION равна <Version> в ZealousMindedPeopleGeo.csproj — поднимая версию пакета, поднимайте и её. Изменённый sw.js браузер ставит как новый воркер, а тот при активации удаляет все кэши zealous-geo-* других версий, в том числе закэшированные текстуры и иконки. Совпадение версий проверяет node --test experiments/service-worker-cache.test.mjs.
Основная модель участника сообщества:
public class Participant
{
public Guid Id { get; set; }
public string Name { get; set; } = "";
public string Email { get; set; } = ""; // необязателен, указанный виден всем на карте
public string Location { get; set; } = "";
public string? City { get; set; }
public string? Country { get; set; }
public double? Latitude { get; set; }
public double? Longitude { get; set; }
public string? Message { get; set; }
public string? LifeGoals { get; set; }
public string? Skills { get; set; }
public SocialContacts? SocialContacts { get; set; }
public DateTime RegisteredAt { get; set; } = DateTime.UtcNow;
}Настройки для 3D глобуса:
public class GlobeOptions
{
public int Width { get; set; } = 800;
public int Height { get; set; } = 600;
public string BackgroundColor { get; set; } = "#000011";
public bool AutoRotate { get; set; } = true;
public double AutoRotateSpeed { get; set; } = 0.5;
public bool EnableMouseControls { get; set; } = true;
public bool EnableZoom { get; set; } = true;
public double MinZoom { get; set; } = 1.03;
public double MaxZoom { get; set; } = 4.0;
public int LevelOfDetail { get; set; } = 2;
public bool SunLightFollowCamera { get; set; } = true;
public double SunLightDistance { get; set; } = 6.0;
public double SunLightIntensity { get; set; } = 2.8;
public double AmbientLightIntensity { get; set; } = 1.2;
public double HemisphereLightIntensity { get; set; } = 0.8;
}Основной сервис для работы с участниками:
public interface IParticipantService
{
Task<ServiceResult<Participant>> RegisterParticipantAsync(ParticipantRegistrationModel model);
Task<ServiceResult<IEnumerable<Participant>>> GetParticipantsAsync(int page = 1, int pageSize = 50);
Task<ServiceResult<Participant>> GetParticipantAsync(Guid id);
Task<ServiceResult> UpdateParticipantAsync(Guid id, Participant participant);
Task<ServiceResult> DeleteParticipantAsync(Guid id);
}Сервис геокодирования:
public interface IGeocodingService
{
Task<GeocodingResult> GeocodeAddressAsync(string address, string? language = null);
Task<ReverseGeocodingResult> ReverseGeocodeAsync(double latitude, double longitude, string? language = null);
Task<IEnumerable<GeocodingSuggestion>> GetSuggestionsAsync(string query, string? language = null);
}Сервис для управления 3D глобусом:
public interface IThreeJsGlobeService
{
Task<GlobeInitializationResult> InitializeGlobeAsync(string containerId, GlobeOptions options);
Task<ServiceResult> AddParticipantsAsync(IEnumerable<Participant> participants);
Task<ServiceResult> SetAutoRotationAsync(bool enabled);
Task<ServiceResult> CenterOnAsync(double latitude, double longitude);
Task<GlobeState> GetStateAsync();
Task DisposeAsync();
}Библиотека поддерживает создание нескольких независимых 3D глобусов на одной странице благодаря модульной архитектуре.
- ✅ Независимые экземпляры - каждый глобус работает автономно
- ✅ Изолированные ресурсы - отдельные сцены, камеры и рендереры
- ✅ Параллельные операции - одновременная работа с разными глобусами
- ✅ Гибкая конфигурация - разные настройки для каждого глобусa
- ✅ Оптимальная производительность - нет конфликтов между экземплярами
-
Добавьте в
Program.cs:builder.Services.AddZealousMindedPeopleGeo();
Стили и скрипты библиотека подключит сама.
-
Используйте компонент в Razor странице:
@page "/globe" @using ZealousMindedPeopleGeo.Components <h3>Интерактивный 3D глобус</h3> <CommunityGlobeComponent Width="800" Height="600" ShowControls="true" ShowParticipantManagement="true"> </CommunityGlobeComponent>
Комплексная валидация данных с использованием FluentValidation:
// Валидация участника
var result = await ValidationService.ValidateParticipantAsync(participant);
if (!result.IsValid)
{
var errors = result.Errors.Select(e => e.ErrorMessage);
// Обработка ошибок
}
// Валидация координат
var isValid = await ValidationService.AreCoordinatesValidAsync(latitude, longitude);
// Валидация адреса
var isValid = await ValidationService.IsAddressValidForGeocodingAsync(address);Контейнер — именованный набор точек: данные одного глобуса, одной карты или одного
контекста («офисы», «события», «датчики»). Точка (GeoPoint) — это что угодно с
координатами, не обязательно человек:
| Поле | Что это |
|---|---|
Id |
Строка до 128 символов: GUID, номер из вашей системы, moscow-office. По умолчанию — новый GUID |
Latitude, Longitude |
Координаты, −90…90 и −180…180 |
Title, Description |
Подпись и описание |
Category |
Группа точки: office, event, sensor |
Color, Icon, Url |
Цвет маркера (CSS), иконка, ссылка |
Properties |
Любые свои поля, Dictionary<string, string> |
Карта и глобус красят маркеры по категориям, карта показывает легенду и карточку точки (см. «Любые точки: категории, легенда, карточка»).
// В Program.cs
builder.Services.AddZealousMindedPeopleGeo(); // Регистрирует и контейнеры гео-данных
// или только контейнеры:
builder.Services.AddGeoDataContainers();@inject IGeoDataContainerManager ContainerManager
// Создание или получение контейнера
var container = ContainerManager.GetOrCreateContainer("europe-participants");
// Проверка существования
if (ContainerManager.ContainerExists("europe-participants"))
{
// Контейнер существует
}
// Получение списка всех контейнеров
var containerIds = ContainerManager.GetContainerIds();var offices = ContainerManager.GetOrCreateContainer("offices");
await offices.AddPointAsync(new GeoPoint
{
Id = "moscow",
Latitude = 55.7558,
Longitude = 37.6176,
Title = "Офис в Москве",
Category = "office",
Url = "https://example.com/offices/moscow",
Properties = { ["staff"] = "120", ["opened"] = "2019-04-01" }
});
// Массивом: null, точки с неверными координатами и повторы Id пропускаются
var result = await offices.AddPointsAsync(points);
Console.WriteLine($"Добавлено {result.ProcessedCount}, пропущено {result.SkippedCount}");
var all = await offices.GetPointsAsync(); // копии: их изменение не трогает контейнер
var moscow = await offices.GetPointAsync("moscow");
moscow!.Properties["staff"] = "130";
await offices.UpdatePointAsync(moscow); // заменяет точку целиком
await offices.RemovePointAsync("moscow");
// Заменить всё содержимое контейнера
await ContainerManager.LoadPointsAsync("offices", points);Точку с неверными координатами (вне диапазона, NaN), пустым или слишком длинным Id
контейнер не сохранит и вернёт ошибку в GeoDataOperationResult.ErrorMessage.
Участника (Participant) контейнер хранит как точку: имя становится Title, сообщение —
Description, остальные поля — Properties (ключи в ParticipantPointProperties). Код,
написанный для участников, работает как раньше:
// Добавление одного участника
var participant = new Participant
{
Name = "Иван Иванов",
Email = "ivan@example.com",
Address = "Москва, Россия",
Latitude = 55.7558,
Longitude = 37.6176
};
var result = await container.AddParticipantAsync(participant);
// Добавление нескольких участников
var participants = new List<Participant> { ... };
var result = await container.AddParticipantsAsync(participants);
// Заменить содержимое контейнера участниками
await ContainerManager.LoadDataAsync("europe-participants", participants);Участник без координат в контейнер не попадает. Точку, добавленную не как участник,
методы участников тоже видят: если её Id не GUID, участник получает GUID, вычисленный
из Id, и по нему же её можно найти, изменить или удалить. Перевести участника в точку и
обратно можно напрямую: participant.ToGeoPoint() и point.ToParticipant().
// Получение всех участников из контейнера
var allParticipants = await container.GetAllParticipantsAsync();
// Получение участника по ID
var participant = await container.GetParticipantByIdAsync(participantId);
// Получение количества участников
int count = container.Count;@inject IGeoDataContainerManager ContainerManager
// Загрузка из JSON файла
var result = await ContainerManager.LoadFromJsonFileAsync("my-container", "data/participants.json");
// Загрузка из JSON строки
var result = await ContainerManager.LoadFromJsonAsync("my-container", jsonContent);JSON — массив точек. Участники в прежнем формате тоже читаются, форматы можно смешивать:
элемент с полем title или properties — точка, остальные — участники.
[
{ "id": "berlin", "latitude": 52.52, "longitude": 13.405, "title": "Berlin",
"category": "office", "properties": { "staff": "40" } },
{ "name": "Иван", "email": "ivan@example.com", "latitude": 55.75, "longitude": 37.62 }
]Элементы без latitude и longitude пропускаются (SkippedCount): иначе точка молча
оказалась бы в (0, 0).
// Экспорт в JSON строку — массив точек; поля участников лежат в "properties"
var json = await ContainerManager.ExportToJsonAsync("my-container");
// Сохранение в файл
var result = await ContainerManager.SaveToJsonFileAsync("my-container", "data/export.json");Точки контейнера выгружаются в GeoJSON (RFC 7946),
а LoadFromJsonAsync и LoadFromJsonFileAsync принимают GeoJSON так же, как массив точек:
формат определяется по содержимому.
// FeatureCollection строкой и в файл
var geoJson = await ContainerManager.ExportToGeoJsonAsync("offices");
await ContainerManager.SaveToGeoJsonFileAsync("offices", "data/offices.geojson");
// Обратно — тем же методом, что и JSON
var result = await ContainerManager.LoadFromJsonFileAsync("offices", "data/offices.geojson");
// result.SkippedCount — линии, полигоны и объекты с неверными координатами
// Без контейнера
string text = GeoPointGeoJson.Write(points);
GeoJsonReadResult read = GeoPointGeoJson.Read(text); // read.Points, read.Skipped — причины пропусков{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"id": "berlin",
"geometry": { "type": "Point", "coordinates": [13.405, 52.52] },
"properties": { "title": "Berlin", "category": "office", "marker-color": "#3987e5", "staff": "40" }
}
]
}- Координаты в GeoJSON идут в порядке [долгота, широта]. Высота (третья координата) отбрасывается, а долгота за ±180° сворачивается в диапазон.
- Поля точки записываются в
propertiesпод именамиtitle,description,category,marker-color,iconиurl. Заголовок, описание и цвет названы по simplestyle-spec, поэтому geojson.io и подобные редакторы сразу показывают подписи и цвета маркеров. ОстальныеPropertiesлежат рядом как есть. - Чужие файлы:
nameподставляется в заголовок, еслиtitleнет;properties.idподставляется в идентификатор, если у объекта нетid; без того и другого точка получает новый GUID;- числа,
true/false, массивы и объекты в свойствах сохраняются строками с их JSON-записью.
- Геометрия.
MultiPointдаёт по точке на каждую позицию (id#1,id#2, …). Линии, полигоны, объекты без геометрии и точки, не прошедшиеGeoPoint.Validate(), пропускаются, и причина каждого пропуска есть вGeoJsonReadResult.Skipped— на языке пользователя (см. «Язык сообщений»). - Совпадение имён. Если имя свойства в
Propertiesсовпадает с полем точки (title,url…), оно записывается, только когда само поле пустое.
На странице /map витрины точки можно скачать в GeoJSON и загрузить свой файл.
@inject GlobeDataInitializer DataInitializer
// Инициализация глобуса с данными из контейнера
var result = await DataInitializer.InitializeGlobeWithDataAsync(
globeId: "europe",
htmlContainerId: "globe-europe",
dataContainerId: "europe-participants",
options: new GlobeOptions { Width = 800, Height = 600 }
);
// Добавление участника через форму с автоматическим отображением на глобусе
var addResult = await DataInitializer.AddParticipantToGlobeAsync(
globeId: "europe",
htmlContainerId: "globe-europe",
dataContainerId: "europe-participants",
participant: newParticipant
);@inject IGeoDataContainerManager ContainerManager
@inject IGlobeMediator GlobeMediator
// Загрузка данных из контейнера в глобус
var result = await ContainerManager.LoadToGlobeAsync(
containerId: "europe-participants",
globeMediator: GlobeMediator,
globeContainerId: "globe-europe"
);@using ZealousMindedPeopleGeo.Components
<GeoDataParticipantForm
DataContainerId="europe-participants"
GlobeId="europe"
Title="Add Point to Europe Map"
SubmitButtonText="Add to Map"
OnParticipantAdded="HandleParticipantAdded" />
@code {
private async Task HandleParticipantAdded(Participant participant)
{
Console.WriteLine($"Added: {participant.Name}");
}
}Обязательны имя и адрес. Email можно не указывать; указанный адрес виден всем в подсказке и карточке точки на карте, о чём форма предупреждает под полем.
Библиотека не ограничивает добавление точек: контейнеры, база и загрузка JSON принимают
сколько угодно точек — например, сервер может заполнить карту при запуске. Решать, кто и
как часто добавляет точки, должно приложение. Для формы на открытой странице для этого
есть OnSubmitting: форма вызывает его с готовым участником (адрес уже найден) перед
сохранением. null — сохранить, строка — не сохранять и показать её пользователю; поля
формы при отказе остаются заполненными.
<GeoDataParticipantForm DataContainerId="community" OnSubmitting="CheckAsync" />
@code {
[CascadingParameter] private Task<AuthenticationState>? Auth { get; set; }
private async Task<string?> CheckAsync(Participant participant)
{
var user = Auth is null ? null : (await Auth).User;
if (user?.Identity?.IsAuthenticated != true)
return "Войдите, чтобы добавить точку";
return Limiter.TryAdd(user.Identity.Name!) ? null : "Не больше 5 точек в час";
}
}Limiter здесь — ваш сервис; подойдёт и
System.Threading.RateLimiting.
Если точки приходят через ваш HTTP API, ограничивайте сам эндпоинт —
middleware ограничения частоты
ASP.NET Core.
@inject IGeoDataContainerManager ContainerManager
protected override void OnInitialized()
{
ContainerManager.OnDataChanged += HandleDataChanged;
}
private void HandleDataChanged(string containerId, GeoDataChangeType changeType)
{
Console.WriteLine($"Container '{containerId}' changed: {changeType}");
// GeoDataChangeType: Added, Updated, Removed, Cleared, BulkLoaded
}Помимо хранения в памяти, библиотека поддерживает постоянное хранение гео-данных в реляционной базе данных через Entity Framework Core. Реализация не зависит от конкретного провайдера БД — вы выбираете провайдер (SQLite, PostgreSQL, SQL Server и т. д.) при регистрации сервисов.
DatabaseGeoDataContainerManager реализует тот же интерфейс IGeoDataContainerManager, что и хранилище в памяти, поэтому весь код работы с контейнерами (добавление по одному, загрузка массивом, загрузка/выгрузка JSON, несколько глобусов, подписка на изменения) остаётся неизменным — меняется только способ регистрации.
// В Program.cs — выберите любой провайдер EF Core
builder.Services.AddGeoDataDatabase(options =>
options.UseSqlite("Data Source=geodata.db"));
// Пример для PostgreSQL:
// builder.Services.AddGeoDataDatabase(options =>
// options.UseNpgsql(builder.Configuration.GetConnectionString("GeoData")));
var app = builder.Build();
// Создание схемы БД при старте (для разработки/демо).
// В продакшене используйте миграции EF Core.
await app.Services.EnsureGeoDataDatabaseCreatedAsync();Для управления схемой в продакшене подключите миграции EF Core к контексту GeoDataDbContext вместо EnsureGeoDataDatabaseCreatedAsync.
Точки лежат в таблице GeoPoints: общие поля — в своих столбцах, Properties — одним
JSON в PropertiesJson, поэтому схема не меняется, какие бы поля ни хранило приложение.
EnsureGeoDataDatabaseCreatedAsync создаёт эту таблицу и в уже существующей БД, например
общей с таблицами приложения: сам EnsureCreated из EF такую БД не трогает.
Прежние версии хранили участников в таблице GeoDataParticipants. При первом
EnsureGeoDataDatabaseCreatedAsync библиотека в одной транзакции переносит их в
GeoPoints и переименовывает старую таблицу в GeoDataParticipants_Migrated. Удалённые
потом точки не вернутся. Строки без координат точкой не станут и остаются в
переименованной таблице. Если схемой управляют миграции EF Core, после их применения
вызовите перенос сами:
await using var context = await dbFactory.CreateDbContextAsync();
await GeoDataDatabaseInitializer.MigrateLegacyParticipantsAsync(context);Каждый именованный контейнер (containerId) соответствует отдельному глобусу или карте. Данные разных контейнеров изолированы друг от друга в одной таблице за счёт колонки ContainerId (составной ключ ContainerId + Id), поэтому одна и та же точка может присутствовать в разных контейнерах.
@inject IGeoDataContainerManager ContainerManager
// Данные сохраняются в БД и переживают перезапуск приложения
var europe = ContainerManager.GetOrCreateContainer("europe");
var asia = ContainerManager.GetOrCreateContainer("asia");
// Добавление по одному
await europe.AddParticipantAsync(new Participant { Name = "Берлин", Latitude = 52.52, Longitude = 13.405 });
// Загрузка массивом (дубликаты по Id пропускаются)
await asia.AddParticipantsAsync(new[]
{
new Participant { Name = "Токио", Latitude = 35.6762, Longitude = 139.6503 },
new Participant { Name = "Дели", Latitude = 28.6139, Longitude = 77.2090 }
});
// Список всех глобусов, для которых есть данные в БД
var globeIds = ContainerManager.GetContainerIds();API загрузки/выгрузки JSON идентичен хранилищу в памяти, но данные читаются и пишутся в БД:
// Загрузка массива точек или участников из JSON-строки прямо в БД
await ContainerManager.LoadFromJsonAsync("europe", jsonContent);
// Загрузка из файла
await ContainerManager.LoadFromJsonFileAsync("europe", "data/europe.json");
// Выгрузка содержимого глобуса из БД в JSON
var json = await ContainerManager.ExportToJsonAsync("europe");💡 Полный пример Blazor-страницы см. в
examples/GeoDataDatabaseExample.razor.
Интеллектуальное кэширование для оптимизации производительности:
// Получить или создать закешированные данные
var participants = await CachingService.GetOrCreateParticipantsAsync(
async cancellationToken => await ParticipantRepository.GetAllParticipantsAsync(),
cancellationToken);
// Специфичные настройки кэширования
var geocodingResult = await CachingService.GetOrCreateGeocodingResultAsync(
address,
async cancellationToken => await GeocodingService.GeocodeAddressAsync(address),
cancellationToken);Библиотека использует модульную архитектуру с четким разделением ответственности:
- CommunityGlobeComponent - Главный компонент-обертка
- CommunityGlobeViewer - Компонент отображения 3D глобуса
- CommunityGlobeControls - Панель управления глобуса
- CommunityGlobeParticipantManager - Панель управления участниками
- CommunityGlobeSettings - Панель настроек глобуса с JSON сохранением/загрузкой
- ThreeJsGlobeService - Управление 3D сценой и рендерингом
- GlobeMediatorService - Посредник между Blazor и JavaScript
- InMemoryParticipantRepository - Хранение данных участников в памяти
Библиотека предоставляет детальную информацию об ошибках:
var result = await ParticipantService.RegisterParticipantAsync(model);
if (result.Success)
{
var participant = result.Data;
}
else
{
var errorMessage = result.ErrorMessage;
var errorCode = result.ErrorCode;
}Причины пропуска при импорте (GeoJsonReadResult.Skipped, ErrorMessage загрузки JSON),
ошибки GeoPoint.Validate() и JsonException из GeoPointGeoJson.Read выходят на языке
CultureInfo.CurrentUICulture, а числа в них форматируются по CultureInfo.CurrentCulture.
Переводы есть на английский (он же для языков без перевода) и русский:
CurrentUICulture |
Сообщение |
|---|---|
ru, ru-RU |
Объект 1 («road»): геометрия LineString — не точка |
en и остальные |
Feature 1 ('road'): geometry LineString is not a point |
Язык выбирает приложение, например middleware локализации ASP.NET Core:
app.UseRequestLocalization(new RequestLocalizationOptions
{
// Язык браузера, если для него есть перевод, иначе русский. Формат чисел не меняется.
DefaultRequestCulture = new RequestCulture(CultureInfo.CurrentCulture, new CultureInfo("ru")),
SupportedCultures = new[] { CultureInfo.CurrentCulture },
SupportedUICultures = new[] { new CultureInfo("ru"), new CultureInfo("en") }
});Интерактивные компоненты Blazor Server работают через WebSocket, а в его запросе
заголовка Accept-Language может не быть. Поэтому выбранный при загрузке страницы язык
стоит запомнить в cookie, как это делает App.razor витрины. Подробности, в том числе
для WebAssembly, — в документации ASP.NET Core.
Новый язык — файл Resources/Messages.<код>.resx с теми же ключами, что в Messages.resx.
Тест MessagesTests сверяет ключи и подстановки {0} каждого перевода с английским, если
добавить код языка в его список Translations. Надписи самих компонентов (карты, формы,
глобуса) пока не переводятся.
Рекомендации по безопасности:
- API ключи - Храните ключи Google вне репозитория (см. «Конфигурация» → «Ключи и секреты»)
- Валидация - Всегда используйте встроенную валидацию данных
- Доступ и частота - Библиотека не ограничивает, кто и сколько точек добавляет; для формы на открытой странице задайте
OnSubmitting(см. «Кто может добавлять точки») - CORS - Настройте политику CORS для защиты от CSRF атак
- HTTPS - Используйте HTTPS для всех запросов
ZealousMindedPeopleGeo/
├── Components/ # Blazor компоненты
│ ├── CommunityGlobeComponent.razor # Главный компонент-обертка
│ ├── CommunityGlobeViewer.razor # Компонент отображения глобуса
│ ├── CommunityGlobeControls.razor # Панель управления
│ ├── CommunityGlobeParticipantManager.razor # Управление участниками
│ └── GeoDataParticipantForm.razor # Форма для добавления точек
├── Services/ # Бизнес-логика сервисы
│ ├── Mapping/ # Сервисы для работы с картами
│ │ ├── ThreeJsGlobeService.cs # Управление 3D сценой
│ │ ├── GlobeMediatorService.cs # Посредник Blazor-JavaScript
│ │ └── IGlobeMediator.cs # Интерфейс посредника
│ ├── GeoDataContainer/ # Именованные контейнеры данных
│ │ ├── IGeoDataContainer.cs # Интерфейс контейнера
│ │ ├── IGeoDataContainerManager.cs # Менеджер контейнеров
│ │ ├── InMemoryGeoDataContainer.cs # In-Memory реализация
│ │ ├── GeoDataContainerManager.cs # Реализация менеджера
│ │ ├── ParticipantGeoDataExtensions.cs # Методы участников поверх точек
│ │ ├── Persistence/ # Хранение в БД: таблица GeoPoints, перенос старой таблицы
│ │ └── GeoDataLoaderExtensions.cs # Расширения для загрузки
│ └── Repositories/ # Репозитории данных
│ └── InMemoryParticipantRepository.cs # In-Memory хранилище
├── Models/ # Модели данных
│ ├── GeoPoint.cs # Точка: координаты, подпись, категория, свои поля
│ ├── ParticipantGeoPointExtensions.cs # Участник ↔ точка
│ ├── Participant.cs # Модель участника
│ ├── GlobeOptions.cs # Настройки глобуса
│ └── GlobeState.cs # Состояние глобуса
├── Resources/ # Сообщения: Messages.resx (английский) и Messages.ru.resx
└── wwwroot/ # Статические ресурсы
├── ZealousMindedPeopleGeo.lib.module.js # JS-инициализатор: Blazor загружает его сам, он подключает стили
├── js/ # JavaScript модули
│ └── community-globe.js # Основной модуль глобуса
├── css/ # Стили
│ ├── zealous-geo.css # Все стили одним файлом, в <head> его добавляет инициализатор
│ ├── zealous-ui.css # Общие токены темной темы
│ └── community-globe.css и др. # Стили отдельных компонентов
└── assets/ # Ресурсы
└── earth/ # 8K текстуры Земли
Нужен .NET SDK 10.
dotnet build ZealousMindedPeopleGeo.csprojТесты и тестовая среда описаны в «Быстром старте».
Мы приветствуем вклад в развитие проекта! Пожалуйста, ознакомьтесь с руководством по вкладу:
- Fork проект
- Создайте feature branch (
git checkout -b feature/AmazingFeature) - Зафиксируйте изменения (
git commit -m 'Add some AmazingFeature') - Отправьте в branch (
git push origin feature/AmazingFeature) - Создайте Pull Request
- Three.js - Библиотека для 3D графики
- Google Maps API - Картографические сервисы
- OpenStreetMap - Бесплатные географические данные
- FluentValidation - Библиотека валидации
- ASP.NET Core - Платформа веб-разработки
"География людей, объединенных стремлением сделать мир добрее и гармоничнее"