Compare commits

..

70 Commits

Author SHA1 Message Date
Lukas Martinelli
99acbd4d92 Ensure GeoJSON styling works 2016-12-31 15:15:28 +01:00
Lukas Martinelli
b0e9790382 Support updating of ArrayInput #39 2016-12-31 14:56:26 +01:00
Lukas Martinelli
e00cdde3af Reset JSONEditor after it loosed focus 2016-12-31 14:37:40 +01:00
Lukas Martinelli
c3a634b216 Show undo/redo message 2016-12-31 14:32:04 +01:00
Lukas Martinelli
4f26a521a0 Fix margins in source editing area 2016-12-31 14:02:14 +01:00
Lukas Martinelli
ca6b48843c Support adding, editing and removing stops 2016-12-31 13:42:25 +01:00
Lukas Martinelli
0eb00312f4 Restyle to use border box 2016-12-31 12:17:02 +01:00
Lukas Martinelli
e7709dae15 Notice for not supported nested filter 2016-12-31 11:08:14 +01:00
Lukas Martinelli
03796c963b Fix React type warnings 2016-12-31 10:39:30 +01:00
Lukas Martinelli
b50855a4a9 Restructure webpack and add favico 2016-12-31 10:36:02 +01:00
Lukas Martinelli
24a90b3c57 Set dark scrollbar globally 2016-12-30 21:02:39 +01:00
Lukas Martinelli
cf80e80025 Switch font input to AutocompleteInput 2016-12-30 20:53:12 +01:00
Lukas Martinelli
48f10bcb73 Convert Autocmplete from tabs to spaces 2016-12-30 20:46:27 +01:00
Lukas Martinelli
7bc2323401 Introduce AutocompelteInput for source editing 2016-12-30 20:38:50 +01:00
Lukas Martinelli
a71ac502d6 Remove style from PropertyGroup 2016-12-30 20:01:14 +01:00
Lukas Martinelli
f2dd785e7b Simplify font stacks and limit to one default value 2016-12-30 18:56:16 +01:00
Lukas Martinelli
0b99e571c4 Prototype FontInput field 2016-12-30 18:13:41 +01:00
Lukas Martinelli
cfc6085718 Add missing properties to symbol layer 2016-12-30 17:17:08 +01:00
Lukas Martinelli
384b2d4bea Show default value in color field 2016-12-30 17:16:58 +01:00
Lukas Martinelli
1058dbfb5a Hide sources when adding background layer 2016-12-30 17:06:08 +01:00
Lukas Martinelli
bda7ce7390 Move change property logic to lib 2016-12-30 16:56:20 +01:00
Lukas Martinelli
7b631b0510 Garbage collect properties when change type #42 2016-12-30 16:47:47 +01:00
Lukas Martinelli
1d7768e37c Make NumberInput more tolerant to errors 2016-12-30 16:18:57 +01:00
Lukas Martinelli
89d497c73f Error panel with current map style errors #40 2016-12-30 13:21:03 +01:00
Lukas Martinelli
886c87f231 Improve groups for symbol layer 2016-12-29 23:01:31 +01:00
Lukas Martinelli
d567a4f98b Add support for circle layer #30 2016-12-29 22:58:36 +01:00
Lukas Martinelli
5eb0e36faf Decrease doc label font size 2016-12-29 22:41:39 +01:00
Lukas Martinelli
51a2eabc91 Add ArrayInput 2016-12-29 22:37:54 +01:00
Lukas Martinelli
007bdad70a Also show MultiButton for 3 options 2016-12-29 22:12:36 +01:00
Lukas Martinelli
1f1a919c77 Only update style if it is valid 2016-12-29 22:00:49 +01:00
Lukas Martinelli
3be3a716d4 Only update if structure of JSON changes 2016-12-29 21:49:40 +01:00
Lukas Martinelli
ae9afdd8d9 Replace gone style store test with sample 2016-12-29 17:40:12 +01:00
Lukas Martinelli
a5307054b3 Fix setting style properties in settings modal 2016-12-29 17:32:23 +01:00
Lukas Martinelli
d16c3f4356 Always show all features in inspect mode 2016-12-29 17:30:01 +01:00
Lukas Martinelli
853361ace7 Indicate if feature is clickable 2016-12-29 17:00:36 +01:00
Lukas Martinelli
e41e1eb2f1 Inspection map is now always aware of current layer 2016-12-29 16:54:58 +01:00
Lukas Martinelli
e36c233b49 Remove highlighted layer from metadata in style 2016-12-29 15:51:11 +01:00
Lukas Martinelli
d1b8f8d63e Change map style to add layer 2016-12-29 15:35:07 +01:00
Lukas Martinelli
29cfb58a56 Update sources if they change 2016-12-29 15:22:47 +01:00
Lukas Martinelli
bf5131cadd Restructure layer settings for add modal 2016-12-29 14:46:04 +01:00
Lukas Martinelli
ccc39b87db Move storing access token into style metadata 2016-12-28 21:50:53 +01:00
Lukas Martinelli
604be38b7c Store highlighted layer in metadata 2016-12-28 21:50:53 +01:00
Lukas Martinelli
160bd9563b Introduce MultiInputButton 2016-12-28 21:50:53 +01:00
Lukas Martinelli
488fdf2bd5 Rename icons and add layer to toolbar 2016-12-28 21:50:53 +01:00
Lukas Martinelli
a0e1e6152b Merge pull request #36 from PetersonGIS/upgrade-mapboxgl
Upgraded reference to mapbox-gl to v0.29.0 to follow their latest webpack recommendations and support Windows dev. This is addressed in mapbox/mapbox-gl-js#3724
2016-12-28 21:46:11 +01:00
PetersonGIS
58897f1856 Upgraded reference to mapbox-gl to v0.29.0 to follow their latest webpack recommendations and support Windows dev. This is addressed in mapbox/mapbox-gl-js#3724 2016-12-28 11:10:27 -07:00
Lukas Martinelli
80678af691 Implement adding public and custom sources 2016-12-28 15:57:30 +01:00
Lukas Martinelli
ba271e1fc6 Allow deleting active source 2016-12-28 15:20:07 +01:00
Lukas Martinelli
c7ac90ba15 Fill extrusion support #31 2016-12-27 12:04:36 +01:00
Lukas Martinelli
0dc335ea5f Deref style on open 2016-12-26 12:21:41 +01:00
Lukas Martinelli
acac314d27 Improve input styling (it is still hacky) 2016-12-26 12:03:12 +01:00
Lukas Martinelli
916c1dc9fc No scrollbar style for JSON mode 2016-12-26 11:55:39 +01:00
Lukas Martinelli
c159f7041f Switch from field components to input components 2016-12-26 11:51:26 +01:00
Lukas Martinelli
a3d586a75d Give more space to layer editor 2016-12-26 11:22:41 +01:00
Lukas Martinelli
6b0b29d1da Increase font size to 12px 2016-12-25 20:36:10 +01:00
Lukas Martinelli
8afda2fe28 Adapt Mapbox GL css icon colors 2016-12-25 20:26:59 +01:00
Lukas Martinelli
beb1a2a8d1 Introduce doc label for help 2016-12-25 19:00:21 +01:00
Lukas Martinelli
436e0c2095 Hack together add and delete button for stops 2016-12-25 18:30:23 +01:00
Lukas Martinelli
e1bc2a321a Improve inspect popups 2016-12-25 17:46:18 +01:00
Lukas Martinelli
720c8f108b Add codemirror as dependency 2016-12-25 13:19:33 +01:00
Lukas Martinelli
4db5c7cf68 Better inspection hover 2016-12-24 22:57:14 +01:00
Lukas Martinelli
8f561d8a27 Show layer table 2016-12-24 22:57:14 +01:00
Lukas Martinelli
0c483cffe3 Allow hash for location 2016-12-24 22:57:14 +01:00
Lukas Martinelli
def5ebb587 Show feature table on hover 2016-12-24 22:57:14 +01:00
Lukas Martinelli
6e9e66b147 Switch renderer with inspect mode 2016-12-24 22:57:14 +01:00
Lukas Martinelli
f332d517f3 Add inspection map 2016-12-24 22:57:14 +01:00
Lukas Martinelli
04eab70e27 Add missing revision store 2016-12-24 22:57:14 +01:00
Lukas Martinelli
cfbcdc7fa1 Basic redo/undo with keybindings #25 2016-12-24 22:57:14 +01:00
Lukas Martinelli
c95dd75e2a Update README.md 2016-12-22 23:16:51 +01:00
Lukas Martinelli
4408f3ab3b Update README.md 2016-12-22 23:09:42 +01:00
75 changed files with 2015 additions and 684 deletions

2
.gitignore vendored
View File

@@ -1,6 +1,8 @@
# Logs
logs
*.log
*.swp
*.swo
# Runtime data
pids

View File

