mirror of
https://github.com/a-nyx/maputnik-with-pmtiles.git
synced 2024-11-13 02:24:16 +01:00
Merge pull request #620 from orangemug/feature/ui-errors-and-expressions
Added support for expressions and UI errors
This commit is contained in:
commit
8c82db9162
34 changed files with 1262 additions and 240 deletions
17
package-lock.json
generated
17
package-lock.json
generated
|
@ -3168,6 +3168,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"code-error-fragment": {
|
||||||
|
"version": "0.0.230",
|
||||||
|
"resolved": "https://registry.npmjs.org/code-error-fragment/-/code-error-fragment-0.0.230.tgz",
|
||||||
|
"integrity": "sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw=="
|
||||||
|
},
|
||||||
"code-point-at": {
|
"code-point-at": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
|
||||||
|
@ -5783,8 +5788,7 @@
|
||||||
"grapheme-splitter": {
|
"grapheme-splitter": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
|
||||||
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
|
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"grid-index": {
|
"grid-index": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
|
@ -7141,6 +7145,15 @@
|
||||||
"integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=",
|
"integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"json-to-ast": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-to-ast/-/json-to-ast-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-W9Lq347r8tA1DfMvAGn9QNcgYm4Wm7Yc+k8e6vezpMnRT+NHbtlxgNBXRVjXe9YM6eTn6+p/MKOlV/aABJcSnQ==",
|
||||||
|
"requires": {
|
||||||
|
"code-error-fragment": "0.0.230",
|
||||||
|
"grapheme-splitter": "^1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"json3": {
|
"json3": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/json3/-/json3-3.3.3.tgz",
|
||||||
|
|
|
@ -30,6 +30,7 @@
|
||||||
"color": "^3.1.2",
|
"color": "^3.1.2",
|
||||||
"detect-browser": "^4.8.0",
|
"detect-browser": "^4.8.0",
|
||||||
"file-saver": "^2.0.2",
|
"file-saver": "^2.0.2",
|
||||||
|
"json-to-ast": "^2.1.0",
|
||||||
"jsonlint": "github:josdejong/jsonlint#85a19d7",
|
"jsonlint": "github:josdejong/jsonlint#85a19d7",
|
||||||
"lodash": "^4.17.15",
|
"lodash": "^4.17.15",
|
||||||
"lodash.capitalize": "^4.2.1",
|
"lodash.capitalize": "^4.2.1",
|
||||||
|
|
|
@ -3,6 +3,7 @@ import React from 'react'
|
||||||
import cloneDeep from 'lodash.clonedeep'
|
import cloneDeep from 'lodash.clonedeep'
|
||||||
import clamp from 'lodash.clamp'
|
import clamp from 'lodash.clamp'
|
||||||
import get from 'lodash.get'
|
import get from 'lodash.get'
|
||||||
|
import {unset} from 'lodash'
|
||||||
import {arrayMove} from 'react-sortable-hoc'
|
import {arrayMove} from 'react-sortable-hoc'
|
||||||
import url from 'url'
|
import url from 'url'
|
||||||
|
|
||||||
|
@ -97,7 +98,7 @@ export default class App extends React.Component {
|
||||||
port = window.location.port
|
port = window.location.port
|
||||||
}
|
}
|
||||||
this.styleStore = new ApiStyleStore({
|
this.styleStore = new ApiStyleStore({
|
||||||
onLocalStyleChange: mapStyle => this.onStyleChanged(mapStyle, false),
|
onLocalStyleChange: mapStyle => this.onStyleChanged(mapStyle, {save: false}),
|
||||||
port: port,
|
port: port,
|
||||||
host: params.get("localhost")
|
host: params.get("localhost")
|
||||||
})
|
})
|
||||||
|
@ -316,39 +317,87 @@ export default class App extends React.Component {
|
||||||
this.onStyleChanged(changedStyle)
|
this.onStyleChanged(changedStyle)
|
||||||
}
|
}
|
||||||
|
|
||||||
onStyleChanged = (newStyle, save=true) => {
|
onStyleChanged = (newStyle, opts={}) => {
|
||||||
|
opts = {
|
||||||
|
save: true,
|
||||||
|
addRevision: true,
|
||||||
|
...opts,
|
||||||
|
};
|
||||||
|
|
||||||
const errors = validate(newStyle, latest)
|
const errors = validate(newStyle, latest) || [];
|
||||||
if(errors.length === 0) {
|
const mappedErrors = errors.map(error => {
|
||||||
|
const layerMatch = error.message.match(/layers\[(\d+)\]\.(?:(\S+)\.)?(\S+): (.*)/);
|
||||||
if(newStyle.glyphs !== this.state.mapStyle.glyphs) {
|
if (layerMatch) {
|
||||||
this.updateFonts(newStyle.glyphs)
|
const [matchStr, index, group, property, message] = layerMatch;
|
||||||
|
const key = (group && property) ? [group, property].join(".") : property;
|
||||||
|
return {
|
||||||
|
message: error.message,
|
||||||
|
parsed: {
|
||||||
|
type: "layer",
|
||||||
|
data: {
|
||||||
|
index,
|
||||||
|
key,
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if(newStyle.sprite !== this.state.mapStyle.sprite) {
|
else {
|
||||||
this.updateIcons(newStyle.sprite)
|
return {
|
||||||
|
message: error.message,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
this.revisionStore.addRevision(newStyle)
|
let dirtyMapStyle = undefined;
|
||||||
if(save) this.saveStyle(newStyle)
|
if (errors.length > 0) {
|
||||||
this.setState({
|
dirtyMapStyle = cloneDeep(newStyle);
|
||||||
mapStyle: newStyle,
|
|
||||||
errors: [],
|
errors.forEach(error => {
|
||||||
})
|
const {message} = error;
|
||||||
} else {
|
if (message) {
|
||||||
this.setState({
|
try {
|
||||||
errors: errors.map(err => err.message)
|
const objPath = message.split(":")[0];
|
||||||
})
|
// Errors can be deply nested for example 'layers[0].filter[1][1][0]' we only care upto the property 'layers[0].filter'
|
||||||
|
const unsetPath = objPath.match(/^\S+?\[\d+\]\.[^\[]+/)[0];
|
||||||
|
unset(dirtyMapStyle, unsetPath);
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(newStyle.glyphs !== this.state.mapStyle.glyphs) {
|
||||||
|
this.updateFonts(newStyle.glyphs)
|
||||||
|
}
|
||||||
|
if(newStyle.sprite !== this.state.mapStyle.sprite) {
|
||||||
|
this.updateIcons(newStyle.sprite)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.addRevision) {
|
||||||
|
this.revisionStore.addRevision(newStyle);
|
||||||
|
}
|
||||||
|
if (opts.save) {
|
||||||
|
this.saveStyle(newStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
mapStyle: newStyle,
|
||||||
|
dirtyMapStyle: dirtyMapStyle,
|
||||||
|
errors: mappedErrors,
|
||||||
|
})
|
||||||
|
|
||||||
this.fetchSources();
|
this.fetchSources();
|
||||||
}
|
}
|
||||||
|
|
||||||
onUndo = () => {
|
onUndo = () => {
|
||||||
const activeStyle = this.revisionStore.undo()
|
const activeStyle = this.revisionStore.undo()
|
||||||
|
|
||||||
const messages = undoMessages(this.state.mapStyle, activeStyle)
|
const messages = undoMessages(this.state.mapStyle, activeStyle)
|
||||||
this.saveStyle(activeStyle)
|
this.onStyleChanged(activeStyle, {addRevision: false});
|
||||||
this.setState({
|
this.setState({
|
||||||
mapStyle: activeStyle,
|
|
||||||
infos: messages,
|
infos: messages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -356,9 +405,8 @@ export default class App extends React.Component {
|
||||||
onRedo = () => {
|
onRedo = () => {
|
||||||
const activeStyle = this.revisionStore.redo()
|
const activeStyle = this.revisionStore.redo()
|
||||||
const messages = redoMessages(this.state.mapStyle, activeStyle)
|
const messages = redoMessages(this.state.mapStyle, activeStyle)
|
||||||
this.saveStyle(activeStyle)
|
this.onStyleChanged(activeStyle, {addRevision: false});
|
||||||
this.setState({
|
this.setState({
|
||||||
mapStyle: activeStyle,
|
|
||||||
infos: messages,
|
infos: messages,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -546,10 +594,11 @@ export default class App extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
mapRenderer() {
|
mapRenderer() {
|
||||||
|
const {mapStyle, dirtyMapStyle} = this.state;
|
||||||
const metadata = this.state.mapStyle.metadata || {};
|
const metadata = this.state.mapStyle.metadata || {};
|
||||||
|
|
||||||
const mapProps = {
|
const mapProps = {
|
||||||
mapStyle: this.state.mapStyle,
|
mapStyle: (dirtyMapStyle || mapStyle),
|
||||||
replaceAccessTokens: (mapStyle) => {
|
replaceAccessTokens: (mapStyle) => {
|
||||||
return style.replaceAccessTokens(mapStyle, {
|
return style.replaceAccessTokens(mapStyle, {
|
||||||
allowFallback: true
|
allowFallback: true
|
||||||
|
@ -663,6 +712,7 @@ export default class App extends React.Component {
|
||||||
selectedLayerIndex={this.state.selectedLayerIndex}
|
selectedLayerIndex={this.state.selectedLayerIndex}
|
||||||
layers={layers}
|
layers={layers}
|
||||||
sources={this.state.sources}
|
sources={this.state.sources}
|
||||||
|
errors={this.state.errors}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
const layerEditor = selectedLayer ? <LayerEditor
|
const layerEditor = selectedLayer ? <LayerEditor
|
||||||
|
@ -680,9 +730,13 @@ export default class App extends React.Component {
|
||||||
onLayerCopy={this.onLayerCopy}
|
onLayerCopy={this.onLayerCopy}
|
||||||
onLayerVisibilityToggle={this.onLayerVisibilityToggle}
|
onLayerVisibilityToggle={this.onLayerVisibilityToggle}
|
||||||
onLayerIdChange={this.onLayerIdChange}
|
onLayerIdChange={this.onLayerIdChange}
|
||||||
|
errors={this.state.errors}
|
||||||
/> : null
|
/> : null
|
||||||
|
|
||||||
const bottomPanel = (this.state.errors.length + this.state.infos.length) > 0 ? <MessagePanel
|
const bottomPanel = (this.state.errors.length + this.state.infos.length) > 0 ? <MessagePanel
|
||||||
|
currentLayer={selectedLayer}
|
||||||
|
onLayerSelect={this.onLayerSelect}
|
||||||
|
mapStyle={this.state.mapStyle}
|
||||||
errors={this.state.errors}
|
errors={this.state.errors}
|
||||||
infos={this.state.infos}
|
infos={this.state.infos}
|
||||||
/> : null
|
/> : null
|
||||||
|
|
|
@ -5,11 +5,45 @@ class MessagePanel extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
errors: PropTypes.array,
|
errors: PropTypes.array,
|
||||||
infos: PropTypes.array,
|
infos: PropTypes.array,
|
||||||
|
mapStyle: PropTypes.object,
|
||||||
|
onLayerSelect: PropTypes.func,
|
||||||
|
currentLayer: PropTypes.object,
|
||||||
|
}
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
onLayerSelect: () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const errors = this.props.errors.map((m, i) => {
|
const errors = this.props.errors.map((error, idx) => {
|
||||||
return <p key={"error-"+i} className="maputnik-message-panel-error">{m}</p>
|
let content;
|
||||||
|
if (error.parsed && error.parsed.type === "layer") {
|
||||||
|
const {parsed} = error;
|
||||||
|
const {mapStyle, currentLayer} = this.props;
|
||||||
|
const layerId = mapStyle.layers[parsed.data.index].id;
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
Layer <span>'{layerId}'</span>: {parsed.data.message}
|
||||||
|
{currentLayer.id !== layerId &&
|
||||||
|
<>
|
||||||
|
—
|
||||||
|
<button
|
||||||
|
className="maputnik-message-panel__switch-button"
|
||||||
|
onClick={() => this.props.onLayerSelect(layerId)}
|
||||||
|
>
|
||||||
|
switch to layer
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
content = error.message;
|
||||||
|
}
|
||||||
|
return <p key={"error-"+idx} className="maputnik-message-panel-error">
|
||||||
|
{content}
|
||||||
|
</p>
|
||||||
})
|
})
|
||||||
|
|
||||||
const infos = this.props.infos.map((m, i) => {
|
const infos = this.props.infos.map((m, i) => {
|
||||||
|
|
|
@ -49,8 +49,15 @@ export default class DocLabel extends React.Component {
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
}
|
}
|
||||||
|
else if (label) {
|
||||||
|
return <label className="maputnik-doc-wrapper">
|
||||||
|
<div className="maputnik-doc-target">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
return <div />
|
<div />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,14 +4,78 @@ import PropTypes from 'prop-types'
|
||||||
import SpecProperty from './_SpecProperty'
|
import SpecProperty from './_SpecProperty'
|
||||||
import DataProperty from './_DataProperty'
|
import DataProperty from './_DataProperty'
|
||||||
import ZoomProperty from './_ZoomProperty'
|
import ZoomProperty from './_ZoomProperty'
|
||||||
|
import ExpressionProperty from './_ExpressionProperty'
|
||||||
|
import {function as styleFunction} 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' &&
|
||||||
|
Array.isArray(value.stops) &&
|
||||||
|
value.stops.length > 1 &&
|
||||||
|
value.stops.every(stop => {
|
||||||
|
return (
|
||||||
|
Array.isArray(stop) &&
|
||||||
|
stop.length === 2
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDataField(value) {
|
function isDataField(value) {
|
||||||
return typeof value === 'object' && value.stops && typeof value.property !== 'undefined'
|
return (
|
||||||
|
typeof(value) === 'object' &&
|
||||||
|
value.stops &&
|
||||||
|
typeof(value.property) !== 'undefined' &&
|
||||||
|
value.stops.length > 1 &&
|
||||||
|
Array.isArray(value.stops) &&
|
||||||
|
value.stops.every(stop => {
|
||||||
|
return (
|
||||||
|
Array.isArray(stop) &&
|
||||||
|
stop.length === 2 &&
|
||||||
|
typeof(stop[0]) === 'object'
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrimative (value) {
|
||||||
|
const valid = ["string", "boolean", "number"];
|
||||||
|
return valid.includes(typeof(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isArrayOfPrimatives (values) {
|
||||||
|
if (Array.isArray(values)) {
|
||||||
|
return values.every(isPrimative);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDataType (value, fieldSpec={}) {
|
||||||
|
if (value === undefined) {
|
||||||
|
return "value";
|
||||||
|
}
|
||||||
|
else if (isPrimative(value)) {
|
||||||
|
return "value";
|
||||||
|
}
|
||||||
|
else if (fieldSpec.type === "array" && isArrayOfPrimatives(value)) {
|
||||||
|
return "value";
|
||||||
|
}
|
||||||
|
else if (isZoomField(value)) {
|
||||||
|
return "zoom_function";
|
||||||
|
}
|
||||||
|
else if (isDataField(value)) {
|
||||||
|
return "data_function";
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return "expression";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -40,7 +104,9 @@ export default class FunctionSpecProperty extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
fieldName: PropTypes.string.isRequired,
|
fieldName: PropTypes.string.isRequired,
|
||||||
|
fieldType: PropTypes.string.isRequired,
|
||||||
fieldSpec: PropTypes.object.isRequired,
|
fieldSpec: PropTypes.object.isRequired,
|
||||||
|
errors: PropTypes.object,
|
||||||
|
|
||||||
value: PropTypes.oneOfType([
|
value: PropTypes.oneOfType([
|
||||||
PropTypes.object,
|
PropTypes.object,
|
||||||
|
@ -51,6 +117,27 @@ export default class FunctionSpecProperty extends React.Component {
|
||||||
]),
|
]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
constructor (props) {
|
||||||
|
super();
|
||||||
|
this.state = {
|
||||||
|
dataType: getDataType(props.value, props.fieldSpec),
|
||||||
|
isEditing: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromProps(props, state) {
|
||||||
|
// Because otherwise when editing values we end up accidentally changing field type.
|
||||||
|
if (state.isEditing) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return {
|
||||||
|
isEditing: false,
|
||||||
|
dataType: getDataType(props.value, props.fieldSpec)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
getFieldFunctionType(fieldSpec) {
|
getFieldFunctionType(fieldSpec) {
|
||||||
if (fieldSpec.expression.interpolated) {
|
if (fieldSpec.expression.interpolated) {
|
||||||
return "exponential"
|
return "exponential"
|
||||||
|
@ -82,6 +169,14 @@ export default class FunctionSpecProperty extends React.Component {
|
||||||
this.props.onChange(this.props.fieldName, changedValue)
|
this.props.onChange(this.props.fieldName, changedValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deleteExpression = () => {
|
||||||
|
const {fieldSpec, fieldName} = this.props;
|
||||||
|
this.props.onChange(fieldName, fieldSpec.default);
|
||||||
|
this.setState({
|
||||||
|
dataType: "value",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
deleteStop = (stopIdx) => {
|
deleteStop = (stopIdx) => {
|
||||||
const stops = this.props.value.stops.slice(0)
|
const stops = this.props.value.stops.slice(0)
|
||||||
stops.splice(stopIdx, 1)
|
stops.splice(stopIdx, 1)
|
||||||
|
@ -108,6 +203,39 @@ 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({
|
||||||
|
dataType: "value",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canUndo = () => {
|
||||||
|
const {value, fieldSpec} = this.props;
|
||||||
|
return (
|
||||||
|
isLiteralExpression(value) ||
|
||||||
|
isPrimative(value) ||
|
||||||
|
(Array.isArray(value) && fieldSpec.type === "array")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
makeExpression = () => {
|
||||||
|
const {value, fieldSpec} = this.props;
|
||||||
|
let expression;
|
||||||
|
|
||||||
|
if (typeof(value) === "object" && 'stops' in value) {
|
||||||
|
expression = styleFunction.convertFunction(value, fieldSpec);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
expression = ["literal", value || this.props.fieldSpec.default];
|
||||||
|
}
|
||||||
|
this.props.onChange(this.props.fieldName, expression);
|
||||||
|
}
|
||||||
|
|
||||||
makeDataFunction = () => {
|
makeDataFunction = () => {
|
||||||
const functionType = this.getFieldFunctionType(this.props.fieldSpec);
|
const functionType = this.getFieldFunctionType(this.props.fieldSpec);
|
||||||
const stopValue = functionType === 'categorical' ? '' : 0;
|
const stopValue = functionType === 'categorical' ? '' : 0;
|
||||||
|
@ -122,43 +250,78 @@ export default class FunctionSpecProperty extends React.Component {
|
||||||
this.props.onChange(this.props.fieldName, dataFunc)
|
this.props.onChange(this.props.fieldName, dataFunc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMarkEditing = () => {
|
||||||
|
this.setState({isEditing: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmarkEditing = () => {
|
||||||
|
this.setState({isEditing: false});
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const {dataType} = this.state;
|
||||||
const propClass = this.props.fieldSpec.default === this.props.value ? "maputnik-default-property" : "maputnik-modified-property"
|
const propClass = this.props.fieldSpec.default === this.props.value ? "maputnik-default-property" : "maputnik-modified-property"
|
||||||
let specField;
|
let specField;
|
||||||
|
|
||||||
if (isZoomField(this.props.value)) {
|
if (dataType === "expression") {
|
||||||
|
specField = (
|
||||||
|
<ExpressionProperty
|
||||||
|
errors={this.props.errors}
|
||||||
|
onChange={this.props.onChange.bind(this, this.props.fieldName)}
|
||||||
|
canUndo={this.canUndo}
|
||||||
|
onUndo={this.undoExpression}
|
||||||
|
onDelete={this.deleteExpression}
|
||||||
|
fieldType={this.props.fieldType}
|
||||||
|
fieldName={this.props.fieldName}
|
||||||
|
fieldSpec={this.props.fieldSpec}
|
||||||
|
value={this.props.value}
|
||||||
|
onFocus={this.onMarkEditing}
|
||||||
|
onBlur={this.onUnmarkEditing}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else if (dataType === "zoom_function") {
|
||||||
specField = (
|
specField = (
|
||||||
<ZoomProperty
|
<ZoomProperty
|
||||||
|
errors={this.props.errors}
|
||||||
onChange={this.props.onChange.bind(this)}
|
onChange={this.props.onChange.bind(this)}
|
||||||
|
fieldType={this.props.fieldType}
|
||||||
fieldName={this.props.fieldName}
|
fieldName={this.props.fieldName}
|
||||||
fieldSpec={this.props.fieldSpec}
|
fieldSpec={this.props.fieldSpec}
|
||||||
value={this.props.value}
|
value={this.props.value}
|
||||||
onDeleteStop={this.deleteStop}
|
onDeleteStop={this.deleteStop}
|
||||||
onAddStop={this.addStop}
|
onAddStop={this.addStop}
|
||||||
|
onExpressionClick={this.makeExpression}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else if (isDataField(this.props.value)) {
|
else if (dataType === "data_function") {
|
||||||
specField = (
|
specField = (
|
||||||
<DataProperty
|
<DataProperty
|
||||||
|
errors={this.props.errors}
|
||||||
onChange={this.props.onChange.bind(this)}
|
onChange={this.props.onChange.bind(this)}
|
||||||
|
fieldType={this.props.fieldType}
|
||||||
fieldName={this.props.fieldName}
|
fieldName={this.props.fieldName}
|
||||||
fieldSpec={this.props.fieldSpec}
|
fieldSpec={this.props.fieldSpec}
|
||||||
value={this.props.value}
|
value={this.props.value}
|
||||||
onDeleteStop={this.deleteStop}
|
onDeleteStop={this.deleteStop}
|
||||||
onAddStop={this.addStop}
|
onAddStop={this.addStop}
|
||||||
|
onExpressionClick={this.makeExpression}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
specField = (
|
specField = (
|
||||||
<SpecProperty
|
<SpecProperty
|
||||||
|
errors={this.props.errors}
|
||||||
onChange={this.props.onChange.bind(this)}
|
onChange={this.props.onChange.bind(this)}
|
||||||
|
fieldType={this.props.fieldType}
|
||||||
fieldName={this.props.fieldName}
|
fieldName={this.props.fieldName}
|
||||||
fieldSpec={this.props.fieldSpec}
|
fieldSpec={this.props.fieldSpec}
|
||||||
value={this.props.value}
|
value={this.props.value}
|
||||||
onZoomClick={this.makeZoomFunction}
|
onZoomClick={this.makeZoomFunction}
|
||||||
onDataClick={this.makeDataFunction}
|
onDataClick={this.makeDataFunction}
|
||||||
|
onExpressionClick={this.makeExpression}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,6 +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.object,
|
||||||
}
|
}
|
||||||
|
|
||||||
onPropertyChange = (property, newValue) => {
|
onPropertyChange = (property, newValue) => {
|
||||||
|
@ -48,18 +49,22 @@ export default class PropertyGroup extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const {errors} = this.props;
|
||||||
const fields = this.props.groupFields.map(fieldName => {
|
const fields = this.props.groupFields.map(fieldName => {
|
||||||
const fieldSpec = getFieldSpec(this.props.spec, this.props.layer.type, fieldName)
|
const fieldSpec = getFieldSpec(this.props.spec, this.props.layer.type, fieldName)
|
||||||
|
|
||||||
const paint = this.props.layer.paint || {}
|
const paint = this.props.layer.paint || {}
|
||||||
const layout = this.props.layer.layout || {}
|
const layout = this.props.layer.layout || {}
|
||||||
const fieldValue = fieldName in paint ? paint[fieldName] : layout[fieldName]
|
const fieldValue = fieldName in paint ? paint[fieldName] : layout[fieldName]
|
||||||
|
const fieldType = fieldName in paint ? 'paint' : 'layout';
|
||||||
|
|
||||||
return <FunctionSpecField
|
return <FunctionSpecField
|
||||||
|
errors={errors}
|
||||||
onChange={this.onPropertyChange}
|
onChange={this.onPropertyChange}
|
||||||
key={fieldName}
|
key={fieldName}
|
||||||
fieldName={fieldName}
|
fieldName={fieldName}
|
||||||
value={fieldValue}
|
value={fieldValue}
|
||||||
|
fieldType={fieldType}
|
||||||
fieldSpec={fieldSpec}
|
fieldSpec={fieldSpec}
|
||||||
/>
|
/>
|
||||||
})
|
})
|
||||||
|
|
|
@ -39,7 +39,9 @@ export default class DataProperty extends React.Component {
|
||||||
onChange: PropTypes.func,
|
onChange: PropTypes.func,
|
||||||
onDeleteStop: PropTypes.func,
|
onDeleteStop: PropTypes.func,
|
||||||
onAddStop: PropTypes.func,
|
onAddStop: PropTypes.func,
|
||||||
|
onExpressionClick: PropTypes.func,
|
||||||
fieldName: PropTypes.string,
|
fieldName: PropTypes.string,
|
||||||
|
fieldType: PropTypes.string,
|
||||||
fieldSpec: PropTypes.object,
|
fieldSpec: PropTypes.object,
|
||||||
value: PropTypes.oneOfType([
|
value: PropTypes.oneOfType([
|
||||||
PropTypes.object,
|
PropTypes.object,
|
||||||
|
@ -48,6 +50,7 @@ export default class DataProperty extends React.Component {
|
||||||
PropTypes.bool,
|
PropTypes.bool,
|
||||||
PropTypes.array
|
PropTypes.array
|
||||||
]),
|
]),
|
||||||
|
errors: PropTypes.object,
|
||||||
}
|
}
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -144,6 +147,8 @@ export default class DataProperty extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const {fieldName, fieldType, errors} = this.props;
|
||||||
|
|
||||||
if (typeof this.props.value.type === "undefined") {
|
if (typeof this.props.value.type === "undefined") {
|
||||||
this.props.value.type = this.getFieldFunctionType(this.props.fieldSpec)
|
this.props.value.type = this.getFieldFunctionType(this.props.fieldSpec)
|
||||||
}
|
}
|
||||||
|
@ -181,7 +186,22 @@ export default class DataProperty extends React.Component {
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
return <InputBlock key={key} action={deleteStopBtn} label="">
|
const errorKeyStart = `${fieldType}.${fieldName}.stops[${idx}]`;
|
||||||
|
const foundErrors = Object.entries(errors).filter(([key, error]) => {
|
||||||
|
return key.startsWith(errorKeyStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = foundErrors.map(([key, error]) => {
|
||||||
|
return error.message;
|
||||||
|
}).join("");
|
||||||
|
const error = message ? {message} : undefined;
|
||||||
|
|
||||||
|
return <InputBlock
|
||||||
|
error={error}
|
||||||
|
key={key}
|
||||||
|
action={deleteStopBtn}
|
||||||
|
label=""
|
||||||
|
>
|
||||||
{zoomInput}
|
{zoomInput}
|
||||||
<div className="maputnik-data-spec-property-stop-data">
|
<div className="maputnik-data-spec-property-stop-data">
|
||||||
{dataInput}
|
{dataInput}
|
||||||
|
@ -198,58 +218,64 @@ export default class DataProperty extends React.Component {
|
||||||
})
|
})
|
||||||
|
|
||||||
return <div className="maputnik-data-spec-block">
|
return <div className="maputnik-data-spec-block">
|
||||||
<div className="maputnik-data-spec-property">
|
<div className="maputnik-data-spec-property">
|
||||||
<InputBlock
|
<InputBlock
|
||||||
fieldSpec={this.props.fieldSpec}
|
fieldSpec={this.props.fieldSpec}
|
||||||
label={labelFromFieldName(this.props.fieldName)}
|
label={labelFromFieldName(this.props.fieldName)}
|
||||||
>
|
|
||||||
<div className="maputnik-data-spec-property-group">
|
|
||||||
<DocLabel
|
|
||||||
label="Property"
|
|
||||||
/>
|
|
||||||
<div className="maputnik-data-spec-property-input">
|
|
||||||
<StringInput
|
|
||||||
value={this.props.value.property}
|
|
||||||
title={"Input a data property to base styles off of."}
|
|
||||||
onChange={propVal => this.changeDataProperty("property", propVal)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="maputnik-data-spec-property-group">
|
|
||||||
<DocLabel
|
|
||||||
label="Type"
|
|
||||||
/>
|
|
||||||
<div className="maputnik-data-spec-property-input">
|
|
||||||
<SelectInput
|
|
||||||
value={this.props.value.type}
|
|
||||||
onChange={propVal => this.changeDataProperty("type", propVal)}
|
|
||||||
title={"Select a type of data scale (default is 'categorical')."}
|
|
||||||
options={this.getDataFunctionTypes(this.props.fieldSpec)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="maputnik-data-spec-property-group">
|
|
||||||
<DocLabel
|
|
||||||
label="Default"
|
|
||||||
/>
|
|
||||||
<div className="maputnik-data-spec-property-input">
|
|
||||||
<SpecField
|
|
||||||
fieldName={this.props.fieldName}
|
|
||||||
fieldSpec={this.props.fieldSpec}
|
|
||||||
value={this.props.value.default}
|
|
||||||
onChange={(_, propVal) => this.changeDataProperty("default", propVal)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{dataFields}
|
|
||||||
<Button
|
|
||||||
className="maputnik-add-stop"
|
|
||||||
onClick={this.props.onAddStop.bind(this)}
|
|
||||||
>
|
>
|
||||||
Add stop
|
<div className="maputnik-data-spec-property-group">
|
||||||
</Button>
|
<DocLabel
|
||||||
</InputBlock>
|
label="Property"
|
||||||
</div>
|
/>
|
||||||
|
<div className="maputnik-data-spec-property-input">
|
||||||
|
<StringInput
|
||||||
|
value={this.props.value.property}
|
||||||
|
title={"Input a data property to base styles off of."}
|
||||||
|
onChange={propVal => this.changeDataProperty("property", propVal)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="maputnik-data-spec-property-group">
|
||||||
|
<DocLabel
|
||||||
|
label="Type"
|
||||||
|
/>
|
||||||
|
<div className="maputnik-data-spec-property-input">
|
||||||
|
<SelectInput
|
||||||
|
value={this.props.value.type}
|
||||||
|
onChange={propVal => this.changeDataProperty("type", propVal)}
|
||||||
|
title={"Select a type of data scale (default is 'categorical')."}
|
||||||
|
options={this.getDataFunctionTypes(this.props.fieldSpec)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="maputnik-data-spec-property-group">
|
||||||
|
<DocLabel
|
||||||
|
label="Default"
|
||||||
|
/>
|
||||||
|
<div className="maputnik-data-spec-property-input">
|
||||||
|
<SpecField
|
||||||
|
fieldName={this.props.fieldName}
|
||||||
|
fieldSpec={this.props.fieldSpec}
|
||||||
|
value={this.props.value.default}
|
||||||
|
onChange={(_, propVal) => this.changeDataProperty("default", propVal)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</InputBlock>
|
||||||
|
</div>
|
||||||
|
{dataFields}
|
||||||
|
<Button
|
||||||
|
className="maputnik-add-stop"
|
||||||
|
onClick={this.props.onAddStop.bind(this)}
|
||||||
|
>
|
||||||
|
Add stop
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="maputnik-add-stop"
|
||||||
|
onClick={this.props.onExpressionClick.bind(this)}
|
||||||
|
>
|
||||||
|
Convert to expression
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
135
src/components/fields/_ExpressionProperty.jsx
Normal file
135
src/components/fields/_ExpressionProperty.jsx
Normal file
|
@ -0,0 +1,135 @@
|
||||||
|
import React from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
|
||||||
|
import InputBlock from '../inputs/InputBlock'
|
||||||
|
import Button from '../Button'
|
||||||
|
import {MdDelete, MdUndo} from 'react-icons/md'
|
||||||
|
import StringInput from '../inputs/StringInput'
|
||||||
|
|
||||||
|
import labelFromFieldName from './_labelFromFieldName'
|
||||||
|
import stringifyPretty from 'json-stringify-pretty-compact'
|
||||||
|
import JSONEditor from '../layers/JSONEditor'
|
||||||
|
|
||||||
|
|
||||||
|
export default class ExpressionProperty extends React.Component {
|
||||||
|
static propTypes = {
|
||||||
|
onDelete: PropTypes.func,
|
||||||
|
fieldName: PropTypes.string,
|
||||||
|
fieldType: PropTypes.string,
|
||||||
|
fieldSpec: PropTypes.object,
|
||||||
|
value: PropTypes.any,
|
||||||
|
errors: PropTypes.object,
|
||||||
|
onChange: PropTypes.func,
|
||||||
|
onUndo: PropTypes.func,
|
||||||
|
canUndo: PropTypes.func,
|
||||||
|
onFocus: PropTypes.func,
|
||||||
|
onBlur: PropTypes.func,
|
||||||
|
}
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
errors: {},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor (props) {
|
||||||
|
super();
|
||||||
|
this.state = {
|
||||||
|
jsonError: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onJSONInvalid = (err) => {
|
||||||
|
this.setState({
|
||||||
|
jsonError: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onJSONValid = () => {
|
||||||
|
this.setState({
|
||||||
|
jsonError: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {errors, fieldName, fieldType, value, canUndo} = this.props;
|
||||||
|
const {jsonError} = this.state;
|
||||||
|
const undoDisabled = canUndo ? !canUndo() : true;
|
||||||
|
|
||||||
|
const deleteStopBtn = (
|
||||||
|
<>
|
||||||
|
{this.props.onUndo &&
|
||||||
|
<Button
|
||||||
|
key="undo_action"
|
||||||
|
onClick={this.props.onUndo}
|
||||||
|
disabled={undoDisabled}
|
||||||
|
className="maputnik-delete-stop"
|
||||||
|
>
|
||||||
|
<MdUndo />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
<Button
|
||||||
|
key="delete_action"
|
||||||
|
onClick={this.props.onDelete}
|
||||||
|
className="maputnik-delete-stop"
|
||||||
|
>
|
||||||
|
<MdDelete />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const fieldKey = fieldType === undefined ? fieldName : `${fieldType}.${fieldName}`;
|
||||||
|
|
||||||
|
const fieldError = errors[fieldKey];
|
||||||
|
const errorKeyStart = `${fieldKey}[`;
|
||||||
|
const foundErrors = [];
|
||||||
|
|
||||||
|
function getValue (data) {
|
||||||
|
return stringifyPretty(data, {indent: 2, maxLength: 38})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonError) {
|
||||||
|
foundErrors.push({message: "Invalid JSON"});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Object.entries(errors)
|
||||||
|
.filter(([key, error]) => {
|
||||||
|
return key.startsWith(errorKeyStart);
|
||||||
|
})
|
||||||
|
.forEach(([key, error]) => {
|
||||||
|
return foundErrors.push(error);
|
||||||
|
})
|
||||||
|
|
||||||
|
if (fieldError) {
|
||||||
|
foundErrors.push(fieldError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return <InputBlock
|
||||||
|
error={foundErrors}
|
||||||
|
fieldSpec={this.props.fieldSpec}
|
||||||
|
label={labelFromFieldName(this.props.fieldName)}
|
||||||
|
action={deleteStopBtn}
|
||||||
|
wideMode={true}
|
||||||
|
>
|
||||||
|
<JSONEditor
|
||||||
|
mode={{name: "mgl"}}
|
||||||
|
lint={{
|
||||||
|
context: "expression",
|
||||||
|
spec: this.props.fieldSpec,
|
||||||
|
}}
|
||||||
|
className="maputnik-expression-editor"
|
||||||
|
onFocus={this.props.onFocus}
|
||||||
|
onBlur={this.props.onBlur}
|
||||||
|
onJSONInvalid={this.onJSONInvalid}
|
||||||
|
onJSONValid={this.onJSONValid}
|
||||||
|
layer={value}
|
||||||
|
lineNumbers={false}
|
||||||
|
maxHeight={200}
|
||||||
|
lineWrapping={true}
|
||||||
|
getValue={getValue}
|
||||||
|
onChange={this.props.onChange}
|
||||||
|
/>
|
||||||
|
</InputBlock>
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,18 +3,50 @@ import PropTypes from 'prop-types'
|
||||||
|
|
||||||
import Button from '../Button'
|
import Button from '../Button'
|
||||||
import {MdFunctions, MdInsertChart} from 'react-icons/md'
|
import {MdFunctions, MdInsertChart} from 'react-icons/md'
|
||||||
|
import {mdiFunctionVariant} from '@mdi/js';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* So here we can't just check is `Array.isArray(value)` because certain
|
||||||
|
* properties accept arrays as values, for example `text-font`. So we must try
|
||||||
|
* and create an expression.
|
||||||
|
*/
|
||||||
|
function isExpression(value, fieldSpec={}) {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
expression.createExpression(value, fieldSpec);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default class FunctionButtons extends React.Component {
|
export default class FunctionButtons extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
fieldSpec: PropTypes.object,
|
fieldSpec: PropTypes.object,
|
||||||
onZoomClick: PropTypes.func,
|
onZoomClick: PropTypes.func,
|
||||||
onDataClick: PropTypes.func,
|
onDataClick: PropTypes.func,
|
||||||
|
onExpressionClick: PropTypes.func,
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let makeZoomButton, makeDataButton
|
let makeZoomButton, makeDataButton, expressionButton;
|
||||||
|
|
||||||
if (this.props.fieldSpec.expression.parameters.includes('zoom')) {
|
if (this.props.fieldSpec.expression.parameters.includes('zoom')) {
|
||||||
|
expressionButton = (
|
||||||
|
<Button
|
||||||
|
className="maputnik-make-zoom-function"
|
||||||
|
onClick={this.props.onExpressionClick}
|
||||||
|
>
|
||||||
|
<svg style={{width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
|
||||||
|
<path fill="currentColor" d={mdiFunctionVariant} />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
makeZoomButton = <Button
|
makeZoomButton = <Button
|
||||||
className="maputnik-make-zoom-function"
|
className="maputnik-make-zoom-function"
|
||||||
onClick={this.props.onZoomClick}
|
onClick={this.props.onZoomClick}
|
||||||
|
@ -32,10 +64,14 @@ export default class FunctionButtons extends React.Component {
|
||||||
<MdInsertChart />
|
<MdInsertChart />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
return <div>{makeDataButton}{makeZoomButton}</div>
|
return <div>
|
||||||
|
{expressionButton}
|
||||||
|
{makeDataButton}
|
||||||
|
{makeZoomButton}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
return null
|
return <div>{expressionButton}</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,18 +13,32 @@ export default class SpecProperty extends React.Component {
|
||||||
onZoomClick: PropTypes.func.isRequired,
|
onZoomClick: PropTypes.func.isRequired,
|
||||||
onDataClick: PropTypes.func.isRequired,
|
onDataClick: PropTypes.func.isRequired,
|
||||||
fieldName: PropTypes.string,
|
fieldName: PropTypes.string,
|
||||||
fieldSpec: PropTypes.object
|
fieldType: PropTypes.string,
|
||||||
|
fieldSpec: PropTypes.object,
|
||||||
|
value: PropTypes.any,
|
||||||
|
errors: PropTypes.object,
|
||||||
|
onExpressionClick: PropTypes.func,
|
||||||
|
}
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
errors: {},
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const {errors, fieldName, fieldType} = this.props;
|
||||||
|
|
||||||
const functionBtn = <FunctionButtons
|
const functionBtn = <FunctionButtons
|
||||||
fieldSpec={this.props.fieldSpec}
|
fieldSpec={this.props.fieldSpec}
|
||||||
onZoomClick={this.props.onZoomClick}
|
onZoomClick={this.props.onZoomClick}
|
||||||
onDataClick={this.props.onDataClick}
|
onDataClick={this.props.onDataClick}
|
||||||
|
value={this.props.value}
|
||||||
|
onExpressionClick={this.props.onExpressionClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
const error = errors[fieldType+"."+fieldName];
|
||||||
|
|
||||||
return <InputBlock
|
return <InputBlock
|
||||||
doc={this.props.fieldSpec.doc}
|
error={error}
|
||||||
fieldSpec={this.props.fieldSpec}
|
fieldSpec={this.props.fieldSpec}
|
||||||
label={labelFromFieldName(this.props.fieldName)}
|
label={labelFromFieldName(this.props.fieldName)}
|
||||||
action={functionBtn}
|
action={functionBtn}
|
||||||
|
|
|
@ -42,8 +42,11 @@ export default class ZoomProperty extends React.Component {
|
||||||
onChange: PropTypes.func,
|
onChange: PropTypes.func,
|
||||||
onDeleteStop: PropTypes.func,
|
onDeleteStop: PropTypes.func,
|
||||||
onAddStop: PropTypes.func,
|
onAddStop: PropTypes.func,
|
||||||
|
onExpressionClick: PropTypes.func,
|
||||||
|
fieldType: PropTypes.string,
|
||||||
fieldName: PropTypes.string,
|
fieldName: PropTypes.string,
|
||||||
fieldSpec: PropTypes.object,
|
fieldSpec: PropTypes.object,
|
||||||
|
errors: PropTypes.object,
|
||||||
value: PropTypes.oneOfType([
|
value: PropTypes.oneOfType([
|
||||||
PropTypes.object,
|
PropTypes.object,
|
||||||
PropTypes.string,
|
PropTypes.string,
|
||||||
|
@ -53,6 +56,10 @@ export default class ZoomProperty extends React.Component {
|
||||||
]),
|
]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
errors: {},
|
||||||
|
}
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
refs: {}
|
refs: {}
|
||||||
}
|
}
|
||||||
|
@ -117,13 +124,26 @@ export default class ZoomProperty extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const {fieldName, fieldType, errors} = this.props;
|
||||||
|
|
||||||
const zoomFields = this.props.value.stops.map((stop, idx) => {
|
const zoomFields = this.props.value.stops.map((stop, idx) => {
|
||||||
const zoomLevel = stop[0]
|
const zoomLevel = stop[0]
|
||||||
const key = this.state.refs[idx];
|
const key = this.state.refs[idx];
|
||||||
const value = stop[1]
|
const value = stop[1]
|
||||||
const deleteStopBtn= <DeleteStopButton onClick={this.props.onDeleteStop.bind(this, idx)} />
|
const deleteStopBtn= <DeleteStopButton onClick={this.props.onDeleteStop.bind(this, idx)} />
|
||||||
|
|
||||||
|
const errorKeyStart = `${fieldType}.${fieldName}.stops[${idx}]`;
|
||||||
|
const foundErrors = Object.entries(errors).filter(([key, error]) => {
|
||||||
|
return key.startsWith(errorKeyStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = foundErrors.map(([key, error]) => {
|
||||||
|
return error.message;
|
||||||
|
}).join("");
|
||||||
|
const error = message ? {message} : undefined;
|
||||||
|
|
||||||
return <InputBlock
|
return <InputBlock
|
||||||
|
error={error}
|
||||||
key={key}
|
key={key}
|
||||||
fieldSpec={this.props.fieldSpec}
|
fieldSpec={this.props.fieldSpec}
|
||||||
label={labelFromFieldName(this.props.fieldName)}
|
label={labelFromFieldName(this.props.fieldName)}
|
||||||
|
@ -158,6 +178,12 @@ export default class ZoomProperty extends React.Component {
|
||||||
>
|
>
|
||||||
Add stop
|
Add stop
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="maputnik-add-stop"
|
||||||
|
onClick={this.props.onExpressionClick.bind(this)}
|
||||||
|
>
|
||||||
|
Convert to expression
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,13 @@
|
||||||
import capitalize from 'lodash.capitalize'
|
import capitalize from 'lodash.capitalize'
|
||||||
|
|
||||||
export default function labelFromFieldName(fieldName) {
|
export default function labelFromFieldName(fieldName) {
|
||||||
let label = fieldName.split('-').slice(1).join(' ')
|
let label;
|
||||||
return capitalize(label)
|
const parts = fieldName.split('-');
|
||||||
|
if (parts.length > 1) {
|
||||||
|
label = fieldName.split('-').slice(1).join(' ');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
label = fieldName;
|
||||||
|
}
|
||||||
|
return capitalize(label);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,13 +2,84 @@ 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';
|
||||||
|
import {mdiFunctionVariant} from '@mdi/js';
|
||||||
|
|
||||||
|
|
||||||
|
function combiningFilter (props) {
|
||||||
|
let filter = props.filter || ['all'];
|
||||||
|
|
||||||
|
if (!Array.isArray(filter)) {
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
let combiningOp = filter[0];
|
||||||
|
let filters = filter.slice(1);
|
||||||
|
|
||||||
|
if(combiningFilterOps.indexOf(combiningOp) < 0) {
|
||||||
|
combiningOp = 'all';
|
||||||
|
filters = [filter.slice(0)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [combiningOp, ...filters];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,46 +98,37 @@ 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.object,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor () {
|
static defaultProps = {
|
||||||
|
filter: ["all"],
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor (props) {
|
||||||
super();
|
super();
|
||||||
this.state = {
|
this.state = {
|
||||||
showDoc: false,
|
showDoc: false,
|
||||||
|
displaySimpleFilter: checkIfSimpleFilter(combiningFilter(props)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert filter to combining filter
|
// Convert filter to combining filter
|
||||||
combiningFilter() {
|
|
||||||
let filter = this.props.filter || ['all']
|
|
||||||
|
|
||||||
let combiningOp = filter[0]
|
|
||||||
let filters = filter.slice(1)
|
|
||||||
|
|
||||||
if(combiningFilterOps.indexOf(combiningOp) < 0) {
|
|
||||||
combiningOp = 'all'
|
|
||||||
filters = [filter.slice(0)]
|
|
||||||
}
|
|
||||||
|
|
||||||
return [combiningOp, ...filters]
|
|
||||||
}
|
|
||||||
|
|
||||||
onFilterPartChanged(filterIdx, newPart) {
|
onFilterPartChanged(filterIdx, newPart) {
|
||||||
const newFilter = this.combiningFilter().slice(0)
|
const newFilter = combiningFilter(this.props).slice(0)
|
||||||
newFilter[filterIdx] = newPart
|
newFilter[filterIdx] = newPart
|
||||||
this.props.onChange(newFilter)
|
this.props.onChange(newFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteFilterItem(filterIdx) {
|
deleteFilterItem(filterIdx) {
|
||||||
const newFilter = this.combiningFilter().slice(0)
|
const newFilter = combiningFilter(this.props).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)
|
||||||
}
|
}
|
||||||
|
|
||||||
addFilterItem = () => {
|
addFilterItem = () => {
|
||||||
const newFilterItem = this.combiningFilter().slice(0)
|
const newFilterItem = combiningFilter(this.props).slice(0)
|
||||||
newFilterItem.push(['==', 'name', ''])
|
newFilterItem.push(['==', 'name', ''])
|
||||||
this.props.onChange(newFilterItem)
|
this.props.onChange(newFilterItem)
|
||||||
}
|
}
|
||||||
|
@ -77,60 +139,171 @@ export default class CombiningFilterEditor extends React.Component {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
makeFilter = () => {
|
||||||
const filter = this.combiningFilter()
|
this.setState({
|
||||||
let combiningOp = filter[0]
|
displaySimpleFilter: true,
|
||||||
let filters = filter.slice(1)
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
makeExpression = () => {
|
||||||
|
let filter = combiningFilter(this.props);
|
||||||
|
this.props.onChange(migrateFilter(filter));
|
||||||
|
this.setState({
|
||||||
|
displaySimpleFilter: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromProps (props, currentState) {
|
||||||
|
const {filter} = props;
|
||||||
|
const displaySimpleFilter = checkIfSimpleFilter(combiningFilter(props));
|
||||||
|
|
||||||
|
// Upgrade but never downgrade
|
||||||
|
if (!displaySimpleFilter && currentState.displaySimpleFilter === true) {
|
||||||
|
return {
|
||||||
|
displaySimpleFilter: false,
|
||||||
|
valueIsSimpleFilter: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (displaySimpleFilter && currentState.displaySimpleFilter === false) {
|
||||||
|
return {
|
||||||
|
valueIsSimpleFilter: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return {
|
||||||
|
valueIsSimpleFilter: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {errors} = this.props;
|
||||||
|
const {displaySimpleFilter} = 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 editorBlocks = filters.map((f, idx) => {
|
const isNestedCombiningFilter = displaySimpleFilter && hasNestedCombiningFilter(combiningFilter(this.props));
|
||||||
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>
|
|
||||||
})
|
|
||||||
|
|
||||||
//TODO: Implement support for nested filter
|
if (isNestedCombiningFilter) {
|
||||||
if(hasNestedCombiningFilter(filter)) {
|
|
||||||
return <div className="maputnik-filter-editor-unsupported">
|
return <div className="maputnik-filter-editor-unsupported">
|
||||||
Nested filters are not supported.
|
<p>
|
||||||
</div>
|
Nested filters are not supported.
|
||||||
}
|
</p>
|
||||||
|
|
||||||
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}
|
|
||||||
fieldSpec={fieldSpec}
|
|
||||||
/>
|
|
||||||
<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
|
<Button
|
||||||
data-wd-key="layer-filter-button"
|
onClick={this.makeExpression}
|
||||||
className="maputnik-add-filter"
|
>
|
||||||
onClick={this.addFilterItem}>
|
<svg style={{marginRight: "0.2em", width:"14px", height:"14px", verticalAlign: "middle"}} viewBox="0 0 24 24">
|
||||||
Add filter
|
<path fill="currentColor" d={mdiFunctionVariant} />
|
||||||
|
</svg>
|
||||||
|
Upgrade to expression
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
}
|
||||||
className="maputnik-doc-inline"
|
else if (displaySimpleFilter) {
|
||||||
style={{display: this.state.showDoc ? '' : 'none'}}
|
const filter = combiningFilter(this.props);
|
||||||
>
|
let combiningOp = filter[0];
|
||||||
<SpecDoc fieldSpec={fieldSpec} />
|
let filters = filter.slice(1)
|
||||||
</div>
|
|
||||||
</div>
|
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={mdiFunctionVariant} />
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InputBlock
|
||||||
|
key="top"
|
||||||
|
fieldSpec={fieldSpec}
|
||||||
|
label={"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"]]}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
let {filter} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ExpressionProperty
|
||||||
|
onDelete={() => {
|
||||||
|
this.setState({displaySimpleFilter: true});
|
||||||
|
this.props.onChange(defaultFilter);
|
||||||
|
}}
|
||||||
|
fieldName="filter"
|
||||||
|
fieldSpec={fieldSpec}
|
||||||
|
value={filter}
|
||||||
|
errors={errors}
|
||||||
|
onChange={this.props.onChange}
|
||||||
|
/>
|
||||||
|
{this.state.valueIsSimpleFilter &&
|
||||||
|
<div className="maputnik-expr-infobox">
|
||||||
|
You've entered a old style filter,{' '}
|
||||||
|
<button
|
||||||
|
onClick={this.makeFilter}
|
||||||
|
className="maputnik-expr-infobox__button"
|
||||||
|
>
|
||||||
|
switch to filter editor
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import FillIcon from './FillIcon.jsx'
|
||||||
import SymbolIcon from './SymbolIcon.jsx'
|
import SymbolIcon from './SymbolIcon.jsx'
|
||||||
import BackgroundIcon from './BackgroundIcon.jsx'
|
import BackgroundIcon from './BackgroundIcon.jsx'
|
||||||
import CircleIcon from './CircleIcon.jsx'
|
import CircleIcon from './CircleIcon.jsx'
|
||||||
|
import MissingIcon from './MissingIcon.jsx'
|
||||||
|
|
||||||
class LayerIcon extends React.Component {
|
class LayerIcon extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
|
@ -25,6 +26,7 @@ class LayerIcon extends React.Component {
|
||||||
case 'line': return <LineIcon {...iconProps} />
|
case 'line': return <LineIcon {...iconProps} />
|
||||||
case 'symbol': return <SymbolIcon {...iconProps} />
|
case 'symbol': return <SymbolIcon {...iconProps} />
|
||||||
case 'circle': return <CircleIcon {...iconProps} />
|
case 'circle': return <CircleIcon {...iconProps} />
|
||||||
|
default: return <MissingIcon {...iconProps} />
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
11
src/components/icons/MissingIcon.jsx
Normal file
11
src/components/icons/MissingIcon.jsx
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import React from 'react'
|
||||||
|
import {MdPriorityHigh} from 'react-icons/md'
|
||||||
|
|
||||||
|
|
||||||
|
export default class MissingIcon extends React.Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<MdPriorityHigh {...this.props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,8 @@ class InputBlock extends React.Component {
|
||||||
style: PropTypes.object,
|
style: PropTypes.object,
|
||||||
onChange: PropTypes.func,
|
onChange: PropTypes.func,
|
||||||
fieldSpec: PropTypes.object,
|
fieldSpec: PropTypes.object,
|
||||||
|
wideMode: PropTypes.bool,
|
||||||
|
error: PropTypes.array,
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor (props) {
|
constructor (props) {
|
||||||
|
@ -39,10 +41,13 @@ class InputBlock extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const errors = [].concat(this.props.error || []);
|
||||||
|
|
||||||
return <div style={this.props.style}
|
return <div style={this.props.style}
|
||||||
data-wd-key={this.props["data-wd-key"]}
|
data-wd-key={this.props["data-wd-key"]}
|
||||||
className={classnames({
|
className={classnames({
|
||||||
"maputnik-input-block": true,
|
"maputnik-input-block": true,
|
||||||
|
"maputnik-input-block--wide": this.props.wideMode,
|
||||||
"maputnik-action-block": this.props.action
|
"maputnik-action-block": this.props.action
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
@ -68,6 +73,13 @@ class InputBlock extends React.Component {
|
||||||
<div className="maputnik-input-block-content">
|
<div className="maputnik-input-block-content">
|
||||||
{this.props.children}
|
{this.props.children}
|
||||||
</div>
|
</div>
|
||||||
|
{errors.length > 0 &&
|
||||||
|
<div className="maputnik-inline-error">
|
||||||
|
{[].concat(this.props.error).map((error, idx) => {
|
||||||
|
return <div key={idx}>{error.message}</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
{this.props.fieldSpec &&
|
{this.props.fieldSpec &&
|
||||||
<div
|
<div
|
||||||
className="maputnik-doc-inline"
|
className="maputnik-doc-inline"
|
||||||
|
|
|
@ -11,6 +11,8 @@ class StringInput extends React.Component {
|
||||||
onInput: PropTypes.func,
|
onInput: PropTypes.func,
|
||||||
multi: PropTypes.bool,
|
multi: PropTypes.bool,
|
||||||
required: PropTypes.bool,
|
required: PropTypes.bool,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
spellCheck: PropTypes.bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
@ -51,9 +53,14 @@ class StringInput extends React.Component {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(!!this.props.disabled) {
|
||||||
|
classes.push("maputnik-string--disabled");
|
||||||
|
}
|
||||||
|
|
||||||
return React.createElement(tag, {
|
return React.createElement(tag, {
|
||||||
"data-wd-key": this.props["data-wd-key"],
|
"data-wd-key": this.props["data-wd-key"],
|
||||||
spellCheck: !(tag === "input"),
|
spellCheck: this.props.hasOwnProperty("spellCheck") ? this.props.spellCheck : !(tag === "input"),
|
||||||
|
disabled: this.props.disabled,
|
||||||
className: classes.join(" "),
|
className: classes.join(" "),
|
||||||
style: this.props.style,
|
style: this.props.style,
|
||||||
value: this.state.value === undefined ? "" : this.state.value,
|
value: this.state.value === undefined ? "" : this.state.value,
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import PropTypes from 'prop-types'
|
import PropTypes from 'prop-types'
|
||||||
|
import classnames from 'classnames';
|
||||||
|
|
||||||
import InputBlock from '../inputs/InputBlock'
|
import InputBlock from '../inputs/InputBlock'
|
||||||
import StringInput from '../inputs/StringInput'
|
import StringInput from '../inputs/StringInput'
|
||||||
|
@ -11,44 +12,68 @@ import 'codemirror/addon/edit/matchbrackets'
|
||||||
import 'codemirror/lib/codemirror.css'
|
import 'codemirror/lib/codemirror.css'
|
||||||
import 'codemirror/addon/lint/lint.css'
|
import 'codemirror/addon/lint/lint.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
|
import '../util/codemirror-mgl';
|
||||||
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,
|
||||||
|
lineWrapping: PropTypes.bool,
|
||||||
|
getValue: PropTypes.func,
|
||||||
|
gutters: PropTypes.array,
|
||||||
|
className: PropTypes.string,
|
||||||
|
onFocus: PropTypes.func,
|
||||||
|
onBlur: PropTypes.func,
|
||||||
|
onJSONValid: PropTypes.func,
|
||||||
|
onJSONInvalid: PropTypes.func,
|
||||||
|
mode: PropTypes.object,
|
||||||
|
lint: PropTypes.oneOfType([
|
||||||
|
PropTypes.bool,
|
||||||
|
PropTypes.object,
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
lineNumbers: true,
|
||||||
|
lineWrapping: false,
|
||||||
|
gutters: ["CodeMirror-lint-markers"],
|
||||||
|
getValue: (data) => {
|
||||||
|
return stringifyPretty(data, {indent: 2, maxLength: 40});
|
||||||
|
},
|
||||||
|
onFocus: () => {},
|
||||||
|
onBlur: () => {},
|
||||||
|
onJSONInvalid: () => {},
|
||||||
|
onJSONValid: () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props)
|
super(props)
|
||||||
this.state = {
|
this.state = {
|
||||||
isEditing: false,
|
isEditing: false,
|
||||||
prevValue: this.getValue(),
|
prevValue: this.props.getValue(this.props.layer),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getValue () {
|
|
||||||
return JSON.stringify(this.props.layer, null, 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
this._doc = CodeMirror(this._el, {
|
this._doc = CodeMirror(this._el, {
|
||||||
value: this.getValue(),
|
value: this.props.getValue(this.props.layer),
|
||||||
mode: {
|
mode: this.props.mode || {
|
||||||
name: "javascript",
|
name: "mgl",
|
||||||
json: true
|
|
||||||
},
|
},
|
||||||
|
lineWrapping: this.props.lineWrapping,
|
||||||
tabSize: 2,
|
tabSize: 2,
|
||||||
theme: 'maputnik',
|
theme: 'maputnik',
|
||||||
viewportMargin: Infinity,
|
viewportMargin: Infinity,
|
||||||
lineNumbers: true,
|
lineNumbers: this.props.lineNumbers,
|
||||||
lint: true,
|
lint: this.props.lint || {
|
||||||
|
context: "layer"
|
||||||
|
},
|
||||||
matchBrackets: true,
|
matchBrackets: true,
|
||||||
gutters: ["CodeMirror-lint-markers"],
|
gutters: this.props.gutters,
|
||||||
scrollbarStyle: "null",
|
scrollbarStyle: "null",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -58,12 +83,14 @@ class JSONEditor extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
onFocus = () => {
|
onFocus = () => {
|
||||||
|
this.props.onFocus();
|
||||||
this.setState({
|
this.setState({
|
||||||
isEditing: true
|
isEditing: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onBlur = () => {
|
onBlur = () => {
|
||||||
|
this.props.onBlur();
|
||||||
this.setState({
|
this.setState({
|
||||||
isEditing: false
|
isEditing: false
|
||||||
});
|
});
|
||||||
|
@ -79,7 +106,7 @@ class JSONEditor extends React.Component {
|
||||||
if (!this.state.isEditing && prevProps.layer !== this.props.layer) {
|
if (!this.state.isEditing && prevProps.layer !== this.props.layer) {
|
||||||
this._cancelNextChange = true;
|
this._cancelNextChange = true;
|
||||||
this._doc.setValue(
|
this._doc.setValue(
|
||||||
this.getValue(),
|
this.props.getValue(this.props.layer),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -87,16 +114,28 @@ class JSONEditor extends React.Component {
|
||||||
onChange = (e) => {
|
onChange = (e) => {
|
||||||
if (this._cancelNextChange) {
|
if (this._cancelNextChange) {
|
||||||
this._cancelNextChange = false;
|
this._cancelNextChange = false;
|
||||||
|
this.setState({
|
||||||
|
prevValue: this._doc.getValue(),
|
||||||
|
})
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newCode = this._doc.getValue();
|
const newCode = this._doc.getValue();
|
||||||
|
|
||||||
if (this.state.prevValue !== newCode) {
|
if (this.state.prevValue !== newCode) {
|
||||||
|
let parsedLayer, err;
|
||||||
try {
|
try {
|
||||||
const parsedLayer = JSON.parse(newCode)
|
parsedLayer = JSON.parse(newCode);
|
||||||
|
} catch(_err) {
|
||||||
|
err = _err;
|
||||||
|
console.warn(_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
this.props.onJSONInvalid();
|
||||||
|
}
|
||||||
|
else {
|
||||||
this.props.onChange(parsedLayer)
|
this.props.onChange(parsedLayer)
|
||||||
} catch(err) {
|
this.props.onJSONValid();
|
||||||
console.warn(err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,7 +151,7 @@ class JSONEditor extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div
|
return <div
|
||||||
className="codemirror-container"
|
className={classnames("codemirror-container", this.props.className)}
|
||||||
ref={(el) => this._el = el}
|
ref={(el) => this._el = el}
|
||||||
style={style}
|
style={style}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -20,6 +20,10 @@ import { changeType, changeProperty } from '../../libs/layer'
|
||||||
import layout from '../../config/layout.json'
|
import layout from '../../config/layout.json'
|
||||||
|
|
||||||
|
|
||||||
|
function getLayoutForType (type) {
|
||||||
|
return layout[type] ? layout[type] : layout.invalid;
|
||||||
|
}
|
||||||
|
|
||||||
function layoutGroups(layerType) {
|
function layoutGroups(layerType) {
|
||||||
const layerGroup = {
|
const layerGroup = {
|
||||||
title: 'Layer',
|
title: 'Layer',
|
||||||
|
@ -33,7 +37,9 @@ function layoutGroups(layerType) {
|
||||||
title: 'JSON Editor',
|
title: 'JSON Editor',
|
||||||
type: 'jsoneditor'
|
type: 'jsoneditor'
|
||||||
}
|
}
|
||||||
return [layerGroup, filterGroup].concat(layout[layerType].groups).concat([editorGroup])
|
return [layerGroup, filterGroup]
|
||||||
|
.concat(getLayoutForType(layerType).groups)
|
||||||
|
.concat([editorGroup])
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Layer editor supporting multiple types of layers. */
|
/** Layer editor supporting multiple types of layers. */
|
||||||
|
@ -52,6 +58,7 @@ export default class LayerEditor extends React.Component {
|
||||||
isFirstLayer: PropTypes.bool,
|
isFirstLayer: PropTypes.bool,
|
||||||
isLastLayer: PropTypes.bool,
|
isLastLayer: PropTypes.bool,
|
||||||
layerIndex: PropTypes.number,
|
layerIndex: PropTypes.number,
|
||||||
|
errors: PropTypes.array,
|
||||||
}
|
}
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
@ -79,7 +86,7 @@ export default class LayerEditor extends React.Component {
|
||||||
static getDerivedStateFromProps(props, state) {
|
static getDerivedStateFromProps(props, state) {
|
||||||
const additionalGroups = { ...state.editorGroups }
|
const additionalGroups = { ...state.editorGroups }
|
||||||
|
|
||||||
layout[props.layer.type].groups.forEach(group => {
|
getLayoutForType(props.layer.type).groups.forEach(group => {
|
||||||
if(!(group.title in additionalGroups)) {
|
if(!(group.title in additionalGroups)) {
|
||||||
additionalGroups[group.title] = true
|
additionalGroups[group.title] = true
|
||||||
}
|
}
|
||||||
|
@ -118,6 +125,20 @@ export default class LayerEditor extends React.Component {
|
||||||
if(this.props.layer.metadata) {
|
if(this.props.layer.metadata) {
|
||||||
comment = this.props.layer.metadata['maputnik:comment']
|
comment = this.props.layer.metadata['maputnik:comment']
|
||||||
}
|
}
|
||||||
|
const {errors, layerIndex} = this.props;
|
||||||
|
|
||||||
|
const errorData = {};
|
||||||
|
errors.forEach(error => {
|
||||||
|
if (
|
||||||
|
error.parsed &&
|
||||||
|
error.parsed.type === "layer" &&
|
||||||
|
error.parsed.data.index == layerIndex
|
||||||
|
) {
|
||||||
|
errorData[error.parsed.data.key] = {
|
||||||
|
message: error.parsed.data.message
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
let sourceLayerIds;
|
let sourceLayerIds;
|
||||||
if(this.props.sources.hasOwnProperty(this.props.layer.source)) {
|
if(this.props.sources.hasOwnProperty(this.props.layer.source)) {
|
||||||
|
@ -129,13 +150,17 @@ export default class LayerEditor extends React.Component {
|
||||||
<LayerIdBlock
|
<LayerIdBlock
|
||||||
value={this.props.layer.id}
|
value={this.props.layer.id}
|
||||||
wdKey="layer-editor.layer-id"
|
wdKey="layer-editor.layer-id"
|
||||||
|
error={errorData.id}
|
||||||
onChange={newId => this.props.onLayerIdChange(this.props.layer.id, newId)}
|
onChange={newId => this.props.onLayerIdChange(this.props.layer.id, newId)}
|
||||||
/>
|
/>
|
||||||
<LayerTypeBlock
|
<LayerTypeBlock
|
||||||
|
disabled={true}
|
||||||
|
error={errorData.type}
|
||||||
value={this.props.layer.type}
|
value={this.props.layer.type}
|
||||||
onChange={newType => this.props.onLayerChanged(changeType(this.props.layer, newType))}
|
onChange={newType => this.props.onLayerChanged(changeType(this.props.layer, newType))}
|
||||||
/>
|
/>
|
||||||
{this.props.layer.type !== 'background' && <LayerSourceBlock
|
{this.props.layer.type !== 'background' && <LayerSourceBlock
|
||||||
|
error={errorData.sources}
|
||||||
sourceIds={Object.keys(this.props.sources)}
|
sourceIds={Object.keys(this.props.sources)}
|
||||||
value={this.props.layer.source}
|
value={this.props.layer.source}
|
||||||
onChange={v => this.changeProperty(null, 'source', v)}
|
onChange={v => this.changeProperty(null, 'source', v)}
|
||||||
|
@ -143,20 +168,24 @@ export default class LayerEditor extends React.Component {
|
||||||
}
|
}
|
||||||
{['background', 'raster', 'hillshade', 'heatmap'].indexOf(this.props.layer.type) < 0 &&
|
{['background', 'raster', 'hillshade', 'heatmap'].indexOf(this.props.layer.type) < 0 &&
|
||||||
<LayerSourceLayerBlock
|
<LayerSourceLayerBlock
|
||||||
|
error={errorData['source-layer']}
|
||||||
sourceLayerIds={sourceLayerIds}
|
sourceLayerIds={sourceLayerIds}
|
||||||
value={this.props.layer['source-layer']}
|
value={this.props.layer['source-layer']}
|
||||||
onChange={v => this.changeProperty(null, 'source-layer', v)}
|
onChange={v => this.changeProperty(null, 'source-layer', v)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
<MinZoomBlock
|
<MinZoomBlock
|
||||||
|
error={errorData.minzoom}
|
||||||
value={this.props.layer.minzoom}
|
value={this.props.layer.minzoom}
|
||||||
onChange={v => this.changeProperty(null, 'minzoom', v)}
|
onChange={v => this.changeProperty(null, 'minzoom', v)}
|
||||||
/>
|
/>
|
||||||
<MaxZoomBlock
|
<MaxZoomBlock
|
||||||
|
error={errorData.maxzoom}
|
||||||
value={this.props.layer.maxzoom}
|
value={this.props.layer.maxzoom}
|
||||||
onChange={v => this.changeProperty(null, 'maxzoom', v)}
|
onChange={v => this.changeProperty(null, 'maxzoom', v)}
|
||||||
/>
|
/>
|
||||||
<CommentBlock
|
<CommentBlock
|
||||||
|
error={errorData.comment}
|
||||||
value={comment}
|
value={comment}
|
||||||
onChange={v => this.changeProperty('metadata', 'maputnik:comment', v == "" ? undefined : v)}
|
onChange={v => this.changeProperty('metadata', 'maputnik:comment', v == "" ? undefined : v)}
|
||||||
/>
|
/>
|
||||||
|
@ -164,6 +193,7 @@ export default class LayerEditor extends React.Component {
|
||||||
case 'filter': return <div>
|
case 'filter': return <div>
|
||||||
<div className="maputnik-filter-editor-wrapper">
|
<div className="maputnik-filter-editor-wrapper">
|
||||||
<FilterEditor
|
<FilterEditor
|
||||||
|
errors={errorData}
|
||||||
filter={this.props.layer.filter}
|
filter={this.props.layer.filter}
|
||||||
properties={this.props.vectorLayers[this.props.layer['source-layer']]}
|
properties={this.props.vectorLayers[this.props.layer['source-layer']]}
|
||||||
onChange={f => this.changeProperty(null, 'filter', f)}
|
onChange={f => this.changeProperty(null, 'filter', f)}
|
||||||
|
@ -171,6 +201,7 @@ export default class LayerEditor extends React.Component {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
case 'properties': return <PropertyGroup
|
case 'properties': return <PropertyGroup
|
||||||
|
errors={errorData}
|
||||||
layer={this.props.layer}
|
layer={this.props.layer}
|
||||||
groupFields={fields}
|
groupFields={fields}
|
||||||
spec={this.props.spec}
|
spec={this.props.spec}
|
||||||
|
|
|
@ -181,10 +181,19 @@ class LayerListContainer extends React.Component {
|
||||||
layers.forEach((layer, idxInGroup) => {
|
layers.forEach((layer, idxInGroup) => {
|
||||||
const groupIdx = findClosestCommonPrefix(this.props.layers, idx)
|
const groupIdx = findClosestCommonPrefix(this.props.layers, idx)
|
||||||
|
|
||||||
|
const layerError = this.props.errors.find(error => {
|
||||||
|
return (
|
||||||
|
error.parsed &&
|
||||||
|
error.parsed.type === "layer" &&
|
||||||
|
error.parsed.data.index == idx
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const listItem = <LayerListItem
|
const listItem = <LayerListItem
|
||||||
className={classnames({
|
className={classnames({
|
||||||
'maputnik-layer-list-item-collapsed': layers.length > 1 && this.isCollapsed(groupPrefix, groupIdx) && idx !== this.props.selectedLayerIndex,
|
'maputnik-layer-list-item-collapsed': layers.length > 1 && this.isCollapsed(groupPrefix, groupIdx) && idx !== this.props.selectedLayerIndex,
|
||||||
'maputnik-layer-list-item-group-last': idxInGroup == layers.length - 1 && layers.length > 1
|
'maputnik-layer-list-item-group-last': idxInGroup == layers.length - 1 && layers.length > 1,
|
||||||
|
'maputnik-layer-list-item--error': !!layerError
|
||||||
})}
|
})}
|
||||||
index={idx}
|
index={idx}
|
||||||
key={layer.id}
|
key={layer.id}
|
||||||
|
|
|
@ -4,33 +4,49 @@ import PropTypes from 'prop-types'
|
||||||
import {latest} from '@mapbox/mapbox-gl-style-spec'
|
import {latest} from '@mapbox/mapbox-gl-style-spec'
|
||||||
import InputBlock from '../inputs/InputBlock'
|
import InputBlock from '../inputs/InputBlock'
|
||||||
import SelectInput from '../inputs/SelectInput'
|
import SelectInput from '../inputs/SelectInput'
|
||||||
|
import StringInput from '../inputs/StringInput'
|
||||||
|
|
||||||
class LayerTypeBlock extends React.Component {
|
class LayerTypeBlock extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
value: PropTypes.string.isRequired,
|
value: PropTypes.string.isRequired,
|
||||||
wdKey: PropTypes.string,
|
wdKey: PropTypes.string,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
|
error: PropTypes.object,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
disabled: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <InputBlock label={"Type"} fieldSpec={latest.layer.type}
|
return <InputBlock label={"Type"} fieldSpec={latest.layer.type}
|
||||||
data-wd-key={this.props.wdKey}
|
data-wd-key={this.props.wdKey}
|
||||||
|
error={this.props.error}
|
||||||
>
|
>
|
||||||
<SelectInput
|
{this.props.disabled &&
|
||||||
options={[
|
<StringInput
|
||||||
['background', 'Background'],
|
value={this.props.value}
|
||||||
['fill', 'Fill'],
|
disabled={true}
|
||||||
['line', 'Line'],
|
/>
|
||||||
['symbol', 'Symbol'],
|
}
|
||||||
['raster', 'Raster'],
|
{!this.props.disabled &&
|
||||||
['circle', 'Circle'],
|
<SelectInput
|
||||||
['fill-extrusion', 'Fill Extrusion'],
|
options={[
|
||||||
['hillshade', 'Hillshade'],
|
['background', 'Background'],
|
||||||
['heatmap', 'Heatmap'],
|
['fill', 'Fill'],
|
||||||
]}
|
['line', 'Line'],
|
||||||
onChange={this.props.onChange}
|
['symbol', 'Symbol'],
|
||||||
value={this.props.value}
|
['raster', 'Raster'],
|
||||||
/>
|
['circle', 'Circle'],
|
||||||
|
['fill-extrusion', 'Fill Extrusion'],
|
||||||
|
['hillshade', 'Hillshade'],
|
||||||
|
['heatmap', 'Heatmap'],
|
||||||
|
]}
|
||||||
|
onChange={this.props.onChange}
|
||||||
|
value={this.props.value}
|
||||||
|
/>
|
||||||
|
}
|
||||||
</InputBlock>
|
</InputBlock>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,10 +9,12 @@ class MaxZoomBlock extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
value: PropTypes.number,
|
value: PropTypes.number,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
|
error: PropTypes.object,
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <InputBlock label={"Max Zoom"} fieldSpec={latest.layer.maxzoom}
|
return <InputBlock label={"Max Zoom"} fieldSpec={latest.layer.maxzoom}
|
||||||
|
error={this.props.error}
|
||||||
data-wd-key="max-zoom"
|
data-wd-key="max-zoom"
|
||||||
>
|
>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
|
|
|
@ -9,10 +9,12 @@ class MinZoomBlock extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
value: PropTypes.number,
|
value: PropTypes.number,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
|
error: PropTypes.object,
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return <InputBlock label={"Min Zoom"} fieldSpec={latest.layer.minzoom}
|
return <InputBlock label={"Min Zoom"} fieldSpec={latest.layer.minzoom}
|
||||||
|
error={this.props.error}
|
||||||
data-wd-key="min-zoom"
|
data-wd-key="min-zoom"
|
||||||
>
|
>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
|
|
154
src/components/util/codemirror-mgl.js
Normal file
154
src/components/util/codemirror-mgl.js
Normal file
|
@ -0,0 +1,154 @@
|
||||||
|
import jsonlint from 'jsonlint';
|
||||||
|
import CodeMirror from 'codemirror';
|
||||||
|
import jsonToAst from 'json-to-ast';
|
||||||
|
import {expression, validate, latest} from '@mapbox/mapbox-gl-style-spec';
|
||||||
|
|
||||||
|
|
||||||
|
CodeMirror.defineMode("mgl", function(config, parserConfig) {
|
||||||
|
// Just using the javascript mode with json enabled. Our logic is in the linter below.
|
||||||
|
return CodeMirror.modes.javascript(
|
||||||
|
{...config, json: true},
|
||||||
|
parserConfig
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
CodeMirror.registerHelper("lint", "mgl", function(text, opts, doc) {
|
||||||
|
const found = [];
|
||||||
|
const {parser} = jsonlint;
|
||||||
|
const {context} = opts;
|
||||||
|
|
||||||
|
parser.parseError = function(str, hash) {
|
||||||
|
const loc = hash.loc;
|
||||||
|
found.push({
|
||||||
|
from: CodeMirror.Pos(loc.first_line - 1, loc.first_column),
|
||||||
|
to: CodeMirror.Pos(loc.last_line - 1, loc.last_column),
|
||||||
|
message: str
|
||||||
|
});
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
parser.parse(text);
|
||||||
|
}
|
||||||
|
catch (e) {}
|
||||||
|
|
||||||
|
if (found.length > 0) {
|
||||||
|
// JSON invalid so don't go any further
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ast = jsonToAst(text);
|
||||||
|
const input = JSON.parse(text);
|
||||||
|
|
||||||
|
function getArrayPositionalFromAst (node, path) {
|
||||||
|
if (!node) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
else if (path.length < 1) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
else if (!node.children) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const key = path[0];
|
||||||
|
let newNode;
|
||||||
|
if (key.match(/^[0-9]+$/)) {
|
||||||
|
newNode = node.children[path[0]];
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
newNode = node.children.find(childNode => {
|
||||||
|
return (
|
||||||
|
childNode.key &&
|
||||||
|
childNode.key.type === "Identifier" &&
|
||||||
|
childNode.key.value === key
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (newNode) {
|
||||||
|
newNode = newNode.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return getArrayPositionalFromAst(newNode, path.slice(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let out;
|
||||||
|
if (context === "layer") {
|
||||||
|
// Just an empty style so we can validate a layer.
|
||||||
|
const errors = validate({
|
||||||
|
"version": 8,
|
||||||
|
"name": "Empty Style",
|
||||||
|
"metadata": {},
|
||||||
|
"sources": {},
|
||||||
|
"sprite": "",
|
||||||
|
"glyphs": "https://example.com/glyphs/{fontstack}/{range}.pbf",
|
||||||
|
"layers": [
|
||||||
|
input
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (errors) {
|
||||||
|
out = {
|
||||||
|
result: "error",
|
||||||
|
value: errors
|
||||||
|
.filter(err => {
|
||||||
|
// Remove missing 'layer source' errors, because we don't include them
|
||||||
|
if (err.message.match(/^layers\[0\]: source ".*" not found$/)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.map(err => {
|
||||||
|
// Remove the 'layers[0].' as we're validating the layer only here
|
||||||
|
const errMessageParts = err.message.replace(/^layers\[0\]./, "").split(":");
|
||||||
|
return {
|
||||||
|
key: errMessageParts[0],
|
||||||
|
message: errMessageParts[1],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (context === "expression") {
|
||||||
|
out = expression.createExpression(input, opts.spec);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new Error(`Invalid context ${context}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (out.result === "error") {
|
||||||
|
const errors = out.value;
|
||||||
|
errors.forEach(error => {
|
||||||
|
const {key, message} = error;
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
const lastLineHandle = doc.getLineHandle(doc.lastLine());
|
||||||
|
const err = {
|
||||||
|
from: CodeMirror.Pos(doc.firstLine(), 0),
|
||||||
|
to: CodeMirror.Pos(doc.lastLine(), lastLineHandle.text.length),
|
||||||
|
message: message,
|
||||||
|
}
|
||||||
|
found.push(err);
|
||||||
|
}
|
||||||
|
else if (key) {
|
||||||
|
const path = key.replace(/^\[|\]$/g, "").split(/\.|[\[\]]+/).filter(Boolean)
|
||||||
|
const parsedError = getArrayPositionalFromAst(ast, path);
|
||||||
|
if (!parsedError) {
|
||||||
|
console.warn("Something went wrong parsing error:", error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {loc} = parsedError;
|
||||||
|
const {start, end} = loc;
|
||||||
|
|
||||||
|
found.push({
|
||||||
|
from: CodeMirror.Pos(start.line - 1, start.column),
|
||||||
|
to: CodeMirror.Pos(end.line - 1, end.column),
|
||||||
|
message: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
});
|
|
@ -233,5 +233,8 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"invalid": {
|
||||||
|
"groups": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -146,7 +146,7 @@
|
||||||
.maputnik-icon-button {
|
.maputnik-icon-button {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|
||||||
&:hover {
|
&:hover:not(:disabled) {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|
||||||
label,
|
label,
|
||||||
|
@ -182,18 +182,26 @@
|
||||||
.maputnik-action-block {
|
.maputnik-action-block {
|
||||||
.maputnik-input-block-label {
|
.maputnik-input-block-label {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 35%;
|
width: 32%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.maputnik-input-block-action {
|
.maputnik-input-block-action {
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 15%;
|
width: 18%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.maputnik-input-block-action > div {
|
.maputnik-input-block-action > div {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.maputnik-data-spec-block,
|
||||||
|
.maputnik-zoom-spec-property {
|
||||||
|
.maputnik-inline-error {
|
||||||
|
margin-left: 32%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SPACE HELPER
|
// SPACE HELPER
|
||||||
|
@ -208,6 +216,12 @@
|
||||||
&-error {
|
&-error {
|
||||||
color: $color-red;
|
color: $color-red;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__switch-button {
|
||||||
|
all: unset;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.maputnik-dialog {
|
.maputnik-dialog {
|
||||||
|
@ -238,3 +252,51 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.maputnik-inline-error {
|
||||||
|
color: #a4a4a4;
|
||||||
|
padding: 0.4em 0.4em;
|
||||||
|
font-size: 0.9em;
|
||||||
|
border: solid 1px $color-red;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: $margin-2 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maputnik-expression-editor {
|
||||||
|
border: solid 1px $color-gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maputnik-input-block--wide {
|
||||||
|
.maputnik-input-block-content {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maputnik-input-block-label {
|
||||||
|
width: 82%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maputnik-input-block-action {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.maputnik-expr-infobox {
|
||||||
|
font-size: $font-size-6;
|
||||||
|
background: $color-midgray;
|
||||||
|
padding: $margin-2;
|
||||||
|
border-radius: 2px;
|
||||||
|
border-top-right-radius: 0px;
|
||||||
|
border-top-left-radius: 0px;
|
||||||
|
color: $color-white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.maputnik-expr-infobox__button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
color: currentColor;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -25,6 +25,11 @@
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
height: 78px;
|
height: 78px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.maputnik-number-container {
|
.maputnik-number-container {
|
||||||
|
|
|
@ -99,6 +99,11 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.maputnik-layer-list-item--error {
|
||||||
|
color: $color-red;
|
||||||
|
}
|
||||||
|
|
||||||
&-item-selected {
|
&-item-selected {
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,7 +38,6 @@
|
||||||
|
|
||||||
&-bottom {
|
&-bottom {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
height: 50px;
|
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
|
@ -2,9 +2,8 @@
|
||||||
.maputnik-make-zoom-function {
|
.maputnik-make-zoom-function {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding-bottom: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
padding: 0 $margin-2 0 0;
|
||||||
|
|
||||||
@extend .maputnik-icon-button;
|
@extend .maputnik-icon-button;
|
||||||
}
|
}
|
||||||
|
@ -63,9 +62,8 @@
|
||||||
.maputnik-make-data-function {
|
.maputnik-make-data-function {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding-bottom: 0;
|
|
||||||
padding-top: 0;
|
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
padding: 0 $margin-2 0 0;
|
||||||
|
|
||||||
@extend .maputnik-icon-button;
|
@extend .maputnik-icon-button;
|
||||||
}
|
}
|
||||||
|
@ -98,10 +96,6 @@
|
||||||
.maputnik-data-spec-property-input {
|
.maputnik-data-spec-property-input {
|
||||||
width: 75%;
|
width: 75%;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
.maputnik-string {
|
|
||||||
margin-bottom: 3%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,3 +34,4 @@
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
31
src/vendor/codemirror/addon/lint/json-lint.js
vendored
31
src/vendor/codemirror/addon/lint/json-lint.js
vendored
|
@ -1,31 +0,0 @@
|
||||||
// CodeMirror, copyright (c) by Marijn Haverbeke and others
|
|
||||||
// Distributed under an MIT license: http://codemirror.net/LICENSE
|
|
||||||
|
|
||||||
// Depends on fork of jsonlint from <https://github.com/josdejong/jsonlint>
|
|
||||||
// becuase of <https://github.com/zaach/jsonlint/issues/57>
|
|
||||||
var jsonlint = require("jsonlint");
|
|
||||||
var CodeMirror = require("codemirror");
|
|
||||||
|
|
||||||
CodeMirror.registerHelper("lint", "json", function(text) {
|
|
||||||
var found = [];
|
|
||||||
|
|
||||||
// NOTE: This was modified from the original to remove the global, also the
|
|
||||||
// old jsonlint API was 'jsonlint.parseError' its now
|
|
||||||
// 'jsonlint.parser.parseError'
|
|
||||||
jsonlint.parser.parseError = function(str, hash) {
|
|
||||||
var loc = hash.loc;
|
|
||||||
found.push({
|
|
||||||
from: CodeMirror.Pos(loc.first_line - 1, loc.first_column),
|
|
||||||
to: CodeMirror.Pos(loc.last_line - 1, loc.last_column),
|
|
||||||
message: str
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
jsonlint.parse(text);
|
|
||||||
}
|
|
||||||
catch(e) {
|
|
||||||
// Do nothing we catch the error above
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
});
|
|
Loading…
Reference in a new issue