@versatiles/style 5.8.3 → 5.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +38 -32
  2. package/dist/index.d.ts +21 -15
  3. package/dist/index.js +719 -419
  4. package/dist/index.js.map +1 -1
  5. package/package.json +22 -17
  6. package/src/color/abstract.ts +1 -1
  7. package/src/color/hsl.test.ts +11 -18
  8. package/src/color/hsl.ts +11 -23
  9. package/src/color/hsv.test.ts +4 -10
  10. package/src/color/hsv.ts +38 -22
  11. package/src/color/index.test.ts +91 -5
  12. package/src/color/index.ts +4 -2
  13. package/src/color/random.test.ts +55 -1
  14. package/src/color/random.ts +130 -26
  15. package/src/color/rgb.test.ts +60 -38
  16. package/src/color/rgb.ts +34 -56
  17. package/src/color/utils.test.ts +4 -6
  18. package/src/color/utils.ts +2 -4
  19. package/src/guess_style/guess_style.test.ts +49 -43
  20. package/src/guess_style/guess_style.ts +67 -22
  21. package/src/guess_style/index.ts +0 -1
  22. package/src/index.test.ts +35 -8
  23. package/src/index.ts +41 -21
  24. package/src/lib/utils.test.ts +86 -3
  25. package/src/lib/utils.ts +12 -20
  26. package/src/shortbread/index.ts +0 -1
  27. package/src/shortbread/layers.test.ts +15 -1
  28. package/src/shortbread/layers.ts +204 -199
  29. package/src/shortbread/properties.test.ts +3 -4
  30. package/src/shortbread/properties.ts +18 -4
  31. package/src/shortbread/template.test.ts +7 -2
  32. package/src/shortbread/template.ts +7 -14
  33. package/src/style_builder/decorator.test.ts +4 -4
  34. package/src/style_builder/decorator.ts +46 -31
  35. package/src/style_builder/recolor.test.ts +6 -31
  36. package/src/style_builder/recolor.ts +38 -31
  37. package/src/style_builder/style_builder.test.ts +50 -13
  38. package/src/style_builder/style_builder.ts +35 -34
  39. package/src/style_builder/types.ts +44 -2
  40. package/src/styles/LICENSE.md +15 -15
  41. package/src/styles/colorful.test.ts +91 -0
  42. package/src/styles/colorful.ts +229 -122
  43. package/src/styles/eclipse.ts +1 -1
  44. package/src/styles/empty.ts +1 -1
  45. package/src/styles/graybeard.ts +2 -2
  46. package/src/styles/index.ts +2 -3
  47. package/src/styles/neutrino.ts +14 -16
  48. package/src/styles/satellite.test.ts +146 -0
  49. package/src/styles/satellite.ts +106 -0
  50. package/src/styles/shadow.ts +2 -2
  51. package/src/types/index.ts +0 -1
  52. package/src/types/maplibre.ts +17 -3
  53. package/src/types/tilejson.test.ts +17 -14
  54. package/src/types/tilejson.ts +59 -37
  55. package/src/types/vector_layer.test.ts +5 -2
  56. package/src/types/vector_layer.ts +8 -10
