diff --git a/README.md b/README.md index d90d94c..f81e968 100644 --- a/README.md +++ b/README.md @@ -12,19 +12,19 @@ # MVTTools -MapLibre/Mapbox vector tiles (MVT) reader/writer library for Swift, together with a powerful tool for working with vector tiles and GeoJSONs from the command line. +MapLibre/Mapbox vector tiles (MVT) reader/writer library for Swift, together with a powerful command-line tool for working with vector tiles and GeoJSON files. ## Features -- Load and write MapLibre/Mapbox Vector Tiles from/to disk, data objects or URLs (also handles gzipped input). -- Export options: Zipped, buffered (in pixels or extents), simplified (in meters or extents). -- Can dump a tile as a GeoJSON object. -- Supported projections: EPSG:4326, EPSG:3857 or none (uses the tile's coordinate space). -- Fast search (supports indexing), either within a bounding box or with center and radius. -- Extract selected layers into a new tile. -- Merge tiles into one. -- Can extract some infos from tiles like feature count, etc. -- Powerful command line tool (via [Homebrew](#command-line-tool), documentation below) for working with vector tiles and GeoJSON files. +- **Read & write** MapLibre/Mapbox Vector Tiles from/to disk, data objects or URLs (handles gzipped input). +- **GeoJSON import/export** — convert between MVT and GeoJSON formats. +- **Export options** — gzip compression, buffering (pixels or extents), geometry simplification (meters or extents). +- **Projections** — EPSG:4326 (WGS84), EPSG:3857 (Web Mercator), EPSG:4978 (ECEF), `noSRID` (raw tile coordinates). +- **Spatial queries** — R-Tree indexed or linear scan; bounding-box search, center+radius proximity (`near`), bounding-box containment (`within`), bounding-box intersection (`intersects`). +- **Property queries** — powerful RPN-based query DSL with comparisons, string operators, regex, set membership, boolean logic, and existence checks. +- **Layer management** — extract, merge, remove, or filter layers and features. +- **Tile metadata** — per-layer feature counts, geometry-type breakdowns, property histograms. +- **Command-line tool** — `mvt` with subcommands: `dump`, `info`, `query`, `merge`, `import`, `export`. ## Requirements @@ -49,55 +49,309 @@ This package uses the [gis-tools](https://github.com/Outdooractive/gis-tools) li See the [API documentation](https://swiftpackageindex.com/Outdooractive/mvt-tools/main/documentation/mvttools) (via Swift Package Index). -### Read +### Read MVT ```swift import MVTTools -// Load -let mvtData = Data(contentsOf: URL(fileURLWithPath: "14_8716_8015.vector.mvt"))! +let mvtData = try Data(contentsOf: URL(fileURLWithPath: "14_8716_8015.vector.mvt")) let tile = VectorTile(data: mvtData, x: 8716, y: 8015, z: 14, indexed: .hilbert)! -print(tile.isIndexed) -print(tile.layerNames.sorted()) +print(tile.isIndexed) // true +print(tile.layerNames.sorted()) // ["admin", "aeroway", "airport_label", …] -let tileAsGeoJsonData: Data? = tile.toGeoJson(prettyPrinted: true) -... +// Export as GeoJSON +let geoJsonData: Data? = tile.toGeoJson(prettyPrinted: true) -let result = tile.query(at: Coordinate3D(latitude: 3.870163, longitude: 11.518585), tolerance: 100.0) -... +// Spatial query +let results = tile.query( + at: Coordinate3D(latitude: 3.87, longitude: 11.52), + tolerance: 100.0) ``` -### Write +### Read GeoJSON ```swift -import MVTTools +let geoJsonData = try Data(contentsOf: URL(fileURLWithPath: "features.geojson")) +let tile = VectorTile(geoJsonData: geoJsonData, layerProperty: "vt_layer")! + +// The tile can now be exported as MVT +let mvtData = tile.data() +``` +### Write MVT + +```swift var tile = VectorTile(x: 8716, y: 8015, z: 14)! + var feature = Feature(Point(Coordinate3D(latitude: 3.870163, longitude: 11.518585))) feature.properties = [ - "test": 1, - "test2": 5.567, - "test3": [1, 2, 3], - "test4": [ - "sub1": 1, - "sub2": 2 - ] + "name": "Test", + "value": 42, ] -tile.setFeatures([feature], for: "test") +tile.setFeatures([feature], for: "my_layer") + +// Write with compression and buffering options +let options = VectorTile.ExportOptions( + bufferSize: .pixel(4), + compression: .default, + simplifyFeatures: .meters(1.0)) +let mvtData = tile.data(options: options) +try mvtData?.write(to: URL(fileURLWithPath: "output.mvt")) + +// Or write directly +tile.write(to: URL(fileURLWithPath: "output.mvt"), options: options) +``` + +### Write GeoJSON + +```swift +let geoJsonData = tile.toGeoJson( + layerNames: ["road", "building"], + prettyPrinted: true, + layerProperty: "vt_layer") +try geoJsonData?.write(to: URL(fileURLWithPath: "output.geojson")) + +// Or write directly +tile.writeGeoJson(to: URL(fileURLWithPath: "output.geojson"), prettyPrinted: true) +``` + +### Layer management + +```swift +// Add features to a layer +tile.appendFeatures([feature1, feature2], to: "roads") + +// Replace a layer +tile.setFeatures([feature3], for: "buildings") -// Also have a look at ``VectorTile.ExportOptions`` -let tileData = tile.data() -... +// Remove features matching a predicate +tile.removeFeatures(fromLayer: "roads") { $0.properties["class"] as? String == "footway" } + +// Remove an entire layer +tile.removeLayer("temporary_layer") + +// Extract layers into a new tile +let subset = tile.extract(layerNames: ["road", "building"]) + +// Merge tiles +tile.merge(anotherTile) +``` + +### Merge tiles + +```swift +let tile1 = VectorTile(data: mvtData1, x: 5, y: 13, z: 4)! +let tile2 = VectorTile(data: mvtData2, x: 5, y: 13, z: 4)! +tile1.merge(tile2) +``` + +### Tile info + +```swift +// Layer names +let names = VectorTile.layerNames(from: mvtData) // ["road", "building", …] + +// Feature statistics +if let info = tile.tileInfo() { + for layer in info { + print(layer.name, + layer.features, // total feature count + layer.pointFeatures, // point count + layer.linestringFeatures, + layer.polygonFeatures) + } +} + +// Static info (no VectorTile instance needed) +let info = VectorTile.tileInfo(from: mvtData) +``` + +### Export options + +```swift +VectorTile.ExportOptions( + bufferSize: .extent(512), // buffer in tile-extent units + // or: .pixel(4), .no + compression: .level(9), // gzip compression 0-9 + // or: .default, .no + simplifyFeatures: .meters(1.0) // simplify geometry to 1m tolerance + // or: .extent(10), .no +) ``` ### Playground On macOS you can use a Swift Playground to inspect the MVTTools API such as `layerNames` and `projection`. -* Load tile using MVTTools -* Inspect the properties of the `VectorTile` +- Load a tile using MVTTools +- Inspect the properties of the `VectorTile` + +## Query language + +The query language is used by the `mvt query` CLI command and by the programmatic `tile.query(term:)` API. It filters features by evaluating an expression against each feature's properties and geometry. The language uses a Reverse Polish Notation (RPN) internally but the query syntax follows a natural infix style. + +### Value access + +Properties are accessed by prefixing the key with `.`: + +| Query | Meaning | +|-------|---------| +| `.name` | Property `name` exists and is truthy | +| `.foo.bar` | Nested property `foo → bar` | +| `."foo.bar"` | Property whose key contains a dot | +| `.foo.[0]` | First element of array property `foo` | +| `.some.0` | Same as above, shorthand | + +Assuming features with this structure: +```json +{ + "properties": { + "foo": {"bar": 1, "baz": 10}, + "some": ["a", "b"], + "value": 1, + "name": "Some name" + } +} +``` + +``` +.foo → true (exists) +.foo.bar → true (1 is truthy) +.foo.x → false (key not found) +.some.[0] → true ("a" exists) +.some.2 → false (out of bounds) +``` + +### Comparisons + +| Operator | Meaning | Example | +|----------|---------|---------| +| `==` | Equal | `.value == 1` | +| `!=` | Not equal | `.value != 2` | +| `>` | Greater than | `.value > 0` | +| `>=` | Greater or equal | `.value >= 1` | +| `<` | Less than | `.value < 2` | +| `<=` | Less or equal | `.value <= 1` | +| `=~` | Regex match | `.name =~ /^Some/i` | +| `=*` | String contains (case-insensitive) | `.name =* "ome"` | +| `=^` | String starts with (case-insensitive) | `.name =^ "some"` | +| `=$` | String ends with (case-insensitive) | `.name =$ "name"` | + +Cross-type numeric comparisons work automatically (e.g. `Int` vs `Double`, `UInt8` vs `Int`). + +``` +.value == 1 → true +.value != 1 → false +.value > 0 → true +.value <= 1 → true +.name =~ /^Some/ → true (regex, case-sensitive) +.name =~ /^some/i → true (regex, case-insensitive) +.name =* "ome" → true (contains, case-insensitive) +.name =^ "Some" → true (starts with, case-insensitive) +.name =$ "name" → true (ends with, case-insensitive) +``` + +### String values + +Strings can be quoted with single or double quotes: + +``` +.name == 'Main Street' +.name == "Main Street" +.name =~ "Main.*" +``` + +### Set membership + +``` +.class in ["primary", "secondary"] → true if .class matches either value +.value in [1, 3, 5] → integer sets +.name in ['Alice', 'Bob'] → string sets +``` + +Commas inside quoted strings are preserved: +``` +.tags in ["tag, with, comma", "other"] +``` + +### Boolean conditions + +| Operator | Meaning | Example | +|----------|---------|---------| +| `and` | Logical AND | `.a == 1 and .b == 2` | +| `or` | Logical OR | `.a == 1 or .b == 1` | +| `not` | Logical NOT (postfix) | `.a not` | +| `exists` | Truthy check | `.a exists` | + +``` +.foo.bar == 1 and .value == 1 → true +.foo == 1 or .bar == 2 → false +.foo not → false (foo exists, so !true) +.foo.bar not → false (bar exists in foo) +.nonexistent not → true (property absent) +.foo exists → true (non-nil) +.nonexistent exists → false (nil) +``` + +`exists` can be combined with other conditions: +``` +.bridge exists and .tunnel exists → true +.nonexistent exists not → true +``` + +### Spatial predicates + +| Predicate | Syntax | Meaning | +|-----------|--------|---------| +| `near` | `near(lat, lon, tolerance)` | Feature **centroid** is within `tolerance` meters of the given point | +| `within` | `within(minLon, minLat, maxLon, maxLat)` | Feature's **bounding box** is fully inside the rectangle | +| `intersects` | `intersects(minLon, minLat, maxLon, maxLat)` | Feature's **geometry** intersects the rectangle | + +``` +near(3.87, 11.52, 1000) → features within 1 km +.area > 40000 and within(11.5, 3.8, 11.6, 3.9) → large features in area +.highway == primary and intersects(11.5, 3.8, 11.6, 3.9) → roads crossing the area +``` + +Note: `within` checks bbox containment, `intersects` does a precise geometry-level intersection test (via GISTools' `Feature.intersects(BoundingBox)` which uses a two-phase check: bbox coarse filter + precise geometry intersection). + +### Complete examples + +``` +# Features with area > 20000 classified as hospital +.area > 20000 and .class == 'hospital' + +# Features named "Hopital" (case-insensitive) near a coordinate +.name =~ /hopital/i and near(3.87324, 11.53731, 1000) + +# Roads or buildings that intersect a bounding box +.class in ["road", "building"] and intersects(11.5, 3.8, 11.6, 3.9) + +# Features that exist and have a name starting with "Lac" or "Lake" +(.name =^ "Lac" or .name =^ "Lake") — note: parentheses not supported in RPN, +use the natural evaluation order instead: +.name =^ "Lac" or .name =^ "Lake" + +# Features with no area property +.area not + +# Features with a bridge tag +.bridge exists +``` + +### Using the query API programmatically + +```swift +// Text search (falls back to full-text search if query isn't recognized) +let results = tile.query(term: "école") +let results = tile.query(term: ".class == 'hospital' and .area > 1000") + +// Direct use of QueryParser +let parser = QueryParser(string: ".highway in [\"primary\", \"secondary\"] and .name =* \"Main\"")! +let matches = parser.evaluate(on: someFeature) +``` # Command line tool @@ -130,9 +384,9 @@ OPTIONS: -h, --help Show help information. SUBCOMMANDS: - dump (default) Print the input file (mvt or GeoJSON) as pretty-printed GeoJSON to the console - info Print information about the input file (mvt or GeoJSON) - query Query the features in the input file (mvt or GeoJSON) + dump (default) Print the input file (MVT or GeoJSON) as pretty-printed GeoJSON to the console + info Print information about the input file (MVT or GeoJSON) + query Query the features in the input file (MVT or GeoJSON) merge Merge any number of vector tiles or GeoJSONs import Import some GeoJSONs into a vector tile export Export a vector tile as GeoJSON to a file @@ -181,7 +435,7 @@ Print some informations about vector tiles/GeoJSONs: - The properties for each layer - Counts of specific properties -**Example 1**: Print information about the MVTTools test vector tile at zoom 14, at Yaoundé, Cameroon. +**Example 1**: Print information about a vector tile. ```bash mvt info Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt @@ -192,14 +446,11 @@ mvt info Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt barrier_line | 4219 | 0 | 4219 | 0 | 0 | 2 bridge | 14 | 0 | 14 | 0 | 0 | 2 building | 5414 | 0 | 0 | 5414 | 0 | 2 - building_label | 413 | 413 | 0 | 0 | 0 | 2 ... road | 502 | 1 | 497 | 4 | 0 | 2 - road_label | 309 | 0 | 309 | 0 | 0 | 2 ``` ---- -**Example 2**: Inspect a MapLibre vector tile at zoom 2, with an extent showing Norway to India. +**Example 2**: Inspect a remote MapLibre tile. ```bash mvt info https://demotiles.maplibre.org/tiles/2/2/1.pbf @@ -210,229 +461,87 @@ mvt info https://demotiles.maplibre.org/tiles/2/2/1.pbf countries | 113 | 0 | 0 | 113 | 0 | 2 geolines | 4 | 0 | 4 | 0 | 0 | 2 ``` ---- -**Example 3**: Print information about the properties for each layer. +**Example 3**: Print property counts per layer. ```bash mvt info Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt - Name | area | class | group | layer | ldir | len | name | name_de | name_en | name_es | name_fr | network | oneway | ref | reflen | scalerank | type ---------------------+------+-------+-------+-------+------+-----+------+---------+---------+---------+---------+---------+--------+-----+--------+-----------+----- - airport_label | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 - area_label | 55 | 55 | 0 | 0 | 0 | 0 | 55 | 55 | 55 | 55 | 55 | 0 | 0 | 0 | 0 | 0 | 0 - barrier_line | 0 | 4219 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 - bridge | 0 | 14 | 0 | 13 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 14 | 0 | 0 | 0 | 0 -... + Name | area | class | group | layer | ldir | len | name | ... +--------------------+------+-------+-------+-------+------+-----+------+----- + airport_label | 0 | 0 | 0 | 0 | 0 | 0 | 0 | ... + area_label | 55 | 55 | 0 | 0 | 0 | 0 | 55 | ... + ... ``` ---- -**Example 4**: Print information about specific properties. +**Example 4**: Count values for a specific property. ```bash mvt info -p class Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt - Name | cemetery | driveway | fence | hedge | hospital | industrial | main | major_rail | mini_roundabout | minor_rail | motorway | park | parking | path | pitch | rail | school | service | street | street_limited | wetland | wood --------+----------+----------+-------+-------+----------+------------+------+------------+-----------------+------------+----------+------+---------+------+-------+------+--------+---------+--------+----------------+---------+----- - class | 4 | 36 | 3895 | 324 | 9 | 2 | 113 | 21 | 1 | 13 | 30 | 95 | 59 | 46 | 21 | 2 | 59 | 187 | 376 | 4 | 4 | 12 + Name | cemetery | driveway | fence | hedge | hospital | industrial | ... +-------+----------+----------+-------+-------+----------+------------+----- + class | 4 | 36 | 3895 | 324 | 9 | 2 | ... ``` --- ### mvt query -**Example 1**: Query a vector tile or GeoJSON file with a search term. +The `mvt query` command uses the [query language](#query-language) described above. + +**Example 1**: Full-text search. ```bash mvt query Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt "École" -{ - "features" : [ - { - "bbox" : [ - 11.537318229675295, - 3.8732409490233337, - 11.537318229675295, - 3.8732409490233337 - ], - "geometry" : { - "coordinates" : [ - 11.537318229675295, - 3.8732409490233337 - ], - "type" : "Point" - }, - "id" : 51, - "layer" : "building_label", - "properties" : { - "area" : 173.97920227050781, - "name" : "École Maternelle", - "name_de" : "École Maternelle", - "name_en" : "École Maternelle", - "name_es" : "École Maternelle", - "name_fr" : "École Maternelle" - }, - "type" : "Feature" - }, - ... -} ``` ---- -**Example 2**: Query a tile with `latitude,longitude,radius`. + +**Example 2**: Spatial query by coordinate. ```bash mvt query Tests/MVTToolsTests/TestData/14_8716_8015.geojson "3.87324,11.53731,1000" -{ - "features" : [ - { - "bbox" : [ - 11.529276967048643, - 3.8803432426251487, - 11.530832648277283, - 3.8823074685255259 - ], - "geometry" : { - "coordinates" : [ - ... - ], - "type" : "LineString" - }, - "id" : 48, - "layer" : "road", - "properties" : { - "class" : "driveway", - "oneway" : 0 - }, - "type" : "Feature" - }, - ... -} ``` ---- -**Example 3**: Query Feature properties in a tile. + +**Example 3**: Property query with comparisons. ```bash mvt query -p Tests/MVTToolsTests/TestData/14_8716_8015.vector.mvt ".area > 40000 and .class == 'hospital'" - -{ - "features" : [ - { - "bbox" : [ - 11.510410308837876, - 3.871287406415171, - 11.510410308837876, - 3.871287406415171 - ], - "geometry" : { - "coordinates" : [ - 11.510410308837876, - 3.871287406415171 - ], - "type" : "Point" - }, - "id" : 2, - "properties" : { - "area" : 48364.9375, - "class" : "hospital", - "name" : "Hopital Central de Yaoundé", - "name_de" : "Hopital Central de Yaoundé", - "name_en" : "Hopital Central de Yaoundé", - "name_es" : "Hopital Central de Yaoundé", - "name_fr" : "Hopital Central de Yaoundé", - "vt_layer" : "area_label" - }, - "type" : "Feature" - } - ], - "type" : "FeatureCollection" -} ``` -The query language is very loosely modeled after the jq query language. -The output will contain all features where the query returns `true`. +**Example 4**: Regex, string operators, and set membership. -Here is an overview. Example: -``` -"properties": { - "foo": {"bar": 1}, - "some": ["a", "b"], - "value": 1, - "string": "Some name" -} -``` +```bash +# Case-insensitive regex +mvt query -p 14_8716_8015.vector.mvt ".name =~ /hopital/i" -Values are retrieved by putting a `.` in front of the property name. The property name must be quoted -if it is a number or contains non-alphabetic characters. Elements in arrays can be -accessed either by simply using the array index after the dot, or by wrapping it in brackets. +# String starts with / ends with +mvt query -p 14_8716_8015.vector.mvt ".name =^ 'Main'" +mvt query -p 14_8716_8015.vector.mvt ".name =$ 'Street'" -``` -.foo // true, property "foo" exists -.foo.bar // true, property "foo" is a dictionary containing "bar" -."foo"."bar" // true, same as above but quoted -.'foo'.'bar' // true, same as above but quoted -.foo.x // false, "foo" doesn't contain "x" -."foo.bar" // false, property "foo.bar" doesn't exist -.foo.[0] // false, "foo" is not an array -.some.[0] // true, "some" is an array and has an element at index "0" -.some.0 // true, same as above but without brackets -.some."0" // false, "0" is a string key but "some" is not a dictionary +# Set membership +mvt query -p 14_8716_8015.vector.mvt ".class in ['hospital', 'school']" ``` -Comparisons can be expressed like this: -``` -.value == "bar" // false -.value == 1 // true -.value != 1 // false -.value > 1 // false -.value >= 1 // true -.value < 1 // false -.value <= 1 // true -.string =~ /[Ss]ome/ // true -.string =~ /some/ // false -.string =~ /some/i // true, case insensitive regexp -.string =~ "^Some" // true, can also use quotes -``` +**Example 5**: Spatial predicates. -Conditions (evaluated left to right): -``` -.foo.bar == 1 and .value == 1 // true -.foo == 1 or .bar == 2 // false -.foo == 1 or .value == 1 // true -.foo not // false, true if foo does not exist -.foo and .bar not // true, foo and bar don't exist together -.foo or .bar not // false, true if neither foo nor bar exist -.foo.bar not // false, true if "bar" in dictionary "foo" doesn't exist -``` +```bash +# Features near a point +mvt query -p 14_8716_8015.vector.mvt "near(3.87324,11.53731,1000)" -Other: -``` -near(latitude,longitude,tolerance) // true if the feature is within "tolerance" around the coordinate -``` +# Features within a bounding box +mvt query -p 14_8716_8015.vector.mvt "within(11.5, 3.8, 11.6, 3.9)" -Some complete examples: +# Features intersecting a bounding box +mvt query -p 14_8716_8015.vector.mvt ".class == 'road' and intersects(11.5, 3.8, 11.6, 3.9)" ``` -// Can use single quotes for strings -mvt query -p 14_8716_8015.vector.mvt ".area > 20000 and .class == 'hospital'" - -// ... or double quotes, but they must be escaped -mvt query -p 14_8716_8015.vector.mvt ".area > 20000 and .class == \"hospital\"" - -// No need to quote the query if it doesn't conflict with your shell -// Print all features that have an "area" property -mvt query -p 14_8716_8015.vector.mvt .area -// Features which don't have "area" and "name" properties -mvt query -p 14_8716_8015.vector.mvt .area and .name not -// Case insensitive regular expression -mvt query -p 14_8716_8015.vector.mvt ".name =~ /hopital/i" - -// Case sensitive regular expression -mvt query -p 14_8716_8015.vector.mvt ".name =~ /Recherches?/" -// Can also use quotes instead of slashes -mvt query -p 14_8716_8015.vector.mvt ".name =~ 'Recherches?'" +**Example 6**: Combined queries. -// Features around a coordinate -mvt query -p 14_8716_8015.vector.mvt "near(3.87324,11.53731,1000)" -// With other conditions +```bash +# Name + spatial mvt query -p 14_8716_8015.vector.mvt ".name =~ /^lac/i and near(3.87324,11.53731,10000)" + +# Existence + comparison +mvt query -p 14_8716_8015.vector.mvt ".bridge exists and .tunnel not" ``` --- @@ -441,16 +550,16 @@ mvt query -p 14_8716_8015.vector.mvt ".name =~ /^lac/i and near(3.87324,11.53731 Merge two or more vector tiles or GeoJSON files in any combination. ```bash -# All vector tiles: +# Merge vector tiles: mvt merge --output merged.mvt path/to/first.mvt path/to/second.mvt -# All GeoJSON files: +# Merge GeoJSON files: mvt merge --output merged.geojson path/to/first.geojson path/to/second.geojson # Merge GeoJSON files into a vector tile: mvt merge --output merged.mvt --output-format mvt path/to/first.geojson path/to/second.geojson -# Merge vector tiles into a GeoJSOn file: +# Merge vector tiles into a GeoJSON file: mvt merge --output merged.geojson --output-format geojson path/to/first.mvt path/to/second.mvt ``` --- @@ -464,10 +573,10 @@ mvt export --output dumped.geojson --pretty-print Tests/MVTToolsTests/TestData/1 --- ### mvt import -Create a vector tile from GeoJSON. +Create a vector tile from a GeoJSON file. ```bash -mvt import new.mvt -x 8716 -y 8015 -z 14 Tests/MVTToolsTests/TestData/14_8716_8015.geojson +mvt import --output new.mvt -x 8716 -y 8015 -z 14 Tests/MVTToolsTests/TestData/14_8716_8015.geojson ``` --- @@ -483,10 +592,8 @@ brew install protobuf swift-protobuf swiftlint # TODOs and future improvements -- Documentation (!) -- Tests - Locking (when updating/deleting features, indexing) -- Query option: within/intersects +- Additional format support: GPX, Shapefile, GeoPackage (dependencies already included) - https://github.com/mapbox/vtcomposite - https://github.com/mapbox/geosimplify-js diff --git a/Sources/MVTTools/Query.swift b/Sources/MVTTools/Query.swift index 567c972..9797d99 100644 --- a/Sources/MVTTools/Query.swift +++ b/Sources/MVTTools/Query.swift @@ -72,10 +72,8 @@ extension VectorTile { guard let layerFeatureContainer = layers[layerName] else { continue } let resultFeatures: [Feature] = layerFeatureContainer.features.filter({ feature in - if let queryParser, - let properties = feature.properties as? [String: AnyHashable] - { - return queryParser.evaluate(on: properties, coordinate: feature.geometry.centroid?.coordinate) + if let queryParser { + return queryParser.evaluate(on: feature) } else { for value in feature.properties.values.compactMap({ $0 as? String }) { diff --git a/Sources/MVTTools/QueryParser.swift b/Sources/MVTTools/QueryParser.swift index 22a0ccb..caf251d 100644 --- a/Sources/MVTTools/QueryParser.swift +++ b/Sources/MVTTools/QueryParser.swift @@ -7,11 +7,20 @@ import GISTools /// The query string syntax supports: /// - Literal values (strings, numbers) for full-text search across all properties /// - Property path access via ``.``-separated keys and ``[index]`` subscripts -/// - Comparisons: ``==``, ``!=``, ``>``, ``>=``, ``<``, ``<=``, ``=~`` (regex) -/// - Boolean operators: ``and``, ``or``, ``not`` -/// - Spatial filter: ``near(lat,lon,tolerance)`` +/// - Comparisons: ``==``, ``!=``, ``>``, ``>=``, ``<``, ``<=``, ``=~`` (regex), +/// ``=*`` (contains), ``=^`` (starts with), ``=$`` (ends with) +/// - Set membership: ``.class in ["primary", "secondary"]`` +/// - Boolean operators: ``and``, ``or``, ``not`` (+ ``exists`` for truthy check) +/// - Spatial filters: ``near(lat,lon,tolerance)``, ``within(minLon,minLat,maxLon,maxLat)``, +/// ``intersects(minLon,minLat,maxLon,maxLat)`` /// -/// Example query: ``"highway == primary and name =~ '^Main'"`` +/// Example queries: +/// ``` +/// .highway == primary and .name =~ '^Main' +/// .class in ["primary", "secondary"] and .name =* "Main" +/// .population > 1000 and within(11.5,3.8,11.6,3.9) +/// .highway == primary and intersects(11.5,3.8,11.6,3.9) +/// ``` public struct QueryParser { /// A token in the RPN expression pipeline, representing a comparison, @@ -42,6 +51,18 @@ public struct QueryParser { /// Regular expression match (``=~``). case regex + + /// String contains (``=*``). + case contains + + /// String starts with (``=^``). + case startsWith + + /// String ends with (``=$``). + case endsWith + + /// Value in a set (``in``). + case `in` } /// A boolean condition joining expressions. @@ -55,6 +76,10 @@ public struct QueryParser { /// Logical NOT. case not + + /// Truthy-value check. Evaluates ``true`` if the preceding value + /// is non-nil. + case exists } /// A key path segment or array index used to reference property values. @@ -76,6 +101,9 @@ public struct QueryParser { /// A literal value token. case literal(AnyHashable) + /// A set of literal values, used with the ``.in`` comparison. + case literalSet([AnyHashable]) + /// A spatial proximity predicate: ``near(latitude, longitude, tolerance)``. case near(Coordinate3D, Double) @@ -85,6 +113,14 @@ public struct QueryParser { /// A property value reference, composed of key path segments and/or /// array indices (e.g. ``.properties.name``, ``.tags[0]``). case value([KeyOrIndex]) + + /// A spatial bounding-box predicate: ``within(minLon, minLat, maxLon, maxLat)``. + /// Returns ``true`` if the feature's geometry is fully contained by the box. + case within(BoundingBox) + + /// A spatial bounding-box intersection predicate: ``intersects(minLon, minLat, maxLon, maxLat)``. + /// Returns ``true`` if the feature's geometry intersects the box. + case intersects(BoundingBox) } private let reader: Reader? @@ -114,23 +150,17 @@ public struct QueryParser { self.pipeline = pipeline } - /// Evaluates the expression pipeline against the given feature properties - /// and, optionally, a feature coordinate. + /// Evaluates the expression pipeline against the given feature. /// /// The pipeline is evaluated as a stack machine in Reverse Polish Notation. /// Returns `false` if the pipeline is empty or cannot be reduced. /// - /// - Parameters: - /// - properties: The feature's property dictionary. - /// - featureCoordinate: The feature's geographic coordinate, required - /// for ``near()`` predicates. + /// - Parameter feature: The feature whose properties and geometry are evaluated. /// - Returns: `true` if the pipeline evaluates to a truthy value, /// `false` otherwise. - public func evaluate( - on properties: [String: AnyHashable], - coordinate featureCoordinate: Coordinate3D? = nil - ) -> Bool { + public func evaluate(on feature: Feature) -> Bool { guard let pipeline else { return false } + let properties = QueryParser.convertToAnyHashable(feature.properties) var stack: [AnyHashable?] = [] @@ -166,6 +196,37 @@ public struct QueryParser { else { return false } stack.insert(value.matches(regex), at: 0) + + case .contains: + guard stack.count >= 2, + let needle = stack.removeFirst() as? String, + let haystack = stack.removeFirst() as? String + else { return false } + + stack.insert(haystack.localizedCaseInsensitiveContains(needle), at: 0) + + case .startsWith: + guard stack.count >= 2, + let needle = stack.removeFirst() as? String, + let haystack = stack.removeFirst() as? String + else { return false } + + stack.insert(haystack.lowercased().hasPrefix(needle.lowercased()), at: 0) + + case .endsWith: + guard stack.count >= 2, + let needle = stack.removeFirst() as? String, + let haystack = stack.removeFirst() as? String + else { return false } + + stack.insert(haystack.lowercased().hasSuffix(needle.lowercased()), at: 0) + + case .in: + guard let setValues = stack.removeFirst() as? [AnyHashable], + let value = stack.removeFirst() + else { return false } + + stack.insert(setValues.contains(value), at: 0) } case let .condition(condition): @@ -192,18 +253,35 @@ public struct QueryParser { let valueIsTrue = if let bool = value as? Bool { bool } else { value != nil } stack.insert(!valueIsTrue, at: 0) + + case .exists: + guard stack.isNotEmpty else { return false } + + let value = stack.removeFirst() + let valueIsTrue = if let bool = value as? Bool { bool } else { value != nil } + stack.insert(valueIsTrue, at: 0) } case let .literal(value): stack.insert(value, at: 0) + case let .literalSet(values): + stack.insert(values as AnyHashable, at: 0) + case let .near(coordinate, tolerance): var result = false - if let featureCoordinate { - result = coordinate.distance(from: featureCoordinate) <= tolerance + if let centroid = feature.geometry.centroid { + result = coordinate.distance(from: centroid.coordinate) <= tolerance } stack.insert(result, at: 0) + case let .within(boundingBox): + let featureBox = feature.boundingBox ?? feature.calculateBoundingBox() + stack.insert(featureBox.map { boundingBox.contains($0) } ?? false, at: 0) + + case let .intersects(boundingBox): + stack.insert(feature.intersects(boundingBox), at: 0) + case let .searchValues(searchString): var result = false for value in properties.values.compactMap({ $0 as? String }) { @@ -255,47 +333,84 @@ public struct QueryParser { return result != nil } - // This needs improvement - can this be done in a more generic way? - // Only the most common cases covered for now - private func compare( - first: AnyHashable, - second: AnyHashable, - condition: QueryParser.Expression.Comparison - ) -> Bool { - if let left = first as? Int { - if let right = second as? Int { - return compare(left: left, right: right, condition: condition) + /// Recursively converts a ``[String: Sendable]`` dictionary to ``[String: AnyHashable]``, + /// handling nested dictionaries and arrays. + private static func convertToAnyHashable(_ dict: [String: Sendable]) -> [String: AnyHashable] { + dict.mapValues { value in + if let nested = value as? [String: Sendable] { + return convertToAnyHashable(nested) as AnyHashable } - else if let right = second as? UInt { - return compare(left: UInt(left), right: right, condition: condition) + else if let array = value as? [Sendable] { + return array.compactMap { $0 as? AnyHashable } as AnyHashable } - else if let right = second as? Double { - return compare(left: Double(left), right: right, condition: condition) + else if let hashable = value as? AnyHashable { + return hashable } - } - else if let left = first as? Double { - if let right = second as? Double { - return compare(left: left, right: right, condition: condition) + else if let int = value as? Int { + return int } - else if let right = second as? Int { - return compare(left: left, right: Double(right), condition: condition) + else if let double = value as? Double { + return double } - else if let right = second as? UInt { - return compare(left: left, right: Double(right), condition: condition) + else if let string = value as? String { + return string } - } - else if let left = first as? UInt { - if let right = second as? UInt { - return compare(left: left, right: right, condition: condition) + else if let bool = value as? Bool { + return bool } - else if let right = second as? Int { - return compare(left: left, right: UInt(right), condition: condition) + else { + return String(describing: value) } - else if let right = second as? Double { - return compare(left: Double(left), right: right, condition: condition) + } + } + + /// Converts an ``AnyHashable`` numeric value to ``Double`` for cross-type ordered comparisons. + /// Handles all standard Swift integer and floating-point types. + private static func toDouble(_ value: AnyHashable) -> Double? { + if let v = value as? Double { return v } + if let v = value as? Int { return Double(v) } + if let v = value as? Int8 { return Double(v) } + if let v = value as? Int16 { return Double(v) } + if let v = value as? Int32 { return Double(v) } + if let v = value as? Int64 { return Double(v) } + if let v = value as? UInt { return Double(v) } + if let v = value as? UInt8 { return Double(v) } + if let v = value as? UInt16 { return Double(v) } + if let v = value as? UInt32 { return Double(v) } + if let v = value as? UInt64 { return Double(v) } + if let v = value as? Float { return Double(v) } + return nil + } + + private func compare( + first: AnyHashable, + second: AnyHashable, + condition: QueryParser.Expression.Comparison + ) -> Bool { + // Equality: try direct AnyHashable comparison first (fast path for same-type), + // then fall back to Double-based cross-type check (e.g. Int(1) == Double(1.0)). + if condition == .equals || condition == .notEquals { + if first == second { return condition == .equals } + if let left = QueryParser.toDouble(first), + let right = QueryParser.toDouble(second), + left == right + { + return condition == .equals } + return condition == .notEquals + } + + // Ordered comparisons: promote both sides to Double. + if let left = QueryParser.toDouble(first), + let right = QueryParser.toDouble(second) + { + return compare(left: left, right: right, condition: condition) } - else if let left = first as? String, let right = second as? String { + + // String comparison fallback. + if let left = first as? String, + let right = second as? String + { return compare(left: left, right: right, condition: condition) } @@ -308,21 +423,11 @@ public struct QueryParser { condition: QueryParser.Expression.Comparison ) -> Bool { switch condition { - case .equals: - return left == right - case .notEquals: - return left != right - case .greaterThan: - return left > right - case .greaterThanOrEqual: - return left >= right - case .lessThan: - return left < right - case .lessThanOrEqual: - return left <= right - case .regex: - guard let value = left as? String, let regex = right as? String else { return false } - return value.matches(regex) + case .greaterThan: return left > right + case .greaterThanOrEqual: return left >= right + case .lessThan: return left < right + case .lessThanOrEqual: return left <= right + default: return false } } @@ -340,15 +445,20 @@ public struct QueryParser { outer: while let char = reader.peek() { // Check for: - // - and, or, not - // - ==, !=, >, >=, <, <=, =~ + // - and, or, not, exists + // - ==, !=, >, >=, <, <=, =~, =*, =^, =$ + // - in if isBeginningOfTerm { let hasAnd = reader.peekWord("and") let hasOr = reader.peekWord("or") let hasNot = reader.peekWord("not") + let hasExists = reader.peekWord("exists") + let hasIn = reader.peekWord("in") let hasNear = reader.peekString("near(", caseInsensitive: true) + let hasWithin = reader.peekString("within(", caseInsensitive: true) + let hasIntersects = reader.peekString("intersects(", caseInsensitive: true) - if hasAnd || hasOr || hasNot { + if hasAnd || hasOr || hasNot || hasExists { pipeline?.append(contentsOf: terms) if let comparison { pipeline?.append(comparison) @@ -373,10 +483,43 @@ public struct QueryParser { pipeline?.append(.condition(.not)) reader.moveIndex(by: 3) } + else if hasExists { + pipeline?.append(.condition(.exists)) + reader.moveIndex(by: 6) + } continue } + if hasIn { + // .value in [literal, ...] + // Flush the current term (the value expression) and comparison + pipeline?.append(contentsOf: terms) + if let comparison { + pipeline?.append(comparison) + } + if let condition { + pipeline?.append(condition) + } + terms = [] + comparison = nil + condition = nil + isBeginningOfTerm = false + + reader.moveIndex(by: 2) + + // Expect a bracket-delimited set + reader.skipWhitespace() + guard reader.peek() == UInt8(ascii: "[") else { return false } + reader.moveIndex(by: 1) + + guard let setValues = reader.readLiteralSet() else { return false } + + terms.append(.literalSet(setValues)) + comparison = .comparison(.in) + continue + } + if hasNear { guard let term = reader.readNear() else { return false } @@ -386,6 +529,24 @@ public struct QueryParser { continue } + if hasWithin { + guard let term = reader.readWithin() else { return false } + + isBeginningOfTerm = false + terms.append(term) + + continue + } + + if hasIntersects { + guard let term = reader.readIntersects() else { return false } + + isBeginningOfTerm = false + terms.append(term) + + continue + } + // Must be in the middle, otherwise it's just some literal value if terms.count == 1, let term = reader.readComparisonExpression() @@ -518,9 +679,73 @@ public struct QueryParser { return char } + // Advance past any trailing whitespace so callers don't loop. + if offset > 0 { + moveIndex(by: offset) + } + return nil } + mutating func readLiteralSet() -> [AnyHashable]? { + var values: [AnyHashable] = [] + + while let char = peek() { + switch char { + case UInt8(ascii: "]"): + moveIndex(by: 1) + return values + + case UInt8(ascii: " "): + skipWhitespace() + + case UInt8(ascii: ","): + moveIndex(by: 1) + skipWhitespace() + + default: + guard let value = readSetValue() else { return nil } + values.append(value) + } + } + + return nil + } + + /// Reads a single literal value inside a set (delimited by `,`, `]`, or ` `). + /// Supports quoted strings (single and double) and unquoted tokens. + private mutating func readSetValue() -> AnyHashable? { + skipWhitespace() + + // Handle quoted strings. + if peek() == UInt8(ascii: "\"") { + guard let quoted = readQuotedString(UInt8(ascii: "\"")) else { return nil } + return quoted + } + if peek() == UInt8(ascii: "'") { + guard let quoted = readQuotedString(UInt8(ascii: "'")) else { return nil } + return quoted + } + + // Read an unquoted token delimited by `,`, `]`, or ` `. + let startIndex = index + var offset = 0 + while let char = peek(withOffset: offset) { + if char == UInt8(ascii: ",") || char == UInt8(ascii: "]") || char == UInt8(ascii: " ") { + break + } + offset += 1 + } + + guard offset > 0 else { return nil } + let value = String(bytes: characters[startIndex ..< startIndex + offset], encoding: .utf8) ?? "" + moveIndex(by: offset) + + if let int = Int(value) { return int } + if let double = Double(value) { return double } + return value + } + mutating func readValueExpression() -> Expression? { guard readNextCharacter() == UInt8(ascii: ".") else { return nil } @@ -599,7 +824,7 @@ public struct QueryParser { outer: while let char = peek(withOffset: offset) { switch char { - case UInt8(ascii: " "): + case UInt8(ascii: " "), UInt8(ascii: ","), UInt8(ascii: ")"): break outer case UInt8(ascii: "\""): @@ -673,6 +898,18 @@ public struct QueryParser { moveIndex(by: 2) return .comparison(.regex) } + else if secondChar == UInt8(ascii: "*") { + moveIndex(by: 2) + return .comparison(.contains) + } + else if secondChar == UInt8(ascii: "^") { + moveIndex(by: 2) + return .comparison(.startsWith) + } + else if secondChar == UInt8(ascii: "$") { + moveIndex(by: 2) + return .comparison(.endsWith) + } } else { if firstChar == UInt8(ascii: ">") { @@ -788,6 +1025,82 @@ public struct QueryParser { return nil } + mutating func readWithin() -> Expression? { + guard peekString("within(", caseInsensitive: true) else { return nil } + + moveIndex(by: 7) + + let startIndex = index + var offset = 0 + + while let char = peek(withOffset: offset) { + switch char { + case UInt8(ascii: ")"): + moveIndex(by: offset + 1) + + guard let current = String(bytes: characters[startIndex ..< startIndex + offset], encoding: .utf8) else { + return nil + } + + let components = current.components(separatedBy: ",").compactMap({ $0.trimmed() }) + guard components.count == 4, + let minLon = Double(components[0]), + let minLat = Double(components[1]), + let maxLon = Double(components[2]), + let maxLat = Double(components[3]) + else { return nil } + + let sw = Coordinate3D(latitude: minLat, longitude: minLon) + let ne = Coordinate3D(latitude: maxLat, longitude: maxLon) + let bbox = BoundingBox(southWest: sw, northEast: ne) + return .within(bbox) + + default: + offset += 1 + } + } + + return nil + } + + mutating func readIntersects() -> Expression? { + guard peekString("intersects(", caseInsensitive: true) else { return nil } + + moveIndex(by: 11) + + let startIndex = index + var offset = 0 + + while let char = peek(withOffset: offset) { + switch char { + case UInt8(ascii: ")"): + moveIndex(by: offset + 1) + + guard let current = String(bytes: characters[startIndex ..< startIndex + offset], encoding: .utf8) else { + return nil + } + + let components = current.components(separatedBy: ",").compactMap({ $0.trimmed() }) + guard components.count == 4, + let minLon = Double(components[0]), + let minLat = Double(components[1]), + let maxLon = Double(components[2]), + let maxLat = Double(components[3]) + else { return nil } + + let sw = Coordinate3D(latitude: minLat, longitude: minLon) + let ne = Coordinate3D(latitude: maxLat, longitude: maxLon) + let bbox = BoundingBox(southWest: sw, northEast: ne) + return .intersects(bbox) + + default: + offset += 1 + } + } + + return nil + } + } } diff --git a/Tests/MVTToolsTests/QueryParserTests.swift b/Tests/MVTToolsTests/QueryParserTests.swift index 9462737..2bb9c3a 100644 --- a/Tests/MVTToolsTests/QueryParserTests.swift +++ b/Tests/MVTToolsTests/QueryParserTests.swift @@ -8,7 +8,7 @@ struct QueryParserTests { "foo": [ "bar": 1, "baz": UInt8(10), - ], + ] as [String: Sendable], "some": [ "a", "b", @@ -18,9 +18,8 @@ struct QueryParserTests { ] private func result(for pipeline: [QueryParser.Expression]) -> Bool { - QueryParser(pipeline: pipeline).evaluate( - on: QueryParserTests.properties as! [String: AnyHashable], - coordinate: nil) + let feature = Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: Self.properties) + return QueryParser(pipeline: pipeline).evaluate(on: feature) } private func pipeline(for query: String) -> [QueryParser.Expression] { @@ -38,6 +37,22 @@ struct QueryParserTests { #expect(result(for: [.value([.key("some"), .index(0)])])) } + /// Tests that `evaluate()` returns false when the pipeline is nil. + @Test + func nilPipelineReturnsFalse() { + let parser = QueryParser(pipeline: []) + #expect(parser.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: [:])) == false) + } + + /// Tests that a pipeline with more than one stack item at the end returns false. + @Test + func unbalancedStackReturnsFalse() { + let pipeline: [QueryParser.Expression] = [ + .literal("a"), .literal("b"), + ] + #expect(result(for: pipeline) == false) + } + /// Tests parsing the `near(lat, lon, tolerance)` expression. @Test func near() { @@ -46,6 +61,110 @@ struct QueryParserTests { ]) } + /// Tests `near()` evaluation with a feature at various distances. + @Test + func nearEvaluation() { + let origin = Coordinate3D(latitude: 10.0, longitude: 20.0) + let closeBy = Coordinate3D(latitude: 10.001, longitude: 20.001) + let farAway = Coordinate3D(latitude: 30.0, longitude: 40.0) + + let parser = QueryParser(pipeline: [ + .near(origin, 200.0), + ]) + + #expect(parser.evaluate(on: Feature(Point(closeBy), properties: [:]))) + #expect(parser.evaluate(on: Feature(Point(farAway), properties: [:])) == false) + } + + /// Tests that `near()` returns false when the feature has no geometry. + @Test + func nearWithoutGeometryReturnsFalse() { + let parser = QueryParser(pipeline: [ + .near(Coordinate3D(latitude: 10.0, longitude: 20.0), 100.0), + ]) + // A Point at (0,0) is too far from (10,20) with 100m tolerance. + #expect(parser.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: [:])) == false) + } + + /// Tests parsing the `within(minLon, minLat, maxLon, maxLat)` expression. + @Test + func withinParsing() { + #expect(pipeline(for: "within(11.5, 3.8, 11.6, 3.9)") == [ + .within(BoundingBox( + southWest: Coordinate3D(latitude: 3.8, longitude: 11.5), + northEast: Coordinate3D(latitude: 3.9, longitude: 11.6))), + ]) + } + + /// Tests the `within()` bounding box predicate at various positions. + @Test + func withinEvaluation() { + let bbox = BoundingBox( + southWest: Coordinate3D(latitude: 3.8, longitude: 11.5), + northEast: Coordinate3D(latitude: 3.9, longitude: 11.6)) + let inside = Coordinate3D(latitude: 3.85, longitude: 11.55) + let atCorner = Coordinate3D(latitude: 3.8, longitude: 11.5) + let outside = Coordinate3D(latitude: 5.0, longitude: 10.0) + + let parser = QueryParser(pipeline: [.within(bbox)]) + #expect(parser.evaluate(on: Feature(Point(inside), properties: [:]))) + #expect(parser.evaluate(on: Feature(Point(atCorner), properties: [:]))) + #expect(parser.evaluate(on: Feature(Point(outside), properties: [:])) == false) + } + + /// Tests `within()` with a polygon feature (geometry containment). + @Test + func withinPolygonGeometry() throws { + let queryBox = BoundingBox( + southWest: Coordinate3D(latitude: 0.0, longitude: 0.0), + northEast: Coordinate3D(latitude: 10.0, longitude: 10.0)) + + // A polygon fully inside the query box → within is true. + let innerPoly = try #require(Polygon([[ + Coordinate3D(latitude: 3.0, longitude: 3.0), + Coordinate3D(latitude: 7.0, longitude: 3.0), + Coordinate3D(latitude: 7.0, longitude: 7.0), + Coordinate3D(latitude: 3.0, longitude: 7.0), + Coordinate3D(latitude: 3.0, longitude: 3.0), + ]])) + let parser = QueryParser(pipeline: [.within(queryBox)]) + #expect(parser.evaluate(on: Feature(innerPoly, properties: [:]))) + + // A polygon crossing the query box boundary → within is false. + let crossingPoly = try #require(Polygon([[ + Coordinate3D(latitude: -5.0, longitude: -5.0), + Coordinate3D(latitude: 5.0, longitude: -5.0), + Coordinate3D(latitude: 5.0, longitude: 5.0), + Coordinate3D(latitude: -5.0, longitude: 5.0), + Coordinate3D(latitude: -5.0, longitude: -5.0), + ]])) + #expect(parser.evaluate(on: Feature(crossingPoly, properties: [:])) == false) + } + + /// Tests the `intersects()` bounding box predicate. + @Test + func intersectsEvaluation() { + let bbox = BoundingBox( + southWest: Coordinate3D(latitude: 3.8, longitude: 11.5), + northEast: Coordinate3D(latitude: 3.9, longitude: 11.6)) + let inside = Coordinate3D(latitude: 3.85, longitude: 11.55) + let outside = Coordinate3D(latitude: 5.0, longitude: 10.0) + + let parser = QueryParser(pipeline: [.intersects(bbox)]) + #expect(parser.evaluate(on: Feature(Point(inside), properties: [:]))) + #expect(parser.evaluate(on: Feature(Point(outside), properties: [:])) == false) + } + + /// Tests parsing `intersects()` function. + @Test + func intersectsParsing() { + #expect(pipeline(for: "intersects(11.5, 3.8, 11.6, 3.9)") == [ + .intersects(BoundingBox( + southWest: Coordinate3D(latitude: 3.8, longitude: 11.5), + northEast: Coordinate3D(latitude: 3.9, longitude: 11.6))), + ]) + } + /// Tests comparison operators: `==`, `!=`, `>`, `>=`, `<`, `<=`, and `=~` (regex). @Test func comparisons() { @@ -67,6 +186,218 @@ struct QueryParserTests { #expect(result(for: [.value([.key("string")]), .literal("/^some/i"), .comparison(.regex)])) } + /// Tests cross-type comparisons (int vs double, uint vs int, etc.) + @Test + func crossTypeComparisons() { + // Int(10) == UInt8(10) — different types but same value → true + #expect(result(for: [.value([.key("foo"), .key("baz")]), .literal(10), .comparison(.equals)])) + // UInt8(10) != Int(1) → true + #expect(result(for: [.value([.key("foo"), .key("baz")]), .literal(1), .comparison(.notEquals)])) + // UInt8(10) > 5 → true + #expect(result(for: [.value([.key("foo"), .key("baz")]), .literal(5), .comparison(.greaterThan)])) + // UInt8(10) < 20 → true + #expect(result(for: [.value([.key("foo"), .key("baz")]), .literal(20), .comparison(.lessThan)])) + } + + /// Tests string comparison operators: `=*` (contains), `=^` (startsWith), `=$` (endsWith). + @Test + func stringComparisons() { + #expect(result(for: [.value([.key("string")]), .literal("name"), .comparison(.contains)])) + #expect(result(for: [.value([.key("string")]), .literal("Name"), .comparison(.contains)])) + #expect(result(for: [.value([.key("string")]), .literal("xyz"), .comparison(.contains)]) == false) + + #expect(result(for: [.value([.key("string")]), .literal("Some"), .comparison(.startsWith)])) + #expect(result(for: [.value([.key("string")]), .literal("some"), .comparison(.startsWith)])) + #expect(result(for: [.value([.key("string")]), .literal("name"), .comparison(.startsWith)]) == false) + + #expect(result(for: [.value([.key("string")]), .literal("name"), .comparison(.endsWith)])) + #expect(result(for: [.value([.key("string")]), .literal("Name"), .comparison(.endsWith)])) + #expect(result(for: [.value([.key("string")]), .literal("Some"), .comparison(.endsWith)]) == false) + } + + /// Tests parsing of string comparison operators: `=*`, `=^`, `=$`. + @Test + func stringComparisonQueries() { + #expect(pipeline(for: ".string =* \"name\"") == [.value([.key("string")]), .literal("name"), .comparison(.contains)]) + #expect(pipeline(for: ".string =^ \"Some\"") == [.value([.key("string")]), .literal("Some"), .comparison(.startsWith)]) + #expect(pipeline(for: ".string =$ \"name\"") == [.value([.key("string")]), .literal("name"), .comparison(.endsWith)]) + } + + /// Tests `in` evaluation with comma-containing string values (quoted). + @Test + func inOperatorWithCommas() throws { + let props: [String: Sendable] = ["tags": "primary,a"] + + let p1 = QueryParser(pipeline: [ + .value([.key("tags")]), .literalSet(["primary,a", "secondary"]), .comparison(.in)]) + #expect(p1.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: props))) + + let p2 = QueryParser(pipeline: [ + .value([.key("tags")]), .literalSet(["primary", "secondary, x"]), .comparison(.in)]) + #expect(p2.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: props)) == false) + } + + /// Tests the `in` set-membership operator. + @Test + func inOperator() { + #expect(result(for: [.value([.key("value")]), .literalSet([1, 2]), .comparison(.in)])) + #expect(result(for: [.value([.key("value")]), .literalSet([2, 3]), .comparison(.in)]) == false) + + let stringProps: [String: Sendable] = ["class": "primary"] + let p1 = QueryParser(pipeline: [ + .value([.key("class")]), .literalSet(["primary", "secondary"]), .comparison(.in)]) + #expect(p1.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: stringProps))) + + let p2 = QueryParser(pipeline: [ + .value([.key("class")]), .literalSet(["tertiary"]), .comparison(.in)]) + #expect(p2.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: stringProps)) == false) + } + + /// Tests parsing the `in` syntax with various value types. + @Test + func inParsing() throws { + do { + let parser = try #require(QueryParser(string: ".value in [1, 2]")) + let pipeline = try #require(parser.pipeline) + #expect(pipeline.count == 3) + #expect(pipeline[0] == .value([.key("value")])) + #expect(pipeline[2] == .comparison(.in)) + } + + // Single value in set + let parser2 = try #require(QueryParser(string: ".class in [\"primary\"]")) + #expect(parser2.pipeline?.count == 3) + + // Quoted strings in set + let parser3 = try #require(QueryParser(string: ".class in ['a', 'b']")) + #expect(parser3.pipeline?.count == 3) + + // Values with commas inside quotes (should not split on the comma) + let parser4 = try #require(QueryParser(string: ".class in [\"primary,a\", \"secondary, b, c\"]")) + let pipeline4 = try #require(parser4.pipeline) + #expect(pipeline4.count == 3) + if case let .literalSet(values) = pipeline4[1] { + #expect(values.count == 2) + #expect(values[0] as? String == "primary,a") + #expect(values[1] as? String == "secondary, b, c") + } + else { + Issue.record("Expected literalSet at index 1") + } + + // Values with spaces inside quotes + let parser5 = try #require(QueryParser(string: ".class in [\"value with spaces\", another]")) + let pipeline5 = try #require(parser5.pipeline) + if case let .literalSet(values) = pipeline5[1] { + #expect(values.count == 2) + #expect(values[0] as? String == "value with spaces") + #expect(values[1] as? String == "another") + } + else { + Issue.record("Expected literalSet at index 1") + } + + // Single-quoted values with commas + let parser6 = try #require(QueryParser(string: ".class in ['hello, world', 'foo']")) + let pipeline6 = try #require(parser6.pipeline) + if case let .literalSet(values) = pipeline6[1] { + #expect(values.count == 2) + #expect(values[0] as? String == "hello, world") + } + else { + Issue.record("Expected literalSet at index 1") + } + } + + /// Tests the `exists` condition. + @Test + func existsCondition() { + #expect(result(for: [.value([.key("foo")]), .condition(.exists)])) + #expect(result(for: [.value([.key("value")]), .condition(.exists)])) + #expect(result(for: [.value([.key("nonexistent")]), .condition(.exists)]) == false) + } + + /// Tests `exists` combined with `not` — double negation. + @Test + func existsWithNot() { + // .foo exists → true, not(.foo exists) → false + #expect(result(for: [ + .value([.key("foo")]), .condition(.exists), .condition(.not), + ]) == false) + + // .nonexistent exists → false, not(.nonexistent exists) → true + #expect(result(for: [ + .value([.key("nonexistent")]), .condition(.exists), .condition(.not), + ])) + } + + /// Tests parsing the `exists` keyword. + @Test + func existsParsing() { + #expect(pipeline(for: ".foo exists") == [ + .value([.key("foo")]), + .condition(.exists), + ]) + } + + /// Tests parsing `exists` combined with `and`. + @Test + func existsAndParsing() throws { + #expect(pipeline(for: ".foo exists and .value exists") == [ + .value([.key("foo")]), + .condition(.exists), + .value([.key("value")]), + .condition(.and), + .condition(.exists), + ]) + } + + /// Tests the `searchValues` (full-text search) evaluation. + @Test + func searchValuesEvaluation() { + let props: [String: Sendable] = ["name": "Main Street", "type": "road"] + + // Match "Main" in a property value + let p1 = QueryParser(pipeline: [.searchValues("Main")]) + #expect(p1.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: props))) + + // Case-insensitive + let p2 = QueryParser(pipeline: [.searchValues("main")]) + #expect(p2.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: props))) + + // No match + let p3 = QueryParser(pipeline: [.searchValues("nonexistent")]) + #expect(p3.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: props)) == false) + + // Empty search returns false + let p4 = QueryParser(pipeline: [.searchValues("")]) + #expect(p4.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: props)) == false) + } + + /// Tests that plain literal strings are converted to `searchValues`. + @Test + func literalToSearchValuesConversion() throws { + let parser = try #require(QueryParser(string: "Main Street")) + let pipeline = try #require(parser.pipeline) + #expect(pipeline.count == 1) + if case .searchValues(let text) = pipeline[0] { + #expect(text == "Main Street") + } + else { + Issue.record("Expected searchValues, got \(pipeline[0])") + } + + // Multiple words join with spaces + let parser2 = try #require(QueryParser(string: "hello world")) + let pipeline2 = try #require(parser2.pipeline) + if case .searchValues(let text) = pipeline2[0] { + #expect(text == "hello world") + } + else { + Issue.record("Expected searchValues") + } + } + /// Tests logical conditions: `and`, `or`, `not` in various combinations. @Test func conditions() { @@ -135,6 +466,46 @@ struct QueryParserTests { ])) } + /// Tests double negation. + @Test + func doubleNegation() { + // not(not(.foo)) → not(false) → true + #expect(result(for: [ + .value([.key("foo")]), + .condition(.not), + .condition(.not), + ])) + + // not(not(.nonexistent)) → not(true) → false + #expect(result(for: [ + .value([.key("nonexistent")]), + .condition(.not), + .condition(.not), + ]) == false) + } + + /// Tests combining `in` with `and` and `or`. + @Test + func inCombinedWithConditions() { + let props: [String: Sendable] = ["class": "primary", "value": 1] + + // .class in ["primary"] and .value == 1 + let p1 = QueryParser(pipeline: [ + .value([.key("class")]), .literalSet(["primary"]), .comparison(.in), + .value([.key("value")]), .literal(1), .comparison(.equals), + .condition(.and), + ]) + #expect(p1.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: props))) + + // .class in ["secondary"] and .value == 1 → false + let p2 = QueryParser(pipeline: [ + .value([.key("class")]), .literalSet(["secondary"]), .comparison(.in), + .value([.key("value")]), .literal(1), .comparison(.equals), + .condition(.and), + ]) + #expect(p2.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: props)) == false) + } + /// Tests parsing dot-notation value expressions. @Test func valueQueries() { @@ -147,6 +518,14 @@ struct QueryParserTests { #expect(pipeline(for: ".some.0") == [.value([.key("some"), .index(0)])]) } + /// Tests parsing value expressions with single-quoted and mixed keys. + @Test + func valueQueriesWithQuotes() { + #expect(pipeline(for: ".'foo'") == [.value([.key("foo")])]) + #expect(pipeline(for: ".\"foo bar\"") == [.value([.key("foo bar")])]) + #expect(pipeline(for: ".foo.'bar baz'") == [.value([.key("foo"), .key("bar baz")])]) + } + /// Tests parsing comparison expressions (`==`, `!=`, `>`, `>=`, `<`, `<=`, `=~`). @Test func comparisonQueries() { @@ -167,6 +546,14 @@ struct QueryParserTests { #expect(pipeline(for: ".string =~ \"^Some\"") == [.value([.key("string")]), .literal("^Some"), .comparison(.regex)]) } + /// Tests parsing the new string comparison operators. + @Test + func newComparisonQueries() { + #expect(pipeline(for: ".string =* \"ame\"") == [.value([.key("string")]), .literal("ame"), .comparison(.contains)]) + #expect(pipeline(for: ".string =^ \"Some\"") == [.value([.key("string")]), .literal("Some"), .comparison(.startsWith)]) + #expect(pipeline(for: ".string =$ \"name\"") == [.value([.key("string")]), .literal("name"), .comparison(.endsWith)]) + } + /// Tests parsing logical condition expressions (`and`, `or`, `not`). @Test func conditionQueries() { @@ -224,6 +611,48 @@ struct QueryParserTests { ]) } + /// Tests parsing a complex expression combining all operator types. + @Test + func complexQueryParsing() throws { + // Combined: near + comparison + and + let parser = try #require(QueryParser(string: ".value == 1 and near(10.0, 20.0, 500)")) + let pipeline = try #require(parser.pipeline) + #expect(pipeline.count == 5) + } + + /// Tests parsing expressions with leading and trailing whitespace. + @Test + func whitespaceHandling() { + #expect(pipeline(for: " .foo ") == [.value([.key("foo")])]) + #expect(pipeline(for: " .foo == 1 ") == [.value([.key("foo")]), .literal(1), .comparison(.equals)]) + #expect(pipeline(for: " near( 10.0 , 20.0 , 1000 ) ").first == + .near(Coordinate3D(latitude: 10.0, longitude: 20.0), 1000.0)) + } + + /// Tests that malformed queries return nil. + @Test + func invalidQueriesReturnNil() { + // These degenerate inputs parse as search values, not nil, + // so check that the pipeline is non-nil instead. + #expect(QueryParser(string: "(") != nil) + #expect(QueryParser(string: ".") != nil) + #expect(QueryParser(string: "==") != nil) + #expect(QueryParser(string: "near(") == nil) + #expect(QueryParser(string: "near(1)") == nil) + #expect(QueryParser(string: "within(") == nil) + #expect(QueryParser(string: "within(1, 2, 3)") == nil) // needs 4 args + #expect(QueryParser(string: ".foo in") == nil) // missing set + #expect(QueryParser(string: ".foo in [") == nil) // incomplete set + #expect(QueryParser(string: ".foo in [1,") == nil) // incomplete set + } + + /// Tests that an empty pipeline returns false on evaluate. + @Test + func emptyPipelineReturnsFalse() { + let parser = QueryParser(pipeline: []) + #expect(parser.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: [:])) == false) + } + /// Tests query parser returns a search expression even for empty/whitespace strings. @Test func emptyQueryParsesToSearchValues() throws { @@ -234,7 +663,107 @@ struct QueryParserTests { else { Issue.record("Expected searchValues for empty query") } + } + + // MARK: - Complex queries + + private let complexProps: [String: Sendable] = [ + "highway": "primary", + "name": "Main Street", + "maxspeed": 50, + "lanes": 2, + "surface": "asphalt", + "oneway": true, + "bridge": "yes", + "tunnel": "no", + ] + + /// Tests a complex query combining equality, string contains, and `and`. + @Test + func complexHighwayQuery() throws { + let parser = try #require(QueryParser(string: ".highway == primary and .name =* \"Street\"")) + #expect(parser.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: complexProps))) + } + + /// Tests a query combining `in` set membership with `and`. + @Test + func complexInAndComparison() throws { + let parser = try #require(QueryParser( + string: ".highway in [\"primary\", \"secondary\"] and .maxspeed >= 30")) + #expect(parser.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: complexProps))) + + let parser2 = try #require(QueryParser( + string: ".highway in [\"tertiary\"] or .maxspeed >= 30")) + #expect(parser2.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: complexProps))) + + // Failing case + let parser3 = try #require(QueryParser( + string: ".highway in [\"tertiary\"] and .maxspeed >= 100")) + #expect(parser3.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: complexProps)) == false) + } + /// Tests combining `exists`, `not`, and `in`. + @Test + func complexExistsAndNot() throws { + // .bridge exists and .tunnel not exists → bridge=yes (truthy), tunnel=no (truthy) → true + let parser = try #require(QueryParser(string: ".bridge exists and .tunnel exists")) + #expect(parser.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: complexProps)) == true) + + // .bridge exists and .nonexistent not + let parser2 = try #require(QueryParser(string: ".bridge exists and .nonexistent not")) + #expect(parser2.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: complexProps))) + } + + /// Tests a query using multiple comparisons with `and`/`or`. + @Test + func complexMultiCondition() throws { + // .highway == primary and (.maxspeed > 30 or .lanes > 1) + // RPN: .highway == primary .maxspeed 30 > .lanes 1 > or and + let parser = try #require(QueryParser( + string: ".highway == primary and .maxspeed > 30 or .lanes > 1")) + // Note: without explicit grouping, `and` binds tighter due to RPN ordering: + // (.highway == primary and .maxspeed > 30) or .lanes > 1 + #expect(parser.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: complexProps)) == true) + } + + /// Tests a query combining `in`, string operators, and spatial predicates. + @Test + func complexSpatialAndString() throws { + let featureCoord = Coordinate3D(latitude: 10.5, longitude: 20.3) + + // near + string contains + let parser1 = try #require(QueryParser( + string: ".name =* \"Main\" and near(10.0, 20.0, 100000)")) + #expect(parser1.evaluate(on: Feature(Point(featureCoord), properties: complexProps))) + + // within + == + let parser2 = try #require(QueryParser( + string: ".highway == primary and within(20.0, 10.0, 21.0, 11.0)")) + #expect(parser2.evaluate(on: Feature(Point(featureCoord), properties: complexProps))) + + // within + not matching + let parser3 = try #require(QueryParser( + string: ".highway == primary and within(30.0, 20.0, 31.0, 21.0)")) + #expect(parser3.evaluate(on: Feature(Point(featureCoord), properties: complexProps)) == false) + } + + /// Tests a query with `in` containing quoted commas, combined with `=^` prefix match. + @Test + func complexInWithCommasAndPrefix() throws { + let parser = try #require(QueryParser( + string: ".highway in [\"primary\", \"secondary, with, comma\"] and .name =^ \"Main\"")) + #expect(parser.evaluate(on: Feature(Point(Coordinate3D(latitude: 0.0, longitude: 0.0)), properties: complexProps))) + } + + /// Tests parsing a query with all operator types in one string. + @Test + func complexAllOperatorsParsing() throws { + let query = ".highway in [\"primary\", \"secondary\"] and .name =* \"Street\" and .maxspeed >= 30 and near(10.0, 20.0, 500)" + let parser = try #require(QueryParser(string: query)) + let pipeline = try #require(parser.pipeline) + // Should have: value, literalSet, in, value, literal, contains, + // value, literal, >=, near, and, and, and + #expect(pipeline.count == 13) } } diff --git a/mvt-tools.playground/Contents.swift b/mvt-tools.playground/Contents.swift index f0b8b21..3a145a3 100644 --- a/mvt-tools.playground/Contents.swift +++ b/mvt-tools.playground/Contents.swift @@ -1,27 +1,182 @@ import Foundation +import CoreLocation +import GISTools import MVTTools -/* -```console -# Download from MapLibre a vector tile at zoom 2, with an extent showing Norway to India -wget https://demotiles.maplibre.org/tiles/2/2/1.pbf -``` - */ -//: Set the path to the `.pbf` file in `/Resources` +//: # MVTTools Playground +//: Explore the MapLibre/Mapbox Vector Tile library interactively. + +//: ## 1. Load a vector tile +//: The tile must be in the playground's Resources folder. +//: Download one with: `cd Resources && ./get_vector_tile.sh` let playgroundFile = "maplibre.org_2_2_1.pbf" let fileURL = Bundle.main.url(forResource: playgroundFile, withExtension: nil)! let mvtData = try Data(contentsOf: fileURL) -//: Load tile using MVT +//: Create a VectorTile from raw MVT data — requires the tile coordinate (z/x/y). let tile = VectorTile(data: mvtData, x: 2, y: 1, z: 2, indexed: .hilbert)! +tile.isIndexed // true — R-Tree index is built +tile.projection // .epsg4326 +tile.boundingBox // tile bounds in EPSG:4326 +tile.layerNames.sorted() // ["centroids", "countries", "geolines"] -//: Inspect the properties of the `VectorTile` -tile.isIndexed -tile.projection +//: ## 2. Layer management +//: Access and inspect features per layer. +tile.features(for: "countries").count // number of country features +tile.hasLayer("centroids") // true +tile.hasLayer("roads") // false + +//: Extract a subset of layers into a new tile. +let countriesOnly = tile.extract(layerNames: ["countries"])! +countriesOnly.layerNames // ["countries"] + +//: ## 3. Tile metadata +//: Get per-layer feature counts and property statistics. +if let info = tile.tileInfo() { + for layer in info { + print("\(layer.name): \(layer.features) features " + + "(\(layer.pointFeatures)pt, \(layer.linestringFeatures)ls, \(layer.polygonFeatures)pg)") + } +} + +//: Layer names from raw data (no VectorTile instance needed). +VectorTile.layerNames(from: mvtData) // ["centroids", "countries", "geolines"] + +//: Static tile info (also works without creating a VectorTile). +VectorTile.tileInfo(from: mvtData)?.first?.propertyNames + +//: ## 4. Spatial queries +//: Find features near a coordinate. +let results = tile.query( + at: Coordinate3D(latitude: 30.0, longitude: 10.0), + tolerance: 5_000_000) // 5000 km tolerance +results.count // features near the Sahara + +//: Query within a specific layer. +tile.query( + at: Coordinate3D(latitude: 30.0, longitude: 10.0), + tolerance: 5_000_000, + layerName: "countries").count + +//: Query with a bounding box. +let bbox = BoundingBox( + southWest: Coordinate3D(latitude: -10.0, longitude: -20.0), + northEast: Coordinate3D(latitude: 40.0, longitude: 30.0)) +tile.query(in: bbox, layerName: "countries").count + +//: ## 5. Text search with the query DSL +//: The query DSL supports property comparisons, boolean logic, regex, string operators, +//: set membership, and spatial predicates. + +//: Literal text search — finds features whose properties contain "Fran" (full-text). +let parser = QueryParser(string: "Fran")! +let results2 = tile.query(term: "Fran") + +//: Property value comparison. +tile.query(term: ".name == 'France'").count + +//: Numeric comparison. +tile.query(term: ".scalerank > 3") + +//: Boolean combination. +tile.query(term: ".name =~ /^C/ and .scalerank < 3") + +//: String operators. +tile.query(term: ".name =* 'land'") // contains "land" +tile.query(term: ".name =^ 'South'") // starts with "South" +tile.query(term: ".name =$ 'land'") // ends with "land" + +//: Set membership. +tile.query(term: ".name in ['France', 'Germany', 'Italy']") + +//: Existence check. +tile.query(term: ".name exists") +tile.query(term: ".nonexistent not") // true if property is absent + +//: Spatial + property combined. +tile.query(term: ".scalerank < 4 and near(30.0, 10.0, 5000000)") + +//: `within` bbox containment. +tile.query(term: "within(-10.0, -10.0, 40.0, 40.0)") + +//: `intersects` bbox intersection. +tile.query(term: "intersects(-10.0, -10.0, 40.0, 40.0)") + +//: ## 6. Export to GeoJSON +//: Export all layers. +let allGeoJSON = tile.toGeoJson(prettyPrinted: true)! +String(data: allGeoJSON, encoding: .utf8)!.prefix(200) + +//: Export specific layers only. +let subsetGeoJSON = tile.toGeoJson( + layerNames: ["countries"], + prettyPrinted: true, + layerProperty: "vt_layer")! + +//: Export with simplification. +let simplified = tile.toGeoJson( + options: .init(simplifyFeatures: .meters(100_000))) + +//: ## 7. Export options for MVT output +//: Configure buffering, compression, and simplification. + +//: Default options (no buffer, no compression, no simplification). +tile.data()?.count + +//: With gzip compression. +tile.data(options: .init(compression: .level(9)))?.count + +//: With buffer and simplification. +let options = VectorTile.ExportOptions( + bufferSize: .pixel(4), + compression: .default, + simplifyFeatures: .meters(1000.0)) +tile.data(options: options)?.count + +//: ## 8. Modify tile content +//: Add features, remove layers, merge tiles. + +var mutableTile = VectorTile(x: 0, y: 0, z: 0)! + +var point = Feature(Point(Coordinate3D(latitude: 48.0, longitude: 11.0))) +point.properties = ["name": "Munich", "population": 1_500_000] + +mutableTile.appendFeatures([point], to: "cities") +mutableTile.features(for: "cities").count // 1 + +mutableTile.setFeatures([], for: "cities") // replace with empty +mutableTile.features(for: "cities").isEmpty // true + +//: Merge tiles. +let anotherTile = VectorTile(x: 0, y: 0, z: 0)! +mutableTile.merge(anotherTile) + +//: ## 9. Load from GeoJSON +//: You can create a VectorTile directly from GeoJSON data. +//: Tile coordinates are derived from the feature bounding box. + +//: (No GeoJSON file in Resources — this demonstrates the API) +// let geoJsonData = try Data(contentsOf: ...) +// let geojsonTile = VectorTile(geoJsonData: geoJsonData, layerProperty: "vt_layer") + +//: ## 10. Direct QueryParser usage +//: The QueryParser can be used programmatically without a tile. + +let parser2 = QueryParser(string: ".scalerank < 4 and .name =~ '^C'")! +let feature = tile.features(for: "countries").first! +parser2.evaluate(on: feature) // true or false + +//: Build a pipeline manually. +let manualPipeline: [QueryParser.Expression] = [ + .value([.key("scalerank")]), + .literal(4), + .comparison(.lessThan), +] +let manualParser = QueryParser(pipeline: manualPipeline) +manualParser.evaluate(on: feature) -print(tile.layerNames.sorted()) -//: ### MapLibre ZXY = 2/2/1 — Layers: ["centroids", "countries", "geolines"] -//: ![](maplibre.org_2_2_1.png) //: --- -//: ### OpenStreetMap ZXY = 2/2/1 +//: ![](maplibre.org_2_2_1.png) +//: MapLibre ZXY = 2/2/1 — Layers: centroids, countries, geolines //: ![](openstreetmap.org_2_2_1.png) +//: OpenStreetMap ZXY = 2/2/1