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

Introduce clusterProperties option for aggregated cluster properties #7584

Merged
merged 7 commits into from
Jan 10, 2019
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions debug/cluster.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

var map = window.map = new mapboxgl.Map({
container: 'map',
zoom: 0,
zoom: 1,
center: [0, 0],
style: 'mapbox://styles/mapbox/cjf4m44iw0uza2spb3q0a7s41',
hash: true
Expand All @@ -31,15 +31,20 @@
"type": "geojson",
"data": "/test/integration/data/places.geojson",
"cluster": true,
"clusterRadius": 50
"clusterRadius": 50,
"clusterProperties": {
"max": ["max", 0, ["get", "scalerank"]],
"sum": ["+", 0, ["get", "scalerank"]],
"has_island": ["any", false, ["==", ["get", "featureclass"], "island"]]
}
});
map.addLayer({
"id": "cluster",
"type": "circle",
"source": "geojson",
"filter": ["==", "cluster", true],
"paint": {
"circle-color": "rgba(0, 200, 0, 1)",
"circle-color": ["case", ["get", "has_island"], "orange", "rgba(0, 200, 0, 1)"],
"circle-radius": 20
}
});
Expand All @@ -49,7 +54,7 @@
"source": "geojson",
"filter": ["==", "cluster", true],
"layout": {
"text-field": "{point_count}",
"text-field": "{point_count} ({max})",
"text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"],
"text-size": 12,
"text-allow-overlap": true,
Expand Down
1 change: 1 addition & 0 deletions docs/components/expression-metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ for (const name in CompoundExpression.definitions) {
}

delete types['error'];
delete types['accumulated']; // skip documenting `accumulated` since it is internal use only
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this still be deleted, given that it is now usable as part of a custom reduce expression?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, just readded it.


export const expressions = {};
export const expressionGroups = {};
Expand Down
3 changes: 2 additions & 1 deletion src/source/geojson_source.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ class GeoJSONSource extends Evented implements Source {
extent: EXTENT,
radius: (options.clusterRadius || 50) * scale,
log: false
}
},
clusterProperties: options.clusterProperties
}, options.workerOptions);
}

Expand Down
73 changes: 71 additions & 2 deletions src/source/geojson_worker_source.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import Supercluster from 'supercluster';
import geojsonvt from 'geojson-vt';
import assert from 'assert';
import VectorTileWorkerSource from './vector_tile_worker_source';
import { createExpression } from '../style-spec/expression';
import { isGlobalPropertyConstant, isFeatureConstant } from '../style-spec/expression/is_constant';

