diff --git a/package-lock.json b/package-lock.json index 01758acb86..600f9bef8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5400,6 +5400,13 @@ "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", "dev": true }, + "node_modules/@types/mustache": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/@types/mustache/-/mustache-4.2.6.tgz", + "integrity": "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.15.24", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.24.tgz", @@ -13950,6 +13957,15 @@ "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", "dev": true }, + "node_modules/mustache": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", + "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", + "license": "MIT", + "bin": { + "mustache": "bin/mustache" + } + }, "node_modules/mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -19598,6 +19614,7 @@ "@linzjs/docker-command": "^7.5.0", "@linzjs/geojson": "^8.0.0", "cmd-ts": "^0.12.1", + "mustache": "^4.2.0", "p-limit": "^6.2.0", "polylabel": "^2.0.1", "stac-ts": "^1.0.0", @@ -19609,6 +19626,7 @@ }, "devDependencies": { "@types/geojson": "^7946.0.7", + "@types/mustache": "^4.2.6", "@types/polylabel": "^1.1.3", "@types/tar-stream": "^2.2.2" }, diff --git a/packages/cli-vector/package.json b/packages/cli-vector/package.json index cc27195f20..cad12347ea 100644 --- a/packages/cli-vector/package.json +++ b/packages/cli-vector/package.json @@ -37,6 +37,7 @@ }, "devDependencies": { "@types/geojson": "^7946.0.7", + "@types/mustache": "^4.2.6", "@types/polylabel": "^1.1.3", "@types/tar-stream": "^2.2.2" }, @@ -56,6 +57,7 @@ "@linzjs/docker-command": "^7.5.0", "@linzjs/geojson": "^8.0.0", "cmd-ts": "^0.12.1", + "mustache": "^4.2.0", "p-limit": "^6.2.0", "polylabel": "^2.0.1", "stac-ts": "^1.0.0", diff --git a/packages/cli-vector/schema/addresses.json b/packages/cli-vector/schema/addresses.json index 1429d81b88..15f62bbaf5 100644 --- a/packages/cli-vector/schema/addresses.json +++ b/packages/cli-vector/schema/addresses.json @@ -1,13 +1,14 @@ { "name": "addresses", - "metadata": { "attributes": ["housenumber", "name", "id"] }, + "description": "This layer contains address features represented as `Point` geometries. Each feature includes a `housenumber` property.", + "metadata": { "attributes": ["housenumber"] }, "layers": [ { "id": "105689", "name": "105689-nz-addresses-pilot", "source": "s3://linz-lds-cache/105689/", "tags": {}, - "attributes": { "address_id": "id", "full_address_number": "housenumber" }, + "attributes": { "full_address_number": "housenumber" }, "style": { "minZoom": 15, "maxZoom": 15 } } ] diff --git a/packages/cli-vector/schema/aerialways.json b/packages/cli-vector/schema/aerialways.json index fa29381c0c..8512bd2d57 100644 --- a/packages/cli-vector/schema/aerialways.json +++ b/packages/cli-vector/schema/aerialways.json @@ -1,6 +1,7 @@ { "name": "aerialways", - "metadata": { "attributes": ["kind", "feature", "name"] }, + "description": "This layer holds aerialways as `LineString` geometries.", + "metadata": { "attributes": ["kind", "feature"] }, "layers": [ { "id": "50248", diff --git a/packages/cli-vector/schema/boundaries.json b/packages/cli-vector/schema/boundaries.json index cb0e9a18ad..e949cb2bdf 100644 --- a/packages/cli-vector/schema/boundaries.json +++ b/packages/cli-vector/schema/boundaries.json @@ -1,5 +1,6 @@ { "name": "boundaries", + "description": "This layer contains coastline features represented as `Polygon` geometries, indicating land-sea boundaries. Most features include a `name` property.", "metadata": { "attributes": ["name"] }, "simplify": [ { "style": { "minZoom": 0, "maxZoom": 0 }, "tolerance": 0.1 }, diff --git a/packages/cli-vector/schema/contours.json b/packages/cli-vector/schema/contours.json index 817f6a35d1..8c164b289d 100644 --- a/packages/cli-vector/schema/contours.json +++ b/packages/cli-vector/schema/contours.json @@ -1,5 +1,7 @@ { "name": "contours", + "description": "This layer contains contour line interval features represented as `LineString` geometries, and height peak features represents as `Point` geometries.", + "custom": true, "metadata": { "attributes": ["designated", "type", "nat_form", "elevation", "kind", "name", "rank"] }, "layers": [ { diff --git a/packages/cli-vector/schema/ferries.json b/packages/cli-vector/schema/ferries.json index d4449f422c..399c2de377 100644 --- a/packages/cli-vector/schema/ferries.json +++ b/packages/cli-vector/schema/ferries.json @@ -1,6 +1,6 @@ { "name": "ferries", - "metadata": { "attributes": ["kind", "name"] }, + "metadata": { "attributes": ["kind"] }, "layers": [ { "id": "50269", diff --git a/packages/cli-vector/schema/land.json b/packages/cli-vector/schema/land.json index 535f31350c..124c209a41 100644 --- a/packages/cli-vector/schema/land.json +++ b/packages/cli-vector/schema/land.json @@ -1,5 +1,6 @@ { "name": "land", + "description": "This layer contains various land-related features represented as `LineString` and `Polygon` geometries. `Polygon` features represent areas, such as forests or mangroves, while `LineString` features delineate vectors such as cliffs or embankments.", "metadata": { "attributes": [ "kind", diff --git a/packages/cli-vector/schema/place_labels.json b/packages/cli-vector/schema/place_labels.json index 59853dac8f..83e2bc362d 100644 --- a/packages/cli-vector/schema/place_labels.json +++ b/packages/cli-vector/schema/place_labels.json @@ -1,6 +1,6 @@ { "name": "place_labels", - "metadata": { "attributes": ["water", "name", "natural", "place"] }, + "metadata": { "attributes": ["kind", "water", "name", "natural", "place"] }, "layers": [ { "id": "51154", diff --git a/packages/cli-vector/schema/pois.json b/packages/cli-vector/schema/pois.json index e322b88e87..2c76a399fa 100644 --- a/packages/cli-vector/schema/pois.json +++ b/packages/cli-vector/schema/pois.json @@ -1,5 +1,6 @@ { "name": "pois", + "description": "This layer contains features with `Point` geometries.", "metadata": { "attributes": [ "leisure", @@ -18,7 +19,6 @@ "barrier", "historic", "ref", - "ladder", "fortification_type", "power", "farmyard", @@ -580,7 +580,7 @@ "id": "50291", "name": "50291-nz-ladder-points-topo-150k", "source": "s3://linz-lds-cache/50291/", - "tags": { "ladder": "yes" }, + "tags": { "man_made": "ladder" }, "style": { "minZoom": 12, "maxZoom": 15 } }, { @@ -792,6 +792,20 @@ "source": "s3://linz-lds-cache/103476/", "tags": { "man_made": "trig_point" }, "style": { "minZoom": 12, "maxZoom": 15 } + }, + { + "id": "50275", + "name": "50275-nz-ford-points-topo-150k", + "source": "s3://linz-lds-cache/50275/", + "tags": { "waterway": "ford" }, + "style": { "minZoom": 12, "maxZoom": 15 } + }, + { + "id": "50080", + "name": "50080-nz-chatham-island-ford-points-topo-150k", + "source": "s3://linz-lds-cache/50080/", + "tags": { "waterway": "ford" }, + "style": { "minZoom": 12, "maxZoom": 15 } } ] } diff --git a/packages/cli-vector/schema/streets.json b/packages/cli-vector/schema/streets.json index dbeaea167a..1b89ce97ef 100644 --- a/packages/cli-vector/schema/streets.json +++ b/packages/cli-vector/schema/streets.json @@ -176,83 +176,6 @@ { "style": { "minZoom": 11, "maxZoom": 15 } } ] }, - { - "id": "50237", - "name": "50237-nz-airport-polygons-topo-150k", - "source": "s3://linz-lds-cache/50237/", - "tags": { "kind": "aerodrome" }, - "style": { "minZoom": 10, "maxZoom": 15 } - }, - { - "id": "50063", - "name": "50063-nz-chatham-island-airport-polygons-topo-150k", - "source": "s3://linz-lds-cache/50063/", - "tags": { "kind": "aerodrome" }, - "style": { "minZoom": 10, "maxZoom": 15 } - }, - { - "id": "52231", - "name": "52231-cook-islands-airport-polygons-topo-125k-zone4", - "source": "s3://linz-lds-cache/52231/", - "tags": { "kind": "aerodrome" }, - "style": { "minZoom": 0, "maxZoom": 15 } - }, - { - "id": "52168", - "name": "52168-niue-airport-polygons-topo-150k", - "source": "s3://linz-lds-cache/52168/", - "tags": { "kind": "aerodrome" }, - "style": { "minZoom": 0, "maxZoom": 15 } - }, - { - "id": "50333", - "name": "50333-nz-runway-polygons-topo-150k", - "source": "s3://linz-lds-cache/50333/", - "tags": { "kind": "runway" }, - "style": { "minZoom": 10, "maxZoom": 15 } - }, - { - "id": "50914", - "name": "50914-nz-kermadec-is-runway-polygons-topo-125k", - "source": "s3://linz-lds-cache/50914/", - "tags": { "kind": "runway" }, - "style": { "minZoom": 12, "maxZoom": 15 } - }, - { - "id": "52302", - "name": "52302-cook-islands-runway-polygons-topo-125k-zone3", - "source": "s3://linz-lds-cache/52302/", - "tags": { "kind": "runway" }, - "style": { "minZoom": 13, "maxZoom": 15 } - }, - { - "id": "52268", - "name": "52268-cook-islands-runway-polygons-topo-125k-zone4", - "source": "s3://linz-lds-cache/52268/", - "tags": { "kind": "runway" }, - "style": { "minZoom": 13, "maxZoom": 15 } - }, - { - "id": "52211", - "name": "52211-cook-islands-runway-polygons-topo-150k-zone4", - "source": "s3://linz-lds-cache/52211/", - "tags": { "kind": "runway" }, - "style": { "minZoom": 13, "maxZoom": 15 } - }, - { - "id": "52190", - "name": "52190-niue-runway-polygons-topo-150k", - "source": "s3://linz-lds-cache/52190/", - "tags": { "kind": "runway" }, - "style": { "minZoom": 13, "maxZoom": 15 } - }, - { - "id": "50103", - "name": "50103-nz-chatham-island-runway-polygons-topo-150k", - "source": "s3://linz-lds-cache/50103/", - "tags": { "kind": "runway" }, - "style": { "minZoom": 10, "maxZoom": 15 } - }, { "id": "50244", "name": "50244-nz-bridge-centrelines-topo-150k", @@ -281,20 +204,6 @@ "tags": { "kind": "secondary", "bridge": "true" }, "style": { "minZoom": 10, "maxZoom": 15 } }, - { - "id": "50275", - "name": "50275-nz-ford-points-topo-150k", - "source": "s3://linz-lds-cache/50275/", - "tags": { "kind": "secondary", "ford": "true" }, - "style": { "minZoom": 12, "maxZoom": 15 } - }, - { - "id": "50080", - "name": "50080-nz-chatham-island-ford-points-topo-150k", - "source": "s3://linz-lds-cache/50080/", - "tags": { "kind": "secondary", "ford": "true" }, - "style": { "minZoom": 12, "maxZoom": 15 } - }, { "id": "50366", "name": "50366-nz-tunnel-centrelines-topo-150k", diff --git a/packages/cli-vector/src/cli/cli.docs.ts b/packages/cli-vector/src/cli/cli.docs.ts new file mode 100644 index 0000000000..59d4150073 --- /dev/null +++ b/packages/cli-vector/src/cli/cli.docs.ts @@ -0,0 +1,191 @@ +import { fsa, Url, UrlFolder } from '@basemaps/shared'; +import { CliInfo } from '@basemaps/shared/build/cli/info.js'; +import { getLogger, logArguments } from '@basemaps/shared/build/cli/log.js'; +import { command, option } from 'cmd-ts'; +import { existsSync, mkdirSync, readFileSync } from 'fs'; +import Mustache from 'mustache'; +import { z } from 'zod'; + +import { zSchema } from '../schema-loader/parser.js'; +import { Schema } from '../schema-loader/schema.js'; +import { AttributeDoc, FeaturesDoc, LayerDoc } from '../types/doc.js'; +import { AttributeReport, LayerReport, zLayerReport } from '../types/report.js'; + +export const DocsArgs = { + ...logArguments, + schemas: option({ + type: UrlFolder, + long: 'schemas', + description: 'Path to the directory containing schemas from which to extract layer-specific information.', + }), + reports: option({ + type: UrlFolder, + long: 'reports', + description: + 'Path to the directory containing reports from which to extract layer, feature, and attribute information.', + }), + template: option({ + type: Url, + long: 'template', + description: 'Path to the Mustache template markdown file.', + }), + target: option({ + type: UrlFolder, + long: 'target', + description: 'Target directory into which to save the generated markdown documentation.', + }), +}; + +export const DocsCommand = command({ + name: 'docs', + version: CliInfo.version, + description: + 'Parses a directory of JSON report files and a Mustache template file to generate a collection of vector tile schema markdown files.', + args: DocsArgs, + async handler(args) { + const logger = getLogger(this, args, 'cli-vector'); + logger.info('Generate Markdown Docs: Start'); + + const targetExists = existsSync(args.target); + if (!targetExists) mkdirSync(args.target, { recursive: true }); + + // parse schema files + const schemas: Schema[] = []; + const schemaFiles = await fsa.toArray(fsa.list(args.schemas)); + + for (const file of schemaFiles) { + if (file.href.endsWith('.json')) { + const json = await fsa.readJson(file); + // Validate the json + try { + const parsed = zSchema.parse(json); + schemas.push(parsed); + } catch (e) { + if (e instanceof z.ZodError) { + throw new Error(`Schema ${file.href} is invalid: ${e.message}`); + } + } + } + } + + // parse report files + const reports: LayerReport[] = []; + const reportFiles = await fsa.toArray(fsa.list(args.reports)); + + for (const file of reportFiles) { + if (file.href.endsWith('.json')) { + const json = await fsa.readJson(file); + // Validate the json + try { + const parsed = zLayerReport.parse(json); + reports.push(parsed); + } catch (e) { + if (e instanceof z.ZodError) { + throw new Error(`Report ${file.href} is invalid: ${e.message}`); + } + } + } + } + + const docs: LayerDoc[] = []; + + for (const report of reports) { + const schema = schemas.find((schema) => schema.name === report.name); + if (schema == null) throw new Error(`Could not locate schema to pair with report: ${report.name}`); + + const attributes = flattenAttributes(report.all.attributes); + const zoom_levels = flattenZoomLevels(report.all.zoom_levels); + + const all: FeaturesDoc = { + name: 'all', + filter: '["all"]', + attributes, + hasAttributes: attributes.length > 0, + geometries: report.all.geometries.join(', '), + zoom_levels, + }; + + if (report.kinds == null) { + docs.push({ + name: report.name, + description: schema.description, + isCustom: schema.custom ?? false, + all, + }); + continue; + } + + const kinds: FeaturesDoc[] = []; + + for (const [name, kind] of Object.entries(report.kinds)) { + const attributes = flattenAttributes(kind.attributes); + const zoom_levels = flattenZoomLevels(kind.zoom_levels); + + kinds.push({ + name, + filter: `["all", ["==", "kind", "${name}"]]`, + attributes, + hasAttributes: attributes.length > 0, + geometries: kind.geometries.join(', '), + zoom_levels, + }); + } + + kinds.sort((a, b) => a.name.localeCompare(b.name)); + + docs.push({ + name: report.name, + description: schema.description, + isCustom: schema.custom ?? false, + all, + kinds, + }); + } + + const template = readFileSync(args.template).toString(); + + for (const layer of docs) { + const markdown = Mustache.render(template, layer); + const url = new URL(`${layer.name}.md`, args.target); + + await fsa.write(url, markdown); + logger.info({ url }, 'File created'); + } + + logger.info('Generate Markdown Docs: End'); + }, +}); + +function flattenAttributes(attributes: Record): AttributeDoc[] { + const attributeDocs: AttributeDoc[] = []; + + for (const [name, attribute] of Object.entries(attributes)) { + // flatten types + const types = attribute.types.join(', '); + + // flatten values + const values: string[] = []; + + if (!attribute.guaranteed) { + values.push('{empty}'); + } + + if (attribute.has_more_values === true) { + values.push(`Too many values to list`); + } else { + values.push(...attribute.values.sort().map(String)); + } + + // push attribute + attributeDocs.push({ name, types, values: values.join(', ') }); + } + + return attributeDocs.toSorted((a, b) => a.name.localeCompare(b.name)); +} + +function flattenZoomLevels(zoom_levels: number[]): { min: number; max: number } { + const min = Math.min(...zoom_levels); + const max = Math.max(...zoom_levels); + + return { min, max }; +} diff --git a/packages/cli-vector/src/cli/cli.reports.ts b/packages/cli-vector/src/cli/cli.reports.ts new file mode 100644 index 0000000000..f8cbb2f86e --- /dev/null +++ b/packages/cli-vector/src/cli/cli.reports.ts @@ -0,0 +1,185 @@ +import { DatabaseSync } from 'node:sqlite'; + +import { Url, UrlFolder } from '@basemaps/shared'; +import { CliInfo } from '@basemaps/shared/build/cli/info.js'; +import { getLogger, logArguments } from '@basemaps/shared/build/cli/log.js'; +import { VectorTile, VectorTileFeature } from '@mapbox/vector-tile'; +import { command, option } from 'cmd-ts'; +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import Protobuf from 'pbf'; +import { gunzipSync } from 'zlib'; + +import { AttributeReport, FeaturesReport, LayerReport } from '../types/report.js'; + +export const MaxUniqueValues = 20; +const MaxZoom = 15; + +export const ReportsArgs = { + ...logArguments, + mbtiles: option({ + type: Url, + long: 'mbtiles', + description: 'Path to the mbtiles from which to generate reports.', + }), + target: option({ + type: UrlFolder, + long: 'target', + description: 'Target directory into which to save the generated reports.', + }), +}; + +export const ReportsCommand = command({ + name: 'reports', + version: CliInfo.version, + description: + 'Parses an MBTiles file to extract and report detailed information about its contents. ' + + 'Identifies the layers, features within those layers, and attributes for each feature.', + args: ReportsArgs, + handler(args) { + const logger = getLogger(this, args, 'cli-vector'); + logger.info('Generate JSON Reports: Start'); + + const targetExists = existsSync(args.target); + if (!targetExists) mkdirSync(args.target, { recursive: true }); + + const db = new DatabaseSync(args.mbtiles); + const layerReports: Record = {}; + + /** + * for each zoom level + */ + for (let zoomLevel = 0; zoomLevel <= MaxZoom; zoomLevel++) { + logger.info({ zoomLevel }, 'Start'); + + const tiles = db + .prepare( + 'SELECT tile_column AS x, tile_row AS y, zoom_level AS z, tile_data AS data ' + + 'FROM tiles ' + + 'WHERE zoom_level = ?', + ) + .all(zoomLevel) as { x: number; y: number; z: number; data: Buffer }[]; + + /** + * for each of the zoom level's tiles + */ + for (const { x, y, z, data } of tiles) { + logger.info({ x, y, z }, 'Start'); + + const buffer = gunzipSync(data); + const tile = new VectorTile(new Protobuf(buffer)); + + /** + * for each of the tile's layers + */ + for (const [name, layer] of Object.entries(tile.layers)) { + if (layerReports[name] == null) { + // init a report for the current layer + layerReports[name] = { + name, + all: { attributes: {}, geometries: [], zoom_levels: [] }, + } as LayerReport; + } + + const layerReport = layerReports[name]; + + /** + * for each of the layer's features + */ + for (let i = 0; i < layer.length; i++) { + const feature = layer.feature(i); + const properties = feature.properties; + const geometry = VectorTileFeature.types[feature.type]; + const kind = properties['kind']; + + // the list of reports for which to capture the current feature's details + const featureReports: FeaturesReport[] = [layerReport.all]; + + if (typeof kind === 'string') { + if (layerReport.kinds == null) { + layerReport.kinds = {}; + } + + if (layerReport.kinds[kind] == null) { + // init a report for the current kind + layerReport.kinds[kind] = { attributes: {}, geometries: [], zoom_levels: [] } as FeaturesReport; + } + + // we also want to capture the feature's details against the corresponding 'kind' report + featureReports.push(layerReport.kinds[kind]); + } + + for (const featureReport of featureReports) { + /** + * for each of the feature's attributes (a.k.a. properties) + */ + for (const [name, value] of Object.entries(properties)) { + if (featureReport.attributes[name] == null) { + // init a report for the current attribute + featureReport.attributes[name] = { + guaranteed: true, + types: [], + values: [], + has_more_values: false, + } as AttributeReport; + } + + const attributeReport = featureReport.attributes[name]; + + // capture the feature's type + const type = typeof value; + + if (!attributeReport.types.includes(type)) { + attributeReport.types.push(type); + } + + // capture the feature's first 20 unique values + if (attributeReport.has_more_values === false) { + if (!attributeReport.values.includes(value)) { + if (attributeReport.values.length < MaxUniqueValues) { + attributeReport.values.push(value); + } else { + attributeReport.has_more_values = true; + } + } + } + + // capture the feature's geometry + if (!featureReport.geometries.includes(geometry)) { + featureReport.geometries.push(geometry); + } + + // capture the feature's zoom level + if (!featureReport.zoom_levels.includes(z)) { + featureReport.zoom_levels.push(z); + } + } + + // check the current feature's properties against the attributes already captured in the current report. + // an attribute is only 'guaranteed' if all features describe it + for (const [name, attribute] of Object.entries(featureReport.attributes)) { + if (attribute.guaranteed === true) { + if (properties[name] == null) { + attribute.guaranteed = false; + } + } + } + } + } + } + } + + logger.info({ zoomLevel }, 'End'); + } + + db.close(); + + // write each report to the target directory + for (const [name, report] of Object.entries(layerReports)) { + const url = new URL(`${name}.json`, args.target); + + writeFileSync(url, JSON.stringify(report, null, 2)); + logger.info({ url }, 'File created'); + } + logger.info('Generate JSON Reports: Done'); + }, +}); diff --git a/packages/cli-vector/src/index.ts b/packages/cli-vector/src/index.ts index a4eb55c497..d5387bede5 100644 --- a/packages/cli-vector/src/index.ts +++ b/packages/cli-vector/src/index.ts @@ -1,8 +1,10 @@ -// eslint-disable-next-line simple-import-sort/imports import { subcommands } from 'cmd-ts'; -import { ExtractCommand } from './cli/cli.extract.js'; + import { CreateCommand } from './cli/cli.create.js'; +import { DocsCommand } from './cli/cli.docs.js'; +import { ExtractCommand } from './cli/cli.extract.js'; import { JoinCommand } from './cli/cli.join.js'; +import { ReportsCommand } from './cli/cli.reports.js'; export const VectorCli = subcommands({ name: 'vector', @@ -10,5 +12,7 @@ export const VectorCli = subcommands({ extract: ExtractCommand, create: CreateCommand, join: JoinCommand, + reports: ReportsCommand, + docs: DocsCommand, }, }); diff --git a/packages/cli-vector/src/schema-loader/parser.ts b/packages/cli-vector/src/schema-loader/parser.ts index f10c34cfdf..c87fae7806 100644 --- a/packages/cli-vector/src/schema-loader/parser.ts +++ b/packages/cli-vector/src/schema-loader/parser.ts @@ -1,24 +1,26 @@ import { z } from 'zod'; +import { Attributes, Layer, Schema, Simplify, SpecialTag, Styling, Tags } from './schema.js'; + export const zStyling = z.object({ minZoom: z.number(), maxZoom: z.number(), detail: z.number().optional(), -}); +}) satisfies z.ZodType; -export const zTags = z.record(z.string().or(z.boolean())); +export const zTags = z.record(z.string().or(z.boolean())) satisfies z.ZodType; -export const zAttributes = z.record(z.string()); +export const zAttributes = z.record(z.string()) satisfies z.ZodType; export const zSpecialTag = z.object({ condition: z.string(), tags: zTags, -}); +}) satisfies z.ZodType; export const zSimplify = z.object({ style: zStyling, tolerance: z.number().optional(), -}); +}) satisfies z.ZodType; export const zLayer = z.object({ id: z.string(), @@ -30,16 +32,18 @@ export const zLayer = z.object({ style: zStyling, simplify: z.array(zSimplify).optional(), tippecanoe: z.array(z.string()).optional(), -}); +}) satisfies z.ZodType; export const zSchema = z.object({ name: z.string(), + description: z.string().optional(), + custom: z.boolean().optional(), metadata: z.object({ attributes: z.array(z.string()), }), simplify: z.array(zSimplify).optional(), layers: z.array(zLayer), -}); +}) satisfies z.ZodType; export type zTypeLayer = z.infer; export type zTypeSchema = z.infer; diff --git a/packages/cli-vector/src/schema-loader/schema.ts b/packages/cli-vector/src/schema-loader/schema.ts index 3fa744cb24..c102a88419 100644 --- a/packages/cli-vector/src/schema-loader/schema.ts +++ b/packages/cli-vector/src/schema-loader/schema.ts @@ -113,7 +113,6 @@ export interface Layer { /** * Schema metadata for vector layer - * */ export interface SchemaMetadata { /** All the attributes that is available for this layer, could be mapped from different attribute from source layer */ @@ -128,6 +127,12 @@ export interface Schema { /** Schema name */ name: string; + /** Schema description */ + description?: string; + + /** True, if the Schema describes a non-default Shortbread layer */ + custom?: boolean; + /** Schema metadata */ metadata: SchemaMetadata; diff --git a/packages/cli-vector/src/templates/vector-tile-schema.md b/packages/cli-vector/src/templates/vector-tile-schema.md new file mode 100644 index 0000000000..081ba04561 --- /dev/null +++ b/packages/cli-vector/src/templates/vector-tile-schema.md @@ -0,0 +1,149 @@ +# {{name}} + +{{#description}} +{{{description}}} +{{/description}} + +{{^description}} +_No description._ +{{/description}} + +{{#isCustom}} +!!! example "Custom" + + This is a custom Shortbread layer. + +{{/isCustom}} + +{{^isCustom}} +!!! Default + + This is a default [Shortbread](https://shortbread-tiles.org/schema/1.0/#layer-{{name}}) layer. + +{{/isCustom}} + +## all + +{{#all}} + +#### Filter + +`{{{filter}}}` + +#### Attributes + +{{#hasAttributes}} + + + + + + + + + + + {{#attributes}} + + + + + + {{/attributes}} + +
NameTypeValue(s)
{{name}}{{types}}{{{values}}}
+{{/hasAttributes}} + +{{^hasAttributes}} + +_Features under this filter have no targetable attributes._ + +{{/hasAttributes}} + +#### Properties + + + + + + + + + + + + + + + + +
GeometriesMin ZoomMax Zoom
{{geometries}}{{zoom_levels.min}}{{zoom_levels.max}}
+ +{{/all}} + +## kinds + +{{#kinds}} + +### {{name}} + +#### Filter + +`{{{filter}}}` + +#### Attributes + +{{#hasAttributes}} + + + + + + + + + + + {{#attributes}} + + + + + + {{/attributes}} + +
NameTypeValue(s)
{{name}}{{types}}{{{values}}}
+{{/hasAttributes}} + +{{^hasAttributes}} + +_Features under this filter have no targetable attributes._ + +{{/hasAttributes}} + +#### Properties + + + + + + + + + + + + + + + + +
GeometriesMin ZoomMax Zoom
{{geometries}}{{zoom_levels.min}}{{zoom_levels.max}}
+ +{{/kinds}} + +{{^kinds}} + +_This layer has no kinds._ + +{{/kinds}} diff --git a/packages/cli-vector/src/types/doc.ts b/packages/cli-vector/src/types/doc.ts new file mode 100644 index 0000000000..dc8c3473a1 --- /dev/null +++ b/packages/cli-vector/src/types/doc.ts @@ -0,0 +1,113 @@ +/** + * Interface describing a LayerDoc object as created by the vector-cli 'docs' command. Interpolated by Mustache to generate a Markdown a document. + */ +export interface LayerDoc { + /** + * The Shortbread layer's name. + * + * @example "addresses" + * @example "boundaries" + * @example "contours" + */ + name: string; + + /** + * The Shortbread layer's description. + * + * @example "addresses" + * @example "boundaries" + * @example "contours" + */ + description?: string; + + /** + * A flag for whether the layer is natively supported by Shortbread (used exclusively by Mustache). + * + * @example "addresses" + * @example "boundaries" + * @example "contours" + */ + isCustom: boolean; + + /** + * FeaturesDoc object containing information from all features, regardless of `kind` attribute value. + */ + all: FeaturesDoc; + + /** + * FeaturesDoc objects deriving information from all features with the same `kind` attribute value. + */ + kinds?: FeaturesDoc[]; +} + +export interface FeaturesDoc { + /** + * The name for this group of features. Typically, the `kind` attribute value for targeting this group of features. + * + * @example "all" + * @example "motorway" + * @example "peaks" + */ + name: string; + + /** + * The filter expression for targeting this group of features. + * + * @example `["all"]` + * @example `["all", ["==", "kind", "motorway"]]`, + */ + filter: string; + + /** + * AttributeDoc objects containing attribute information across this group of features. + */ + attributes: AttributeDoc[]; + + /** + * A flag for whether this FeaturesDoc object describes any attributes (used exclusively by Mustache). + */ + hasAttributes: boolean; + + /** + * A comma-separated list of all geometries across this group of features. Typically, only one geometry. + * + * @example "LineString" + * @example "Point, Polygon" + */ + geometries: string; + + /** + * An object describing the min and max zoom levels for which features within this group appear. + * + * @example { "min": 12, "max": 15 } + */ + zoom_levels: { + min: number; + max: number; + }; +} + +export interface AttributeDoc { + /** + * The attribute's name. + */ + name: string; + + /** + * A comma-separated list of the attribute's types. Typically, only one type. + * + * @example "boolean" + * @example "number, string" + */ + types: string; + + /** + * A comma-separated list of the attribute's values. Sometimes only a snippet of the attribute's values. Sometimes minified. + * + * @example "1, 2, 3, 4, 5" + * @example "cable_car, ski_lift, ski_tow" + * @example "{empty}, industrial, people" + * @example "12448 unique values"" + */ + values: string; +} diff --git a/packages/cli-vector/src/types/report.ts b/packages/cli-vector/src/types/report.ts new file mode 100644 index 0000000000..b820333f4b --- /dev/null +++ b/packages/cli-vector/src/types/report.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; + +/** + * Interface describing a LayerReport object as created and exported by the vector-cli 'reports' command. + */ +export interface LayerReport { + /** + * Name of the Shortbread layer for which the report derives. + * + * @example "addresses" + * @example "boundaries" + * @example "contours" + */ + name: string; + + /** + * FeaturesReport object containing information from all features, regardless of `kind` attribute value. + */ + all: FeaturesReport; + + /** + * FeaturesReport objects deriving information from all features with the same `kind` attribute value. + * + * @example { "contours": FeaturesReport, "peaks": FeaturesReport } + */ + kinds?: Record; +} + +export interface FeaturesReport { + /** + * @example { "feature": { "guaranteed": true, "type": "string", "values": ["people", "industrial"] } } + */ + attributes: Record; + + /** + * @example ["LineString"] + */ + geometries: ('LineString' | 'Point' | 'Polygon' | 'Unknown')[]; + + /** + * @example [12, 13, 14, 15] + */ + zoom_levels: number[]; +} + +export interface AttributeReport { + /** + * `true`, if all features of the parent `FeaturesReport` define this attribute. Otherwise, `false`. + */ + guaranteed: boolean; + + /** + * @example ["boolean", "string"] + */ + types: string[]; + + /** + * @example ["people", "industrial"] + */ + values: unknown[]; + + /** + * `true`, if all features of the parent `FeaturesReport` define this attribute with more than `MaxValues` (i.e. 20) unique values. Otherwise, `false`. + * Capturing all unique values at runtime demands too much memory. Useful for determining if an attribute is likely to describe a limited set of options (i.e an Enum). + */ + has_more_values: boolean; +} + +const zAttributeReport = z.object({ + guaranteed: z.boolean(), + types: z.array(z.string()), + values: z.array(z.union([z.boolean(), z.number(), z.string()])), + has_more_values: z.boolean(), +}) satisfies z.ZodType; + +const zFeaturesReport = z.object({ + attributes: z.record(z.string(), zAttributeReport), + geometries: z.array( + z.union([z.literal('LineString'), z.literal('Point'), z.literal('Polygon'), z.literal('Unknown')]), + ), + zoom_levels: z.array(z.number()), +}) satisfies z.ZodType; + +export const zLayerReport = z.object({ + name: z.string(), + all: zFeaturesReport, + kinds: z.record(z.string(), zFeaturesReport).optional(), +}) satisfies z.ZodType;