diff --git a/src/components/App.jsx_json_modal b/src/components/App.jsx_json_modal new file mode 100644 index 0000000..ac775e0 --- /dev/null +++ b/src/components/App.jsx_json_modal @@ -0,0 +1,832 @@ +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 {unset} from 'lodash' +import {arrayMove} from 'react-sortable-hoc' +import url from 'url' + +import MapboxGlMap from './map/MapboxGlMap' +import OpenLayersMap from './map/OpenLayersMap' +import LayerList from './layers/LayerList' +import LayerEditor from './layers/LayerEditor' +import Toolbar from './Toolbar' +import AppLayout from './AppLayout' +import MessagePanel from './MessagePanel' + +import SettingsModal from './modals/SettingsModal' +import ExportModal from './modals/ExportModal' +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 JSONEditorModal from './modals/JSONEditorModal' + +import { downloadGlyphsMetadata, downloadSpriteMetadata } from '../libs/metadata' +import {latest, validate} from '@mapbox/mapbox-gl-style-spec' +import style from '../libs/style' +import { initialStyleUrl, loadStyleUrl, removeStyleQuerystring } from '../libs/urlopen' +import { undoMessages, redoMessages } from '../libs/diffmessage' +import { StyleStore } from '../libs/stylestore' +import { ApiStyleStore } from '../libs/apistore' +import { RevisionStore } from '../libs/revisions' +import LayerWatcher from '../libs/layerwatcher' +import tokens from '../config/tokens.json' +import isEqual from 'lodash.isequal' +import Debug from '../libs/debug' +import queryUtil from '../libs/query-util' + +import MapboxGl from 'mapbox-gl' + + +// Similar functionality as +function normalizeSourceURL (url, apiToken="") { + const matches = url.match(/^mapbox:\/\/(.*)/); + if (matches) { + // mapbox://mapbox.mapbox-streets-v7 + return `https://api.mapbox.com/v4/${matches[1]}.json?secure&access_token=${apiToken}` + } + else { + return url; + } +} + +function setFetchAccessToken(url, mapStyle) { + const matchesTilehosting = url.match(/\.tilehosting\.com/); + const matchesMaptiler = url.match(/\.maptiler\.com/); + const matchesThunderforest = url.match(/\.thunderforest\.com/); + if (matchesTilehosting || matchesMaptiler) { + const accessToken = style.getAccessToken("openmaptiles", mapStyle, {allowFallback: true}) + if (accessToken) { + return url.replace('{key}', accessToken) + } + } + else if (matchesThunderforest) { + const accessToken = style.getAccessToken("thunderforest", mapStyle, {allowFallback: true}) + if (accessToken) { + return url.replace('{key}', accessToken) + } + } + else { + return url; + } +} + +function updateRootSpec(spec, fieldName, newValues) { + return { + ...spec, + $root: { + ...spec.$root, + [fieldName]: { + ...spec.$root[fieldName], + values: newValues + } + } + } +} + +export default class App extends React.Component { + constructor(props) { + super(props) + autoBind(this); + + this.revisionStore = new RevisionStore() + const params = new URLSearchParams(window.location.search.substring(1)) + let port = params.get("localport") + if (port == null && (window.location.port != 80 && window.location.port != 443)) { + port = window.location.port + } + this.styleStore = new ApiStyleStore({ + onLocalStyleChange: mapStyle => this.onStyleChanged(mapStyle, {save: false}), + port: port, + host: params.get("localhost") + }) + + + const shortcuts = [ + { + key: "j", + handler: () => { + this.toggleModal("json_editor"); + } + }, + { + key: "?", + handler: () => { + this.toggleModal("shortcuts"); + } + }, + { + key: "o", + handler: () => { + this.toggleModal("open"); + } + }, + { + key: "e", + handler: () => { + this.toggleModal("export"); + } + }, + { + key: "d", + handler: () => { + this.toggleModal("sources"); + } + }, + { + key: "s", + handler: () => { + this.toggleModal("settings"); + } + }, + { + key: "i", + handler: () => { + this.setMapState( + this.state.mapState === "map" ? "inspect" : "map" + ); + } + }, + { + key: "m", + handler: () => { + document.querySelector(".mapboxgl-canvas").focus(); + } + }, + { + key: "!", + handler: () => { + this.toggleModal("debug"); + } + }, + ] + + document.body.addEventListener("keyup", (e) => { + if(e.key === "Escape") { + e.target.blur(); + document.body.focus(); + } + else if(this.state.isOpen.shortcuts || document.activeElement === document.body) { + const shortcut = shortcuts.find((shortcut) => { + return (shortcut.key === e.key) + }) + + if(shortcut) { + this.setModal("shortcuts", false); + shortcut.handler(e); + } + } + }) + + const styleUrl = initialStyleUrl() + if(styleUrl && window.confirm("Load style from URL: " + styleUrl + " and discard current changes?")) { + this.styleStore = new StyleStore() + loadStyleUrl(styleUrl, mapStyle => this.onStyleChanged(mapStyle)) + removeStyleQuerystring() + } else { + if(styleUrl) { + removeStyleQuerystring() + } + this.styleStore.init(err => { + if(err) { + console.log('Falling back to local storage for storing styles') + this.styleStore = new StyleStore() + } + this.styleStore.latestStyle(mapStyle => this.onStyleChanged(mapStyle)) + + if(Debug.enabled()) { + Debug.set("maputnik", "styleStore", this.styleStore); + Debug.set("maputnik", "revisionStore", this.revisionStore); + } + }) + } + + if(Debug.enabled()) { + Debug.set("maputnik", "revisionStore", this.revisionStore); + Debug.set("maputnik", "styleStore", this.styleStore); + } + + const queryObj = url.parse(window.location.href, true).query; + + this.state = { + errors: [], + infos: [], + mapStyle: style.emptyStyle, + selectedLayerIndex: 0, + sources: {}, + vectorLayers: {}, + mapState: "map", + spec: latest, + mapView: { + zoom: 0, + center: { + lng: 0, + lat: 0, + }, + }, + isOpen: { + settings: false, + sources: false, + open: false, + shortcuts: false, + export: false, + // TODO: Disabled for now, this should be opened on the Nth visit to the editor + survey: false, + debug: false, + }, + mapboxGlDebugOptions: { + showTileBoundaries: false, + showCollisionBoxes: false, + showOverdrawInspector: false, + }, + openlayersDebugOptions: { + debugToolbox: false, + }, + } + + this.layerWatcher = new LayerWatcher({ + onVectorLayersChange: v => this.setState({ vectorLayers: v }) + }) + } + + 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); + } + } + } + + componentDidMount() { + window.addEventListener("keydown", this.handleKeyPress); + } + + componentWillUnmount() { + window.removeEventListener("keydown", this.handleKeyPress); + } + + saveStyle(snapshotStyle) { + this.styleStore.save(snapshotStyle) + } + + updateFonts(urlTemplate) { + const metadata = this.state.mapStyle.metadata || {} + const accessToken = metadata['maputnik:openmaptiles_access_token'] || tokens.openmaptiles + + let glyphUrl = (typeof urlTemplate === 'string')? urlTemplate.replace('{key}', accessToken): urlTemplate; + downloadGlyphsMetadata(glyphUrl, fonts => { + this.setState({ spec: updateRootSpec(this.state.spec, 'glyphs', fonts)}) + }) + } + + updateIcons(baseUrl) { + downloadSpriteMetadata(baseUrl, icons => { + this.setState({ spec: updateRootSpec(this.state.spec, 'sprite', icons)}) + }) + } + + 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, opts={}) => { + opts = { + save: true, + addRevision: true, + ...opts, + }; + + const errors = validate(newStyle, latest) || []; + + // The validate function doesn't give us errors for duplicate error with + // empty string for layer.id, manually deal with that here. + const layerErrors = []; + if (newStyle && newStyle.layers) { + const foundLayers = new Map(); + newStyle.layers.forEach((layer, index) => { + if (layer.id === "" && foundLayers.has(layer.id)) { + const message = `Duplicate layer: [empty string]`; + layerErrors.push({ + message, + parsed: { + type: "layer", + data: { + index, + message, + } + } + }); + } + foundLayers.set(layer.id, true); + }); + } + + const mappedErrors = errors.map(error => { + const layerMatch = error.message.match(/layers\[(\d+)\]\.(?:(\S+)\.)?(\S+): (.*)/); + if (layerMatch) { + 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 + } + } + } + } + else { + return { + message: error.message, + }; + } + }).concat(layerErrors); + + let dirtyMapStyle = undefined; + if (errors.length > 0) { + dirtyMapStyle = cloneDeep(newStyle); + + errors.forEach(error => { + const {message} = error; + if (message) { + try { + 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(); + } + + onUndo = () => { + const activeStyle = this.revisionStore.undo() + + const messages = undoMessages(this.state.mapStyle, activeStyle) + this.onStyleChanged(activeStyle, {addRevision: false}); + this.setState({ + infos: messages, + }) + } + + onRedo = () => { + const activeStyle = this.revisionStore.redo() + const messages = redoMessages(this.state.mapStyle, activeStyle) + this.onStyleChanged(activeStyle, {addRevision: false}); + this.setState({ + infos: messages, + }) + } + + onMoveLayer = (move) => { + let { oldIndex, newIndex } = move; + let layers = this.state.mapStyle.layers; + oldIndex = clamp(oldIndex, 0, layers.length-1); + newIndex = clamp(newIndex, 0, layers.length-1); + if(oldIndex === newIndex) return; + + if (oldIndex === this.state.selectedLayerIndex) { + this.setState({ + selectedLayerIndex: newIndex + }); + } + + layers = layers.slice(0); + layers = arrayMove(layers, oldIndex, newIndex); + this.onLayersChange(layers); + } + + onLayersChange = (changedLayers) => { + const changedStyle = { + ...this.state.mapStyle, + layers: changedLayers + } + this.onStyleChanged(changedStyle) + } + + onLayerDestroy = (index) => { + let layers = this.state.mapStyle.layers; + const remainingLayers = layers.slice(0); + remainingLayers.splice(index, 1); + this.onLayersChange(remainingLayers); + } + + onLayerCopy = (index) => { + let layers = this.state.mapStyle.layers; + const changedLayers = layers.slice(0) + + const clonedLayer = cloneDeep(changedLayers[index]) + clonedLayer.id = clonedLayer.id + "-copy" + changedLayers.splice(index, 0, clonedLayer) + this.onLayersChange(changedLayers) + } + + onLayerVisibilityToggle = (index) => { + let layers = this.state.mapStyle.layers; + const changedLayers = layers.slice(0) + + const layer = { ...changedLayers[index] } + const changedLayout = 'layout' in layer ? {...layer.layout} : {} + changedLayout.visibility = changedLayout.visibility === 'none' ? 'visible' : 'none' + + layer.layout = changedLayout + changedLayers[index] = layer + this.onLayersChange(changedLayers) + } + + + onLayerIdChange = (index, oldId, newId) => { + const changedLayers = this.state.mapStyle.layers.slice(0) + changedLayers[index] = { + ...changedLayers[index], + id: newId + } + + this.onLayersChange(changedLayers) + } + + onLayerChanged = (index, layer) => { + const changedLayers = this.state.mapStyle.layers.slice(0) + changedLayers[index] = layer + + this.onLayersChange(changedLayers) + } + + setMapState = (newState) => { + this.setState({ + mapState: newState + }) + } + + 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}; + + for(let [key, val] of Object.entries(this.state.mapStyle.sources)) { + if(sourceList.hasOwnProperty(key)) { + continue; + } + + sourceList[key] = { + type: val.type, + layers: [] + }; + + if(!this.state.sources.hasOwnProperty(key) && val.type === "vector" && val.hasOwnProperty("url")) { + let url = val.url; + try { + url = normalizeSourceURL(url, MapboxGl.accessToken); + } catch(err) { + console.warn("Failed to normalizeSourceURL: ", err); + } + + try { + url = setFetchAccessToken(url, this.state.mapStyle) + } catch(err) { + console.warn("Failed to setFetchAccessToken: ", err); + } + + fetch(url, { + mode: 'cors', + }) + .then((response) => { + return response.json(); + }) + .then((json) => { + if(!json.hasOwnProperty("vector_layers")) { + return; + } + + // Create new objects before setState + const sources = Object.assign({}, this.state.sources); + + for(let layer of json.vector_layers) { + sources[key].layers.push(layer.id) + } + + console.debug("Updating source: "+key); + this.setState({ + sources: sources + }); + }) + .catch((err) => { + console.error("Failed to process sources for '%s'", url, err); + }) + } + } + + if(!isEqual(this.state.sources, sourceList)) { + console.debug("Setting sources"); + this.setState({ + sources: sourceList + }) + } + } + + _getRenderer () { + const metadata = this.state.mapStyle.metadata || {}; + return metadata['maputnik:renderer'] || 'mbgljs'; + } + + onMapChange = (mapView) => { + this.setState({ + mapView, + }); + } + + mapRenderer() { + const {mapStyle, dirtyMapStyle} = this.state; + const metadata = this.state.mapStyle.metadata || {}; + + const mapProps = { + mapStyle: (dirtyMapStyle || mapStyle), + replaceAccessTokens: (mapStyle) => { + return style.replaceAccessTokens(mapStyle, { + allowFallback: true + }); + }, + onDataChange: (e) => { + this.layerWatcher.analyzeMap(e.map) + this.fetchSources(); + }, + } + + const renderer = this._getRenderer(); + + let mapElement; + + // Check if OL code has been loaded? + if(renderer === 'ol') { + mapElement = + } else { + mapElement = + } + + let filterName; + if(this.state.mapState.match(/^filter-/)) { + filterName = this.state.mapState.replace(/^filter-/, ""); + } + const elementStyle = {}; + if (filterName) { + elementStyle.filter = `url('#${filterName}')`; + }; + + return
+ {mapElement} +
+ } + + onLayerSelect = (index) => { + this.setState({ selectedLayerIndex: index }) + } + + setModal(modalName, value) { + if(modalName === 'survey' && value === false) { + localStorage.setItem('survey', ''); + } + + this.setState({ + isOpen: { + ...this.state.isOpen, + [modalName]: value + } + }) + } + + toggleModal(modalName) { + 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 = + + const layerList = + + const layerEditor = selectedLayer ? : null + + const bottomPanel = (this.state.errors.length + this.state.infos.length) > 0 ? : null + + + const modals =
+ + this.shortcutEl = el} + isOpen={this.state.isOpen.shortcuts} + onOpenToggle={this.toggleModal.bind(this, 'shortcuts')} + /> + + + + + + +
+ + return + } +} diff --git a/src/components/Toolbar.jsx b/src/components/Toolbar.jsx index c2c25c1..3dbcab5 100644 --- a/src/components/Toolbar.jsx +++ b/src/components/Toolbar.jsx @@ -185,18 +185,21 @@ export default class Toolbar extends React.Component { > {/* Keyboard accessible quick links */}