@@ -0,0 +1,146 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildSatelliteStyle } from './satellite.js';
3
+
4
+ describe('satellite style', () => {
5
+ it('should create a satellite style with default options', () => {
6
+ const style = buildSatelliteStyle();
7
+
8
+ expect(style.name).toBe('versatiles-satellite');
9
+ expect(style.sources.satellite).toBeDefined();
10
+ expect(style.sources.satellite).toMatchObject({
11
+ type: 'raster',
12
+ tiles: ['https://tiles.versatiles.org/tiles/satellite/{z}/{x}/{y}'],
13
+ tileSize: 512,
14
+ maxzoom: 17,
15
+ });
16
+
17
+ // Raster layer should be the first layer
18
+ expect(style.layers[0]).toMatchObject({
19
+ id: 'satellite',
20
+ type: 'raster',
21
+ source: 'satellite',
22
+ });
23
+ });
24
+
25
+ it('should include vector overlay by default', () => {
26
+ const style = buildSatelliteStyle();
27
+
28
+ // Should have more than just the raster layer
29
+ expect(style.layers.length).toBeGreaterThan(1);
30
+
31
+ // Should have the vector source
32
+ expect(style.sources['versatiles-shortbread']).toBeDefined();
33
+
34
+ // Should not contain background layer
35
+ expect(style.layers.find((l) => l.id === 'background')).toBeUndefined();
36
+
37
+ // Should not contain fill layers
38
+ expect(style.layers.filter((l) => l.type === 'fill')).toHaveLength(0);
39
+
40
+ // Should not contain land-*, water-*, site-*, airport-*, tunnel-* layers
41
+ const unwanted = style.layers.filter((l) => /^(land|water|site|airport|tunnel)-/.test(l.id));
42
+ expect(unwanted).toHaveLength(0);
43
+ });
44
+
45
+ it('should modify symbol layers for satellite overlay', () => {
46
+ const style = buildSatelliteStyle();
47
+
48
+ const symbolLayers = style.layers.filter((l) => l.type === 'symbol');
49
+ expect(symbolLayers.length).toBeGreaterThan(0);
50
+
51
+ for (const layer of symbolLayers) {
52
+ if (layer.type !== 'symbol') continue;
53
+ if (layer.paint) {
54
+ expect(layer.paint['text-color']).toBe('#fff');
55
+ expect(layer.paint['text-halo-color']).toBe('#000');
56
+ }
57
+ if (layer.layout?.['text-font']) {
58
+ expect(layer.layout['text-font']).toEqual(['noto_sans_bold']);
59
+ }
60
+ }
61
+ });
62
+
63
+ it('should reduce line layer opacity', () => {
64
+ const style = buildSatelliteStyle();
65
+
66
+ const lineLayers = style.layers.filter((l) => l.type === 'line');
67
+ expect(lineLayers.length).toBeGreaterThan(0);
68
+
69
+ for (const layer of lineLayers) {
70
+ if (layer.type !== 'line' || !layer.paint) continue;
71
+ const opacity = layer.paint['line-opacity'];
72
+ if (typeof opacity === 'number') {
73
+ expect(opacity).toBeLessThanOrEqual(0.2);
74
+ }
75
+ }
76
+ });
77
+
78
+ it('should create minimal style when overlay is false', () => {
79
+ const style = buildSatelliteStyle({ overlay: false });
80
+
81
+ expect(style.name).toBe('versatiles-satellite');
82
+ // Should only have the raster layer
83
+ expect(style.layers).toHaveLength(1);
84
+ expect(style.layers[0]).toMatchObject({
85
+ id: 'satellite',
86
+ type: 'raster',
87
+ source: 'satellite',
88
+ });
89
+
90
+ // Should only have the raster source
91
+ expect(Object.keys(style.sources)).toEqual(['satellite']);
92
+ });
93
+
94
+ it('should accept custom raster tiles', () => {
95
+ const customTiles = ['https://example.com/tiles/{z}/{x}/{y}'];
96
+ const style = buildSatelliteStyle({ rasterTiles: customTiles });
97
+
98
+ const source = style.sources.satellite as { tiles: string[] };
99
+ expect(source.tiles).toEqual(customTiles);
100
+ });
101
+
102
+ it('should accept custom baseUrl', () => {
103
+ const style = buildSatelliteStyle({ baseUrl: 'https://example.org' });
104
+
105
+ expect(style.glyphs).toBe('https://example.org/assets/glyphs/{fontstack}/{range}.pbf');
106
+ });
107
+
108
+ it('should apply raster paint properties', () => {
109
+ const style = buildSatelliteStyle({
110
+ overlay: false,
111
+ rasterOpacity: 0.8,
112
+ rasterHueRotate: 45,
113
+ rasterBrightnessMin: 0.1,
114
+ rasterBrightnessMax: 0.9,
115
+ rasterSaturation: 0.5,
116
+ rasterContrast: -0.3,
117
+ });
118
+
119
+ const rasterLayer = style.layers[0];
120
+ expect(rasterLayer).toMatchObject({
121
+ paint: {
122
+ 'raster-opacity': 0.8,
123
+ 'raster-hue-rotate': 45,
124
+ 'raster-brightness-min': 0.1,
125
+ 'raster-brightness-max': 0.9,
126
+ 'raster-saturation': 0.5,
127
+ 'raster-contrast': -0.3,
128
+ },
129
+ });
130
+ });
131
+
132
+ it('should not include raster paint when no raster options set', () => {
133
+ const style = buildSatelliteStyle({ overlay: false });
134
+
135
+ const rasterLayer = style.layers[0];
136
+ expect(rasterLayer).not.toHaveProperty('paint');
137
+ });
138
+
139
+ it('should accept language option', () => {
140
+ const style = buildSatelliteStyle({ language: 'de' });
141
+
142
+ // Style should still be valid
143
+ expect(style.name).toBe('versatiles-satellite');
144
+ expect(style.layers.length).toBeGreaterThan(1);
145
+ });
146
+ });
@@ -0,0 +1,106 @@
1
+ import Graybeard from './graybeard.js';
2
+ import type { StyleSpecification } from '../types/maplibre.js';
3
+ import type { Language } from '../style_builder/types.js';
4
+
5
+ export interface SatelliteStyleOptions {
6
+ baseUrl?: string;
7
+ rasterTiles?: string[];
8
+ overlayTiles?: string[];
9
+ overlay?: boolean;
10
+ language?: Language;
11
+ rasterOpacity?: number;
12
+ rasterHueRotate?: number;
13
+ rasterBrightnessMin?: number;
14
+ rasterBrightnessMax?: number;
15
+ rasterSaturation?: number;
16
+ rasterContrast?: number;
17
+ }
18
+
19
+ export function buildSatelliteStyle(options?: SatelliteStyleOptions): StyleSpecification {
20
+ options ??= {};
21
+ const baseUrl = options.baseUrl ?? 'https://tiles.versatiles.org';
22
+ const rasterTiles = options.rasterTiles ?? [`${baseUrl}/tiles/satellite/{z}/{x}/{y}`];
23
+ const overlay = options.overlay ?? true;
24
+
25
+ let style: StyleSpecification;
26
+
27
+ if (overlay) {
28
+ // Generate graybeard style for overlay
29
+ style = new Graybeard().build({
30
+ baseUrl,
31
+ tiles: options.overlayTiles,
32
+ language: options.language,
33
+ });
34
+
35
+ // Filter out background, fill layers, and unwanted layer groups
36
+ style.layers = style.layers.filter(
37
+ (l) => l.id !== 'background' && l.type !== 'fill' && !/^(land|water|site|airport|tunnel)-/.test(l.id)
38
+ );
39
+
40
+ // Modify remaining layers
41
+ for (const layer of style.layers) {
42
+ if (layer.type === 'symbol') {
43
+ // Bold font, white text, black halo
44
+ if (layer.layout?.['text-font']) {
45
+ layer.layout['text-font'] = ['noto_sans_bold'];
46
+ }
47
+ if (layer.paint) {
48
+ layer.paint['text-color'] = '#fff';
49
+ layer.paint['text-halo-color'] = '#000';
50
+ if ('text-halo-blur' in layer.paint) layer.paint['text-halo-blur'] = 0;
51
+ if ('text-halo-width' in layer.paint) layer.paint['text-halo-width'] = 1;
52
+ }
53
+ }
54
+
55
+ if (layer.type === 'line' && layer.paint) {
56
+ // Multiply existing opacity by 0.2
57
+ const v = layer.paint['line-opacity'];
58
+ if (v == null) {
59
+ layer.paint['line-opacity'] = 0.2;
60
+ } else if (typeof v === 'number') {
61
+ layer.paint['line-opacity'] = v * 0.2;
62
+ } else if (typeof v === 'object' && 'stops' in v) {
63
+ (v as { stops: [number, number][] }).stops = (v as { stops: [number, number][] }).stops.map(
64
+ (s: [number, number]): [number, number] => [s[0], s[1] * 0.2]
65
+ );
66
+ }
67
+ }
68
+ }
69
+ } else {
70
+ // Minimal style with no overlay
71
+ style = { version: 8, sources: {}, layers: [] } as unknown as StyleSpecification;
72
+ }
73
+
74
+ // Build raster paint properties
75
+ const rasterPaint: Record<string, number> = {};
76
+ if (options.rasterOpacity != null) rasterPaint['raster-opacity'] = options.rasterOpacity;
77
+ if (options.rasterHueRotate != null) rasterPaint['raster-hue-rotate'] = options.rasterHueRotate;
78
+ if (options.rasterBrightnessMin != null) rasterPaint['raster-brightness-min'] = options.rasterBrightnessMin;
79
+ if (options.rasterBrightnessMax != null) rasterPaint['raster-brightness-max'] = options.rasterBrightnessMax;
80
+ if (options.rasterSaturation != null) rasterPaint['raster-saturation'] = options.rasterSaturation;
81
+ if (options.rasterContrast != null) rasterPaint['raster-contrast'] = options.rasterContrast;
82
+
83
+ // Add raster source
84
+ style.sources.satellite = {
85
+ type: 'raster',
86
+ tiles: rasterTiles,
87
+ tileSize: 512,
88
+ attribution: "<a href='https://versatiles.org/sources/'>VersaTiles sources</a>",
89
+ bounds: [-178.187256, -21.401934, 55.846252, 58.061897],
90
+ minzoom: 0,
91
+ maxzoom: 17,
92
+ };
93
+
94
+ // Add raster layer at bottom
95
+ style.layers.unshift({
96
+ id: 'satellite',
97
+ type: 'raster',
98
+ source: 'satellite',
99
+ minzoom: 0,
100
+ ...(Object.keys(rasterPaint).length > 0 ? { paint: rasterPaint } : {}),
101
+ } as StyleSpecification['layers'][number]);
102
+
103
+ style.name = 'versatiles-satellite';
104
+
105
+ return style;
106
+ }
@@ -6,6 +6,6 @@ export default class Shadow extends Colorful {
6
6
  public constructor() {
7
7
  super();
8
8
 
9
- this.transformDefaultColors(color => color.saturate(-1).invert().brightness(0.2));
9
+ this.transformDefaultColors((color) => color.saturate(-1).invert().brightness(0.2));
10
10
  }
11
- }
11
+ }
@@ -1,4 +1,3 @@
1
-
2
1
  export type { MaplibreLayerDefinition, MaplibreLayer } from './maplibre.js';
