Added expression support for filters.

This commit is contained in:
orangemug 2020-02-09 15:08:24 +00:00
parent be36eec93d
commit b539644b2b
7 changed files with 248 additions and 109 deletions

View file

@ -318,7 +318,7 @@ export default class App extends React.Component {
onStyleChanged = (newStyle, save=true) => { onStyleChanged = (newStyle, save=true) => {
const errors = validate(newStyle, latest) const errors = validate(newStyle, latest) || [];
const mappedErrors = errors.map(error => { const mappedErrors = errors.map(error => {
const layerMatch = error.message.match(/layers\[(\d+)\]\.(?:(\S+)\.)?(\S+): (.*)/); const layerMatch = error.message.match(/layers\[(\d+)\]\.(?:(\S+)\.)?(\S+): (.*)/);
if (layerMatch) { if (layerMatch) {

View file

@ -8,6 +8,9 @@ import ExpressionProperty from './_ExpressionProperty'
import {expression} from '@mapbox/mapbox-gl-style-spec' import {expression} from '@mapbox/mapbox-gl-style-spec'
function isLiteralExpression (value) {
return (Array.isArray(value) && value.length === 2 && value[0] === "literal");
}
function isZoomField(value) { function isZoomField(value) {
return typeof value === 'object' && value.stops && typeof value.property === 'undefined' return typeof value === 'object' && value.stops && typeof value.property === 'undefined'
@ -22,6 +25,7 @@ function isDataField(value) {
* properties accept arrays as values, for example `text-font`. So we must try * properties accept arrays as values, for example `text-font`. So we must try
* and create an expression. * and create an expression.
*/ */
// TODO: Use function from filter checks.
function checkIsExpression (value, fieldSpec={}) { function checkIsExpression (value, fieldSpec={}) {
if (!Array.isArray(value)) { if (!Array.isArray(value)) {
return false; return false;
@ -111,8 +115,9 @@ export default class FunctionSpecProperty extends React.Component {
this.props.onChange(this.props.fieldName, changedValue) this.props.onChange(this.props.fieldName, changedValue)
} }
deleteExpression = (newValue) => { deleteExpression = () => {
this.props.onChange(this.props.fieldName, newValue); const {fieldSpec, fieldName} = this.props;
this.props.onChange(fieldName, fieldSpec.default);
this.setState({ this.setState({
isExpression: false, isExpression: false,
}); });
@ -144,6 +149,22 @@ export default class FunctionSpecProperty extends React.Component {
this.props.onChange(this.props.fieldName, zoomFunc) this.props.onChange(this.props.fieldName, zoomFunc)
} }
undoExpression = () => {
const {value, fieldName} = this.props;
if (isLiteralExpression(value)) {
this.props.onChange(fieldName, value[1]);
this.setState({
isExpression: false
});
}
}
canUndo = () => {
const {value} = this.props;
return isLiteralExpression(value);
}
makeExpression = () => { makeExpression = () => {
const expression = ["literal", this.props.value || this.props.fieldSpec.default]; const expression = ["literal", this.props.value || this.props.fieldSpec.default];
this.props.onChange(this.props.fieldName, expression); this.props.onChange(this.props.fieldName, expression);
@ -176,6 +197,8 @@ export default class FunctionSpecProperty extends React.Component {
<ExpressionProperty <ExpressionProperty
error={this.props.error} error={this.props.error}
onChange={this.props.onChange.bind(this, this.props.fieldName)} onChange={this.props.onChange.bind(this, this.props.fieldName)}
canUndo={this.canUndo}
onUndo={this.undoExpression}
onDelete={this.deleteExpression} onDelete={this.deleteExpression}
fieldName={this.props.fieldName} fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec} fieldSpec={this.props.fieldSpec}

View file

@ -40,7 +40,7 @@ export default class PropertyGroup extends React.Component {
groupFields: PropTypes.array.isRequired, groupFields: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
spec: PropTypes.object.isRequired, spec: PropTypes.object.isRequired,
errors: PropTypes.array, errors: PropTypes.object,
} }
onPropertyChange = (property, newValue) => { onPropertyChange = (property, newValue) => {

View file

@ -11,70 +11,38 @@ import stringifyPretty from 'json-stringify-pretty-compact'
import JSONEditor from '../layers/JSONEditor' import JSONEditor from '../layers/JSONEditor'
function isLiteralExpression (value) {
return (Array.isArray(value) && value.length === 2 && value[0] === "literal");
}
export default class ExpressionProperty extends React.Component { export default class ExpressionProperty extends React.Component {
static propTypes = { static propTypes = {
onDelete: PropTypes.func, onDelete: PropTypes.func,
fieldName: PropTypes.string, fieldName: PropTypes.string,
fieldSpec: PropTypes.object, fieldSpec: PropTypes.object,
value: PropTypes.object, value: PropTypes.any,
error: PropTypes.object, error: PropTypes.object,
onChange: PropTypes.func, onChange: PropTypes.func,
} }
constructor (props) { constructor (props) {
super(); super();
this.state = {
lastValue: props.value,
};
}
onChange = (jsonVal) => {
if (isLiteralExpression(jsonVal)) {
this.setState({
lastValue: jsonVal
});
}
this.props.onChange(jsonVal);
}
onReset = () => {
const {lastValue} = this.state;
const {value, fieldSpec} = this.props;
if (isLiteralExpression(value)) {
this.props.onDelete(value[1]);
}
else if (isLiteralExpression(lastValue)) {
this.props.onDelete(lastValue[1]);
}
}
onDelete = () => {
const {fieldSpec} = this.props;
this.props.onDelete(fieldSpec.default);
} }
render() { render() {
const {lastValue} = this.state; const {value, canUndo} = this.props;
const {value} = this.props;
const canUndo = isLiteralExpression(value) || isLiteralExpression(lastValue);
const deleteStopBtn = ( const deleteStopBtn = (
<> <>
{this.props.onUndo &&
<Button <Button
onClick={this.onReset} key="undo_action"
disabled={!canUndo} onClick={this.props.onUndo}
disabled={canUndo ? canUndo() : false}
className="maputnik-delete-stop" className="maputnik-delete-stop"
> >
<MdUndo /> <MdUndo />
</Button> </Button>
}
<Button <Button
onClick={this.onDelete} key="delete_action"
onClick={this.props.onDelete}
className="maputnik-delete-stop" className="maputnik-delete-stop"
> >
<MdDelete /> <MdDelete />
@ -96,7 +64,7 @@ export default class ExpressionProperty extends React.Component {
maxHeight={200} maxHeight={200}
lineWrapping={true} lineWrapping={true}
getValue={(data) => stringifyPretty(data, {indent: 2, maxLength: 50})} getValue={(data) => stringifyPretty(data, {indent: 2, maxLength: 50})}
onChange={this.onChange} onChange={this.props.onChange}
/> />
</InputBlock> </InputBlock>
} }

View file

@ -2,13 +2,66 @@ import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import { combiningFilterOps } from '../../libs/filterops.js' import { combiningFilterOps } from '../../libs/filterops.js'
import {latest} from '@mapbox/mapbox-gl-style-spec' import {latest, validate, migrate} from '@mapbox/mapbox-gl-style-spec'
import DocLabel from '../fields/DocLabel' import DocLabel from '../fields/DocLabel'
import SelectInput from '../inputs/SelectInput' import SelectInput from '../inputs/SelectInput'
import InputBlock from '../inputs/InputBlock'
import SingleFilterEditor from './SingleFilterEditor' import SingleFilterEditor from './SingleFilterEditor'
import FilterEditorBlock from './FilterEditorBlock' import FilterEditorBlock from './FilterEditorBlock'
import Button from '../Button' import Button from '../Button'
import SpecDoc from '../inputs/SpecDoc' import SpecDoc from '../inputs/SpecDoc'
import ExpressionProperty from '../fields/_ExpressionProperty';
function migrateFilter (filter) {
return migrate(createStyleFromFilter(filter)).layers[0].filter;
}
function createStyleFromFilter (filter) {
return {
"id": "tmp",
"version": 8,
"name": "Empty Style",
"metadata": {"maputnik:renderer": "mbgljs"},
"sources": {
"tmp": {
"type": "geojson",
"data": {}
}
},
"sprite": "",
"glyphs": "https://orangemug.github.io/font-glyphs/glyphs/{fontstack}/{range}.pbf",
"layers": [
{
id: "tmp",
type: "fill",
source: "tmp",
filter: filter,
},
],
};
}
/**
* This is doing way more work than we need it to, however validating a whole
* style if the only thing that's exported from mapbox-gl-style-spec at the
* moment. Not really an issue though as it take ~0.1ms to calculate.
*/
function checkIfSimpleFilter (filter) {
if (!filter || !combiningFilterOps.includes(filter[0])) {
return false;
}
// Because "none" isn't supported by the next expression syntax we can test
// with ["none", ...] because it'll return false if it's a new style
// expression.
const moddedFilter = ["none", ...filter.slice(1)];
const tmpStyle = createStyleFromFilter(moddedFilter)
const errors = validate(tmpStyle);
return (errors.length < 1);
}
function hasCombiningFilter(filter) { function hasCombiningFilter(filter) {
return combiningFilterOps.indexOf(filter[0]) >= 0 return combiningFilterOps.indexOf(filter[0]) >= 0
@ -27,20 +80,21 @@ export default class CombiningFilterEditor extends React.Component {
/** Properties of the vector layer and the available fields */ /** Properties of the vector layer and the available fields */
properties: PropTypes.object, properties: PropTypes.object,
filter: PropTypes.array, filter: PropTypes.array,
errors: PropTypes.array, errors: PropTypes.object,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
} }
constructor () { constructor (props) {
super(); super();
this.state = { this.state = {
showDoc: false, showDoc: false,
isSimpleFilter: checkIfSimpleFilter(this.combiningFilter(props)),
}; };
} }
// Convert filter to combining filter // Convert filter to combining filter
combiningFilter() { combiningFilter(props=this.props) {
let filter = this.props.filter || ['all'] let filter = props.filter || ['all']
let combiningOp = filter[0] let combiningOp = filter[0]
let filters = filter.slice(1) let filters = filter.slice(1)
@ -61,7 +115,6 @@ export default class CombiningFilterEditor extends React.Component {
deleteFilterItem(filterIdx) { deleteFilterItem(filterIdx) {
const newFilter = this.combiningFilter().slice(0) const newFilter = this.combiningFilter().slice(0)
console.log('Delete', filterIdx, newFilter)
newFilter.splice(filterIdx + 1, 1) newFilter.splice(filterIdx + 1, 1)
this.props.onChange(newFilter) this.props.onChange(newFilter)
} }
@ -78,15 +131,71 @@ export default class CombiningFilterEditor extends React.Component {
}); });
} }
render() { makeExpression = () => {
const filter = this.combiningFilter() let filter = this.combiningFilter();
let combiningOp = filter[0] this.props.onChange(migrateFilter(filter));
let filters = filter.slice(1) this.setState({
const {errors} = this.props; isSimpleFilter: false,
})
}
static getDerivedStateFromProps (props, currentState) {
const {filter} = props;
const isSimpleFilter = checkIfSimpleFilter(props.filter);
// Upgrade but never downgrade
if (!isSimpleFilter && currentState.isSimpleFilter === true) {
return {
isSimpleFilter: false,
};
}
else {
return {};
}
}
render() {
const {errors} = this.props;
const {isSimpleFilter} = this.state;
const fieldSpec={ const fieldSpec={
doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter." doc: latest.layer.filter.doc + " Combine multiple filters together by using a compound filter."
}; };
const defaultFilter = ["all"];
const isNestedCombiningFilter = isSimpleFilter && hasNestedCombiningFilter(this.combiningFilter());
if (isNestedCombiningFilter) {
return <div className="maputnik-filter-editor-unsupported">
<p>
Nested filters are not supported.
</p>
<Button
onClick={this.makeExpression}
>
<svg style={{marginRight: "0.2em", width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
<path fill="currentColor" d="M12.42,5.29C11.32,5.19 10.35,6 10.25,7.11L10,10H12.82V12H9.82L9.38,17.07C9.18,19.27 7.24,20.9 5.04,20.7C3.79,20.59 2.66,19.9 2,18.83L3.5,17.33C3.83,18.38 4.96,18.97 6,18.63C6.78,18.39 7.33,17.7 7.4,16.89L7.82,12H4.82V10H8L8.27,6.93C8.46,4.73 10.39,3.1 12.6,3.28C13.86,3.39 15,4.09 15.66,5.17L14.16,6.67C13.91,5.9 13.23,5.36 12.42,5.29M22,13.65L20.59,12.24L17.76,15.07L14.93,12.24L13.5,13.65L16.35,16.5L13.5,19.31L14.93,20.72L17.76,17.89L20.59,20.72L22,19.31L19.17,16.5L22,13.65Z" />
</svg>
Upgrade to expression
</Button>
</div>
}
else if (isSimpleFilter) {
const filter = this.combiningFilter();
let combiningOp = filter[0];
let filters = filter.slice(1)
const actions = (
<div>
<Button
onClick={this.makeExpression}
className="maputnik-make-zoom-function"
>
<svg style={{width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
<path fill="currentColor" d="M12.42,5.29C11.32,5.19 10.35,6 10.25,7.11L10,10H12.82V12H9.82L9.38,17.07C9.18,19.27 7.24,20.9 5.04,20.7C3.79,20.59 2.66,19.9 2,18.83L3.5,17.33C3.83,18.38 4.96,18.97 6,18.63C6.78,18.39 7.33,17.7 7.4,16.89L7.82,12H4.82V10H8L8.27,6.93C8.46,4.73 10.39,3.1 12.6,3.28C13.86,3.39 15,4.09 15.66,5.17L14.16,6.67C13.91,5.9 13.23,5.36 12.42,5.29M22,13.65L20.59,12.24L17.76,15.07L14.93,12.24L13.5,13.65L16.35,16.5L13.5,19.31L14.93,20.72L17.76,17.89L20.59,20.72L22,19.31L19.17,16.5L22,13.65Z" />
</svg>
</Button>
</div>
);
const editorBlocks = filters.map((f, idx) => { const editorBlocks = filters.map((f, idx) => {
const error = errors[`filter[${idx+1}]`]; const error = errors[`filter[${idx+1}]`];
@ -107,28 +216,26 @@ export default class CombiningFilterEditor extends React.Component {
); );
}) })
//TODO: Implement support for nested filter
if(hasNestedCombiningFilter(filter)) {
return <div className="maputnik-filter-editor-unsupported">
Nested filters are not supported.
</div>
}
return <div className="maputnik-filter-editor"> return (
<div className="maputnik-filter-editor-compound-select" data-wd-key="layer-filter"> <>
<DocLabel <InputBlock
label={"Compound Filter"} key="top"
onToggleDoc={this.onToggleDoc}
fieldSpec={fieldSpec} fieldSpec={fieldSpec}
/> label={"Compound filter"}
action={actions}
>
<SelectInput <SelectInput
value={combiningOp} value={combiningOp}
onChange={this.onFilterPartChanged.bind(this, 0)} onChange={this.onFilterPartChanged.bind(this, 0)}
options={[["all", "every filter matches"], ["none", "no filter matches"], ["any", "any filter matches"]]} options={[["all", "every filter matches"], ["none", "no filter matches"], ["any", "any filter matches"]]}
/> />
</div> </InputBlock>
{editorBlocks} {editorBlocks}
<div className="maputnik-filter-editor-add-wrapper"> <div
key="buttons"
className="maputnik-filter-editor-add-wrapper"
>
<Button <Button
data-wd-key="layer-filter-button" data-wd-key="layer-filter-button"
className="maputnik-add-filter" className="maputnik-add-filter"
@ -137,11 +244,46 @@ export default class CombiningFilterEditor extends React.Component {
</Button> </Button>
</div> </div>
<div <div
key="doc"
className="maputnik-doc-inline" className="maputnik-doc-inline"
style={{display: this.state.showDoc ? '' : 'none'}} style={{display: this.state.showDoc ? '' : 'none'}}
> >
<SpecDoc fieldSpec={fieldSpec} /> <SpecDoc fieldSpec={fieldSpec} />
</div> </div>
</div> </>
);
}
else {
let {filter} = this.props;
if (!filter) {
filter = defaultFilter;
}
else if (isNestedCombiningFilter) {
filter = migrateFilter(filter);
}
const errorMessage = Object.entries(errors)
.filter(([k, v]) => k.match(/filter(\[\d+\])?/))
.map(([k, v]) => {
return v.message;
})
.join("\n")
const error = errorMessage ? {message: errorMessage} : null;
return (
<ExpressionProperty
onDelete={() => {
this.setState({isSimpleFilter: true});
this.props.onChange(defaultFilter);
}}
fieldName="filter-compound-filter"
fieldSpec={fieldSpec}
value={filter}
error={error}
onChange={this.props.onChange}
/>
);
}
} }
} }

View file

@ -13,6 +13,7 @@ import 'codemirror/lib/codemirror.css'
import 'codemirror/addon/lint/lint.css' import 'codemirror/addon/lint/lint.css'
import '../../codemirror-maputnik.css' import '../../codemirror-maputnik.css'
import jsonlint from 'jsonlint' import jsonlint from 'jsonlint'
import stringifyPretty from 'json-stringify-pretty-compact'
// This is mainly because of this issue <https://github.com/zaach/jsonlint/issues/57> also the API has changed, see comment in file // This is mainly because of this issue <https://github.com/zaach/jsonlint/issues/57> also the API has changed, see comment in file
import '../../vendor/codemirror/addon/lint/json-lint' import '../../vendor/codemirror/addon/lint/json-lint'
@ -20,7 +21,7 @@ import '../../vendor/codemirror/addon/lint/json-lint'
class JSONEditor extends React.Component { class JSONEditor extends React.Component {
static propTypes = { static propTypes = {
layer: PropTypes.object.isRequired, layer: PropTypes.any.isRequired,
maxHeight: PropTypes.number, maxHeight: PropTypes.number,
onChange: PropTypes.func, onChange: PropTypes.func,
lineNumbers: PropTypes.bool, lineNumbers: PropTypes.bool,
@ -35,7 +36,7 @@ class JSONEditor extends React.Component {
lineWrapping: false, lineWrapping: false,
gutters: ["CodeMirror-lint-markers"], gutters: ["CodeMirror-lint-markers"],
getValue: (data) => { getValue: (data) => {
return JSON.stringify(data, null, 2) return stringifyPretty(data, {indent: 2, maxLength: 50} );
} }
} }

View file

@ -1,5 +1,10 @@
.maputnik-filter-editor-wrapper { .maputnik-filter-editor-wrapper {
padding: $margin-3; padding: $margin-3;
overflow: hidden;
.maputnik-input-block {
margin: 0;
}
} }
.maputnik-filter-editor { .maputnik-filter-editor {