Skip to content

Commit

Permalink
Add projection expression syntax (#888)
Browse files Browse the repository at this point in the history
  • Loading branch information
birkskyum committed Nov 15, 2024
1 parent 8cb30d3 commit 13e3a7a
Show file tree
Hide file tree
Showing 30 changed files with 650 additions and 56 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## main

### ✨ Features and improvements
- Add projection type expression syntax ([#888](https://github.com/maplibre/maplibre-style-spec/pull/888))
- _...Add new stuff here..._

### 🐞 Bug fixes
Expand All @@ -9,7 +10,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

Expand Down
1 change: 1 addition & 0 deletions build/generate-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
11 changes: 8 additions & 3 deletions build/generate-style-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ function propertyType(property) {
return 'SkySpecification';
case 'sources':
return '{[_: string]: SourceSpecification}';
case 'projection:':
return 'ProjectionSpecification';
case '*':
return 'unknown';
default:
Expand Down Expand Up @@ -122,6 +124,9 @@ fs.writeFileSync('src/types.g.ts',
export type ColorSpecification = string;
export type ProjectionDefinitionT = [string, string, number];
export type ProjectionDefinitionSpecification = string | ProjectionDefinitionT | PropertyValueSpecification<ProjectionDefinitionT>
export type PaddingSpecification = number | number[];
export type VariableAnchorOffsetCollectionSpecification = Array<string | [number, number]>;
Expand Down Expand Up @@ -202,7 +207,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,
Expand Down Expand Up @@ -317,10 +322,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') {
Expand Down
73 changes: 73 additions & 0 deletions docs/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"],`<br>`10, "vertical-perspective", 12, "mercator"]` | Adaptive globe: interpolates from vertical-perspective to mercator projection between zoom levels 10 and 12. |
7 changes: 3 additions & 4 deletions src/diff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -606,11 +606,10 @@ describe('diff', () => {
expect(diffStyles({
} 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]}]},
]);
});
});
2 changes: 2 additions & 0 deletions src/expression/definitions/coercion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,8 @@ 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));
}
Expand Down
16 changes: 8 additions & 8 deletions src/expression/definitions/interpolate.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import UnitBezier from '@mapbox/unitbezier';

import {array, ArrayType, ColorType, ColorTypeT, NumberType, NumberTypeT, PaddingType, PaddingTypeT, VariableAnchorOffsetCollectionType, VariableAnchorOffsetCollectionTypeT, toString, verifyType} from '../types';
import {array, ArrayType, ColorType, ColorTypeT, NumberType, NumberTypeT, PaddingType, PaddingTypeT, VariableAnchorOffsetCollectionType, VariableAnchorOffsetCollectionTypeT, toString, 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';
Expand All @@ -22,18 +22,18 @@ export type InterpolationType = {
name: 'cubic-bezier';
controlPoints: [number, number, number, number];
};
type InterpolatedValueType = NumberTypeT | ColorTypeT | PaddingTypeT | VariableAnchorOffsetCollectionTypeT | ArrayType<NumberTypeT>;

type InterpolatedValueType = NumberTypeT | ColorTypeT | StringTypeT | ProjectionDefinitionTypeT | PaddingTypeT | VariableAnchorOffsetCollectionTypeT | ArrayType<NumberTypeT>;
type InterpolationOperator = 'interpolate' | 'interpolate-hcl' | 'interpolate-lab';
class Interpolate implements Expression {
type: InterpolatedValueType;

operator: 'interpolate' | 'interpolate-hcl' | 'interpolate-lab';
operator: InterpolationOperator ;
interpolation: InterpolationType;
input: Expression;
labels: Array<number>;
outputs: Array<Expression>;

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;
Expand Down Expand Up @@ -129,14 +129,14 @@ 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;
stops.push([label, parsed]);
}

if (!verifyType(outputType, NumberType) &&
!verifyType(outputType, ProjectionDefinitionType) &&
!verifyType(outputType, ColorType) &&
!verifyType(outputType, PaddingType) &&
!verifyType(outputType, VariableAnchorOffsetCollectionType) &&
Expand Down Expand Up @@ -173,7 +173,7 @@ 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) {
Expand Down
53 changes: 53 additions & 0 deletions src/expression/expression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Check failure on line 655 in src/expression/expression.test.ts

View workflow job for this annotation

GitHub Actions / Unit and Integration Tests

projection expression › step array

Failed to parse Step expression at Object.<anonymous> (src/expression/expression.test.ts:655:19)
}
})

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');