3
2
  export type { VectorLayer } from './vector_layer.js';
4
3
  export { isTileJSONSpecification } from './tilejson.js';
@@ -1,11 +1,25 @@
1
- import type { BackgroundLayerSpecification, FillLayerSpecification, FilterSpecification, LineLayerSpecification, SymbolLayerSpecification } from '@maplibre/maplibre-gl-style-spec';
1
+ import type {
2
+ BackgroundLayerSpecification,
3
+ FillLayerSpecification,
4
+ FilterSpecification,
5
+ LineLayerSpecification,
6
+ SymbolLayerSpecification,
7
+ } from '@maplibre/maplibre-gl-style-spec';
2
8
  export type { StyleSpecification } from '@maplibre/maplibre-gl-style-spec';
3
9
 
4
10
  /** Type for Maplibre layers, including background, fill, line, and symbol specifications. */
5
- export type MaplibreLayer = BackgroundLayerSpecification | FillLayerSpecification | LineLayerSpecification | SymbolLayerSpecification;
11
+ export type MaplibreLayer =
12
+ | BackgroundLayerSpecification
13
+ | FillLayerSpecification
14
+ | LineLayerSpecification
15
+ | SymbolLayerSpecification;
6
16
 
7
17
  /** Defines the structure of Maplibre layer definitions, omitting the 'source' property for fill, line, and symbol specifications. */
