@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.
- package/README.md +38 -32
- package/dist/index.d.ts +21 -15
- package/dist/index.js +719 -419
- package/dist/index.js.map +1 -1
- package/package.json +22 -17
- package/src/color/abstract.ts +1 -1
- package/src/color/hsl.test.ts +11 -18
- package/src/color/hsl.ts +11 -23
- package/src/color/hsv.test.ts +4 -10
- package/src/color/hsv.ts +38 -22
- package/src/color/index.test.ts +91 -5
- package/src/color/index.ts +4 -2
- package/src/color/random.test.ts +55 -1
- package/src/color/random.ts +130 -26
- package/src/color/rgb.test.ts +60 -38
- package/src/color/rgb.ts +34 -56
- package/src/color/utils.test.ts +4 -6
- package/src/color/utils.ts +2 -4
- package/src/guess_style/guess_style.test.ts +49 -43
- package/src/guess_style/guess_style.ts +67 -22
- package/src/guess_style/index.ts +0 -1
- package/src/index.test.ts +35 -8
- package/src/index.ts +41 -21
- package/src/lib/utils.test.ts +86 -3
- package/src/lib/utils.ts +12 -20
- package/src/shortbread/index.ts +0 -1
- package/src/shortbread/layers.test.ts +15 -1
- package/src/shortbread/layers.ts +204 -199
- package/src/shortbread/properties.test.ts +3 -4
- package/src/shortbread/properties.ts +18 -4
- package/src/shortbread/template.test.ts +7 -2
- package/src/shortbread/template.ts +7 -14
- package/src/style_builder/decorator.test.ts +4 -4
- package/src/style_builder/decorator.ts +46 -31
- package/src/style_builder/recolor.test.ts +6 -31
- package/src/style_builder/recolor.ts +38 -31
- package/src/style_builder/style_builder.test.ts +50 -13
- package/src/style_builder/style_builder.ts +35 -34
- package/src/style_builder/types.ts +44 -2
- package/src/styles/LICENSE.md +15 -15
- package/src/styles/colorful.test.ts +91 -0
- package/src/styles/colorful.ts +229 -122
- package/src/styles/eclipse.ts +1 -1
- package/src/styles/empty.ts +1 -1
- package/src/styles/graybeard.ts +2 -2
- package/src/styles/index.ts +2 -3
- package/src/styles/neutrino.ts +14 -16
- package/src/styles/satellite.test.ts +146 -0
- package/src/styles/satellite.ts +106 -0
- package/src/styles/shadow.ts +2 -2
- package/src/types/index.ts +0 -1
- package/src/types/maplibre.ts +17 -3
- package/src/types/tilejson.test.ts +17 -14
- package/src/types/tilejson.ts +59 -37
- package/src/types/vector_layer.test.ts +5 -2
- 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
|
+
}
|
package/src/styles/shadow.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/types/index.ts
CHANGED
package/src/types/maplibre.ts
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
|
-
import type {
|
|
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 =
|
|
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 =
|
|
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(
|
|
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]
|
|
39
|
-
{ bounds: [-180, -91, 180, 90], errorMessage: 'spec.bounds[1]
|
|
40
|
-
{ bounds: [-180, -90, 181, 90], errorMessage: 'spec.bounds[2]
|
|
41
|
-
{ bounds: [-180, -90, 180, 91], errorMessage: 'spec.bounds[3]
|
|
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]
|
|
52
|
-
{ center: [181, 0], errorMessage: 'spec.center[0]
|
|
53
|
-
{ center: [0, -91], errorMessage: 'spec.center[1]
|
|
54
|
-
{ center: [0, 91], errorMessage: 'spec.center[1]
|
|
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', '
|
|
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
|
-
|
|
88
|
+
expect(() => isTileJSONSpecification({ ...validVectorSpec, [key]: value })).toThrow(
|
|
89
|
+
`spec.${key} must be ${errorMessage}`
|
|
90
|
+
);
|
|
88
91
|
}
|
|
89
92
|
}
|
|
90
93
|
});
|
package/src/types/tilejson.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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)
|
|
58
|
-
|
|
59
|
-
if (a[
|
|
60
|
-
|
|
61
|
-
if (a[
|
|
62
|
-
|
|
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(
|
|
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)
|
|
71
|
-
|
|
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(
|
|
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' ||
|
|
83
|
-
throw Error(
|
|
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(
|
|
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' ||
|
|
95
|
-
throw Error(
|
|
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' ||
|
|
99
|
-
throw Error(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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 (
|
|
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(
|
|
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(
|
|
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(
|
|
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 (
|
|
62
|
-
|
|
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
|
|