From 4b5617cc56c17268eb5d90fa4a7fd1a23d50efb4 Mon Sep 17 00:00:00 2001 From: Birk Skyum <74932975+birkskyum@users.noreply.github.com> Date: Wed, 13 Nov 2024 09:42:11 +0100 Subject: [PATCH 01/14] Add projection expression syntax (#888) --- CHANGELOG.md | 3 +- build/generate-docs.ts | 1 + build/generate-style-spec.ts | 11 ++- docs/types.md | 73 +++++++++++++++++++ src/diff.test.ts | 7 +- src/expression/definitions/coercion.ts | 2 + src/expression/definitions/interpolate.ts | 15 ++-- src/expression/expression.test.ts | 53 ++++++++++++++ src/expression/index.ts | 7 +- src/expression/parsing_context.ts | 2 + src/expression/types.ts | 7 +- .../types/projection_definition.test.ts | 9 +++ src/expression/types/projection_definition.ts | 36 +++++++++ src/expression/values.ts | 12 ++- src/index.test.ts | 3 +- src/index.ts | 9 +++ src/reference/v8.json | 34 +++++---- src/validate/validate.ts | 2 + src/validate/validate_projection.test.ts | 31 ++++++-- .../validate_projectiondefinition.test.ts | 59 +++++++++++++++ src/validate/validate_projectiondefinition.ts | 36 +++++++++ .../integration/expression/expression.test.ts | 6 +- .../projection/higher-than-stop/test.json | 44 +++++++++++ .../interpolate/projection/linear/test.json | 48 ++++++++++++ .../projection/lower-than-stop/test.json | 44 +++++++++++ .../projection/same-from-to/test.json | 48 ++++++++++++ .../step/projection/step-array/test.json | 44 +++++++++++ .../step/projection/step-primitive/test.json | 42 +++++++++++ .../style-spec/tests/projection.input.json | 6 ++ .../style-spec/tests/projection.output.json | 1 + 30 files changed, 649 insertions(+), 46 deletions(-) create mode 100644 src/expression/types/projection_definition.test.ts create mode 100644 src/expression/types/projection_definition.ts create mode 100644 src/validate/validate_projectiondefinition.test.ts create mode 100644 src/validate/validate_projectiondefinition.ts create mode 100644 test/integration/expression/tests/interpolate/projection/higher-than-stop/test.json create mode 100644 test/integration/expression/tests/interpolate/projection/linear/test.json create mode 100644 test/integration/expression/tests/interpolate/projection/lower-than-stop/test.json create mode 100644 test/integration/expression/tests/interpolate/projection/same-from-to/test.json create mode 100644 test/integration/expression/tests/step/projection/step-array/test.json create mode 100644 test/integration/expression/tests/step/projection/step-primitive/test.json create mode 100644 test/integration/style-spec/tests/projection.input.json create mode 100644 test/integration/style-spec/tests/projection.output.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a9bfb0e46..1354df50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### ✨ Features and improvements - Use named imports internally - no package entrypoints changed ([#904](https://github.com/maplibre/maplibre-style-spec/pull/904)) +- Add projection type expression syntax ([#888](https://github.com/maplibre/maplibre-style-spec/pull/888)) - _...Add new stuff here..._ ### 🐞 Bug fixes @@ -10,7 +11,7 @@ ## 21.2.0 ### ✨ Features and improvements -Add `vertical-perspective` projection ([#890](https://github.com/maplibre/maplibre-style-spec/pull/890)) +- Add `vertical-perspective` projection ([#890](https://github.com/maplibre/maplibre-style-spec/pull/890)) ## 21.1.0 diff --git a/build/generate-docs.ts b/build/generate-docs.ts index 5dd9fdfa6..1cf373311 100644 --- a/build/generate-docs.ts +++ b/build/generate-docs.ts @@ -166,6 +166,7 @@ function typeToMarkdownLink(type: string): string { case 'promoteid': return ` [${type}](types.md)`; case 'color': + case 'projectiondefinition': case 'number': case 'string': case 'boolean': diff --git a/build/generate-style-spec.ts b/build/generate-style-spec.ts index 1f45a38d7..8c3c6387c 100644 --- a/build/generate-style-spec.ts +++ b/build/generate-style-spec.ts @@ -37,6 +37,8 @@ function propertyType(property) { return 'SkySpecification'; case 'sources': return '{[_: string]: SourceSpecification}'; + case 'projection:': + return 'ProjectionSpecification'; case '*': return 'unknown'; default: @@ -121,6 +123,9 @@ writeFileSync('src/types.g.ts', export type ColorSpecification = string; +export type ProjectionDefinitionT = [string, string, number]; +export type ProjectionDefinitionSpecification = string | ProjectionDefinitionT | PropertyValueSpecification + export type PaddingSpecification = number | number[]; export type VariableAnchorOffsetCollectionSpecification = Array; @@ -201,7 +206,7 @@ export type ExpressionSpecification = | ['distance', unknown | ExpressionSpecification] // Ramps, scales, curves | ['interpolate', InterpolationSpecification, number | ExpressionSpecification, - ...(number | number[] | ColorSpecification | ExpressionSpecification)[]] // alternating number and number | number[] | ColorSpecification + ...(number | number[] | ColorSpecification | ExpressionSpecification | ProjectionDefinitionSpecification )[]] // alternating number and number | number[] | ColorSpecification | ['interpolate-hcl', InterpolationSpecification, number | ExpressionSpecification, ...(number | ColorSpecification)[]] // alternating number and ColorSpecificaton | ['interpolate-lab', InterpolationSpecification, number | ExpressionSpecification, @@ -316,10 +321,10 @@ ${objectDeclaration('LightSpecification', spec.light)} ${objectDeclaration('SkySpecification', spec.sky)} -${objectDeclaration('TerrainSpecification', spec.terrain)} - ${objectDeclaration('ProjectionSpecification', spec.projection)} +${objectDeclaration('TerrainSpecification', spec.terrain)} + ${spec.source.map(key => { let str = objectDeclaration(sourceTypeName(key), spec[key]); if (sourceTypeName(key) === 'GeoJSONSourceSpecification') { diff --git a/docs/types.md b/docs/types.md index a5738aefd..206294452 100644 --- a/docs/types.md +++ b/docs/types.md @@ -133,3 +133,76 @@ The following example applies 2em padding on top and bottom and 3em padding left "icon-padding": [2, 3] } ``` + +## ProjectionDefinition + +The `projection` is used to configure which projection to use for the map. + +There are currently two projections implemented. + +- `mercator` - [Web Mercator projection](https://en.wikipedia.org/wiki/Web_Mercator_projection) +- `vertical-perspective` - [Vertical Perspective projection](https://en.wikipedia.org/wiki/General_Perspective_projection) + +And the following [presets](#use-a-projection-preset) + +The `projection` output sent to the renderer is always of the shape: + +`[from, to, transition]: [string, string, number]` + +- `from` is the projection of lower zoom level +- `to` is the projection of higher zoom level +- `transition` is the interpolation value, going from 0 to 1, with 0 being in the `from` projection, and 1 being in the `to` projection. + +In case `from` and `to` are equal, the `transition` will have no effect. + +### Examples + +#### Step between projection at discrete zoom levels + +Use a [`camera expression`](./expressions.md#camera-expressions), to discretely [`step`](./expressions.md#step) between projections at certain zoom levels. + + +```ts +type: ["step", ["zoom"], + "vertical-perspective", + 11, "mercator" +] + + +output at zoom 10.9: "vertical-perspective" +output at zoom 11.0: "vertical-perspective" +output at zoom 11.1: "mercator" +``` + +#### Animate between different projections based on zoom level** + +Use a [`camera expression`](./expressions.md#camera-expressions), to animate between projections based on zoom, using [`interpolate`](./expressions.md#interpolate) function. The example below will yield an adaptive globe that interpolates from `vertical-perspective` to `mercator` between zoom 10 and 12. + +```ts +type: ["interpolate", ["linear"], ["zoom"], + 10,"vertical-perspective", + 12,"mercator" +] + + +output at zoom 9.9: "vertical-perspective" +output at zoom 11: ["vertical-perspective", "mercator", 0.5] +output at zoom 12: ["vertical-perspective", "mercator", 1] +output at zoom 12.1: "mercator" +``` + + +#### Provide a `projection` + +```ts +type: ["vertical-perspective", "mercator", 0.7] +``` + +#### Use a projection preset + +There are also additional presets that yield commonly used expressions: + + +| Preset | Full value | Description | +|--------|------------|-------------| +| `globe` | `["interpolate", ["linear"], ["zoom"],`
`10, "vertical-perspective", 12, "mercator"]` | Adaptive globe: interpolates from vertical-perspective to mercator projection between zoom levels 10 and 12. | diff --git a/src/diff.test.ts b/src/diff.test.ts index e2fdee07d..6c8105d9a 100644 --- a/src/diff.test.ts +++ b/src/diff.test.ts @@ -606,11 +606,10 @@ describe('diff', () => { expect(diff({ } as StyleSpecification, { - projection: { - type: 'globe' - } + projection: {type: ['vertical-perspective', 'mercator', 0.5]} + } as StyleSpecification)).toEqual([ - {command: 'setProjection', args: [{type: 'globe'}]}, + {command: 'setProjection', args: [{type: ['vertical-perspective', 'mercator', 0.5]}]}, ]); }); }); diff --git a/src/expression/definitions/coercion.ts b/src/expression/definitions/coercion.ts index f118ad960..cb63da091 100644 --- a/src/expression/definitions/coercion.ts +++ b/src/expression/definitions/coercion.ts @@ -126,6 +126,8 @@ export class Coercion implements Expression { return Formatted.fromString(valueToString(this.args[0].evaluate(ctx))); case 'resolvedImage': return ResolvedImage.fromString(valueToString(this.args[0].evaluate(ctx))); + case 'projectionDefinition': + return this.args[0].evaluate(ctx); default: return valueToString(this.args[0].evaluate(ctx)); } diff --git a/src/expression/definitions/interpolate.ts b/src/expression/definitions/interpolate.ts index a4326c696..c3fcd2218 100644 --- a/src/expression/definitions/interpolate.ts +++ b/src/expression/definitions/interpolate.ts @@ -1,13 +1,13 @@ import UnitBezier from '@mapbox/unitbezier'; -import {array, ArrayType, ColorType, ColorTypeT, NumberType, NumberTypeT, PaddingType, PaddingTypeT, VariableAnchorOffsetCollectionType, VariableAnchorOffsetCollectionTypeT, typeToString, verifyType} from '../types'; +import {array, ArrayType, ColorType, ColorTypeT, NumberType, NumberTypeT, PaddingType, PaddingTypeT, VariableAnchorOffsetCollectionType, VariableAnchorOffsetCollectionTypeT, typeToString, verifyType, ProjectionDefinitionType} from '../types'; import {findStopLessThanOrEqualTo} from '../stops'; import type {Stops} from '../stops'; import type {Expression} from '../expression'; import type {ParsingContext} from '../parsing_context'; import type {EvaluationContext} from '../evaluation_context'; -import type {Type} from '../types'; +import type {ProjectionDefinitionTypeT, StringTypeT, Type} from '../types'; import {Color} from '../types/color'; import {interpolateArray, interpolateNumber} from '../../util/interpolate-primitives'; import {Padding} from '../types/padding'; @@ -22,18 +22,19 @@ export type InterpolationType = { name: 'cubic-bezier'; controlPoints: [number, number, number, number]; }; -type InterpolatedValueType = NumberTypeT | ColorTypeT | PaddingTypeT | VariableAnchorOffsetCollectionTypeT | ArrayType; +type InterpolatedValueType = NumberTypeT | ColorTypeT | StringTypeT | ProjectionDefinitionTypeT | PaddingTypeT | VariableAnchorOffsetCollectionTypeT | ArrayType; +type InterpolationOperator = 'interpolate' | 'interpolate-hcl' | 'interpolate-lab'; export class Interpolate implements Expression { type: InterpolatedValueType; - operator: 'interpolate' | 'interpolate-hcl' | 'interpolate-lab'; + operator: InterpolationOperator ; interpolation: InterpolationType; input: Expression; labels: Array; outputs: Array; - constructor(type: InterpolatedValueType, operator: 'interpolate' | 'interpolate-hcl' | 'interpolate-lab', interpolation: InterpolationType, input: Expression, stops: Stops) { + constructor(type: InterpolatedValueType, operator: InterpolationOperator, interpolation: InterpolationType, input: Expression, stops: Stops) { this.type = type; this.operator = operator; this.interpolation = interpolation; @@ -129,7 +130,6 @@ export class Interpolate implements Expression { if (stops.length && stops[stops.length - 1][0] >= label) { return context.error('Input/output pairs for "interpolate" expressions must be arranged with input values in strictly ascending order.', labelKey) as null; } - const parsed = context.parse(value, valueKey, outputType); if (!parsed) return null; outputType = outputType || parsed.type; @@ -137,6 +137,7 @@ export class Interpolate implements Expression { } if (!verifyType(outputType, NumberType) && + !verifyType(outputType, ProjectionDefinitionType) && !verifyType(outputType, ColorType) && !verifyType(outputType, PaddingType) && !verifyType(outputType, VariableAnchorOffsetCollectionType) && @@ -173,7 +174,7 @@ export class Interpolate implements Expression { const outputLower = outputs[index].evaluate(ctx); const outputUpper = outputs[index + 1].evaluate(ctx); - + switch (this.operator) { case 'interpolate': switch (this.type.kind) { diff --git a/src/expression/expression.test.ts b/src/expression/expression.test.ts index 33d660a74..28b05cc50 100644 --- a/src/expression/expression.test.ts +++ b/src/expression/expression.test.ts @@ -629,3 +629,56 @@ describe('slice expression', () => { expect((response.value as StyleExpression)?.evaluate({zoom: 20})).toEqual([]); }); }); + +describe('projection expression', () => { + + test('step', () => { + const response = createExpression(['step', ['zoom'], 'vertical-perspective', 10, 'mercator']); + + if (response.result === 'success') { + expect(response.value.evaluate({zoom: 5})).toBe('vertical-perspective'); + expect(response.value.evaluate({zoom: 10})).toBe('mercator'); + expect(response.value.evaluate({zoom: 11})).toBe('mercator'); + } else { + throw new Error('Failed to parse Step expression'); + } + }) + + test('step array', () => { + const response = createExpression(['step', ['zoom'], ['literal', ['vertical-perspective', 'mercator', 0.5]], 10, 'mercator']); + + if (response.result === 'success') { + expect(response.value.evaluate({zoom: 5})).toStrictEqual(['vertical-perspective', 'mercator', 0.5]); + expect(response.value.evaluate({zoom: 10})).toBe('mercator'); + expect(response.value.evaluate({zoom: 11})).toBe('mercator'); + } else { + throw new Error('Failed to parse Step expression'); + } + }) + + test('interpolate color', () => { + + const response = createExpression(['interpolate', ['linear'], ['zoom'], 8, 'vertical-perspective', 10, 'mercator']); + + if (response.result === 'success') { + expect(response.value.evaluate({zoom: 5})).toBe('vertical-perspective'); + expect(response.value.evaluate({zoom: 9})).toBe(['vertical-perspective', 'mercator', 0.5]); + expect(response.value.evaluate({zoom: 11})).toBe('mercator'); + } else { + throw new Error('Failed to parse Interpolate expression'); + } + }) + + test('interpolate', () => { + const response = createExpression(['interpolate', ['linear'], ['zoom'], 8, 'vertical-perspective', 10, 'mercator']); + + if (response.result === 'success') { + expect(response.value.evaluate({zoom: 5})).toBe('vertical-perspective'); + expect(response.value.evaluate({zoom: 9})).toBe(['vertical-perspective', 'mercator', 0.5]); + expect(response.value.evaluate({zoom: 11})).toBe('mercator'); + } else { + throw new Error('Failed to parse Interpolate expression'); + } + }) + +}); diff --git a/src/expression/index.ts b/src/expression/index.ts index f820549a5..cf0c64014 100644 --- a/src/expression/index.ts +++ b/src/expression/index.ts @@ -20,7 +20,7 @@ import {RuntimeError} from './runtime_error'; import {success, error} from '../util/result'; import {supportsPropertyExpression, supportsZoomExpression, supportsInterpolation} from '../util/properties'; -import {ColorType, StringType, NumberType, BooleanType, ValueType, FormattedType, PaddingType, ResolvedImageType, VariableAnchorOffsetCollectionType, array, type Type, type EvaluationKind} from './types'; +import {ColorType, StringType, NumberType, BooleanType, ValueType, FormattedType, PaddingType, ResolvedImageType, VariableAnchorOffsetCollectionType, array, type Type, type EvaluationKind, ProjectionDefinitionType} from './types'; import type {Value} from './values'; import type {Expression} from './expression'; import type {StylePropertySpecification} from '..'; @@ -394,6 +394,8 @@ export function normalizePropertyExpression( constant = Padding.parse(value as PaddingSpecification); } else if (specification.type === 'variableAnchorOffsetCollection' && Array.isArray(value)) { constant = VariableAnchorOffsetCollection.parse(value as VariableAnchorOffsetCollectionSpecification); + } else if (specification.type === 'projectionDefinition' && typeof value === 'string') { + constant = ProjectionDefinition.parse(value); } return { kind: 'constant', @@ -452,6 +454,7 @@ function getExpectedType(spec: StylePropertySpecification): Type { boolean: BooleanType, formatted: FormattedType, padding: PaddingType, + projectionDefinition: ProjectionDefinitionType, resolvedImage: ResolvedImageType, variableAnchorOffsetCollection: VariableAnchorOffsetCollectionType }; @@ -475,6 +478,8 @@ function getDefaultValue(spec: StylePropertySpecification): Value { return Padding.parse(spec.default) || null; } else if (spec.type === 'variableAnchorOffsetCollection') { return VariableAnchorOffsetCollection.parse(spec.default) || null; + } else if (spec.type === 'projectionDefinition') { + return ProjectionDefinition.parse(spec.default) || null; } else if (spec.default === undefined) { return null; } else { diff --git a/src/expression/parsing_context.ts b/src/expression/parsing_context.ts index 11be4f5a3..618f79732 100644 --- a/src/expression/parsing_context.ts +++ b/src/expression/parsing_context.ts @@ -120,6 +120,8 @@ export class ParsingContext { // if ((expected.kind === 'string' || expected.kind === 'number' || expected.kind === 'boolean' || expected.kind === 'object' || expected.kind === 'array') && actual.kind === 'value') { parsed = annotate(parsed, expected, options.typeAnnotation || 'assert'); + } else if ((expected.kind === 'projectionDefinition') && (actual.kind === 'string' || actual.kind === 'array')) { + parsed = annotate(parsed, expected, options.typeAnnotation || 'coerce'); } else if ((expected.kind === 'color' || expected.kind === 'formatted' || expected.kind === 'resolvedImage') && (actual.kind === 'value' || actual.kind === 'string')) { parsed = annotate(parsed, expected, options.typeAnnotation || 'coerce'); } else if (expected.kind === 'padding' && (actual.kind === 'value' || actual.kind === 'number' || actual.kind === 'array')) { diff --git a/src/expression/types.ts b/src/expression/types.ts index ab7ac672a..deb0f3a70 100644 --- a/src/expression/types.ts +++ b/src/expression/types.ts @@ -13,6 +13,9 @@ export type BooleanTypeT = { export type ColorTypeT = { kind: 'color'; }; +export type ProjectionDefinitionTypeT = { + kind: 'projectionDefinition'; +}; export type ObjectTypeT = { kind: 'object'; }; @@ -40,7 +43,7 @@ export type VariableAnchorOffsetCollectionTypeT = { export type EvaluationKind = 'constant' | 'source' | 'camera' | 'composite'; -export type Type = NullTypeT | NumberTypeT | StringTypeT | BooleanTypeT | ColorTypeT | ObjectTypeT | ValueTypeT | +export type Type = NullTypeT | NumberTypeT | StringTypeT | BooleanTypeT | ColorTypeT | ProjectionDefinitionTypeT | ObjectTypeT | ValueTypeT | ArrayType | ErrorTypeT | CollatorTypeT | FormattedTypeT | PaddingTypeT | ResolvedImageTypeT | VariableAnchorOffsetCollectionTypeT; export interface ArrayType { @@ -56,6 +59,7 @@ export const NumberType = {kind: 'number'} as NumberTypeT; export const StringType = {kind: 'string'} as StringTypeT; export const BooleanType = {kind: 'boolean'} as BooleanTypeT; export const ColorType = {kind: 'color'} as ColorTypeT; +export const ProjectionDefinitionType = {kind: 'projectionDefinition'} as ProjectionDefinitionTypeT; export const ObjectType = {kind: 'object'} as ObjectTypeT; export const ValueType = {kind: 'value'} as ValueTypeT; export const ErrorType = {kind: 'error'} as ErrorTypeT; @@ -90,6 +94,7 @@ const valueMemberTypes = [ StringType, BooleanType, ColorType, + ProjectionDefinitionType, FormattedType, ObjectType, array(ValueType), diff --git a/src/expression/types/projection_definition.test.ts b/src/expression/types/projection_definition.test.ts new file mode 100644 index 000000000..a6e9cf4f7 --- /dev/null +++ b/src/expression/types/projection_definition.test.ts @@ -0,0 +1,9 @@ +import ProjectionDefinition from './projection_definition'; + +describe('Projection class', () => { + + test('should serialize projection, with [from, to, transition]', () => { + expect(`${new ProjectionDefinition('mercator', 'vertical-perspective', 1)}`).toBe('["mercator", "vertical-perspective", 1]'); + expect(`${new ProjectionDefinition('vertical-perspective', 'mercator', 0.3)}`).toBe('["vertical-perspective", "mercator", 0.3]'); + }); +}); diff --git a/src/expression/types/projection_definition.ts b/src/expression/types/projection_definition.ts new file mode 100644 index 000000000..d4877f2ba --- /dev/null +++ b/src/expression/types/projection_definition.ts @@ -0,0 +1,36 @@ +export default class ProjectionDefinition { + public from + public to + public transition + + constructor(from: string, to: string, transition: number){ + this.from = from; + this.to = to; + this.transition = transition; + } + + toString() { + return `["${this.from}", "${this.to}", ${this.transition}]`; + } + + toJSON() { + return [this.from, this.to, this.transition]; + } + + static interpolate(from: string, to: string, t: number) { + return new ProjectionDefinition(from, to, t); + } + + static parse(input?: any): ProjectionDefinition { + if (input instanceof ProjectionDefinition) { + return input; + } + if (Array.isArray(input) && input.length === 3) { + return new ProjectionDefinition(input[0], input[1], input[2]); + } + if (typeof input === 'string') { + return new ProjectionDefinition(input, input, 1); + } + return undefined; + } +} \ No newline at end of file diff --git a/src/expression/values.ts b/src/expression/values.ts index fb6034c29..8da3aeb75 100644 --- a/src/expression/values.ts +++ b/src/expression/values.ts @@ -5,9 +5,10 @@ import {Formatted} from './types/formatted'; import {Padding} from './types/padding'; import {VariableAnchorOffsetCollection} from './types/variable_anchor_offset_collection'; import {ResolvedImage} from './types/resolved_image'; -import {NullType, NumberType, StringType, BooleanType, ColorType, ObjectType, ValueType, CollatorType, FormattedType, ResolvedImageType, array, PaddingType, VariableAnchorOffsetCollectionType} from './types'; +import {NullType, NumberType, StringType, BooleanType, ColorType, ObjectType, ValueType, CollatorType, FormattedType, ResolvedImageType, array, PaddingType, VariableAnchorOffsetCollectionType, ProjectionDefinitionType} from './types'; import type {Type} from './types'; +import ProjectionDefinition from './types/projection_definition'; export function validateRGBA(r: unknown, g: unknown, b: unknown, a?: unknown): string | null { if (!( @@ -28,7 +29,7 @@ export function validateRGBA(r: unknown, g: unknown, b: unknown, a?: unknown): s return null; } -export type Value = null | string | boolean | number | Color | Collator | Formatted | Padding | ResolvedImage | VariableAnchorOffsetCollection | ReadonlyArray | { +export type Value = null | string | boolean | number | Color | ProjectionDefinition | Collator | Formatted | Padding | ResolvedImage | VariableAnchorOffsetCollection | ReadonlyArray | { readonly [x: string]: Value; }; @@ -37,6 +38,7 @@ export function isValue(mixed: unknown): boolean { typeof mixed === 'string' || typeof mixed === 'boolean' || typeof mixed === 'number' || + mixed instanceof ProjectionDefinition || mixed instanceof Color || mixed instanceof Collator || mixed instanceof Formatted || @@ -74,6 +76,8 @@ export function typeOf(value: Value): Type { return NumberType; } else if (value instanceof Color) { return ColorType; + } else if (value instanceof ProjectionDefinition) { + return ProjectionDefinitionType; } else if (value instanceof Collator) { return CollatorType; } else if (value instanceof Formatted) { @@ -112,9 +116,9 @@ export function valueToString(value: Value) { return ''; } else if (type === 'string' || type === 'number' || type === 'boolean') { return String(value); - } else if (value instanceof Color || value instanceof Formatted || value instanceof Padding || value instanceof VariableAnchorOffsetCollection || value instanceof ResolvedImage) { + } else if (value instanceof Color || value instanceof ProjectionDefinition || value instanceof Formatted || value instanceof Padding || value instanceof VariableAnchorOffsetCollection || value instanceof ResolvedImage) { return value.toString(); } else { return JSON.stringify(value); } -} \ No newline at end of file +} diff --git a/src/index.test.ts b/src/index.test.ts index 675e197e4..2cd78af9c 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -69,7 +69,8 @@ function validSchema(k, v, obj, ref, version, kind) { 'promoteId', 'padding', 'variableAnchorOffsetCollection', - 'sprite' + 'sprite', + 'projectionDefinition' ]); const keys = [ 'default', diff --git a/src/index.ts b/src/index.ts index fd6c8b191..8f336d888 100644 --- a/src/index.ts +++ b/src/index.ts @@ -39,6 +39,7 @@ import {format} from './format'; import {validate} from './validate/validate'; import {migrate} from './migrate'; import {classifyRings} from './util/classify_rings'; +import ProjectionDefinition from './expression/types/projection_definition'; type ExpressionType = 'data-driven' | 'cross-faded' | 'cross-faded-data-driven' | 'color-ramp' | 'data-constant' | 'constant'; type ExpressionParameters = Array<'zoom' | 'feature' | 'feature-state' | 'heatmap-density' | 'line-progress'>; @@ -109,6 +110,12 @@ export type StylePropertySpecification = { expression?: ExpressionSpecificationDefinition; transition: boolean; default?: VariableAnchorOffsetCollectionSpecification; +} | { + type: 'projectionDefinition'; + 'property-type': ExpressionType; + expression?: ExpressionSpecificationDefinition; + transition: boolean; + default?: ProjectionDefinitionSpecification; }; const expression = { @@ -138,6 +145,7 @@ export { ValidationError, ParsingError, FeatureState, + ProjectionDefinition, Color, Step, CompoundExpression, @@ -188,6 +196,7 @@ export { migrate, classifyRings, + ProjectionDefinitionType, ColorType, interpolateFactory as interpolates, v8, diff --git a/src/reference/v8.json b/src/reference/v8.json index d91c18f8e..eb79093f9 100644 --- a/src/reference/v8.json +++ b/src/reference/v8.json @@ -127,9 +127,15 @@ }, "projection": { "type": "projection", - "doc": "The projection configuration. **Note:** this definition is still experimental and is under development in maplibre-gl-js.", + "doc": "The projection configuration", "example": { - "type": "globe" + "type": [ + "interpolate", + ["linear"], + ["zoom"], + 10, "vertical-perspective", + 12, "mercator" + ] } }, "terrain": { @@ -4570,19 +4576,15 @@ }, "projection": { "type": { - "type": "enum", - "doc": "The projection type.", - "default": "mercator", - "values": { - "mercator": { - "doc": "Web Mercator projection." - }, - "globe": { - "doc": "Globe projection. Zoom transition from Vertical Perspective projection to Web Mercator projection." - }, - "vertical-perspective": { - "doc": "Vertical Perspective projection." - } + "type": "projectionDefinition", + "doc": "The projection definition type. Can be specified as a string, a transition state, or an expression.", + "default": "mercator", + "property-type": "data-constant", + "expression": { + "interpolated": true, + "parameters": [ + "zoom" + ] } } }, @@ -6657,4 +6659,4 @@ "doc": "A name of a feature property to use as ID for feature state." } } -} +} \ No newline at end of file diff --git a/src/validate/validate.ts b/src/validate/validate.ts index 6cd68d34b..abde5b3f6 100644 --- a/src/validate/validate.ts +++ b/src/validate/validate.ts @@ -27,6 +27,7 @@ import {validateVariableAnchorOffsetCollection} from './validate_variable_anchor import {validateSprite} from './validate_sprite'; import {ValidationError} from '../error/validation_error'; import {validateProjection} from './validate_projection'; +import {validateProjectionDefinition} from './validate_projectiondefinition'; const VALIDATORS = { '*'() { @@ -47,6 +48,7 @@ const VALIDATORS = { 'sky': validateSky, 'terrain': validateTerrain, 'projection': validateProjection, + 'projectionDefinition': validateProjectionDefinition, 'string': validateString, 'formatted': validateFormatted, 'resolvedImage': validateImage, diff --git a/src/validate/validate_projection.test.ts b/src/validate/validate_projection.test.ts index c2c4d8339..b07e7c018 100644 --- a/src/validate/validate_projection.test.ts +++ b/src/validate/validate_projection.test.ts @@ -19,20 +19,39 @@ describe('Validate projection', () => { test('Should return error in case of unknown property', () => { const errors = validateProjection({validateSpec: validate, value: {a: 1} as any, styleSpec: v8, style: {} as any}); expect(errors).toHaveLength(1); - expect(errors[0].message).toContain('a'); - expect(errors[0].message).toContain('unknown'); + expect(errors[0].message).toContain('a: unknown property \"a\"'); }); test('Should return errors according to spec violations', () => { const errors = validateProjection({validateSpec: validate, value: {type: 1 as any}, styleSpec: v8, style: {} as any}); expect(errors).toHaveLength(1); - expect(errors[0].message).toBe('type: expected one of [mercator, globe, vertical-perspective], 1 found'); + expect(errors[0].message).toBe('projection: object expected, number found'); }); - test('Should pass if everything is according to spec', () => { - let errors = validateProjection({validateSpec: validate, value: {type: 'globe'}, styleSpec: v8, style: {} as any}); + test('Should return error when value is null', () => { + const errors = validateProjection({validateSpec: validate, value: null as any, styleSpec: v8, style: {} as any}); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('projection: object expected, null found'); + }); + + test('Should pass step function', () => { + const errors = validateProjection({validateSpec: validate, value: {'type': ['step', ['zoom'], 'vertical-perspective', 10, 'mercator']}, styleSpec: v8, style: {} as any}); + expect(errors).toHaveLength(0); + }); + + test('Should pass string value', () => { + const errors = validateProjection({validateSpec: validate, value: {'type': 'mercator'}, styleSpec: v8, style: {} as any}); expect(errors).toHaveLength(0); - errors = validateProjection({validateSpec: validate, value: {type: 'mercator'}, styleSpec: v8, style: {} as any}); + }); + + test('Should pass if [proj, proj, number]', () => { + const errors = validateProjection({validateSpec: validate, value: {'type': ['mercator', 'mercator', 0.3]}, styleSpec: v8, style: {} as any}); + expect(errors).toHaveLength(0); + }); + + test('should parse interpolate', () => { + const errors = validateProjection({validateSpec: validate, value: {'type': ['interpolate', ['linear'], ['zoom'], 0, 'mercator', 5, 'vertical-perspective']}, styleSpec: v8, style: {} as any}); expect(errors).toHaveLength(0); }); + }); diff --git a/src/validate/validate_projectiondefinition.test.ts b/src/validate/validate_projectiondefinition.test.ts new file mode 100644 index 000000000..528b2fa78 --- /dev/null +++ b/src/validate/validate_projectiondefinition.test.ts @@ -0,0 +1,59 @@ +import validateProjection from './validate_projectiondefinition'; + +describe('validateProjection function', () => { + + const key = 'sample_projection_key'; + + test('should return error when projection is not a string or array', () => { + expect(validateProjection({key, value: ''})).toHaveLength(0); + + expect(validateProjection({key, value: 0})).toMatchObject([ + {message: `${key}: projection expected, invalid type "number" found`}, + ]); + expect(validateProjection({key, value: {}})).toMatchObject([ + {message: `${key}: projection expected, invalid type "object" found`}, + ]); + expect(validateProjection({key, value: false})).toMatchObject([ + {message: `${key}: projection expected, invalid type "boolean" found`}, + ]); + expect(validateProjection({key, value: null})).toMatchObject([ + {message: `${key}: projection expected, invalid type "null" found`}, + ]); + expect(validateProjection({key, value: undefined})).toMatchObject([ + {message: `${key}: projection expected, invalid type "undefined" found`}, + ]); + }); + + test('Should error when projection is an invalid projection transition', () => { + const errors = validateProjection({value: [3, 'mercator', 0.3], key}); + expect(errors).toMatchObject([ + {message: `${key}: projection expected, invalid array [3,\"mercator\",0.3] found`}, + ]); + }); + + test('Should allow string', () => { + const errors = validateProjection({value: 'mercator', key}); + expect(errors).toHaveLength(0); + }); + + test('Should return no errors when projection is valid projection transition', () => { + const errors = validateProjection({value: ['mercator', 'mercator', 0.3], key}); + expect(errors).toHaveLength(0); + }); + + test('Should return no errors when projection is valid interpolation-projection expression', () => { + const errors = validateProjection({value: ['interpolate', ['linear'], ['zoom'], 0, 'mercator', 5, 'vertical-perspective'], key}); + expect(errors).toHaveLength(0); + }); + + test('Should return no errors when projection is valid step expression', () => { + const errors = validateProjection({value: ['step', ['zoom'], 'vertical-perspective', 10, 'mercator'], key}); + expect(errors).toHaveLength(0); + }); + + test('Should return no errors when projection is valid step expression with a transition', () => { + const errors = validateProjection({value: ['step', ['zoom'], ['vertical-perspective', 'mercator', 0.5], 10, 'mercator'], key}); + expect(errors).toHaveLength(0); + }); + +}); diff --git a/src/validate/validate_projectiondefinition.ts b/src/validate/validate_projectiondefinition.ts new file mode 100644 index 000000000..499a46c77 --- /dev/null +++ b/src/validate/validate_projectiondefinition.ts @@ -0,0 +1,36 @@ +import {ValidationError} from '../error/validation_error'; +import {ProjectionDefinitionT, PropertyValueSpecification} from '../types.g'; +import {getType} from '../util/get_type'; + +export function validateProjectionDefinition(options) { + + const key = options.key; + let value = options.value; + value = value instanceof String ? value.valueOf() : value; + + const type = getType(value); + + if (type === 'array' && !isProjectionDefinitionValue(value) && !isPropertyValueSpecification(value)) { + return [new ValidationError(key, value, `projection expected, invalid array ${JSON.stringify(value)} found`)]; + } else if (!['array', 'string'].includes(type)) { + return [new ValidationError(key, value, `projection expected, invalid type "${type}" found`)]; + } + + return []; +} + +function isPropertyValueSpecification(value: unknown): value is PropertyValueSpecification { + + if (['interpolate', 'step', 'literal'].includes(value[0])) { + return true + } + return false +} + +function isProjectionDefinitionValue(value: unknown): value is ProjectionDefinitionT { + return Array.isArray(value) && + value.length === 3 && + typeof value[0] === 'string' && + typeof value[1] === 'string' && + typeof value[2] === 'number'; +} \ No newline at end of file diff --git a/test/integration/expression/expression.test.ts b/test/integration/expression/expression.test.ts index 2a01e3092..971c08630 100644 --- a/test/integration/expression/expression.test.ts +++ b/test/integration/expression/expression.test.ts @@ -57,13 +57,15 @@ describe('expression', () => { const expected = fixture.expected; const compileOk = deepEqual(result.compiled, expected.compiled, DECIMAL_SIGNIFICANT_FIGURES); + const evalOk = compileOk && deepEqual(result.outputs, expected.outputs, DECIMAL_SIGNIFICANT_FIGURES); + try { expect(compileOk).toBeTruthy(); } catch { throw new Error(`Compilation Failed:\nExpected ${JSON.stringify(expected.compiled)}\nResult ${JSON.stringify(result.compiled)}`); } - + try { expect(evalOk).toBeTruthy(); } catch { @@ -162,7 +164,7 @@ function evaluateExpression(fixture: ExpressionFixture, expression: Result