├── src ├── libs │ ├── stylegen.js │ ├── document-uid.js │ ├── sort-numerically.js │ ├── filterops.js │ ├── query-util.js │ ├── accessibility.js │ ├── mapbox-rtl.js │ ├── diffmessage.js │ ├── field-spec-additional.js │ ├── source.js │ ├── revisions.js │ ├── zoomcontrol.js │ ├── debug.js │ ├── metadata.js │ ├── highlight.js │ ├── urlopen.js │ ├── layer.js │ ├── layerwatcher.js │ ├── apistore.js │ └── stylestore.js ├── favicon.ico ├── img │ └── maputnik.png ├── fonts │ ├── Roboto-Medium.ttf │ └── Roboto-Regular.ttf ├── util │ ├── format.js │ └── spec-helper.js ├── components │ ├── SmallError.scss │ ├── IconMissing.jsx │ ├── FieldJson.jsx │ ├── _labelFromFieldName.js │ ├── ScrollContainer.jsx │ ├── SmallError.jsx │ ├── FieldColor.jsx │ ├── FieldNumber.jsx │ ├── FieldSelect.jsx │ ├── FieldCheckbox.jsx │ ├── FieldString.jsx │ ├── FieldEnum.jsx │ ├── FieldUrl.jsx │ ├── FieldAutocomplete.jsx │ ├── FieldArray.jsx │ ├── FieldMultiInput.jsx │ ├── IconCircle.jsx │ ├── _DeleteStopButton.jsx │ ├── FieldDynamicArray.jsx │ ├── Collapser.jsx │ ├── IconBackground.jsx │ ├── IconLine.jsx │ ├── _FieldSymbol.jsx │ ├── IconSymbol.jsx │ ├── IconFill.jsx │ ├── FieldId.jsx │ ├── _FieldId.jsx │ ├── FieldComment.jsx │ ├── _FieldComment.jsx │ ├── Collapse.jsx │ ├── FilterEditorBlock.jsx │ ├── FieldMaxZoom.jsx │ ├── FieldMinZoom.jsx │ ├── _FieldMaxZoom.jsx │ ├── _FieldMinZoom.jsx │ ├── FieldSource.jsx │ ├── _FieldSource.jsx │ ├── FieldSourceLayer.jsx │ ├── _FieldSourceLayer.jsx │ ├── InputButton.jsx │ ├── InputCheckbox.jsx │ ├── InputSelect.jsx │ ├── LayerListGroup.jsx │ ├── ModalLoading.jsx │ ├── AppLayout.jsx │ ├── IconLayer.jsx │ ├── SpecField.jsx │ ├── InputMultiInput.jsx │ ├── InputEnum.jsx │ ├── _SpecProperty.jsx │ ├── ModalSurvey.jsx │ ├── FieldType.jsx │ ├── _FieldType.jsx │ ├── Fieldset.jsx │ ├── _FieldFont.jsx │ ├── InputFont.jsx │ ├── FieldDocLabel.jsx │ ├── LayerEditorGroup.jsx │ ├── AppMessagePanel.jsx │ ├── Modal.jsx │ ├── _FunctionButtons.jsx │ ├── PropertyGroup.jsx │ ├── InputString.jsx │ ├── ModalDebug.jsx │ ├── InputUrl.jsx │ ├── Doc.jsx │ ├── MapMapboxGlFeaturePropertyPopup.jsx │ ├── SingleFilterEditor.jsx │ └── InputAutocomplete.jsx ├── config │ ├── tokens.json │ ├── empty-style.json │ ├── tilesets.json │ └── styles.json ├── manifest.json ├── styles │ ├── _picker.scss │ ├── _react-collapse.scss │ ├── _export.scss │ ├── _scrollbar.scss │ ├── _mixins.scss │ ├── _popup.scss │ ├── _vars.scss │ ├── _layout.scss │ ├── _reset.scss │ ├── _map.scss │ ├── _base.scss │ ├── _filtereditor.scss │ ├── _codemirror.scss │ └── _zoomproperty.scss └── index.jsx ├── stories ├── ModalShortcuts.stories.js ├── helper.js ├── ui.js ├── InputButton.stories.js ├── ModalAdd.stories.js ├── ModalExport.stories.js ├── ModalSurvey.stories.js ├── InputJson.stories.js ├── Modal.stories.js ├── ModalLoading.stories.js ├── FieldColor.stories.js ├── FieldString.stories.js ├── InputColor.stories.js ├── ScrollContainer.stories.js ├── ModalDebug.stories.js ├── ModalOpen.stories.js ├── ModalSettings.stories.js ├── FieldSelect.stories.js ├── InputSelect.stories.js ├── ModalSources.stories.js ├── InputMultiInput.stories.js ├── FieldMultiInput.stories.js ├── FieldAutocomplete.stories.js ├── InputAutocomplete.stories.js ├── 0-Welcome.stories.js ├── FieldCheckbox.stories.js ├── InputUrl.stories.js ├── InputCheckbox.stories.js ├── FieldUrl.stories.js ├── InputArray.stories.js ├── FieldNumber.stories.js ├── FieldArray.stories.js ├── InputNumber.stories.js ├── FieldFunction.stories.js ├── IconLayer.stories.js ├── InputDynamicArray.stories.js ├── FieldDynamicArray.stories.js ├── LayerListItem.stories.js ├── InputString.stories.js ├── InputEnum.stories.js ├── FieldEnum.stories.js ├── MapMapboxGl.stories.js └── MapOpenLayers.stories.js ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── other-issue.md │ └── bug_report.md └── workflows │ └── deploy.yml ├── .codesandbox └── ci.json ├── test ├── sources │ ├── index.js │ └── example.json ├── functional │ ├── accessibility │ │ ├── index.js │ │ └── skip-links.js │ ├── util │ │ ├── coverage.js │ │ └── webdriverio-ext.js │ ├── index.js │ ├── map │ │ └── index.js │ ├── keyboard │ │ └── index.js │ ├── helper.js │ └── history │ │ └── index.js ├── wd-helper.js ├── config │ └── specs.js ├── example-style.json ├── example-layer-style.json ├── artifacts.js └── geojson-server.js ├── media └── sponsors │ ├── wemap.jpg │ ├── dreipol.png │ ├── geofabrik.png │ ├── terranodo.png │ ├── klokantech.png │ └── orbicon_informatik.png ├── sandbox.config.json ├── .storybook ├── manager.js ├── maputnik.theme.js └── main.js ├── .topissuesrc ├── .editorconfig ├── .babelrc ├── config ├── webpack.profiling.config.js ├── webpack.rules.js ├── webpack.production.config.js ├── wdio.conf.js └── webpack.config.js ├── Dockerfile ├── .gitignore ├── .dockerignore └── LICENSE /src/libs/stylegen.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /stories/ModalShortcuts.stories.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: "https://maputnik.github.io/donate" 2 | -------------------------------------------------------------------------------- /.codesandbox/ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [], 3 | "sandboxes": ["/"] 4 | } 5 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gisarmory/editor/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /test/sources/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | example: require("./example") 3 | }; 4 | -------------------------------------------------------------------------------- /src/img/maputnik.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gisarmory/editor/HEAD/src/img/maputnik.png -------------------------------------------------------------------------------- /media/sponsors/wemap.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gisarmory/editor/HEAD/media/sponsors/wemap.jpg -------------------------------------------------------------------------------- /sandbox.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "container": { 3 | "startScript": "start-sandbox" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /media/sponsors/dreipol.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gisarmory/editor/HEAD/media/sponsors/dreipol.png -------------------------------------------------------------------------------- /media/sponsors/geofabrik.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gisarmory/editor/HEAD/media/sponsors/geofabrik.png -------------------------------------------------------------------------------- /media/sponsors/terranodo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gisarmory/editor/HEAD/media/sponsors/terranodo.png -------------------------------------------------------------------------------- /src/fonts/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gisarmory/editor/HEAD/src/fonts/Roboto-Medium.ttf -------------------------------------------------------------------------------- /src/fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gisarmory/editor/HEAD/src/fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /media/sponsors/klokantech.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gisarmory/editor/HEAD/media/sponsors/klokantech.png -------------------------------------------------------------------------------- /src/util/format.js: -------------------------------------------------------------------------------- 1 | export function formatLayerId (id) { 2 | return id === "" ? "[empty_string]" : `'${id}'`; 3 | } 4 | -------------------------------------------------------------------------------- /test/functional/accessibility/index.js: -------------------------------------------------------------------------------- 1 | describe("accessibility", function () { 2 | require("./skip-links"); 3 | }) 4 | -------------------------------------------------------------------------------- /media/sponsors/orbicon_informatik.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gisarmory/editor/HEAD/media/sponsors/orbicon_informatik.png -------------------------------------------------------------------------------- /src/components/SmallError.scss: -------------------------------------------------------------------------------- 1 | @import '../styles/vars'; 2 | 3 | .SmallError { 4 | color: #E57373; 5 | font-size: $font-size-6; 6 | margin-top: $margin-2 7 | } 8 | -------------------------------------------------------------------------------- /test/wd-helper.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "$": function(key, selector) { 3 | selector = selector || ""; 4 | return "*[data-wd-key='"+key+"'] "+selector; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/libs/document-uid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A unique id for the current document. 3 | */ 4 | let REF = 0; 5 | 6 | export default function(prefix="") { 7 | REF++; 8 | return prefix+REF; 9 | } 10 | -------------------------------------------------------------------------------- /.storybook/manager.js: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/addons'; 2 | import { themes } from '@storybook/theming'; 3 | import theme from './maputnik.theme'; 4 | 5 | addons.setConfig({ 6 | theme: theme, 7 | }); 8 | -------------------------------------------------------------------------------- /test/config/specs.js: -------------------------------------------------------------------------------- 1 | var config = {}; 2 | var testNetwork = process.env.TEST_NETWORK || "localhost"; 3 | 4 | config.port = 9001; 5 | config.baseUrl = "http://"+testNetwork+":"+config.port; 6 | 7 | module.exports = config; 8 | -------------------------------------------------------------------------------- /.storybook/maputnik.theme.js: -------------------------------------------------------------------------------- 1 | import { create } from '@storybook/theming/create'; 2 | 3 | export default create({ 4 | base: 'light', 5 | 6 | brandTitle: 'Maputnik', 7 | brandUrl: 'https://github.com/maputnik/editor', 8 | }); 9 | -------------------------------------------------------------------------------- /src/config/tokens.json: -------------------------------------------------------------------------------- 1 | { 2 | "mapbox": "pk.eyJ1IjoibW9yZ2Vua2FmZmVlIiwiYSI6ImNpeHJmNXNmZTAwNHIycXBid2NqdTJibjMifQ.Dv1-GDpTWi0NP6xW9Fct1w", 3 | "openmaptiles": "KDhMfHvorAFkFe64wlZb", 4 | "thunderforest": "b71f7f0ba4064f5eb9e903859a9cf5c6" 5 | } 6 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Maputnik", 3 | "short_name": "Maputnik", 4 | "description": "Visual Map Designer", 5 | "start_url": ".", 6 | "display": "browser", 7 | "background_color": "#1c1f24", 8 | "theme_color": "#1c1f24" 9 | } 10 | -------------------------------------------------------------------------------- /src/config/empty-style.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 8, 3 | "name": "Empty Style", 4 | "metadata": {}, 5 | "sources": {}, 6 | "sprite": "", 7 | "glyphs": "https://orangemug.github.io/font-glyphs/glyphs/{fontstack}/{range}.pbf", 8 | "layers": [] 9 | } 10 | -------------------------------------------------------------------------------- /src/libs/sort-numerically.js: -------------------------------------------------------------------------------- 1 | export default function(a, b) { 2 | a = parseFloat(a, 10); 3 | b = parseFloat(b, 10); 4 | 5 | if(a < b) { 6 | return -1 7 | } 8 | else if(a > b) { 9 | return 1 10 | } 11 | else { 12 | return 0; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/components/IconMissing.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {MdPriorityHigh} from 'react-icons/md' 3 | 4 | 5 | export default class IconMissing extends React.Component { 6 | render() { 7 | return ( 8 | 9 | ) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/libs/filterops.js: -------------------------------------------------------------------------------- 1 | import {latest} from '@mapbox/mapbox-gl-style-spec' 2 | export const combiningFilterOps = ['all', 'any', 'none'] 3 | export const setFilterOps = ['in', '!in'] 4 | export const otherFilterOps = Object 5 | .keys(latest.filter_operator.values) 6 | .filter(op => combiningFilterOps.indexOf(op) < 0) 7 | -------------------------------------------------------------------------------- /src/styles/_picker.scss: -------------------------------------------------------------------------------- 1 | 2 | 3 | .chrome-picker { 4 | background-color: #1c1f24 !important; 5 | font-family: inherit !important; 6 | } 7 | 8 | .chrome-picker input { 9 | background-color: rgb(38, 40, 46) !important; 10 | color: rgb(142, 142, 142) !important; 11 | box-shadow: none !important; 12 | } 13 | -------------------------------------------------------------------------------- /.topissuesrc: -------------------------------------------------------------------------------- 1 | { 2 | "labels": { 3 | "bug": 5, 4 | "maintenance": 3, 5 | "mentioned in the 1st survey": 2 6 | }, 7 | "reactions": { 8 | "+1": 2, 9 | "-1": -1, 10 | "laugh": 1, 11 | "hooray": 2, 12 | "confused": 1, 13 | "heart": 2 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/styles/_react-collapse.scss: -------------------------------------------------------------------------------- 1 | // See 2 | .react-collapse-container { 3 | display: flex; 4 | max-width: 100%; 5 | 6 | > * { 7 | flex: 1; 8 | } 9 | } 10 | 11 | .ReactCollapse--collapse { 12 | transition: height 180ms; 13 | } 14 | -------------------------------------------------------------------------------- /test/sources/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "type":"FeatureCollection", 3 | "features":[ 4 | { 5 | "type":"Feature", 6 | "properties": { 7 | "name": "Dinagat Islands" 8 | }, 9 | "geometry":{ 10 | "type": "Point", 11 | "coordinates": [125.6, 10.1] 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/libs/query-util.js: -------------------------------------------------------------------------------- 1 | function asBool(queryObj, key) { 2 | if(queryObj.hasOwnProperty(key)) { 3 | if(queryObj[key].match(/^false|0$/)) { 4 | return false; 5 | } 6 | else { 7 | return true; 8 | } 9 | } 10 | else { 11 | return false; 12 | } 13 | } 14 | 15 | module.exports = { 16 | asBool 17 | } 18 | -------------------------------------------------------------------------------- /src/styles/_export.scss: -------------------------------------------------------------------------------- 1 | .maputnik-export-gist { 2 | label.maputnik-checkbox-wrapper { 3 | display: inline-block; 4 | } 5 | } 6 | 7 | .maputnik-render-gist { 8 | p { 9 | margin: 10px 0; 10 | } 11 | 12 | input.maputnik-string { 13 | margin-left: 5px; 14 | width: 60%; 15 | display: inline-block; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/example-style.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "test-style", 3 | "version": 8, 4 | "name": "Test Style", 5 | "metadata": { 6 | "maputnik:renderer": "mbgljs" 7 | }, 8 | "sources": {}, 9 | "glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf", 10 | "sprites": "https://example.local/fonts/{fontstack}/{range}.pbf", 11 | "layers": [] 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # Unix-style newlines with a newline ending every file 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | 8 | # Matches multiple files with brace expansion notation 9 | # Set default charset 10 | [*.{js,jsx,html,sass}] 11 | charset = utf-8 12 | indent_style = space 13 | indent_size = 2 14 | trim_trailing_whitespace = true 15 | -------------------------------------------------------------------------------- /src/libs/accessibility.js: -------------------------------------------------------------------------------- 1 | import throttle from 'lodash.throttle' 2 | 3 | 4 | // Throttle for 3 seconds so when a user enables it they don't have to refresh the page. 5 | const reducedMotionEnabled = throttle(() => { 6 | return window.matchMedia("(prefers-reduced-motion: reduce)").matches 7 | }, 3000); 8 | 9 | 10 | export default { 11 | reducedMotionEnabled 12 | } 13 | -------------------------------------------------------------------------------- /stories/helper.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import {action} from '@storybook/addon-actions'; 3 | 4 | export function useActionState (name, initialVal) { 5 | const [val, fn] = useState(initialVal); 6 | const actionFn = action(name); 7 | function retFn(val) { 8 | actionFn(val); 9 | return fn(val); 10 | } 11 | return [val, retFn]; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/FieldJson.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import InputJson from './InputJson' 4 | 5 | 6 | export default class FieldJson extends React.Component { 7 | static propTypes = { 8 | ...InputJson.propTypes, 9 | } 10 | 11 | render() { 12 | const {props} = this; 13 | return 14 | } 15 | } 16 | 17 | -------------------------------------------------------------------------------- /src/components/_labelFromFieldName.js: -------------------------------------------------------------------------------- 1 | import capitalize from 'lodash.capitalize' 2 | 3 | export default function labelFromFieldName(fieldName) { 4 | let label; 5 | const parts = fieldName.split('-'); 6 | if (parts.length > 1) { 7 | label = fieldName.split('-').slice(1).join(' '); 8 | } 9 | else { 10 | label = fieldName; 11 | } 12 | return capitalize(label); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/ScrollContainer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | export default class ScrollContainer extends React.Component { 5 | static propTypes = { 6 | children: PropTypes.node 7 | } 8 | 9 | render() { 10 | return
11 | {this.props.children} 12 |
13 | } 14 | } 15 | 16 | -------------------------------------------------------------------------------- /src/libs/mapbox-rtl.js: -------------------------------------------------------------------------------- 1 | import MapboxGl from 'mapbox-gl' 2 | import {readFileSync} from 'fs' 3 | 4 | const data = readFileSync(__dirname+"/../../node_modules/@mapbox/mapbox-gl-rtl-text/mapbox-gl-rtl-text.js", "utf8"); 5 | 6 | const blob = new window.Blob([data], { 7 | type: "text/javascript" 8 | }); 9 | const objectUrl = window.URL.createObjectURL(blob); 10 | 11 | MapboxGl.setRTLTextPlugin(objectUrl); 12 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react" 5 | ], 6 | "plugins": [ 7 | "static-fs", 8 | "react-hot-loader/babel", 9 | "@babel/plugin-proposal-class-properties" 10 | ], 11 | "env": { 12 | "test": { 13 | "plugins": [ 14 | ["istanbul", { 15 | "exclude": ["node_modules/**", "test/**"] 16 | }] 17 | ] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/util/spec-helper.js: -------------------------------------------------------------------------------- 1 | /** 2 | * If we don't have a default value just make one up 3 | */ 4 | export function findDefaultFromSpec (spec) { 5 | if (spec.hasOwnProperty('default')) { 6 | return spec.default; 7 | } 8 | 9 | const defaults = { 10 | 'color': '#000000', 11 | 'string': '', 12 | 'boolean': false, 13 | 'number': 0, 14 | 'array': [], 15 | } 16 | 17 | return defaults[spec.type] || ''; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/SmallError.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import './SmallError.scss'; 4 | 5 | 6 | export default class SmallError extends React.Component { 7 | static propTypes = { 8 | children: PropTypes.node, 9 | } 10 | 11 | render () { 12 | return ( 13 |
14 | Error: {this.props.children} 15 |
16 | ); 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /test/example-layer-style.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "test-style", 3 | "version": 8, 4 | "name": "Test Style", 5 | "metadata": { 6 | "maputnik:renderer": "mbgljs" 7 | }, 8 | "sources": {}, 9 | "glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf", 10 | "sprites": "https://example.local/fonts/{fontstack}/{range}.pbf", 11 | "layers": [ 12 | { 13 | "id": "background", 14 | "type": "background" 15 | } 16 | ] 17 | } 18 | 19 | -------------------------------------------------------------------------------- /src/components/FieldColor.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Block from './Block' 4 | import InputColor from './InputColor' 5 | 6 | 7 | export default class FieldColor extends React.Component { 8 | static propTypes = { 9 | ...InputColor.propTypes, 10 | } 11 | 12 | render() { 13 | const {props} = this; 14 | 15 | return 16 | 17 | 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/components/FieldNumber.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import InputNumber from './InputNumber' 4 | import Block from './Block' 5 | 6 | 7 | export default class FieldNumber extends React.Component { 8 | static propTypes = { 9 | ...InputNumber.propTypes, 10 | } 11 | 12 | render() { 13 | const {props} = this; 14 | return 15 | 16 | 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /src/components/FieldSelect.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Block from './Block' 4 | import InputSelect from './InputSelect' 5 | 6 | 7 | export default class FieldSelect extends React.Component { 8 | static propTypes = { 9 | ...InputSelect.propTypes, 10 | } 11 | 12 | render() { 13 | const {props} = this; 14 | 15 | return 16 | 17 | 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/styles/_scrollbar.scss: -------------------------------------------------------------------------------- 1 | *:not(select) { 2 | &::-webkit-scrollbar { 3 | background-color: #26282e; 4 | width: 8px; 5 | height: 8px; 6 | } 7 | 8 | &::-webkit-scrollbar-thumb { 9 | border-radius: 6px; 10 | -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3); 11 | background-color: #666; 12 | padding-left: 2px; 13 | padding-right: 2px; 14 | } 15 | 16 | // Styling for Firefox 17 | scrollbar-width: thin; 18 | scrollbar-color: #666 #26282e; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/FieldCheckbox.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Block from './Block' 4 | import InputCheckbox from './InputCheckbox' 5 | 6 | 7 | export default class FieldCheckbox extends React.Component { 8 | static propTypes = { 9 | ...InputCheckbox.propTypes, 10 | } 11 | 12 | render() { 13 | const {props} = this; 14 | 15 | return 16 | 17 | 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import { IconContext } from "react-icons"; 2 | import React from 'react'; 3 | import ReactDOM from 'react-dom'; 4 | 5 | import './favicon.ico' 6 | import './styles/index.scss' 7 | import App from './components/App'; 8 | 9 | ReactDOM.render( 10 | 11 | 12 | , 13 | document.querySelector("#app") 14 | ); 15 | 16 | // Hide the loader. 17 | document.querySelector(".loading").style.display = "none"; 18 | -------------------------------------------------------------------------------- /src/components/FieldString.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Block from './Block' 4 | import InputString from './InputString' 5 | 6 | export default class FieldString extends React.Component { 7 | static propTypes = { 8 | ...InputString.propTypes, 9 | name: PropTypes.string, 10 | } 11 | 12 | render() { 13 | const {props} = this; 14 | 15 | return 16 | 17 | 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/components/FieldEnum.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import InputEnum from './InputEnum' 4 | import Block from './Block'; 5 | import Fieldset from './Fieldset'; 6 | 7 | 8 | export default class FieldEnum extends React.Component { 9 | static propTypes = { 10 | ...InputEnum.propTypes, 11 | } 12 | 13 | render() { 14 | const {props} = this; 15 | 16 | return
17 | 18 |
19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/components/FieldUrl.jsx: -------------------------------------------------------------------------------- 1 | import React, {Fragment} from 'react' 2 | import PropTypes from 'prop-types' 3 | import InputUrl from './InputUrl' 4 | import Block from './Block' 5 | 6 | 7 | export default class FieldUrl extends React.Component { 8 | static propTypes = { 9 | ...InputUrl.propTypes, 10 | } 11 | 12 | render () { 13 | const {props} = this; 14 | 15 | return ( 16 | 17 | 18 | 19 | ); 20 | } 21 | } 22 | 23 | -------------------------------------------------------------------------------- /src/components/FieldAutocomplete.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Block from './Block' 4 | import InputAutocomplete from './InputAutocomplete' 5 | 6 | 7 | export default class FieldAutocomplete extends React.Component { 8 | static propTypes = { 9 | ...InputAutocomplete.propTypes, 10 | } 11 | 12 | render() { 13 | const {props} = this; 14 | 15 | return 16 | 17 | 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /src/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin vendor-prefix($name, $argument) { 2 | -webkit-#{$name}: #{$argument}; 3 | -ms-#{$name}: #{$argument}; 4 | -moz-#{$name}: #{$argument}; 5 | -o-#{$name}: #{$argument}; 6 | #{$name}: #{$argument}; 7 | } 8 | 9 | @mixin flex-row { 10 | display: flex; 11 | display: -ms-flexbox; 12 | 13 | @include vendor-prefix(flex-direction, row); 14 | } 15 | 16 | @mixin flex-column { 17 | display: flex; 18 | display: -ms-flexbox; 19 | 20 | @include vendor-prefix(flex-direction, column); 21 | } 22 | -------------------------------------------------------------------------------- /src/libs/diffmessage.js: -------------------------------------------------------------------------------- 1 | import {diff} from '@mapbox/mapbox-gl-style-spec' 2 | 3 | export function diffMessages(beforeStyle, afterStyle) { 4 | const changes = diff(beforeStyle, afterStyle) 5 | return changes.map(cmd => cmd.command + ' ' + cmd.args.join(' ')) 6 | } 7 | 8 | export function undoMessages(beforeStyle, afterStyle) { 9 | return diffMessages(beforeStyle, afterStyle).map(m => 'Undo ' + m) 10 | } 11 | export function redoMessages(beforeStyle, afterStyle) { 12 | return diffMessages(beforeStyle, afterStyle).map(m => 'Redo ' + m) 13 | } 14 | -------------------------------------------------------------------------------- /src/components/FieldArray.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Block from './Block' 4 | import InputArray from './InputArray' 5 | import Fieldset from './Fieldset' 6 | 7 | export default class FieldArray extends React.Component { 8 | static propTypes = { 9 | ...InputArray.propTypes, 10 | name: PropTypes.string, 11 | } 12 | 13 | render() { 14 | const {props} = this; 15 | 16 | return
17 | 18 |
19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/components/FieldMultiInput.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Block from './Block' 4 | import InputMultiInput from './InputMultiInput' 5 | import Fieldset from './Fieldset' 6 | 7 | 8 | export default class FieldMultiInput extends React.Component { 9 | static propTypes = { 10 | ...InputMultiInput.propTypes, 11 | } 12 | 13 | render() { 14 | const {props} = this; 15 | 16 | return
17 | 18 |
19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/components/IconCircle.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import IconBase from 'react-icon-base' 3 | 4 | 5 | export default class IconCircle extends React.Component { 6 | render() { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /stories/ui.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | 4 | export function Describe ({children}) { 5 | return ( 6 |
7 | {children} 8 |
9 | ) 10 | } 11 | 12 | export function Wrapper ({children}) { 13 | return ( 14 |
15 | {children} 16 |
17 | ); 18 | }; 19 | 20 | export function InputContainer ({children}) { 21 | return ( 22 |
23 | {children} 24 |
25 | ); 26 | }; 27 | 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/other-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Other issue 3 | about: Feature request or other issue which is no bug report 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/components/_DeleteStopButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import InputButton from './InputButton' 5 | import {MdDelete} from 'react-icons/md' 6 | 7 | 8 | export default class DeleteStopButton extends React.Component { 9 | static propTypes = { 10 | onClick: PropTypes.func, 11 | } 12 | 13 | render() { 14 | return 19 | 20 | 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stories/InputButton.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import InputButton from '../src/components/InputButton'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {InputContainer} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'InputButton', 10 | component: InputButton, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const Basic = () => ( 15 | 16 | 17 | Hello InputButton 18 | 19 | 20 | ); 21 | 22 | -------------------------------------------------------------------------------- /src/components/FieldDynamicArray.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Block from './Block' 4 | import InputDynamicArray from './InputDynamicArray' 5 | import Fieldset from './Fieldset' 6 | 7 | export default class FieldDynamicArray extends React.Component { 8 | static propTypes = { 9 | ...InputDynamicArray.propTypes, 10 | name: PropTypes.string, 11 | } 12 | 13 | render() { 14 | const {props} = this; 15 | 16 | return
17 | 18 |
19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /config/webpack.profiling.config.js: -------------------------------------------------------------------------------- 1 | const webpackProdConfig = require('./webpack.production.config'); 2 | const artifacts = require("../test/artifacts"); 3 | 4 | const OUTPATH = artifacts.pathSync("/profiling"); 5 | 6 | module.exports = { 7 | ...webpackProdConfig, 8 | output: { 9 | ...webpackProdConfig.output, 10 | path: OUTPATH, 11 | }, 12 | resolve: { 13 | ...webpackProdConfig.resolve, 14 | alias: { 15 | ...webpackProdConfig.resolve.alias, 16 | 'react-dom$': 'react-dom/profiling', 17 | 'scheduler/tracing': 'scheduler/tracing-profiling', 18 | } 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | const rules = require('../config/webpack.rules'); 2 | 3 | module.exports = { 4 | stories: ['../stories/**/*.stories.js'], 5 | addons: [ 6 | '@storybook/addon-actions', 7 | '@storybook/addon-links', 8 | '@storybook/addon-a11y/register', 9 | '@storybook/addon-storysource', 10 | ], 11 | webpackFinal: async config => { 12 | // do mutation to the config 13 | console.log("config.module", config.module); 14 | 15 | return { 16 | ...config, 17 | module: { 18 | rules: [ 19 | ...rules, 20 | ] 21 | } 22 | }; 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/Collapser.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import {MdArrowDropDown, MdArrowDropUp} from 'react-icons/md' 4 | 5 | export default class Collapser extends React.Component { 6 | static propTypes = { 7 | isCollapsed: PropTypes.bool.isRequired, 8 | style: PropTypes.object, 9 | } 10 | 11 | render() { 12 | const iconStyle = { 13 | width: 20, 14 | height: 20, 15 | ...this.props.style, 16 | } 17 | return this.props.isCollapsed ? : 18 | } 19 | } 20 | 21 | -------------------------------------------------------------------------------- /stories/ModalAdd.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ModalAdd from '../src/components/ModalAdd'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'ModalAdd', 10 | component: ModalAdd, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const Basic = () => ( 15 | 16 |
17 | 22 |
23 |
24 | ); 25 | 26 | -------------------------------------------------------------------------------- /stories/ModalExport.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ModalExport from '../src/components/ModalExport'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'ModalExport', 10 | component: ModalExport, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const Basic = () => ( 15 | 16 |
17 | 21 |
22 |
23 | ); 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /stories/ModalSurvey.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ModalSurvey from '../src/components/ModalSurvey'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'ModalSurvey', 10 | component: ModalSurvey, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const Basic = () => ( 15 | 16 |
17 | 20 |
21 |
22 | ); 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /stories/InputJson.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import InputJson from '../src/components/InputJson'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'InputJson', 10 | component: InputJson, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const Basic = () => { 15 | const layer = { 16 | id: "background", 17 | type: "background", 18 | }; 19 | 20 | return 21 | 25 | 26 | }; 27 | 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10 as builder 2 | WORKDIR /maputnik 3 | 4 | # Only copy package.json to prevent npm install from running on every build 5 | COPY package.json package-lock.json ./ 6 | RUN npm install 7 | 8 | # Build maputnik 9 | # TODO: we should also do a npm run test here (needs more dependencies) 10 | COPY . . 11 | RUN npm run build 12 | 13 | #--------------------------------------------------------------------------- 14 | 15 | # Create a clean python-based image with just the build results 16 | FROM python:3-slim 17 | WORKDIR /maputnik 18 | 19 | COPY --from=builder /maputnik/build/build . 20 | 21 | EXPOSE 8888 22 | CMD python -m http.server 8888 23 | -------------------------------------------------------------------------------- /stories/Modal.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Modal from '../src/components/Modal'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'Modal', 10 | component: Modal, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const Basic = () => ( 15 | 16 |
17 | 18 | {[...Array(50).keys()].map(() => { 19 | return

Some text

20 | })} 21 |
22 |
23 |
24 | ); 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/components/IconBackground.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import IconBase from 'react-icon-base' 3 | 4 | 5 | export default class IconBackground extends React.Component { 6 | render() { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/IconLine.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import IconBase from 'react-icon-base' 3 | 4 | 5 | export default class IconLine extends React.Component { 6 | render() { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /stories/ModalLoading.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ModalLoading from '../src/components/ModalLoading'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'ModalLoading', 10 | component: ModalLoading, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const Basic = () => ( 15 | 16 |
17 | 22 |
23 |
24 | ); 25 | 26 | -------------------------------------------------------------------------------- /stories/FieldColor.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import FieldColor from '../src/components/FieldColor'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'FieldColor', 9 | component: FieldColor, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const Basic = () => { 15 | const [color, setColor] = useActionState("onChange", "#ff0000"); 16 | 17 | return ( 18 | 19 | 24 | 25 | ); 26 | }; 27 | 28 | -------------------------------------------------------------------------------- /stories/FieldString.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import FieldString from '../src/components/FieldString'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'FieldString', 9 | component: FieldString, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const Basic = () => { 15 | const [value, setValue] = useActionState("onChange", "Hello world"); 16 | 17 | return ( 18 | 19 | 24 | 25 | ); 26 | }; 27 | 28 | -------------------------------------------------------------------------------- /src/libs/field-spec-additional.js: -------------------------------------------------------------------------------- 1 | const spec = { 2 | maputnik: { 3 | mapbox_access_token: { 4 | label: "Mapbox Access Token", 5 | doc: "Public access token for Mapbox services." 6 | }, 7 | maptiler_access_token: { 8 | label: "MapTiler Access Token", 9 | doc: "Public access token for MapTiler Cloud." 10 | }, 11 | thunderforest_access_token: { 12 | label: "Thunderforest Access Token", 13 | doc: "Public access token for Thunderforest services." 14 | }, 15 | style_renderer: { 16 | label: "Style Renderer", 17 | doc: "Choose the default Maputnik renderer for this style.", 18 | }, 19 | } 20 | } 21 | 22 | export default spec; 23 | -------------------------------------------------------------------------------- /stories/InputColor.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import InputColor from '../src/components/InputColor'; 4 | import {InputContainer} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'InputColor', 9 | component: InputColor, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const Basic = () => { 15 | const [color, setColor] = useActionState("onChange", "#ff0000"); 16 | 17 | return ( 18 | 19 | 24 | 25 | ); 26 | }; 27 | 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve Maputnik 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | **Maputnik version**: 13 | **Browser**: 14 | **OS**: 15 | 16 | **Description of the bug**: 17 | 18 | **Steps to reproduce the behavior**: 19 | 1. 20 | 2. 21 | 3. 22 | 23 | **Style file or style URL**: 24 | 25 | 26 | **Screenshots**: 27 | 28 | -------------------------------------------------------------------------------- /stories/ScrollContainer.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ScrollContainer from '../src/components/ScrollContainer'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'ScrollContainer', 10 | component: ScrollContainer, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const Basic = () => ( 15 | 16 |
17 | 18 | {[...Array(50).keys()].map(() => { 19 | return

Some text

20 | })} 21 |
22 |
23 |
24 | ); 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/libs/source.js: -------------------------------------------------------------------------------- 1 | export function deleteSource(mapStyle, sourceId) { 2 | const remainingSources = { ...mapStyle.sources} 3 | delete remainingSources[sourceId] 4 | const changedStyle = { 5 | ...mapStyle, 6 | sources: remainingSources 7 | } 8 | return changedStyle 9 | } 10 | 11 | 12 | export function addSource(mapStyle, sourceId, source) { 13 | return changeSource(mapStyle, sourceId, source) 14 | } 15 | 16 | export function changeSource(mapStyle, sourceId, source) { 17 | const changedSources = { 18 | ...mapStyle.sources, 19 | [sourceId]: source 20 | } 21 | const changedStyle = { 22 | ...mapStyle, 23 | sources: changedSources 24 | } 25 | return changedStyle 26 | } 27 | 28 | -------------------------------------------------------------------------------- /stories/ModalDebug.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ModalDebug from '../src/components/ModalDebug'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'ModalDebug', 10 | component: ModalDebug, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const Basic = () => ( 15 | 16 |
17 | 23 |
24 |
25 | ); 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/components/_FieldSymbol.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import FieldAutocomplete from './FieldAutocomplete' 4 | 5 | 6 | export default class FieldSymbol extends React.Component { 7 | static propTypes = { 8 | value: PropTypes.string, 9 | icons: PropTypes.array, 10 | style: PropTypes.object, 11 | onChange: PropTypes.func.isRequired, 12 | } 13 | 14 | static defaultProps = { 15 | icons: [] 16 | } 17 | 18 | render() { 19 | return [f, f])} 22 | onChange={this.props.onChange} 23 | wrapperStyle={this.props.style} 24 | /> 25 | } 26 | } 27 | 28 | -------------------------------------------------------------------------------- /src/components/IconSymbol.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import IconBase from 'react-icon-base' 3 | 4 | 5 | export default class IconSymbol extends React.Component { 6 | render() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | ) 14 | } 15 | } 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | *.swp 5 | *.swo 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # node-waf configuration 22 | .lock-wscript 23 | 24 | # Compiled binary addons (http://nodejs.org/api/addons.html) 25 | build/Release 26 | 27 | # Dependency directory 28 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 29 | node_modules 30 | 31 | # Ignore build files 32 | public 33 | /errorShots 34 | /old 35 | /build 36 | -------------------------------------------------------------------------------- /test/functional/util/coverage.js: -------------------------------------------------------------------------------- 1 | var artifacts = require("../../artifacts"); 2 | var fs = require("fs"); 3 | var istanbulCov = require('istanbul-lib-coverage'); 4 | 5 | var COVERAGE_PATH = artifacts.pathSync("/coverage"); 6 | 7 | 8 | var coverage = istanbulCov.createCoverageMap({}); 9 | 10 | // Capture the coverage after each test 11 | afterEach(function() { 12 | // Code coverage 13 | var results = browser.execute(function() { 14 | return window.__coverage__; 15 | }); 16 | 17 | if (results) { 18 | coverage.merge(results); 19 | } 20 | }) 21 | 22 | // Dump the coverage to a file 23 | after(function() { 24 | var jsonStr = JSON.stringify(coverage, null, 2); 25 | fs.writeFileSync(COVERAGE_PATH+"/coverage.json", jsonStr); 26 | }) 27 | -------------------------------------------------------------------------------- /src/components/IconFill.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import IconBase from 'react-icon-base' 3 | 4 | 5 | export default class IconFill extends React.Component { 6 | render() { 7 | return ( 8 | 9 | 10 | 11 | ) 12 | } 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /stories/ModalOpen.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ModalOpen from '../src/components/ModalOpen'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'ModalOpen', 10 | component: ModalOpen, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const Basic = () => ( 15 | 16 |
17 | 23 |
24 |
25 | ); 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /stories/ModalSettings.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ModalSettings from '../src/components/ModalSettings'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'ModalSettings', 10 | component: ModalSettings, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const Basic = () => ( 15 | 16 |
17 | 23 |
24 |
25 | ); 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | push: 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | # publish docker to github registry 12 | deploy-docker: 13 | name: deploy/docker 14 | runs-on: ${{ matrix.os }} 15 | 16 | if: ${{ github.event_name == 'push' }} 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | os: [ubuntu-latest] 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login docker.pkg.github.com -u orangemug --password-stdin 26 | - run: docker build -t docker.pkg.github.com/maputnik/editor/editor:master . 27 | - run: docker push docker.pkg.github.com/maputnik/editor/editor:master 28 | 29 | -------------------------------------------------------------------------------- /stories/FieldSelect.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import FieldSelect from '../src/components/FieldSelect'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'FieldSelect', 9 | component: FieldSelect, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const Basic = () => { 15 | const options = [["FOO", "Foo"], ["BAR", "Bar"], ["BAZ", "Baz"]]; 16 | const [value, setValue] = useActionState("onChange", "FOO"); 17 | 18 | return ( 19 | 20 | 26 | 27 | ); 28 | }; 29 | 30 | 31 | -------------------------------------------------------------------------------- /stories/InputSelect.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import InputSelect from '../src/components/InputSelect'; 4 | import {InputContainer} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'InputSelect', 9 | component: InputSelect, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const Basic = () => { 15 | const options = [["FOO", "Foo"], ["BAR", "Bar"], ["BAZ", "Baz"]]; 16 | const [value, setValue] = useActionState("onChange", "FOO"); 17 | 18 | return ( 19 | 20 | 25 | 26 | ); 27 | }; 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /stories/ModalSources.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ModalSources from '../src/components/ModalSources'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'ModalSources', 10 | component: ModalSources, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const Basic = () => ( 15 | 16 |
17 | 23 |
24 |
25 | ); 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /stories/InputMultiInput.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import InputMultiInput from '../src/components/InputMultiInput'; 4 | import {InputContainer} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'InputMultiInput', 9 | component: InputMultiInput, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const Basic = () => { 15 | const options = [["FOO", "foo"], ["BAR", "bar"], ["BAZ", "baz"]]; 16 | const [value, setValue] = useActionState("onChange", "FOO"); 17 | 18 | return ( 19 | 20 | 25 | 26 | ); 27 | }; 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/components/FieldId.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import {latest} from '@mapbox/mapbox-gl-style-spec' 5 | import Block from './Block' 6 | import InputString from './InputString' 7 | 8 | export default class FieldId extends React.Component { 9 | static propTypes = { 10 | value: PropTypes.string.isRequired, 11 | wdKey: PropTypes.string.isRequired, 12 | onChange: PropTypes.func.isRequired, 13 | error: PropTypes.object, 14 | } 15 | 16 | render() { 17 | return 21 | 25 | 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /stories/FieldMultiInput.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import FieldMultiInput from '../src/components/FieldMultiInput'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'FieldMultiInput', 9 | component: FieldMultiInput, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const Basic = () => { 15 | const options = [["FOO", "foo"], ["BAR", "bar"], ["BAZ", "baz"]]; 16 | const [value, setValue] = useActionState("onChange", "FOO"); 17 | 18 | return ( 19 | 20 | 26 | 27 | ); 28 | }; 29 | 30 | -------------------------------------------------------------------------------- /src/components/_FieldId.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import {latest} from '@mapbox/mapbox-gl-style-spec' 5 | import Block from './Block' 6 | import FieldString from './FieldString' 7 | 8 | export default class BlockId extends React.Component { 9 | static propTypes = { 10 | value: PropTypes.string.isRequired, 11 | wdKey: PropTypes.string.isRequired, 12 | onChange: PropTypes.func.isRequired, 13 | error: PropTypes.object, 14 | } 15 | 16 | render() { 17 | return 21 | 25 | 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /src/config/tilesets.json: -------------------------------------------------------------------------------- 1 | { 2 | "openmaptiles": { 3 | "type": "vector", 4 | "url": "https://api.maptiler.com/tiles/v3/tiles.json?key={key}", 5 | "title": "OpenMapTiles v3" 6 | }, 7 | "thunderforest_transport": { 8 | "type": "vector", 9 | "url": "https://tile.thunderforest.com/thunderforest.transport-v2.json?apikey={key}", 10 | "title": "Thunderforest Transport v2" 11 | }, 12 | "thunderforest_outdoors": { 13 | "type": "vector", 14 | "url": "https://tile.thunderforest.com/thunderforest.outdoors-v2.json?apikey={key}", 15 | "title": "Thunderforest Outdoors v2" 16 | }, 17 | "open_zoomstack": { 18 | "type": "vector", 19 | "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/data/vector/open-zoomstack/config.json", 20 | "title": "OS Open Zoomstack v2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /stories/FieldAutocomplete.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import FieldAutocomplete from '../src/components/FieldAutocomplete'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'FieldAutocomplete', 9 | component: FieldAutocomplete, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const Basic = () => { 15 | const options = [["FOO", "foo"], ["BAR", "bar"], ["BAZ", "baz"]]; 16 | const [value, setValue] = useActionState("onChange", "bar"); 17 | 18 | return ( 19 | 20 | 26 | 27 | ); 28 | }; 29 | 30 | -------------------------------------------------------------------------------- /stories/InputAutocomplete.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import InputAutocomplete from '../src/components/InputAutocomplete'; 4 | import {InputContainer} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'InputAutocomplete', 9 | component: InputAutocomplete, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const Basic = () => { 15 | const options = [["FOO", "foo"], ["BAR", "bar"], ["BAZ", "baz"]]; 16 | const [value, setValue] = useActionState("onChange", "bar"); 17 | 18 | return ( 19 | 20 | 26 | 27 | ); 28 | }; 29 | 30 | -------------------------------------------------------------------------------- /src/components/FieldComment.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Block from './Block' 5 | import InputString from './InputString' 6 | 7 | export default class FieldComment extends React.Component { 8 | static propTypes = { 9 | value: PropTypes.string, 10 | onChange: PropTypes.func.isRequired, 11 | } 12 | 13 | render() { 14 | const fieldSpec = { 15 | doc: "Comments for the current layer. This is non-standard and not in the spec." 16 | }; 17 | 18 | return 23 | 29 | 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/components/_FieldComment.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Block from './Block' 5 | import FieldString from './FieldString' 6 | 7 | export default class BlockComment extends React.Component { 8 | static propTypes = { 9 | value: PropTypes.string, 10 | onChange: PropTypes.func.isRequired, 11 | } 12 | 13 | render() { 14 | const fieldSpec = { 15 | doc: "Comments for the current layer. This is non-standard and not in the spec." 16 | }; 17 | 18 | return 23 | 29 | 30 | } 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/libs/revisions.js: -------------------------------------------------------------------------------- 1 | export class RevisionStore { 2 | constructor(initialRevisions=[]) { 3 | this.revisions = initialRevisions 4 | this.currentIdx = initialRevisions.length - 1 5 | } 6 | 7 | get latest() { 8 | return this.revisions[this.revisions.length - 1] 9 | } 10 | 11 | get current() { 12 | return this.revisions[this.currentIdx] 13 | } 14 | 15 | addRevision(revision) { 16 | //TODO: compare new revision style id with old ones 17 | //and ensure that it is always the same id 18 | this.revisions.push(revision) 19 | this.currentIdx++ 20 | } 21 | 22 | undo() { 23 | if(this.currentIdx > 0) { 24 | this.currentIdx-- 25 | } 26 | return this.current 27 | } 28 | 29 | redo() { 30 | if(this.currentIdx < this.revisions.length - 1) { 31 | this.currentIdx++ 32 | } 33 | return this.current 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /stories/0-Welcome.stories.js: -------------------------------------------------------------------------------- 1 | import '../src/styles/index.scss'; 2 | import React from 'react'; 3 | import {Describe} from './ui'; 4 | 5 | 6 | export default { 7 | title: 'Welcome', 8 | }; 9 | 10 | export const ToStorybook = () => { 11 | return ( 12 | 13 |

Maputnik component library

14 |

15 | This is the Maputnik component library, which shows the uses of some commonly used components from the Maputnik editor. This is a stand alone place where we can better refine them and improve their API separate from their use inside the editor. 16 |

17 |

18 | This should also help us better refine our CSS and make it more modular as currently we rely on the cascade quite a bit in a number of places. 19 |

20 |
21 | ); 22 | } 23 | 24 | ToStorybook.story = { 25 | name: 'Intro', 26 | }; 27 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | Dockerfile 4 | 5 | # 6 | # 7 | # COPIED FROM .gitignore , please keep it in sync 8 | # 9 | # 10 | 11 | # Logs 12 | logs 13 | *.log 14 | *.swp 15 | *.swo 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | 28 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (http://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directory 38 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 39 | node_modules 40 | 41 | # Ignore build files 42 | public 43 | /errorShots 44 | /old 45 | /build 46 | -------------------------------------------------------------------------------- /test/artifacts.js: -------------------------------------------------------------------------------- 1 | var path = require("path"); 2 | var mkdirp = require("mkdirp"); 3 | 4 | 5 | function genPath(subPath) { 6 | subPath = subPath || "."; 7 | var buildPath; 8 | 9 | if(process.env.CIRCLECI) { 10 | buildPath = path.join("/tmp/artifacts", subPath); 11 | } 12 | else { 13 | buildPath = path.join(__dirname, '..', 'build', subPath); 14 | } 15 | 16 | return buildPath; 17 | } 18 | 19 | module.exports.path = function(subPath) { 20 | var dirPath = genPath(subPath); 21 | 22 | return new Promise(function(resolve, reject) { 23 | mkdirp(dirPath, function(err) { 24 | if(err) { 25 | reject(err); 26 | } 27 | else { 28 | resolve(dirPath); 29 | } 30 | }); 31 | }); 32 | } 33 | 34 | module.exports.pathSync = function(subPath) { 35 | var dirPath = genPath(subPath); 36 | mkdirp.sync(dirPath); 37 | return dirPath; 38 | } 39 | 40 | -------------------------------------------------------------------------------- /src/styles/_popup.scss: -------------------------------------------------------------------------------- 1 | .maputnik-popup-layer { 2 | display: flex; 3 | flex-direction: row; 4 | } 5 | 6 | .maputnik-popup-layer__swatch { 7 | display: inline-block; 8 | width: 5px; 9 | align-content: stretch; 10 | } 11 | 12 | .maputnik-popup-layer__label { 13 | display: block; 14 | color: $color-lowgray; 15 | cursor: pointer; 16 | user-select: none; 17 | line-height: 1.2; 18 | padding: $margin-2; 19 | padding-top: $margin-1; 20 | padding-bottom: $margin-1; 21 | } 22 | 23 | .maputnik-popup-layer-id { 24 | padding-left: $margin-2; 25 | padding-right: 1.6em; 26 | background-color: $color-midgray; 27 | color: $color-white; 28 | } 29 | 30 | .maputnik-feature-property-popup { 31 | max-height: calc(50vh - 40px); /* toolbar height: 40px */ 32 | overflow-y: auto; 33 | .maputnik-input-block { 34 | margin: 0; 35 | margin-left: $margin-2; 36 | margin-top: $margin-2; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/components/Collapse.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Collapse as ReactCollapse from 'react-collapse' 4 | import accessibility from '../../libs/accessibility' 5 | 6 | 7 | export default class Collapse extends React.Component { 8 | static propTypes = { 9 | isActive: PropTypes.bool.isRequired, 10 | children: PropTypes.element.isRequired 11 | } 12 | 13 | static defaultProps = { 14 | isActive: true 15 | } 16 | 17 | render() { 18 | if (accessibility.reducedMotionEnabled()) { 19 | return ( 20 |
21 | {this.props.children} 22 |
23 | ) 24 | } 25 | else { 26 | return ( 27 | 28 | {this.props.children} 29 | 30 | ) 31 | } 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /src/components/FilterEditorBlock.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import InputButton from './InputButton' 4 | import {MdDelete} from 'react-icons/md' 5 | 6 | export default class FilterEditorBlock extends React.Component { 7 | static propTypes = { 8 | onDelete: PropTypes.func.isRequired, 9 | children: PropTypes.element.isRequired, 10 | } 11 | 12 | render() { 13 | return
14 |
15 | 20 | 21 | 22 |
23 |
24 | {this.props.children} 25 |
26 |
27 | } 28 | } 29 | 30 | -------------------------------------------------------------------------------- /src/components/FieldMaxZoom.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import {latest} from '@mapbox/mapbox-gl-style-spec' 5 | import Block from './Block' 6 | import InputNumber from './InputNumber' 7 | 8 | export default class FieldMaxZoom extends React.Component { 9 | static propTypes = { 10 | value: PropTypes.number, 11 | onChange: PropTypes.func.isRequired, 12 | error: PropTypes.object, 13 | } 14 | 15 | render() { 16 | return 20 | 28 | 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/FieldMinZoom.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import {latest} from '@mapbox/mapbox-gl-style-spec' 5 | import Block from './Block' 6 | import InputNumber from './InputNumber' 7 | 8 | export default class FieldMinZoom extends React.Component { 9 | static propTypes = { 10 | value: PropTypes.number, 11 | onChange: PropTypes.func.isRequired, 12 | error: PropTypes.object, 13 | } 14 | 15 | render() { 16 | return 20 | 28 | 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/_FieldMaxZoom.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import {latest} from '@mapbox/mapbox-gl-style-spec' 5 | import Block from './Block' 6 | import FieldNumber from './FieldNumber' 7 | 8 | export default class BlockMaxZoom extends React.Component { 9 | static propTypes = { 10 | value: PropTypes.number, 11 | onChange: PropTypes.func.isRequired, 12 | error: PropTypes.object, 13 | } 14 | 15 | render() { 16 | return 20 | 28 | 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /src/components/_FieldMinZoom.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import {latest} from '@mapbox/mapbox-gl-style-spec' 5 | import Block from './Block' 6 | import FieldNumber from './FieldNumber' 7 | 8 | export default class BlockMinZoom extends React.Component { 9 | static propTypes = { 10 | value: PropTypes.number, 11 | onChange: PropTypes.func.isRequired, 12 | error: PropTypes.object, 13 | } 14 | 15 | render() { 16 | return 20 | 28 | 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /src/libs/zoomcontrol.js: -------------------------------------------------------------------------------- 1 | export default class ZoomControl { 2 | onAdd(map) { 3 | this._map = map; 4 | this._container = document.createElement('div'); 5 | this._container.className = 'mapboxgl-ctrl mapboxgl-ctrl-group mapboxgl-ctrl-zoom'; 6 | this._container.innerHTML = ` 7 | Zoom: 8 | `; 9 | this._textEl = this._container.querySelector("span"); 10 | 11 | this.addEventListeners(); 12 | 13 | return this._container; 14 | } 15 | 16 | updateZoomLevel() { 17 | this._textEl.innerHTML = this._map.getZoom().toFixed(2); 18 | } 19 | 20 | addEventListeners (){ 21 | this._map.on('render', this.updateZoomLevel.bind(this) ); 22 | this._map.on('zoomIn', this.updateZoomLevel.bind(this) ); 23 | this._map.on('zoomOut', this.updateZoomLevel.bind(this) ); 24 | } 25 | 26 | onRemove() { 27 | this._container.parentNode.removeChild(this._container); 28 | this._map = undefined; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /config/webpack.rules.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = [ 4 | { 5 | test: /\.jsx?$/, 6 | exclude: [ 7 | path.resolve(__dirname, '../node_modules') 8 | ], 9 | use: 'babel-loader' 10 | }, 11 | { 12 | test: /\.(eot|ttf|woff|woff2)$/, 13 | use: 'file-loader?name=fonts/[name].[ext]' 14 | }, 15 | { 16 | test: /\.ico$/, 17 | use: 'file-loader?name=[name].[ext]' 18 | }, 19 | { 20 | test: /\.(gif|jpg|png)$/, 21 | use: 'file-loader?name=img/[name].[ext]' 22 | }, 23 | { 24 | test: /\.svg$/, 25 | use: [ 26 | 'svg-inline-loader' 27 | ] 28 | }, 29 | { 30 | test: /[\/\\](node_modules|global|src)[\/\\].*\.scss$/, 31 | use: [ 32 | 'style-loader', 33 | "css-loader", 34 | "sass-loader" 35 | ] 36 | }, 37 | { 38 | test: /[\/\\](node_modules|global|src)[\/\\].*\.css$/, 39 | use: [ 40 | 'style-loader', 41 | 'css-loader' 42 | ] 43 | } 44 | ]; 45 | -------------------------------------------------------------------------------- /src/libs/debug.js: -------------------------------------------------------------------------------- 1 | import querystring from 'querystring' 2 | 3 | 4 | const debugStore = {}; 5 | 6 | function enabled() { 7 | const qs = querystring.parse(window.location.search.slice(1)); 8 | if(qs.hasOwnProperty("debug")) { 9 | return !!qs.debug.match(/^(|1|true)$/); 10 | } 11 | else { 12 | return false; 13 | } 14 | } 15 | 16 | function genErr() { 17 | return new Error("Debug not enabled, enable by appending '?debug' to your query string"); 18 | } 19 | 20 | function set(namespace, key, value) { 21 | if(!enabled()) { 22 | throw genErr(); 23 | } 24 | debugStore[namespace] = debugStore[namespace] || {}; 25 | debugStore[namespace][key] = value; 26 | } 27 | 28 | function get(namespace, key) { 29 | if(!enabled()) { 30 | throw genErr(); 31 | } 32 | if(debugStore.hasOwnProperty(namespace)) { 33 | return debugStore[namespace][key]; 34 | } 35 | } 36 | 37 | const mod = { 38 | enabled, 39 | get, 40 | set 41 | } 42 | 43 | window.debug = mod; 44 | export default mod; 45 | -------------------------------------------------------------------------------- /stories/FieldCheckbox.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import FieldCheckbox from '../src/components/FieldCheckbox'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'FieldCheckbox', 9 | component: FieldCheckbox, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const BasicUnchecked = () => { 15 | const [value, setValue] = useActionState("onChange", false); 16 | 17 | return ( 18 | 19 | 24 | 25 | ); 26 | }; 27 | 28 | export const BasicChecked = () => { 29 | const [value, setValue] = useActionState("onChange", true); 30 | 31 | return ( 32 | 33 | 38 | 39 | ); 40 | }; 41 | 42 | -------------------------------------------------------------------------------- /src/components/FieldSource.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import {latest} from '@mapbox/mapbox-gl-style-spec' 5 | import Block from './Block' 6 | import InputAutocomplete from './InputAutocomplete' 7 | 8 | export default class FieldSource extends React.Component { 9 | static propTypes = { 10 | value: PropTypes.string, 11 | wdKey: PropTypes.string, 12 | onChange: PropTypes.func, 13 | sourceIds: PropTypes.array, 14 | error: PropTypes.object, 15 | } 16 | 17 | static defaultProps = { 18 | onChange: () => {}, 19 | sourceIds: [], 20 | } 21 | 22 | render() { 23 | return 29 | [src, src])} 33 | /> 34 | 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/styles/_vars.scss: -------------------------------------------------------------------------------- 1 | $color-black: #191b20; 2 | $color-gray: #222429; 3 | $color-midgray: #303237; 4 | $color-lowgray: #a4a4a4; 5 | $color-white: #f0f0f0; 6 | $color-red: #cf4a4a; 7 | $color-green: #53b972; 8 | $margin-1: 3px; 9 | $margin-2: 5px; 10 | $margin-3: 10px; 11 | $margin-4: 30px; 12 | $margin-5: 40px; 13 | $font-size-1: 24px; 14 | $font-size-2: 20px; 15 | $font-size-3: 18px; 16 | $font-size-4: 16px; 17 | $font-size-5: 14px; 18 | $font-size-6: 12px; 19 | $font-family: Roboto, sans-serif; 20 | 21 | $toolbar-height: 40px; 22 | $toolbar-offset: 0; 23 | 24 | $layout-list-width: 200px; 25 | $layout-editor-width: 370px; 26 | $layout-map-width: calc(100% - #{$layout-list-width + $layout-editor-width}); 27 | 28 | // 'menu-down' from 'https://materialdesignicons.com/' 29 | // See 30 | $icon-down-arrow: "data:image/svg+xml;charset=utf-8," 31 | -------------------------------------------------------------------------------- /stories/InputUrl.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import InputUrl from '../src/components/InputUrl'; 4 | import {InputContainer} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'InputUrl', 9 | component: InputUrl, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const Valid = () => { 15 | const [value, setValue] = useActionState("onChange", "http://example.com"); 16 | 17 | return ( 18 | 19 | 24 | 25 | ); 26 | }; 27 | 28 | export const Invalid = () => { 29 | const [value, setValue] = useActionState("onChange", "foo"); 30 | 31 | return ( 32 | 33 | 38 | 39 | ); 40 | }; 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/components/_FieldSource.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import {latest} from '@mapbox/mapbox-gl-style-spec' 5 | import Block from './Block' 6 | import FieldAutocomplete from './FieldAutocomplete' 7 | 8 | export default class BlockSource extends React.Component { 9 | static propTypes = { 10 | value: PropTypes.string, 11 | wdKey: PropTypes.string, 12 | onChange: PropTypes.func, 13 | sourceIds: PropTypes.array, 14 | error: PropTypes.object, 15 | } 16 | 17 | static defaultProps = { 18 | onChange: () => {}, 19 | sourceIds: [], 20 | } 21 | 22 | render() { 23 | return 29 | [src, src])} 33 | /> 34 | 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /stories/InputCheckbox.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import InputCheckbox from '../src/components/InputCheckbox'; 4 | import {InputContainer} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'InputCheckbox', 9 | component: InputCheckbox, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const BasicUnchecked = () => { 15 | const [value, setValue] = useActionState("onChange", false); 16 | 17 | return ( 18 | 19 | 24 | 25 | ); 26 | }; 27 | 28 | export const BasicChecked = () => { 29 | const [value, setValue] = useActionState("onChange", true); 30 | 31 | return ( 32 | 33 | 38 | 39 | ); 40 | }; 41 | 42 | -------------------------------------------------------------------------------- /stories/FieldUrl.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import FieldUrl from '../src/components/FieldUrl'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'FieldUrl', 9 | component: FieldUrl, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const Valid = () => { 15 | const [value, setValue] = useActionState("onChange", "http://example.com"); 16 | 17 | return ( 18 | 19 | 25 | 26 | ); 27 | }; 28 | 29 | export const Invalid = () => { 30 | const [value, setValue] = useActionState("onChange", "foo"); 31 | 32 | return ( 33 | 34 | 40 | 41 | ); 42 | }; 43 | 44 | 45 | -------------------------------------------------------------------------------- /stories/InputArray.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import InputArray from '../src/components/InputArray'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'InputArray', 9 | component: InputArray, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const NumberType = () => { 15 | const [value, setValue] = useActionState("onChange", [1,2,3]); 16 | 17 | return ( 18 | 19 | 25 | 26 | ); 27 | }; 28 | 29 | export const StringType = () => { 30 | const [value, setValue] = useActionState("onChange", ["a", "b", "c"]); 31 | 32 | return ( 33 | 34 | 40 | 41 | ); 42 | }; 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/components/FieldSourceLayer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import {latest} from '@mapbox/mapbox-gl-style-spec' 5 | import Block from './Block' 6 | import InputAutocomplete from './InputAutocomplete' 7 | 8 | export default class FieldSourceLayer extends React.Component { 9 | static propTypes = { 10 | value: PropTypes.string, 11 | onChange: PropTypes.func, 12 | sourceLayerIds: PropTypes.array, 13 | isFixed: PropTypes.bool, 14 | } 15 | 16 | static defaultProps = { 17 | onChange: () => {}, 18 | sourceLayerIds: [], 19 | isFixed: false 20 | } 21 | 22 | render() { 23 | return 26 | [l, l])} 31 | /> 32 | 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/_FieldSourceLayer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import {latest} from '@mapbox/mapbox-gl-style-spec' 5 | import Block from './Block' 6 | import FieldAutocomplete from './FieldAutocomplete' 7 | 8 | export default class BlockSourceLayer extends React.Component { 9 | static propTypes = { 10 | value: PropTypes.string, 11 | onChange: PropTypes.func, 12 | sourceLayerIds: PropTypes.array, 13 | isFixed: PropTypes.bool, 14 | } 15 | 16 | static defaultProps = { 17 | onChange: () => {}, 18 | sourceLayerIds: [], 19 | isFixed: false 20 | } 21 | 22 | render() { 23 | return 26 | [l, l])} 31 | /> 32 | 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /stories/FieldNumber.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import FieldNumber from '../src/components/FieldNumber'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'FieldNumber', 9 | component: FieldNumber, 10 | decorators: [withA11y], 11 | }; 12 | 13 | export const Basic = () => { 14 | const [value, setValue] = useActionState("onChange", 1); 15 | 16 | return ( 17 | 18 | 23 | 24 | ); 25 | }; 26 | 27 | export const Range = () => { 28 | const [value, setValue] = useActionState("onChange", 1); 29 | 30 | return ( 31 | 32 | 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/components/InputButton.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import classnames from 'classnames' 4 | 5 | export default class InputButton extends React.Component { 6 | static propTypes = { 7 | "data-wd-key": PropTypes.string, 8 | "aria-label": PropTypes.string, 9 | onClick: PropTypes.func, 10 | style: PropTypes.object, 11 | className: PropTypes.string, 12 | children: PropTypes.node, 13 | disabled: PropTypes.bool, 14 | type: PropTypes.string, 15 | id: PropTypes.string, 16 | title: PropTypes.string, 17 | } 18 | 19 | render() { 20 | return 33 | } 34 | } 35 | 36 | -------------------------------------------------------------------------------- /src/styles/_layout.scss: -------------------------------------------------------------------------------- 1 | //SCROLLING 2 | .maputnik-scroll-container { 3 | overflow-x: hidden; 4 | overflow-y: scroll; 5 | bottom: 0; 6 | left: 0; 7 | right: 0; 8 | top: 1px; 9 | position: absolute; 10 | } 11 | 12 | //APP LAYOUT 13 | .maputnik-layout { 14 | font-family: $font-family; 15 | color: $color-white; 16 | 17 | &-list { 18 | position: fixed; 19 | bottom: 0; 20 | height: calc(100% - #{$toolbar-height + $toolbar-offset}); 21 | top: $toolbar-height + $toolbar-offset; 22 | left: 0; 23 | z-index: 3; 24 | width: 200px; 25 | background-color: $color-black; 26 | } 27 | 28 | &-drawer { 29 | position: fixed; 30 | bottom: 0; 31 | height: calc(100% - #{$toolbar-height + $toolbar-offset}); 32 | top: $toolbar-height + $toolbar-offset; 33 | left: 200px; 34 | z-index: 1; 35 | width: 370px; 36 | background-color: $color-black; 37 | } 38 | 39 | &-bottom { 40 | position: fixed; 41 | bottom: 0; 42 | right: 0; 43 | z-index: 1; 44 | width: $layout-map-width; 45 | background-color: $color-black; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /stories/FieldArray.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import FieldArray from '../src/components/FieldArray'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'FieldArray', 9 | component: FieldArray, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const NumberType = () => { 15 | const [value, setValue] = useActionState("onChange", [1,2,3]); 16 | 17 | return ( 18 | 19 | 26 | 27 | ); 28 | }; 29 | 30 | export const StringType = () => { 31 | const [value, setValue] = useActionState("onChange", ["a", "b", "c"]); 32 | 33 | return ( 34 | 35 | 42 | 43 | ); 44 | }; 45 | 46 | 47 | -------------------------------------------------------------------------------- /stories/InputNumber.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import InputNumber from '../src/components/InputNumber'; 4 | import {InputContainer} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'InputNumber', 9 | component: InputNumber, 10 | decorators: [withA11y], 11 | }; 12 | 13 | export const Basic = () => { 14 | const [value, setValue] = useActionState("onChange", 1); 15 | 16 | return ( 17 | 18 | 23 | 24 | ); 25 | }; 26 | 27 | export const Range = () => { 28 | const [value, setValue] = useActionState("onChange", 1); 29 | 30 | return ( 31 | 32 | 41 | 42 | ); 43 | }; 44 | 45 | -------------------------------------------------------------------------------- /stories/FieldFunction.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import FieldFunction from '../src/components/FieldFunction'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | import {latest} from '@mapbox/mapbox-gl-style-spec' 7 | 8 | 9 | export default { 10 | title: 'FieldFunction', 11 | component: FieldFunction, 12 | decorators: [withA11y], 13 | }; 14 | 15 | export const Basic = () => { 16 | const value = { 17 | "property": "rank", 18 | "type": "categorical", 19 | "default": "#222", 20 | "stops": [ 21 | [ 22 | {"zoom": 6, "value": ""}, 23 | ["#777"] 24 | ], 25 | [ 26 | {"zoom": 10, "value": ""}, 27 | ["#444"] 28 | ] 29 | ] 30 | }; 31 | 32 | return
33 | {}} 35 | value={value} 36 | errors={[]} 37 | fieldName={"Color"} 38 | fieldType={"color"} 39 | fieldSpec={latest['paint_fill']['fill-color']} 40 | /> 41 |
42 | }; 43 | 44 | -------------------------------------------------------------------------------- /src/components/InputCheckbox.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | export default class InputCheckbox extends React.Component { 5 | static propTypes = { 6 | value: PropTypes.bool, 7 | style: PropTypes.object, 8 | onChange: PropTypes.func, 9 | } 10 | 11 | static defaultProps = { 12 | value: false, 13 | } 14 | 15 | onChange = () => { 16 | this.props.onChange(!this.props.value); 17 | } 18 | 19 | render() { 20 | return
21 | 29 |
30 | 33 | 34 | 35 |
36 |
37 | } 38 | } 39 | 40 | -------------------------------------------------------------------------------- /src/components/InputSelect.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | export default class InputSelect extends React.Component { 5 | static propTypes = { 6 | value: PropTypes.string.isRequired, 7 | "data-wd-key": PropTypes.string, 8 | options: PropTypes.array.isRequired, 9 | style: PropTypes.object, 10 | onChange: PropTypes.func.isRequired, 11 | title: PropTypes.string, 12 | 'aria-label': PropTypes.string, 13 | } 14 | 15 | 16 | render() { 17 | let options = this.props.options 18 | if(options.length > 0 && !Array.isArray(options[0])) { 19 | options = options.map(v => [v, v]) 20 | } 21 | 22 | return 33 | } 34 | } 35 | 36 | 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Lukas Martinelli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /test/functional/index.js: -------------------------------------------------------------------------------- 1 | var config = require("../config/specs"); 2 | var helper = require("./helper"); 3 | 4 | 5 | describe('maputnik', function() { 6 | 7 | before(function(done) { 8 | require("./util/webdriverio-ext"); 9 | helper.startGeoserver(done); 10 | }); 11 | 12 | after(function(done) { 13 | helper.stopGeoserver(done); 14 | }); 15 | 16 | beforeEach(function() { 17 | browser.url(config.baseUrl+"?debug&style="+helper.getStyleUrl([ 18 | "geojson:example", 19 | "raster:raster" 20 | ])); 21 | browser.acceptAlert(); 22 | browser.execute(function() { 23 | localStorage.setItem("survey", true); 24 | }); 25 | const elem = $(".maputnik-toolbar-link"); 26 | elem.waitForExist(); 27 | browser.flushReactUpdates(); 28 | }); 29 | 30 | // -------- setup -------- 31 | require("./util/coverage"); 32 | // ----------------------- 33 | 34 | // ---- All the tests ---- 35 | require("./history"); 36 | require("./layers"); 37 | require("./map"); 38 | require("./modals"); 39 | require("./screenshots"); 40 | require("./accessibility"); 41 | require("./keyboard"); 42 | // ------------------------ 43 | 44 | }); 45 | 46 | -------------------------------------------------------------------------------- /test/functional/map/index.js: -------------------------------------------------------------------------------- 1 | var config = require("../../config/specs"); 2 | var helper = require("../helper"); 3 | 4 | 5 | describe("map", function() { 6 | describe.skip("zoom level", function() { 7 | it("via url", function() { 8 | var zoomLevel = "12.37" 9 | browser.url(config.baseUrl+"?debug&style="+helper.getStyleUrl([ 10 | "geojson:example" 11 | ])+"#"+zoomLevel+"/41.3805/2.1635"); 12 | browser.alertAccept(); 13 | 14 | browser.waitUntil(function () { 15 | return ( 16 | browser.isVisible(".mapboxgl-ctrl-zoom") 17 | && browser.getText(".mapboxgl-ctrl-zoom") === "Zoom level: "+(zoomLevel) 18 | ); 19 | }, 10*1000) 20 | }) 21 | it("via map controls", function() { 22 | var zoomLevel = 12.37; 23 | browser.url(config.baseUrl+"?debug&style="+helper.getStyleUrl([ 24 | "geojson:example" 25 | ])+"#"+zoomLevel+"/41.3805/2.1635"); 26 | browser.alertAccept(); 27 | 28 | browser.click(".mapboxgl-ctrl-zoom-in") 29 | browser.waitUntil(function () { 30 | var text = browser.getText(".mapboxgl-ctrl-zoom") 31 | return text === "Zoom level: "+(zoomLevel+1); 32 | }, 10*1000) 33 | }) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/components/LayerListGroup.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Collapser from './Collapser' 4 | 5 | export default class LayerListGroup extends React.Component { 6 | static propTypes = { 7 | title: PropTypes.string.isRequired, 8 | "data-wd-key": PropTypes.string, 9 | isActive: PropTypes.bool.isRequired, 10 | onActiveToggle: PropTypes.func.isRequired, 11 | 'aria-controls': PropTypes.string, 12 | } 13 | 14 | render() { 15 | return
  • 16 |
    this.props.onActiveToggle(!this.props.isActive)} 19 | > 20 | 27 | 28 | 32 |
    33 |
  • 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/components/ModalLoading.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import InputButton from './InputButton' 5 | import Modal from './Modal' 6 | 7 | 8 | export default class ModalLoading extends React.Component { 9 | static propTypes = { 10 | isOpen: PropTypes.bool.isRequired, 11 | onCancel: PropTypes.func.isRequired, 12 | title: PropTypes.string.isRequired, 13 | message: PropTypes.node.isRequired, 14 | } 15 | 16 | underlayOnClick(e) { 17 | // This stops click events falling through to underlying modals. 18 | e.stopPropagation(); 19 | } 20 | 21 | render() { 22 | return underlayProps(e) 28 | }} 29 | closeable={false} 30 | title={this.props.title} 31 | onOpenToggle={() => this.props.onCancel()} 32 | > 33 |

    34 | {this.props.message} 35 |

    36 |

    37 | this.props.onCancel(e)}> 38 | Cancel 39 | 40 |

    41 |
    42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /src/libs/metadata.js: -------------------------------------------------------------------------------- 1 | import npmurl from 'url' 2 | 3 | function loadJSON(url, defaultValue, cb) { 4 | fetch(url, { 5 | mode: 'cors', 6 | credentials: "same-origin" 7 | }) 8 | .then(function(response) { 9 | return response.json(); 10 | }) 11 | .then(function(body) { 12 | cb(body) 13 | }) 14 | .catch(function() { 15 | console.warn('Can not metadata for ' + url) 16 | cb(defaultValue) 17 | }) 18 | } 19 | 20 | export function downloadGlyphsMetadata(urlTemplate, cb) { 21 | if(!urlTemplate) return cb([]) 22 | 23 | // Special handling because Tileserver GL serves the fontstacks metadata differently 24 | // https://github.com/klokantech/tileserver-gl/pull/104#issuecomment-274444087 25 | let urlObj = npmurl.parse(urlTemplate); 26 | const normPathPart = '/%7Bfontstack%7D/%7Brange%7D.pbf'; 27 | if(urlObj.pathname === normPathPart) { 28 | urlObj.pathname = '/fontstacks.json'; 29 | } else { 30 | urlObj.pathname = urlObj.pathname.replace(normPathPart, '.json'); 31 | } 32 | let url = npmurl.format(urlObj); 33 | 34 | loadJSON(url, [], cb) 35 | } 36 | 37 | export function downloadSpriteMetadata(baseUrl, cb) { 38 | if(!baseUrl) return cb([]) 39 | const url = baseUrl + '.json' 40 | loadJSON(url, {}, glyphs => cb(Object.keys(glyphs))) 41 | } 42 | -------------------------------------------------------------------------------- /stories/IconLayer.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import IconLayer from '../src/components/IconLayer'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'IconLayer', 10 | component: IconLayer, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const IconList = () => { 15 | const types = [ 16 | 'fill-extrusion', 17 | 'raster', 18 | 'hillshade', 19 | 'heatmap', 20 | 'fill', 21 | 'background', 22 | 'line', 23 | 'symbol', 24 | 'circle', 25 | 'INVALID', 26 | ] 27 | 28 | return 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {types.map(type => ( 38 | 39 | 42 | 45 | 46 | ))} 47 | 48 |
    IDPreview
    40 | {type} 41 | 43 | 44 |
    49 |
    50 | }; 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /src/components/AppLayout.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import ScrollContainer from './ScrollContainer' 4 | 5 | class AppLayout extends React.Component { 6 | static propTypes = { 7 | toolbar: PropTypes.element.isRequired, 8 | layerList: PropTypes.element.isRequired, 9 | layerEditor: PropTypes.element, 10 | map: PropTypes.element.isRequired, 11 | bottom: PropTypes.element, 12 | modals: PropTypes.node, 13 | } 14 | 15 | static childContextTypes = { 16 | reactIconBase: PropTypes.object 17 | } 18 | 19 | getChildContext() { 20 | return { 21 | reactIconBase: { size: 14 } 22 | } 23 | } 24 | 25 | render() { 26 | return
    27 | {this.props.toolbar} 28 |
    29 | {this.props.layerList} 30 |
    31 |
    32 | 33 | {this.props.layerEditor} 34 | 35 |
    36 | {this.props.map} 37 | {this.props.bottom &&
    38 | {this.props.bottom} 39 |
    40 | } 41 | {this.props.modals} 42 |
    43 | } 44 | } 45 | 46 | export default AppLayout 47 | -------------------------------------------------------------------------------- /src/components/IconLayer.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import IconLine from './IconLine.jsx' 5 | import IconFill from './IconFill.jsx' 6 | import IconSymbol from './IconSymbol.jsx' 7 | import IconBackground from './IconBackground.jsx' 8 | import IconCircle from './IconCircle.jsx' 9 | import IconMissing from './IconMissing.jsx' 10 | 11 | export default class IconLayer extends React.Component { 12 | static propTypes = { 13 | type: PropTypes.string.isRequired, 14 | style: PropTypes.object, 15 | } 16 | 17 | render() { 18 | const iconProps = { style: this.props.style } 19 | switch(this.props.type) { 20 | case 'fill-extrusion': return 21 | case 'raster': return 22 | case 'hillshade': return 23 | case 'heatmap': return 24 | case 'fill': return 25 | case 'background': return 26 | case 'line': return 27 | case 'symbol': return 28 | case 'circle': return 29 | default: return 30 | } 31 | } 32 | } 33 | 34 | -------------------------------------------------------------------------------- /src/libs/highlight.js: -------------------------------------------------------------------------------- 1 | import stylegen from 'mapbox-gl-inspect/lib/stylegen' 2 | import colors from 'mapbox-gl-inspect/lib/colors' 3 | 4 | export function colorHighlightedLayer(layer) { 5 | if(!layer || layer.type === 'background' || layer.type === 'raster') return null 6 | 7 | function changeLayer(l) { 8 | if(l.type === 'circle') { 9 | l.paint['circle-radius'] = 3 10 | } else if(l.type === 'line') { 11 | l.paint['line-width'] = 2 12 | } 13 | 14 | if(layer.filter) { 15 | l.filter = layer.filter 16 | } else { 17 | delete l['filter'] 18 | } 19 | l.id = l.id + '_highlight' 20 | return l 21 | } 22 | 23 | const sourceLayerId = layer['source-layer'] || '' 24 | const color = colors.brightColor(sourceLayerId, 1) 25 | const layers = [] 26 | 27 | if(layer.type === "fill" || layer.type === 'fill-extrusion') { 28 | return changeLayer(stylegen.polygonLayer(color, color, layer.source, layer['source-layer'])) 29 | } 30 | 31 | if(layer.type === "symbol" || layer.type === 'circle') { 32 | return changeLayer(stylegen.circleLayer(color, layer.source, layer['source-layer'])) 33 | } 34 | 35 | if(layer.type === 'line') { 36 | return changeLayer(stylegen.lineLayer(color, layer.source, layer['source-layer'])) 37 | } 38 | 39 | return null 40 | } 41 | -------------------------------------------------------------------------------- /src/styles/_reset.scss: -------------------------------------------------------------------------------- 1 | /* stylelint-disable */ 2 | html { 3 | background-color: rgb(28, 31, 36); 4 | } 5 | 6 | /* CSS Reset */ 7 | html, body, div, span, applet, object, iframe, 8 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 9 | a, abbr, acronym, address, big, cite, code, 10 | del, dfn, em, img, ins, kbd, q, s, samp, 11 | small, strike, strong, sub, sup, tt, var, 12 | b, u, i, center, 13 | dl, dt, dd, ol, ul, li, 14 | fieldset, form, label, legend, 15 | table, caption, tbody, tfoot, thead, tr, th, td, 16 | article, aside, canvas, details, embed, 17 | figure, figcaption, footer, header, hgroup, 18 | menu, nav, output, ruby, section, summary, 19 | time, mark, audio, video { 20 | margin: 0; 21 | padding: 0; 22 | border: 0; 23 | font-size: 100%; 24 | font: inherit; 25 | vertical-align: baseline; 26 | } 27 | /* HTML5 display-role reset for older browsers */ 28 | article, aside, details, figcaption, figure, 29 | footer, header, hgroup, menu, nav, section { 30 | display: block; 31 | } 32 | body { 33 | line-height: 1; 34 | } 35 | ol, ul { 36 | list-style: none; 37 | } 38 | blockquote, q { 39 | quotes: none; 40 | } 41 | blockquote:before, blockquote:after, 42 | q:before, q:after { 43 | content: ''; 44 | content: none; 45 | } 46 | table { 47 | border-collapse: collapse; 48 | border-spacing: 0; 49 | } 50 | -------------------------------------------------------------------------------- /src/components/SpecField.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Block from './Block' 4 | import InputSpec from './InputSpec' 5 | import Fieldset from './Fieldset' 6 | 7 | 8 | const typeMap = { 9 | color: () => Block, 10 | enum: ({fieldSpec}) => (Object.keys(fieldSpec.values).length <= 3 ? Fieldset : Block), 11 | number: () => Block, 12 | boolean: () => Block, 13 | array: () => Fieldset, 14 | resolvedImage: () => Block, 15 | number: () => Block, 16 | string: () => Block, 17 | formatted: () => Block, 18 | }; 19 | 20 | export default class SpecField extends React.Component { 21 | static propTypes = { 22 | ...InputSpec.propTypes, 23 | name: PropTypes.string, 24 | } 25 | 26 | render() { 27 | const {props} = this; 28 | 29 | const fieldType = props.fieldSpec.type; 30 | 31 | const typeBlockFn = typeMap[fieldType]; 32 | 33 | let TypeBlock; 34 | if (typeBlockFn) { 35 | TypeBlock = typeBlockFn(props); 36 | } 37 | else { 38 | console.warn("No such type for '%s'", fieldType); 39 | TypeBlock = Block; 40 | } 41 | 42 | return 47 | 48 | 49 | } 50 | } 51 | 52 | -------------------------------------------------------------------------------- /src/components/InputMultiInput.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import classnames from 'classnames' 4 | import InputButton from './InputButton' 5 | 6 | export default class InputMultiInput extends React.Component { 7 | static propTypes = { 8 | name: PropTypes.string.isRequired, 9 | value: PropTypes.string.isRequired, 10 | options: PropTypes.array.isRequired, 11 | onChange: PropTypes.func.isRequired, 12 | } 13 | 14 | render() { 15 | let options = this.props.options 16 | if(options.length > 0 && !Array.isArray(options[0])) { 17 | options = options.map(v => [v, v]) 18 | } 19 | 20 | const selectedValue = this.props.value || options[0][0] 21 | const radios = options.map(([val, label])=> { 22 | return 34 | }) 35 | 36 | return
    37 | {radios} 38 |
    39 | } 40 | } 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/components/InputEnum.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import InputSelect from './InputSelect' 4 | import InputMultiInput from './InputMultiInput' 5 | 6 | 7 | function optionsLabelLength(options) { 8 | let sum = 0; 9 | options.forEach(([_, label]) => { 10 | sum += label.length 11 | }) 12 | return sum 13 | } 14 | 15 | 16 | export default class InputEnum extends React.Component { 17 | static propTypes = { 18 | "data-wd-key": PropTypes.string, 19 | value: PropTypes.string, 20 | style: PropTypes.object, 21 | default: PropTypes.string, 22 | name: PropTypes.string, 23 | onChange: PropTypes.func, 24 | options: PropTypes.array, 25 | 'aria-label': PropTypes.string, 26 | } 27 | 28 | render() { 29 | const {options, value, onChange, name, label} = this.props; 30 | 31 | if(options.length <= 3 && optionsLabelLength(options) <= 20) { 32 | return 39 | } else { 40 | return 46 | } 47 | } 48 | } 49 | 50 | -------------------------------------------------------------------------------- /stories/InputDynamicArray.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import InputDynamicArray from '../src/components/InputDynamicArray'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'InputDynamicArray', 9 | component: InputDynamicArray, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const NumberType = () => { 15 | const [value, setValue] = useActionState("onChange", [1,2,3]); 16 | 17 | return ( 18 | 19 | 24 | 25 | ); 26 | }; 27 | 28 | export const UrlType = () => { 29 | const [value, setValue] = useActionState("onChange", ["http://example.com"]); 30 | 31 | return ( 32 | 33 | 38 | 39 | ); 40 | }; 41 | 42 | export const EnumType = () => { 43 | const [value, setValue] = useActionState("onChange", ["foo"]); 44 | 45 | return ( 46 | 47 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/_SpecProperty.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import SpecField from './SpecField' 5 | import FunctionButtons from './_FunctionButtons' 6 | import Block from './Block' 7 | 8 | import labelFromFieldName from './_labelFromFieldName' 9 | 10 | 11 | export default class SpecProperty extends React.Component { 12 | static propTypes = { 13 | onZoomClick: PropTypes.func.isRequired, 14 | onDataClick: PropTypes.func.isRequired, 15 | fieldName: PropTypes.string, 16 | fieldType: PropTypes.string, 17 | fieldSpec: PropTypes.object, 18 | value: PropTypes.any, 19 | errors: PropTypes.object, 20 | onExpressionClick: PropTypes.func, 21 | } 22 | 23 | static defaultProps = { 24 | errors: {}, 25 | } 26 | 27 | render() { 28 | const {errors, fieldName, fieldType} = this.props; 29 | 30 | const functionBtn = 37 | 38 | const error = errors[fieldType+"."+fieldName]; 39 | 40 | return 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /stories/FieldDynamicArray.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import FieldDynamicArray from '../src/components/FieldDynamicArray'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'FieldDynamicArray', 9 | component: FieldDynamicArray, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const NumberType = () => { 15 | const [value, setValue] = useActionState("onChange", [1,2,3]); 16 | 17 | return ( 18 | 19 | 25 | 26 | ); 27 | }; 28 | 29 | export const UrlType = () => { 30 | const [value, setValue] = useActionState("onChange", ["http://example.com"]); 31 | 32 | return ( 33 | 34 | 40 | 41 | ); 42 | }; 43 | 44 | export const EnumType = () => { 45 | const [value, setValue] = useActionState("onChange", ["foo"]); 46 | 47 | return ( 48 | 49 | 56 | 57 | ); 58 | }; 59 | -------------------------------------------------------------------------------- /stories/LayerListItem.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import LayerList from '../src/components/LayerList'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'LayerList', 10 | component: LayerList, 11 | decorators: [withA11y], 12 | }; 13 | 14 | export const Basic = () => ( 15 | 16 |
    17 | {}} 46 | onLayerSelect={() => {}} 47 | onLayerDestroy={() => {}} 48 | onLayerCopy={() => {}} 49 | onLayerVisibilityToggle={() => {}} 50 | onMoveLayer={() => {}} 51 | sources={{}} 52 | errors={[]} 53 | /> 54 |
    55 |
    56 | ); 57 | 58 | 59 | -------------------------------------------------------------------------------- /src/components/ModalSurvey.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import InputButton from './InputButton' 5 | import Modal from './Modal' 6 | 7 | import logoImage from 'maputnik-design/logos/logo-color.svg' 8 | 9 | export default class ModalSurvey extends React.Component { 10 | static propTypes = { 11 | isOpen: PropTypes.bool.isRequired, 12 | onOpenToggle: PropTypes.func.isRequired, 13 | } 14 | 15 | onClick = () => { 16 | window.open('https://gregorywolanski.typeform.com/to/cPgaSY', '_blank'); 17 | 18 | this.props.onOpenToggle(); 19 | } 20 | 21 | render() { 22 | return 28 |
    29 | 30 |

    You + Maputnik = Maputnik better for you

    31 |

    We don’t track you, so we don’t know how you use Maputnik. Help us make Maputnik better for you by completing a 7–minute survey carried out by our contributing designer.

    32 | Take the Maputnik Survey 33 |

    It takes 7 minutes, tops! Every question is optional.

    34 |
    35 |
    36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /src/components/FieldType.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import {latest} from '@mapbox/mapbox-gl-style-spec' 5 | import Block from './Block' 6 | import InputSelect from './InputSelect' 7 | import InputString from './InputString' 8 | 9 | export default class FieldType extends React.Component { 10 | static propTypes = { 11 | value: PropTypes.string.isRequired, 12 | wdKey: PropTypes.string, 13 | onChange: PropTypes.func.isRequired, 14 | error: PropTypes.object, 15 | disabled: PropTypes.bool, 16 | } 17 | 18 | static defaultProps = { 19 | disabled: false, 20 | } 21 | 22 | render() { 23 | return 27 | {this.props.disabled && 28 | 32 | } 33 | {!this.props.disabled && 34 | 49 | } 50 | 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/components/_FieldType.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import {latest} from '@mapbox/mapbox-gl-style-spec' 5 | import Block from './Block' 6 | import FieldSelect from './FieldSelect' 7 | import FieldString from './FieldString' 8 | 9 | export default class BlockType extends React.Component { 10 | static propTypes = { 11 | value: PropTypes.string.isRequired, 12 | wdKey: PropTypes.string, 13 | onChange: PropTypes.func.isRequired, 14 | error: PropTypes.object, 15 | disabled: PropTypes.bool, 16 | } 17 | 18 | static defaultProps = { 19 | disabled: false, 20 | } 21 | 22 | render() { 23 | return 27 | {this.props.disabled && 28 | 32 | } 33 | {!this.props.disabled && 34 | 49 | } 50 | 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /src/styles/_map.scss: -------------------------------------------------------------------------------- 1 | //OPENLAYERS 2 | .maputnik-layout { 3 | .ol-zoom { 4 | top: 40px; 5 | right: 10px; 6 | left: auto; 7 | } 8 | 9 | .ol-rotate { 10 | top: 94px; 11 | right: 10px; 12 | left: auto; 13 | } 14 | 15 | .ol-attribution.ol-logo-only { 16 | height: 20px; 17 | } 18 | 19 | .ol-attribution a { 20 | color: rgba(0, 0, 0, 0.75); 21 | text-decoration: none; 22 | } 23 | 24 | .ol-control { 25 | button { 26 | background-color: rgb(28, 31, 36); 27 | } 28 | 29 | button:hover { 30 | background-color: rgb(86, 83, 83); 31 | } 32 | } 33 | } 34 | 35 | 36 | .maputnik-ol { 37 | width: 100%; 38 | height: 100%; 39 | } 40 | 41 | .maputnik-ol-popup { 42 | background: $color-black; 43 | 44 | } 45 | 46 | .maputnik-coords { 47 | font-family: monospace; 48 | &:before { 49 | content: '['; 50 | color: #888; 51 | } 52 | &:after { 53 | content: ']'; 54 | color: #888; 55 | } 56 | } 57 | 58 | .maputnik-ol-debug { 59 | font-family: monospace; 60 | font-size: smaller; 61 | position: absolute; 62 | bottom: 10px; 63 | left: 10px; 64 | background: rgb(28, 31, 36); 65 | padding: 6px 8px; 66 | border-radius: 2px; 67 | z-index: 9999; 68 | } 69 | 70 | .maputnik-ol-zoom { 71 | position: absolute; 72 | right: 10px; 73 | top: 10px; 74 | background: #1c1f24; 75 | border-radius: 2px; 76 | padding: 6px 8px; 77 | color: $color-lowgray; 78 | z-index: 9999; 79 | font-size: 12px; 80 | font-weight: bold; 81 | } 82 | 83 | .maputnik-ol-container { 84 | display: flex; 85 | flex: 1; 86 | position: relative; 87 | } 88 | -------------------------------------------------------------------------------- /test/functional/accessibility/skip-links.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert"); 2 | var config = require("../../config/specs"); 3 | var helper = require("../helper"); 4 | var wd = require("../../wd-helper"); 5 | 6 | 7 | describe("skip links", function() { 8 | beforeEach(function () { 9 | browser.url(config.baseUrl+"?debug&style="+helper.getGeoServerUrl("example-layer-style.json")); 10 | browser.acceptAlert(); 11 | }); 12 | 13 | it("skip link to layer list", function() { 14 | const selector = wd.$("root:skip:layer-list") 15 | const elem = $(selector); 16 | assert(elem.isExisting()); 17 | browser.keys(['Tab']); 18 | assert(elem.isFocused()); 19 | elem.click(); 20 | 21 | const targetEl = $("#skip-target-layer-list"); 22 | assert(targetEl.isFocused()); 23 | }); 24 | 25 | it("skip link to layer editor", function() { 26 | const selector = wd.$("root:skip:layer-editor") 27 | const elem = $(selector); 28 | assert(elem.isExisting()); 29 | browser.keys(['Tab']); 30 | browser.keys(['Tab']); 31 | assert(elem.isFocused()); 32 | elem.click(); 33 | 34 | const targetEl = $("#skip-target-layer-editor"); 35 | assert(targetEl.isFocused()); 36 | }); 37 | 38 | it("skip link to map view", function() { 39 | const selector = wd.$("root:skip:map-view") 40 | const elem = $(selector); 41 | assert(elem.isExisting()); 42 | browser.keys(['Tab']); 43 | browser.keys(['Tab']); 44 | browser.keys(['Tab']); 45 | assert(elem.isFocused()); 46 | elem.click(); 47 | 48 | const targetEl = $(".mapboxgl-canvas"); 49 | assert(targetEl.isFocused()); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/components/Fieldset.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import FieldDocLabel from './FieldDocLabel' 4 | import Doc from './Doc' 5 | 6 | 7 | let IDX = 0; 8 | 9 | export default class Fieldset extends React.Component { 10 | constructor (props) { 11 | super(props); 12 | this._labelId = `fieldset_label_${(IDX++)}`; 13 | this.state = { 14 | showDoc: false, 15 | } 16 | } 17 | 18 | onToggleDoc = (val) => { 19 | this.setState({ 20 | showDoc: val 21 | }); 22 | } 23 | 24 | render () { 25 | const {props} = this; 26 | 27 | return
    28 | {this.props.fieldSpec && 29 |
    30 | 35 |
    36 | } 37 | {!this.props.fieldSpec && 38 |
    39 | {props.label} 40 |
    41 | } 42 |
    43 | {this.props.action} 44 |
    45 |
    46 | {props.children} 47 |
    48 | {this.props.fieldSpec && 49 |
    53 | 54 |
    55 | } 56 |
    57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/components/_FieldFont.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Block from './Block' 3 | import PropTypes from 'prop-types' 4 | import FieldAutocomplete from './FieldAutocomplete' 5 | 6 | export default class FieldFont extends React.Component { 7 | static propTypes = { 8 | value: PropTypes.array, 9 | default: PropTypes.array, 10 | fonts: PropTypes.array, 11 | style: PropTypes.object, 12 | onChange: PropTypes.func.isRequired, 13 | } 14 | 15 | static defaultProps = { 16 | fonts: [] 17 | } 18 | 19 | get values() { 20 | const out = this.props.value || this.props.default || []; 21 | 22 | // Always put a "" in the last field to you can keep adding entries 23 | if (out[out.length-1] !== ""){ 24 | return out.concat(""); 25 | } 26 | else { 27 | return out; 28 | } 29 | } 30 | 31 | changeFont(idx, newValue) { 32 | const changedValues = this.values.slice(0) 33 | changedValues[idx] = newValue 34 | const filteredValues = changedValues 35 | .filter(v => v !== undefined) 36 | .filter(v => v !== "") 37 | 38 | this.props.onChange(filteredValues); 39 | } 40 | 41 | render() { 42 | const inputs = this.values.map((value, i) => { 43 | return
  • 46 | [f, f])} 49 | onChange={this.changeFont.bind(this, i)} 50 | /> 51 |
  • 52 | }) 53 | 54 | return 55 |
      56 | {inputs} 57 |
    58 |
    59 | } 60 | } 61 | 62 | -------------------------------------------------------------------------------- /src/components/InputFont.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import InputAutocomplete from './InputAutocomplete' 4 | 5 | export default class FieldFont extends React.Component { 6 | static propTypes = { 7 | value: PropTypes.array, 8 | default: PropTypes.array, 9 | fonts: PropTypes.array, 10 | style: PropTypes.object, 11 | onChange: PropTypes.func.isRequired, 12 | 'aria-label': PropTypes.string, 13 | } 14 | 15 | static defaultProps = { 16 | fonts: [] 17 | } 18 | 19 | get values() { 20 | const out = this.props.value || this.props.default || []; 21 | 22 | // Always put a "" in the last field to you can keep adding entries 23 | if (out[out.length-1] !== ""){ 24 | return out.concat(""); 25 | } 26 | else { 27 | return out; 28 | } 29 | } 30 | 31 | changeFont(idx, newValue) { 32 | const changedValues = this.values.slice(0) 33 | changedValues[idx] = newValue 34 | const filteredValues = changedValues 35 | .filter(v => v !== undefined) 36 | .filter(v => v !== "") 37 | 38 | this.props.onChange(filteredValues); 39 | } 40 | 41 | render() { 42 | const inputs = this.values.map((value, i) => { 43 | return
  • 46 | [f, f])} 50 | onChange={this.changeFont.bind(this, i)} 51 | /> 52 |
  • 53 | }) 54 | 55 | return ( 56 |
      57 | {inputs} 58 |
    59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /stories/InputString.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import InputString from '../src/components/InputString'; 4 | import {InputContainer} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'InputString', 9 | component: InputString, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const Basic = () => { 15 | const [value, setValue] = useActionState("onChange", "Hello world"); 16 | 17 | return ( 18 | 19 | 23 | 24 | ); 25 | }; 26 | 27 | export const WithDefault = () => { 28 | const [value, setValue] = useActionState("onChange", null); 29 | 30 | return ( 31 | 32 | 37 | 38 | ); 39 | }; 40 | 41 | export const Multiline = () => { 42 | const [value, setValue] = useActionState("onChange", "Hello\nworld"); 43 | 44 | return ( 45 | 46 | 51 | 52 | ); 53 | }; 54 | 55 | export const MultilineWithDefault = () => { 56 | const [value, setValue] = useActionState("onChange", null); 57 | 58 | return ( 59 | 60 | 66 | 67 | ); 68 | }; 69 | 70 | -------------------------------------------------------------------------------- /src/styles/_base.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Roboto'; 3 | src: url('../fonts/Roboto-Regular.ttf') format('truetype'); 4 | font-weight: normal; 5 | font-style: normal; 6 | font-display: swap; 7 | } 8 | 9 | @font-face { 10 | font-family: 'Roboto'; 11 | src: url('../fonts/Roboto-Medium.ttf') format('truetype'); 12 | font-weight: bold; 13 | font-style: normal; 14 | font-display: swap; 15 | } 16 | 17 | html { 18 | color: $color-white; 19 | font-size: $font-size-5; 20 | box-sizing: border-box; 21 | } 22 | 23 | body { 24 | // The UI is 100% height so prevent bounce scroll on OSX 25 | overflow: hidden; 26 | } 27 | 28 | *, 29 | *::before, 30 | *::after { 31 | box-sizing: inherit; 32 | } 33 | 34 | p { 35 | font-size: $font-size-6; 36 | margin-top: $margin-2; 37 | margin-bottom: $margin-2; 38 | color: $color-lowgray; 39 | line-height: 1.3; 40 | } 41 | 42 | h1 { 43 | font-size: $font-size-2; 44 | margin-bottom: $margin-3; 45 | font-weight: bold; 46 | } 47 | 48 | h2 { 49 | font-size: $font-size-3; 50 | margin-bottom: $margin-3; 51 | font-weight: bold; 52 | } 53 | 54 | h3 { 55 | font-size: $font-size-4; 56 | margin-bottom: $margin-3; 57 | font-weight: bold; 58 | } 59 | 60 | h4 { 61 | font-size: $font-size-5; 62 | margin-bottom: $margin-3; 63 | } 64 | 65 | input:focus, 66 | textarea:focus, 67 | *[role="button"]:focus, 68 | button:focus, 69 | .maputnik-toolbar-link:focus, 70 | select:focus { 71 | color: $color-white; 72 | outline: #8e8e8e auto 1px; 73 | } 74 | 75 | label:hover { 76 | color: $color-white; 77 | } 78 | 79 | .clearfix { 80 | &::after { 81 | content: ""; 82 | display: table; 83 | clear: both; 84 | } 85 | } 86 | 87 | a { 88 | color: white; 89 | } 90 | 91 | .hide { 92 | display: none !important; 93 | } 94 | -------------------------------------------------------------------------------- /test/functional/util/webdriverio-ext.js: -------------------------------------------------------------------------------- 1 | var artifacts = require("../../artifacts"); 2 | var fs = require("fs"); 3 | var path = require("path"); 4 | 5 | 6 | browser.setTimeout({ 'script': 20*1000 }); 7 | browser.setTimeout({ 'implicit': 20*1000 }); 8 | 9 | var SCREENSHOTS_PATH = artifacts.pathSync("/screenshots"); 10 | 11 | /** 12 | * Sometimes chrome driver can result in the wrong text. 13 | * 14 | * See 15 | */ 16 | try { 17 | browser.addCommand('setValueSafe', function(selector, text) { 18 | for(var i=0; i<10; i++) { 19 | const elem = $(selector); 20 | elem.waitForDisplayed(500); 21 | 22 | var elements = browser.findElements("css selector", selector); 23 | if(elements.length > 1) { 24 | throw "Too many elements found"; 25 | } 26 | 27 | const elem2 = $(selector); 28 | elem2.setValue(text); 29 | 30 | var browserText = elem2.getValue(); 31 | 32 | if(browserText == text) { 33 | return; 34 | } 35 | else { 36 | console.error("Warning: setValue failed, trying again"); 37 | } 38 | } 39 | 40 | // Wait for change events to fire and state updated 41 | browser.flushReactUpdates(); 42 | }) 43 | 44 | browser.addCommand('takeScreenShot', function(filepath) { 45 | var savepath = path.join(SCREENSHOTS_PATH, filepath); 46 | browser.saveScreenshot(savepath); 47 | }); 48 | 49 | browser.addCommand('flushReactUpdates', function() { 50 | browser.executeAsync(function(done) { 51 | // For any events to propogate 52 | setTimeout(function() { 53 | // For the DOM to be updated. 54 | setTimeout(done, 0); 55 | }, 0) 56 | }) 57 | }) 58 | 59 | } catch(err) { 60 | console.error(">>> Ignored error: "+err); 61 | } 62 | -------------------------------------------------------------------------------- /src/components/FieldDocLabel.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import {MdInfoOutline, MdHighlightOff} from 'react-icons/md' 5 | 6 | export default class FieldDocLabel extends React.Component { 7 | static propTypes = { 8 | label: PropTypes.oneOfType([ 9 | PropTypes.object, 10 | PropTypes.string 11 | ]).isRequired, 12 | fieldSpec: PropTypes.object, 13 | onToggleDoc: PropTypes.func, 14 | } 15 | 16 | constructor (props) { 17 | super(props); 18 | this.state = { 19 | open: false, 20 | } 21 | } 22 | 23 | onToggleDoc = (open) => { 24 | this.setState({ 25 | open, 26 | }, () => { 27 | if (this.props.onToggleDoc) { 28 | this.props.onToggleDoc(this.state.open); 29 | } 30 | }); 31 | } 32 | 33 | render() { 34 | const {label, fieldSpec} = this.props; 35 | const {doc} = fieldSpec || {}; 36 | 37 | if (doc) { 38 | return 51 | } 52 | else if (label) { 53 | return 58 | } 59 | else { 60 |
    61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/LayerEditorGroup.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Icon from '@mdi/react' 4 | import { 5 | mdiMenuDown, 6 | mdiMenuUp 7 | } from '@mdi/js'; 8 | import { 9 | AccordionItem, 10 | AccordionItemHeading, 11 | AccordionItemButton, 12 | AccordionItemPanel, 13 | } from 'react-accessible-accordion'; 14 | 15 | 16 | export default class LayerEditorGroup extends React.Component { 17 | static propTypes = { 18 | "id": PropTypes.string, 19 | "data-wd-key": PropTypes.string, 20 | title: PropTypes.string.isRequired, 21 | isActive: PropTypes.bool.isRequired, 22 | children: PropTypes.element.isRequired, 23 | onActiveToggle: PropTypes.func.isRequired 24 | } 25 | 26 | render() { 27 | return 28 | this.props.onActiveToggle(!this.props.isActive)} 31 | > 32 | 33 | {this.props.title} 34 | 39 | 44 | 45 | 46 | 47 | {this.props.children} 48 | 49 | 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /test/functional/keyboard/index.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert"); 2 | var config = require("../../config/specs"); 3 | var helper = require("../helper"); 4 | var wd = require("../../wd-helper"); 5 | 6 | 7 | describe("keyboard", function() { 8 | describe("shortcuts", function() { 9 | it("ESC should unfocus", function() { 10 | const tmpTargetEl = $(wd.$("nav:inspect") + " select"); 11 | tmpTargetEl.click(); 12 | assert(tmpTargetEl.isFocused()); 13 | 14 | browser.keys(["Escape"]); 15 | assert($("body").isFocused()); 16 | }); 17 | 18 | it("'?' should show shortcuts modal", function() { 19 | browser.keys(["?"]); 20 | assert($(wd.$("modal:shortcuts")).isDisplayed()); 21 | }); 22 | 23 | it("'o' should show open modal", function() { 24 | browser.keys(["o"]); 25 | assert($(wd.$("modal:open")).isDisplayed()); 26 | }); 27 | 28 | it("'e' should show export modal", function() { 29 | browser.keys(["e"]); 30 | assert($(wd.$("modal:export")).isDisplayed()); 31 | }); 32 | 33 | it("'d' should show sources modal", function() { 34 | browser.keys(["d"]); 35 | assert($(wd.$("modal:sources")).isDisplayed()); 36 | }); 37 | 38 | it("'s' should show settings modal", function() { 39 | browser.keys(["s"]); 40 | assert($(wd.$("modal:settings")).isDisplayed()); 41 | }); 42 | 43 | it.skip("'i' should change map to inspect mode", function() { 44 | // browser.keys(["i"]); 45 | }); 46 | 47 | it("'m' should focus map", function() { 48 | browser.keys(["m"]); 49 | $(".mapboxgl-canvas").isFocused(); 50 | }); 51 | 52 | it("'!' should show debug modal", function() { 53 | browser.keys(["!"]); 54 | assert($(wd.$("modal:debug")).isDisplayed()); 55 | }); 56 | }); 57 | 58 | }); 59 | -------------------------------------------------------------------------------- /src/libs/urlopen.js: -------------------------------------------------------------------------------- 1 | import url from 'url' 2 | import querystring from 'querystring' 3 | import style from './style.js' 4 | 5 | export function initialStyleUrl() { 6 | const initialUrl = url.parse(window.location.href, true) 7 | return (initialUrl.query || {}).style 8 | } 9 | 10 | export function loadStyleUrl(styleUrl, cb) { 11 | console.log('Loading style', styleUrl) 12 | fetch(styleUrl, { 13 | mode: 'cors', 14 | credentials: "same-origin" 15 | }) 16 | .then(function(response) { 17 | return response.json(); 18 | }) 19 | .then(function(body) { 20 | cb(style.ensureStyleValidity(body)) 21 | }) 22 | .catch(function() { 23 | console.warn('Could not fetch default style', styleUrl) 24 | cb(style.emptyStyle) 25 | }) 26 | } 27 | 28 | export function removeStyleQuerystring() { 29 | const initialUrl = url.parse(window.location.href, true) 30 | let qs = querystring.parse(window.location.search.slice(1)) 31 | delete qs["style"] 32 | if(Object.getOwnPropertyNames(qs).length === 0) { 33 | qs = "" 34 | } else { 35 | qs = "?" + querystring.stringify(qs) 36 | } 37 | let newUrlHash = initialUrl.hash 38 | if(newUrlHash === null) { 39 | newUrlHash = "" 40 | } 41 | const newUrl = initialUrl.protocol + "//" + initialUrl.host + initialUrl.pathname + qs + newUrlHash 42 | window.history.replaceState({}, document.title, newUrl) 43 | } 44 | 45 | export function loadJSON(url, defaultValue, cb) { 46 | fetch(url, { 47 | mode: 'cors', 48 | credentials: "same-origin" 49 | }) 50 | .then(function(response) { 51 | return response.json(); 52 | }) 53 | .then(function(body) { 54 | try { 55 | cb(body) 56 | } catch(err) { 57 | console.error(err) 58 | cb(defaultValue) 59 | } 60 | }) 61 | .catch(function() { 62 | console.error('Can not load JSON from ' + url) 63 | cb(defaultValue) 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /src/styles/_filtereditor.scss: -------------------------------------------------------------------------------- 1 | .maputnik-filter-editor-wrapper { 2 | padding: $margin-3; 3 | overflow: hidden; 4 | 5 | .maputnik-input-block { 6 | margin: 0; 7 | } 8 | } 9 | 10 | .maputnik-filter-editor { 11 | @extend .clearfix; 12 | color: $color-lowgray; 13 | } 14 | 15 | .maputnik-filter-editor-property { 16 | display: inline-block; 17 | width: 25%; 18 | } 19 | 20 | .maputnik-filter-editor-operator { 21 | margin-left: 2%; 22 | display: inline-block; 23 | width: 17%; 24 | 25 | .maputnik-select { 26 | width: 100%; 27 | } 28 | } 29 | 30 | .maputnik-filter-editor-args { 31 | display: inline-block; 32 | width: 54%; 33 | margin-left: 2%; 34 | 35 | .maputnik-string, 36 | .maputnik-number { 37 | width: 100%; 38 | } 39 | } 40 | 41 | .maputnik-filter-editor-compound-select { 42 | margin-bottom: $margin-2; 43 | 44 | .maputnik-doc-wrapper { 45 | width: 50%; 46 | } 47 | 48 | .maputnik-select { 49 | display: inline-block; 50 | width: 50%; 51 | } 52 | } 53 | 54 | .maputnik-filter-editor-unsupported { 55 | color: $color-midgray; 56 | } 57 | 58 | .maputnik-add-filter { 59 | display: inline-block; 60 | float: right; 61 | margin-top: $margin-3; 62 | } 63 | 64 | .maputnik-delete-filter { 65 | @extend .maputnik-icon-button; 66 | } 67 | 68 | .maputnik-filter-editor-block-action { 69 | margin-top: $margin-2; 70 | margin-bottom: $margin-2; 71 | display: inline-block; 72 | width: 6%; 73 | margin-right: 1.5%; 74 | } 75 | 76 | .maputnik-filter-editor-block-content { 77 | display: inline-block; 78 | width: 92.5%; 79 | } 80 | 81 | .maputnik-radio-as-button { 82 | @extend .maputnik-button; 83 | 84 | border: solid 1px transparent; 85 | 86 | &:focus-within { 87 | border: solid 1px $color-white; 88 | } 89 | 90 | input { 91 | width: 0; 92 | overflow: hidden; 93 | opacity: 0; 94 | margin: 0; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /config/webpack.production.config.js: -------------------------------------------------------------------------------- 1 | var webpack = require('webpack'); 2 | var path = require('path'); 3 | var rules = require('./webpack.rules'); 4 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | var HtmlWebpackInlineSVGPlugin = require('html-webpack-inline-svg-plugin'); 6 | var WebpackCleanupPlugin = require('webpack-cleanup-plugin'); 7 | var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 8 | var CopyWebpackPlugin = require('copy-webpack-plugin'); 9 | var artifacts = require("../test/artifacts"); 10 | 11 | var OUTPATH = artifacts.pathSync("/build"); 12 | 13 | module.exports = { 14 | entry: { 15 | app: './src/index.jsx', 16 | }, 17 | output: { 18 | path: OUTPATH, 19 | filename: '[name].[contenthash].js', 20 | chunkFilename: '[contenthash].js' 21 | }, 22 | resolve: { 23 | extensions: ['.js', '.jsx'] 24 | }, 25 | module: { 26 | noParse: [ 27 | /mapbox-gl\/dist\/mapbox-gl.js/ 28 | ], 29 | rules: rules 30 | }, 31 | node: { 32 | fs: "empty", 33 | net: 'empty', 34 | tls: 'empty' 35 | }, 36 | plugins: [ 37 | new webpack.NoEmitOnErrorsPlugin(), 38 | new WebpackCleanupPlugin(), 39 | new webpack.DefinePlugin({ 40 | 'process.env': { 41 | NODE_ENV: '"production"' 42 | } 43 | }), 44 | new HtmlWebpackPlugin({ 45 | template: './src/template.html', 46 | title: 'Maputnik' 47 | }), 48 | new HtmlWebpackInlineSVGPlugin({ 49 | runPreEmit: true, 50 | }), 51 | new CopyWebpackPlugin([ 52 | { 53 | from: './src/manifest.json', 54 | to: 'manifest.json' 55 | } 56 | ]), 57 | new BundleAnalyzerPlugin({ 58 | analyzerMode: 'static', 59 | defaultSizes: 'gzip', 60 | openAnalyzer: false, 61 | generateStatsFile: true, 62 | reportFilename: 'bundle-stats.html', 63 | statsFilename: 'bundle-stats.json', 64 | }) 65 | ] 66 | }; 67 | -------------------------------------------------------------------------------- /src/libs/layer.js: -------------------------------------------------------------------------------- 1 | import {latest} from '@mapbox/mapbox-gl-style-spec' 2 | 3 | export function changeType(layer, newType) { 4 | const changedPaintProps = { ...layer.paint } 5 | Object.keys(changedPaintProps).forEach(propertyName => { 6 | if(!(propertyName in latest['paint_' + newType])) { 7 | delete changedPaintProps[propertyName] 8 | } 9 | }) 10 | 11 | const changedLayoutProps = { ...layer.layout } 12 | Object.keys(changedLayoutProps).forEach(propertyName => { 13 | if(!(propertyName in latest['layout_' + newType])) { 14 | delete changedLayoutProps[propertyName] 15 | } 16 | }) 17 | 18 | return { 19 | ...layer, 20 | paint: changedPaintProps, 21 | layout: changedLayoutProps, 22 | type: newType, 23 | } 24 | } 25 | 26 | /** A {@property} in either the paint our layout {@group} has changed 27 | * to a {@newValue}. 28 | */ 29 | export function changeProperty(layer, group, property, newValue) { 30 | // Remove the property if undefined 31 | if(newValue === undefined) { 32 | if(group) { 33 | const newLayer = { 34 | ...layer, 35 | // Change object so the diff works in ./src/components/map/MapboxGlMap.jsx 36 | [group]: { 37 | ...layer[group] 38 | } 39 | }; 40 | delete newLayer[group][property]; 41 | 42 | // Remove the group if it is now empty 43 | if(Object.keys(newLayer[group]).length < 1) { 44 | delete newLayer[group]; 45 | } 46 | return newLayer; 47 | } else { 48 | const newLayer = { 49 | ...layer 50 | }; 51 | delete newLayer[property]; 52 | return newLayer; 53 | } 54 | } 55 | else { 56 | if(group) { 57 | return { 58 | ...layer, 59 | [group]: { 60 | ...layer[group], 61 | [property]: newValue 62 | } 63 | } 64 | } else { 65 | return { 66 | ...layer, 67 | [property]: newValue 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /config/wdio.conf.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var WebpackDevServer = require("webpack-dev-server"); 3 | var webpackConfig = require("./webpack.config"); 4 | var testConfig = require("../test/config/specs"); 5 | var artifacts = require("../test/artifacts"); 6 | var isDocker = require("is-docker"); 7 | 8 | 9 | var server; 10 | var SCREENSHOT_PATH = artifacts.pathSync("screenshots"); 11 | 12 | exports.config = { 13 | runner: 'local', 14 | path: '/wd/hub', 15 | specs: [ 16 | './test/functional/index.js' 17 | ], 18 | maxInstances: 10, 19 | capabilities: [ 20 | { 21 | maxInstances: 5, 22 | browserName: (process.env.BROWSER || 'chrome'), 23 | } 24 | ], 25 | logLevel: 'info', 26 | bail: 0, 27 | screenshotPath: SCREENSHOT_PATH, 28 | hostname: process.env.DOCKER_HOST || "0.0.0.0", 29 | framework: 'mocha', 30 | reporters: ['spec'], 31 | mochaOpts: { 32 | ui: 'bdd', 33 | // Because we don't know how long the initial build will take... 34 | timeout: 4*60*1000, 35 | }, 36 | onPrepare: function (config, capabilities) { 37 | return new Promise(function(resolve, reject) { 38 | var compiler = webpack(webpackConfig); 39 | const serverHost = "0.0.0.0"; 40 | 41 | server = new WebpackDevServer(compiler, { 42 | host: serverHost, 43 | disableHostCheck: true, 44 | stats: { 45 | colors: true 46 | } 47 | }); 48 | 49 | server.listen(testConfig.port, serverHost, function(err) { 50 | if(err) { 51 | reject(err); 52 | } 53 | else { 54 | resolve(); 55 | } 56 | }); 57 | }) 58 | }, 59 | onComplete: function(exitCode) { 60 | return new Promise(function(resolve, reject) { 61 | server.close(function (err) { 62 | if (err) { 63 | reject(err) 64 | } 65 | else { 66 | resolve(); 67 | } 68 | }) 69 | }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/AppMessagePanel.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import {formatLayerId} from '../util/format'; 4 | 5 | export default class AppMessagePanel extends React.Component { 6 | static propTypes = { 7 | errors: PropTypes.array, 8 | infos: PropTypes.array, 9 | mapStyle: PropTypes.object, 10 | onLayerSelect: PropTypes.func, 11 | currentLayer: PropTypes.object, 12 | selectedLayerIndex: PropTypes.number, 13 | } 14 | 15 | static defaultProps = { 16 | onLayerSelect: () => {}, 17 | } 18 | 19 | render() { 20 | const {selectedLayerIndex} = this.props; 21 | const errors = this.props.errors.map((error, idx) => { 22 | let content; 23 | if (error.parsed && error.parsed.type === "layer") { 24 | const {parsed} = error; 25 | const {mapStyle, currentLayer} = this.props; 26 | const layerId = mapStyle.layers[parsed.data.index].id; 27 | content = ( 28 | <> 29 | Layer {formatLayerId(layerId)}: {parsed.data.message} 30 | {selectedLayerIndex !== parsed.data.index && 31 | <> 32 |  —  33 | 39 | 40 | } 41 | 42 | ); 43 | } 44 | else { 45 | content = error.message; 46 | } 47 | return

    48 | {content} 49 |

    50 | }) 51 | 52 | const infos = this.props.infos.map((m, i) => { 53 | return

    {m}

    54 | }) 55 | 56 | return
    57 | {errors} 58 | {infos} 59 |
    60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /stories/InputEnum.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import InputEnum from '../src/components/InputEnum'; 4 | import {InputContainer} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'InputEnum', 9 | component: InputEnum, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const BasicFew = () => { 15 | const options = ["Foo", "Bar", "Baz"]; 16 | const [value, setValue] = useActionState("onChange", "Foo"); 17 | 18 | return ( 19 | 20 | 25 | 26 | ); 27 | }; 28 | 29 | export const BasicFewWithDefault = () => { 30 | const options = ["Foo", "Bar", "Baz"]; 31 | const [value, setValue] = useActionState("onChange", null); 32 | 33 | return ( 34 | 35 | 41 | 42 | ); 43 | }; 44 | 45 | export const BasicMany = () => { 46 | const options = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]; 47 | const [value, setValue] = useActionState("onChange", "a"); 48 | 49 | return ( 50 | 51 | 56 | 57 | ); 58 | }; 59 | 60 | export const BasicManyWithDefault = () => { 61 | const options = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]; 62 | const [value, setValue] = useActionState("onChange", "a"); 63 | 64 | return ( 65 | 66 | 72 | 73 | ); 74 | }; 75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /stories/FieldEnum.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {useActionState} from './helper'; 3 | import FieldEnum from '../src/components/FieldEnum'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | export default { 8 | title: 'FieldEnum', 9 | component: FieldEnum, 10 | decorators: [withA11y], 11 | }; 12 | 13 | 14 | export const BasicFew = () => { 15 | const options = ["Foo", "Bar", "Baz"]; 16 | const [value, setValue] = useActionState("onChange", "Foo"); 17 | 18 | return ( 19 | 20 | 26 | 27 | ); 28 | }; 29 | 30 | export const BasicFewWithDefault = () => { 31 | const options = ["Foo", "Bar", "Baz"]; 32 | const [value, setValue] = useActionState("onChange", null); 33 | 34 | return ( 35 | 36 | 43 | 44 | ); 45 | }; 46 | 47 | export const BasicMany = () => { 48 | const options = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]; 49 | const [value, setValue] = useActionState("onChange", "a"); 50 | 51 | return ( 52 | 53 | 59 | 60 | ); 61 | }; 62 | 63 | export const BasicManyWithDefault = () => { 64 | const options = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]; 65 | const [value, setValue] = useActionState("onChange", "a"); 66 | 67 | return ( 68 | 69 | 76 | 77 | ); 78 | }; 79 | 80 | 81 | -------------------------------------------------------------------------------- /src/components/Modal.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import {MdClose} from 'react-icons/md' 4 | import AriaModal from 'react-aria-modal' 5 | import classnames from 'classnames'; 6 | 7 | 8 | export default class Modal extends React.Component { 9 | static propTypes = { 10 | "data-wd-key": PropTypes.string, 11 | isOpen: PropTypes.bool.isRequired, 12 | title: PropTypes.string.isRequired, 13 | onOpenToggle: PropTypes.func.isRequired, 14 | children: PropTypes.node, 15 | underlayClickExits: PropTypes.bool, 16 | underlayProps: PropTypes.object, 17 | className: PropTypes.string, 18 | } 19 | 20 | static defaultProps = { 21 | underlayClickExits: true 22 | } 23 | 24 | // See 25 | onClose = () => { 26 | if (document.activeElement) { 27 | document.activeElement.blur(); 28 | } 29 | 30 | setImmediate(() => { 31 | this.props.onOpenToggle(false); 32 | }); 33 | } 34 | 35 | render() { 36 | if(this.props.isOpen) { 37 | return 45 |
    48 |
    49 |

    {this.props.title}

    50 | 51 | 58 |
    59 |
    60 |
    {this.props.children}
    61 |
    62 |
    63 |
    64 | } 65 | else { 66 | return false; 67 | } 68 | } 69 | } 70 | 71 | -------------------------------------------------------------------------------- /test/geojson-server.js: -------------------------------------------------------------------------------- 1 | const cors = require("cors"); 2 | const express = require("express"); 3 | const fs = require("fs"); 4 | const sourceData = require("./sources"); 5 | 6 | 7 | var app = express(); 8 | 9 | app.use(cors()); 10 | 11 | 12 | function buildStyle(opts) { 13 | opts = opts || {}; 14 | opts = Object.assign({ 15 | sources: {} 16 | }, opts); 17 | 18 | return { 19 | "id": "test-style", 20 | "version": 8, 21 | "name": "Test Style", 22 | "metadata": { 23 | "maputnik:renderer": "mbgljs" 24 | }, 25 | "sources": opts.sources, 26 | "glyphs": "https://example.local/fonts/{fontstack}/{range}.pbf", 27 | "sprites": "https://example.local/fonts/{fontstack}/{range}.pbf", 28 | "layers": [] 29 | } 30 | } 31 | 32 | function buildGeoJSONSource(data) { 33 | return { 34 | type: "vector", 35 | data: data 36 | }; 37 | } 38 | 39 | function buildResterSource(req, key) { 40 | return { 41 | "tileSize": 256, 42 | "tiles": [ 43 | req.protocol + '://' + req.get('host') + "/" + key + "/{x}/{y}/{z}" 44 | ], 45 | "type": "raster" 46 | }; 47 | } 48 | 49 | 50 | app.get("/sources/raster/{x}/{y}/{z}", function(req, res) { 51 | res.status(404).end(); 52 | }) 53 | 54 | app.get("/styles/empty/:sources", function(req, res) { 55 | var reqSources = req.params.sources.split(","); 56 | 57 | var sources = {}; 58 | reqSources.forEach(function(key) { 59 | var parts = key.split(":"); 60 | var type = parts[0]; 61 | var key = parts[1]; 62 | 63 | if(type === "geojson") { 64 | sources[key] = buildGeoJSONSource(sourceData[key]); 65 | } 66 | else if(type === "raster") { 67 | sources[key] = buildResterSource(req, key); 68 | } 69 | else { 70 | console.error("ERR: Invalid type: %s", type); 71 | throw "Invalid type" 72 | } 73 | }); 74 | 75 | var json = buildStyle({ 76 | sources: sources 77 | }); 78 | res.send(json); 79 | }) 80 | 81 | app.get("/example-layer-style.json", function(req, res) { 82 | res.json( 83 | JSON.parse( 84 | fs.readFileSync(__dirname+"/example-layer-style.json").toString() 85 | ) 86 | ); 87 | }) 88 | 89 | app.get("/example-style.json", function(req, res) { 90 | res.json( 91 | JSON.parse( 92 | fs.readFileSync(__dirname+"/example-style.json").toString() 93 | ) 94 | ); 95 | }) 96 | 97 | 98 | module.exports = app; 99 | -------------------------------------------------------------------------------- /config/webpack.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var webpack = require('webpack'); 3 | var path = require('path'); 4 | var rules = require('./webpack.rules'); 5 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | var HtmlWebpackInlineSVGPlugin = require('html-webpack-inline-svg-plugin'); 7 | var CopyWebpackPlugin = require('copy-webpack-plugin'); 8 | 9 | const HOST = process.env.HOST || "127.0.0.1"; 10 | const PORT = process.env.PORT || "8888"; 11 | 12 | module.exports = { 13 | target: 'web', 14 | mode: 'development', 15 | entry: [ 16 | `webpack-dev-server/client?http://${HOST}:${PORT}`, 17 | `webpack/hot/only-dev-server`, 18 | `./src/index.jsx` // Your appʼs entry point 19 | ], 20 | devtool: process.env.WEBPACK_DEVTOOL || 'cheap-module-source-map', 21 | output: { 22 | path: path.join(__dirname, '..', 'public'), 23 | filename: 'bundle.js' 24 | }, 25 | resolve: { 26 | extensions: ['.js', '.jsx'] 27 | }, 28 | module: { 29 | noParse: [ 30 | /mapbox-gl\/dist\/mapbox-gl.js/ 31 | ], 32 | rules: rules 33 | }, 34 | node: { 35 | fs: "empty", 36 | net: 'empty', 37 | tls: 'empty' 38 | }, 39 | devServer: { 40 | contentBase: "./public", 41 | // do not print bundle build stats 42 | noInfo: true, 43 | // enable HMR 44 | hot: true, 45 | // embed the webpack-dev-server runtime into the bundle 46 | inline: true, 47 | // serve index.html in place of 404 responses to allow HTML5 history 48 | historyApiFallback: true, 49 | port: PORT, 50 | host: HOST, 51 | watchOptions: { 52 | // Disabled polling by default as it causes lots of CPU usage and hence drains laptop batteries. To enable polling add WEBPACK_DEV_SERVER_POLLING to your environment 53 | // See for details 54 | poll: (!!process.env.WEBPACK_DEV_SERVER_POLLING ? true : false), 55 | watch: false 56 | }, 57 | }, 58 | plugins: [ 59 | new webpack.NoEmitOnErrorsPlugin(), 60 | new webpack.HotModuleReplacementPlugin(), 61 | new HtmlWebpackPlugin({ 62 | title: 'Maputnik', 63 | template: './src/template.html' 64 | }), 65 | new HtmlWebpackInlineSVGPlugin({ 66 | runPreEmit: true, 67 | }), 68 | new CopyWebpackPlugin([ 69 | { 70 | from: './src/manifest.json', 71 | to: 'manifest.json' 72 | } 73 | ]) 74 | ] 75 | }; 76 | -------------------------------------------------------------------------------- /src/components/_FunctionButtons.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import InputButton from './InputButton' 5 | import {MdFunctions, MdInsertChart} from 'react-icons/md' 6 | import {mdiFunctionVariant} from '@mdi/js'; 7 | 8 | 9 | /** 10 | * So here we can't just check is `Array.isArray(value)` because certain 11 | * properties accept arrays as values, for example `text-font`. So we must try 12 | * and create an expression. 13 | */ 14 | function isExpression(value, fieldSpec={}) { 15 | if (!Array.isArray(value)) { 16 | return false; 17 | } 18 | try { 19 | expression.createExpression(value, fieldSpec); 20 | return true; 21 | } 22 | catch (err) { 23 | return false; 24 | } 25 | } 26 | 27 | export default class FunctionInputButtons extends React.Component { 28 | static propTypes = { 29 | fieldSpec: PropTypes.object, 30 | onZoomClick: PropTypes.func, 31 | onDataClick: PropTypes.func, 32 | onExpressionClick: PropTypes.func, 33 | } 34 | 35 | render() { 36 | let makeZoomInputButton, makeDataInputButton, expressionInputButton; 37 | 38 | if (this.props.fieldSpec.expression.parameters.includes('zoom')) { 39 | expressionInputButton = ( 40 | 45 | 46 | 47 | 48 | 49 | ); 50 | 51 | makeZoomInputButton = 56 | 57 | 58 | 59 | if (this.props.fieldSpec['property-type'] === 'data-driven') { 60 | makeDataInputButton = 65 | 66 | 67 | } 68 | return
    69 | {expressionInputButton} 70 | {makeDataInputButton} 71 | {makeZoomInputButton} 72 |
    73 | } 74 | else { 75 | return
    {expressionInputButton}
    76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/styles/_codemirror.scss: -------------------------------------------------------------------------------- 1 | .CodeMirror-lint-tooltip { 2 | z-index: 2000 !important; 3 | } 4 | 5 | .codemirror-container { 6 | max-width: 100%; 7 | position: relative; 8 | overflow: auto; 9 | } 10 | 11 | .cm-s-maputnik.CodeMirror { 12 | height: 100%; 13 | font-size: 12px; 14 | background: transparent; 15 | } 16 | 17 | .cm-s-maputnik.CodeMirror, .cm-s-maputnik .CodeMirror-gutters { 18 | color: #8e8e8e; 19 | border: none; 20 | } 21 | 22 | .cm-s-maputnik .CodeMirror-gutters { 23 | background: #212328; 24 | } 25 | 26 | .cm-s-maputnik .CodeMirror-cursor { 27 | border-left: solid thin #f0f0f0 !important; 28 | } 29 | 30 | .cm-s-maputnik.CodeMirror-focused div.CodeMirror-selected { 31 | background: rgba(255, 255, 255, 0.10); 32 | } 33 | 34 | .cm-s-maputnik .CodeMirror-line::selection, 35 | .cm-s-maputnik .CodeMirror-line > span::selection, 36 | .cm-s-maputnik .CodeMirror-line > span > span::selection { 37 | background: rgba(255, 255, 255, 0.10); 38 | } 39 | 40 | .cm-s-maputnik .CodeMirror-line::-moz-selection, 41 | .cm-s-maputnik .CodeMirror-line > span::-moz-selection, 42 | .cm-s-maputnik .CodeMirror-line > span > span::-moz-selection { 43 | background: rgba(255, 255, 255, 0.10); 44 | } 45 | 46 | .cm-s-maputnik span.cm-string, .cm-s-maputnik span.cm-string-2 { 47 | color: #8f9d6a; 48 | } 49 | .cm-s-maputnik span.cm-number { color: #91675f; } 50 | .cm-s-maputnik span.cm-property { color: #b8a077; } 51 | 52 | .cm-s-maputnik .CodeMirror-activeline-background { 53 | background: rgba(255,255,255,0.1); 54 | } 55 | 56 | .cm-s-maputnik .CodeMirror-matchingbracket { 57 | background: hsla(223, 12%, 35%, 1); 58 | color: $color-white !important; 59 | } 60 | 61 | .cm-s-maputnik .CodeMirror-nonmatchingbracket { 62 | background-color: #bb0000; 63 | color: white !important; 64 | } 65 | 66 | @keyframes JSONEditor__animation-fade { 67 | from { 68 | opacity: 1; 69 | } 70 | to { 71 | opacity: 0; 72 | } 73 | } 74 | 75 | .JSONEditor__message { 76 | position: absolute; 77 | right: 0; 78 | font-size: 0.85em; 79 | z-index: 99999; 80 | padding: 0.3em 0.5em; 81 | background: hsla(0, 0%, 0%, 0.3); 82 | color: $color-lowgray; 83 | border-bottom-left-radius: 2px; 84 | transition: opacity 320ms ease; 85 | opacity: 0; 86 | pointer-events: none; 87 | 88 | &--on { 89 | opacity: 1; 90 | animation: 320ms ease 0s JSONEditor__animation-fade; 91 | animation-delay: 2000ms; 92 | animation-fill-mode: forwards; 93 | } 94 | 95 | kbd { 96 | font-family: monospace; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/components/PropertyGroup.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import FieldFunction from './FieldFunction' 5 | const iconProperties = ['background-pattern', 'fill-pattern', 'line-pattern', 'fill-extrusion-pattern', 'icon-image'] 6 | 7 | /** Extract field spec by {@fieldName} from the {@layerType} in the 8 | * style specification from either the paint or layout group */ 9 | function getFieldSpec(spec, layerType, fieldName) { 10 | const groupName = getGroupName(spec, layerType, fieldName) 11 | const group = spec[groupName + '_' + layerType] 12 | const fieldSpec = group[fieldName] 13 | if(iconProperties.indexOf(fieldName) >= 0) { 14 | return { 15 | ...fieldSpec, 16 | values: spec.$root.sprite.values 17 | } 18 | } 19 | if(fieldName === 'text-font') { 20 | return { 21 | ...fieldSpec, 22 | values: spec.$root.glyphs.values 23 | } 24 | } 25 | return fieldSpec 26 | } 27 | 28 | function getGroupName(spec, layerType, fieldName) { 29 | const paint = spec['paint_' + layerType] || {} 30 | if (fieldName in paint) { 31 | return 'paint' 32 | } else { 33 | return 'layout' 34 | } 35 | } 36 | 37 | export default class PropertyGroup extends React.Component { 38 | static propTypes = { 39 | layer: PropTypes.object.isRequired, 40 | groupFields: PropTypes.array.isRequired, 41 | onChange: PropTypes.func.isRequired, 42 | spec: PropTypes.object.isRequired, 43 | errors: PropTypes.object, 44 | } 45 | 46 | onPropertyChange = (property, newValue) => { 47 | const group = getGroupName(this.props.spec, this.props.layer.type, property) 48 | this.props.onChange(group , property, newValue) 49 | } 50 | 51 | render() { 52 | const {errors} = this.props; 53 | const fields = this.props.groupFields.map(fieldName => { 54 | const fieldSpec = getFieldSpec(this.props.spec, this.props.layer.type, fieldName) 55 | 56 | const paint = this.props.layer.paint || {} 57 | const layout = this.props.layer.layout || {} 58 | const fieldValue = fieldName in paint ? paint[fieldName] : layout[fieldName] 59 | const fieldType = fieldName in paint ? 'paint' : 'layout'; 60 | 61 | return 70 | }) 71 | 72 | return
    73 | {fields} 74 |
    75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/components/InputString.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | export default class InputString extends React.Component { 5 | static propTypes = { 6 | "data-wd-key": PropTypes.string, 7 | value: PropTypes.string, 8 | style: PropTypes.object, 9 | default: PropTypes.string, 10 | onChange: PropTypes.func, 11 | onInput: PropTypes.func, 12 | multi: PropTypes.bool, 13 | required: PropTypes.bool, 14 | disabled: PropTypes.bool, 15 | spellCheck: PropTypes.bool, 16 | 'aria-label': PropTypes.string, 17 | } 18 | 19 | static defaultProps = { 20 | onInput: () => {}, 21 | } 22 | 23 | constructor(props) { 24 | super(props) 25 | this.state = { 26 | editing: false, 27 | value: props.value || '' 28 | } 29 | } 30 | 31 | static getDerivedStateFromProps(props, state) { 32 | if (!state.editing) { 33 | return { 34 | value: props.value 35 | }; 36 | } 37 | return {}; 38 | } 39 | 40 | render() { 41 | let tag; 42 | let classes; 43 | 44 | if(!!this.props.multi) { 45 | tag = "textarea" 46 | classes = [ 47 | "maputnik-string", 48 | "maputnik-string--multi" 49 | ] 50 | } 51 | else { 52 | tag = "input" 53 | classes = [ 54 | "maputnik-string" 55 | ] 56 | } 57 | 58 | if(!!this.props.disabled) { 59 | classes.push("maputnik-string--disabled"); 60 | } 61 | 62 | return React.createElement(tag, { 63 | "aria-label": this.props["aria-label"], 64 | "data-wd-key": this.props["data-wd-key"], 65 | spellCheck: this.props.hasOwnProperty("spellCheck") ? this.props.spellCheck : !(tag === "input"), 66 | disabled: this.props.disabled, 67 | className: classes.join(" "), 68 | style: this.props.style, 69 | value: this.state.value === undefined ? "" : this.state.value, 70 | placeholder: this.props.default, 71 | onChange: e => { 72 | this.setState({ 73 | editing: true, 74 | value: e.target.value 75 | }, () => { 76 | this.props.onInput(this.state.value); 77 | }); 78 | }, 79 | onBlur: () => { 80 | if(this.state.value!==this.props.value) { 81 | this.setState({editing: false}); 82 | this.props.onChange(this.state.value); 83 | } 84 | }, 85 | onKeyDown: (e) => { 86 | if (e.keyCode === 13) { 87 | this.props.onChange(this.state.value); 88 | } 89 | }, 90 | required: this.props.required, 91 | }); 92 | } 93 | } 94 | 95 | 96 | -------------------------------------------------------------------------------- /src/components/ModalDebug.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import Modal from './Modal' 5 | 6 | 7 | export default class ModalDebug extends React.Component { 8 | static propTypes = { 9 | isOpen: PropTypes.bool.isRequired, 10 | renderer: PropTypes.string.isRequired, 11 | onChangeMaboxGlDebug: PropTypes.func.isRequired, 12 | onChangeOpenlayersDebug: PropTypes.func.isRequired, 13 | onOpenToggle: PropTypes.func.isRequired, 14 | mapboxGlDebugOptions: PropTypes.object, 15 | openlayersDebugOptions: PropTypes.object, 16 | mapView: PropTypes.object, 17 | } 18 | 19 | render() { 20 | const {mapView} = this.props; 21 | 22 | const osmZoom = Math.round(mapView.zoom)+1; 23 | const osmLon = Number.parseFloat(mapView.center.lng).toFixed(5); 24 | const osmLat = Number.parseFloat(mapView.center.lat).toFixed(5); 25 | 26 | return 32 |
    33 |

    Options

    34 | {this.props.renderer === 'mbgljs' && 35 |
      36 | {Object.entries(this.props.mapboxGlDebugOptions).map(([key, val]) => { 37 | return
    • 38 | 41 |
    • 42 | })} 43 |
    44 | } 45 | {this.props.renderer === 'ol' && 46 |
      47 | {Object.entries(this.props.openlayersDebugOptions).map(([key, val]) => { 48 | return
    • 49 | 52 |
    • 53 | })} 54 |
    55 | } 56 |
    57 |
    58 |

    Links

    59 |

    60 | 65 | Open in OSM 66 | — Opens the current view on openstreetmap.org 67 |

    68 |
    69 |
    70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /src/components/InputUrl.jsx: -------------------------------------------------------------------------------- 1 | import React, {Fragment} from 'react' 2 | import PropTypes from 'prop-types' 3 | import InputString from './InputString' 4 | import SmallError from './SmallError' 5 | 6 | 7 | function validate (url) { 8 | if (url === "") { 9 | return; 10 | } 11 | 12 | let error; 13 | const getProtocol = (url) => { 14 | try { 15 | const urlObj = new URL(url); 16 | return urlObj.protocol; 17 | } 18 | catch (err) { 19 | return undefined; 20 | } 21 | }; 22 | const protocol = getProtocol(url); 23 | const isSsl = window.location.protocol === "https:"; 24 | 25 | if (!protocol) { 26 | error = ( 27 | 28 | Must provide protocol { 29 | isSsl 30 | ? https:// 31 | : <>http:// or https:// 32 | } 33 | 34 | ); 35 | } 36 | else if ( 37 | protocol && 38 | protocol === "http:" && 39 | window.location.protocol === "https:" 40 | ) { 41 | error = ( 42 | 43 | CORS policy won't allow fetching resources served over http from https, use a https:// domain 44 | 45 | ); 46 | } 47 | 48 | return error; 49 | } 50 | 51 | export default class FieldUrl extends React.Component { 52 | static propTypes = { 53 | "data-wd-key": PropTypes.string, 54 | value: PropTypes.string, 55 | style: PropTypes.object, 56 | default: PropTypes.string, 57 | onChange: PropTypes.func, 58 | onInput: PropTypes.func, 59 | multi: PropTypes.bool, 60 | required: PropTypes.bool, 61 | 'aria-label': PropTypes.string, 62 | } 63 | 64 | static defaultProps = { 65 | onInput: () => {}, 66 | } 67 | 68 | constructor (props) { 69 | super(props); 70 | this.state = { 71 | error: validate(props.value) 72 | }; 73 | } 74 | 75 | onInput = (url) => { 76 | this.setState({ 77 | error: validate(url) 78 | }); 79 | this.props.onInput(url); 80 | } 81 | 82 | onChange = (url) => { 83 | this.setState({ 84 | error: validate(url) 85 | }); 86 | this.props.onChange(url); 87 | } 88 | 89 | render () { 90 | return ( 91 |
    92 | 98 | {this.state.error} 99 |
    100 | ); 101 | } 102 | } 103 | 104 | -------------------------------------------------------------------------------- /src/libs/layerwatcher.js: -------------------------------------------------------------------------------- 1 | import throttle from 'lodash.throttle' 2 | import isEqual from 'lodash.isequal' 3 | 4 | /** Listens to map events to build up a store of available vector 5 | * layers contained in the tiles */ 6 | export default class LayerWatcher { 7 | constructor(opts = {}) { 8 | this.onSourcesChange = opts.onSourcesChange || (() => {}) 9 | this.onVectorLayersChange = opts.onVectorLayersChange || (() => {}) 10 | 11 | this._sources = {} 12 | this._vectorLayers = {} 13 | 14 | // Since we scan over all features we want to avoid this as much as 15 | // possible and only do it after a batch of data has loaded because 16 | // we only care eventuall about knowing the fields in the vector layers 17 | this.throttledAnalyzeVectorLayerFields = throttle(this.analyzeVectorLayerFields, 5000) 18 | } 19 | 20 | analyzeMap(map) { 21 | const previousSources = { ...this._sources } 22 | 23 | Object.keys(map.style.sourceCaches).forEach(sourceId => { 24 | //NOTE: This heavily depends on the internal API of Mapbox GL 25 | //so this breaks between Mapbox GL JS releases 26 | this._sources[sourceId] = map.style.sourceCaches[sourceId]._source.vectorLayerIds 27 | }) 28 | 29 | if(!isEqual(previousSources, this._sources)) { 30 | this.onSourcesChange(this._sources) 31 | } 32 | 33 | this.throttledAnalyzeVectorLayerFields(map) 34 | } 35 | 36 | analyzeVectorLayerFields(map) { 37 | const previousVectorLayers = { ...this._vectorLayers } 38 | 39 | Object.keys(this._sources).forEach(sourceId => { 40 | (this._sources[sourceId] || []).forEach(vectorLayerId => { 41 | const knownProperties = this._vectorLayers[vectorLayerId] || {} 42 | const params = { sourceLayer: vectorLayerId } 43 | map.querySourceFeatures(sourceId, params).forEach(feature => { 44 | Object.keys(feature.properties).forEach(propertyName => { 45 | const knownPropertyValues = knownProperties[propertyName] || {} 46 | knownPropertyValues[feature.properties[propertyName]] = {} 47 | knownProperties[propertyName] = knownPropertyValues 48 | }) 49 | }) 50 | 51 | this._vectorLayers[vectorLayerId] = knownProperties 52 | }) 53 | }) 54 | 55 | if(!isEqual(previousVectorLayers, this._vectorLayers)) { 56 | this.onVectorLayersChange(this._vectorLayers) 57 | } 58 | 59 | } 60 | 61 | /** Access all known sources and their vector tile ids */ 62 | get sources() { 63 | return this._sources 64 | } 65 | 66 | get vectorLayers() { 67 | return this._vectorLayers 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/libs/apistore.js: -------------------------------------------------------------------------------- 1 | import style from './style.js' 2 | import {format} from '@mapbox/mapbox-gl-style-spec' 3 | import ReconnectingWebSocket from 'reconnecting-websocket' 4 | 5 | export class ApiStyleStore { 6 | 7 | constructor(opts) { 8 | this.onLocalStyleChange = opts.onLocalStyleChange || (() => {}) 9 | const port = opts.port || '8000' 10 | const host = opts.host || 'localhost' 11 | this.localUrl = `http://${host}:${port}` 12 | this.websocketUrl = `ws://${host}:${port}/ws` 13 | this.init = this.init.bind(this) 14 | } 15 | 16 | init(cb) { 17 | fetch(this.localUrl + '/styles', { 18 | mode: 'cors', 19 | }) 20 | .then((response) => { 21 | return response.json(); 22 | }) 23 | .then((body) => { 24 | const styleIds = body; 25 | this.latestStyleId = styleIds[0] 26 | this.notifyLocalChanges() 27 | cb(null) 28 | }) 29 | .catch(function(e) { 30 | cb(new Error('Can not connect to style API')) 31 | }) 32 | } 33 | 34 | notifyLocalChanges() { 35 | const connection = new ReconnectingWebSocket(this.websocketUrl) 36 | connection.onmessage = e => { 37 | if(!e.data) return 38 | console.log('Received style update from API') 39 | let parsedStyle = style.emptyStyle 40 | try { 41 | parsedStyle = JSON.parse(e.data) 42 | } catch(err) { 43 | console.error(err) 44 | } 45 | const updatedStyle = style.ensureStyleValidity(parsedStyle) 46 | this.onLocalStyleChange(updatedStyle) 47 | } 48 | } 49 | 50 | latestStyle(cb) { 51 | if(this.latestStyleId) { 52 | fetch(this.localUrl + '/styles/' + this.latestStyleId, { 53 | mode: 'cors', 54 | }) 55 | .then(function(response) { 56 | return response.json(); 57 | }) 58 | .then(function(body) { 59 | cb(style.ensureStyleValidity(body)) 60 | }) 61 | } else { 62 | throw new Error('No latest style available. You need to init the api backend first.') 63 | } 64 | } 65 | 66 | // Save current style replacing previous version 67 | save(mapStyle) { 68 | const styleJSON = format( 69 | style.stripAccessTokens( 70 | style.replaceAccessTokens(mapStyle) 71 | ) 72 | ); 73 | 74 | const id = mapStyle.id 75 | fetch(this.localUrl + '/styles/' + id, { 76 | method: "PUT", 77 | mode: 'cors', 78 | headers: { 79 | "Content-Type": "application/json; charset=utf-8", 80 | }, 81 | body: styleJSON 82 | }) 83 | .catch(function(error) { 84 | if(error) console.error(error) 85 | }) 86 | return mapStyle 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /test/functional/helper.js: -------------------------------------------------------------------------------- 1 | var wd = require("../wd-helper"); 2 | var uuid = require('uuid/v1'); 3 | var geoServer = require("../geojson-server"); 4 | 5 | var testNetwork = process.env.TEST_NETWORK || "localhost"; 6 | var geoserver; 7 | 8 | module.exports = { 9 | startGeoserver: function(done) { 10 | geoserver = geoServer.listen(9002, "0.0.0.0", done); 11 | }, 12 | stopGeoserver: function(done) { 13 | geoserver.close(done); 14 | geoserver = undefined; 15 | }, 16 | getStyleUrl: function(styles) { 17 | var port = geoserver.address().port; 18 | return "http://"+testNetwork+":"+port+"/styles/empty/"+styles.join(","); 19 | }, 20 | getGeoServerUrl: function(urlPath) { 21 | var port = geoserver.address().port; 22 | return "http://"+testNetwork+":"+port+"/"+urlPath; 23 | }, 24 | getStyleStore: function(browser) { 25 | var result = browser.executeAsync(function(done) { 26 | window.debug.get("maputnik", "styleStore").latestStyle(done); 27 | }) 28 | return result; 29 | }, 30 | getRevisionStore: function(browser) { 31 | var result = browser.execute(function(done) { 32 | var rs = window.debug.get("maputnik", "revisionStore") 33 | 34 | return { 35 | currentIdx: rs.currentIdx, 36 | revisions: rs.revisions 37 | }; 38 | }) 39 | return result.value; 40 | }, 41 | modal: { 42 | addLayer: { 43 | open: function() { 44 | const selector = $(wd.$('layer-list:add-layer')); 45 | selector.click(); 46 | 47 | // Wait for events 48 | browser.flushReactUpdates(); 49 | 50 | const elem = $(wd.$('modal:add-layer')); 51 | elem.waitForExist(); 52 | elem.isDisplayed(); 53 | elem.isDisplayedInViewport(); 54 | 55 | // Wait for events 56 | browser.flushReactUpdates(); 57 | }, 58 | fill: function(opts) { 59 | var type = opts.type; 60 | var layer = opts.layer; 61 | var id; 62 | if(opts.id) { 63 | id = opts.id 64 | } 65 | else { 66 | id = type+":"+uuid(); 67 | } 68 | 69 | const selectBox = $(wd.$("add-layer.layer-type", "select")); 70 | selectBox.selectByAttribute('value', type); 71 | browser.flushReactUpdates(); 72 | 73 | browser.setValueSafe(wd.$("add-layer.layer-id", "input"), id); 74 | if(layer) { 75 | browser.setValueSafe(wd.$("add-layer.layer-source-block", "input"), layer); 76 | } 77 | 78 | browser.flushReactUpdates(); 79 | const elem_addLayer = $(wd.$("add-layer")); 80 | elem_addLayer.click(); 81 | 82 | return id; 83 | } 84 | } 85 | } 86 | } 87 | 88 | -------------------------------------------------------------------------------- /src/components/Doc.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | export default class Doc extends React.Component { 5 | static propTypes = { 6 | fieldSpec: PropTypes.object.isRequired, 7 | } 8 | 9 | render () { 10 | const {fieldSpec} = this.props; 11 | 12 | const {doc, values} = fieldSpec; 13 | const sdkSupport = fieldSpec['sdk-support']; 14 | 15 | const headers = { 16 | js: "JS", 17 | android: "Android", 18 | ios: "iOS", 19 | macos: "macOS", 20 | }; 21 | 22 | const renderValues = ( 23 | !!values && 24 | // HACK: Currently we merge additional values into the stylespec, so this is required 25 | // See 26 | !Array.isArray(values) 27 | ); 28 | 29 | return ( 30 | <> 31 | {doc && 32 |
    33 |
    {doc}
    34 | {renderValues && 35 |
      36 | {Object.entries(values).map(([key, value]) => { 37 | return ( 38 |
    • 39 | {JSON.stringify(key)} 40 |
      {value.doc}
      41 |
    • 42 | ); 43 | })} 44 |
    45 | } 46 |
    47 | } 48 | {sdkSupport && 49 |
    50 | 51 | 52 | 53 | 54 | {Object.values(headers).map(header => { 55 | return ; 56 | })} 57 | 58 | 59 | 60 | {Object.entries(sdkSupport).map(([key, supportObj]) => { 61 | return ( 62 | 63 | 64 | {Object.keys(headers).map(k => { 65 | const value = supportObj[k]; 66 | if (supportObj.hasOwnProperty(k)) { 67 | return ; 68 | } 69 | else { 70 | return ; 71 | } 72 | })} 73 | 74 | ); 75 | })} 76 | 77 |
    {header}
    {key}{supportObj[k]}no
    78 |
    79 | } 80 | 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/styles/_zoomproperty.scss: -------------------------------------------------------------------------------- 1 | // ZOOM FUNC 2 | .maputnik-make-zoom-function { 3 | background-color: transparent; 4 | display: inline-block; 5 | vertical-align: middle; 6 | padding: 0 $margin-2 0 0; 7 | 8 | @extend .maputnik-icon-button; 9 | } 10 | 11 | // ZOOM PROPERTY 12 | .maputnik-zoom-spec-property { 13 | @extend .clearfix; 14 | } 15 | 16 | .maputnik-zoom-spec-property-label { 17 | display: inline-block; 18 | width: 41%; 19 | } 20 | 21 | .maputnik-zoom-spec-property-stop-item { 22 | margin-bottom: $margin-2; 23 | margin-top: $margin-2; 24 | } 25 | 26 | .maputnik-zoom-spec-property-stop-edit { 27 | display: inline-block; 28 | vertical-align: top; 29 | width: 16%; 30 | margin-right: 3%; 31 | 32 | > * { 33 | width: 100%; 34 | } 35 | } 36 | 37 | .maputnik-zoom-spec-property-stop-value { 38 | display: inline-block; 39 | width: 81%; 40 | 41 | > * { 42 | width: 100%; 43 | } 44 | } 45 | 46 | .maputnik-delete-stop { 47 | display: inline-block; 48 | padding-bottom: 0; 49 | padding-top: 0; 50 | vertical-align: middle; 51 | 52 | @extend .maputnik-icon-button; 53 | } 54 | 55 | .maputnik-add-stop { 56 | display: inline-block; 57 | float: right; 58 | margin-right: $margin-3; 59 | } 60 | 61 | // DATA FUNC 62 | .maputnik-make-data-function { 63 | background-color: transparent; 64 | display: inline-block; 65 | vertical-align: middle; 66 | padding: 0 $margin-2 0 0; 67 | 68 | @extend .maputnik-icon-button; 69 | } 70 | 71 | .maputnik-data-spec-property { 72 | .maputnik-input-block-label { 73 | width: 30%; 74 | } 75 | 76 | .maputnik-input-block-action { 77 | display: none; 78 | } 79 | 80 | .maputnik-input-block-content { 81 | width: 70%; 82 | } 83 | 84 | .maputnik-data-spec-property-group { 85 | margin-bottom: 3%; 86 | 87 | .maputnik-doc-wrapper { 88 | width: 25%; 89 | color: $color-lowgray; 90 | } 91 | 92 | .maputnik-doc-wrapper:hover { 93 | color: inherit; 94 | } 95 | 96 | .maputnik-data-spec-property-input { 97 | width: 75%; 98 | display: inline-block; 99 | } 100 | } 101 | } 102 | 103 | .maputnik-data-spec-block { 104 | overflow: auto; 105 | 106 | .maputnik-data-spec-property-stop-edit, 107 | .maputnik-data-spec-property-stop-data { 108 | display: inline-block; 109 | margin-bottom: 3%; 110 | } 111 | 112 | .maputnik-data-spec-property-stop-edit { 113 | width: 18%; 114 | margin-right: 3%; 115 | } 116 | 117 | .maputnik-data-spec-property-stop-data { 118 | width: 100%; 119 | } 120 | 121 | .maputnik-data-spec-property-stop-edit + .maputnik-data-spec-property-stop-data { 122 | width: 78%; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /stories/MapMapboxGl.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MapMapboxGl from '../src/components/MapMapboxGl'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'MapMapboxGl', 10 | component: MapMapboxGl, 11 | decorators: [withA11y], 12 | }; 13 | 14 | const mapStyle = { 15 | "version": 8, 16 | "sources": { 17 | "test1": { 18 | "type": "geojson", 19 | "data": { 20 | "type": "FeatureCollection", 21 | "features": [ 22 | { 23 | "type": "Feature", 24 | "geometry": { 25 | "type": "Point", 26 | "coordinates": [0, -10] 27 | }, 28 | "properties": {} 29 | } 30 | ] 31 | } 32 | }, 33 | "test2": { 34 | "type": "geojson", 35 | "data": { 36 | "type": "FeatureCollection", 37 | "features": [ 38 | { 39 | "type": "Feature", 40 | "geometry": { 41 | "type": "Point", 42 | "coordinates": [15, 10] 43 | }, 44 | "properties": {} 45 | } 46 | ] 47 | } 48 | }, 49 | "test3": { 50 | "type": "geojson", 51 | "data": { 52 | "type": "FeatureCollection", 53 | "features": [ 54 | { 55 | "type": "Feature", 56 | "geometry": { 57 | "type": "Point", 58 | "coordinates": [-15, 10] 59 | }, 60 | "properties": {} 61 | } 62 | ] 63 | } 64 | } 65 | }, 66 | "sprite": "", 67 | "glyphs": "https://orangemug.github.io/font-glyphs/glyphs/{fontstack}/{range}.pbf", 68 | "layers": [ 69 | { 70 | "id": "test1", 71 | "type": "circle", 72 | "source": "test1", 73 | "paint": { 74 | "circle-radius": 40, 75 | "circle-color": "red" 76 | } 77 | }, 78 | { 79 | "id": "test2", 80 | "type": "circle", 81 | "source": "test2", 82 | "paint": { 83 | "circle-radius": 40, 84 | "circle-color": "green" 85 | } 86 | }, 87 | { 88 | "id": "test3", 89 | "type": "circle", 90 | "source": "test3", 91 | "paint": { 92 | "circle-radius": 40, 93 | "circle-color": "blue" 94 | } 95 | } 96 | ] 97 | } 98 | 99 | export const Basic = () => { 100 | return
    101 | s} 105 | /> 106 |
    107 | }; 108 | 109 | 110 | -------------------------------------------------------------------------------- /src/components/MapMapboxGlFeaturePropertyPopup.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Block from './Block' 4 | import FieldString from './FieldString' 5 | 6 | function displayValue(value) { 7 | if (typeof value === 'undefined' || value === null) return value; 8 | if (value instanceof Date) return value.toLocaleString(); 9 | if (typeof value === 'object' || 10 | typeof value === 'number' || 11 | typeof value === 'string') return value.toString(); 12 | return value; 13 | } 14 | 15 | function renderProperties(feature) { 16 | return Object.keys(feature.properties).map(propertyName => { 17 | const property = feature.properties[propertyName] 18 | return 19 | 20 | 21 | }) 22 | } 23 | 24 | function renderFeatureId(feature) { 25 | return 26 | 27 | 28 | } 29 | 30 | function renderFeature(feature, idx) { 31 | return
    32 |
    {feature.layer['source']}: {feature.layer['source-layer']}{feature.inspectModeCounter && × {feature.inspectModeCounter}}
    33 | 34 | 35 | 36 | {renderFeatureId(feature)} 37 | {renderProperties(feature)} 38 |
    39 | } 40 | 41 | function removeDuplicatedFeatures(features) { 42 | let uniqueFeatures = []; 43 | 44 | features.forEach(feature => { 45 | const featureIndex = uniqueFeatures.findIndex(feature2 => { 46 | return feature.layer['source-layer'] === feature2.layer['source-layer'] 47 | && JSON.stringify(feature.properties) === JSON.stringify(feature2.properties) 48 | }) 49 | 50 | if(featureIndex === -1) { 51 | uniqueFeatures.push(feature) 52 | } else { 53 | if(uniqueFeatures[featureIndex].hasOwnProperty('inspectModeCounter')) { 54 | uniqueFeatures[featureIndex].inspectModeCounter++ 55 | } else { 56 | uniqueFeatures[featureIndex].inspectModeCounter = 2 57 | } 58 | } 59 | }) 60 | 61 | return uniqueFeatures 62 | } 63 | 64 | class FeaturePropertyPopup extends React.Component { 65 | static propTypes = { 66 | features: PropTypes.array 67 | } 68 | 69 | render() { 70 | const features = removeDuplicatedFeatures(this.props.features) 71 | return
    72 | {features.map(renderFeature)} 73 |
    74 | } 75 | } 76 | 77 | 78 | export default FeaturePropertyPopup 79 | -------------------------------------------------------------------------------- /src/components/SingleFilterEditor.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | import { otherFilterOps } from '../libs/filterops.js' 5 | import InputString from './InputString' 6 | import InputAutocomplete from './InputAutocomplete' 7 | import InputSelect from './InputSelect' 8 | 9 | function tryParseInt(v) { 10 | if (v === '') return v 11 | if (isNaN(v)) return v 12 | return parseFloat(v) 13 | } 14 | 15 | function tryParseBool(v) { 16 | const isString = (typeof(v) === "string"); 17 | if(!isString) { 18 | return v; 19 | } 20 | 21 | if(v.match(/^\s*true\s*$/)) { 22 | return true; 23 | } 24 | else if(v.match(/^\s*false\s*$/)) { 25 | return false; 26 | } 27 | else { 28 | return v; 29 | } 30 | } 31 | 32 | function parseFilter(v) { 33 | v = tryParseInt(v); 34 | v = tryParseBool(v); 35 | return v; 36 | } 37 | 38 | export default class SingleFilterEditor extends React.Component { 39 | static propTypes = { 40 | filter: PropTypes.array.isRequired, 41 | onChange: PropTypes.func.isRequired, 42 | properties: PropTypes.object, 43 | } 44 | 45 | static defaultProps = { 46 | properties: {}, 47 | } 48 | 49 | onFilterPartChanged(filterOp, propertyName, filterArgs) { 50 | let newFilter = [filterOp, propertyName, ...filterArgs.map(parseFilter)] 51 | if(filterOp === 'has' || filterOp === '!has') { 52 | newFilter = [filterOp, propertyName] 53 | } else if(filterArgs.length === 0) { 54 | newFilter = [filterOp, propertyName, ''] 55 | } 56 | this.props.onChange(newFilter) 57 | } 58 | 59 | render() { 60 | const f = this.props.filter 61 | const filterOp = f[0] 62 | const propertyName = f[1] 63 | const filterArgs = f.slice(2) 64 | 65 | return
    66 |
    67 | [propName, propName])} 71 | onChange={newPropertyName => this.onFilterPartChanged(filterOp, newPropertyName, filterArgs)} 72 | /> 73 |
    74 |
    75 | this.onFilterPartChanged(newFilterOp, propertyName, filterArgs)} 79 | options={otherFilterOps} 80 | /> 81 |
    82 | {filterArgs.length > 0 && 83 |
    84 | this.onFilterPartChanged(filterOp, propertyName, v.split(','))} 88 | /> 89 |
    90 | } 91 |
    92 | } 93 | 94 | } 95 | 96 | -------------------------------------------------------------------------------- /stories/MapOpenLayers.stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MapOpenLayers from '../src/components/MapOpenLayers'; 3 | import {action} from '@storybook/addon-actions'; 4 | import {Wrapper} from './ui'; 5 | import {withA11y} from '@storybook/addon-a11y'; 6 | 7 | 8 | export default { 9 | title: 'MapOpenLayers', 10 | component: MapOpenLayers, 11 | decorators: [withA11y], 12 | }; 13 | 14 | const mapStyle = { 15 | "version": 8, 16 | "sources": { 17 | "test1": { 18 | "type": "geojson", 19 | "data": { 20 | "type": "FeatureCollection", 21 | "features": [ 22 | { 23 | "type": "Feature", 24 | "geometry": { 25 | "type": "Point", 26 | "coordinates": [0, -10] 27 | }, 28 | "properties": {} 29 | } 30 | ] 31 | } 32 | }, 33 | "test2": { 34 | "type": "geojson", 35 | "data": { 36 | "type": "FeatureCollection", 37 | "features": [ 38 | { 39 | "type": "Feature", 40 | "geometry": { 41 | "type": "Point", 42 | "coordinates": [15, 10] 43 | }, 44 | "properties": {} 45 | } 46 | ] 47 | } 48 | }, 49 | "test3": { 50 | "type": "geojson", 51 | "data": { 52 | "type": "FeatureCollection", 53 | "features": [ 54 | { 55 | "type": "Feature", 56 | "geometry": { 57 | "type": "Point", 58 | "coordinates": [-15, 10] 59 | }, 60 | "properties": {} 61 | } 62 | ] 63 | } 64 | } 65 | }, 66 | "sprite": "", 67 | "glyphs": "https://orangemug.github.io/font-glyphs/glyphs/{fontstack}/{range}.pbf", 68 | "layers": [ 69 | { 70 | "id": "test1", 71 | "type": "circle", 72 | "source": "test1", 73 | "paint": { 74 | "circle-radius": 40, 75 | "circle-color": "red" 76 | } 77 | }, 78 | { 79 | "id": "test2", 80 | "type": "circle", 81 | "source": "test2", 82 | "paint": { 83 | "circle-radius": 40, 84 | "circle-color": "green" 85 | } 86 | }, 87 | { 88 | "id": "test3", 89 | "type": "circle", 90 | "source": "test3", 91 | "paint": { 92 | "circle-radius": 40, 93 | "circle-color": "blue" 94 | } 95 | } 96 | ] 97 | } 98 | 99 | export const Basic = () => { 100 | return
    101 | s} 105 | onChange={() => {}} 106 | debugToolbox={true} 107 | /> 108 |
    109 | }; 110 | 111 | 112 | 113 | -------------------------------------------------------------------------------- /src/libs/stylestore.js: -------------------------------------------------------------------------------- 1 | import style from './style.js' 2 | import { loadStyleUrl } from './urlopen' 3 | import publicSources from '../config/styles.json' 4 | 5 | const storagePrefix = "maputnik" 6 | const stylePrefix = 'style' 7 | const storageKeys = { 8 | latest: [storagePrefix, 'latest_style'].join(':'), 9 | accessToken: [storagePrefix, 'access_token'].join(':') 10 | } 11 | 12 | const defaultStyleUrl = publicSources[0].url 13 | 14 | // Fetch a default style via URL and return it or a fallback style via callback 15 | export function loadDefaultStyle(cb) { 16 | loadStyleUrl(defaultStyleUrl, cb) 17 | } 18 | 19 | // Return style ids and dates of all styles stored in local storage 20 | function loadStoredStyles() { 21 | const styles = [] 22 | for (let i = 0; i < window.localStorage.length; i++) { 23 | const key = window.localStorage.key(i) 24 | if(isStyleKey(key)) { 25 | styles.push(fromKey(key)) 26 | } 27 | } 28 | return styles 29 | } 30 | 31 | function isStyleKey(key) { 32 | const parts = key.split(":") 33 | return parts.length == 3 && parts[0] === storagePrefix && parts[1] === stylePrefix 34 | } 35 | 36 | // Load style id from key 37 | function fromKey(key) { 38 | if(!isStyleKey(key)) { 39 | throw "Key is not a valid style key" 40 | } 41 | 42 | const parts = key.split(":") 43 | const styleId = parts[2] 44 | return styleId 45 | } 46 | 47 | // Calculate key that identifies the style with a version 48 | function styleKey(styleId) { 49 | return [storagePrefix, stylePrefix, styleId].join(":") 50 | } 51 | 52 | // Manages many possible styles that are stored in the local storage 53 | export class StyleStore { 54 | // Tile store will load all items from local storage and 55 | // assume they do not change will working on it 56 | constructor() { 57 | this.mapStyles = loadStoredStyles() 58 | } 59 | 60 | init(cb) { 61 | cb(null) 62 | } 63 | 64 | // Delete entire style history 65 | purge() { 66 | for (let i = 0; i < window.localStorage.length; i++) { 67 | const key = window.localStorage.key(i) 68 | if(key.startsWith(storagePrefix)) { 69 | window.localStorage.removeItem(key) 70 | } 71 | } 72 | } 73 | 74 | // Find the last edited style 75 | latestStyle(cb) { 76 | if(this.mapStyles.length === 0) return loadDefaultStyle(cb) 77 | const styleId = window.localStorage.getItem(storageKeys.latest) 78 | const styleItem = window.localStorage.getItem(styleKey(styleId)) 79 | 80 | if(styleItem) return cb(JSON.parse(styleItem)) 81 | loadDefaultStyle(cb) 82 | } 83 | 84 | // Save current style replacing previous version 85 | save(mapStyle) { 86 | mapStyle = style.ensureStyleValidity(mapStyle) 87 | const key = styleKey(mapStyle.id) 88 | window.localStorage.setItem(key, JSON.stringify(mapStyle)) 89 | window.localStorage.setItem(storageKeys.latest, mapStyle.id) 90 | return mapStyle 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/components/InputAutocomplete.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import classnames from 'classnames' 4 | import Autocomplete from 'react-autocomplete' 5 | 6 | 7 | const MAX_HEIGHT = 140; 8 | 9 | export default class InputAutocomplete extends React.Component { 10 | static propTypes = { 11 | value: PropTypes.string, 12 | options: PropTypes.array, 13 | onChange: PropTypes.func, 14 | keepMenuWithinWindowBounds: PropTypes.bool, 15 | 'aria-label': PropTypes.string, 16 | } 17 | 18 | state = { 19 | maxHeight: MAX_HEIGHT 20 | } 21 | 22 | static defaultProps = { 23 | onChange: () => {}, 24 | options: [], 25 | } 26 | 27 | calcMaxHeight() { 28 | if(this.props.keepMenuWithinWindowBounds) { 29 | const maxHeight = window.innerHeight - this.autocompleteMenuEl.getBoundingClientRect().top; 30 | const limitedMaxHeight = Math.min(maxHeight, MAX_HEIGHT); 31 | 32 | if(limitedMaxHeight != this.state.maxHeight) { 33 | this.setState({ 34 | maxHeight: limitedMaxHeight 35 | }) 36 | } 37 | } 38 | } 39 | 40 | componentDidMount() { 41 | this.calcMaxHeight(); 42 | } 43 | 44 | componentDidUpdate() { 45 | this.calcMaxHeight(); 46 | } 47 | 48 | onChange (v) { 49 | this.props.onChange(v === "" ? undefined : v); 50 | } 51 | 52 | render() { 53 | return
    { 55 | this.autocompleteMenuEl = el; 56 | }} 57 | > 58 | item[0]} 77 | onSelect={v => this.onChange(v)} 78 | onChange={(e, v) => this.onChange(v)} 79 | shouldItemRender={(item, value="") => { 80 | if (typeof(value) === "string") { 81 | return item[0].toLowerCase().indexOf(value.toLowerCase()) > -1 82 | } 83 | }} 84 | renderItem={(item, isHighlighted) => ( 85 |
    92 | {item[1]} 93 |
    94 | )} 95 | /> 96 |
    97 | } 98 | } 99 | 100 | 101 | -------------------------------------------------------------------------------- /src/config/styles.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "osm-liberty", 4 | "title": "OSM Liberty", 5 | "url": "https://maputnik.github.io/osm-liberty/style.json", 6 | "thumbnail": "https://maputnik.github.io/thumbnails/osm-liberty.png" 7 | }, 8 | { 9 | "id": "maptiler-basic-gl-style", 10 | "title": "Maptiler Basic", 11 | "url": "https://cdn.jsdelivr.net/gh/openmaptiles/klokantech-basic-gl-style@v1.9/style.json", 12 | "thumbnail": "https://maputnik.github.io/thumbnails/klokantech-basic.png" 13 | }, 14 | { 15 | "id": "dark-matter", 16 | "title": "Dark Matter", 17 | "url": "https://cdn.jsdelivr.net/gh/openmaptiles/dark-matter-gl-style@v1.8/style.json", 18 | "thumbnail": "https://maputnik.github.io/thumbnails/dark-matter.png" 19 | }, 20 | { 21 | "id": "positron", 22 | "title": "Positron", 23 | "url": "https://cdn.jsdelivr.net/gh/openmaptiles/positron-gl-style@v1.8/style.json", 24 | "thumbnail": "https://maputnik.github.io/thumbnails/positron.png" 25 | }, 26 | { 27 | "id": "osm-bright", 28 | "title": "OSM Bright", 29 | "url": "https://cdn.jsdelivr.net/gh/openmaptiles/osm-bright-gl-style@v1.9/style.json", 30 | "thumbnail": "https://maputnik.github.io/thumbnails/osm-bright.png" 31 | }, 32 | { 33 | "id": "maptiler-toner-gl-style", 34 | "title": "Toner", 35 | "url": "https://cdn.jsdelivr.net/gh/openmaptiles/toner-gl-style@339e5b7/style.json", 36 | "thumbnail": "https://maputnik.github.io/thumbnails/toner.png" 37 | }, 38 | { 39 | "id": "os-zoomstack-outdoor", 40 | "title": "Zoomstack Outdoor", 41 | "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-outdoor/style.json", 42 | "thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-outdoor.png" 43 | }, 44 | { 45 | "id": "os-zoomstack-road", 46 | "title": "Zoomstack Road", 47 | "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-road/style.json", 48 | "thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-road.png" 49 | }, 50 | { 51 | "id": "os-zoomstack-light", 52 | "title": "Zoomstack Light", 53 | "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-light/style.json", 54 | "thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-light.png" 55 | }, 56 | { 57 | "id": "os-zoomstack-night", 58 | "title": "Zoomstack Night", 59 | "url": "https://s3-eu-west-1.amazonaws.com/tiles.os.uk/v2/styles/open-zoomstack-night/style.json", 60 | "thumbnail": "https://maputnik.github.io/thumbnails/os-zoomstack-night.png" 61 | }, 62 | { 63 | "id": "empty-style", 64 | "title": "Empty Style", 65 | "url": "https://cdn.jsdelivr.net/gh/maputnik/editor@9cf74ca405d2be0608b57db8109cf3a6af5b9f49/src/config/empty-style.json", 66 | "thumbnail": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAYAAAAECAQAAAAHDYbIAAAAEUlEQVR42mP8/58BDhiJ4wAA974H/U5Xe1oAAAAASUVORK5CYII=" 67 | } 68 | ] 69 | -------------------------------------------------------------------------------- /test/functional/history/index.js: -------------------------------------------------------------------------------- 1 | var assert = require("assert"); 2 | var config = require("../../config/specs"); 3 | var helper = require("../helper"); 4 | 5 | 6 | 7 | describe("history", function() { 8 | let undoKeyCombo; 9 | let undoKeyComboReset; 10 | let redoKeyCombo; 11 | let redoKeyComboReset; 12 | 13 | before(function() { 14 | const isMac = browser.execute(function() { 15 | return navigator.platform.toUpperCase().indexOf('MAC') >= 0; 16 | }); 17 | undoKeyCombo = ['Meta', 'z']; 18 | undoKeyComboReset = ['Meta']; 19 | redoKeyCombo = isMac ? ['Meta', 'Shift', 'z'] : ['Meta', 'y']; 20 | redoKeyComboReset = isMac ? ['Meta', 'Shift'] : ['Meta']; 21 | }); 22 | 23 | /** 24 | * See 25 | */ 26 | it.skip("undo/redo", function() { 27 | var styleObj; 28 | 29 | browser.url(config.baseUrl+"?debug&style="+helper.getStyleUrl([ 30 | "geojson:example" 31 | ])); 32 | browser.acceptAlert(); 33 | 34 | helper.modal.addLayer.open(); 35 | 36 | styleObj = helper.getStyleStore(browser); 37 | assert.deepEqual(styleObj.layers, []); 38 | 39 | helper.modal.addLayer.fill({ 40 | id: "step 1", 41 | type: "background" 42 | }) 43 | 44 | styleObj = helper.getStyleStore(browser); 45 | assert.deepEqual(styleObj.layers, [ 46 | { 47 | "id": "step 1", 48 | "type": 'background' 49 | } 50 | ]); 51 | 52 | helper.modal.addLayer.open(); 53 | helper.modal.addLayer.fill({ 54 | id: "step 2", 55 | type: "background" 56 | }) 57 | 58 | styleObj = helper.getStyleStore(browser); 59 | assert.deepEqual(styleObj.layers, [ 60 | { 61 | "id": "step 1", 62 | "type": 'background' 63 | }, 64 | { 65 | "id": "step 2", 66 | "type": 'background' 67 | } 68 | ]); 69 | 70 | browser.keys(undoKeyCombo) 71 | browser.keys(undoKeyComboReset); 72 | styleObj = helper.getStyleStore(browser); 73 | assert.deepEqual(styleObj.layers, [ 74 | { 75 | "id": "step 1", 76 | "type": 'background' 77 | } 78 | ]); 79 | 80 | browser.keys(undoKeyCombo) 81 | browser.keys(undoKeyComboReset); 82 | styleObj = helper.getStyleStore(browser); 83 | assert.deepEqual(styleObj.layers, [ 84 | ]); 85 | 86 | browser.keys(redoKeyCombo) 87 | browser.keys(redoKeyComboReset); 88 | styleObj = helper.getStyleStore(browser); 89 | assert.deepEqual(styleObj.layers, [ 90 | { 91 | "id": "step 1", 92 | "type": 'background' 93 | } 94 | ]); 95 | 96 | browser.keys(redoKeyCombo) 97 | browser.keys(redoKeyComboReset); 98 | styleObj = helper.getStyleStore(browser); 99 | assert.deepEqual(styleObj.layers, [ 100 | { 101 | "id": "step 1", 102 | "type": 'background' 103 | }, 104 | { 105 | "id": "step 2", 106 | "type": 'background' 107 | } 108 | ]); 109 | 110 | }); 111 | 112 | }) 113 | --------------------------------------------------------------------------------