import type {
WorkerTileParameters,
Expand All @@ -30,7 +32,8 @@ export type LoadGeoJSONParameters = {
source: string,
cluster: boolean,
superclusterOptions?: Object,
geojsonVtOptions?: Object
geojsonVtOptions?: Object,
clusterProperties?: Object
};

export type LoadGeoJSON = (params: LoadGeoJSONParameters, callback: ResponseCallback<Object>) => void;
Expand Down Expand Up @@ -171,7 +174,7 @@ class GeoJSONWorkerSource extends VectorTileWorkerSource {

try {
this._geoJSONIndex = params.cluster ?
new Supercluster(params.superclusterOptions).load(data.features) :
new Supercluster(getSuperclusterOptions(params)).load(data.features) :
geojsonvt(data, params.geojsonVtOptions);
} catch (err) {
return callback(err);
Expand Down Expand Up @@ -293,4 +296,70 @@ class GeoJSONWorkerSource extends VectorTileWorkerSource {
}
}

function getSuperclusterOptions({superclusterOptions, clusterProperties}) {
if (!clusterProperties || !superclusterOptions) return superclusterOptions;

const initialValues = {};
const mapExpressions = {};
const reduceExpressions = {};
const globals = {accumulated: null, zoom: 0};
const feature = {properties: null};
const propertyNames = Object.keys(clusterProperties);

for (const key of propertyNames) {
const [operator, initialExpression, mapExpression] = clusterProperties[key];

const parsed = createExpression(clusterProperties[key]);
if (parsed.result === 'error') {
const message = parsed.value.map(e => e.message).join('; ');
throw new Error(`Error parsing expression for cluster property "${key}": ${message}`);

} else if (!isGlobalPropertyConstant(parsed.value.expression, ['zoom'])) {
throw new Error(`Error parsing expression for cluster property "${key}": zoom expressions not supported.`);
}

const initialExpressionParsed = createExpression(initialExpression);
const mapExpressionParsed = createExpression(mapExpression);
const reduceExpressionParsed = createExpression([operator, ['accumulated'], ['get', key]]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think the map/reduce expressions need to support the feature-state operator as well? Given that these expressions are evaluated just once it probably doesn't make sense to support that operator - it can change through interactivity on the map. An additional isStateConstant check would be required.

If we want to support feature-state, it would require re-computing the map/reduce on every change of a feature's state, or at least the clusters affected by a feature. Not sure that there is a need for it right now.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I think we shouldn't support feature-state, at least initially, because it would add a lot of complexity for a very obscure use case. I'll add the isStateConstant check in the validation.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@asheemmamoowala @mourner I'm just wondering if you're looking to support feature-state on clusterProperties in the near future?

I've got a use case where I need to highlight the marker cluster for a given feature when a reference of it is selected in a list view outside the map canvas.

This works nicely for non-clustered points, but I need a work around for this situation:

map.addSource('foos', {
  type: 'geojson',
  data,
  cluster: true,
  clusterMaxZoom: 14,
  clusterRadius: 50,
  clusterProperties: {
    'has_hovered_foo': ['==', ['feature-state', 'hover'], true]
  }
})

map.addLayer({
  id: 'clusters',
  type: 'circle',
  source: 'foos',
  filter: ['has', 'point_count'],
  paint: {
    'circle-color': [
      'case',
      ['get', 'has_hovered_foo'],
      '#000',
      '#FFF'
    ]
  }
})


if (initialExpressionParsed.result === 'success') {
if (!isFeatureConstant(initialExpressionParsed.value.expression))
throw new Error(`Error parsing expression for cluster property "${key}": can't use feature properties in initial expression.`);

initialValues[key] = initialExpressionParsed.value.evaluate(globals);
}

if (mapExpressionParsed.result === 'success')
mapExpressions[key] = mapExpressionParsed.value;

if (reduceExpressionParsed.result === 'success')
reduceExpressions[key] = reduceExpressionParsed.value;
}

superclusterOptions.initial = () => {
const properties = {};
for (const key of propertyNames) {
properties[key] = initialValues[key];
}
return properties;
};
superclusterOptions.map = (pointProperties) => {
feature.properties = pointProperties;
Copy link

@redbmk redbmk Jan 4, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this (and in reduce below) using a shared feature and overriding the properties of it instead of defining a new const feature = { properties: pointProperties } here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The map function and expression evaluation is synchronous, meaning there are no side effects from using mutable state here (which is reset at the beginning). At the same time, we avoid constantly creating new feature objects, improving performance and lots of garbage collection passes.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, OK that makes sense. I thought it might have something to do with performance optimization.

const properties = {};
for (const key of propertyNames) {
properties[key] = mapExpressions[key].evaluate(globals, feature);
}
return properties;
};
superclusterOptions.reduce = (accumulated, clusterProperties) => {
feature.properties = clusterProperties;
for (const key of propertyNames) {
globals.accumulated = accumulated[key];
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean a reducer could access the accumulated property if it wanted to, and do something weird like the following?

"total": [
  ["case",
    [">", ["get", "accumulated"], 100],
    "too many",
    ["+", ["get", "accumulated"], ["get", "total"]
  ],
  0,
  ["get", "total"]
]

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently not, but I think this could be added (check if operator is string and if it's not, consider it as a reduce expression). Would add quite a bit of complexity to the docs, but add some flexibility as well. Not sure how to go here.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah ok, I see - the reduce expression has to be something that fits into [operator, ['accumulated'], ['get', key]]. So that's why max and any work.

👍

accumulated[key] = reduceExpressions[key].evaluate(globals, feature);
}
};

return superclusterOptions;
}

export default GeoJSONWorkerSource;
5 changes: 5 additions & 0 deletions src/style-spec/expression/definitions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@ CompoundExpression.register(expressions, {
[],
(ctx) => ctx.globals.lineProgress || 0
],
'accumulated': [
ValueType,
[],
(ctx) => ctx.globals.accumulated === undefined ? null : ctx.globals.accumulated
],
'+': [
NumberType,
varargs(NumberType),
Expand Down
15 changes: 8 additions & 7 deletions src/style-spec/expression/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export type GlobalProperties = $ReadOnly<{
zoom: number,
heatmapDensity?: number,
lineProgress?: number,
isSupportedScript?: (string) => boolean
isSupportedScript?: (string) => boolean,
accumulated?: Value
}>;

export class StyleExpression {
Expand All @@ -49,12 +50,12 @@ export class StyleExpression {
_warningHistory: {[key: string]: boolean};
_enumValues: ?{[string]: any};

constructor(expression: Expression, propertySpec: StylePropertySpecification) {
constructor(expression: Expression, propertySpec: ?StylePropertySpecification) {
this.expression = expression;
this._warningHistory = {};
this._evaluator = new EvaluationContext();
this._defaultValue = getDefaultValue(propertySpec);
this._enumValues = propertySpec.type === 'enum' ? propertySpec.values : null;
this._defaultValue = propertySpec ? getDefaultValue(propertySpec) : null;
this._enumValues = propertySpec && propertySpec.type === 'enum' ? propertySpec.values : null;
}

evaluateWithoutErrorHandling(globals: GlobalProperties, feature?: Feature, featureState?: FeatureState): any {
Expand Down Expand Up @@ -105,12 +106,12 @@ export function isExpression(expression: mixed) {
*
* @private
*/
export function createExpression(expression: mixed, propertySpec: StylePropertySpecification): Result<StyleExpression, Array<ParsingError>> {
const parser = new ParsingContext(definitions, [], getExpectedType(propertySpec));
export function createExpression(expression: mixed, propertySpec: ?StylePropertySpecification): Result<StyleExpression, Array<ParsingError>> {
const parser = new ParsingContext(definitions, [], propertySpec ? getExpectedType(propertySpec) : undefined);

// For string-valued properties, coerce to string at the top level rather than asserting.
const parsed = parser.parse(expression, undefined, undefined, undefined,
propertySpec.type === 'string' ? {typeAnnotation: 'coerce'} : undefined);
propertySpec && propertySpec.type === 'string' ? {typeAnnotation: 'coerce'} : undefined);

if (!parsed) {
assert(parser.errors.length > 0);
Expand Down
2 changes: 1 addition & 1 deletion src/style-spec/expression/parsing_context.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,5 +226,5 @@ function isConstant(expression: Expression) {
}

return isFeatureConstant(expression) &&
isGlobalPropertyConstant(expression, ['zoom', 'heatmap-density', 'line-progress', 'is-supported-script']);
isGlobalPropertyConstant(expression, ['zoom', 'heatmap-density', 'line-progress', 'accumulated', 'is-supported-script']);
}
4 changes: 4 additions & 0 deletions src/style-spec/reference/v8.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,10 @@
"type": "number",
"doc": "Max zoom on which to cluster points if clustering is enabled. Defaults to one zoom less than maxzoom (so that last zoom features are not clustered)."
},
"clusterProperties": {
"type": "*",
"doc": "An object defining custom properties on the generated clusters if clustering is enabled, aggregating values from clustered points. Has the form `{\"property_name\": [operator, initial_expression, map_expression]}`. `operator` is any expression function that accepts at least 2 operands (e.g. `\"+\"` or `\"max\"`) — it accumulates the property value from clusters/points the cluster contains; `initial_expression` evaluates the initial value of the property before accummulating other points/clusters; `map_expression` produces the value of a single point.\n\nExample: `{\"sum\": [\"+\", 0, [\"get\", \"scalerank\"]]}`."
},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it would be useful to add an expression support block for each of the expressions - initial,map, reduce ?Something similar to what is in the the layer properties.

"expression": {
"interpolated": true,
"parameters": [
"zoom",
"feature",
"feature-state"
]
},
"property-type": "data-driven"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how to separate those into separate properties. There are two expressions (map, initial) and an operator that accepts them as parameters. Map expression would be just ["feature"], and initial expression would be [].

Given the non-standard syntax and that support block is dictated more by property aggregation semantics rather than technical limitations (like in case of many properties), perhaps we should leave that to validation, and maybe add a note clarifying this in the doc string.

"lineMetrics": {
"type": "boolean",
"default": false,
Expand Down
1 change: 1 addition & 0 deletions src/style-spec/types.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export type GeoJSONSourceSpecification = {|
"cluster"?: boolean,
"clusterRadius"?: number,
"clusterMaxZoom"?: number,
"clusterProperties"?: mixed,
"lineMetrics"?: boolean,
"generateId"?: boolean
|}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"version": 8,
"metadata": {
"test": {
"width": 256,
"height": 128
}
},
"center": [
-10,
-5
],
"zoom": 0,
"sources": {
"geojson": {
"type": "geojson",
"data": "local://data/places.geojson",
"cluster": true,
"clusterRadius": 50,
"clusterProperties": {
"max": ["max", 0, ["get", "scalerank"]],
"sum": ["+", 0, ["get", "scalerank"]],
"has_island": ["any", false, ["==", ["get", "featureclass"], "island"]]
}
}
},
"glyphs": "local://glyphs/{fontstack}/{range}.pbf",
"layers": [
{
"id": "cluster",
"type": "circle",
"source": "geojson",
"filter": [
"==",
"cluster",
true
],
"paint": {
"circle-color": ["case", ["get", "has_island"], "orange", "rgba(0, 200, 0, 1)"],
"circle-radius": 20
}
},
{
"id": "cluster_label",
"type": "symbol",
"source": "geojson",
"filter": [
"==",
"cluster",
true
],
"layout": {
"text-field": "{sum},{max}",
"text-font": [
"Open Sans Semibold",
"Arial Unicode MS Bold"
],
"text-size": 12,
"text-allow-overlap": true,
"text-ignore-placement": true
}
},
{
"id": "unclustered_point",
"type": "circle",
"source": "geojson",
"filter": [
"!=",
"cluster",
true
],
"paint": {
"circle-color": "rgba(0, 0, 200, 1)",
"circle-radius": 10
}
}
]
}
2 changes: 1 addition & 1 deletion test/unit/style-spec/expression.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {expressions as definitionMetadata} from '../../../docs/components/expres
// filter out interal "error" and "filter-*" expressions from definition list
const filterExpressionRegex = /filter-/;
const definitionList = Object.keys(definitions).filter((expression) => {
return expression !== 'error' && !filterExpressionRegex.exec(expression);
return expression !== 'error' && expression !== 'accumulated' && !filterExpressionRegex.exec(expression);
}).sort();

test('v8.json includes all definitions from style-spec', (t) => {
Expand Down