From 419ed37d4f953b83fc60142e326d221374384b21 Mon Sep 17 00:00:00 2001 From: Tawera Manaena Date: Tue, 19 Nov 2024 05:31:13 +1300 Subject: [PATCH] feat: explore strategies for validating vector tile styles (wip) --- validation/.gitignore | 5 + validation/package-lock.json | 146 +++++++++++++++++ validation/package.json | 20 +++ validation/scripts/dir-checker.py | 51 ++++++ validation/scripts/image-checker.py | 155 ++++++++++++++++++ validation/src/checker.ts | 74 +++++++++ validation/src/providers/Fonts.ts | 25 +++ validation/src/providers/Sprites.ts | 26 +++ validation/src/style/Root.ts | 59 +++++++ validation/src/style/root/Bearing.ts | 7 + validation/src/style/root/Center.ts | 4 + validation/src/style/root/CenterAltitude.ts | 4 + validation/src/style/root/Glyphs.ts | 12 ++ validation/src/style/root/Light.ts | 21 +++ validation/src/style/root/Name.ts | 14 ++ validation/src/style/root/Pitch.ts | 7 + validation/src/style/root/Projection.ts | 4 + validation/src/style/root/Roll.ts | 4 + validation/src/style/root/Sky.ts | 4 + validation/src/style/root/Sources.ts | 4 + validation/src/style/root/Sprite.ts | 4 + validation/src/style/root/Terrain.ts | 4 + validation/src/style/root/Transition.ts | 4 + validation/src/style/root/Version.ts | 4 + validation/src/style/root/Zoom.ts | 4 + .../src/style/root/properties/Filter.ts | 0 .../style/root/properties/Layers/Layers.ts | 41 +++++ .../root/properties/Layers/properties/Id.ts | 3 + .../properties/Layers/properties/MaxZoom.ts | 6 + .../properties/Layers/properties/Metadata.ts | 3 + .../properties/Layers/properties/MinZoom.ts | 6 + .../properties/Layers/properties/Source.ts | 3 + .../root/properties/Layers/properties/Type.ts | 13 ++ .../style/root/properties/Layout/Layout.ts | 11 ++ .../properties/Layout/properties/IconImage.ts | 19 +++ .../properties/Layout/properties/TextFont.ts | 12 ++ validation/src/style/root/properties/Paint.ts | 0 validation/tsconfig.json | 9 + 38 files changed, 792 insertions(+) create mode 100644 validation/.gitignore create mode 100644 validation/package-lock.json create mode 100644 validation/package.json create mode 100644 validation/scripts/dir-checker.py create mode 100644 validation/scripts/image-checker.py create mode 100644 validation/src/checker.ts create mode 100644 validation/src/providers/Fonts.ts create mode 100644 validation/src/providers/Sprites.ts create mode 100644 validation/src/style/Root.ts create mode 100644 validation/src/style/root/Bearing.ts create mode 100644 validation/src/style/root/Center.ts create mode 100644 validation/src/style/root/CenterAltitude.ts create mode 100644 validation/src/style/root/Glyphs.ts create mode 100644 validation/src/style/root/Light.ts create mode 100644 validation/src/style/root/Name.ts create mode 100644 validation/src/style/root/Pitch.ts create mode 100644 validation/src/style/root/Projection.ts create mode 100644 validation/src/style/root/Roll.ts create mode 100644 validation/src/style/root/Sky.ts create mode 100644 validation/src/style/root/Sources.ts create mode 100644 validation/src/style/root/Sprite.ts create mode 100644 validation/src/style/root/Terrain.ts create mode 100644 validation/src/style/root/Transition.ts create mode 100644 validation/src/style/root/Version.ts create mode 100644 validation/src/style/root/Zoom.ts create mode 100644 validation/src/style/root/properties/Filter.ts create mode 100644 validation/src/style/root/properties/Layers/Layers.ts create mode 100644 validation/src/style/root/properties/Layers/properties/Id.ts create mode 100644 validation/src/style/root/properties/Layers/properties/MaxZoom.ts create mode 100644 validation/src/style/root/properties/Layers/properties/Metadata.ts create mode 100644 validation/src/style/root/properties/Layers/properties/MinZoom.ts create mode 100644 validation/src/style/root/properties/Layers/properties/Source.ts create mode 100644 validation/src/style/root/properties/Layers/properties/Type.ts create mode 100644 validation/src/style/root/properties/Layout/Layout.ts create mode 100644 validation/src/style/root/properties/Layout/properties/IconImage.ts create mode 100644 validation/src/style/root/properties/Layout/properties/TextFont.ts create mode 100644 validation/src/style/root/properties/Paint.ts create mode 100644 validation/tsconfig.json diff --git a/validation/.gitignore b/validation/.gitignore new file mode 100644 index 00000000..3965e5dd --- /dev/null +++ b/validation/.gitignore @@ -0,0 +1,5 @@ +build +node_modules +tsconfig.tsbuildinfo + +errors.json \ No newline at end of file diff --git a/validation/package-lock.json b/validation/package-lock.json new file mode 100644 index 00000000..b835b05e --- /dev/null +++ b/validation/package-lock.json @@ -0,0 +1,146 @@ +{ + "name": "validation", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "validation", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "zod": "^3.23.8" + }, + "devDependencies": { + "@mapbox/mapbox-gl-style-spec": "^14.7.1", + "@types/node": "^22.9.0" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/mapbox-gl-style-spec": { + "version": "14.8.0", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-style-spec/-/mapbox-gl-style-spec-14.8.0.tgz", + "integrity": "sha512-wA4uBTrvvcSeeBKDGcjzX2XwSqpQxzv0siTduKJxIhlC68yZmWXx700rK5Rnv7+zd/neniAJZO56+vrUDwnixA==", + "dev": true, + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/unitbezier": "^0.0.1", + "cheap-ruler": "^4.0.0", + "csscolorparser": "~1.0.2", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.6", + "quickselect": "^3.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-composite": "bin/gl-style-composite.js", + "gl-style-format": "bin/gl-style-format.js", + "gl-style-migrate": "bin/gl-style-migrate.js", + "gl-style-validate": "bin/gl-style-validate.js" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/@types/node": { + "version": "22.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", + "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.8" + } + }, + "node_modules/cheap-ruler": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cheap-ruler/-/cheap-ruler-4.0.0.tgz", + "integrity": "sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==", + "dev": true, + "license": "ISC" + }, + "node_modules/csscolorparser": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/csscolorparser/-/csscolorparser-1.0.3.tgz", + "integrity": "sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/validation/package.json b/validation/package.json new file mode 100644 index 00000000..26c3be43 --- /dev/null +++ b/validation/package.json @@ -0,0 +1,20 @@ +{ + "name": "validation", + "version": "1.0.0", + "description": "Validation for basemaps-config vector tile stylesheets", + "repository": "github:linz/basemaps-config", + "type": "module", + "license": "MIT", + "scripts": { + "start": "node build/checker.js", + "build": "tsc -b", + "validate": "npx gl-style-validate ../config/style/aerialhybrid.json" + }, + "devDependencies": { + "@mapbox/mapbox-gl-style-spec": "^14.7.1", + "@types/node": "^22.9.0" + }, + "dependencies": { + "zod": "^3.23.8" + } +} diff --git a/validation/scripts/dir-checker.py b/validation/scripts/dir-checker.py new file mode 100644 index 00000000..dce661de --- /dev/null +++ b/validation/scripts/dir-checker.py @@ -0,0 +1,51 @@ +import os +import tkinter as tk +from tkinter import filedialog + + +def get_dir() -> str | None: + # Create a root window and hide it (we don't need the window to show) + root = tk.Tk() + root.withdraw() + + # Open a directory selection dialog + directory = filedialog.askdirectory(title="Select a directory") + + # If the user cancels, the directory will be an empty string + if directory: + return directory + + print("No directory selected.") + return None + + +def has_subdirs(dir: str) -> bool: + # Define the required subdirectories + subdirs = ["fonts", "sprites", "style"] + + # Check if the required subdirectories exist in the given directory + missing_subdirs = [ + subdir + for subdir in subdirs + if not os.path.isdir(os.path.join(dir, subdir)) + ] + + if missing_subdirs: + print( + f"The following required subdirectories are missing: {', '.join(missing_subdirs)}" + ) + return False + + return True + + +if __name__ == "__main__": + dir = get_dir() + + if dir is None: + exit(1) + + if has_subdirs(dir) is False: + exit(1) + + exit(0) diff --git a/validation/scripts/image-checker.py b/validation/scripts/image-checker.py new file mode 100644 index 00000000..4c0f4f08 --- /dev/null +++ b/validation/scripts/image-checker.py @@ -0,0 +1,155 @@ +import json +from os import listdir +from tkinter import filedialog, Tk +from typing import Any + + +def select_style_json() -> str: + file_path = filedialog.askopenfilename( + title="Select style json (e.g. style/aerialhybrid.json)", + filetypes=[("JSON files", "*.json"), ("All files", "*.*")], + ) + + if not file_path: + print("No file selected. Exiting.") + exit(1) + + return file_path + + +def select_sprite_dir(): + dir_path = filedialog.askdirectory( + title="Select sprites directory (e.g. sprites/topographic)" + ) + + if not dir_path: + print("No directory selected. Exiting.") + exit(1) + + return dir_path + + +def extract_icon_image_names(style_json_path: dict[str, str]) -> set[str]: + image_names: set[str] = set() + + try: + with open(style_json_path, "r") as file: + data = json.load(file) + + if type(data) is not dict: + print("Data is not dict. Exiting.") + exit(1) + + layers = data.get("layers") + + if type(layers) is not list: + print("Layers is not list. Exiting.") + exit(1) + + for layer in layers: + if type(layer) is not dict: + print("Layer is not dict. Exiting.") + exit(1) + + layout = layer.get("layout") + + if layout is None: + # print("Layout is None. Continuing.") + continue + + if type(layout) is not dict: + print("Layout is not dict. Exiting.") + exit(1) + + icon_image = layout.get("icon-image") + + if icon_image is None: + # print("Icon image is None. Continuing.") + continue + + if type(icon_image) is str: + image_names.add(icon_image) + continue + + if type(icon_image) is not dict: + print("Icon image is not dict or str. Exiting.") + exit(1) + + stops = icon_image.get("stops") + + if stops is None: + print("Stops is None. Exiting.") + exit(1) + + if type(stops) is not list: + print("Stops is not list. Exiting.") + exit(1) + + for stop in stops: + if type(stop) is not list: + print("Stop is not list. Exiting.") + exit(1) + + if len(stop) != 2: + print("Stop is not tuple. Exiting.") + exit(1) + + image_name = stop[1] + + if type(image_name) is not str: + print("Image name is not str. Exiting.") + exit(1) + + image_names.add(image_name) + + # print(f"Loaded JSON data from {style_json_path}:") + # print(json.dumps(data, indent=4)) + except Exception as e: + print(f"Failed to load style json: {e}") + exit(1) + + return image_names + + +def check_icon_image_names_in_sprites_dr(icon_image_names: set[str], sprites_dir_path: str) -> bool: + files_in_dir: set[str] = set(listdir(sprites_dir_path)) + + missing_files: set[str] = set() + + for name in icon_image_names: + if not any(file.startswith(name) for file in files_in_dir): + missing_files.add(name) + + if len(missing_files) > 0: + print("missing_files") + + for i, missing in enumerate(sorted(missing_files)): + print(i + 1, missing) + + return False + else: + print('no missing files') + + return True + + + + + +def main(): + root = Tk() + root.withdraw() + + style_json_path: str = select_style_json() + icon_image_names: set[str] = extract_icon_image_names(style_json_path) + + # print(sorted(icon_image_names)) + + sprites_dir_path: str = select_sprite_dir() + succeeded: bool = check_icon_image_names_in_sprites_dr(icon_image_names, sprites_dir_path) + + exit(0) + + +if __name__ == "__main__": + main() diff --git a/validation/src/checker.ts b/validation/src/checker.ts new file mode 100644 index 00000000..f7070a65 --- /dev/null +++ b/validation/src/checker.ts @@ -0,0 +1,74 @@ +import { readdirSync, readFileSync, Stats, statSync, writeFileSync } from 'fs'; +import path from 'path'; + +import { Fonts } from './providers/Fonts.js'; +import { Sprites } from './providers/Sprites.js'; +import { Root } from './style/Root.js'; + +interface Props { + filepath: string; + name: string; + ext: string; + stat: Stats; +} + +function readFilesSync(dir: string): Props[] { + const files: Props[] = []; + + readdirSync(dir).forEach((filename) => { + const name = path.parse(filename).name; + const ext = path.parse(filename).ext; + const filepath = path.resolve(dir, filename); + const stat = statSync(filepath); + const isFile = stat.isFile(); + + if (isFile) files.push({ filepath, name, ext, stat }); + }); + + files.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })); + + return files; +} + +function readContentsSync(files: Props[]): string[] { + const contents: string[] = []; + + files.forEach(({ filepath }) => contents.push(readFileSync(filepath, { encoding: 'utf-8' }))); + + return contents; +} + +export async function validate(): Promise { + const files = readFilesSync('../config/style'); + + const contents = readContentsSync(files); + + const jsons = contents.map((c) => JSON.parse(c) as unknown); + + try { + await Fonts.init(); + await Sprites.init(); + } catch (e) { + return; + } + + const parses = jsons.map((json) => Root.safeParse(json)); + + if (parses.every((p) => p.success)) { + return console.log('no errors'); + } + + const errors = { + results: [ + ...files.map((f, i) => ({ + style: f.name, + errors: parses[i]?.error, + })), + ], + }; + + writeFileSync('errors.json', JSON.stringify(errors), 'utf-8'); + console.log('issues written to errors.json'); +} + +void validate(); diff --git a/validation/src/providers/Fonts.ts b/validation/src/providers/Fonts.ts new file mode 100644 index 00000000..fa51ef73 --- /dev/null +++ b/validation/src/providers/Fonts.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +const FontsType = z.array(z.string()); + +export class Fonts { + private static fonts?: Array; + + private constructor() {} + + static async init(): Promise { + const response = await fetch('https://basemaps.linz.govt.nz/v1/fonts.json'); + const json: unknown = await response.json(); + + const parse = FontsType.safeParse(json); + if (!parse.success) throw new Error(); + + this.fonts = parse.data; + } + + static verify(fontName: string): boolean { + if (this.fonts === undefined) throw new Error(); + + return this.fonts.includes(fontName); + } +} diff --git a/validation/src/providers/Sprites.ts b/validation/src/providers/Sprites.ts new file mode 100644 index 00000000..63b62da9 --- /dev/null +++ b/validation/src/providers/Sprites.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +const SpritesType = z.record(z.string(), z.unknown()); + +export class Sprites { + private static sprites?: Array; + + private constructor() {} + + static async init(): Promise { + const response = await fetch('https://basemaps.linz.govt.nz/v1/sprites/topographic.json'); + const json: unknown = await response.json(); + + const parse = SpritesType.safeParse(json); + if (!parse.success) throw new Error(); + + this.sprites = Object.keys(parse.data); + } + + static verify(spriteName: string): boolean { + if (this.sprites === undefined) throw new Error(); + + if (spriteName.length === 0) return true; + return this.sprites.includes(spriteName); + } +} diff --git a/validation/src/style/Root.ts b/validation/src/style/Root.ts new file mode 100644 index 00000000..0b0d739a --- /dev/null +++ b/validation/src/style/Root.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; + +import { Bearing } from './root/Bearing.js'; +import { Center } from './root/Center.js'; +import { CenterAltitude } from './root/CenterAltitude.js'; +import { Glyphs } from './root/Glyphs.js'; +import { Light } from './root/Light.js'; +import { Name } from './root/Name.js'; +import { Pitch } from './root/Pitch.js'; +import { Projection } from './root/Projection.js'; +import { Layers } from './root/properties/Layers/Layers.js'; +import { Metadata } from './root/properties/Layers/properties/Metadata.js'; +import { Roll } from './root/Roll.js'; +import { Sky } from './root/Sky.js'; +import { Sources } from './root/Sources.js'; +import { Sprite } from './root/Sprite.js'; +import { Terrain } from './root/Terrain.js'; +import { Transition } from './root/Transition.js'; +import { Version } from './root/Version.js'; +import { Zoom } from './root/Zoom.js'; + +/** + * Root + * + * @description Root level properties of a MapLibre style specify the map's layers, tile sources and other resources, and default values for the initial camera position when not specified elsewhere. + * + * @example { + * "version": 8, + * "name": "MapLibre Demo Tiles", + * "sprite": "https://demotiles.maplibre.org/styles/osm-bright-gl-style/sprite", + * "glyphs": "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf", + * "sources": {...}, + * "layers": [...] + * } + * + * @link https://maplibre.org/maplibre-style-spec/root/#root + */ +export const Root = z.object({ + version: Version, + name: Name, + metadata: Metadata, // shared + center: Center, + centerAltitude: CenterAltitude, + zoom: Zoom, + bearing: Bearing, + pitch: Pitch, + roll: Roll, + light: Light, + sky: Sky, + projection: Projection, + terrain: Terrain, + sources: Sources, + sprite: Sprite, + glyphs: Glyphs, + transition: Transition, + layers: Layers, +}); + +export type RootType = z.infer; diff --git a/validation/src/style/root/Bearing.ts b/validation/src/style/root/Bearing.ts new file mode 100644 index 00000000..250757e5 --- /dev/null +++ b/validation/src/style/root/Bearing.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#bearing +export const Bearing = z + .number() + .refine((n) => -180 < n && n <= 180) + .optional(); diff --git a/validation/src/style/root/Center.ts b/validation/src/style/root/Center.ts new file mode 100644 index 00000000..71628513 --- /dev/null +++ b/validation/src/style/root/Center.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#center +export const Center = z.tuple([z.number(), z.number()]).optional(); diff --git a/validation/src/style/root/CenterAltitude.ts b/validation/src/style/root/CenterAltitude.ts new file mode 100644 index 00000000..bd276476 --- /dev/null +++ b/validation/src/style/root/CenterAltitude.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#centeraltitude +export const CenterAltitude = z.number().optional(); diff --git a/validation/src/style/root/Glyphs.ts b/validation/src/style/root/Glyphs.ts new file mode 100644 index 00000000..79852633 --- /dev/null +++ b/validation/src/style/root/Glyphs.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const Glyphs = z.string().optional(); + +// export function check(style: StyleType) { +// return style.refine((style) => { +// style.layers.some((layer) => { +// layer. +// }) +// }) + +// } diff --git a/validation/src/style/root/Light.ts b/validation/src/style/root/Light.ts new file mode 100644 index 00000000..a5d7607a --- /dev/null +++ b/validation/src/style/root/Light.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#light +export const Light = z + .object({ + // https://maplibre.org/maplibre-style-spec/light/#anchor + anchor: z.union([z.literal('map'), z.literal('viewport')]).optional(), + + // https://maplibre.org/maplibre-style-spec/light/#position (TODO) + position: z.tuple([z.number(), z.number(), z.number()]).optional(), + + // https://maplibre.org/maplibre-style-spec/light/#color (TODO) + color: z.string().optional(), + + // https://maplibre.org/maplibre-style-spec/light/#intensity (TODO) + intensity: z + .number() + .refine((n) => 0 <= n && n <= 1) + .optional(), + }) + .optional(); diff --git a/validation/src/style/root/Name.ts b/validation/src/style/root/Name.ts new file mode 100644 index 00000000..bfb755f8 --- /dev/null +++ b/validation/src/style/root/Name.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +/** + * name + * + * @type {string | undefined} + * + * @description A human-readable name for the style. + * + * @example name: "Bright" + * + * @link https://maplibre.org/maplibre-style-spec/root/#name + */ +export const Name = z.string().optional(); diff --git a/validation/src/style/root/Pitch.ts b/validation/src/style/root/Pitch.ts new file mode 100644 index 00000000..d433835b --- /dev/null +++ b/validation/src/style/root/Pitch.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#pitch +export const Pitch = z + .number() + .refine((n) => 0 <= n && n <= 60) + .optional(); diff --git a/validation/src/style/root/Projection.ts b/validation/src/style/root/Projection.ts new file mode 100644 index 00000000..c8852715 --- /dev/null +++ b/validation/src/style/root/Projection.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#projection (TODO) +export const Projection = z.object({}).optional(); diff --git a/validation/src/style/root/Roll.ts b/validation/src/style/root/Roll.ts new file mode 100644 index 00000000..ac507832 --- /dev/null +++ b/validation/src/style/root/Roll.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#roll +export const Roll = z.number().optional(); diff --git a/validation/src/style/root/Sky.ts b/validation/src/style/root/Sky.ts new file mode 100644 index 00000000..cc233243 --- /dev/null +++ b/validation/src/style/root/Sky.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#sky (TODO) +export const Sky = z.object({}).optional(); diff --git a/validation/src/style/root/Sources.ts b/validation/src/style/root/Sources.ts new file mode 100644 index 00000000..e16e90be --- /dev/null +++ b/validation/src/style/root/Sources.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#sources (TODO) +export const Sources = z.object({}); diff --git a/validation/src/style/root/Sprite.ts b/validation/src/style/root/Sprite.ts new file mode 100644 index 00000000..a7b3fa04 --- /dev/null +++ b/validation/src/style/root/Sprite.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#sprite +export const Sprite = z.string().optional(); diff --git a/validation/src/style/root/Terrain.ts b/validation/src/style/root/Terrain.ts new file mode 100644 index 00000000..dd053152 --- /dev/null +++ b/validation/src/style/root/Terrain.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#terrain +export const Terrain = z.object({}).optional(); diff --git a/validation/src/style/root/Transition.ts b/validation/src/style/root/Transition.ts new file mode 100644 index 00000000..67a6bad1 --- /dev/null +++ b/validation/src/style/root/Transition.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#transition +export const Transition = z.object({}).optional(); diff --git a/validation/src/style/root/Version.ts b/validation/src/style/root/Version.ts new file mode 100644 index 00000000..975c53f2 --- /dev/null +++ b/validation/src/style/root/Version.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#version +export const Version = z.number().refine((n) => n === 8); diff --git a/validation/src/style/root/Zoom.ts b/validation/src/style/root/Zoom.ts new file mode 100644 index 00000000..e42c4b31 --- /dev/null +++ b/validation/src/style/root/Zoom.ts @@ -0,0 +1,4 @@ +import { z } from 'zod'; + +// https://maplibre.org/maplibre-style-spec/root/#zoom +export const Zoom = z.number().optional(); diff --git a/validation/src/style/root/properties/Filter.ts b/validation/src/style/root/properties/Filter.ts new file mode 100644 index 00000000..e69de29b diff --git a/validation/src/style/root/properties/Layers/Layers.ts b/validation/src/style/root/properties/Layers/Layers.ts new file mode 100644 index 00000000..1cfb2c15 --- /dev/null +++ b/validation/src/style/root/properties/Layers/Layers.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +import { Layout } from '../Layout/Layout.js'; +import { Id } from './properties/Id.js'; +import { MaxZoom } from './properties/MaxZoom.js'; +import { Metadata } from './properties/Metadata.js'; +import { MinZoom } from './properties/MinZoom.js'; +import { Source } from './properties/Source.js'; +import { Type } from './properties/Type.js'; + +const Layer = z + .object({ + id: Id, + type: Type, + metadata: Metadata, + source: Source, + // source-layer + minzoom: MinZoom, + maxzoom: MaxZoom, + // filter + layout: Layout, + // paint + }) + .refine((layer) => { + // https://maplibre.org/maplibre-style-spec/layers/#source + if (layer.type !== 'background') { + if (layer.source === undefined) { + return false; + } + } + + return true; + }); + +export const Layers = z.array(Layer).refine((layers) => { + // check that all ids are unique + const ids = new Set(layers.map((l) => l.id)); + if (layers.length !== ids.size) return false; + + return true; +}); diff --git a/validation/src/style/root/properties/Layers/properties/Id.ts b/validation/src/style/root/properties/Layers/properties/Id.ts new file mode 100644 index 00000000..cbe4f96c --- /dev/null +++ b/validation/src/style/root/properties/Layers/properties/Id.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const Id = z.string(); diff --git a/validation/src/style/root/properties/Layers/properties/MaxZoom.ts b/validation/src/style/root/properties/Layers/properties/MaxZoom.ts new file mode 100644 index 00000000..598dbdbc --- /dev/null +++ b/validation/src/style/root/properties/Layers/properties/MaxZoom.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const MaxZoom = z + .number() + .refine((n) => 0 <= n && n <= 24) + .optional(); diff --git a/validation/src/style/root/properties/Layers/properties/Metadata.ts b/validation/src/style/root/properties/Layers/properties/Metadata.ts new file mode 100644 index 00000000..c6b8df08 --- /dev/null +++ b/validation/src/style/root/properties/Layers/properties/Metadata.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const Metadata = z.record(z.string(), z.any()).optional(); diff --git a/validation/src/style/root/properties/Layers/properties/MinZoom.ts b/validation/src/style/root/properties/Layers/properties/MinZoom.ts new file mode 100644 index 00000000..9dfa1107 --- /dev/null +++ b/validation/src/style/root/properties/Layers/properties/MinZoom.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const MinZoom = z + .number() + .refine((n) => 0 <= n && n <= 24) + .optional(); diff --git a/validation/src/style/root/properties/Layers/properties/Source.ts b/validation/src/style/root/properties/Layers/properties/Source.ts new file mode 100644 index 00000000..25d102c2 --- /dev/null +++ b/validation/src/style/root/properties/Layers/properties/Source.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const Source = z.string().optional(); diff --git a/validation/src/style/root/properties/Layers/properties/Type.ts b/validation/src/style/root/properties/Layers/properties/Type.ts new file mode 100644 index 00000000..fab4c6db --- /dev/null +++ b/validation/src/style/root/properties/Layers/properties/Type.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +export const Type = z.union([ + z.literal('fill'), + z.literal('line'), + z.literal('symbol'), + z.literal('circle'), + z.literal('heatmap'), + z.literal('fill-extrusion'), + z.literal('raster'), + z.literal('hillshade'), + z.literal('background'), +]); diff --git a/validation/src/style/root/properties/Layout/Layout.ts b/validation/src/style/root/properties/Layout/Layout.ts new file mode 100644 index 00000000..e6cd74d9 --- /dev/null +++ b/validation/src/style/root/properties/Layout/Layout.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +import { IconImage } from './properties/IconImage.js'; +import { TextFont } from './properties/TextFont.js'; + +export const Layout = z + .object({ + 'icon-image': IconImage, + 'text-font': TextFont, + }) + .optional(); diff --git a/validation/src/style/root/properties/Layout/properties/IconImage.ts b/validation/src/style/root/properties/Layout/properties/IconImage.ts new file mode 100644 index 00000000..27f7a743 --- /dev/null +++ b/validation/src/style/root/properties/Layout/properties/IconImage.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { Sprites } from '../../../../../providers/Sprites.js'; + +const spriteName = z.string().refine( + (s) => Sprites.verify(s), + (s) => ({ + message: `Sprite '${s}' not found.`, + }), +); + +export const IconImage = z + .union([ + spriteName, + z.object({ + stops: z.array(z.tuple([z.number(), spriteName])), + }), + ]) + .optional(); diff --git a/validation/src/style/root/properties/Layout/properties/TextFont.ts b/validation/src/style/root/properties/Layout/properties/TextFont.ts new file mode 100644 index 00000000..bfad2373 --- /dev/null +++ b/validation/src/style/root/properties/Layout/properties/TextFont.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { Fonts } from '../../../../../providers/Fonts.js'; + +const fontName = z.string().refine( + (s) => Fonts.verify(s), + (s) => ({ + message: `Font '${s}' not found.`, + }), +); + +export const TextFont = z.array(fontName).optional(); diff --git a/validation/src/style/root/properties/Paint.ts b/validation/src/style/root/properties/Paint.ts new file mode 100644 index 00000000..e69de29b diff --git a/validation/tsconfig.json b/validation/tsconfig.json new file mode 100644 index 00000000..ffb82d35 --- /dev/null +++ b/validation/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@linzjs/style/tsconfig.base.json", + "compilerOptions": { + "lib": ["DOM", "ES2020"], + "rootDir": "src", + "outDir": "build" + } + } + \ No newline at end of file