8
- export type MaplibreLayerDefinition = BackgroundLayerSpecification | Omit<FillLayerSpecification, 'source'> | Omit<LineLayerSpecification, 'source'> | Omit<SymbolLayerSpecification, 'source'>;
18
+ export type MaplibreLayerDefinition =
19
+ | BackgroundLayerSpecification
20
+ | Omit<FillLayerSpecification, 'source'>
21
+ | Omit<LineLayerSpecification, 'source'>
22
+ | Omit<SymbolLayerSpecification, 'source'>;
9
23
 
10
24
  /** Represents a filter specification in Maplibre styles. */
11
25
  export type MaplibreFilter = FilterSpecification;
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it } from 'vitest';
2
2
  import { isTileJSONSpecification } from './tilejson.js';
3
3
 
4
4
  describe('isTileJSONSpecification', () => {
@@ -30,15 +30,17 @@ describe('isTileJSONSpecification', () => {
30
30
  });
31
31
 
32
32
  it('should throw an error if the tiles property is missing', () => {
33
- expect(() => isTileJSONSpecification({ ...validRasterSpec, tiles: undefined })).toThrow('spec.tiles must be an array of strings');
33
+ expect(() => isTileJSONSpecification({ ...validRasterSpec, tiles: undefined })).toThrow(
34
+ 'spec.tiles must be a non-empty array of strings'
35
+ );
34
36
  });
35
37
 
36
38
  it('should throw an error if the bounds property is invalid', () => {
37
39
  [
38
- { bounds: [-181, -90, 180, 90], errorMessage: 'spec.bounds[0] must be between -180 and 180' },
39
- { bounds: [-180, -91, 180, 90], errorMessage: 'spec.bounds[1] must be between -90 and 90' },
40
- { bounds: [-180, -90, 181, 90], errorMessage: 'spec.bounds[2] must be between -180 and 180' },
41
- { bounds: [-180, -90, 180, 91], errorMessage: 'spec.bounds[3] must be between -90 and 90' },
40
+ { bounds: [-181, -90, 180, 90], errorMessage: 'spec.bounds[0]' },
41
+ { bounds: [-180, -91, 180, 90], errorMessage: 'spec.bounds[1]' },
42
+ { bounds: [-180, -90, 181, 90], errorMessage: 'spec.bounds[2]' },
43
+ { bounds: [-180, -90, 180, 91], errorMessage: 'spec.bounds[3]' },
42
44
  { bounds: [180, -90, -180, 90], errorMessage: 'spec.bounds[0] must be smaller than spec.bounds[2]' },
43
45
  { bounds: [-180, 90, 180, -90], errorMessage: 'spec.bounds[1] must be smaller than spec.bounds[3]' },
44
46
  ].forEach(({ bounds, errorMessage }) => {
@@ -48,10 +50,10 @@ describe('isTileJSONSpecification', () => {
48
50
 
49
51
  it('should throw an error if the center property is invalid', () => {
50
52
  [
51
- { center: [-181, 0], errorMessage: 'spec.center[0] must be between -180 and 180' },
52
- { center: [181, 0], errorMessage: 'spec.center[0] must be between -180 and 180' },
53
- { center: [0, -91], errorMessage: 'spec.center[1] must be between -90 and 90' },
54
- { center: [0, 91], errorMessage: 'spec.center[1] must be between -90 and 90' },
53
+ { center: [-181, 0], errorMessage: 'spec.center[0]' },
54
+ { center: [181, 0], errorMessage: 'spec.center[0]' },
55
+ { center: [0, -91], errorMessage: 'spec.center[1]' },
56
+ { center: [0, 91], errorMessage: 'spec.center[1]' },
55
57
  ].forEach(({ center, errorMessage }) => {
56
58
  expect(() => isTileJSONSpecification({ ...validVectorSpec, center })).toThrow(errorMessage);
57
59
  });
@@ -59,7 +61,7 @@ describe('isTileJSONSpecification', () => {
59
61
 
60
62
  describe('check every property', () => {
61
63
  [
62
- ['tiles', 'an array of strings', ['url'], 'url', [], [1], 1],
64
+ ['tiles', 'a non-empty array of strings', ['url'], 'url', [], [1], 1],
63
65
  ['attribution', 'a string if present', 'valid', 1],
64
66
  ['bounds', 'an array of four numbers if present', [1, 2, 3, 4], ['1', '2', '3', '4'], [1, 2, 3], [], 'invalid'],
65
67
  ['center', 'an array of two numbers if present', [1, 2], ['1', '2'], [1, 2, 3], [], 'invalid'],
@@ -73,7 +75,7 @@ describe('isTileJSONSpecification', () => {
73
75
  ['name', 'a string if present', 'valid', 1],
74
76
  ['scheme', '"tms" or "xyz" if present', 'xyz', 'invalid', 1],
75
77
  ['template', 'a string if present', 'valid', 1],
76
- ].forEach(test => {
78
+ ].forEach((test) => {
77
79
  const key = test[0] as string;
78
80
  const errorMessage = test[1] as string;
79
81
  const values = test.slice(2) as unknown[];
@@ -83,8 +85,9 @@ describe('isTileJSONSpecification', () => {
83
85
  if (i === 0) {
84
86
  expect(isTileJSONSpecification({ ...validVectorSpec, [key]: value })).toBe(true);
85
87
  } else {
86
- expect(() => isTileJSONSpecification({ ...validVectorSpec, [key]: value }))
87
- .toThrow(`spec.${key} must be ${errorMessage}`);
88
+ expect(() => isTileJSONSpecification({ ...validVectorSpec, [key]: value })).toThrow(
89
+ `spec.${key} must be ${errorMessage}`
90
+ );
88
91
  }
89
92
  }
90
93
  });
@@ -29,90 +29,112 @@ export interface TileJSONSpecificationVector extends TileJSONSpecificationRaster
29
29
  /** Represents a TileJSON specification, which can be either raster or vector. */
30
30
  export type TileJSONSpecification = TileJSONSpecificationRaster | TileJSONSpecificationVector;
31
31
 
32
- /**
33
- * Checks if an object adheres to the TileJSON specification.
34
- * Throws errors if the object does not conform to the expected structure or types.
35
- */
32
+ /**
33
+ * Checks if an object adheres to the TileJSON specification.
34
+ * Throws errors if the object does not conform to the expected structure or types.
35
+ */
36
36
  export function isTileJSONSpecification(spec: unknown): spec is TileJSONSpecification {
37
37
  if (typeof spec !== 'object' || spec === null) {
38
- throw Error('spec must be an object');
38
+ throw new Error(`TileJSON validation: spec must be an object, but got ${typeof spec}`);
39
39
  }
40
40
 
41
41
  const obj = spec as Record<string, unknown>;
42
42
 
43
43
  // Common property validation
44
44
  if (obj.data != null && obj.tilejson !== '3.0.0') {
45
- throw Error('spec.tilejson must be "3.0.0"');
45
+ throw new Error(`TileJSON validation: spec.tilejson must be "3.0.0", but got "${obj.tilejson}"`);
46
46
  }
47
47
 
48
48
  if (obj.attribution != null && typeof obj.attribution !== 'string') {
49
- throw Error('spec.attribution must be a string if present');
49
+ throw new Error(
50
+ `TileJSON validation: spec.attribution must be a string if present, but got ${typeof obj.attribution}`
51
+ );
50
52
  }
51
53
 
52
54
  if (obj.bounds != null) {
53
- if (!Array.isArray(obj.bounds) || obj.bounds.length !== 4 || obj.bounds.some(num => typeof num !== 'number')) {
54
- throw Error('spec.bounds must be an array of four numbers if present');
55
+ if (!Array.isArray(obj.bounds) || obj.bounds.length !== 4 || obj.bounds.some((num) => typeof num !== 'number')) {
56
+ throw new Error(
57
+ `TileJSON validation: spec.bounds must be an array of four numbers if present, but got ${JSON.stringify(obj.bounds)}`
58
+ );
55
59
  }
56
60
  const a = obj.bounds as [number, number, number, number];
57
- if (a[0] < -180 || a[0] > 180) throw Error('spec.bounds[0] must be between -180 and 180');
58
- if (a[1] < -90 || a[1] > 90) throw Error('spec.bounds[1] must be between -90 and 90');
59
- if (a[2] < -180 || a[2] > 180) throw Error('spec.bounds[2] must be between -180 and 180');
60
- if (a[3] < -90 || a[3] > 90) throw Error('spec.bounds[3] must be between -90 and 90');
61
- if (a[0] > a[2]) throw Error('spec.bounds[0] must be smaller than spec.bounds[2]');
62
- if (a[1] > a[3]) throw Error('spec.bounds[1] must be smaller than spec.bounds[3]');
61
+ if (a[0] < -180 || a[0] > 180)
62
+ throw new Error(`TileJSON validation: spec.bounds[0] (longitude) must be between -180 and 180, but got ${a[0]}`);
63
+ if (a[1] < -90 || a[1] > 90)
64
+ throw new Error(`TileJSON validation: spec.bounds[1] (latitude) must be between -90 and 90, but got ${a[1]}`);
65
+ if (a[2] < -180 || a[2] > 180)
66
+ throw new Error(`TileJSON validation: spec.bounds[2] (longitude) must be between -180 and 180, but got ${a[2]}`);
67
+ if (a[3] < -90 || a[3] > 90)
68
+ throw new Error(`TileJSON validation: spec.bounds[3] (latitude) must be between -90 and 90, but got ${a[3]}`);
69
+ if (a[0] > a[2])
70
+ throw new Error(
71
+ `TileJSON validation: spec.bounds[0] must be smaller than spec.bounds[2] (min longitude < max longitude), but got [${a[0]}, ${a[2]}]`
72
+ );
73
+ if (a[1] > a[3])
74
+ throw new Error(
75
+ `TileJSON validation: spec.bounds[1] must be smaller than spec.bounds[3] (min latitude < max latitude), but got [${a[1]}, ${a[3]}]`
76
+ );
63
77
  }
64
78
 
65
79
  if (obj.center != null) {
66
- if (!Array.isArray(obj.center) || obj.center.length !== 2 || obj.center.some(num => typeof num !== 'number')) {
67
- throw Error('spec.center must be an array of two numbers if present');
80
+ if (!Array.isArray(obj.center) || obj.center.length !== 2 || obj.center.some((num) => typeof num !== 'number')) {
81
+ throw new Error(
82
+ `TileJSON validation: spec.center must be an array of two numbers if present, but got ${JSON.stringify(obj.center)}`
83
+ );
68
84
  }
69
85
  const a = obj.center as [number, number];
70
- if (a[0] < -180 || a[0] > 180) throw Error('spec.center[0] must be between -180 and 180');
71
- if (a[1] < -90 || a[1] > 90) throw Error('spec.center[1] must be between -90 and 90');
86
+ if (a[0] < -180 || a[0] > 180)
87
+ throw new Error(`TileJSON validation: spec.center[0] (longitude) must be between -180 and 180, but got ${a[0]}`);
88
+ if (a[1] < -90 || a[1] > 90)
89
+ throw new Error(`TileJSON validation: spec.center[1] (latitude) must be between -90 and 90, but got ${a[1]}`);
72
90
  }
73
91
 
74
- if (obj.data != null && (!Array.isArray(obj.data) || obj.data.some(url => typeof url !== 'string'))) {
75
- throw Error('spec.data must be an array of strings if present');
92
+ if (obj.data != null && (!Array.isArray(obj.data) || obj.data.some((url) => typeof url !== 'string'))) {
93
+ throw new Error('TileJSON validation: spec.data must be an array of strings if present');
76
94
  }
77
95
 
78
96
  if (obj.description != null && typeof obj.description !== 'string') {
79
- throw Error('spec.description must be a string if present');
97
+ throw new Error(
98
+ `TileJSON validation: spec.description must be a string if present, but got ${typeof obj.description}`
99
+ );
80
100
  }
81
101
 
82
- if (obj.fillzoom != null && (typeof obj.fillzoom !== 'number' || (obj.fillzoom < 0))) {
83
- throw Error('spec.fillzoom must be a positive integer if present');
102
+ if (obj.fillzoom != null && (typeof obj.fillzoom !== 'number' || obj.fillzoom < 0)) {
103
+ throw new Error(
104
+ `TileJSON validation: spec.fillzoom must be a positive integer if present, but got ${obj.fillzoom}`
105
+ );
84
106
  }
85
107
 
86
- if (obj.grids != null && (!Array.isArray(obj.grids) || obj.grids.some(url => typeof url !== 'string'))) {
87
- throw Error('spec.grids must be an array of strings if present');
108
+ if (obj.grids != null && (!Array.isArray(obj.grids) || obj.grids.some((url) => typeof url !== 'string'))) {
109
+ throw new Error('TileJSON validation: spec.grids must be an array of strings if present');
88
110
  }
89
111
 
90
112
  if (obj.legend != null && typeof obj.legend !== 'string') {
91
- throw Error('spec.legend must be a string if present');
113
+ throw new Error(`TileJSON validation: spec.legend must be a string if present, but got ${typeof obj.legend}`);
92
114
  }
93
115
 
94
- if (obj.minzoom != null && (typeof obj.minzoom !== 'number' || (obj.minzoom < 0))) {
95
- throw Error('spec.minzoom must be a positive integer if present');
116
+ if (obj.minzoom != null && (typeof obj.minzoom !== 'number' || obj.minzoom < 0)) {
117
+ throw new Error(`TileJSON validation: spec.minzoom must be a positive integer if present, but got ${obj.minzoom}`);
96
118
  }
97
119
 
98
- if (obj.maxzoom != null && (typeof obj.maxzoom !== 'number' || (obj.maxzoom < 0))) {
99
- throw Error('spec.maxzoom must be a positive integer if present');
120
+ if (obj.maxzoom != null && (typeof obj.maxzoom !== 'number' || obj.maxzoom < 0)) {
121
+ throw new Error(`TileJSON validation: spec.maxzoom must be a positive integer if present, but got ${obj.maxzoom}`);
100
122
  }
101
123
 
102
124
  if (obj.name != null && typeof obj.name !== 'string') {
103
- throw Error('spec.name must be a string if present');
125
+ throw new Error(`TileJSON validation: spec.name must be a string if present, but got ${typeof obj.name}`);
104
126
  }
105
127
 
106
128
  if (obj.scheme != null && obj.scheme !== 'xyz' && obj.scheme !== 'tms') {
107
- throw Error('spec.scheme must be "tms" or "xyz" if present');
129
+ throw new Error(`TileJSON validation: spec.scheme must be "tms" or "xyz" if present, but got "${obj.scheme}"`);
108
130
  }
109
131
 
110
132
  if (obj.template != null && typeof obj.template !== 'string') {
111
- throw Error('spec.template must be a string if present');
133
+ throw new Error(`TileJSON validation: spec.template must be a string if present, but got ${typeof obj.template}`);
112
134
  }
113
135
 
114
- if (!Array.isArray(obj.tiles) || obj.tiles.length === 0 || obj.tiles.some(url => typeof url !== 'string')) {
115
- throw Error('spec.tiles must be an array of strings');
136
+ if (!Array.isArray(obj.tiles) || obj.tiles.length === 0 || obj.tiles.some((url) => typeof url !== 'string')) {
137
+ throw new Error('TileJSON validation: spec.tiles must be a non-empty array of strings');
116
138
  }
117
139
 
118
140
  return true;
@@ -120,6 +142,6 @@ export function isTileJSONSpecification(spec: unknown): spec is TileJSONSpecific
120
142
 
121
143
  export function isRasterTileJSONSpecification(spec: unknown): spec is TileJSONSpecificationRaster {
122
144
  if (!isTileJSONSpecification(spec)) return false;
123
- if (('vector_layers' in spec) && (spec.vector_layers != null)) return false;
145
+ if ('vector_layers' in spec && spec.vector_layers != null) return false;
124
146
  return true;
125
147
  }
@@ -20,7 +20,10 @@ describe('isVectorLayer', () => {
20
20
 
21
21
  it('should throw an error for invalid fields', () => {
22
22
  verifyError({ id: 'test', fields: null }, 'Layer.fields must be a non-null object');
23
- verifyError({ id: 'test', fields: { field1: 'InvalidType' } }, 'Layer.fields values must be one of \'Boolean\', \'Number\', or \'String\'');
23
+ verifyError(
24
+ { id: 'test', fields: { field1: 'InvalidType' } },
25
+ "Layer.fields values must be one of 'Boolean', 'Number', or 'String'"
26
+ );
24
27
  });
25
28
 
26
29
  it('should throw an error for invalid optional properties', () => {
@@ -60,6 +63,6 @@ describe('isVectorLayers', () => {
60
63
  { id: 'layer2', fields: { field2: 'InvalidType' } },
61
64
  ];
62
65
 
63
- expect(() => isVectorLayers(invalidLayers)).toThrow(/Layer\[\d+\] at invalid:/);
66
+ expect(() => isVectorLayers(invalidLayers)).toThrow('Layer[1] is invalid');
64
67
  });
65
68
  });
@@ -7,10 +7,10 @@ export interface VectorLayer {
7
7
  maxzoom?: number;
8
8
  }
9
9
 
10
- /**
11
- * Verifies if an object conforms to the VectorLayer structure.
12
- * Throws errors for any deviations from the expected structure or types.
13
- */
10
+ /**
11
+ * Verifies if an object conforms to the VectorLayer structure.
12
+ * Throws errors for any deviations from the expected structure or types.
13
+ */
14
14
  export function isVectorLayer(layer: unknown): layer is VectorLayer {
15
15
  if (typeof layer !== 'object' || layer === null) {
16
16
  throw new Error('Layer must be a non-null object');
@@ -25,8 +25,8 @@ export function isVectorLayer(layer: unknown): layer is VectorLayer {
25
25
  if (typeof obj.fields !== 'object' || obj.fields === null) {
26
26
  throw new Error('Layer.fields must be a non-null object');
27
27
  }
28
- if (Object.values(obj.fields).some(type => !['Boolean', 'Number', 'String'].includes(type as string))) {
29
- throw new Error('Layer.fields values must be one of \'Boolean\', \'Number\', or \'String\'');
28
+ if (Object.values(obj.fields).some((type) => !['Boolean', 'Number', 'String'].includes(type as string))) {
29
+ throw new Error("Layer.fields values must be one of 'Boolean', 'Number', or 'String'");
30
30
  }
31
31
 
32
32
  if ('description' in obj && typeof obj.description !== 'string') {
@@ -58,10 +58,8 @@ export function isVectorLayers(layers: unknown): layers is VectorLayer[] {
58
58
  if (!isVectorLayer(layer)) {
59
59
  throw new Error(`Layer[${index}] is invalid`);
60
60
  }
61
- } catch (error) {
62
- // Assuming `isVectorLayer` throws an error with a meaningful message, you can rethrow it
63
- // Alternatively, customize the error message or handle the error as needed
64
- throw new Error(`Layer[${index}] at invalid: ${String((error instanceof Error) ? error.message : error)}`);
61
+ } catch (cause) {
62
+ throw new Error(`Layer[${index}] is invalid`, { cause });
65
63
  }
66
64
  });
67
65