Merge remote-tracking branch 'upstream/master' into fix/issue-509

This commit is contained in:
orangemug
2019-06-20 20:40:22 +01:00
22 changed files with 935 additions and 229 deletions

View File

@@ -2,6 +2,7 @@ import autoBind from 'react-autobind';
import React from 'react'
import cloneDeep from 'lodash.clonedeep'
import clamp from 'lodash.clamp'
import get from 'lodash.get'
import {arrayMove} from 'react-sortable-hoc'
import url from 'url'
@@ -19,6 +20,7 @@ import SourcesModal from './modals/SourcesModal'
import OpenModal from './modals/OpenModal'
import ShortcutsModal from './modals/ShortcutsModal'
import SurveyModal from './modals/SurveyModal'
import DebugModal from './modals/DebugModal'
import { downloadGlyphsMetadata, downloadSpriteMetadata } from '../libs/metadata'
import {latest, validate} from '@mapbox/mapbox-gl-style-spec'
@@ -139,6 +141,12 @@ export default class App extends React.Component {
document.querySelector(".mapboxgl-canvas").focus();
}
},
{
key: "!",
handler: () => {
this.toggleModal("debug");
}
},
]
document.body.addEventListener("keyup", (e) => {
@@ -203,12 +211,16 @@ export default class App extends React.Component {
open: false,
shortcuts: false,
export: false,
survey: localStorage.hasOwnProperty('survey') ? false : true
survey: localStorage.hasOwnProperty('survey') ? false : true,
debug: false,
},
mapOptions: {
showTileBoundaries: queryUtil.asBool(queryObj, "show-tile-boundaries"),
showCollisionBoxes: queryUtil.asBool(queryObj, "show-collision-boxes"),
showOverdrawInspector: queryUtil.asBool(queryObj, "show-overdraw-inspector")
mapboxGlDebugOptions: {
showTileBoundaries: false,
showCollisionBoxes: false,
showOverdrawInspector: false,
},
openlayersDebugOptions: {
debugToolbox: false,
},
}
@@ -217,20 +229,24 @@ export default class App extends React.Component {
})
}
handleKeyPress(e) {
handleKeyPress = (e) => {
if(navigator.platform.toUpperCase().indexOf('MAC') >= 0) {
if(e.metaKey && e.shiftKey && e.keyCode === 90) {
e.preventDefault();
this.onRedo(e);
}
else if(e.metaKey && e.keyCode === 90) {
e.preventDefault();
this.onUndo(e);
}
}
else {
if(e.ctrlKey && e.keyCode === 90) {
e.preventDefault();
this.onUndo(e);
}
else if(e.ctrlKey && e.keyCode === 89) {
e.preventDefault();
this.onRedo(e);
}
}
@@ -264,6 +280,27 @@ export default class App extends React.Component {
})
}
onChangeMetadataProperty = (property, value) => {
// If we're changing renderer reset the map state.
if (
property === 'maputnik:renderer' &&
value !== get(this.state.mapStyle, ['metadata', 'maputnik:renderer'], 'mbgljs')
) {
this.setState({
mapState: 'map'
});
}
const changedStyle = {
...this.state.mapStyle,
metadata: {
...this.state.mapStyle.metadata,
[property]: value
}
}
this.onStyleChanged(changedStyle)
}
onStyleChanged = (newStyle, save=true) => {
const errors = validate(newStyle, latest)
@@ -397,6 +434,27 @@ export default class App extends React.Component {
})
}
setDefaultValues = (styleObj) => {
const metadata = styleObj.metadata || {}
if(metadata['maputnik:renderer'] === undefined) {
const changedStyle = {
...styleObj,
metadata: {
...styleObj.metadata,
'maputnik:renderer': 'mbgljs'
}
}
return changedStyle
} else {
return styleObj
}
}
openStyle = (styleObj) => {
styleObj = this.setDefaultValues(styleObj)
this.onStyleChanged(styleObj)
}
fetchSources() {
const sourceList = {...this.state.sources};
@@ -461,18 +519,23 @@ export default class App extends React.Component {
}
}
_getRenderer () {
const metadata = this.state.mapStyle.metadata || {};
return metadata['maputnik:renderer'] || 'mbgljs';
}
mapRenderer() {
const metadata = this.state.mapStyle.metadata || {};
const mapProps = {
mapStyle: style.replaceAccessTokens(this.state.mapStyle, {allowFallback: true}),
options: this.state.mapOptions,
onDataChange: (e) => {
this.layerWatcher.analyzeMap(e.map)
this.fetchSources();
},
}
const metadata = this.state.mapStyle.metadata || {}
const renderer = metadata['maputnik:renderer'] || 'mbgljs'
const renderer = this._getRenderer();
let mapElement;
@@ -480,9 +543,12 @@ export default class App extends React.Component {
if(renderer === 'ol') {
mapElement = <OpenLayersMap
{...mapProps}
debugToolbox={this.state.openlayersDebugOptions.debugToolbox}
onLayerSelect={this.onLayerSelect}
/>
} else {
mapElement = <MapboxGlMap {...mapProps}
options={this.state.mapboxGlDebugOptions}
inspectModeEnabled={this.state.mapState === "inspect"}
highlightedLayer={this.state.mapStyle.layers[this.state.selectedLayerIndex]}
onLayerSelect={this.onLayerSelect} />
@@ -524,12 +590,31 @@ export default class App extends React.Component {
this.setModal(modalName, !this.state.isOpen[modalName]);
}
onChangeOpenlayersDebug = (key, value) => {
this.setState({
openlayersDebugOptions: {
...this.state.openlayersDebugOptions,
[key]: value,
}
});
}
onChangeMaboxGlDebug = (key, value) => {
this.setState({
mapboxGlDebugOptions: {
...this.state.mapboxGlDebugOptions,
[key]: value,
}
});
}
render() {
const layers = this.state.mapStyle.layers || []
const selectedLayer = layers.length > 0 ? layers[this.state.selectedLayerIndex] : null
const metadata = this.state.mapStyle.metadata || {}
const toolbar = <Toolbar
renderer={this._getRenderer()}
mapState={this.state.mapState}
mapStyle={this.state.mapStyle}
inspectModeEnabled={this.state.mapState === "inspect"}
@@ -575,6 +660,15 @@ export default class App extends React.Component {
const modals = <div>
<DebugModal
renderer={this._getRenderer()}
mapboxGlDebugOptions={this.state.mapboxGlDebugOptions}
openlayersDebugOptions={this.state.openlayersDebugOptions}
onChangeMaboxGlDebug={this.onChangeMaboxGlDebug}
onChangeOpenlayersDebug={this.onChangeOpenlayersDebug}
isOpen={this.state.isOpen.debug}
onOpenToggle={this.toggleModal.bind(this, 'debug')}
/>
<ShortcutsModal
ref={(el) => this.shortcutEl = el}
isOpen={this.state.isOpen.shortcuts}
@@ -583,8 +677,10 @@ export default class App extends React.Component {
<SettingsModal
mapStyle={this.state.mapStyle}
onStyleChanged={this.onStyleChanged}
onChangeMetadataProperty={this.onChangeMetadataProperty}
isOpen={this.state.isOpen.settings}
onOpenToggle={this.toggleModal.bind(this, 'settings')}
openlayersDebugOptions={this.state.openlayersDebugOptions}
/>
<ExportModal
mapStyle={this.state.mapStyle}
@@ -594,7 +690,7 @@ export default class App extends React.Component {
/>
<OpenModal
isOpen={this.state.isOpen.open}
onStyleOpen={this.onStyleChanged}
onStyleOpen={this.openStyle}
onOpenToggle={this.toggleModal.bind(this, 'open')}
/>
<SourcesModal

View File

@@ -1,6 +1,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import classnames from 'classnames'
import {detect} from 'detect-browser';
import {MdFileDownload, MdOpenInBrowser, MdSettings, MdLayers, MdHelpOutline, MdFindInPage, MdAssignmentTurnedIn} from 'react-icons/md'
@@ -9,6 +10,11 @@ import logoImage from 'maputnik-design/logos/logo-color.svg'
import pkgJson from '../../package.json'
// This is required because of <https://stackoverflow.com/a/49846426>, there isn't another way to detect support that I'm aware of.
const browser = detect();
const colorAccessibilityFiltersEnabled = ['chrome', 'firefox'].indexOf(browser.name) > -1;
class IconText extends React.Component {
static propTypes = {
children: PropTypes.node,
@@ -108,6 +114,7 @@ export default class Toolbar extends React.Component {
onToggleModal: PropTypes.func,
onSetMapState: PropTypes.func,
mapState: PropTypes.string,
renderer: PropTypes.string,
}
state = {
@@ -133,22 +140,27 @@ export default class Toolbar extends React.Component {
{
id: "inspect",
title: "Inspect",
disabled: this.props.renderer !== 'mbgljs',
},
{
id: "filter-deuteranopia",
title: "Map (deuteranopia)",
disabled: !colorAccessibilityFiltersEnabled,
},
{
id: "filter-protanopia",
title: "Map (protanopia)",
disabled: !colorAccessibilityFiltersEnabled,
},
{
id: "filter-tritanopia",
title: "Map (tritanopia)",
disabled: !colorAccessibilityFiltersEnabled,
},
{
id: "filter-achromatopsia",
title: "Map (achromatopsia)",
disabled: !colorAccessibilityFiltersEnabled,
},
];
@@ -201,7 +213,7 @@ export default class Toolbar extends React.Component {
<select onChange={(e) => this.handleSelection(e.target.value)} value={currentView.id}>
{views.map((item) => {
return (
<option key={item.id} value={item.id}>
<option key={item.id} value={item.id} disabled={item.disabled}>
{item.title}
</option>
);

View File

@@ -13,25 +13,28 @@ class NumberInput extends React.Component {
constructor(props) {
super(props)
this.state = {
value: props.value
editing: false,
value: props.value,
}
}
static getDerivedStateFromProps(props, state) {
return {
value: props.value
};
if (!state.editing) {
return {
value: props.value
};
}
}
changeValue(newValue) {
this.setState({editing: true});
const value = parseFloat(newValue)
const hasChanged = this.state.value !== value
if(this.isValid(value) && hasChanged) {
this.props.onChange(value)
} else {
this.setState({ value: newValue })
}
this.setState({ value: newValue })
}
isValid(v) {
@@ -52,6 +55,7 @@ class NumberInput extends React.Component {
}
resetValue = () => {
this.setState({editing: false});
// Reset explicitly to default value if value has been cleared
if(this.state.value === "") {
return this.changeValue(this.props.default)

View File

@@ -14,13 +14,16 @@ class StringInput extends React.Component {
constructor(props) {
super(props)
this.state = {
editing: false,
value: props.value || ''
}
}
componentDidUpdate(prevProps) {
if(this.props.value !== prevProps.value) {
this.setState({value: this.props.value})
static getDerivedStateFromProps(props, state) {
if (!state.editing) {
return {
value: props.value
};
}
}
@@ -51,11 +54,15 @@ class StringInput extends React.Component {
placeholder: this.props.default,
onChange: e => {
this.setState({
editing: true,
value: e.target.value
})
},
onBlur: () => {
if(this.state.value!==this.props.value) this.props.onChange(this.state.value)
if(this.state.value!==this.props.value) {
this.setState({editing: false});
this.props.onChange(this.state.value);
}
}
});
}

View File

@@ -1,6 +1,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import LayerIcon from '../icons/LayerIcon'
import {latest, expression, function as styleFunction} from '@mapbox/mapbox-gl-style-spec'
function groupFeaturesBySourceLayer(features) {
const sources = {}
@@ -28,7 +29,64 @@ function groupFeaturesBySourceLayer(features) {
class FeatureLayerPopup extends React.Component {
static propTypes = {
onLayerSelect: PropTypes.func.isRequired,
features: PropTypes.array
features: PropTypes.array,
zoom: PropTypes.number,
}
_getFeatureColor(feature, zoom) {
// Guard because openlayers won't have this
if (!feature.layer.paint) {
return;
}
try {
const paintProps = feature.layer.paint;
let propName;
if(paintProps.hasOwnProperty("text-color") && paintProps["text-color"]) {
propName = "text-color";
}
else if (paintProps.hasOwnProperty("fill-color") && paintProps["fill-color"]) {
propName = "fill-color";
}
else if (paintProps.hasOwnProperty("line-color") && paintProps["line-color"]) {
propName = "line-color";
}
else if (paintProps.hasOwnProperty("fill-extrusion-color") && paintProps["fill-extrusion-color"]) {
propName = "fill-extrusion-color";
}
if(propName) {
const propertySpec = latest["paint_"+feature.layer.type][propName];
let color = feature.layer.paint[propName];
if(typeof(color) === "object") {
if(color.stops) {
color = styleFunction.convertFunction(color, propertySpec);
}
const exprResult = expression.createExpression(color, propertySpec);
const val = exprResult.value.evaluate({
zoom: zoom
}, feature);
return val.toString();
}
else {
return color;
}
}
else {
// Default color
return "black";
}
}
// This is quite complex, just incase there's an edgecase we're missing
// always return black if we get an unexpected error.
catch (err) {
console.error("Unable to get feature color, error:", err);
return "black";
}
}
render() {
@@ -36,21 +94,33 @@ class FeatureLayerPopup extends React.Component {
const items = Object.keys(sources).map(vectorLayerId => {
const layers = sources[vectorLayerId].map((feature, idx) => {
return <label
key={idx}
className="maputnik-popup-layer"
onClick={() => {
this.props.onLayerSelect(feature.layer.id)
}}
const featureColor = this._getFeatureColor(feature, this.props.zoom);
return <div
key={idx}
className="maputnik-popup-layer"
>
<LayerIcon type={feature.layer.type} style={{
width: 14,
height: 14,
paddingRight: 3
}}/>
{feature.layer.id}
{feature.counter && <span> × {feature.counter}</span>}
</label>
<div
className="maputnik-popup-layer__swatch"
style={{background: featureColor}}
></div>
<label
className="maputnik-popup-layer__label"
onClick={() => {
this.props.onLayerSelect(feature.layer.id)
}}
>
{feature.layer.type &&
<LayerIcon type={feature.layer.type} style={{
width: 14,
height: 14,
paddingRight: 3
}}/>
}
{feature.layer.id}
{feature.counter && <span> × {feature.counter}</span>}
</label>
</div>
})
return <div key={vectorLayerId}>
<div className="maputnik-popup-layer-id">{vectorLayerId}</div>

View File

@@ -166,14 +166,18 @@ export default class MapboxGlMap extends React.Component {
if(this.props.inspectModeEnabled) {
return renderPopup(<FeaturePropertyPopup features={features} />, tmpNode);
} else {
return renderPopup(<FeatureLayerPopup features={features} onLayerSelect={this.props.onLayerSelect} />, tmpNode);
return renderPopup(<FeatureLayerPopup features={features} onLayerSelect={this.props.onLayerSelect} zoom={this.state.zoom} />, tmpNode);
}
}
})
map.addControl(inspect)
map.on("style.load", () => {
this.setState({ map, inspect });
this.setState({
map,
inspect,
zoom: map.getZoom()
});
if(this.props.inspectModeEnabled) {
inspect.toggleInspector();
}
@@ -185,6 +189,12 @@ export default class MapboxGlMap extends React.Component {
map: this.state.map
})
})
map.on("zoom", e => {
this.setState({
zoom: map.getZoom()
});
})
}
render() {

View File

@@ -1,11 +1,28 @@
import React from 'react'
import {throttle} from 'lodash';
import PropTypes from 'prop-types'
import { loadJSON } from '../../libs/urlopen'
import FeatureLayerPopup from './FeatureLayerPopup';
import 'ol/ol.css'
import {apply} from 'ol-mapbox-style';
import {Map, View} from 'ol';
import {Map, View, Proj, Overlay} from 'ol';
import {toLonLat} from 'ol/proj';
import {toStringHDMS} from 'ol/coordinate';
function renderCoords (coords) {
if (!coords || coords.length < 2) {
return null;
}
else {
return <span className="maputnik-coords">
{coords.map((coord) => String(coord).padStart(7, "\u00A0")).join(', ')}
</span>
}
}
export default class OpenLayersMap extends React.Component {
static propTypes = {
@@ -13,53 +30,137 @@ export default class OpenLayersMap extends React.Component {
mapStyle: PropTypes.object.isRequired,
accessToken: PropTypes.string,
style: PropTypes.object,
onLayerSelect: PropTypes.func.isRequired,
debugToolbox: PropTypes.bool.isRequired,
}
static defaultProps = {
onMapLoaded: () => {},
onDataChange: () => {},
onLayerSelect: () => {},
}
constructor(props) {
super(props);
this.state = {
zoom: 0,
rotation: 0,
cursor: [],
center: [],
};
this.updateStyle = throttle(this._updateStyle.bind(this), 200);
}
updateStyle(newMapStyle) {
_updateStyle(newMapStyle) {
if(!this.map) return;
// See <https://github.com/openlayers/ol-mapbox-style/issues/215#issuecomment-493198815>
this.map.getLayers().clear();
apply(this.map, newMapStyle);
}
componentDidUpdate() {
this.updateStyle(this.props.mapStyle);
componentDidUpdate(prevProps) {
if (this.props.mapStyle !== prevProps.mapStyle) {
this.updateStyle(this.props.mapStyle);
}
}
componentDidMount() {
this.updateStyle(this.props.mapStyle);
this.overlay = new Overlay({
element: this.popupContainer,
autoPan: true,
autoPanAnimation: {
duration: 250
}
});
const map = new Map({
target: this.container,
layers: [],
overlays: [this.overlay],
view: new View({
zoom: 2,
center: [52.5, -78.4]
zoom: 1,
center: [180, -90],
})
});
map.on('pointermove', (evt) => {
var coords = toLonLat(evt.coordinate);
this.setState({
cursor: [
coords[0].toFixed(2),
coords[1].toFixed(2)
]
})
})
map.on('postrender', (evt) => {
const center = toLonLat(map.getView().getCenter());
this.setState({
center: [
center[0].toFixed(2),
center[1].toFixed(2),
],
rotation: map.getView().getRotation().toFixed(2),
zoom: map.getView().getZoom().toFixed(2)
});
});
this.map = map;
this.updateStyle(this.props.mapStyle);
}
closeOverlay = (e) => {
e.target.blur();
this.overlay.setPosition(undefined);
}
render() {
return <div
ref={x => this.container = x}
style={{
position: "fixed",
top: 40,
right: 0,
bottom: 0,
height: 'calc(100% - 40px)',
width: "75%",
backgroundColor: '#fff',
...this.props.style,
}}>
return <div className="maputnik-ol-container">
<div
ref={x => this.popupContainer = x}
style={{background: "black"}}
className="maputnik-popup"
>
<button
className="mapboxgl-popup-close-button"
onClick={this.closeOverlay}
aria-label="Close popup"
>
×
</button>
<FeatureLayerPopup
features={this.state.selectedFeatures || []}
onLayerSelect={this.props.onLayerSelect}
/>
</div>
<div className="maputnik-ol-zoom">
Zoom level: {this.state.zoom}
</div>
{this.props.debugToolbox &&
<div className="maputnik-ol-debug">
<div>
<label>cursor: </label>
<span>{renderCoords(this.state.cursor)}</span>
</div>
<div>
<label>center: </label>
<span>{renderCoords(this.state.center)}</span>
</div>
<div>
<label>rotation: </label>
<span>{this.state.rotation}</span>
</div>
</div>
}
<div
className="maputnik-ol"
ref={x => this.container = x}
style={{
...this.props.style,
}}>
</div>
</div>
}
}

View File

@@ -0,0 +1,53 @@
import React from 'react'
import PropTypes from 'prop-types'
import Modal from './Modal'
class DebugModal extends React.Component {
static propTypes = {
isOpen: PropTypes.bool.isRequired,
renderer: PropTypes.string.isRequired,
onChangeMaboxGlDebug: PropTypes.func.isRequired,
onChangeOpenlayersDebug: PropTypes.func.isRequired,
onOpenToggle: PropTypes.func.isRequired,
mapboxGlDebugOptions: PropTypes.object,
openlayersDebugOptions: PropTypes.object,
}
render() {
return <Modal
data-wd-key="debug-modal"
isOpen={this.props.isOpen}
onOpenToggle={this.props.onOpenToggle}
title={'Debug'}
>
<div className="maputnik-modal-section maputnik-modal-shortcuts">
{this.props.renderer === 'mbgljs' &&
<ul>
{Object.entries(this.props.mapboxGlDebugOptions).map(([key, val]) => {
return <li key={key}>
<label>
<input type="checkbox" checked={val} onClick={(e) => this.props.onChangeMaboxGlDebug(key, e.target.checked)} /> {key}
</label>
</li>
})}
</ul>
}
{this.props.renderer === 'ol' &&
<ul>
{Object.entries(this.props.openlayersDebugOptions).map(([key, val]) => {
return <li key={key}>
<label>
<input type="checkbox" checked={val} onClick={(e) => this.props.onChangeOpenlayersDebug(key, e.target.checked)} /> {key}
</label>
</li>
})}
</ul>
}
</div>
</Modal>
}
}
export default DebugModal;

View File

@@ -11,6 +11,7 @@ class SettingsModal extends React.Component {
static propTypes = {
mapStyle: PropTypes.object.isRequired,
onStyleChanged: PropTypes.func.isRequired,
onChangeMetadataProperty: PropTypes.func.isRequired,
isOpen: PropTypes.bool.isRequired,
onOpenToggle: PropTypes.func.isRequired,
}
@@ -23,19 +24,9 @@ class SettingsModal extends React.Component {
this.props.onStyleChanged(changedStyle)
}
changeMetadataProperty(property, value) {
const changedStyle = {
...this.props.mapStyle,
metadata: {
...this.props.mapStyle.metadata,
[property]: value
}
}
this.props.onStyleChanged(changedStyle)
}
render() {
const metadata = this.props.mapStyle.metadata || {}
const {onChangeMetadataProperty} = this.props;
const inputProps = { }
return <Modal
data-wd-key="modal-settings"
@@ -78,7 +69,7 @@ class SettingsModal extends React.Component {
<StringInput {...inputProps}
data-wd-key="modal-settings.maputnik:mapbox_access_token"
value={metadata['maputnik:mapbox_access_token']}
onChange={this.changeMetadataProperty.bind(this, "maputnik:mapbox_access_token")}
onChange={onChangeMetadataProperty.bind(this, "maputnik:mapbox_access_token")}
/>
</InputBlock>
@@ -86,7 +77,7 @@ class SettingsModal extends React.Component {
<StringInput {...inputProps}
data-wd-key="modal-settings.maputnik:openmaptiles_access_token"
value={metadata['maputnik:openmaptiles_access_token']}
onChange={this.changeMetadataProperty.bind(this, "maputnik:openmaptiles_access_token")}
onChange={onChangeMetadataProperty.bind(this, "maputnik:openmaptiles_access_token")}
/>
</InputBlock>
@@ -94,7 +85,7 @@ class SettingsModal extends React.Component {
<StringInput {...inputProps}
data-wd-key="modal-settings.maputnik:thunderforest_access_token"
value={metadata['maputnik:thunderforest_access_token']}
onChange={this.changeMetadataProperty.bind(this, "maputnik:thunderforest_access_token")}
onChange={onChangeMetadataProperty.bind(this, "maputnik:thunderforest_access_token")}
/>
</InputBlock>
@@ -106,9 +97,10 @@ class SettingsModal extends React.Component {
['ol', 'Open Layers (experimental)'],
]}
value={metadata['maputnik:renderer'] || 'mbgljs'}
onChange={this.changeMetadataProperty.bind(this, 'maputnik:renderer')}
onChange={onChangeMetadataProperty.bind(this, 'maputnik:renderer')}
/>
</InputBlock>
</div>
</Modal>
}

View File

@@ -40,6 +40,10 @@ class ShortcutsModal extends React.Component {
key: "m",
text: "Focus map"
},
{
key: "!",
text: "Debug modal"
},
]