mirror of
https://github.com/a-nyx/maputnik-with-pmtiles.git
synced 2024-12-27 08:25:24 +01:00
Added expression support for filters.
This commit is contained in:
parent
be36eec93d
commit
b539644b2b
7 changed files with 248 additions and 109 deletions
|
@ -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) {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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} );
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
.maputnik-filter-editor-wrapper {
|
||||
padding: $margin-3;
|
||||
overflow: hidden;
|
||||
|
||||
.maputnik-input-block {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.maputnik-filter-editor {
|
||||
|
|
Loading…
Reference in a new issue