Skip to content

Commit b3323f2

Browse files
underootgithub-actions[bot]
authored andcommitted
Accept an expression as an argument in the distance expression
GitOrigin-RevId: ffdfab90b2875f6b3697860d44d92ea063e164b1
1 parent c66e661 commit b3323f2

38 files changed

Lines changed: 1218 additions & 2247 deletions

File tree

src/style-spec/expression/definitions/distance.ts

Lines changed: 99 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import {isValue} from '../values';
2-
import {NumberType} from '../types';
2+
import {NumberType, ValueType} from '../types';
33
import {classifyRings, updateBBox, boxWithinBox, pointWithinPolygon, segmentIntersectSegment} from '../../util/geometry_util';
44
import {lngFromMercatorX, latFromMercatorY} from '../../util/mercator';
55
import TinyQueue from "tinyqueue";
66
import EXTENT from '../../data/extent';
7+
import Literal from './literal';
8+
import {isGlobalPropertyConstant, isStateConstant} from '../is_constant';
79

810
// Geodesic scale factors (cheap-ruler math): meters per degree lon/lat at a given latitude.
911
// Only the three distance operations used in this file are implemented.
@@ -587,70 +589,130 @@ function isTypeValid(type: string) {
587589
type === "MultiPolygon"
588590
);
589591
}
592+
593+
// Resolves a GeoJSON value (Feature / FeatureCollection / bare geometry) down
594+
// to the geometries `distance` measures against -- one per feature for a
595+
// FeatureCollection, one otherwise. Shared by parse-time (literal argument)
596+
// and evaluate-time (e.g. a `["config", ...]` argument, whose value isn't
597+
// known until evaluation) resolution. Returns null if the value isn't valid
598+
// GeoJSON, or if any feature has a geometry type other than
599+
// Point/LineString/Polygon (and their Multi* variants).
600+
function extractDistanceGeometry(value: unknown): Array<DistanceGeometry> | null {
601+
if (!isValue(value) || typeof value !== 'object' || value === null || Array.isArray(value)) {
602+
return null;
603+
}
604+
const geojson = value as GeoJSON.GeoJSON;
605+
if (geojson.type === 'FeatureCollection') {
606+
if (geojson.features.length === 0) return null;
607+
const geometries: Array<DistanceGeometry> = [];
608+
for (const feature of geojson.features) {
609+
if (!isTypeValid(feature.geometry.type)) return null;
610+
geometries.push(feature.geometry as DistanceGeometry);
611+
}
612+
return geometries;
613+
}
614+
if (geojson.type === 'Feature') {
615+
return isTypeValid(geojson.geometry.type) ? [geojson.geometry as DistanceGeometry] : null;
616+
}
617+
if (isTypeValid(geojson.type)) {
618+
return [geojson as DistanceGeometry];
619+
}
620+
return null;
621+
}
622+
590623
class Distance implements Expression {
591624
type: Type;
592-
geojson: GeoJSON.GeoJSON;
593-
geometries: DistanceGeometry;
625+
geojson: Expression;
594626

595-
constructor(geojson: GeoJSON.GeoJSON, geometries: DistanceGeometry) {
627+
constructor(geojson: Expression) {
596628
this.type = NumberType;
597629
this.geojson = geojson;
598-
this.geometries = geometries;
599630
}
600631

601632
static parse(args: ReadonlyArray<unknown>, context: ParsingContext): Distance | null | void {
602633
if (args.length !== 2) {
603634
return context.error(`'distance' expression requires either one argument, but found ' ${args.length - 1} instead.`);
604635
}
605-
if (isValue(args[1])) {
606-
const geojson = args[1] as GeoJSON.GeoJSON;
607-
if (geojson.type === 'FeatureCollection') {
608-
for (let i = 0; i < geojson.features.length; ++i) {
609-
if (isTypeValid(geojson.features[i]!.geometry.type)) {
610-
return new Distance(geojson, geojson.features[i]!.geometry as DistanceGeometry);
611-
}
612-
}
613-
} else if (geojson.type === 'Feature') {
614-
if (isTypeValid(geojson.geometry.type)) {
615-
return new Distance(geojson, geojson.geometry as DistanceGeometry);
616-
}
617-
} else if (isTypeValid(geojson.type)) {
618-
return new Distance(geojson, geojson as DistanceGeometry);
636+
637+
const arg = args[1];
638+
// A bare GeoJSON value (Feature / FeatureCollection / bare geometry)
639+
// isn't valid expression syntax on its own, so wrap it as a literal
640+
// like ["literal", {...}] would be. Anything else -- e.g.
641+
// `["config", "key"]` -- is left as-is and parsed as a regular
642+
// sub-expression, resolved to GeoJSON at evaluation time so a config
643+
// value can change at runtime without re-parsing the style.
644+
const isBareGeoJSON = isValue(arg) && !Array.isArray(arg);
645+
const parsed = context.parse(isBareGeoJSON ? ['literal', arg] : arg, 1, ValueType);
646+
if (!parsed) return null;
647+
648+
for (const [globalProperties, name] of [
649+
[['measure-light'], 'brightness'],
650+
[['pitch'], 'pitch'],
651+
[['distance-from-center'], 'distance-from-center'],
652+
] as Array<[Array<string>, string]>) {
653+
if (!isGlobalPropertyConstant(parsed, globalProperties)) {
654+
return context.error(`'distance' expression may not depend on ${name}.`);
619655
}
620656
}
621-
return context.error(
622-
"'distance' expression needs to be an array with format [\'Distance\', GeoJSONObj]."
623-
);
657+
if (!isStateConstant(parsed)) {
658+
return context.error(`'distance' expression may not depend on feature-state.`);
659+
}
660+
661+
// Literal arguments can be validated eagerly; a config-driven
662+
// argument can't be checked until it's evaluated.
663+
if (parsed instanceof Literal && !extractDistanceGeometry(parsed.value)) {
664+
return context.error(
665+
"'distance' expression needs to be an array with format [\'Distance\', GeoJSONObj]."
666+
);
667+
}
668+
669+
return new Distance(parsed);
624670
}
625671

626672
evaluate(ctx: EvaluationContext): number | null {
673+
const geometries = extractDistanceGeometry(this.geojson.evaluate(ctx));
674+
if (!geometries) {
675+
console.warn("Distance Expression: could not resolve a valid Point/LineString/Polygon GeoJSON geometry.");
676+
return null;
677+
}
678+
627679
const geometry = ctx.geometry();
628680
const canonical = ctx.canonicalID();
629-
if (geometry != null && canonical != null) {
630-
if (ctx.geometryType() === 'Point') {
631-
return pointsToGeometryDistance(geometry, canonical, this.geometries);
632-
}
633-
if (ctx.geometryType() === 'LineString') {
634-
return linesToGeometryDistance(geometry, canonical, this.geometries);
635-
}
636-
if (ctx.geometryType() === 'Polygon') {
637-
return polygonsToGeometryDistance(geometry, canonical, this.geometries);
638-
}
639-
console.warn("Distance Expression: currently only evaluates valid Point/LineString/Polygon geometries.");
640-
} else {
681+
if (geometry == null || canonical == null) {
641682
console.warn("Distance Expression: requires valid feature and canonical information.");
683+
return null;
642684
}
643-
return null;
685+
686+
const geometryType = ctx.geometryType();
687+
const distanceTo = geometryType === 'Point' ? pointsToGeometryDistance :
688+
geometryType === 'LineString' ? linesToGeometryDistance :
689+
geometryType === 'Polygon' ? polygonsToGeometryDistance : null;
690+
if (!distanceTo) {
691+
console.warn("Distance Expression: currently only evaluates valid Point/LineString/Polygon geometries.");
692+
return null;
693+
}
694+
695+
// A FeatureCollection reference resolves to one geometry per feature;
696+
// report the distance to the closest one.
697+
let dist = Infinity;
698+
for (const g of geometries) {
699+
const tempDist = distanceTo(geometry, canonical, g);
700+
if (tempDist == null || isNaN(tempDist)) return tempDist;
701+
if ((dist = Math.min(dist, tempDist)) === 0) break;
702+
}
703+
return dist;
644704
}
645705

646-
eachChild() {}
706+
eachChild(fn: (_: Expression) => void) {
707+
fn(this.geojson);
708+
}
647709

648710
outputDefined(): boolean {
649711
return true;
650712
}
651713

652714
serialize(): Array<unknown> {
653-
return ['distance', this.geojson];
715+
return ['distance', this.geojson.serialize()];
654716
}
655717
}
656718

src/style-spec/expression/index.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ import {
2121
} from '../util/properties';
2222
import {isFunction, createFunction} from '../function/index';
2323
import {Color} from './values';
24-
import {ColorType, StringType, NumberType, BooleanType, ValueType, FormattedType, ResolvedImageType, array} from './types';
24+
import {ColorType, StringType, NumberType, BooleanType, ValueType, FormattedType, ResolvedImageType, ObjectType, array} from './types';
2525

2626
import type {Type, EvaluationKind} from './types';
2727
import type {Value} from './values';
2828
import type {Expression} from './expression';
2929
import type {StylePropertySpecification} from '../style-spec';
3030
import type {Result} from '../util/result';
3131
import type {InterpolationType} from './definitions/interpolate';
32-
import type {PropertyValueSpecification} from '../types';
32+
import type {PropertyValueSpecification, OptionSpecification} from '../types';
3333
import type {FormattedSection} from './types/formatted';
3434
import type Point from '@mapbox/point-geometry';
3535
import type {CanonicalTileID} from '../types/tile_id';
@@ -198,6 +198,26 @@ export function createExpression(
198198
return success(new StyleExpression(parsed, propertySpec, scope, options, iconImageUseTheme));
199199
}
200200

201+
// Parse a config option's default or value with the option's declared type
202+
// fed to the expression parser. This drives implicit string→color coercion
203+
// inside expressions (e.g. `["interpolate", ..., "hsl(...)"]` on a color
204+
// option). Skipped for array options (the parser doesn't model the schema's
205+
// `array: true` flag) and for primitive values (they keep their original
206+
// literal shape so `getConfig` round-trips unchanged).
207+
//
208+
// A plain (non-array) object -- e.g. a GeoJSON geometry for an `object`-typed
209+
// option -- isn't valid expression syntax on its own (the parser rejects bare
210+
// objects to avoid ambiguity with legacy function specs); treat it like
211+
// `["literal", {...}]` instead, so both a schema `default` and a runtime
212+
// value can be written as a bare object.
213+
export function createConfigExpression(value: unknown, option: OptionSpecification = {} as OptionSpecification): Result<StyleExpression, Array<ParsingError>> {
214+
const propertySpec = (option.type && !option.array && Array.isArray(value)) ?
215+
{type: option.type, 'property-type': 'data-constant'} as unknown as StylePropertySpecification :
216+
undefined;
217+
const isBareObject = value !== null && typeof value === 'object' && !Array.isArray(value);
218+
return createExpression(isBareObject ? ['literal', value] : value, propertySpec);
219+
}
220+
201221
export class ZoomConstantExpression<Kind extends EvaluationKind> {
202222
kind: Kind;
203223
isStateDependent: boolean;
@@ -557,7 +577,8 @@ function getExpectedType(spec: StylePropertySpecification): Type {
557577
enum: StringType,
558578
boolean: BooleanType,
559579
formatted: FormattedType,
560-
resolvedImage: ResolvedImageType
580+
resolvedImage: ResolvedImageType,
581+
object: ObjectType
561582
};
562583

563584
if (spec.type === 'array') {

src/style-spec/reference/v8.json

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,16 @@
380380
},
381381
"color": {
382382
"doc": "The result will be coerced to a color."
383+
},
384+
"object": {
385+
"doc": "The result will be coerced to an object.",
386+
"sdk-support": {
387+
"basic functionality": {
388+
"js": "3.29.0",
389+
"android": "11.29.0",
390+
"ios": "11.29.0"
391+
}
392+
}
383393
}
384394
}
385395
},
@@ -5637,13 +5647,18 @@
56375647
}
56385648
},
56395649
"distance": {
5640-
"doc": "Returns the shortest distance in meters between the evaluated feature and the input geometry. The input value can be a valid GeoJSON of type `Point`, `MultiPoint`, `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon`, `Feature`, or `FeatureCollection`. Distance values returned may vary in precision due to loss in precision from encoding geometries, particularly below zoom level 13.",
5650+
"doc": "Returns the shortest distance in meters between the evaluated feature and the input geometry. The input value can be a valid GeoJSON of type `Point`, `MultiPoint`, `LineString`, `MultiLineString`, `Polygon`, `MultiPolygon`, `Feature`, or `FeatureCollection`, or expression that returns a valid GeoJSON. Distance values returned may vary in precision due to loss in precision from encoding geometries, particularly below zoom level 13.",
56415651
"group": "Math",
56425652
"sdk-support": {
56435653
"basic functionality": {
56445654
"js": "3.0.0",
56455655
"android": "9.2.0",
56465656
"ios": "5.9.0"
5657+
},
5658+
"expressions support": {
5659+
"js": "3.29.0",
5660+
"android": "11.29.0",
5661+
"ios": "11.29.0"
56475662
}
56485663
}
56495664
},

src/style-spec/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -366,7 +366,7 @@ export type SchemaSpecification = {
366366

367367
export type OptionSpecification = {
368368
"default": unknown | ExpressionSpecification,
369-
"type"?: "string" | "number" | "boolean" | "color",
369+
"type"?: "string" | "number" | "boolean" | "color" | "object",
370370
"array"?: boolean,
371371
"minValue"?: number,
372372
"maxValue"?: number,

src/style-spec/types/config_options.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export type ConfigOptionValue = {
77
minValue?: number;
88
maxValue?: number;
99
stepValue?: number;
10-
type?: 'string' | 'number' | 'boolean' | 'color';
10+
type?: 'string' | 'number' | 'boolean' | 'color' | 'object';
1111
};
1212

1313
export type ConfigOptions = Map<string, ConfigOptionValue>;

src/style-spec/validate/validate_option.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,32 @@ export default function validateOption(options: ValidatorOptions): ValidationErr
2020
const isArrayOption = isObject(optionValue) && unbundle(optionValue.array) === true;
2121
const declaredType = !isArrayOption && isObject(optionValue) ? unbundle(optionValue.type) : undefined;
2222

23+
const validateDefault = (elementOptions: ValidatorOptions): ValidationError[] => {
24+
const defaultValue = elementOptions.value;
25+
if (isObject(defaultValue)) {
26+
// A plain object default (e.g. a GeoJSON geometry for an
27+
// `object`-typed option) isn't an expression or a legacy
28+
// function spec -- validate it as a bare value like a runtime
29+
// `config` value, instead of running it through
30+
// validateFunction()/validateExpression() (see `validate()`).
31+
return validateSpec({...elementOptions, valueSpec: {...elementOptions.valueSpec, type: '*', expression: undefined} as unknown as typeof elementOptions.valueSpec});
32+
}
33+
// Only propagate the declared type when the default is an
34+
// expression (array-form). Primitive defaults keep the previous
35+
// permissive validation, mirroring the runtime parser's narrowing
36+
// in style.ts/parser.cpp.
37+
if (declaredType && Array.isArray(defaultValue)) {
38+
return validateSpec({...elementOptions, valueSpec: {...elementOptions.valueSpec, type: declaredType} as typeof elementOptions.valueSpec});
39+
}
40+
return validateSpec(elementOptions);
41+
};
42+
2343
return validateObject({
2444
...options,
2545
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
2646
valueSpec: styleSpec.option,
27-
objectElementValidators: declaredType ? {
28-
// Only propagate the declared type when the default is an
29-
// expression (array-form). Primitive defaults keep the previous
30-
// permissive validation, mirroring the runtime parser's narrowing
31-
// in style.ts/parser.cpp.
32-
default: (elementOptions: ValidatorOptions): ValidationError[] => (Array.isArray(elementOptions.value) ?
33-
validateSpec({...elementOptions, valueSpec: {...elementOptions.valueSpec, type: declaredType} as typeof elementOptions.valueSpec}) :
34-
validateSpec(elementOptions)),
35-
} as Record<string, ObjectElementValidator> : undefined,
47+
objectElementValidators: {
48+
default: validateDefault,
49+
} as Record<string, ObjectElementValidator>,
3650
});
3751
}

0 commit comments

Comments
 (0)