with one click
shiny-spatial
// Build geospatial applications with Shiny.Spatial - a dependency-free, AOT-compatible .NET spatial database using SQLite R*Tree indexing with custom C# geometry algorithms for two-pass spatial queries
// Build geospatial applications with Shiny.Spatial - a dependency-free, AOT-compatible .NET spatial database using SQLite R*Tree indexing with custom C# geometry algorithms for two-pass spatial queries
| name | shiny-spatial |
| description | Build geospatial applications with Shiny.Spatial - a dependency-free, AOT-compatible .NET spatial database using SQLite R*Tree indexing with custom C# geometry algorithms for two-pass spatial queries |
| auto_invoke | true |
| triggers | ["spatial","geospatial","spatial database","rtree","shiny.spatial","shiny spatial","geofence","geofencing","region change"] |
You are an expert in Shiny.Spatial, a dependency-free, cross-platform .NET geospatial database library that uses SQLite R*Tree for spatial indexing with custom C# geometry algorithms for query refinement. No SpatiaLite, no NetTopologySuite — only SQLite via Microsoft.Data.Sqlite.
Invoke this skill when the user wants to:
ISpatialGeofenceDelegate for region change handlingISpatialGeofenceManager to start/stop geofence detectionRepository: https://github.com/shinyorg/geospatialdb
Namespace: Shiny.Spatial
AOT compatible and trimmable.
| Package | Framework | Notes |
|---|---|---|
Shiny.Spatial | net10.0 | AOT compatible and trimmable |
Shiny.Spatial.Geofencing | net10.0-ios, net10.0-android | iOS/Android GPS geofencing |
Microsoft.Data.Sqlite — brings SQLitePCLRaw.bundle_e_sqlite3 with R*Tree enabledShiny.Locations — geofencing package only (background GPS)dotnet add package Shiny.Spatial
Each spatial table creates a single R*Tree virtual table with auxiliary columns:
CREATE VIRTUAL TABLE {name}_rtree USING rtree(
id, min_x, max_x, min_y, max_y,
+geometry BLOB, -- WKB-encoded geometry
+prop_{name} {type}, ... -- user-defined property columns
);
Metadata is tracked in __spatial_meta and __spatial_columns tables.
All geometry classes are immutable and sealed, extending the abstract Geometry base class. Namespace: Shiny.Spatial.Geometry.
| Type | Description |
|---|---|
Coordinate | Readonly struct with X/Y (aliased as Longitude/Latitude) |
Envelope | Readonly struct — bounding box with MinX, MaxX, MinY, MaxY |
Point | Single coordinate |
LineString | Ordered sequence of coordinates (minimum 2) |
Polygon | Exterior ring + optional interior rings (holes) |
MultiPoint | Collection of Points |
MultiLineString | Collection of LineStrings |
MultiPolygon | Collection of Polygons |
GeometryCollection | Collection of mixed Geometry types |
using Shiny.Spatial.Geometry;
// Point
var point = new Point(-104.99, 39.74);
// LineString (minimum 2 coordinates)
var line = new LineString(new[]
{
new Coordinate(-104.99, 39.74),
new Coordinate(-104.82, 38.83)
});
// Polygon (exterior ring, closed — first == last coordinate)
var polygon = new Polygon(new[]
{
new Coordinate(-109.05, 37.0), new Coordinate(-102.05, 37.0),
new Coordinate(-102.05, 41.0), new Coordinate(-109.05, 41.0),
new Coordinate(-109.05, 37.0)
});
// Polygon with holes
var polygonWithHole = new Polygon(
exteriorRing: new[] { /* outer coords */ },
interiorRings: new[] { new[] { /* hole coords */ } }
);
// Multi types
var multiPoint = new MultiPoint(new[] { point1, point2 });
var multiLine = new MultiLineString(new[] { line1, line2 });
var multiPoly = new MultiPolygon(new[] { polygon1, polygon2 });
var collection = new GeometryCollection(new Geometry[] { point, line, polygon });
using Shiny.Spatial.Database;
var db = new SpatialDatabase("path.db"); // file-backed
var db = new SpatialDatabase(":memory:"); // in-memory
SpatialTable table = db.CreateTable(name, coordinateSystem, properties...);
SpatialTable table = db.GetTable(name);
bool exists = db.TableExists(name);
db.DropTable(name);
db.Dispose();
var table = db.CreateTable("cities", CoordinateSystem.Wgs84,
new PropertyDefinition("name", PropertyType.Text),
new PropertyDefinition("population", PropertyType.Integer),
new PropertyDefinition("area", PropertyType.Real));
| PropertyType | Description |
|---|---|
Text | String values |
Integer | Long integer values |
Real | Double floating point values |
Blob | Binary data |
| Method | Returns | Description |
|---|---|---|
Insert(feature) | long | Insert a feature, returns its ID |
BulkInsert(features) | void | Insert many features in a single transaction |
Update(feature) | void | Update a feature by ID |
Delete(id) | bool | Delete a feature by ID |
GetById(id) | SpatialFeature? | Retrieve a single feature |
Count() | long | Total feature count |
var feature = new SpatialFeature(new Point(-104.99, 39.74))
{
Properties = { ["name"] = "Denver", ["population"] = 715000L }
};
long id = feature.Id; // set after Insert
Geometry geom = feature.Geometry;
Dictionary<string, object?> props = feature.Properties;
| Method | Description |
|---|---|
FindInEnvelope(envelope) | R*Tree bounding box query |
FindIntersecting(geometry) | Two-pass intersection query |
FindContainedBy(geometry) | Two-pass containment query |
FindWithinDistance(center, meters) | Two-pass distance query |
Query() | Returns a fluent SpatialQuery builder |
// Distance query
var nearby = table.FindWithinDistance(
new Coordinate(-104.99, 39.74),
distanceMeters: 150_000
);
// Shape intersection
var inState = table.FindIntersecting(coloradoPolygon);
// Bounding box
var envelope = new Envelope(-110, -100, 35, 42);
var inBox = table.FindInEnvelope(envelope);
| Method | Type | Description |
|---|---|---|
InEnvelope(envelope) | Filter | Bounding box filter |
Intersecting(geometry) | Filter | Geometry intersection |
ContainedBy(geometry) | Filter | Geometry containment |
WithinDistance(center, meters) | Filter | Distance radius |
WhereProperty(name, op, value) | Filter | Property comparison (=, !=, <, <=, >, >=, LIKE) |
OrderByDistance(center) | Sort | Order by distance from coordinate |
Limit(count) | Paging | Limit result count |
Offset(count) | Paging | Skip first N results |
ToList() | Terminal | Execute and return results |
Count() | Terminal | Execute and return count |
FirstOrDefault() | Terminal | Execute and return first or null |
var center = new Coordinate(-104.99, 39.74);
var results = table.Query()
.WithinDistance(center, 150_000)
.WhereProperty("population", ">", 200000L)
.OrderByDistance(center)
.Limit(10)
.ToList();
int count = table.Query().InEnvelope(envelope).Count();
var first = table.Query().WithinDistance(center, 1000).FirstOrDefault();
Namespace: Shiny.Spatial.Algorithms
| Class | Method | Description |
|---|---|---|
DistanceCalculator | Haversine(a, b) | Great-circle distance in meters (WGS84) |
DistanceCalculator | Euclidean(a, b) | Cartesian distance |
DistanceCalculator | DistanceToSegment(p, a, b) | Perpendicular distance from point to segment |
PointInPolygon | Contains(polygon, point) | Ray-casting with hole support |
SegmentIntersection | Intersects(a1, a2, b1, b2) | Cross-product segment intersection test |
SpatialPredicates | Intersects(a, b) | Dispatch for all geometry type combinations |
SpatialPredicates | Contains(container, contained) | Dispatch for all geometry type combinations |
EnvelopeExpander | ExpandByDistance(env, meters, cs) | Expand envelope by distance (WGS84 or Cartesian) |
using Shiny.Spatial.Serialization;
byte[] wkb = WkbWriter.Write(geometry);
Geometry restored = WkbReader.Read(wkb);
Full roundtrip support for all geometry types using the WKB (Well-Known Binary) format.
Located in databases/:
| Database | Table | Geometry | Records | Properties |
|---|---|---|---|---|
us-states.db | states | Polygon | 51 (50 states + DC) | name, abbreviation, population |
us-cities.db | cities | Point | 100 (top 100 by pop.) | name, state, population |
ca-provinces.db | provinces | Polygon | 13 (all provinces/territories) | name, abbreviation, population |
ca-cities.db | cities | Point | 50 (top 50 by pop.) | name, province, population |
All use CoordinateSystem.Wgs84 (longitude/latitude).
using var db = new SpatialDatabase("databases/us-states.db");
var states = db.GetTable("states");
// Find which state Denver is in
var denver = new Point(-104.99, 39.74);
var results = states.FindIntersecting(denver);
// results[0].Properties["name"] == "Colorado"
| System | Enum | Distance Algorithm | Use Case |
|---|---|---|---|
| WGS84 | CoordinateSystem.Wgs84 | Haversine (great-circle) | Real-world GPS coordinates |
| Cartesian | CoordinateSystem.Cartesian | Euclidean | Flat/projected coordinate systems |
When generating code with Shiny.Spatial:
SpatialDatabase in a using statement or IDisposable pattern:memory: for tests, file paths for productionCreateTable for new tables, GetTable for existing tablesCoordinateSystem.Wgs84 for real-world GPS latitude/longitude dataCoordinateSystem.Cartesian for projected or flat coordinate systemsPropertyType.Text for stringsPropertyType.Integer for long values — always use L suffix (e.g., 715000L)PropertyType.Real for doublesPropertyType.Blob for binary dataFindWithinDistance, FindIntersecting, etc.)Query() builderLimit() and Offset() for paginationOrderByDistance() for nearest-neighbor resultsBulkInsert() for inserting multiple features — it wraps in a transaction for performanceInsert() is fine for one-off insertsCoordinate struct for X/Y pairs (aliases: Longitude/Latitude)using var db = new SpatialDatabase(":memory:");
var table = db.CreateTable("test", CoordinateSystem.Wgs84,
new PropertyDefinition("name", PropertyType.Text));
table.Insert(new SpatialFeature(new Point(-104.99, 39.74))
{
Properties = { ["name"] = "Denver" }
});
var results = table.FindWithinDistance(new Coordinate(-104.99, 39.74), 1000);
results.Count.ShouldBe(1);
results[0].Properties["name"].ShouldBe("Denver");
Shiny.Spatial.Geofencing)A separate NuGet package that adds GPS-driven geofence monitoring on top of Shiny.Spatial. Built on Shiny.Locations for background GPS on iOS and Android.
The primary use case is monitoring preexisting spatial databases containing city and state/province polygons. There is currently no API to add individual geofences manually — you point the monitor at one or more spatial database tables and it detects region enter/exit automatically.
dotnet add package Shiny.Spatial.Geofencing
Platforms: iOS, Android (registration API is #if IOS || ANDROID)
Add() requires a file path on disk. For databases bundled as MAUI raw assets (Resources/Raw), copy the asset to AppDataDirectory first since SQLite cannot open files directly from the app package.
// In MauiProgram.cs
builder.Services.AddSpatialGps<MyGeofenceDelegate>(config =>
{
config.MinimumDistance = Distance.FromMeters(300); // default
config.MinimumTime = TimeSpan.FromMinutes(1); // default
config
.Add(CopyAssetToAppData("us-states.db"), "states")
.Add(CopyAssetToAppData("us-cities.db"), "cities");
});
// Helper to copy a MAUI raw asset to a writable location
static string CopyAssetToAppData(string assetFileName)
{
var destPath = Path.Combine(FileSystem.AppDataDirectory, assetFileName);
if (!File.Exists(destPath))
{
using var source = FileSystem.OpenAppPackageFileAsync(assetFileName).GetAwaiter().GetResult();
using var dest = File.Create(destPath);
source.CopyTo(dest);
}
return destPath;
}
ISpatialGeofenceManagerThe main interface for controlling geofence monitoring. Inject this to start/stop monitoring and query the current region.
public interface ISpatialGeofenceManager
{
bool IsStarted { get; }
Task<AccessState> RequestAccess();
Task Start();
Task Stop();
Task<IReadOnlyList<SpatialCurrentRegion>> GetCurrent(CancellationToken cancelToken = default);
}
| Method | Description |
|---|---|
IsStarted | Whether geofence monitoring is active |
RequestAccess() | Requests GPS permissions |
Start() | Begins background GPS monitoring and region detection |
Stop() | Stops monitoring |
GetCurrent() | Gets the current GPS position and queries all monitored tables to determine which region(s) the device is in |
ISpatialGeofenceDelegateImplement this interface to receive geofence enter/exit events. Register it with AddSpatialGps<T>().
public interface ISpatialGeofenceDelegate
{
Task OnRegionChanged(SpatialRegionChange change);
}
SpatialRegionChangeEvent data for geofence transitions. Each event represents either entering or exiting a single region.
public record SpatialRegionChange(
string TableName, // the spatial table that was matched
SpatialFeature Region, // the region being entered or exited
bool Entered // true = entered, false = exited
);
When transitioning directly from Region A to Region B, two events fire: an exit from A (Entered = false), then an entry into B (Entered = true).
SpatialCurrentRegionReturned by ISpatialGeofenceManager.GetCurrent().
public record SpatialCurrentRegion(string TableName, SpatialFeature? Region);
SpatialMonitorConfigConfiguration for which databases/tables to monitor.
public class SpatialMonitorConfig
{
public List<SpatialMonitorEntry> Entries { get; }
public Distance? MinimumDistance { get; set; } // default: 300m
public TimeSpan? MinimumTime { get; set; } // default: 1 minute
public SpatialMonitorConfig Add(string databasePath, string tableName);
}
Add() takes a file path on disk — for MAUI raw assets, copy to AppDataDirectory first (see setup example above)public class MyGeofenceDelegate(
ILogger<MyGeofenceDelegate> logger,
INotificationManager notifications
) : ISpatialGeofenceDelegate
{
public async Task OnRegionChanged(SpatialRegionChange change)
{
var regionName = change.Region.Properties.GetValueOrDefault("name") ?? "Unknown";
var action = change.Entered ? "Entered" : "Exited";
logger.LogInformation("{Action} {Region} in {Table}", action, regionName, change.TableName);
await notifications.Send("Geofence", $"{action}: {regionName}");
}
}
public class MyViewModel(ISpatialGeofenceManager geofences)
{
public async Task StartMonitoring()
{
await geofences.RequestAccess();
await geofences.Start();
}
public async Task StopMonitoring()
{
await geofences.Stop();
}
public async Task CheckCurrentRegions()
{
var regions = await geofences.GetCurrent();
foreach (var r in regions)
{
var name = r.Region?.Properties.GetValueOrDefault("name") ?? "None";
Console.WriteLine($"{r.TableName}: {name}");
}
}
}
using for SpatialDatabase — it manages SQLite connectionsL suffix for integer properties — PropertyType.Integer expects long valuesFindWithinDistance for proximity queries — more intuitive than manual envelope expansiondatabases/ folder has ready-to-use US/Canadian geographic data