Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add projection expression syntax (#888) #899

Merged
merged 14 commits into from
Nov 17, 2024
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

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 @@ -37,6 +37,8 @@ function propertyType(property) {
return 'SkySpecification';
case 'sources':
return '{[_: string]: SourceSpecification}';
case 'projection:':
return 'ProjectionSpecification';
case '*':
return 'unknown';
default:
Expand Down Expand Up @@ -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<ProjectionDefinitionT>

export type PaddingSpecification = number | number[];

export type VariableAnchorOffsetCollectionSpecification = Array<string | [number, number]>;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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') {
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(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]}]},
]);
});
});
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 @@ 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));
}
Expand Down
20 changes: 11 additions & 9 deletions src/expression/definitions/interpolate.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
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 {Color} from '../types/color';
import {interpolateArray, interpolateNumber} from '../../util/interpolate-primitives';
import {Padding} from '../types/padding';
import {VariableAnchorOffsetCollection} from '../types/variable_anchor_offset_collection';
import {ProjectionDefinition} from '../types/projection_definition';

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 {Color} from '../types/color';
import {interpolateArray, interpolateNumber} from '../../util/interpolate-primitives';
import {Padding} from '../types/padding';
import {VariableAnchorOffsetCollection} from '../types/variable_anchor_offset_collection';
import type {ProjectionDefinitionTypeT, Type} from '../types';

export type InterpolationType = {
name: 'linear';
Expand All @@ -22,8 +23,7 @@ export type InterpolationType = {
name: 'cubic-bezier';
controlPoints: [number, number, number, number];
};
type InterpolatedValueType = NumberTypeT | ColorTypeT | PaddingTypeT | VariableAnchorOffsetCollectionTypeT | ArrayType<NumberTypeT>;

type InterpolatedValueType = NumberTypeT | ColorTypeT | ProjectionDefinitionTypeT | PaddingTypeT | VariableAnchorOffsetCollectionTypeT | ArrayType<NumberTypeT>;
export class Interpolate implements Expression {
type: InterpolatedValueType;

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

if (!verifyType(outputType, NumberType) &&
!verifyType(outputType, ProjectionDefinitionType) &&
!verifyType(outputType, ColorType) &&
!verifyType(outputType, PaddingType) &&
!verifyType(outputType, VariableAnchorOffsetCollectionType) &&
Expand Down Expand Up @@ -187,6 +187,8 @@ export class Interpolate implements Expression {
return VariableAnchorOffsetCollection.interpolate(outputLower, outputUpper, t);
case 'array':
return interpolateArray(outputLower, outputUpper, t);
case 'projectionDefinition':
return ProjectionDefinition.interpolate(outputLower, outputUpper, t);
}
case 'interpolate-hcl':
return Color.interpolate(outputLower, outputUpper, t, 'hcl');
Expand Down
40 changes: 40 additions & 0 deletions src/expression/expression.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,3 +629,43 @@ 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'], v8.projection.type as StylePropertySpecification);

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', () => {
const response = createExpression(['interpolate', ['linear'], ['zoom'], 8, 'vertical-perspective', 10, 'mercator'], v8.projection.type as StylePropertySpecification);

if (response.result === 'success') {
expect(response.value.evaluate({zoom: 5})).toBe('vertical-perspective');
expect(response.value.evaluate({zoom: 9})).toEqual({from: 'vertical-perspective', to: 'mercator', transition: 0.5});
expect(response.value.evaluate({zoom: 11})).toBe('mercator');
} else {
throw new Error('Failed to parse Interpolate expression');
}
})

});
10 changes: 8 additions & 2 deletions src/expression/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ 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 '..';
import {type StylePropertySpecification} from '..';
import type {Result} from '../util/result';
import type {InterpolationType} from './definitions/interpolate';
import type {PaddingSpecification, PropertyValueSpecification, VariableAnchorOffsetCollectionSpecification} from '../types.g';
Expand All @@ -35,6 +35,7 @@ import {isFunction, createFunction} from '../function';
import {Color} from './types/color';
import {Padding} from './types/padding';
import {VariableAnchorOffsetCollection} from './types/variable_anchor_offset_collection';
import {ProjectionDefinition} from './types/projection_definition';

export type Feature = {
readonly type: 0 | 1 | 2 | 3 | 'Unknown' | 'Point' | 'MultiPoint' | 'LineString' | 'MultiLineString' | 'Polygon' | 'MultiPolygon';
Expand Down Expand Up @@ -394,6 +395,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);
}
return {
kind: 'constant',
Expand Down Expand Up @@ -452,6 +455,7 @@ function getExpectedType(spec: StylePropertySpecification): Type {
boolean: BooleanType,
formatted: FormattedType,
padding: PaddingType,
projectionDefinition: ProjectionDefinitionType,
resolvedImage: ResolvedImageType,
variableAnchorOffsetCollection: VariableAnchorOffsetCollectionType
};
Expand All @@ -475,6 +479,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 {
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 @@ 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')) {
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
Loading