@@ -1,24 +1,18 @@
# Maputnik [![Build Status](https://travis-ci.org/maputnik/editor.svg?branch=master)](https://travis-ci.org/maputnik/editor) [![Windows Build Status](https://ci.appveyor.com/api/projects/status/anelbgv6jdb3qnh9/branch/master?svg=true)](https://ci.appveyor.com/project/lukasmartinelli/editor) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://tldrlegal.com/license/mit-license)
<img width="200" align="right" alt="Maputnik" src="media/maputnik.png" />
<img width="200" align="right" alt="Maputnik" src="src/img/maputnik.png" />
A free and open visual editor for the [Mapbox GL styles](https://www.mapbox.com/mapbox-gl-style-spec/)
targeted at developers and map designers. Creating your own custom map is easy with **Maputnik**.
Check it out at **http://maputnik.com/editor/**
*Maputnik is an early prototype and is under development.
[Thanks to the supporters of the Kickstarter campaign who made this project possible](https://www.kickstarter.com/projects/174808720/maputnik-visual-map-editor-for-mapbox-gl)*.
## Features
- [x] Completely free and open source
- [x] Visual interface for designing maps
- [x] Immediate feedback (thanks to [style diffs](https://github.com/mapbox/mapbox-gl-style-spec/blob/mb-pages/lib/diff.js))
- [x] Edit layers
- [x] Easy to deploy as single HTML file
- [ ] Support for Open Layers 3
![Demo showing interactive feedback](media/demo.gif)
## Latest Status Update Video
![Latest Status Update for Maputnik v0.2.2](https://j.gifs.com/g5XMgl.gif)
## Develop

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

View File

@@ -19,18 +19,23 @@
"license": "MIT",
"homepage": "https://github.com/maputnik/editor#readme",
"dependencies": {
"codemirror": "^5.18.2",
"color": "^1.0.3",
"file-saver": "^1.3.2",
"lodash.capitalize": "^4.2.1",
"lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.4.0",
"lodash.throttle": "^4.1.1",
"lodash.topairs": "^4.3.0",
"mapbox-gl": "mapbox/mapbox-gl-js#6c24b9621d2aa770eda67fb5638b4d78087b5624",
"mapbox-gl-style-spec": "mapbox/mapbox-gl-style-spec#e85407a377510acb647161de6be6357ab4f606dd",
"mapbox-gl": "^0.29.0",
"mapbox-gl-style-spec": "^8.11.0",
"mousetrap": "^1.6.0",
"ol-mapbox-style": "0.0.11",
"openlayers": "^3.19.1",
"randomcolor": "^0.4.4",
"react": "^15.4.0",
"react-addons-pure-render-mixin": "^15.4.0",
"react-autocomplete": "^1.4.0",
"react-codemirror": "^0.3.0",
"react-collapse": "^2.3.3",
"react-color": "^2.10.0",

View File

@@ -1,6 +1,6 @@
.cm-s-maputnik.CodeMirror {
height: 100%;
font-size: 10px;
font-size: 12px;
}
.cm-s-maputnik.CodeMirror, .cm-s-maputnik .CodeMirror-gutters {

View File

@@ -1,25 +1,32 @@
import React from 'react'
import { saveAs } from 'file-saver'
import Mousetrap from 'mousetrap'
import InspectionMap from './map/InspectionMap'
import MapboxGlMap from './map/MapboxGlMap'
import OpenLayers3Map from './map/OpenLayers3Map'
import LayerList from './layers/LayerList'
import LayerEditor from './layers/LayerEditor'
import Toolbar from './Toolbar'
import AppLayout from './AppLayout'
import MessagePanel from './MessagePanel'
import GlSpec from 'mapbox-gl-style-spec/reference/latest.js'
import validateStyleMin from 'mapbox-gl-style-spec/lib/validate_style.min'
import style from '../libs/style.js'
import { loadDefaultStyle, SettingsStore, StyleStore } from '../libs/stylestore'
import { undoMessages, redoMessages } from '../libs/diffmessage'
import { loadDefaultStyle, StyleStore } from '../libs/stylestore'
import { ApiStyleStore } from '../libs/apistore'
import { RevisionStore } from '../libs/revisions'
import LayerWatcher from '../libs/layerwatcher'
export default class App extends React.Component {
constructor(props) {
super(props)
this.layerWatcher = new LayerWatcher()
this.styleStore = new ApiStyleStore()
this.revisionStore = new RevisionStore()
this.styleStore.supported(isSupported => {
if(!isSupported) {
console.log('Falling back to local storage for storing styles')
@@ -28,12 +35,29 @@ export default class App extends React.Component {
this.styleStore.latestStyle(mapStyle => this.onStyleChanged(mapStyle))
})
this.settingsStore = new SettingsStore()
this.state = {
accessToken: this.settingsStore.accessToken,
errors: [],
infos: [],
mapStyle: style.emptyStyle,
selectedLayerIndex: 0,
sources: {},
vectorLayers: {},
}
this.layerWatcher = new LayerWatcher({
onSourcesChange: v => this.setState({ sources: v }),
onVectorLayersChange: v => this.setState({ vectorLayers: v })
})
}
componentDidMount() {
Mousetrap.bind(['ctrl+z'], this.onUndo.bind(this));
Mousetrap.bind(['ctrl+y'], this.onRedo.bind(this));
}
componentWillUnmount() {
Mousetrap.unbind(['ctrl+z'], this.onUndo.bind(this));
Mousetrap.unbind(['ctrl+y'], this.onRedo.bind(this));
}
onReset() {
@@ -53,13 +77,39 @@ export default class App extends React.Component {
}
onStyleChanged(newStyle) {
this.saveStyle(newStyle)
this.setState({ mapStyle: newStyle })
const errors = validateStyleMin(newStyle, GlSpec)
if(errors.length === 0) {
this.revisionStore.addRevision(newStyle)
this.saveStyle(newStyle)
this.setState({
mapStyle: newStyle,
errors: [],
})
} else {
this.setState({
errors: errors.map(err => err.message)
})
}
}
onAccessTokenChanged(newToken) {
this.settingsStore.accessToken = newToken
this.setState({ accessToken: newToken })
onUndo() {
const activeStyle = this.revisionStore.undo()
const messages = undoMessages(this.state.mapStyle, activeStyle)
this.saveStyle(activeStyle)
this.setState({
mapStyle: activeStyle,
infos: messages,
})
}
onRedo() {
const activeStyle = this.revisionStore.redo()
const messages = redoMessages(this.state.mapStyle, activeStyle)
this.saveStyle(activeStyle)
this.setState({
mapStyle: activeStyle,
infos: messages,
})
}
onLayersChange(changedLayers) {
@@ -91,20 +141,29 @@ export default class App extends React.Component {
}
mapRenderer() {
const metadata = this.state.mapStyle.metadata || {}
const mapProps = {
mapStyle: this.state.mapStyle,
accessToken: this.state.accessToken,
onMapLoaded: (map) => {
this.layerWatcher.map = map
}
accessToken: metadata['maputnik:access_token'],
onDataChange: (e) => {
this.layerWatcher.analyzeMap(e.map)
},
//TODO: This would actually belong to the layout component
style:{
top: 40,
//left: 500,
}
}
const metadata = this.state.mapStyle.metadata || {}
const renderer = metadata['maputnik:renderer'] || 'mbgljs'
// Check if OL3 code has been loaded?
if(renderer === 'ol3') {
return <OpenLayers3Map {...mapProps} />
} else if(renderer === 'inspection') {
return <InspectionMap {...mapProps}
sources={this.state.sources}
highlightedLayer={this.state.mapStyle.layers[this.state.selectedLayerIndex]} />
} else {
return <MapboxGlMap {...mapProps} />
}
@@ -118,9 +177,11 @@ export default class App extends React.Component {
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
mapStyle={this.state.mapStyle}
sources={this.state.sources}
onStyleChanged={this.onStyleChanged.bind(this)}
onStyleOpen={this.onStyleChanged.bind(this)}
onStyleDownload={this.onStyleDownload.bind(this)}
@@ -135,17 +196,23 @@ export default class App extends React.Component {
const layerEditor = selectedLayer ? <LayerEditor
layer={selectedLayer}
sources={this.layerWatcher.sources}
vectorLayers={this.layerWatcher.vectorLayers}
sources={this.state.sources}
vectorLayers={this.state.vectorLayers}
onLayerChanged={this.onLayerChanged.bind(this)}
onLayerIdChange={this.onLayerIdChange.bind(this)}
/> : null
const bottomPanel = (this.state.errors.length + this.state.infos.length) > 0 ? <MessagePanel
errors={this.state.errors}
infos={this.state.infos}
/> : null
return <AppLayout
toolbar={toolbar}
layerList={layerList}
layerEditor={layerEditor}
map={this.mapRenderer()}
bottom={bottomPanel}
/>
}
}

View File

@@ -3,6 +3,7 @@ import ScrollContainer from './ScrollContainer'
import theme from '../config/theme'
import colors from '../config/colors'
import { fontSizes } from '../config/scales'
class AppLayout extends React.Component {
static propTypes = {
@@ -10,6 +11,7 @@ class AppLayout extends React.Component {
layerList: React.PropTypes.element.isRequired,
layerEditor: React.PropTypes.element,
map: React.PropTypes.element.isRequired,
bottom: React.PropTypes.element,
}
static childContextTypes = {
@@ -18,7 +20,7 @@ class AppLayout extends React.Component {
getChildContext() {
return {
reactIconBase: { size: 14 }
reactIconBase: { size: fontSizes[3] }
}
}
@@ -51,7 +53,7 @@ class AppLayout extends React.Component {
top: 40,
left: 200,
zIndex: 1,
width: 300,
width: 350,
backgroundColor: colors.black
}}>
<ScrollContainer>
@@ -59,6 +61,18 @@ class AppLayout extends React.Component {
</ScrollContainer>
</div>
{this.props.map}
{this.props.bottom && <div style={{
position: 'fixed',
height: 50,
bottom: 0,
left: 550,
zIndex: 1,
width: '100%',
backgroundColor: colors.black
}}>
{this.props.bottom}
</div>
}
</div>
}
}

View File

@@ -15,7 +15,7 @@ class Button extends React.Component {
cursor: 'pointer',
backgroundColor: colors.midgray,
color: colors.lowgray,
fontSize: fontSizes[4],
fontSize: fontSizes[5],
padding: margins[1],
userSelect: 'none',
borderRadius: 2,

View File

@@ -0,0 +1,44 @@
import React from 'react'
import Paragraph from './Paragraph'
import colors from '../config/colors'
import { fontSizes, margins } from '../config/scales'
class MessagePanel extends React.Component {
static propTypes = {
errors: React.PropTypes.array,
infos: React.PropTypes.array,
}
render() {
const paragraphStyle = {
margin: 0,
lineHeight: 1.2,
}
const errors = this.props.errors.map((m, i) => {
return <Paragraph key={i}
style={{
...paragraphStyle,
color: colors.red,
}}>{m}</Paragraph>
})
const infos = this.props.infos.map((m, i) => {
return <Paragraph key={i}
style={{
...paragraphStyle,
color: colors.lowgray,
}}>{m}</Paragraph>
})
return <div style={{
padding: margins[1],
}}>
{errors}
{infos}
</div>
}
}
export default MessagePanel

View File

@@ -1,8 +1,7 @@
import React from 'react'
import scrollbars from './scrollbars.scss'
const ScrollContainer = (props) => {
return <div className={scrollbars.darkScrollbar} style={{
return <div style={{
overflowX: "visible",
overflowY: "scroll",
bottom:0,

View File

@@ -3,18 +3,21 @@ import FileReaderInput from 'react-file-reader-input'
import MdFileDownload from 'react-icons/lib/md/file-download'
import MdFileUpload from 'react-icons/lib/md/file-upload'
import MdOpenInBrowser from 'react-icons/lib/md/open-in-browser'
import MdSettings from 'react-icons/lib/md/settings'
import OpenIcon from 'react-icons/lib/md/open-in-browser'
import SettingsIcon from 'react-icons/lib/md/settings'
import MdInfo from 'react-icons/lib/md/info'
import MdLayers from 'react-icons/lib/md/layers'
import SourcesIcon from 'react-icons/lib/md/layers'
import MdSave from 'react-icons/lib/md/save'
import MdStyle from 'react-icons/lib/md/style'
import MdMap from 'react-icons/lib/md/map'
import MdInsertEmoticon from 'react-icons/lib/md/insert-emoticon'
import MdFontDownload from 'react-icons/lib/md/font-download'
import MdHelpOutline from 'react-icons/lib/md/help-outline'
import MdFindInPage from 'react-icons/lib/md/find-in-page'
import HelpIcon from 'react-icons/lib/md/help-outline'
import InspectionIcon from 'react-icons/lib/md/find-in-page'
import AddIcon from 'react-icons/lib/md/add-circle-outline'
import logoImage from '../img/maputnik.png'
import AddModal from './modals/AddModal'
import SettingsModal from './modals/SettingsModal'
import SourcesModal from './modals/SourcesModal'
import OpenModal from './modals/OpenModal'
@@ -29,7 +32,7 @@ const IconText = props => <span style={{ paddingLeft: margins[0] }}>
const actionStyle = {
display: "inline-block",
padding: 12.5,
padding: 10,
fontSize: fontSizes[4],
cursor: "pointer",
color: colors.white,
@@ -79,6 +82,8 @@ export default class Toolbar extends React.Component {
onStyleOpen: React.PropTypes.func.isRequired,
// Current style is requested for download
onStyleDownload: React.PropTypes.func.isRequired,
// A dict of source id's and the available source layers
sources: React.PropTypes.object.isRequired,
}
constructor(props) {
@@ -88,6 +93,7 @@ export default class Toolbar extends React.Component {
settings: false,
sources: false,
open: false,
add: false,
}
}
}
@@ -99,6 +105,22 @@ export default class Toolbar extends React.Component {
</ToolbarAction>
}
toggleInspectionMode() {
const metadata = this.props.mapStyle.metadata || {}
const currentRenderer = metadata['maputnik:renderer'] || 'mbgljs'
const changedRenderer = currentRenderer === 'inspection' ? 'mbgljs' : 'inspection'
const changedStyle = {
...this.props.mapStyle,
metadata: {
'maputnik:renderer': changedRenderer
}
}
this.props.onStyleChanged(changedStyle)
}
toggleModal(modalName) {
this.setState({
isOpen: {
@@ -135,6 +157,13 @@ export default class Toolbar extends React.Component {
isOpen={this.state.isOpen.sources}
onOpenToggle={this.toggleModal.bind(this, 'sources')}
/>
<AddModal
mapStyle={this.props.mapStyle}
sources={this.props.sources}
isOpen={this.state.isOpen.add}
onOpenToggle={this.toggleModal.bind(this, 'add')}
onStyleChange={this.props.onStyleChanged}
/>
<ToolbarLink
href={"https://github.com/maputnik/editor"}
style={{
@@ -144,28 +173,32 @@ export default class Toolbar extends React.Component {
padding: 5,
}}
>
<img src="https://github.com/maputnik/editor/raw/master/media/maputnik.png" alt="Maputnik" style={{width: 30, height: 30, paddingRight: 5, verticalAlign: 'middle'}}/>
<img src={logoImage} alt="Maputnik" style={{width: 30, height: 30, paddingRight: 5, verticalAlign: 'middle'}}/>
<span style={{fontSize: 20, verticalAlign: 'middle' }}>Maputnik</span>
</ToolbarLink>
<ToolbarAction onClick={this.toggleModal.bind(this, 'open')}>
<MdOpenInBrowser />
<OpenIcon />
<IconText>Open</IconText>
</ToolbarAction>
{this.downloadButton()}
<ToolbarAction onClick={this.toggleModal.bind(this, 'add')}>
<AddIcon />
<IconText>Add Layer</IconText>
</ToolbarAction>
<ToolbarAction onClick={this.toggleModal.bind(this, 'sources')}>
<MdLayers />
<SourcesIcon />
<IconText>Sources</IconText>
</ToolbarAction>
<ToolbarAction onClick={this.toggleModal.bind(this, 'settings')}>
<MdSettings />
<SettingsIcon />
<IconText>Style Settings</IconText>
</ToolbarAction>
<ToolbarAction onClick={this.toggleModal.bind(this, 'settings')}>
<MdFindInPage />
<ToolbarAction onClick={this.toggleInspectionMode.bind(this)}>
<InspectionIcon />
<IconText>Inspect</IconText>
</ToolbarAction>
<ToolbarLink href={"https://github.com/maputnik/editor/wiki"}>
<MdHelpOutline />
<HelpIcon />
<IconText>Help</IconText>
</ToolbarLink>
</div>

View File

@@ -17,6 +17,7 @@ class ColorField extends React.Component {
value: React.PropTypes.string,
doc: React.PropTypes.string,
style: React.PropTypes.object,
default: React.PropTypes.string,
}
constructor(props) {
@@ -35,13 +36,13 @@ class ColorField extends React.Component {
const pos = elem.getBoundingClientRect()
return {
top: pos.top,
left: pos.left + 165,
left: pos.left + 196,
}
} else {
console.warn('Color field has no element to adjust position')
return {
top: 160,
left: 500,
left: 555,
}
}
}
@@ -81,7 +82,6 @@ class ColorField extends React.Component {
</div>
return <div style={{
...input.property,
position: 'relative',
display: 'inline',
}}>
@@ -90,7 +90,7 @@ class ColorField extends React.Component {
ref="colorInput"
onClick={this.togglePicker.bind(this)}
style={{
...input.select,
...input.input,
...this.props.style
}}
name={this.props.name}

View File

@@ -0,0 +1,50 @@
import React from 'react'
import input from '../../config/input.js'
import colors from '../../config/colors.js'
import { margins, fontSizes } from '../../config/scales.js'
export default class DocLabel extends React.Component {
static propTypes = {
label: React.PropTypes.string.isRequired,
doc: React.PropTypes.string.isRequired,
style: React.PropTypes.object,
}
constructor(props) {
super(props)
this.state = { showDoc: false }
}
render() {
return <label
style={{
...input.label,
...this.props.style,
position: 'relative',
}}
>
<span
onMouseOver={e => this.setState({showDoc: true})}
onMouseOut={e => this.setState({showDoc: false})}
style={{
cursor: 'help',
}}
>
{this.props.label}
</span>
<div style={{
backgroundColor: colors.gray,
padding: margins[1],
fontSize: 10,
position: 'absolute',
top: 20,
left: 0,
width: 120,
display: this.state.showDoc ? null : 'none',
zIndex: 3,
}}>
{this.props.doc}
</div>
</label>
}
}

View File

@@ -1,36 +0,0 @@
import React from 'react'
import input from '../../config/input.js'
class EnumField extends React.Component {
static propTypes = {
onChange: React.PropTypes.func.isRequired,
name: React.PropTypes.string.isRequired,
value: React.PropTypes.string,
allowedValues: React.PropTypes.array.isRequired,
doc: React.PropTypes.string,
style: React.PropTypes.object,
}
onChange(e) {
return this.props.onChange(e.target.value)
}
render() {
const options = this.props.allowedValues.map(val => {
return <option key={val} value={val}>{val}</option>
})
return <select
style={{
...input.select,
...this.props.style
}}
value={this.props.value}
onChange={this.onChange.bind(this)}
>
{options}
</select>
}
}
export default EnumField

View File

@@ -1,50 +0,0 @@
import React from 'react'
import input from '../../config/input.js'
/*** Number fields with support for min, max and units and documentation*/
class NumberField extends React.Component {
static propTypes = {
onChange: React.PropTypes.func.isRequired,
name: React.PropTypes.string.isRequired,
value: React.PropTypes.number,
default: React.PropTypes.number,
unit: React.PropTypes.string,
min: React.PropTypes.number,
max: React.PropTypes.number,
doc: React.PropTypes.string,
style: React.PropTypes.object,
}
onChange(e) {
const value = parseFloat(e.target.value)
/*TODO: we can do range validation already here?
if(this.props.min && value < this.props.min) return
if(this.props.max && value > this.props.max) return
*/
this.props.onChange(value)
}
render() {
let stepSize = null
if(this.props.max && this.props.min) {
stepSize = (this.props.max - this.props.min) / 10
}
return <input
style={{
...input.input,
...this.props.style
}}
type="number"
min={this.props.min}
max={this.props.max}
step={stepSize}
name={this.props.name}
placeholder={this.props.default}
value={this.props.value}
onChange={this.onChange.bind(this)}
/>
}
}
export default NumberField

View File

@@ -1,5 +1,5 @@
import React from 'react'
import GlSpec from 'mapbox-gl-style-spec/reference/latest.min.js'
import GlSpec from 'mapbox-gl-style-spec/reference/latest.js'
import ZoomSpecField from './ZoomSpecField'
import colors from '../../config/colors'
@@ -31,7 +31,7 @@ export default class PropertyGroup extends React.Component {
onPropertyChange(property, newValue) {
const group = getGroupName(this.props.layer.type, property)
this.props.onChange(group , property ,newValue)
this.props.onChange(group , property, newValue)
}
render() {
@@ -40,7 +40,7 @@ export default class PropertyGroup extends React.Component {
const paint = this.props.layer.paint || {}
const layout = this.props.layer.layout || {}
const fieldValue = paint[fieldName] || layout[fieldName]
const fieldValue = fieldName in paint ? paint[fieldName] : layout[fieldName]
return <ZoomSpecField
onChange={this.onPropertyChange.bind(this)}
@@ -51,11 +51,7 @@ export default class PropertyGroup extends React.Component {
/>
})
return <div style={{
padding: margins[2],
paddingRight: 0,
backgroundColor: colors.black,
}}>
return <div>
{fields}
</div>
}

View File

@@ -2,11 +2,16 @@ import React from 'react'
import color from 'color'
import GlSpec from 'mapbox-gl-style-spec/reference/latest.min.js'
import NumberField from './NumberField'
import EnumField from './EnumField'
import BooleanField from './BooleanField'
import ColorField from './ColorField'
import StringField from './StringField'
import NumberInput from '../inputs/NumberInput'
import CheckboxInput from '../inputs/CheckboxInput'
import StringInput from '../inputs/StringInput'
import SelectInput from '../inputs/SelectInput'
import MultiButtonInput from '../inputs/MultiButtonInput'
import ArrayInput from '../inputs/ArrayInput'
import FontInput from '../inputs/FontInput'
import capitalize from 'lodash.capitalize'
import input from '../../config/input.js'
@@ -18,6 +23,14 @@ function labelFromFieldName(fieldName) {
return label
}
function optionsLabelLength(options) {
let sum = 0;
options.forEach(([_, label]) => {
sum += label.length
})
return sum
}
/** Display any field from the Mapbox GL style spec and
* choose the correct field component based on the @{fieldSpec}
* to display @{value}. */
@@ -26,10 +39,10 @@ export default class SpecField extends React.Component {
onChange: React.PropTypes.func.isRequired,
fieldName: React.PropTypes.string.isRequired,
fieldSpec: React.PropTypes.object.isRequired,
value: React.PropTypes.oneOfType([
React.PropTypes.string,
React.PropTypes.number,
React.PropTypes.array,
]),
/** Override the style of the field */
style: React.PropTypes.object,
@@ -37,30 +50,36 @@ export default class SpecField extends React.Component {
render() {
const commonProps = {
doc: this.props.fieldSpec.doc,
style: this.props.style,
default: this.props.fieldSpec.default,
value: this.props.value,
name: this.props.fieldName,
onChange: newValue => this.props.onChange(this.props.fieldName, newValue)
}
switch(this.props.fieldSpec.type) {
case 'number': return (
<NumberField
<NumberInput
{...commonProps}
default={this.props.fieldSpec.default}
min={this.props.fieldSpec.minimum}
max={this.props.fieldSpec.maximum}
unit={this.props.fieldSpec.unit}
/>
)
case 'enum': return (
<EnumField
{...commonProps}
allowedValues={Object.keys(this.props.fieldSpec.values)}
/>
)
case 'enum':
const options = Object.keys(this.props.fieldSpec.values).map(v => [v, capitalize(v)])
if(options.length <= 3 && optionsLabelLength(options) <= 20) {
return <MultiButtonInput
{...commonProps}
options={options}
/>
} else {
return <SelectInput
{...commonProps}
options={options}
/>
}
case 'string': return (
<StringField
<StringInput
{...commonProps}
/>
)
@@ -70,10 +89,22 @@ export default class SpecField extends React.Component {
/>
)
case 'boolean': return (
<BooleanField
<CheckboxInput
{...commonProps}
/>
)
case 'array':
if(this.props.fieldName === 'text-font') {
return <FontInput
{...commonProps}
/>
} else {
return <ArrayInput
{...commonProps}
type={this.props.fieldSpec.value}
length={this.props.fieldSpec.length}
/>
}
default: return null
}
}

View File

@@ -1,34 +0,0 @@
import React from 'react'
import input from '../../config/input.js'
/*** Number fields with support for min, max and units and documentation*/
class StringField extends React.Component {
static propTypes = {
onChange: React.PropTypes.func.isRequired,
name: React.PropTypes.string.isRequired,
value: React.PropTypes.string,
default: React.PropTypes.number,
doc: React.PropTypes.string,
style: React.PropTypes.object,
}
onChange(e) {
const value = e.target.value
return this.props.onChange(value === "" ? null: value)
}
render() {
return <input
style={{
...input.input,
...this.props.style
}}
name={this.props.name}
placeholder={this.props.default}
value={this.props.value ? this.props.value : ""}
onChange={this.onChange.bind(this)}
/>
}
}
export default StringField

View File

@@ -1,27 +1,23 @@
import React from 'react'
import Color from 'color'
import NumberField from './NumberField'
import EnumField from './EnumField'
import BooleanField from './BooleanField'
import ColorField from './ColorField'
import StringField from './StringField'
import Button from '../Button'
import SpecField from './SpecField'
import NumberInput from '../inputs/NumberInput'
import DocLabel from './DocLabel'
import AddIcon from 'react-icons/lib/md/add-circle-outline'
import DeleteIcon from 'react-icons/lib/md/delete'
import capitalize from 'lodash.capitalize'
import input from '../../config/input.js'
import colors from '../../config/colors.js'
import { margins } from '../../config/scales.js'
import { margins, fontSizes } from '../../config/scales.js'
function isZoomField(value) {
return typeof value === 'object' && value.stops
}
const specFieldProps = {
onChange: React.PropTypes.func.isRequired,
fieldName: React.PropTypes.string.isRequired,
fieldSpec: React.PropTypes.object.isRequired,
}
/** Supports displaying spec field for zoom function objects
* https://www.mapbox.com/mapbox-gl-style-spec/#types-function-zoom-property
*/
@@ -39,50 +35,117 @@ export default class ZoomSpecField extends React.Component {
]),
}
addStop() {
const stops = this.props.value.stops.slice(0)
const lastStop = stops[stops.length - 1]
stops.push([lastStop[0] + 1, lastStop[1]])
const changedValue = {
...this.props.value,
stops: stops,
}
this.props.onChange(this.props.fieldName, changedValue)
}
deleteStop(stopIdx) {
const stops = this.props.value.stops.slice(0)
stops.splice(stopIdx, 1)
let changedValue = {
...this.props.value,
stops: stops,
}
if(stops.length === 1) {
changedValue = stops[0][1]
}
this.props.onChange(this.props.fieldName, changedValue)
}
changeStop(changeIdx, zoomLevel, value) {
const stops = this.props.value.stops.slice(0)
stops[changeIdx] = [zoomLevel, value]
const changedValue = {
...this.props.value,
stops: stops,
}
this.props.onChange(this.props.fieldName, changedValue)
}
render() {
const label = <label style={input.label}>
{labelFromFieldName(this.props.fieldName)}
</label>
let label = <DocLabel
label={labelFromFieldName(this.props.fieldName)}
doc={this.props.fieldSpec.doc}
style={{
width: '50%',
}}
/>
if(isZoomField(this.props.value)) {
const zoomFields = this.props.value.stops.map(stop => {
const zoomFields = this.props.value.stops.map((stop, idx) => {
label = <DocLabel
doc={this.props.fieldSpec.doc}
style={{ width: '42.5%'}}
label={idx > 0 ? "" : labelFromFieldName(this.props.fieldName)}
/>
if(idx === 1) {
label = <label style={{...input.label, width: '42.5%'}}>
<Button
style={{fontSize: fontSizes[5]}}
onClick={this.addStop.bind(this)}
>
Add stop
</Button>
</label>
}
const zoomLevel = stop[0]
const value = stop[1]
return <div style={input.property} key={zoomLevel}>
return <div key={zoomLevel} style={{
...input.property,
marginLeft: 0,
marginRight: 0
}}>
{label}
<SpecField {...this.props}
value={value}
<Button
style={{backgroundColor: null}}
onClick={this.deleteStop.bind(this, idx)}
>
<DeleteIcon />
</Button>
<NumberInput
style={{
width: '33%'
width: '7%',
}}
/>
<input
style={{
...input.input,
width: '10%',
marginLeft: margins[0],
}}
type="number"
value={zoomLevel}
onChange={changedStop => this.changeStop(idx, changedStop, value)}
min={0}
max={22}
/>
<SpecField
fieldName={this.props.fieldName}
fieldSpec={this.props.fieldSpec}
value={value}
onChange={(_, newValue) => this.changeStop(idx, zoomLevel, newValue)}
style={{
width: '42%',
marginLeft: margins[0],
}}
/>
</div>
})
return <div style={{
border: 1,
borderStyle: 'solid',
borderColor: Color(colors.gray).lighten(0.1).string(),
padding: margins[1],
}}>
return <div style={input.property}>
{zoomFields}
</div>
} else {
return <div style={input.property}>
{label}
<SpecField {...this.props} />
<SpecField {...this.props} style={{ width: '50%' } }/>
</div>
}
}
@@ -90,8 +153,5 @@ export default class ZoomSpecField extends React.Component {
function labelFromFieldName(fieldName) {
let label = fieldName.split('-').slice(1).join(' ')
if(label.length > 0) {
label = label.charAt(0).toUpperCase() + label.slice(1);
}
return label
return capitalize(label)
}

View File

@@ -5,12 +5,28 @@ import input from '../../config/input.js'
import colors from '../../config/colors.js'
import { margins } from '../../config/scales.js'
import SelectInput from '../inputs/SelectInput'
import StringInput from '../inputs/StringInput'
import AutocompleteInput from '../inputs/AutocompleteInput'
const combiningFilterOps = ['all', 'any', 'none']
const setFilterOps = ['in', '!in']
const otherFilterOps = Object
.keys(GlSpec.filter_operator.values)
.filter(op => combiningFilterOps.indexOf(op) < 0)
function hasCombiningFilter(filter) {
return combiningFilterOps.indexOf(filter[0]) >= 0
}
function hasNestedCombiningFilter(filter) {
if(hasCombiningFilter(filter)) {
const combinedFilters = filter.slice(1)
return filter.slice(1).map(f => hasCombiningFilter(f)).filter(f => f == true).length > 0
}
return false
}
class CombiningOperatorSelect extends React.Component {
static propTypes = {
value: React.PropTypes.string.isRequired,
@@ -22,12 +38,16 @@ class CombiningOperatorSelect extends React.Component {
return <option key={op} value={op}>{op}</option>
})
return <div>
return <div
style={{
marginTop: margins[1],
marginBottom: margins[1],
}}
>
<select
style={{
...input.select,
width: '20.5%',
margin: margins[0],
}}
value={this.props.value}
onChange={e => this.props.onChange(e.target.value)}
@@ -52,20 +72,15 @@ class OperatorSelect extends React.Component {
}
render() {
const options = otherFilterOps.map(op => {
return <option key={op} value={op}>{op}</option>
})
return <select
return <SelectInput
style={{
...input.select,
width: '15%',
margin: margins[0]
}}
value={this.props.value}
onChange={e => this.props.onChange(e.target.value)}
>
{options}
</select>
onChange={this.props.onChange}
options={otherFilterOps.map(op => [op, op])}
/>
}
}
@@ -91,33 +106,30 @@ class SingleFilterEditor extends React.Component {
const propertyName = f[1]
const filterArgs = f.slice(2)
return <div>
<select
style={{
...input.select,
width: '17%',
margin: margins[0]
return <div style={{
marginTop: margins[1],
marginBottom: margins[1],
}}>
<AutocompleteInput
wrapperStyle={{
width: '31%',
marginRight: margins[0]
}}
value={propertyName}
options={Object.keys(this.props.properties).map(propName => [propName, propName])}
onChange={newPropertyName => this.onFilterPartChanged(filterOp, newPropertyName, filterArgs)}
>
{Object.keys(this.props.properties).map(propName => {
return <option key={propName} value={propName}>{propName}</option>
})}
</select>
/>
<OperatorSelect
value={filterOp}
onChange={newFilterOp => this.onFilterPartChanged(newFilterOp, propertyName, filterArgs)}
/>
<input
<StringInput
style={{
...input.input,
width: '53%',
margin: margins[0]
width: '50%',
marginLeft: margins[0]
}}
value={filterArgs.join(',')}
onChange={e => {
this.onFilterPartChanged(filterOp, propertyName, e.target.value.split(','))}}
onChange={ v=> this.onFilterPartChanged(filterOp, propertyName, v.split(','))}
/>
</div>
}
@@ -165,11 +177,12 @@ export default class CombiningFilterEditor extends React.Component {
/>
})
return <div style={{
padding: margins[2],
paddingRight: 0,
backgroundColor: colors.black
}}>
//TODO: Implement support for nested filter
if(hasNestedCombiningFilter(filter)) {
return null
}
return <div>
<CombiningOperatorSelect
value={combiningOp}
onChange={this.onFilterPartChanged.bind(this, 0)}

View File

@@ -0,0 +1,15 @@
import React from 'react'
import IconBase from 'react-icon-base'
export default class FillIcon extends React.Component {
render() {
return (
<IconBase viewBox="0 0 20 20" {...this.props}>
<path transform="translate(2 2)" d="M7.5,0C11.6422,0,15,3.3578,15,7.5S11.6422,15,7.5,15 S0,11.6422,0,7.5S3.3578,0,7.5,0z M7.5,1.6666c-3.2217,0-5.8333,2.6117-5.8333,5.8334S4.2783,13.3334,7.5,13.3334 s5.8333-2.6117,5.8333-5.8334S10.7217,1.6666,7.5,1.6666z"></path>
</IconBase>
)
}
}

View File

@@ -4,6 +4,7 @@ import LineIcon from './LineIcon.jsx'
import FillIcon from './FillIcon.jsx'
import SymbolIcon from './SymbolIcon.jsx'
import BackgroundIcon from './BackgroundIcon.jsx'
import CircleIcon from './CircleIcon.jsx'
class LayerIcon extends React.Component {
static propTypes = {
@@ -14,10 +15,12 @@ class LayerIcon extends React.Component {
render() {
const iconProps = { style: this.props.style }
switch(this.props.type) {
case 'fill-extrusion': return <BackgroundIcon {...iconProps} />
case 'fill': return <FillIcon {...iconProps} />
case 'background': return <BackgroundIcon {...iconProps} />
case 'line': return <LineIcon {...iconProps} />
case 'symbol': return <SymbolIcon {...iconProps} />
case 'circle': return <CircleIcon {...iconProps} />
default: return null
}
}

View File

@@ -0,0 +1,58 @@
import React from 'react'
import input from '../../config/input.js'
import StringInput from './StringInput'
import NumberInput from './NumberInput'
import { margins } from '../../config/scales.js'
class ArrayInput extends React.Component {
static propTypes = {
value: React.PropTypes.array,
type: React.PropTypes.string,
length: React.PropTypes.number,
default: React.PropTypes.array,
style: React.PropTypes.object,
onChange: React.PropTypes.func,
}
changeValue(idx, newValue) {
console.log(idx, newValue)
const values = this.values.slice(0)
values[idx] = newValue
this.props.onChange(values)
}
get values() {
return this.props.value || this.props.default || []
}
render() {
const commonStyle = {
width: '49%',
marginRight: '1%',
}
const inputs = this.values.map((v, i) => {
if(this.props.type === 'number') {
return <NumberInput
key={i}
value={v}
style={commonStyle}
onChange={this.changeValue.bind(this, i)}
/>
} else {
return <StringInput
key={i}
value={v}
style={commonStyle}
onChange={this.changeValue.bind(this, i)}
/>
}
})
return <div style={{display: 'inline-block', width: '50%'}}>
{inputs}
</div>
}
}
export default ArrayInput

View File

@@ -0,0 +1,68 @@
import React from 'react'
import Autocomplete from 'react-autocomplete'
import input from '../../config/input'
import colors from '../../config/colors'
import { margins, fontSizes } from '../../config/scales'
class AutocompleteInput extends React.Component {
static propTypes = {
value: React.PropTypes.string,
options: React.PropTypes.array,
wrapperStyle: React.PropTypes.object,
inputStyle: React.PropTypes.object,
onChange: React.PropTypes.func,
}
static defaultProps = {
onChange: () => {},
options: [],
}
render() {
return <Autocomplete
menuStyle={{
border: 'none',
padding: '2px 0',
position: 'fixed',
overflow: 'auto',
maxHeight: '50%',
background: colors.gray,
zIndex: 3,
}}
wrapperStyle={{
display: 'inline-block',
...this.props.wrapperStyle
}}
inputProps={{
style: {
...input.input,
width: '100%',
...this.props.inputStyle,
}
}}
value={this.props.value}
items={this.props.options}
getItemValue={(item) => item[0]}
onSelect={v => this.props.onChange(v)}
onChange={(e, v) => this.props.onChange(v)}
renderItem={(item, isHighlighted) => (
<div
key={item[0]}
style={{
userSelect: 'none',
color: colors.lowgray,
cursor: 'default',
background: isHighlighted ? colors.midgray : colors.gray,
padding: margins[0],
fontSize: fontSizes[5],
zIndex: 3,
}}
>
{item[1]}
</div>
)}
/>
}
}
export default AutocompleteInput

View File

@@ -1,16 +1,13 @@
import React from 'react'
import input from '../../config/input'
import input from '../../config/input.js'
import colors from '../../config/colors'
import { margins } from '../../config/scales'
class BooleanField extends React.Component {
class CheckboxInput extends React.Component {
static propTypes = {
onChange: React.PropTypes.func.isRequired,
name: React.PropTypes.string.isRequired,
value: React.PropTypes.bool,
doc: React.PropTypes.string,
value: React.PropTypes.string,
style: React.PropTypes.object,
onChange: React.PropTypes.func,
}
render() {
@@ -57,7 +54,6 @@ class BooleanField extends React.Component {
...styles.input,
...this.props.style,
}}
value={this.props.value}
onChange={e => {this.props.onChange(!this.props.value)}}
checked={this.props.value}
/>
@@ -72,4 +68,4 @@ class BooleanField extends React.Component {
}
}
export default BooleanField
export default CheckboxInput

View File

@@ -0,0 +1,41 @@
import React from 'react'
import AutocompleteInput from './AutocompleteInput'
import input from '../../config/input.js'
//TODO: Query available font stack dynamically
import fontStacks from '../../config/fontstacks.json'
class FontInput extends React.Component {
static propTypes = {
value: React.PropTypes.array.isRequired,
style: React.PropTypes.object,
onChange: React.PropTypes.func.isRequired,
}
get values() {
return this.props.value || this.props.default.slice(1) || []
}
changeFont(idx, newValue) {
const changedValues = this.values.slice(0)
changedValues[idx] = newValue
this.props.onChange(changedValues)
}
render() {
const inputs = this.values.map((value, i) => {
return <AutocompleteInput
key={i}
value={value}
options={fontStacks.map(f => [f, f])}
onChange={this.changeFont.bind(this, i)}
/>
})
return <div style={{display: 'inline-block'}}>
{inputs}
</div>
}
}
export default FontInput

View File

@@ -7,6 +7,7 @@ class InputBlock extends React.Component {
static propTypes = {
label: React.PropTypes.string.isRequired,
children: React.PropTypes.element.isRequired,
style: React.PropTypes.object,
}
onChange(e) {
@@ -14,14 +15,30 @@ class InputBlock extends React.Component {
return this.props.onChange(value === "" ? null: value)
}
renderChildren() {
return React.Children.map(this.props.children, child => {
return React.cloneElement(child, {
style: {
...child.props.style,
width: '50%',
}
})
})
}
render() {
return <div style={{
display: 'block',
marginTop: margins[2],
marginBottom: margins[2],
...input.property,
...this.props.style,
}}>
<label style={input.label}>{this.props.label}</label>
{this.props.children}
<label
style={{
...input.label,
width: '50%',
}}>
{this.props.label}
</label>
{this.renderChildren()}
</div>
}
}

View File

@@ -0,0 +1,38 @@
import React from 'react'
import Button from '../Button'
import colors from '../../config/colors'
import { margins } from '../../config/scales'
import input from '../../config/input.js'
class MultiButtonInput extends React.Component {
static propTypes = {
value: React.PropTypes.string.isRequired,
options: React.PropTypes.array.isRequired,
style: React.PropTypes.object,
onChange: React.PropTypes.func.isRequired,
}
render() {
const selectedValue = this.props.value || this.props.options[0][0]
const buttons = this.props.options.map(([val, label])=> {
return <Button
key={val}
style={{
marginRight: margins[0],
backgroundColor: val === selectedValue ? colors.midgray : colors.gray,
}}
onClick={e => this.props.onChange(val)}
>
{label}
</Button>
})
return <div style={{display: 'inline-block'}}>
{buttons}
</div>
}
}
export default MultiButtonInput

View File

@@ -0,0 +1,83 @@
import React from 'react'
import input from '../../config/input.js'
class NumberInput extends React.Component {
static propTypes = {
value: React.PropTypes.number,
style: React.PropTypes.object,
default: React.PropTypes.number,
min: React.PropTypes.number,
max: React.PropTypes.number,
onChange: React.PropTypes.func,
}
constructor(props) {
super(props)
this.state = {
value: props.value
}
}
componentWillReceiveProps(nextProps) {
this.setState({ value: nextProps.value })
}
changeValue(newValue) {
const value = parseFloat(newValue)
const hasChanged = this.state.value !== value
if(this.isValid(value) && hasChanged) {
this.props.onChange(value)
} else {
this.setState({ value: newValue })
}
}
isValid(v) {
const value = parseFloat(v)
if(isNaN(value)) {
return false
}
if(!isNaN(this.props.min) && value < this.props.min) {
return false
}
if(!isNaN(this.props.max) && value > this.props.max) {
return false
}
return true
}
resetValue() {
// Reset explicitly to default value if value has been cleared
if(this.state.value === "") {
return this.changeValue(this.props.default)
}
// If set value is invalid fall back to the last valid value from props or at last resort the default value
if(!this.isValid(this.state.value)) {
if(this.isValid(this.props.value)) {
this.changeValue(this.props.value)
} else {
this.changeValue(this.props.default)
}
}
}
render() {
return <input
style={{
...input.input,
...this.props.style
}}
placeholder={this.props.default}
value={this.state.value}
onChange={e => this.changeValue(e.target.value)}
onBlur={this.resetValue.bind(this)}
/>
}
}
export default NumberInput

View File

@@ -5,6 +5,7 @@ class StringInput extends React.Component {
static propTypes = {
value: React.PropTypes.string,
style: React.PropTypes.object,
default: React.PropTypes.number,
onChange: React.PropTypes.func,
}
@@ -15,6 +16,7 @@ class StringInput extends React.Component {
...this.props.style
}}
value={this.props.value}
placeholder={this.props.default}
onChange={e => this.props.onChange(e.target.value)}
/>
}

View File

@@ -32,18 +32,37 @@ class JSONEditor extends React.Component {
})
}
shouldComponentUpdate(nextProps, nextState) {
try {
const parsedLayer = JSON.parse(this.state.code)
// If the structure is still the same do not update
// because it affects editing experience by reformatting all the time
return nextState.code !== JSON.stringify(parsedLayer, null, 2)
} catch(err) {
return true
}
}
onCodeUpdate(newCode) {
try {
const parsedLayer = JSON.parse(newCode)
this.props.onChange(parsedLayer)
} catch(err) {
console.warn(err)
} finally {
this.setState({
code: newCode
})
}
}
resetValue() {
console.log('reset')
this.setState({
code: JSON.stringify(this.props.layer, null, 2)
})
}
render() {
const codeMirrorOptions = {
mode: {name: "javascript", json: true},
@@ -51,11 +70,13 @@ class JSONEditor extends React.Component {
theme: 'maputnik',
viewportMargin: Infinity,
lineNumbers: false,
scrollbarStyle: "null",
}
return <CodeMirror
value={this.state.code}
onChange={this.onCodeUpdate.bind(this)}
onFocusChange={focused => focused ? true : this.resetValue()}
options={codeMirrorOptions}
/>
}

View File

@@ -1,12 +1,20 @@
import React from 'react'
import GlSpec from 'mapbox-gl-style-spec/reference/latest.min.js'
import JSONEditor from './JSONEditor'
import SourceEditor from './SourceEditor'
import FilterEditor from '../filter/FilterEditor'
import PropertyGroup from '../fields/PropertyGroup'
import LayerEditorGroup from './LayerEditorGroup'
import LayerSettings from './LayerSettings'
import LayerTypeBlock from './LayerTypeBlock'
import LayerIdBlock from './LayerIdBlock'
import LayerSourceBlock from './LayerSourceBlock'
import LayerSourceLayerBlock from './LayerSourceLayerBlock'
import InputBlock from '../inputs/InputBlock'
import MultiButtonInput from '../inputs/MultiButtonInput'
import input from '../../config/input.js'
import { changeType, changeProperty } from '../../libs/layer'
import layout from '../../config/layout.json'
import { margins, fontSizes } from '../../config/scales'
import colors from '../../config/colors'
@@ -72,32 +80,8 @@ export default class LayerEditor extends React.Component {
}
}
/** A {@property} in either the paint our layout {@group} has changed
* to a {@newValue}.
*/
onPropertyChange(group, property, newValue) {
if(group) {
this.props.onLayerChanged({
...this.props.layer,
[group]: {
...this.props.layer[group],
[property]: newValue
}
})
} else {
this.props.onLayerChanged({
...this.props.layer,
[property]: newValue
})
}
}
onFilterChange(newValue) {
const changedLayer = {
...this.props.layer,
filter: newValue
}
this.props.onLayerChanged(changedLayer)
changeProperty(group, property, newValue) {
this.props.onLayerChanged(changeProperty(this.props.layer, group, property, newValue))
}
onGroupToggle(groupTitle, active) {
@@ -112,30 +96,41 @@ export default class LayerEditor extends React.Component {
renderGroupType(type, fields) {
switch(type) {
case 'settings': return <LayerSettings
id={this.props.layer.id}
type={this.props.layer.type}
onTypeChange={v => this.onPropertyChange(null, 'type', v)}
onIdChange={newId => this.props.onLayerIdChange(this.props.layer.id, newId)}
/>
case 'settings': return <div>
<LayerIdBlock
value={this.props.layer.id}
onChange={newId => this.props.onLayerIdChange(this.props.layer.id, newId)}
/>
<LayerTypeBlock
value={this.props.layer.type}
onChange={newType => this.props.onLayerChanged(changeType(this.props.layer, newType))}
/>
</div>
case 'source': return <div>
<FilterEditor
filter={this.props.layer.filter}
properties={this.props.vectorLayers[this.props.layer['source-layer']]}
onChange={f => this.onFilterChange(f)}
<LayerSourceBlock
sourceIds={Object.keys(this.props.sources)}
value={this.props.layer.source}
onChange={v => this.changeProperty(null, 'source', v)}
/>
<SourceEditor
source={this.props.layer.source}
sourceLayer={this.props.layer['source-layer']}
sources={this.props.sources}
onSourceChange={console.log}
onSourceLayerChange={console.log}
<LayerSourceLayerBlock
sourceLayerIds={this.props.sources[this.props.layer.source]}
value={this.props.layer['source-layer']}
onChange={v => this.changeProperty(null, 'source-layer', v)}
/>
{this.props.layer.filter &&
<div style={input.property}>
<FilterEditor
filter={this.props.layer.filter}
properties={this.props.vectorLayers[this.props.layer['source-layer']]}
onChange={f => this.changeProperty(null, 'filter', f)}
/>
</div>
}
</div>
case 'properties': return <PropertyGroup
layer={this.props.layer}
groupFields={fields}
onChange={this.onPropertyChange.bind(this)}
onChange={this.changeProperty.bind(this)}
/>
case 'jsoneditor': return <JSONEditor
layer={this.props.layer}

View File

@@ -0,0 +1,22 @@
import React from 'react'
import InputBlock from '../inputs/InputBlock'
import StringInput from '../inputs/StringInput'
class LayerIdBlock extends React.Component {
static propTypes = {
value: React.PropTypes.string.isRequired,
onChange: React.PropTypes.func.isRequired,
}
render() {
return <InputBlock label={"Layer ID"}>
<StringInput
value={this.props.value}
onChange={this.props.onChange}
/>
</InputBlock>
}
}
export default LayerIdBlock

View File

@@ -23,8 +23,8 @@ class LayerTypeDragHandle extends React.Component {
{...this.props}
style={{
cursor: 'move',
width: 15,
height: 15,
width: fontSizes[4],
height: fontSizes[4],
paddingRight: margins[0],
}}
/>
@@ -115,7 +115,7 @@ class LayerListItem extends React.Component {
getChildContext() {
return {
reactIconBase: { size: 12 }
reactIconBase: { size: fontSizes[4] }
}
}
@@ -137,6 +137,7 @@ class LayerListItem extends React.Component {
padding: margins[1],
borderColor: Color(colors.black).lighten(0.10).string(),
backgroundColor: colors.black,
lineHeight: 1.3,
}
if(this.state.hover) {

View File

@@ -1,50 +0,0 @@
import React from 'react'
import GlSpec from 'mapbox-gl-style-spec/reference/latest.min.js'
import InputBlock from '../inputs/InputBlock'
import StringInput from '../inputs/StringInput'
import SelectInput from '../inputs/SelectInput'
import colors from '../../config/colors'
import { margins } from '../../config/scales'
class LayerSettings extends React.Component {
static propTypes = {
id: React.PropTypes.string.isRequired,
type: React.PropTypes.oneOf(Object.keys(GlSpec.layer.type.values)).isRequired,
onIdChange: React.PropTypes.func.isRequired,
onTypeChange: React.PropTypes.func.isRequired,
}
render() {
return <div style={{
padding: margins[2],
paddingRight: 0,
backgroundColor: colors.black,
}}>
<InputBlock label={"Layer ID"}>
<StringInput
value={this.props.id}
onChange={this.props.onIdChange}
/>
</InputBlock>
<InputBlock label={"Layer Type"}>
<SelectInput
options={[
['background', 'Background'],
['fill', 'Fill'],
['line', 'Line'],
['symbol', 'Symbol'],
['raster', 'Raster'],
['circle', 'Circle'],
['fill-extrusion', 'Fill Extrusion'],
]}
onChange={this.props.onTypeChange}
value={this.props.type}
/>
</InputBlock>
</div>
}
}
export default LayerSettings

View File

@@ -0,0 +1,32 @@
import React from 'react'
import InputBlock from '../inputs/InputBlock'
import StringInput from '../inputs/StringInput'
import SelectInput from '../inputs/SelectInput'
import AutocompleteInput from '../inputs/AutocompleteInput'
class LayerSourceBlock extends React.Component {
static propTypes = {
value: React.PropTypes.string,
onChange: React.PropTypes.func,
sourceIds: React.PropTypes.array,
}
static defaultProps = {
onChange: () => {},
sourceIds: [],
}
render() {
return <InputBlock label={"Source"}>
<AutocompleteInput
value={this.props.value}
onChange={this.props.onChange}
options={this.props.sourceIds.map(src => [src, src])}
wrapperStyle={{ width: '50%' }}
/>
</InputBlock>
}
}
export default LayerSourceBlock

View File

@@ -0,0 +1,32 @@
import React from 'react'
import InputBlock from '../inputs/InputBlock'
import StringInput from '../inputs/StringInput'
import SelectInput from '../inputs/SelectInput'
import AutocompleteInput from '../inputs/AutocompleteInput'
class LayerSourceLayer extends React.Component {
static propTypes = {
value: React.PropTypes.string,
onChange: React.PropTypes.func,
sourceLayerIds: React.PropTypes.array,
}
static defaultProps = {
onChange: () => {},
sourceLayerIds: [],
}
render() {
return <InputBlock label={"Source Layer"}>
<AutocompleteInput
value={this.props.value}
onChange={this.props.onChange}
options={this.props.sourceLayerIds.map(l => [l, l])}
wrapperStyle={{ width: '50%' }}
/>
</InputBlock>
}
}
export default LayerSourceLayer

View File

@@ -0,0 +1,31 @@
import React from 'react'
import InputBlock from '../inputs/InputBlock'
import SelectInput from '../inputs/SelectInput'
class LayerTypeBlock extends React.Component {
static propTypes = {
value: React.PropTypes.string.isRequired,
onChange: React.PropTypes.func.isRequired,
}
render() {
return <InputBlock label={"Layer Type"}>
<SelectInput
options={[
['background', 'Background'],
['fill', 'Fill'],
['line', 'Line'],
['symbol', 'Symbol'],
['raster', 'Raster'],
['circle', 'Circle'],
['fill-extrusion', 'Fill Extrusion'],
]}
onChange={this.props.onChange}
value={this.props.value}
/>
</InputBlock>
}
}
export default LayerTypeBlock

View File

@@ -1,54 +0,0 @@
import React from 'react'
import PropertyGroup from '../fields/PropertyGroup'
import input from '../../config/input.js'
/** Choose tileset (source) and the source layer */
export default class SourceEditor extends React.Component {
static propTypes = {
source: React.PropTypes.string.isRequired,
sourceLayer: React.PropTypes.string.isRequired,
onSourceChange: React.PropTypes.func.isRequired,
onSourceLayerChange: React.PropTypes.func.isRequired,
/** List of available sources in the style
* https://www.mapbox.com/mapbox-gl-style-spec/#root-sources */
sources: React.PropTypes.object.isRequired,
}
render() {
const options = Object.keys(this.props.sources).map(sourceId => {
return <option key={sourceId} value={sourceId}>{sourceId}</option>
})
const layerOptions = this.props.sources[this.props.source].map(vectorLayerId => {
const id = vectorLayerId
return <option key={id} value={id}>{id}</option>
})
return <div>
<div style={input.property}>
<label style={input.label}>Source</label>
<select
style={input.select}
value={this.props.source}
onChange={(e) => this.onSourceChange(e.target.value)}
>
{options}
</select>
</div>
<div style={input.property}>
<label style={input.label}>Source Layer</label>
<select
style={input.select}
value={this.props.sourceLayer}
onChange={(e) => this.onSourceLayerChange(e.target.value)}
>
{layerOptions}
</select>
</div>
</div>
}
}

View File

@@ -0,0 +1,66 @@
import React from 'react'
import InputBlock from '../inputs/InputBlock'
import StringInput from '../inputs/StringInput'
import LayerIcon from '../icons/LayerIcon'
import input from '../../config/input'
import colors from '../../config/colors'
import { margins, fontSizes } from '../../config/scales'
const Panel = (props) => {
return <div style={{
backgroundColor: colors.gray,
padding: margins[0],
fontSize: fontSizes[5],
lineHeight: 1.2,
}}>{props.children}</div>
}
function renderFeature(feature) {
return <div>
<Panel>{feature.layer['source-layer']}</Panel>
</div>
}
function groupFeaturesBySourceLayer(features) {
const sources = {}
features.forEach(feature => {
sources[feature.layer['source-layer']] = sources[feature.layer['source-layer']] || []
sources[feature.layer['source-layer']].push(feature)
})
return sources
}
class FeatureLayerTable extends React.Component {
render() {
const sources = groupFeaturesBySourceLayer(this.props.features)
const items = Object.keys(sources).map(vectorLayerId => {
const layers = sources[vectorLayerId].map(feature => {
return <label style={{
...input.label,
display: 'block',
width: 'auto',
}}>
<LayerIcon type={feature.layer.type} style={{
width: fontSizes[4],
height: fontSizes[4],
paddingRight: margins[0],
}}/>
{feature.layer.id}
</label>
})
return <div>
<Panel>{vectorLayerId}</Panel>
{layers}
</div>
})
return <div>
{items}
</div>
}
}
export default FeatureLayerTable

View File

@@ -0,0 +1,45 @@
import React from 'react'
import InputBlock from '../inputs/InputBlock'
import StringInput from '../inputs/StringInput'
import colors from '../../config/colors'
import { margins, fontSizes } from '../../config/scales'
function renderProperties(feature) {
return Object.keys(feature.properties).map(propertyName => {
const property = feature.properties[propertyName]
return <InputBlock label={propertyName} style={{marginTop: 0, marginBottom: 0}}>
<StringInput value={property} style={{backgroundColor: 'transparent'}}/>
</InputBlock>
})
}
const Panel = (props) => {
return <div style={{
backgroundColor: colors.gray,
padding: margins[0],
fontSize: fontSizes[5],
lineHeight: 1.2,
}}>{props.children}</div>
}
function renderFeature(feature) {
console.log(feature)
return <div>
<Panel>{feature.layer['source-layer']}</Panel>
{renderProperties(feature)}
</div>
}
class FeaturePropertyPopup extends React.Component {
render() {
const features = this.props.features
return <div>
{features.map(renderFeature)}
</div>
}
}
export default FeaturePropertyPopup

View File

@@ -0,0 +1,126 @@
import React from 'react'
import ReactDOM from 'react-dom'
import MapboxGl from 'mapbox-gl/dist/mapbox-gl.js'
import validateColor from 'mapbox-gl-style-spec/lib/validate/validate_color'
import colors from '../../config/colors'
import style from '../../libs/style'
import FeaturePropertyPopup from './FeaturePropertyPopup'
import { colorHighlightedLayer, generateColoredLayers } from '../../libs/stylegen'
import 'mapbox-gl/dist/mapbox-gl.css'
import '../../mapboxgl.css'
function convertInspectStyle(mapStyle, sources, highlightedLayer) {
const coloredLayers = generateColoredLayers(sources)
const layer = colorHighlightedLayer(highlightedLayer)
if(layer) {
coloredLayers.push(layer)
}
const newStyle = {
...mapStyle,
layers: [
{
"id": "background",
"type": "background",
"paint": {
"background-color": colors.black,
}
},
...coloredLayers,
]
}
return newStyle
}
function renderPopup(features) {
var mountNode = document.createElement('div');
ReactDOM.render(<FeaturePropertyPopup features={features} />, mountNode)
return mountNode.innerHTML;
}
export default class InspectionMap extends React.Component {
static propTypes = {
onDataChange: React.PropTypes.func,
sources: React.PropTypes.object,
originalStyle: React.PropTypes.object,
highlightedLayer: React.PropTypes.object,
style: React.PropTypes.object,
}
static defaultProps = {
onMapLoaded: () => {},
onTileLoaded: () => {},
}
constructor(props) {
super(props)
this.state = { map: null }
}
componentWillReceiveProps(nextProps) {
if(!this.state.map) return
this.state.map.setStyle(convertInspectStyle(nextProps.mapStyle, this.props.sources, nextProps.highlightedLayer), { diff: true})
}
componentDidMount() {
MapboxGl.accessToken = this.props.accessToken
const map = new MapboxGl.Map({
container: this.container,
style: convertInspectStyle(this.props.mapStyle, this.props.sources, this.props.highlightedLayer),
hash: true,
})
const nav = new MapboxGl.NavigationControl();
map.addControl(nav, 'top-right');
map.on("style.load", () => {
this.setState({ map });
})
map.on("data", e => {
if(e.dataType !== 'tile') return
this.props.onDataChange({
map: this.state.map
})
})
map.on('click', this.displayPopup.bind(this))
map.on('mousemove', function(e) {
var features = map.queryRenderedFeatures(e.point, { layers: this.layers })
map.getCanvas().style.cursor = (features.length) ? 'pointer' : ''
})
}
displayPopup(e) {
const features = this.state.map.queryRenderedFeatures(e.point, {
layers: this.layers
});
if (!features.length) {
return
}
// Populate the popup and set its coordinates
// based on the feature found.
const popup = new MapboxGl.Popup()
.setLngLat(e.lngLat)
.setHTML(renderPopup(features))
.addTo(this.state.map)
}
render() {
return <div
ref={x => this.container = x}
style={{
position: "fixed",
top: 0,
bottom: 0,
height: "100%",
width: "100%",
...this.props.style,
}}></div>
}
}

View File

@@ -1,20 +0,0 @@
import React from 'react'
export default class Map extends React.Component {
static propTypes = {
mapStyle: React.PropTypes.object.isRequired,
accessToken: React.PropTypes.string,
}
render() {
return <div
ref={x => this.container = x}
style={{
position: "fixed",
top: 0,
bottom: 0,
height: "100%",
width: "100%",
}}></div>
}
}

View File

@@ -1,23 +1,41 @@
import React from 'react'
import MapboxGl from 'mapbox-gl'
import ReactDOM from 'react-dom'
import MapboxGl from 'mapbox-gl/dist/mapbox-gl.js'
import FeatureLayerTable from './FeatureLayerTable'
import validateColor from 'mapbox-gl-style-spec/lib/validate/validate_color'
import Map from './Map.jsx'
import style from '../../libs/style.js'
import 'mapbox-gl/dist/mapbox-gl.css'
import '../../mapboxgl.css'
export default class MapboxGlMap extends Map {
function renderPopup(features) {
var mountNode = document.createElement('div');
ReactDOM.render(<FeatureLayerTable features={features} />, mountNode)
return mountNode.innerHTML;
}
export default class MapboxGlMap extends React.Component {
static propTypes = {
onMapLoaded: React.PropTypes.func,
onDataChange: React.PropTypes.func,
mapStyle: React.PropTypes.object.isRequired,
accessToken: React.PropTypes.string,
style: React.PropTypes.object,
}
static defaultProps = {
onMapLoaded: () => {}
onMapLoaded: () => {},
onDataChange: () => {},
}
constructor(props) {
super(props)
this.state = { map: null }
this.state = {
map: null,
isPopupOpen: false,
popupX: 0,
popupY: 0,
}
}
componentWillReceiveProps(nextProps) {
if(!this.state.map) return
@@ -32,11 +50,53 @@ export default class MapboxGlMap extends Map {
const map = new MapboxGl.Map({
container: this.container,
style: this.props.mapStyle,
});
hash: true,
})
map.on("style.load", (...args) => {
this.props.onMapLoaded(map)
const nav = new MapboxGl.NavigationControl();
map.addControl(nav, 'top-right');
map.on("style.load", () => {
this.setState({ map });
});
})
map.on("data", e => {
if(e.dataType !== 'tile') return
this.props.onDataChange({
map: this.state.map
})
})
map.on('click', this.displayPopup.bind(this));
map.on('mousemove', function(e) {
var features = map.queryRenderedFeatures(e.point, { layers: this.layers })
map.getCanvas().style.cursor = (features.length) ? 'pointer' : ''
})
}
displayPopup(e) {
const features = this.state.map.queryRenderedFeatures(e.point, {
layers: this.layers
});
if(features.length < 1) return
const popup = new MapboxGl.Popup()
.setLngLat(e.lngLat)
.setHTML(renderPopup(features))
.addTo(this.state.map)
}
render() {
return <div
ref={x => this.container = x}
style={{
position: "fixed",
top: 0,
bottom: 0,
height: "100%",
width: "100%",
...this.props.style,
}}>
</div>
}
}

View File

@@ -1,10 +1,16 @@
import React from 'react'
import Map from './Map'
import style from '../../libs/style.js'
class OpenLayers3Map extends Map {
constructor(props) {
super(props)
class OpenLayers3Map extends React.Component {
static propTypes = {
onDataChange: React.PropTypes.func,
mapStyle: React.PropTypes.object.isRequired,
accessToken: React.PropTypes.string,
}
static defaultProps = {
onMapLoaded: () => {},
onDataChange: () => {},
}
componentWillReceiveProps(nextProps) {
@@ -63,6 +69,18 @@ class OpenLayers3Map extends Map {
this.setState({ map });
})
}
render() {
return <div
ref={x => this.container = x}
style={{
position: "fixed",
top: 0,
bottom: 0,
height: "100%",
width: "100%",
}}></div>
}
}
export default OpenLayers3Map

View File

@@ -0,0 +1,108 @@
import React from 'react'
import Button from '../Button'
import InputBlock from '../inputs/InputBlock'
import StringInput from '../inputs/StringInput'
import SelectInput from '../inputs/SelectInput'
import Modal from './Modal'
import colors from '../../config/colors'
import LayerTypeBlock from '../layers/LayerTypeBlock'
import LayerIdBlock from '../layers/LayerIdBlock'
import LayerSourceBlock from '../layers/LayerSourceBlock'
import LayerSourceLayerBlock from '../layers/LayerSourceLayerBlock'
class AddModal extends React.Component {
static propTypes = {
mapStyle: React.PropTypes.object.isRequired,
onStyleChange: React.PropTypes.func.isRequired,
isOpen: React.PropTypes.bool.isRequired,
onOpenToggle: React.PropTypes.func.isRequired,
// A dict of source id's and the available source layers
sources: React.PropTypes.object.isRequired,
}
addLayer() {
const changedLayers = this.props.mapStyle.layers.slice(0)
const layer = {
id: this.state.id,
type: this.state.type,
}
if(this.state.type !== 'background') {
layer.source = this.state.source
layer['source-layer'] = this.state['source-layer']
}
changedLayers.push(layer)
const changedStyle = {
...this.props.mapStyle,
layers: changedLayers,
}
this.props.onStyleChange(changedStyle)
this.props.onOpenToggle(false)
}
constructor(props) {
super(props)
this.state = {
type: 'fill',
id: '',
}
if(props.sources.length > 0) {
this.state.source = Object.keys(this.props.sources)[0]
this.state['source-layer'] = this.props.sources[this.state.source][0]
}
}
componentWillReceiveProps(nextProps) {
const sourceIds = Object.keys(nextProps.sources)
if(!this.state.source && sourceIds.length > 0) {
this.setState({
source: sourceIds[0],
'source-layer': this.state['source-layer'] || nextProps.sources[sourceIds[0]][0]
})
}
}
render() {
return <Modal
isOpen={this.props.isOpen}
onOpenToggle={this.props.onOpenToggle}
title={'Add Layer'}
>
<LayerIdBlock
value={this.state.id}
onChange={v => this.setState({ id: v })}
/>
<LayerTypeBlock
value={this.state.type}
onChange={v => this.setState({ type: v })}
/>
{this.state.type !== 'background' &&
<LayerSourceBlock
sourceIds={Object.keys(this.props.sources)}
value={this.state.source}
onChange={v => this.setState({ source: v })}
/>
}
{this.state.type !== 'background' &&
<LayerSourceLayerBlock
sourceLayerIds={this.props.sources[this.state.source] || []}
value={this.state['source-layer']}
onChange={v => this.setState({ 'source-layer': v })}
/>
}
<Button onClick={this.addLayer.bind(this)}>
Add Layer
</Button>
</Modal>
}
}
export default AddModal

View File

@@ -76,7 +76,7 @@ class OpenModal extends React.Component {
withCredentials: false,
}, (error, response, body) => {
if (!error && response.statusCode == 200) {
const mapStyle = style.ensureMetadataExists(JSON.parse(body))
const mapStyle = style.ensureStyleValidity(JSON.parse(body))
console.log('Loaded style ', mapStyle.id)
this.props.onStyleOpen(mapStyle)
} else {
@@ -91,7 +91,7 @@ class OpenModal extends React.Component {
reader.readAsText(file, "UTF-8");
reader.onload = e => {
let mapStyle = JSON.parse(e.target.result)
mapStyle = style.ensureMetadataExists(mapStyle)
mapStyle = style.ensureStyleValidity(mapStyle)
this.props.onStyleOpen(mapStyle);
}
reader.onerror = e => console.log(e.target);

View File

@@ -18,23 +18,27 @@ class SettingsModal extends React.Component {
super(props);
}
onChange(property, e) {
const changedStyle = this.props.mapStyle.set(property, e.target.value)
changeStyleProperty(property, value) {
const changedStyle = {
...this.props.mapStyle,
[property]: value
}
this.props.onStyleChanged(changedStyle)
}
onRendererChange(renderer) {
changeMetadataProperty(property, value) {
const changedStyle = {
...this.props.mapStyle,
metadata: {
...this.props.mapStyle.metadata,
'maputnik:renderer': renderer,
[property]: value
}
}
this.props.onStyleChanged(changedStyle)
}
render() {
const metadata = this.props.mapStyle.metadata || {}
const inputProps = { }
return <Modal
isOpen={this.props.isOpen}
@@ -44,38 +48,45 @@ class SettingsModal extends React.Component {
<InputBlock label={"Name"}>
<StringInput {...inputProps}
value={this.props.mapStyle.name}
onChange={this.onChange.bind(this, "name")}
onChange={this.changeStyleProperty.bind(this, "name")}
/>
</InputBlock>
<InputBlock label={"Owner"}>
<StringInput {...inputProps}
value={this.props.mapStyle.owner}
onChange={this.onChange.bind(this, "owner")}
onChange={this.changeStyleProperty.bind(this, "owner")}
/>
</InputBlock>
<InputBlock label={"Sprite URL"}>
<StringInput {...inputProps}
value={this.props.mapStyle.sprite}
onChange={this.onChange.bind(this, "sprite")}
onChange={this.changeStyleProperty.bind(this, "sprite")}
/>
</InputBlock>
<InputBlock label={"Glyphs URL"}>
<StringInput {...inputProps}
value={this.props.mapStyle.glyphs}
onChange={this.onChange.bind(this, "glyphs")}
onChange={this.changeStyleProperty.bind(this, "glyphs")}
/>
</InputBlock>
<InputBlock label={"Access Token"}>
<StringInput {...inputProps}
value={metadata['maputnik:access_token']}
onChange={this.changeMetadataProperty.bind(this, "maputnik:access_token")}
/>
</InputBlock>
<InputBlock label={"Style Renderer"}>
<SelectInput {...inputProps}
options={[
['mbgljs', 'MapboxGL JS'],
['ol3', 'Open Layers 3']
['ol3', 'Open Layers 3'],
['inspection', 'Inspection Mode'],
]}
value={(this.props.mapStyle.metadata || {})['maputnik:renderer'] || 'mbgljs'}
onChange={this.onRendererChange.bind(this)}
value={metadata['maputnik:renderer'] || 'mbgljs'}
onChange={this.changeMetadataProperty.bind(this, 'maputnik:renderer')}
/>
</InputBlock>
</Modal>

View File

@@ -64,7 +64,7 @@ function editorMode(source) {
return 'tilejson'
}
class SourceEditorLayout extends React.Component {
class ActiveSourceTypeEditor extends React.Component {
static propTypes = {
sourceId: React.PropTypes.string.isRequired,
source: React.PropTypes.object.isRequired,
@@ -87,7 +87,7 @@ class SourceEditorLayout extends React.Component {
<span style={{fontWeight: 700, fontSize: fontSizes[4], lineHeight: 2}}>#{this.props.sourceId}</span>
<span style={{flexGrow: 1}} />
<Button
onClick={this.props.onSourceDelete}
onClick={()=> this.props.onSourceDelete(this.props.sourceId)}
style={{backgroundColor: 'transparent'}}
>
<DeleteIcon />
@@ -118,19 +118,30 @@ class AddSource extends React.Component {
super(props)
this.state = {
mode: 'tilejson',
source: {
id: style.generateId(),
}
sourceId: style.generateId(),
source: this.defaultSource('tilejson'),
}
}
onSourceIdChange(newId) {
this.setState({
source: {
...this.state.source,
id: newId,
defaultSource(mode) {
const source = (this.state || {}).source || {}
switch(mode) {
case 'geojson': return {
type: 'geojson',
data: source.data || 'http://localhost:3000/geojson.json'
}
})
case 'tilejson': return {
type: 'vector',
url: source.url || 'http://localhost:3000/tilejson.json'
}
case 'tilexyz': return {
type: 'vector',
tiles: source.tiles || ['http://localhost:3000/{x}/{y}/{z}.pbf'],
minZoom: source.minZoom || 0,
maxZoom: source.maxZoom || 14
}
default: return {}
}
}
onSourceChange(source) {
@@ -143,8 +154,8 @@ class AddSource extends React.Component {
return <div>
<InputBlock label={"Source ID"}>
<StringInput
value={this.state.source.id}
onChange={this.onSourceIdChange.bind(this)}
value={this.state.sourceId}
onChange={v => this.setState({ sourceId: v})}
/>
</InputBlock>
<InputBlock label={"Source Type"}>
@@ -154,7 +165,7 @@ class AddSource extends React.Component {
['tilejson', 'Vector (TileJSON URL)'],
['tilexyz', 'Vector (XYZ URLs)'],
]}
onChange={v => this.setState({mode: v})}
onChange={mode => this.setState({mode: mode, source: this.defaultSource(mode)})}
value={this.state.mode}
/>
</InputBlock>
@@ -163,7 +174,7 @@ class AddSource extends React.Component {
mode={this.state.mode}
source={this.state.source}
/>
<Button onClick={() => this.onSourceAdd(this.state.source)}>
<Button onClick={() => this.props.onSourceAdd(this.state.sourceId, this.state.source)}>
Add Source
</Button>
</div>
@@ -178,10 +189,10 @@ class SourcesModal extends React.Component {
onStyleChanged: React.PropTypes.func.isRequired,
}
onSourceAdd(source) {
onSourceAdd(sourceId, source) {
const changedSources = {
...this.props.mapStyle.sources,
[source.id]: source
[sourceId]: source
}
const changedStyle = {
@@ -192,24 +203,42 @@ class SourcesModal extends React.Component {
this.props.onStyleChanged(changedStyle)
}
deleteSource(sourceId) {
const remainingSources = { ...this.props.mapStyle.sources}
delete remainingSources[sourceId]
const changedStyle = {
...this.props.mapStyle,
sources: remainingSources
}
this.props.onStyleChanged(changedStyle)
}
stripTitle(source) {
const strippedSource = {...source}
delete strippedSource['title']
return strippedSource
}
render() {
const activeSources = Object.keys(this.props.mapStyle.sources).map(sourceId => {
const source = this.props.mapStyle.sources[sourceId]
return <SourceEditorLayout
return <ActiveSourceTypeEditor
key={sourceId}
sourceId={sourceId}
source={source}
onSourceDelete={this.deleteSource.bind(this)}
/>
})
const tilesetOptions = publicSources.filter(source => !(source.id in this.props.mapStyle.sources)).map(source => {
const tilesetOptions = Object.keys(publicSources).filter(sourceId => !(sourceId in this.props.mapStyle.sources)).map(sourceId => {
const source = publicSources[sourceId]
return <PublicSource
key={source.id}
id={source.id}
key={sourceId}
id={sourceId}
type={source.type}
title={source.title}
description={source.description}
onSelect={() => this.onSourceAdd(source)}
onSelect={() => this.onSourceAdd(sourceId, this.stripTitle(source))}
/>
})

View File

@@ -1,12 +0,0 @@
.darkScrollbar::-webkit-scrollbar {
background-color: #26282e;
width: 5px;
}
.darkScrollbar::-webkit-scrollbar-thumb {
border-radius: 6px;
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,.3);
background-color: #40444e;
padding-left: 2px;
padding-right: 2px;
}

View File

@@ -1,18 +1,22 @@
import React from 'react'
import InputBlock from '../inputs/InputBlock'
import StringInput from '../inputs/StringInput'
import NumberInput from '../inputs/NumberInput'
class TileJSONSourceEditor extends React.Component {
static propTypes = {
url: React.PropTypes.string.isRequired,
source: React.PropTypes.object.isRequired,
onChange: React.PropTypes.func,
}
render() {
return <InputBlock label={"TileJSON URL"}>
<StringInput
value={this.props.url}
onChange={this.props.onChange}
value={this.props.source.url}
onChange={url => this.props.onChange({
...this.props.source,
url: url
})}
/>
</InputBlock>
}
@@ -20,15 +24,14 @@ class TileJSONSourceEditor extends React.Component {
class TileURLSourceEditor extends React.Component {
static propTypes = {
tiles: React.PropTypes.array.isRequired,
minZoom: React.PropTypes.number.isRequired,
maxZoom: React.PropTypes.number.isRequired,
source: React.PropTypes.object.isRequired,
onChange: React.PropTypes.func,
}
renderTileUrls() {
const prefix = ['1st', '2nd', '3rd', '4th', '5th', '6th', '7th']
return this.props.tiles.map((tileUrl, tileIndex) => {
const tiles = this.props.source.tiles || []
return tiles.map((tileUrl, tileIndex) => {
return <InputBlock key={tileIndex} label={prefix[tileIndex] + " Tile URL"}>
<StringInput
value={tileUrl}
@@ -38,17 +41,24 @@ class TileURLSourceEditor extends React.Component {
}
render() {
console.log(this.props.tiles)
return <div>
{this.renderTileUrls()}
<InputBlock label={"Min Zoom"}>
<StringInput
value={this.props.minZoom}
<NumberInput
value={this.props.source.minZoom}
onChange={minZoom => this.props.onChange({
...this.props.source,
minZoom: minZoom
})}
/>
</InputBlock>
<InputBlock label={"Max Zoom"}>
<StringInput
value={this.props.maxZoom}
<NumberInput
value={this.props.source.maxZoom}
onChange={maxZoom => this.props.onChange({
...this.props.source,
maxZoom: maxZoom
})}
/>
</InputBlock>
</div>
@@ -58,15 +68,18 @@ class TileURLSourceEditor extends React.Component {
class GeoJSONSourceEditor extends React.Component {
static propTypes = {
data: React.PropTypes.string.isRequired,
source: React.PropTypes.object.isRequired,
onChange: React.PropTypes.func,
}
render() {
return <InputBlock label={"GeoJSON Data"}>
<StringInput
value={this.props.data}
onChange={this.props.onChange}
value={this.props.source.data}
onChange={data => this.props.onChange({
...this.props.source,
data: data
})}
/>
</InputBlock>
}
@@ -80,11 +93,14 @@ class SourceTypeEditor extends React.Component {
}
render() {
const source = this.props.source
const commonProps = {
source: this.props.source,
onChange: this.props.onChange,
}
switch(this.props.mode) {
case 'geojson': return <GeoJSONSourceEditor data={source.data || 'http://localhost:3000/mygeojson.json'} />
case 'tilejson': return <TileJSONSourceEditor url={source.url || 'http://localhost:3000/tiles.json'}/>
case 'tilexyz': return <TileURLSourceEditor tiles={source.tiles || ['http://localhost:3000/{x}/{y}/{z}.pbf']} minZoom={source.minZoom || 0} maxZoom={source.maxZoom || 14}/>
case 'geojson': return <GeoJSONSourceEditor {...commonProps} />
case 'tilejson': return <TileJSONSourceEditor {...commonProps} />
case 'tilexyz': return <TileURLSourceEditor {...commonProps} />
default: return null
}
}

View File

@@ -8,7 +8,7 @@ const baseColors = {
blue: '#00d9f7',
green: '#B4C7AD',
orange: '#fb3',
red: '#f04',
red: '#cf4a4a',
}
const themeColors = {

View File

@@ -0,0 +1,35 @@
[
"Metropolis Black Italic",
"Metropolis Black",
"Metropolis Bold Italic",
"Metropolis Bold",
"Metropolis Extra Bold Italic",
"Metropolis Extra Bold",
"Metropolis Extra Light Italic",
"Metropolis Extra Light",
"Metropolis Light Italic",
"Metropolis Light",
"Metropolis Medium Italic",
"Metropolis Medium",
"Metropolis Regular Italic",
"Metropolis Regular",
"Metropolis Semi Bold Italic",
"Metropolis Semi Bold",
"Metropolis Thin Italic",
"Metropolis Thin",
"Open Sans Bold Italic",
"Open Sans Bold",
"Open Sans Extra Bold Italic",
"Open Sans Extra Bold",
"Open Sans Italic",
"Open Sans Light Italic",
"Open Sans Light",
"Open Sans Regular",
"Open Sans Semibold Italic",
"Open Sans Semibold",
"Klokantech Noto Sans Bold",
"Klokantech Noto Sans CJK Bold",
"Klokantech Noto Sans CJK Regular",
"Klokantech Noto Sans Italic",
"Klokantech Noto Sans Regular"
]

View File

@@ -3,6 +3,7 @@ import { margins, fontSizes } from './scales'
const base = {
display: 'inline-block',
boxSizing: 'border-box',
fontSize: fontSizes[5],
lineHeight: 2,
paddingLeft: 5,
@@ -11,20 +12,19 @@ const base = {
const label = {
...base,
width: '40%',
padding: null,
color: colors.lowgray,
userSelect: 'none',
}
const property = {
marginTop: margins[2],
marginBottom: margins[2],
display: 'block',
margin: margins[2],
}
const input = {
...base,
border: 'none',
width: '47%',
backgroundColor: colors.gray,
color: colors.lowgray,
}
@@ -38,8 +38,7 @@ const checkbox = {
const select = {
...input,
width: '51%',
height: '2.3em',
height: '2.15em',
}
export default {

View File

@@ -95,6 +95,79 @@
}
]
},
"fill-extrusion": {
"groups": [
{
"title": "Settings",
"type": "settings"
},
{
"title": "Source",
"type": "source"
},
{
"title": "Basic",
"type": "properties",
"fields": [
"fill-extrusion-opacity",
"fill-extrusion-color",
"fill-extrusion-translate",
"fill-extrusion-translate-anchor",
"fill-extrusion-pattern",
"fill-extrusion-height",
"fill-extrusion-base"
]
},
{
"title": "JSON",
"type": "jsoneditor"
}
]
},
"circle": {
"groups": [
{
"title": "Settings",
"type": "settings"
},
{
"title": "Source",
"type": "source"
},
{
"title": "Basic",
"type": "properties",
"fields": [
"circle-color",
"circle-opacity",
"circle-stroke-color",
"circle-stroke-opacity",
"circle-blur"
]
},
{
"title": "Scale",
"type": "properties",
"fields": [
"circle-radius",
"circle-stroke-width",
"circle-pitch-scale"
]
},
{
"title": "Position",
"type": "properties",
"fields": [
"circle-translate",
"circle-translate-anchor"
]
},
{
"title": "JSON",
"type": "jsoneditor"
}
]
},
"symbol": {
"groups": [
{
@@ -111,8 +184,12 @@
"fields": [
"text-field",
"text-font",
"text-color",
"text-size",
"text-line-height"
"text-line-height",
"text-halo-color",
"text-halo-width",
"text-halo-blur"
]
},
{
@@ -121,14 +198,16 @@
"fields": [
"symbol-placement",
"symbol-spacing",
"symbol-avoid-edges",
"text-padding",
"symbol-avoid-edges",
"text-allow-overlap",
"text-ignore-placement"
"text-ignore-placement",
"text-translate",
"text-translate-anchor"
]
},
{
"title": "Layout",
"title": "Text",
"type": "properties",
"fields": [
"text-pitch-alignment",

View File

@@ -1,2 +1,2 @@
export const margins = [3, 5, 10, 30, 40]
export const fontSizes = [26, 20, 16, 14, 12, 10]
export const fontSizes = [24, 20, 18, 16, 14, 12]

View File

@@ -28,11 +28,5 @@
"title": "Fiord Color",
"url": "https://rawgit.com/openmaptiles/fiord-color-gl-style/gh-pages/style-cdn.json",
"thumbnail": "https://camo.githubusercontent.com/605f2edc30e413b37d16a6ca1d500f265725d76d/68747470733a2f2f6170692e6d6170626f782e636f6d2f7374796c65732f76312f6f70656e6d617074696c65732f6369776775693378353030317732706e7668633063327767302f7374617469632f31302e3938373235382c34362e3435333135302c332e30322c302e30302c302e30302f363030783430303f6163636573735f746f6b656e3d706b2e65794a31496a6f696233426c626d3168634852706247567a4969776959534936496d4e70646e593365544a785a7a41774d474d796233427064574a6d616a63784e7a636966512e685031427863786c644968616b4d6350534a4c513151"
},
{
"id": "toner",
"title": "Toner",
"url": "https://rawgit.com/openmaptiles/toner-color-gl-style/gh-pages/style-cdn.json",
"thumbnail": "https://cloud.githubusercontent.com/assets/1288339/21422755/86ebe96e-c839-11e6-8337-42742dfe34a2.png"
}
]

View File

@@ -1,12 +1,10 @@
[
{
"id": "mapbox-streets",
{
"mapbox-streets": {
"type": "vector",
"url": "mapbox://mapbox.mapbox-streets-v7",
"title": "Mapbox Streets"
},
{
"id": "tilezen",
"tilezen": {
"type": "vector",
"tiles": [
"http://tile.mapzen.com/mapzen/vector/v1/{layers}/{z}/{x}/{y}.pbf?api_key=mapzen-RVcyVL7"
@@ -15,16 +13,14 @@
"maxZoom": 15,
"title": "Mapzen Vector Tile Service"
},
{
"id": "openmaptiles",
"openmaptiles": {
"type": "vector",
"url": "https://free.tilehosting.com/data/v3.json?key=25ItXg7aI5wurYDtttD",
"title": "OpenMapTiles CDN"
},
{
"id": "swissnames-landscape",
"naturalearth-airports": {
"type": "geojson",
"data": "http://swissnames.lukasmartinelli.ch/data/landscape.geojson",
"title": "Landscape Names GeoJSON"
"data": "https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_airports.geojson",
"title": "NaturalEarth Airports GeoJSON"
}
]
}

BIN
src/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -5,6 +5,10 @@
font-style: normal;
}
html {
background-color: rgb(28, 31, 36);
}
.chrome-picker {
background-color: #1c1f24 !important;
font-family: inherit !important;
@@ -15,3 +19,16 @@
color: rgb(142, 142, 142) !important;
box-shadow: none !important;
}
::-webkit-scrollbar {
background-color: #26282e;
width: 5px;
}
::-webkit-scrollbar-thumb {
border-radius: 6px;
-webkit-box-shadow: inset 0 0 6px rgba(0,0,0,.3);
background-color: #40444e;
padding-left: 2px;
padding-right: 2px;
}

View File

@@ -1,6 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './favicon.ico'
import './index.css'
import App from './components/App';

13
src/libs/diffmessage.js Normal file
View File

@@ -0,0 +1,13 @@
import diffStyles from 'mapbox-gl-style-spec/lib/diff'
export function diffMessages(beforeStyle, afterStyle) {
const changes = diffStyles(beforeStyle, afterStyle)
return changes.map(cmd => cmd.command + ' ' + cmd.args.join(' '))
}
export function undoMessages(beforeStyle, afterStyle) {
return diffMessages(beforeStyle, afterStyle).map(m => 'Undo ' + m)
}
export function redoMessages(beforeStyle, afterStyle) {
return diffMessages(beforeStyle, afterStyle).map(m => 'Redo ' + m)
}

44
src/libs/layer.js Normal file
View File

@@ -0,0 +1,44 @@
import GlSpec from 'mapbox-gl-style-spec/reference/latest.js'
export function changeType(layer, newType) {
const changedPaintProps = { ...layer.paint }
Object.keys(changedPaintProps).forEach(propertyName => {
if(!(propertyName in GlSpec['paint_' + newType])) {
delete changedPaintProps[propertyName]
}
})
const changedLayoutProps = { ...layer.layout }
Object.keys(changedLayoutProps).forEach(propertyName => {
if(!(propertyName in GlSpec['layout_' + newType])) {
delete changedLayoutProps[propertyName]
}
})
return {
...layer,
paint: changedPaintProps,
layout: changedLayoutProps,
type: newType,
}
}
/** A {@property} in either the paint our layout {@group} has changed
* to a {@newValue}.
*/
export function changeProperty(layer, group, property, newValue) {
if(group) {
return {
...layer,
[group]: {
...layer[group],
[property]: newValue
}
}
} else {
return {
...layer,
[property]: newValue
}
}
}

View File

@@ -1,12 +1,15 @@
import throttle from 'lodash.throttle'
import isEqual from 'lodash.isequal'
/** Listens to map events to build up a store of available vector
* layers contained in the tiles */
export default class LayerWatcher {
constructor() {
constructor(opts = {}) {
this.onSourcesChange = opts.onSourcesChange || (() => {})
this.onVectorLayersChange = opts.onVectorLayersChange || (() => {})
this._sources = {}
this._vectorLayers = {}
this._map= null
// Since we scan over all features we want to avoid this as much as
// possible and only do it after a batch of data has loaded because
@@ -14,27 +17,30 @@ export default class LayerWatcher {
this.throttledAnalyzeVectorLayerFields = throttle(this.analyzeVectorLayerFields, 5000)
}
/** Set the map as soon as the map is initialized */
set map(m) {
this._map = m
//TODO: At some point we need to unsubscribe when new map is set
this._map.on('data', (e) => {
if(e.dataType !== 'tile') return
analyzeMap(map) {
const previousSources = { ...this._sources }
Object.keys(map.style.sourceCaches).forEach(sourceId => {
//NOTE: This heavily depends on the internal API of Mapbox GL
//so this breaks between Mapbox GL JS releases
this._sources[e.sourceId] = e.style.sourceCaches[e.sourceId]._source.vectorLayerIds
this.throttledAnalyzeVectorLayerFields()
this._sources[sourceId] = map.style.sourceCaches[sourceId]._source.vectorLayerIds
})
if(!isEqual(previousSources, this._sources)) {
this.onSourcesChange(this._sources)
}
this.throttledAnalyzeVectorLayerFields(map)
}
analyzeVectorLayerFields() {
analyzeVectorLayerFields(map) {
const previousVectorLayers = { ...this._vectorLayers }
Object.keys(this._sources).forEach(sourceId => {
this._sources[sourceId].forEach(vectorLayerId => {
(this._sources[sourceId] || []).forEach(vectorLayerId => {
const knownProperties = this._vectorLayers[vectorLayerId] || {}
const params = { sourceLayer: vectorLayerId }
this._map.querySourceFeatures(sourceId, params).forEach(feature => {
map.querySourceFeatures(sourceId, params).forEach(feature => {
Object.keys(feature.properties).forEach(propertyName => {
const knownPropertyValues = knownProperties[propertyName] || {}
knownPropertyValues[feature.properties[propertyName]] = {}
@@ -45,6 +51,11 @@ export default class LayerWatcher {
this._vectorLayers[vectorLayerId] = knownProperties
})
})
if(!isEqual(previousVectorLayers, this._vectorLayers)) {
this.onVectorLayersChange(this._vectorLayers)
}
}
/** Access all known sources and their vector tile ids */

35
src/libs/revisions.js Normal file
View File

@@ -0,0 +1,35 @@
export class RevisionStore {
constructor(initialRevisions=[]) {
this.revisions = initialRevisions
this.currentIdx = initialRevisions.length - 1
}
get latest() {
return this.revisions[this.revisions.length - 1]
}
get current() {
return this.revisions[this.currentIdx]
}
addRevision(revision) {
//TODO: compare new revision style id with old ones
//and ensure that it is always the same id
this.revisions.push(revision)
this.currentIdx++
}
undo() {
if(this.currentIdx > 0) {
this.currentIdx--
}
return this.current
}
redo() {
if(this.currentIdx < this.revisions.length - 1) {
this.currentIdx++
}
return this.current
}
}

View File

@@ -1,8 +1,9 @@
import React from 'react';
import spec from 'mapbox-gl-style-spec/reference/latest.min.js'
import derefLayers from 'mapbox-gl-style-spec/lib/deref'
// Empty style is always used if no style could be restored or fetched
const emptyStyle = ensureMetadataExists({
const emptyStyle = ensureStyleValidity({
version: 8,
sources: {},
layers: [],
@@ -24,8 +25,16 @@ function ensureHasTimestamp(style) {
return style
}
function ensureMetadataExists(style) {
return ensureHasId(ensureHasTimestamp(style))
function ensureHasNoRefs(style) {
const derefedStyle = {
...style,
layers: derefLayers(style.layers)
}
return derefedStyle
}
function ensureStyleValidity(style) {
return ensureHasNoRefs(ensureHasId(ensureHasTimestamp(style)))
}
function indexOfLayer(layers, layerId) {
@@ -38,7 +47,7 @@ function indexOfLayer(layers, layerId) {
}
export default {
ensureMetadataExists,
ensureStyleValidity,
emptyStyle,
indexOfLayer,
generateId,

141
src/libs/stylegen.js Normal file
View File

@@ -0,0 +1,141 @@
import randomColor from 'randomcolor'
import Color from 'color'
function assignVectorLayerColor(layerId) {
let hue = null
if(/water|ocean|lake|sea|river/.test(layerId)) {
hue = 'blue'
}
if(/road|highway|transport/.test(layerId)) {
hue = 'orange'
}
if(/building/.test(layerId)) {
hue = 'yellow'
}
if(/wood|forest|park|landcover|landuse/.test(layerId)) {
hue = 'green'
}
return randomColor({
luminosity: 'bright',
hue: hue,
seed: layerId,
})
}
function circleLayer(source, vectorLayer, color) {
const layer = {
id: `${source}_${vectorLayer}_circle`,
source: source,
type: 'circle',
paint: {
'circle-color': color,
'circle-radius': 2,
},
filter: ["==", "$type", "Point"]
}
if(vectorLayer) {
layer['source-layer'] = vectorLayer
}
return layer
}
function polygonLayer(source, vectorLayer, color, fillColor) {
const layer = {
id: `${source}_${vectorLayer}_polygon`,
source: source,
type: 'fill',
paint: {
'fill-color': fillColor,
'fill-antialias': true,
'fill-outline-color': color,
},
filter: ["==", "$type", "Polygon"]
}
if(vectorLayer) {
layer['source-layer'] = vectorLayer
}
return layer
}
function lineLayer(source, vectorLayer, color) {
const layer = {
id: `${source}_${vectorLayer}_line`,
source: source,
layout: {
'line-join': 'round',
'line-cap': 'round'
},
type: 'line',
paint: {
'line-color': color,
},
filter: ["==", "$type", "LineString"]
}
if(vectorLayer) {
layer['source-layer'] = vectorLayer
}
return layer
}
export function colorHighlightedLayer(layer) {
if(!layer || layer.type === 'background' || layer.type === 'raster') return null
function changeLayer(l) {
if(layer.filter) {
l.filter = layer.filter
} else {
delete l['filter']
}
l.id = l.id + '_highlight'
return l
}
const color = assignVectorLayerColor(layer.id)
const layers = []
if(layer.type === "fill" || layer.type === 'fill-extrusion') {
return changeLayer(polygonLayer(layer.source, layer['source-layer'], color, Color(color).alpha(0.2).string()))
}
if(layer.type === "symbol" || layer.type === 'circle') {
return changeLayer(circleLayer(layer.source, layer['source-layer'], color))
}
if(layer.type === 'line') {
return changeLayer(lineLayer(layer.source, layer['source-layer'], color))
}
return null
}
export function generateColoredLayers(sources) {
const polyLayers = []
const circleLayers = []
const lineLayers = []
Object.keys(sources).forEach(sourceId => {
const layers = sources[sourceId]
// Deal with GeoJSON sources that do not have any source layers
if(!layers) {
const color = Color(assignVectorLayerColor(sourceId))
circleLayers.push(circleLayer(sourceId, null, color.alpha(0.3).string()))
lineLayers.push(lineLayer(sourceId, null, color.alpha(0.3).string()))
polyLayers.push(polygonLayer(sourceId, null, color.alpha(0.2).string(), color.alpha(0.05).string()))
return
}
layers.forEach(layerId => {
const color = Color(assignVectorLayerColor(layerId))
circleLayers.push(circleLayer(sourceId, layerId, color.alpha(0.3).string()))
lineLayers.push(lineLayer(sourceId, layerId, color.alpha(0.3).string()))
polyLayers.push(polygonLayer(sourceId, layerId, color.alpha(0.2).string(), color.alpha(0.05).string()))
})
})
return polyLayers.concat(lineLayers).concat(circleLayers)
}

View File

@@ -20,7 +20,7 @@ export function loadDefaultStyle(cb) {
withCredentials: false,
}, (error, response, body) => {
if (!error && response.statusCode == 200) {
cb(style.ensureMetadataExists(JSON.parse(body)))
cb(style.ensureStyleValidity(JSON.parse(body)))
} else {
console.warn('Could not fetch default style', styleUrl)
cb(style.emptyStyle)
@@ -61,17 +61,6 @@ function styleKey(styleId) {
return [storagePrefix, stylePrefix, styleId].join(":")
}
// Store style independent settings
export class SettingsStore {
get accessToken() {
const token = window.localStorage.getItem(storageKeys.accessToken)
return token ? token : ""
}
set accessToken(val) {
window.localStorage.setItem(storageKeys.accessToken, val)
}
}
// Manages many possible styles that are stored in the local storage
export class StyleStore {
// Tile store will load all items from local storage and
@@ -106,7 +95,7 @@ export class StyleStore {
// Save current style replacing previous version
save(mapStyle) {
mapStyle = style.ensureMetadataExists(mapStyle)
mapStyle = style.ensureStyleValidity(mapStyle)
const key = styleKey(mapStyle.id)
window.localStorage.setItem(key, JSON.stringify(mapStyle))
window.localStorage.setItem(storageKeys.latest, mapStyle.id)

51
src/mapboxgl.css Normal file
View File

@@ -0,0 +1,51 @@
.mapboxgl-popup-anchor-top .mapboxgl-popup-tip {
border-bottom-color: rgb(28, 31, 36);
}
.mapboxgl-popup-anchor-left .mapboxgl-popup-tip {
border-right-color: rgb(28, 31, 36);
}
.mapboxgl-popup-anchor-right .mapboxgl-popup-tip {
border-left-color: rgb(28, 31, 36);
}
.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
border-top-color: rgb(28, 31, 36);
}
.mapboxgl-popup-content {
background-color: rgb(28, 31, 36);
border-radius: 0px;
box-shadow: rgba(0, 0, 0, 0.298039) 0px 0px 5px 0px;
padding: 0px;
}
.mapboxgl-popup-close-button {
color: white;
}
.mapboxgl-ctrl-group {
background: rgb(28, 31, 36);
}
.mapboxgl-ctrl-group > button {
background-color: rgb(28, 31, 36);
border-color: rgb(28, 31, 36);
}
.mapboxgl-ctrl-group > button:hover {
background-color: rgb(86, 83, 83);
}
.mapboxgl-ctrl-icon.mapboxgl-ctrl-zoom-in {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%270%200%2020%2020%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%0A%20%20%3Cpath%20style%3D%27fill%3A%238e8e8e%3B%27%20d%3D%27M%2010%206%20C%209.446%206%209%206.4459904%209%207%20L%209%209%20L%207%209%20C%206.446%209%206%209.446%206%2010%20C%206%2010.554%206.446%2011%207%2011%20L%209%2011%20L%209%2013%20C%209%2013.55401%209.446%2014%2010%2014%20C%2010.554%2014%2011%2013.55401%2011%2013%20L%2011%2011%20L%2013%2011%20C%2013.554%2011%2014%2010.554%2014%2010%20C%2014%209.446%2013.554%209%2013%209%20L%2011%209%20L%2011%207%20C%2011%206.4459904%2010.554%206%2010%206%20z%27%20%2F%3E%0A%3C%2Fsvg%3E%0A")
}
.mapboxgl-ctrl-icon.mapboxgl-ctrl-zoom-out {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%270%200%2020%2020%27%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%3E%0A%20%20%3Cpath%20style%3D%27fill%3A%238e8e8e%3B%27%20d%3D%27m%207%2C9%20c%20-0.554%2C0%20-1%2C0.446%20-1%2C1%200%2C0.554%200.446%2C1%201%2C1%20l%206%2C0%20c%200.554%2C0%201%2C-0.446%201%2C-1%200%2C-0.554%20-0.446%2C-1%20-1%2C-1%20z%27%20%2F%3E%0A%3C%2Fsvg%3E%0A")
}
.mapboxgl-ctrl-icon.mapboxgl-ctrl-compass > span.arrow {
background-image: url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%2020%2020%27%3E%0A%09%3Cpolygon%20fill%3D%27%238e8e8e%27%20points%3D%276%2C9%2010%2C1%2014%2C9%27%2F%3E%0A%09%3Cpolygon%20fill%3D%27%23CCCCCC%27%20points%3D%276%2C11%2010%2C19%2014%2C11%20%27%2F%3E%0A%3C%2Fsvg%3E")
}

7
test/sample_test.js Normal file
View File

@@ -0,0 +1,7 @@
import assert from 'assert'
describe('Component', () => {
it('#always successds', () => {
assert.equal(1, 1)
})
})

View File

@@ -1,16 +0,0 @@
import { SettingsStore } from '../src/stylestore.js'
import assert from 'assert'
describe('SettingsStore', () => {
const store = new SettingsStore()
it('#get should return access token from local storage', () => {
window.localStorage.setItem('maputnik:access_token', 'OLD_TOKEN')
assert.equal(store.accessToken, 'OLD_TOKEN')
})
it('#set should set access token in local storage', () => {
store.accessToken = 'NEW_TOKEN'
assert.equal(window.localStorage.getItem('maputnik:access_token'), 'NEW_TOKEN')
})
})

View File

@@ -7,16 +7,6 @@ var HtmlWebpackPlugin = require('html-webpack-plugin');
const HOST = process.env.HOST || "127.0.0.1";
const PORT = process.env.PORT || "8888";
// local scss modules
loaders.push({
test: /[\/\\]src[\/\\].*\.scss/,
loaders: [
'style?sourceMap',
'css?modules&importLoaders=1&localIdentName=[path]___[name]__[local]___[hash:base64:5]',
'sass'
]
});
module.exports = {
target: 'web',
entry: [
@@ -30,20 +20,10 @@ module.exports = {
filename: 'bundle.js'
},
resolve: {
alias: {
'webworkify': 'webworkify-webpack',
// TODO: otherwise I get a max call stack error in browser?
// 'mapbox-gl': path.resolve('./node_modules/mapbox-gl/dist/mapbox-gl.js')
},
extensions: ['', '.js', '.jsx']
},
module: {
loaders,
postLoaders: [{
include: /node_modules\/mapbox-gl\/js\/render\/shaders.js/,
loader: 'transform',
query: 'brfs'
}]
loaders
},
node: {
fs: "empty",

View File

@@ -14,47 +14,21 @@ module.exports = [
}
},
{
test: /\.js?$/,
include: /node_modules\/mapbox-gl\//,
loader: 'babel',
query: {
presets: ['react'],
plugins: ['transform-flow-strip-types'],
}
},
{
test: /\.(eot|svg|ttf|woff|woff2)$/,
test: /\.(eot|ttf|woff|woff2)$/,
loader: 'file?name=fonts/[name].[ext]'
},
{
test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
exclude: /(node_modules|bower_components)/,
loader: "url?limit=10000&mimetype=image/svg+xml"
test: /\.ico$/,
loader: 'file?name=[name].[ext]'
},
{
test: /\.gif/,
exclude: /(node_modules|bower_components)/,
loader: "url-loader?limit=10000&mimetype=image/gif"
},
{
test: /\.jpg/,
exclude: /(node_modules|bower_components)/,
loader: "url-loader?limit=10000&mimetype=image/jpg"
},
{
test: /\.png/,
exclude: /(node_modules|bower_components)/,
loader: "url-loader?limit=10000&mimetype=image/png"
test: /\.(svg|gif|jpg|png)$/,
loader: 'file?name=img/[name].[ext]'
},
{
test: /\.json$/,
loader: 'json-loader'
},
{
test: /\.js$/,
include: /node_modules\/mapbox-gl\/js\/render\/shaders.js/,
loader: 'transform/cacheable?brfs'
},
{
test: /[\/\\](node_modules|global|src)[\/\\].*\.css$/,
loaders: [

View File

@@ -6,18 +6,12 @@ var ExtractTextPlugin = require('extract-text-webpack-plugin');
var HtmlWebpackPlugin = require('html-webpack-plugin');
var WebpackCleanupPlugin = require('webpack-cleanup-plugin');
// local scss modules
loaders.push({
test: /[\/\\]src[\/\\].*\.scss/,
loader: ExtractTextPlugin.extract('style', 'css?modules&importLoaders=1&localIdentName=[name]__[local]___[hash:base64:5]', 'sass')
});
module.exports = {
entry: {
app: './src/index.jsx',
vendor: [
'file-saver',
'mapbox-gl',
'mapbox-gl/dist/mapbox-gl.js',
//TODO: Build failure because cannot resolve migrations file
//"mapbox-gl-style-spec",
"randomcolor",
@@ -45,18 +39,10 @@ module.exports = {
chunkFilename: '[chunkhash].js'
},
resolve: {
alias: {
'webworkify': 'webworkify-webpack',
},
extensions: ['', '.js', '.jsx']
},
module: {
loaders,
postLoaders: [{
include: /node_modules\/mapbox-gl-shaders/,
loader: 'transform',
query: 'brfs'
}]
loaders
},
node: {
fs: "empty",