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) => {
const errors = validate(newStyle, latest)
const errors = validate(newStyle, latest) || [];
const mappedErrors = errors.map(error => {
const layerMatch = error.message.match(/layers\[(\d+)\]\.(?:(\S+)\.)?(\S+): (.*)/);
if (layerMatch) {

View file

@ -8,6 +8,9 @@ import ExpressionProperty from './_ExpressionProperty'
import {expression} from '@mapbox/mapbox-gl-style-spec'
function isLiteralExpression (value) {
return (Array.isArray(value) && value.length === 2 && value[0] === "literal");
}
function isZoomField(value) {
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
* and create an expression.
*/
// TODO: Use function from filter checks.
function checkIsExpression (value, fieldSpec={}) {
if (!Array.isArray(value)) {
return false;
@ -111,8 +115,9 @@ export default class FunctionSpecProperty extends React.Component {
this.props.onChange(this.props.fieldName, changedValue)
}
deleteExpression = (newValue) => {
this.props.onChange(this.props.fieldName, newValue);
deleteExpression = () => {
const {fieldSpec, fieldName} = this.props;
this.props.onChange(fieldName, fieldSpec.default);
this.setState({
isExpression: false,
});
@ -144,6 +149,22 @@ export default class FunctionSpecProperty extends React.Component {
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 = () => {
const expression = ["literal", this.props.value || this.props.fieldSpec.default];
this.props.onChange(this.props.fieldName, expression);
@ -176,6 +197,8 @@ export default class FunctionSpecProperty extends React.Component {
<ExpressionProperty
error={this.props.error}
onChange={this.props.onChange.bind(this, this.props.fieldName)}
canUndo={this.canUndo}
onUndo={this.undoExpression}
onDelete={this.deleteExpression}
fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec}

View file

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

View file

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

View file

@ -2,13 +2,66 @@ import React from 'react'
import PropTypes from 'prop-types'
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 SelectInput from '../inputs/SelectInput'
import InputBlock from '../inputs/InputBlock'
import SingleFilterEditor from './SingleFilterEditor'
import FilterEditorBlock from './FilterEditorBlock'
import Button from '../Button'
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) {
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: PropTypes.object,
filter: PropTypes.array,
errors: PropTypes.array,
errors: PropTypes.object,
onChange: PropTypes.func.isRequired,
}
constructor () {
constructor (props) {
super();
this.state = {
showDoc: false,
isSimpleFilter: checkIfSimpleFilter(this.combiningFilter(props)),
};
}
// Convert filter to combining filter
combiningFilter() {
let filter = this.props.filter || ['all']
combiningFilter(props=this.props) {
let filter = props.filter || ['all']
let combiningOp = filter[0]
let filters = filter.slice(1)
@ -61,7 +115,6 @@ export default class CombiningFilterEditor extends React.Component {
deleteFilterItem(filterIdx) {
const newFilter = this.combiningFilter().slice(0)
console.log('Delete', filterIdx, newFilter)
newFilter.splice(filterIdx + 1, 1)
this.props.onChange(newFilter)
}
@ -78,70 +131,159 @@ export default class CombiningFilterEditor extends React.Component {
});
}
render() {
const filter = this.combiningFilter()
let combiningOp = filter[0]
let filters = filter.slice(1)
const {errors} = this.props;
makeExpression = () => {
let filter = this.combiningFilter();
this.props.onChange(migrateFilter(filter));
this.setState({
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={
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 error = errors[`filter[${idx+1}]`];
return (
<>
<FilterEditorBlock key={idx} onDelete={this.deleteFilterItem.bind(this, idx)}>
<SingleFilterEditor
properties={this.props.properties}
filter={f}
onChange={this.onFilterPartChanged.bind(this, idx + 1)}
/>
</FilterEditorBlock>
{error &&
<div className="maputnik-inline-error">{error.message}</div>
}
</>
);
})
const editorBlocks = filters.map((f, idx) => {
const error = errors[`filter[${idx+1}]`];
return (
<>
<FilterEditorBlock key={idx} onDelete={this.deleteFilterItem.bind(this, idx)}>
<SingleFilterEditor
properties={this.props.properties}
filter={f}
onChange={this.onFilterPartChanged.bind(this, idx + 1)}
<InputBlock
key="top"
fieldSpec={fieldSpec}
label={"Compound filter"}
action={actions}
>
<SelectInput
value={combiningOp}
onChange={this.onFilterPartChanged.bind(this, 0)}
options={[["all", "every filter matches"], ["none", "no filter matches"], ["any", "any filter matches"]]}
/>
</FilterEditorBlock>
{error &&
<div className="maputnik-inline-error">{error.message}</div>
}
</InputBlock>
{editorBlocks}
<div
key="buttons"
className="maputnik-filter-editor-add-wrapper"
>
<Button
data-wd-key="layer-filter-button"
className="maputnik-add-filter"
onClick={this.addFilterItem}>
Add filter
</Button>
</div>
<div
key="doc"
className="maputnik-doc-inline"
style={{display: this.state.showDoc ? '' : 'none'}}
>
<SpecDoc fieldSpec={fieldSpec} />
</div>
</>
);
})
//TODO: Implement support for nested filter
if(hasNestedCombiningFilter(filter)) {
return <div className="maputnik-filter-editor-unsupported">
Nested filters are not supported.
</div>
}
else {
let {filter} = this.props;
return <div className="maputnik-filter-editor">
<div className="maputnik-filter-editor-compound-select" data-wd-key="layer-filter">
<DocLabel
label={"Compound Filter"}
onToggleDoc={this.onToggleDoc}
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}
/>
<SelectInput
value={combiningOp}
onChange={this.onFilterPartChanged.bind(this, 0)}
options={[["all", "every filter matches"], ["none", "no filter matches"], ["any", "any filter matches"]]}
/>
</div>
{editorBlocks}
<div className="maputnik-filter-editor-add-wrapper">
<Button
data-wd-key="layer-filter-button"
className="maputnik-add-filter"
onClick={this.addFilterItem}>
Add filter
</Button>
</div>
<div
className="maputnik-doc-inline"
style={{display: this.state.showDoc ? '' : 'none'}}
>
<SpecDoc fieldSpec={fieldSpec} />
</div>
</div>
);
}
}
}

View file

@ -13,6 +13,7 @@ import 'codemirror/lib/codemirror.css'
import 'codemirror/addon/lint/lint.css'
import '../../codemirror-maputnik.css'
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
import '../../vendor/codemirror/addon/lint/json-lint'
@ -20,7 +21,7 @@ import '../../vendor/codemirror/addon/lint/json-lint'
class JSONEditor extends React.Component {
static propTypes = {
layer: PropTypes.object.isRequired,
layer: PropTypes.any.isRequired,
maxHeight: PropTypes.number,
onChange: PropTypes.func,
lineNumbers: PropTypes.bool,
@ -35,7 +36,7 @@ class JSONEditor extends React.Component {
lineWrapping: false,
gutters: ["CodeMirror-lint-markers"],
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 {
padding: $margin-3;
overflow: hidden;
.maputnik-input-block {
margin: 0;
}
}
.maputnik-filter-editor {