Check failure on line 668 in src/expression/expression.test.ts

View workflow job for this annotation

GitHub Actions / Unit and Integration Tests

projection expression › interpolate color

Failed to parse Interpolate expression at Object.<anonymous> (src/expression/expression.test.ts:668:19)
}
})

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');

Check failure on line 680 in src/expression/expression.test.ts

View workflow job for this annotation

GitHub Actions / Unit and Integration Tests

projection expression › interpolate

Failed to parse Interpolate expression at Object.<anonymous> (src/expression/expression.test.ts:680:19)
}
})

});
7 changes: 6 additions & 1 deletion src/expression/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '..';
Expand Down Expand Up @@ -394,6 +394,8 @@ export function normalizePropertyExpression<T>(
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);

Check failure on line 398 in src/expression/index.ts

View workflow job for this annotation

GitHub Actions / Code Hygiene

Cannot find name 'ProjectionDefinition'. Did you mean 'ProjectionDefinitionType'?
}
return {
kind: 'constant',
Expand Down Expand Up @@ -452,6 +454,7 @@ function getExpectedType(spec: StylePropertySpecification): Type {
boolean: BooleanType,
formatted: FormattedType,
padding: PaddingType,
projectionDefinition: ProjectionDefinitionType,
resolvedImage: ResolvedImageType,
variableAnchorOffsetCollection: VariableAnchorOffsetCollectionType
};
Expand All @@ -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;

Check failure on line 482 in src/expression/index.ts

View workflow job for this annotation

GitHub Actions / Packaging and Build tests (ubuntu-latest)

Cannot find name 'ProjectionDefinition'. Did you mean 'ProjectionDefinitionType'?

Check failure on line 482 in src/expression/index.ts

View workflow job for this annotation

GitHub Actions / build-test-deploy

Cannot find name 'ProjectionDefinition'. Did you mean 'ProjectionDefinitionType'?

Check failure on line 482 in src/expression/index.ts

View workflow job for this annotation

GitHub Actions / Code Hygiene

Cannot find name 'ProjectionDefinition'. Did you mean 'ProjectionDefinitionType'?

Check failure on line 482 in src/expression/index.ts

View workflow job for this annotation

GitHub Actions / Unit and Integration Tests

Validate projection › Should pass step function

ReferenceError: ProjectionDefinition is not defined at parse (src/expression/index.ts:482:37) at new getDefaultValue (src/expression/index.ts:75:45) at createExpression (src/expression/index.ts:158:20) at validateExpression (src/validate/validate_expression.ts:13:111) at Object.validate [as validateSpec] (src/validate/validate.ts:93:34) at validateSpec (src/validate/validate_projection.ts:30:44) at Object.<anonymous> (src/validate/validate_projection.test.ts:30:42)

Check failure on line 482 in src/expression/index.ts

View workflow job for this annotation

GitHub Actions / Unit and Integration Tests

Validate projection › should parse interpolate

ReferenceError: ProjectionDefinition is not defined at parse (src/expression/index.ts:482:37) at new getDefaultValue (src/expression/index.ts:75:45) at createExpression (src/expression/index.ts:158:20) at validateExpression (src/validate/validate_expression.ts:13:111) at Object.validate [as validateSpec] (src/validate/validate.ts:93:34) at validateSpec (src/validate/validate_projection.ts:30:44) at Object.<anonymous> (src/validate/validate_projection.test.ts:45:42)
} else if (spec.default === undefined) {
return null;
} else {
Expand Down
2 changes: 2 additions & 0 deletions src/expression/parsing_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ 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')) {
Expand Down
7 changes: 6 additions & 1 deletion src/expression/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export type BooleanTypeT = {
export type ColorTypeT = {
kind: 'color';
};
export type ProjectionDefinitionTypeT = {
kind: 'projectionDefinition';
};
export type ObjectTypeT = {
kind: 'object';
};
Expand Down Expand Up @@ -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<T extends Type = Type> {
Expand All @@ -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;
Expand Down Expand Up @@ -90,6 +94,7 @@ const valueMemberTypes = [
StringType,
BooleanType,
ColorType,
ProjectionDefinitionType,
FormattedType,
ObjectType,
array(ValueType),
Expand Down
9 changes: 9 additions & 0 deletions src/expression/types/projection_definition.test.ts
Original file line number Diff line number Diff line change
@@ -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]');
});
});
Loading

0 comments on commit 13e3a7a

Please sign in to comment.