Move modals and rename classes to match file names (#1367)

## Launch Checklist

I've created a folder for modals and moved them all inside.
I removed the icons which were small files with no real benefit and
moved the icons to where they were used.
I fixed an issue with the close button position in modal.
I've added missing translation to "Links" in debug modal.

Before: 

<img width="610" height="81" alt="image"
src="https://github.com/user-attachments/assets/dd7520f6-9634-4ff1-a83d-99ceae7c9144"
/>

After:
<img width="610" height="81" alt="image"
src="https://github.com/user-attachments/assets/fe3a2ccf-6c09-42ab-bf6f-dd30d3c68e13"
/>


 - [x] Briefly describe the changes in this PR.
- [x] Include before/after visuals or gifs if this PR includes visual
changes.
 - [ ] Add an entry to `CHANGELOG.md` under the `## main` section.
This commit is contained in:
Harel M
2025-09-10 14:37:23 +03:00
committed by GitHub
parent 3725f83b48
commit 9c85883b8a
36 changed files with 133 additions and 187 deletions
+71
View File
@@ -0,0 +1,71 @@
import React, { PropsWithChildren } from 'react'
import {MdClose} from 'react-icons/md'
import AriaModal from 'react-aria-modal'
import classnames from 'classnames';
import { WithTranslation, withTranslation } from 'react-i18next';
type ModalInternalProps = PropsWithChildren & {
"data-wd-key"?: string
isOpen: boolean
title: string
onOpenToggle(value: boolean): unknown
underlayClickExits?: boolean
className?: string
} & WithTranslation;
class ModalInternal extends React.Component<ModalInternalProps> {
static defaultProps = {
underlayClickExits: true
}
// See <https://github.com/maplibre/maputnik/issues/416>
onClose = () => {
if (document.activeElement) {
(document.activeElement as HTMLElement).blur();
}
setTimeout(() => {
this.props.onOpenToggle(false);
}, 0);
}
render() {
const t = this.props.t;
if(this.props.isOpen) {
return <AriaModal
titleText={this.props.title}
underlayClickExits={this.props.underlayClickExits}
data-wd-key={this.props["data-wd-key"]}
verticallyCenter={true}
onExit={this.onClose}
dialogClass='maputnik-modal-container'
>
<div className={classnames("maputnik-modal", this.props.className)}
data-wd-key={this.props["data-wd-key"]}
>
<header className="maputnik-modal-header">
<h1 className="maputnik-modal-header-title">{this.props.title}</h1>
<span className="maputnik-space"></span>
<button className="maputnik-modal-header-toggle"
title={t("Close modal")}
onClick={this.onClose}
data-wd-key={this.props["data-wd-key"]+".close-modal"}
>
<MdClose />
</button>
</header>
<div className="maputnik-modal-scroller">
<div className="maputnik-modal-content">{this.props.children}</div>
</div>
</div>
</AriaModal>
}
else {
return false;
}
}
}
const Modal = withTranslation()(ModalInternal);
export default Modal;
+196
View File
@@ -0,0 +1,196 @@
import React from 'react'
import { WithTranslation, withTranslation } from 'react-i18next';
import type {LayerSpecification, SourceSpecification} from 'maplibre-gl'
import InputButton from '../InputButton'
import Modal from './Modal'
import FieldType from '../FieldType'
import FieldId from '../FieldId'
import FieldSource from '../FieldSource'
import FieldSourceLayer from '../FieldSourceLayer'
import { NON_SOURCE_LAYERS } from '../../libs/non-source-layers'
type ModalAddInternalProps = {
layers: LayerSpecification[]
onLayersChange(layers: LayerSpecification[]): unknown
isOpen: boolean
onOpenToggle(open: boolean): unknown
// A dict of source id's and the available source layers
sources: Record<string, SourceSpecification & {layers: string[]}>;
} & WithTranslation;
type ModalAddState = {
type: LayerSpecification["type"]
id: string
source?: string
'source-layer'?: string
error?: string | null
};
class ModalAddInternal extends React.Component<ModalAddInternalProps, ModalAddState> {
addLayer = () => {
if (this.props.layers.some(l => l.id === this.state.id)) {
this.setState({ error: this.props.t('Layer ID already exists') })
return
}
const changedLayers = this.props.layers.slice(0)
const layer: ModalAddState = {
id: this.state.id,
type: this.state.type,
}
if(this.state.type !== 'background') {
layer.source = this.state.source
if(!NON_SOURCE_LAYERS.includes(this.state.type) && this.state['source-layer']) {
layer['source-layer'] = this.state['source-layer']
}
}
changedLayers.push(layer as LayerSpecification)
this.setState({ error: null }, () => {
this.props.onLayersChange(changedLayers)
this.props.onOpenToggle(false)
})
}
constructor(props: ModalAddInternalProps) {
super(props)
const state: ModalAddState = {
type: 'fill',
id: '',
error: null,
}
if(Object.keys(props.sources).length > 0) {
state.source = Object.keys(this.props.sources)[0];
const sourceLayers = this.props.sources[state.source].layers || []
if (sourceLayers.length > 0) {
state['source-layer'] = sourceLayers[0];
}
}
this.state = state;
}
componentDidUpdate(_prevProps: ModalAddInternalProps, prevState: ModalAddState) {
// Check if source is valid for new type
const oldType = prevState.type;
const newType = this.state.type;
const availableSourcesOld = this.getSources(oldType);
const availableSourcesNew = this.getSources(newType);
if(
// Type has changed
oldType !== newType
&& prevState.source !== ""
// Was a valid source previously
&& availableSourcesOld.indexOf(prevState.source!) > -1
// And is not a valid source now
&& availableSourcesNew.indexOf(this.state.source!) < 0
) {
// Clear the source
this.setState({
source: ""
});
}
}
getLayersForSource(source: string) {
const sourceObj = this.props.sources[source] || {};
return sourceObj.layers || [];
}
getSources(type: LayerSpecification["type"]) {
switch(type) {
case 'background':
return [];
case 'hillshade':
case 'color-relief':
return Object.entries(this.props.sources).filter(([_, v]) => v.type === 'raster-dem').map(([k, _]) => k);
case 'raster':
return Object.entries(this.props.sources).filter(([_, v]) => v.type === 'raster').map(([k, _]) => k);
case 'heatmap':
case 'circle':
case 'fill':
case 'fill-extrusion':
case 'line':
case 'symbol':
return Object.entries(this.props.sources).filter(([_, v]) => v.type === 'vector' || v.type === 'geojson').map(([k, _]) => k);
default:
return [];
}
}
render() {
const t = this.props.t;
const sources = this.getSources(this.state.type);
const layers = this.getLayersForSource(this.state.source!);
let errorElement;
if (this.state.error) {
errorElement = (
<div className="maputnik-modal-error">
{this.state.error}
<a
href="#"
onClick={() => this.setState({ error: null })}
className="maputnik-modal-error-close"
>
×
</a>
</div>
);
}
return <Modal
isOpen={this.props.isOpen}
onOpenToggle={this.props.onOpenToggle}
title={t('Add Layer')}
data-wd-key="modal:add-layer"
className="maputnik-add-modal"
>
{errorElement}
<div className="maputnik-add-layer">
<FieldId
value={this.state.id}
wdKey="add-layer.layer-id"
onChange={(v: string) => {
this.setState({ id: v, error: null })
}}
/>
<FieldType
value={this.state.type}
wdKey="add-layer.layer-type"
onChange={(v: LayerSpecification["type"]) => this.setState({ type: v })}
/>
{this.state.type !== 'background' &&
<FieldSource
sourceIds={sources}
wdKey="add-layer.layer-source-block"
value={this.state.source}
onChange={(v: string) => this.setState({ source: v })}
/>
}
{!NON_SOURCE_LAYERS.includes(this.state.type) &&
<FieldSourceLayer
sourceLayerIds={layers}
value={this.state['source-layer']}
onChange={(v: string) => this.setState({ 'source-layer': v })}
/>
}
<InputButton
className="maputnik-add-layer-button"
onClick={this.addLayer}
data-wd-key="add-layer"
>
{t("Add Layer")}
</InputButton>
</div>
</Modal>
}
}
const ModalAdd = withTranslation()(ModalAddInternal);
export default ModalAdd;
+83
View File
@@ -0,0 +1,83 @@
import React from 'react'
import { Trans, WithTranslation, withTranslation } from 'react-i18next';
import Modal from './Modal'
type ModalDebugInternalProps = {
isOpen: boolean
renderer: string
onChangeMaplibreGlDebug(key: string, checked: boolean): unknown
onChangeOpenlayersDebug(key: string, checked: boolean): unknown
onOpenToggle(value: boolean): unknown
maplibreGlDebugOptions?: object
openlayersDebugOptions?: object
mapView: {
zoom: number
center: {
lng: number
lat: number
}
}
} & WithTranslation;
class ModalDebugInternal extends React.Component<ModalDebugInternalProps> {
render() {
const {t, mapView} = this.props;
const osmZoom = Math.round(mapView.zoom)+1;
const osmLon = +(mapView.center.lng).toFixed(5);
const osmLat = +(mapView.center.lat).toFixed(5);
return <Modal
data-wd-key="modal:debug"
isOpen={this.props.isOpen}
onOpenToggle={this.props.onOpenToggle}
title={t('Debug')}
>
<section className="maputnik-modal-section maputnik-modal-shortcuts">
<h1>{t("Options")}</h1>
{this.props.renderer === 'mlgljs' &&
<ul>
{Object.entries(this.props.maplibreGlDebugOptions!).map(([key, val]) => {
return <li key={key}>
<label>
<input type="checkbox" checked={val} onChange={(e) => this.props.onChangeMaplibreGlDebug(key, e.target.checked)} /> {key}
</label>
</li>
})}
</ul>
}
{this.props.renderer === 'ol' &&
<ul>
{Object.entries(this.props.openlayersDebugOptions!).map(([key, val]) => {
return <li key={key}>
<label>
<input type="checkbox" checked={val} onChange={(e) => this.props.onChangeOpenlayersDebug(key, e.target.checked)} /> {key}
</label>
</li>
})}
</ul>
}
</section>
<section className="maputnik-modal-section">
<h1>{t("Links")}</h1>
<p>
<Trans t={t}>
<a
target="_blank"
rel="noopener noreferrer"
href={`https://www.openstreetmap.org/#map=${osmZoom}/${osmLat}/${osmLon}`}
>
Open in OSM
</a>. Opens the current view on openstreetmap.org
</Trans>
</p>
</section>
</Modal>
}
}
const ModalDebug = withTranslation()(ModalDebugInternal);
export default ModalDebug;
+221
View File
@@ -0,0 +1,221 @@
import React from 'react'
import Slugify from 'slugify'
import {saveAs} from 'file-saver'
import {version} from 'maplibre-gl/package.json'
import {format} from '@maplibre/maplibre-gl-style-spec'
import {MdMap, MdSave} from 'react-icons/md'
import {WithTranslation, withTranslation} from 'react-i18next';
import FieldString from '../FieldString'
import InputButton from '../InputButton'
import Modal from './Modal'
import style from '../../libs/style'
import fieldSpecAdditional from '../../libs/field-spec-additional'
import type {OnStyleChangedCallback, StyleSpecificationWithId} from '../../libs/definitions'
const MAPLIBRE_GL_VERSION = version;
const showSaveFilePickerAvailable = typeof window.showSaveFilePicker === "function";
type ModalExportInternalProps = {
mapStyle: StyleSpecificationWithId
onStyleChanged: OnStyleChangedCallback
isOpen: boolean
onOpenToggle(...args: unknown[]): unknown
onSetFileHandle(fileHandle: FileSystemFileHandle | null): unknown
fileHandle: FileSystemFileHandle | null
} & WithTranslation;
class ModalExportInternal extends React.Component<ModalExportInternalProps> {
tokenizedStyle() {
return format(
style.stripAccessTokens(
style.replaceAccessTokens(this.props.mapStyle)
)
);
}
exportName() {
if (this.props.mapStyle.name) {
return Slugify(this.props.mapStyle.name, {
replacement: '_',
remove: /[*\-+~.()'"!:]/g,
lower: true
});
} else {
return this.props.mapStyle.id
}
}
createHtml() {
const tokenStyle = this.tokenizedStyle();
const htmlTitle = this.props.mapStyle.name || this.props.t("Map");
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>${htmlTitle}</title>
<meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" />
<script src="https://unpkg.com/maplibre-gl@${MAPLIBRE_GL_VERSION}/dist/maplibre-gl.js"></script>
<link href="https://unpkg.com/maplibre-gl@${MAPLIBRE_GL_VERSION}/dist/maplibre-gl.css" rel="stylesheet" />
<style>
body { margin: 0; padding: 0; }
#map { position: absolute; top: 0; bottom: 0; width: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const map = new maplibregl.Map({
container: 'map',
style: ${tokenStyle},
});
map.addControl(new maplibregl.NavigationControl());
</script>
</body>
</html>
`;
const blob = new Blob([html], {type: "text/html;charset=utf-8"});
const exportName = this.exportName();
saveAs(blob, exportName + ".html");
}
async saveStyle() {
const tokenStyle = this.tokenizedStyle();
// it is not guaranteed that the File System Access API is available on all
// browsers. If the function is not available, a fallback behavior is used.
if (!showSaveFilePickerAvailable) {
const blob = new Blob([tokenStyle], {type: "application/json;charset=utf-8"});
const exportName = this.exportName();
saveAs(blob, exportName + ".json");
return;
}
let fileHandle = this.props.fileHandle;
if (fileHandle == null) {
fileHandle = await this.createFileHandle();
this.props.onSetFileHandle(fileHandle)
if (fileHandle == null) return;
}
const writable = await fileHandle.createWritable();
await writable.write(tokenStyle);
await writable.close();
this.props.onOpenToggle();
}
async saveStyleAs() {
const tokenStyle = this.tokenizedStyle();
const fileHandle = await this.createFileHandle();
this.props.onSetFileHandle(fileHandle)
if (fileHandle == null) return;
const writable = await fileHandle.createWritable();
await writable.write(tokenStyle);
await writable.close();
this.props.onOpenToggle();
}
async createFileHandle(): Promise<FileSystemFileHandle | null> {
const pickerOpts: SaveFilePickerOptions = {
types: [
{
description: "json",
accept: {"application/json": [".json"]},
},
],
suggestedName: this.exportName(),
};
const fileHandle = await window.showSaveFilePicker(pickerOpts) as FileSystemFileHandle;
this.props.onSetFileHandle(fileHandle)
return fileHandle;
}
changeMetadataProperty(property: string, value: any) {
const changedStyle = {
...this.props.mapStyle,
metadata: {
...this.props.mapStyle.metadata as any,
[property]: value
}
}
this.props.onStyleChanged(changedStyle)
}
render() {
const t = this.props.t;
const fsa = fieldSpecAdditional(t);
return <Modal
data-wd-key="modal:export"
isOpen={this.props.isOpen}
onOpenToggle={this.props.onOpenToggle}
title={t('Save Style')}
className="maputnik-export-modal"
>
<section className="maputnik-modal-section">
<h1>{t("Save Style")}</h1>
<p>
{t("Save the JSON style to your computer.")}
</p>
<div>
<FieldString
label={fsa.maputnik.maptiler_access_token.label}
fieldSpec={fsa.maputnik.maptiler_access_token}
value={(this.props.mapStyle.metadata || {} as any)['maputnik:openmaptiles_access_token']}
onChange={this.changeMetadataProperty.bind(this, "maputnik:openmaptiles_access_token")}
/>
<FieldString
label={fsa.maputnik.thunderforest_access_token.label}
fieldSpec={fsa.maputnik.thunderforest_access_token}
value={(this.props.mapStyle.metadata || {} as any)['maputnik:thunderforest_access_token']}
onChange={this.changeMetadataProperty.bind(this, "maputnik:thunderforest_access_token")}
/>
<FieldString
label={fsa.maputnik.stadia_access_token.label}
fieldSpec={fsa.maputnik.stadia_access_token}
value={(this.props.mapStyle.metadata || {} as any)['maputnik:stadia_access_token']}
onChange={this.changeMetadataProperty.bind(this, "maputnik:stadia_access_token")}
/>
<FieldString
label={fsa.maputnik.locationiq_access_token.label}
fieldSpec={fsa.maputnik.locationiq_access_token}
value={(this.props.mapStyle.metadata || {} as any)['maputnik:locationiq_access_token']}
onChange={this.changeMetadataProperty.bind(this, "maputnik:locationiq_access_token")}
/>
</div>
<div className="maputnik-modal-export-buttons">
<InputButton onClick={this.saveStyle.bind(this)}>
<MdSave/>
{t("Save")}
</InputButton>
{showSaveFilePickerAvailable && (
<InputButton onClick={this.saveStyleAs.bind(this)}>
<MdSave/>
{t("Save as")}
</InputButton>
)}
<InputButton onClick={this.createHtml.bind(this)}>
<MdMap/>
{t("Create HTML")}
</InputButton>
</div>
</section>
</Modal>
}
}
const ModalExport = withTranslation()(ModalExportInternal);
export default ModalExport;
+39
View File
@@ -0,0 +1,39 @@
import React from 'react'
import { WithTranslation, withTranslation } from 'react-i18next';
import InputButton from '../InputButton'
import Modal from './Modal'
type ModalLoadingInternalProps = {
isOpen: boolean
onCancel(...args: unknown[]): unknown
title: string
message: React.ReactNode
} & WithTranslation;
class ModalLoadingInternal extends React.Component<ModalLoadingInternalProps> {
render() {
const t = this.props.t;
return <Modal
data-wd-key="modal:loading"
isOpen={this.props.isOpen}
underlayClickExits={false}
title={this.props.title}
onOpenToggle={() => this.props.onCancel()}
>
<p>
{this.props.message}
</p>
<p className="maputnik-dialog__buttons">
<InputButton onClick={(e) => this.props.onCancel(e)}>
{t("Cancel")}
</InputButton>
</p>
</Modal>
}
}
const ModalLoading = withTranslation()(ModalLoadingInternal);
export default ModalLoading;
+312
View File
@@ -0,0 +1,312 @@
import React, { FormEvent } from 'react'
import {MdFileUpload} from 'react-icons/md'
import {MdAddCircleOutline} from 'react-icons/md'
import FileReaderInput, { Result } from 'react-file-reader-input'
import { Trans, WithTranslation, withTranslation } from 'react-i18next';
import ModalLoading from './ModalLoading'
import Modal from './Modal'
import InputButton from '../InputButton'
import InputUrl from '../InputUrl'
import style from '../../libs/style'
import publicStyles from '../../config/styles.json'
type PublicStyleProps = {
url: string
thumbnailUrl: string
title: string
onSelect(...args: unknown[]): unknown
};
class PublicStyle extends React.Component<PublicStyleProps> {
render() {
return <div className="maputnik-public-style">
<InputButton
className="maputnik-public-style-button"
aria-label={this.props.title}
onClick={() => this.props.onSelect(this.props.url)}
>
<div className="maputnik-public-style-header">
<div>{this.props.title}</div>
<span className="maputnik-space" />
<MdAddCircleOutline />
</div>
<div
className="maputnik-public-style-thumbnail"
style={{
backgroundImage: `url(${this.props.thumbnailUrl})`
}}
></div>
</InputButton>
</div>
}
}
type ModalOpenInternalProps = {
isOpen: boolean
onOpenToggle(...args: unknown[]): unknown
onStyleOpen(...args: unknown[]): unknown
fileHandle: FileSystemFileHandle | null
} & WithTranslation;
type ModalOpenState = {
styleUrl: string
error?: string | null
activeRequest?: any
activeRequestUrl?: string | null
};
class ModalOpenInternal extends React.Component<ModalOpenInternalProps, ModalOpenState> {
constructor(props: ModalOpenInternalProps) {
super(props);
this.state = {
styleUrl: ""
};
}
clearError() {
this.setState({
error: null
})
}
onCancelActiveRequest(e: Event) {
// Else the click propagates to the underlying modal
if(e) e.stopPropagation();
if(this.state.activeRequest) {
this.state.activeRequest.abort();
this.setState({
activeRequest: null,
activeRequestUrl: null
});
}
}
onStyleSelect = (styleUrl: string) => {
this.clearError();
let canceled: boolean = false;
fetch(styleUrl, {
mode: 'cors',
credentials: "same-origin"
})
.then(function(response) {
return response.json();
})
.then((body) => {
if(canceled) {
return;
}
this.setState({
activeRequest: null,
activeRequestUrl: null
});
const mapStyle = style.ensureStyleValidity(body)
console.log('Loaded style ', mapStyle.id)
this.props.onStyleOpen(mapStyle)
this.onOpenToggle()
})
.catch((err) => {
this.setState({
error: `Failed to load: '${styleUrl}'`,
activeRequest: null,
activeRequestUrl: null
});
console.error(err);
console.warn('Could not open the style URL', styleUrl)
})
this.setState({
activeRequest: {
abort: function() {
canceled = true;
}
},
activeRequestUrl: styleUrl
})
}
onSubmitUrl = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
this.onStyleSelect(this.state.styleUrl);
}
onOpenFile = async () => {
this.clearError();
const pickerOpts: OpenFilePickerOptions = {
types: [
{
description: "json",
accept: { "application/json": [".json"] },
},
],
multiple: false,
};
const [fileHandle] = await window.showOpenFilePicker(pickerOpts) as Array<FileSystemFileHandle>;
const file = await fileHandle.getFile();
const content = await file.text();
let mapStyle;
try {
mapStyle = JSON.parse(content)
} catch (err) {
this.setState({
error: (err as Error).toString()
});
return;
}
mapStyle = style.ensureStyleValidity(mapStyle)
this.props.onStyleOpen(mapStyle, fileHandle);
this.onOpenToggle();
return file;
}
// it is not guaranteed that the File System Access API is available on all
// browsers. If the function is not available, a fallback behavior is used.
onFileChanged = async (_: any, files: Result[]) => {
const [, file] = files[0];
const reader = new FileReader();
this.clearError();
reader.readAsText(file, "UTF-8");
reader.onload = e => {
let mapStyle;
try {
mapStyle = JSON.parse(e.target?.result as string)
}
catch(err) {
this.setState({
error: (err as Error).toString()
});
return;
}
mapStyle = style.ensureStyleValidity(mapStyle)
this.props.onStyleOpen(mapStyle);
this.onOpenToggle();
}
reader.onerror = e => console.log(e.target);
}
onOpenToggle() {
this.setState({
styleUrl: ""
});
this.clearError();
this.props.onOpenToggle();
}
onChangeUrl = (url: string) => {
this.setState({
styleUrl: url,
});
}
render() {
const t = this.props.t;
const styleOptions = publicStyles.map(style => {
return <PublicStyle
key={style.id}
url={style.url}
title={style.title}
thumbnailUrl={style.thumbnail}
onSelect={this.onStyleSelect}
/>
})
let errorElement;
if(this.state.error) {
errorElement = (
<div className="maputnik-modal-error">
{this.state.error}
<a href="#" onClick={() => this.clearError()} className="maputnik-modal-error-close">×</a>
</div>
);
}
return (
<div>
<Modal
data-wd-key="modal:open"
isOpen={this.props.isOpen}
onOpenToggle={() => this.onOpenToggle()}
title={t('Open Style')}
>
{errorElement}
<section className="maputnik-modal-section">
<h1>{t("Open local Style")}</h1>
<p>{t("Open a local JSON style from your computer.")}</p>
<div>
{typeof window.showOpenFilePicker === "function" ? (
<InputButton
className="maputnik-big-button"
onClick={this.onOpenFile}><MdFileUpload/> {t("Open Style")}
</InputButton>
) : (
<FileReaderInput onChange={this.onFileChanged} tabIndex={-1} aria-label={t("Open Style")}>
<InputButton className="maputnik-upload-button"><MdFileUpload /> {t("Open Style")}</InputButton>
</FileReaderInput>
)}
</div>
</section>
<section className="maputnik-modal-section">
<form onSubmit={this.onSubmitUrl}>
<h1>{t("Load from URL")}</h1>
<p>
<Trans t={t}>
Load from a URL. Note that the URL must have <a href="https://enable-cors.org" target="_blank" rel="noopener noreferrer">CORS enabled</a>.
</Trans>
</p>
<InputUrl
aria-label={t("Style URL")}
data-wd-key="modal:open.url.input"
type="text"
className="maputnik-input"
default={t("Enter URL...")}
value={this.state.styleUrl}
onInput={this.onChangeUrl}
onChange={this.onChangeUrl}
/>
<div>
<InputButton
data-wd-key="modal:open.url.button"
type="submit"
className="maputnik-big-button"
disabled={this.state.styleUrl.length < 1}
>Load from URL</InputButton>
</div>
</form>
</section>
<section className="maputnik-modal-section maputnik-modal-section--shrink">
<h1>{t("Gallery Styles")}</h1>
<p>
{t("Open one of the publicly available styles to start from.")}
</p>
<div className="maputnik-style-gallery-container">
{styleOptions}
</div>
</section>
</Modal>
<ModalLoading
isOpen={!!this.state.activeRequest}
title={t('Loading style')}
onCancel={(e: Event) => this.onCancelActiveRequest(e)}
message={t("Loading: {{requestUrl}}", { requestUrl: this.state.activeRequestUrl })}
/>
</div>
)
}
}
const ModalOpen = withTranslation()(ModalOpenInternal);
export default ModalOpen;
+295
View File
@@ -0,0 +1,295 @@
import React from 'react'
import latest from '@maplibre/maplibre-gl-style-spec/dist/latest.json'
import type {LightSpecification, StyleSpecification, TerrainSpecification, TransitionSpecification} from 'maplibre-gl'
import { WithTranslation, withTranslation } from 'react-i18next';
import FieldArray from '../FieldArray'
import FieldNumber from '../FieldNumber'
import FieldString from '../FieldString'
import FieldUrl from '../FieldUrl'
import FieldSelect from '../FieldSelect'
import FieldEnum from '../FieldEnum'
import FieldColor from '../FieldColor'
import Modal from './Modal'
import fieldSpecAdditional from '../../libs/field-spec-additional'
import type {OnStyleChangedCallback, StyleSpecificationWithId} from '../../libs/definitions';
type ModalSettingsInternalProps = {
mapStyle: StyleSpecificationWithId
onStyleChanged: OnStyleChangedCallback
onChangeMetadataProperty(...args: unknown[]): unknown
isOpen: boolean
onOpenToggle(...args: unknown[]): unknown
} & WithTranslation;
class ModalSettingsInternal extends React.Component<ModalSettingsInternalProps> {
changeTransitionProperty(property: keyof TransitionSpecification, value: number | undefined) {
const transition = {
...this.props.mapStyle.transition,
}
if (value === undefined) {
delete transition[property];
}
else {
transition[property] = value;
}
this.props.onStyleChanged({
...this.props.mapStyle,
transition,
});
}
changeLightProperty(property: keyof LightSpecification, value: any) {
const light = {
...this.props.mapStyle.light,
}
if (value === undefined) {
delete light[property];
}
else {
// @ts-ignore
light[property] = value;
}
this.props.onStyleChanged({
...this.props.mapStyle,
light,
});
}
changeTerrainProperty(property: keyof TerrainSpecification, value: any) {
const terrain = {
...this.props.mapStyle.terrain,
} as TerrainSpecification;
if (value === undefined) {
delete terrain[property];
}
else {
// @ts-ignore
terrain[property] = value;
}
this.props.onStyleChanged({
...this.props.mapStyle,
terrain,
});
}
changeStyleProperty(property: keyof StyleSpecification | "owner", value: any) {
const changedStyle = {
...this.props.mapStyle,
};
if (value === undefined) {
// @ts-ignore
delete changedStyle[property];
}
else {
// @ts-ignore
changedStyle[property] = value;
}
this.props.onStyleChanged(changedStyle);
}
render() {
const metadata = this.props.mapStyle.metadata || {} as any;
const {t, onChangeMetadataProperty, mapStyle} = this.props;
const fsa = fieldSpecAdditional(t);
const light = this.props.mapStyle.light || {};
const transition = this.props.mapStyle.transition || {};
const terrain = this.props.mapStyle.terrain || {} as TerrainSpecification;
return <Modal
data-wd-key="modal:settings"
isOpen={this.props.isOpen}
onOpenToggle={this.props.onOpenToggle}
title={t('Style Settings')}
>
<div className="modal:settings">
<FieldString
label={t("Name")}
fieldSpec={latest.$root.name}
data-wd-key="modal:settings.name"
value={this.props.mapStyle.name}
onChange={this.changeStyleProperty.bind(this, "name")}
/>
<FieldString
label={t("Owner")}
fieldSpec={{doc: t("Owner ID of the style. Used by Mapbox or future style APIs.")}}
data-wd-key="modal:settings.owner"
value={(this.props.mapStyle as any).owner}
onChange={this.changeStyleProperty.bind(this, "owner")}
/>
<FieldUrl
fieldSpec={latest.$root.sprite}
label={t("Sprite URL")}
data-wd-key="modal:settings.sprite"
value={this.props.mapStyle.sprite as string}
onChange={this.changeStyleProperty.bind(this, "sprite")}
/>
<FieldUrl
label={t("Glyphs URL")}
fieldSpec={latest.$root.glyphs}
data-wd-key="modal:settings.glyphs"
value={this.props.mapStyle.glyphs as string}
onChange={this.changeStyleProperty.bind(this, "glyphs")}
/>
<FieldString
label={fsa.maputnik.maptiler_access_token.label}
fieldSpec={fsa.maputnik.maptiler_access_token}
data-wd-key="modal:settings.maputnik:openmaptiles_access_token"
value={metadata['maputnik:openmaptiles_access_token']}
onChange={onChangeMetadataProperty.bind(this, "maputnik:openmaptiles_access_token")}
/>
<FieldString
label={fsa.maputnik.thunderforest_access_token.label}
fieldSpec={fsa.maputnik.thunderforest_access_token}
data-wd-key="modal:settings.maputnik:thunderforest_access_token"
value={metadata['maputnik:thunderforest_access_token']}
onChange={onChangeMetadataProperty.bind(this, "maputnik:thunderforest_access_token")}
/>
<FieldString
label={fsa.maputnik.stadia_access_token.label}
fieldSpec={fsa.maputnik.stadia_access_token}
data-wd-key="modal:settings.maputnik:stadia_access_token"
value={metadata['maputnik:stadia_access_token']}
onChange={onChangeMetadataProperty.bind(this, "maputnik:stadia_access_token")}
/>
<FieldString
label={fsa.maputnik.locationiq_access_token.label}
fieldSpec={fsa.maputnik.locationiq_access_token}
data-wd-key="modal:settings.maputnik:locationiq_access_token"
value={metadata['maputnik:locationiq_access_token']}
onChange={onChangeMetadataProperty.bind(this, "maputnik:locationiq_access_token")}
/>
<FieldArray
label={t("Center")}
fieldSpec={latest.$root.center}
length={2}
type="number"
value={mapStyle.center || []}
default={[0, 0]}
onChange={this.changeStyleProperty.bind(this, "center")}
/>
<FieldNumber
label={t("Zoom")}
fieldSpec={latest.$root.zoom}
value={mapStyle.zoom}
default={0}
onChange={this.changeStyleProperty.bind(this, "zoom")}
/>
<FieldNumber
label={t("Bearing")}
fieldSpec={latest.$root.bearing}
value={mapStyle.bearing}
default={latest.$root.bearing.default}
onChange={this.changeStyleProperty.bind(this, "bearing")}
/>
<FieldNumber
label={t("Pitch")}
fieldSpec={latest.$root.pitch}
value={mapStyle.pitch}
default={latest.$root.pitch.default}
onChange={this.changeStyleProperty.bind(this, "pitch")}
/>
<FieldEnum
label={t("Light anchor")}
fieldSpec={latest.light.anchor}
name="light-anchor"
value={light.anchor as string}
options={Object.keys(latest.light.anchor.values)}
default={latest.light.anchor.default}
onChange={this.changeLightProperty.bind(this, "anchor")}
/>
<FieldColor
label={t("Light color")}
fieldSpec={latest.light.color}
value={light.color as string}
default={latest.light.color.default}
onChange={this.changeLightProperty.bind(this, "color")}
/>
<FieldNumber
label={t("Light intensity")}
fieldSpec={latest.light.intensity}
value={light.intensity as number}
default={latest.light.intensity.default}
onChange={this.changeLightProperty.bind(this, "intensity")}
/>
<FieldArray
label={t("Light position")}
fieldSpec={latest.light.position}
type="number"
length={latest.light.position.length}
value={light.position as number[]}
default={latest.light.position.default}
onChange={this.changeLightProperty.bind(this, "position")}
/>
<FieldString
label={t("Terrain source")}
fieldSpec={latest.terrain.source}
data-wd-key="modal:settings.maputnik:terrain_source"
value={terrain.source}
onChange={this.changeTerrainProperty.bind(this, "source")}
/>
<FieldNumber
label={t("Terrain exaggeration")}
fieldSpec={latest.terrain.exaggeration}
value={terrain.exaggeration}
default={latest.terrain.exaggeration.default}
onChange={this.changeTerrainProperty.bind(this, "exaggeration")}
/>
<FieldNumber
label={t("Transition delay")}
fieldSpec={latest.transition.delay}
value={transition.delay}
default={latest.transition.delay.default}
onChange={this.changeTransitionProperty.bind(this, "delay")}
/>
<FieldNumber
label={t("Transition duration")}
fieldSpec={latest.transition.duration}
value={transition.duration}
default={latest.transition.duration.default}
onChange={this.changeTransitionProperty.bind(this, "duration")}
/>
<FieldSelect
label={fsa.maputnik.style_renderer.label}
fieldSpec={fsa.maputnik.style_renderer}
data-wd-key="modal:settings.maputnik:renderer"
options={[
['mlgljs', 'MapLibreGL JS'],
['ol', t('Open Layers (experimental)')],
]}
value={metadata['maputnik:renderer'] || 'mlgljs'}
onChange={onChangeMetadataProperty.bind(this, 'maputnik:renderer')}
/>
</div>
</Modal>
}
}
const ModalSettings = withTranslation()(ModalSettingsInternal)
export default ModalSettings;
+138
View File
@@ -0,0 +1,138 @@
import React from 'react'
import { Trans, WithTranslation, withTranslation } from 'react-i18next';
import Modal from './Modal'
type ModalShortcutsInternalProps = {
isOpen: boolean
onOpenToggle(...args: unknown[]): unknown
} & WithTranslation;
class ModalShortcutsInternal extends React.Component<ModalShortcutsInternalProps> {
render() {
const t = this.props.t;
const help = [
{
key: <kbd>?</kbd>,
text: t("Shortcuts menu")
},
{
key: <kbd>o</kbd>,
text: t("Open modal")
},
{
key: <kbd>e</kbd>,
text: t("Export modal")
},
{
key: <kbd>d</kbd>,
text: t("Data Sources modal")
},
{
key: <kbd>s</kbd>,
text: t("Style Settings modal")
},
{
key: <kbd>i</kbd>,
text: t("Toggle inspect")
},
{
key: <kbd>m</kbd>,
text: t("Focus map")
},
{
key: <kbd>!</kbd>,
text: t("Debug modal")
},
]
const mapShortcuts = [
{
key: <kbd>+</kbd>,
text: t("Increase the zoom level by 1.",)
},
{
key: <><kbd>Shift</kbd> + <kbd>+</kbd></>,
text: t("Increase the zoom level by 2.",)
},
{
key: <kbd>-</kbd>,
text: t("Decrease the zoom level by 1.",)
},
{
key: <><kbd>Shift</kbd> + <kbd>-</kbd></>,
text: t("Decrease the zoom level by 2.",)
},
{
key: <kbd>Up</kbd>,
text: t("Pan up by 100 pixels.",)
},
{
key: <kbd>Down</kbd>,
text: t("Pan down by 100 pixels.",)
},
{
key: <kbd>Left</kbd>,
text: t("Pan left by 100 pixels.",)
},
{
key: <kbd>Right</kbd>,
text: t("Pan right by 100 pixels.",)
},
{
key: <><kbd>Shift</kbd> + <kbd>Right</kbd></>,
text: t("Increase the rotation by 15 degrees.",)
},
{
key: <><kbd>Shift</kbd> + <kbd>Left</kbd></>,
text: t("Decrease the rotation by 15 degrees.")
},
{
key: <><kbd>Shift</kbd> + <kbd>Up</kbd></>,
text: t("Increase the pitch by 10 degrees.")
},
{
key: <><kbd>Shift</kbd> + <kbd>Down</kbd></>,
text: t("Decrease the pitch by 10 degrees.")
},
]
return <Modal
data-wd-key="modal:shortcuts"
isOpen={this.props.isOpen}
onOpenToggle={this.props.onOpenToggle}
title={t('Shortcuts')}
>
<section className="maputnik-modal-section maputnik-modal-shortcuts">
<p>
<Trans t={t}>
Press <code>ESC</code> to lose focus of any active elements, then press one of:
</Trans>
</p>
<dl>
{help.map((item, idx) => {
return <div key={idx} className="maputnik-modal-shortcuts__shortcut">
<dt key={"dt"+idx}>{item.key}</dt>
<dd key={"dd"+idx}>{item.text}</dd>
</div>
})}
</dl>
<p>{t("If the Map is in focused you can use the following shortcuts")}</p>
<ul>
{mapShortcuts.map((item, idx) => {
return <li key={idx}>
<span>{item.key}</span> {item.text}
</li>
})}
</ul>
</section>
</Modal>
}
}
const ModalShortcuts = withTranslation()(ModalShortcutsInternal);
export default ModalShortcuts;
+347
View File
@@ -0,0 +1,347 @@
import React from 'react'
import {MdAddCircleOutline, MdDelete} from 'react-icons/md'
import latest from '@maplibre/maplibre-gl-style-spec/dist/latest.json'
import type {GeoJSONSourceSpecification, RasterDEMSourceSpecification, RasterSourceSpecification, SourceSpecification, VectorSourceSpecification} from 'maplibre-gl'
import { WithTranslation, withTranslation } from 'react-i18next';
import Modal from './Modal'
import InputButton from '../InputButton'
import FieldString from '../FieldString'
import FieldSelect from '../FieldSelect'
import ModalSourcesTypeEditor, { EditorMode } from './ModalSourcesTypeEditor'
import style from '../../libs/style'
import { deleteSource, addSource, changeSource } from '../../libs/source'
import publicSources from '../../config/tilesets.json'
import { OnStyleChangedCallback, StyleSpecificationWithId } from '../../libs/definitions';
type PublicSourceProps = {
id: string
type: string
title: string
onSelect(...args: unknown[]): unknown
};
class PublicSource extends React.Component<PublicSourceProps> {
render() {
return <div className="maputnik-public-source">
<InputButton
className="maputnik-public-source-select"
onClick={() => this.props.onSelect(this.props.id)}
>
<div className="maputnik-public-source-info">
<p className="maputnik-public-source-name">{this.props.title}</p>
<p className="maputnik-public-source-id">#{this.props.id}</p>
</div>
<span className="maputnik-space" />
<MdAddCircleOutline />
</InputButton>
</div>
}
}
function editorMode(source: SourceSpecification) {
if(source.type === 'raster') {
if(source.tiles) return 'tile_raster'
return 'tilejson_raster'
}
if(source.type === 'raster-dem') {
if(source.tiles) return 'tilexyz_raster-dem'
return 'tilejson_raster-dem'
}
if(source.type === 'vector') {
if(source.tiles) return 'tile_vector'
if(source.url && source.url.startsWith("pmtiles://")) return 'pmtiles_vector'
return 'tilejson_vector'
}
if(source.type === 'geojson') {
if (typeof(source.data) === "string") {
return 'geojson_url';
}
else {
return 'geojson_json';
}
}
if(source.type === 'image') {
return 'image';
}
if(source.type === 'video') {
return 'video';
}
return null
}
type ActiveModalSourcesTypeEditorProps = {
sourceId: string
source: SourceSpecification
onDelete(...args: unknown[]): unknown
onChange(...args: unknown[]): unknown
} & WithTranslation;
class ActiveModalSourcesTypeEditor extends React.Component<ActiveModalSourcesTypeEditorProps> {
render() {
const t = this.props.t;
return <div className="maputnik-active-source-type-editor">
<div className="maputnik-active-source-type-editor-header">
<span className="maputnik-active-source-type-editor-header-id">#{this.props.sourceId}</span>
<span className="maputnik-space" />
<InputButton
aria-label={t("Remove '{{sourceId}}' source", {sourceId: this.props.sourceId})}
className="maputnik-active-source-type-editor-header-delete"
onClick={()=> this.props.onDelete(this.props.sourceId)}
style={{backgroundColor: 'transparent'}}
>
<MdDelete />
</InputButton>
</div>
<div className="maputnik-active-source-type-editor-content">
<ModalSourcesTypeEditor
onChange={this.props.onChange}
mode={editorMode(this.props.source)}
source={this.props.source}
/>
</div>
</div>
}
}
type AddSourceProps = {
onAdd(...args: unknown[]): unknown
} & WithTranslation;
type AddSourceState = {
mode: EditorMode
sourceId: string
source: SourceSpecification
};
class AddSource extends React.Component<AddSourceProps, AddSourceState> {
constructor(props: AddSourceProps) {
super(props)
this.state = {
mode: 'tilejson_vector',
sourceId: style.generateId(),
source: this.defaultSource('tilejson_vector'),
}
}
defaultSource(mode: EditorMode): SourceSpecification {
const source = (this.state || {}).source || {}
const {protocol} = window.location;
switch(mode) {
case 'pmtiles_vector': return {
type: 'vector',
url: `${protocol}//localhost:3000/file.pmtiles`
}
case 'geojson_url': return {
type: 'geojson',
data: `${protocol}//localhost:3000/geojson.json`
}
case 'geojson_json': return {
type: 'geojson',
cluster: (source as GeoJSONSourceSpecification).cluster || false,
data: ''
}
case 'tilejson_vector': return {
type: 'vector',
url: (source as VectorSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json`
}
case 'tile_vector': return {
type: 'vector',
tiles: (source as VectorSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.pbf`],
minzoom: (source as VectorSourceSpecification).minzoom || 0,
maxzoom: (source as VectorSourceSpecification).maxzoom || 14,
scheme: (source as VectorSourceSpecification).scheme || 'xyz'
}
case 'tilejson_raster': return {
type: 'raster',
url: (source as RasterSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json`
}
case 'tile_raster': return {
type: 'raster',
tiles: (source as RasterSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.png`],
minzoom: (source as RasterSourceSpecification).minzoom || 0,
maxzoom: (source as RasterSourceSpecification).maxzoom || 14,
scheme: (source as RasterSourceSpecification).scheme || 'xyz',
tileSize: (source as RasterSourceSpecification).tileSize || 512,
}
case 'tilejson_raster-dem': return {
type: 'raster-dem',
url: (source as RasterDEMSourceSpecification).url || `${protocol}//localhost:3000/tilejson.json`
}
case 'tilexyz_raster-dem': return {
type: 'raster-dem',
tiles: (source as RasterDEMSourceSpecification).tiles || [`${protocol}//localhost:3000/{x}/{y}/{z}.png`],
minzoom: (source as RasterDEMSourceSpecification).minzoom || 0,
maxzoom: (source as RasterDEMSourceSpecification).maxzoom || 14,
tileSize: (source as RasterDEMSourceSpecification).tileSize || 512
}
case 'image': return {
type: 'image',
url: `${protocol}//localhost:3000/image.png`,
coordinates: [
[0,0],
[0,0],
[0,0],
[0,0],
],
}
case 'video': return {
type: 'video',
urls: [
`${protocol}//localhost:3000/movie.mp4`
],
coordinates: [
[0,0],
[0,0],
[0,0],
[0,0],
],
}
default: return {} as any
}
}
onAdd = () => {
const {source, sourceId} = this.state;
this.props.onAdd(sourceId, source);
}
onChangeSource = (source: SourceSpecification) => {
this.setState({source});
}
render() {
const t = this.props.t;
// Kind of a hack because the type changes, however maputnik has 1..n
// options per type, for example
//
// - 'geojson' - 'GeoJSON (URL)' and 'GeoJSON (JSON)'
// - 'raster' - 'Raster (TileJSON URL)' and 'Raster (XYZ URL)'
//
// So we just ignore the values entirely as they are self explanatory
const sourceTypeFieldSpec = {
doc: latest.source_vector.type.doc
};
return <div className="maputnik-add-source">
<FieldString
label={t("Source ID")}
fieldSpec={{doc: t("Unique ID that identifies the source and is used in the layer to reference the source.")}}
value={this.state.sourceId}
onChange={(v: string) => this.setState({ sourceId: v})}
data-wd-key="modal:sources.add.source_id"
/>
<FieldSelect
label={t("Source Type")}
fieldSpec={sourceTypeFieldSpec}
options={[
['geojson_json', t('GeoJSON (JSON)')],
['geojson_url', t('GeoJSON (URL)')],
['tilejson_vector', t('Vector (TileJSON URL)')],
['tile_vector', t('Vector (Tile URLs)')],
['tilejson_raster', t('Raster (TileJSON URL)')],
['tile_raster', t('Raster (Tile URLs)')],
['tilejson_raster-dem', t('Raster DEM (TileJSON URL)')],
['tilexyz_raster-dem', t('Raster DEM (XYZ URLs)')],
['pmtiles_vector', t('Vector (PMTiles)')],
['image', t('Image')],
['video', t('Video')],
]}
onChange={mode => this.setState({mode: mode as EditorMode, source: this.defaultSource(mode as EditorMode)})}
value={this.state.mode as string}
data-wd-key="modal:sources.add.source_type"
/>
<ModalSourcesTypeEditor
onChange={this.onChangeSource}
mode={this.state.mode}
source={this.state.source}
/>
<InputButton
className="maputnik-add-source-button"
onClick={this.onAdd}
data-wd-key="modal:sources.add.add_source"
>
{t("Add Source")}
</InputButton>
</div>
}
}
type ModalSourcesInternalProps = {
mapStyle: StyleSpecificationWithId
isOpen: boolean
onOpenToggle(...args: unknown[]): unknown
onStyleChanged: OnStyleChangedCallback
} & WithTranslation;
class ModalSourcesInternal extends React.Component<ModalSourcesInternalProps> {
stripTitle(source: SourceSpecification & {title?: string}): SourceSpecification {
const strippedSource = {...source}
delete strippedSource['title']
return strippedSource
}
render() {
const {t, mapStyle} = this.props;
const i18nProps = {t, i18n: this.props.i18n, tReady: this.props.tReady};
const activeSources = Object.keys(mapStyle.sources).map(sourceId => {
const source = mapStyle.sources[sourceId]
return <ActiveModalSourcesTypeEditor
key={sourceId}
sourceId={sourceId}
source={source}
onChange={(src: SourceSpecification) => this.props.onStyleChanged(changeSource(mapStyle, sourceId, src))}
onDelete={() => this.props.onStyleChanged(deleteSource(mapStyle, sourceId))}
{...i18nProps}
/>
})
const tilesetOptions = Object.keys(publicSources).filter((sourceId: string) => !(sourceId in mapStyle.sources)).map((sourceId: string) => {
const source = publicSources[sourceId as keyof typeof publicSources] as SourceSpecification & {title: string};
return <PublicSource
key={sourceId}
id={sourceId}
type={source.type}
title={source.title}
onSelect={() => this.props.onStyleChanged(addSource(mapStyle, sourceId, this.stripTitle(source)))}
/>
})
return <Modal
data-wd-key="modal:sources"
isOpen={this.props.isOpen}
onOpenToggle={this.props.onOpenToggle}
title={t('Sources')}
>
<section className="maputnik-modal-section">
<h1>{t("Active Sources")}</h1>
{activeSources}
</section>
<section className="maputnik-modal-section">
<h1>{t("Choose Public Source")}</h1>
<p>
{t("Add one of the publicly available sources to your style.")}
</p>
<div className="maputnik-public-sources" style={{maxWidth: 500}}>
{tilesetOptions}
</div>
</section>
<section className="maputnik-modal-section">
<h1>{t("Add New Source")}</h1>
<p>{t("Add a new source to your style. You can only choose the source type and id at creation time!")}</p>
<AddSource
onAdd={(sourceId: string, source: SourceSpecification) => this.props.onStyleChanged(addSource(mapStyle, sourceId, source))}
{...i18nProps}
/>
</section>
</Modal>
}
}
const ModalSources = withTranslation()(ModalSourcesInternal);
export default ModalSources;
@@ -0,0 +1,384 @@
import React from 'react'
import {latest} from '@maplibre/maplibre-gl-style-spec'
import { WithTranslation, withTranslation } from 'react-i18next';
import { TFunction } from 'i18next'
import Block from '../Block'
import FieldUrl from '../FieldUrl'
import FieldNumber from '../FieldNumber'
import FieldSelect from '../FieldSelect'
import FieldDynamicArray from '../FieldDynamicArray'
import FieldArray from '../FieldArray'
import FieldJson from '../FieldJson'
import FieldCheckbox from '../FieldCheckbox'
export type EditorMode = "video" | "image" | "tilejson_vector" | "tile_raster" | "tilejson_raster" | "tilexyz_raster-dem" | "tilejson_raster-dem" | "pmtiles_vector" | "tile_vector" | "geojson_url" | "geojson_json" | null;
type TileJSONSourceEditorProps = {
source: {
url: string
}
onChange(...args: unknown[]): unknown
children?: React.ReactNode
} & WithTranslation;
class TileJSONSourceEditor extends React.Component<TileJSONSourceEditorProps> {
render() {
const t = this.props.t;
return <div>
<FieldUrl
label={t("TileJSON URL")}
fieldSpec={latest.source_vector.url}
value={this.props.source.url}
onChange={url => this.props.onChange({
...this.props.source,
url: url
})}
/>
{this.props.children}
</div>
}
}
type TileURLSourceEditorProps = {
source: {
tiles: string[]
minzoom: number
maxzoom: number
scheme: 'xyz' | 'tms'
}
onChange(...args: unknown[]): unknown
children?: React.ReactNode
} & WithTranslation;
class TileURLSourceEditor extends React.Component<TileURLSourceEditorProps> {
changeTileUrls(tiles: string[]) {
this.props.onChange({
...this.props.source,
tiles,
})
}
renderTileUrls() {
const tiles = this.props.source.tiles || [];
return <FieldDynamicArray
label={this.props.t("Tile URL")}
fieldSpec={latest.source_vector.tiles}
type="url"
value={tiles}
onChange={this.changeTileUrls.bind(this)}
/>
}
render() {
const t = this.props.t;
return <div>
{this.renderTileUrls()}
<FieldSelect
label={t("Scheme Type")}
fieldSpec={latest.source_vector.scheme}
options={[
['xyz', 'xyz (Slippy map tilenames scheme)'],
['tms', 'tms (OSGeo spec scheme)'],
]}
onChange={scheme => this.props.onChange({
...this.props.source,
scheme
})}
value={this.props.source.scheme}
data-wd-key="modal:sources.add.scheme_type"
/>
<FieldNumber
label={t("Min Zoom")}
fieldSpec={latest.source_vector.minzoom}
value={this.props.source.minzoom || 0}
onChange={minzoom => this.props.onChange({
...this.props.source,
minzoom: minzoom
})}
/>
<FieldNumber
label={t("Max Zoom")}
fieldSpec={latest.source_vector.maxzoom}
value={this.props.source.maxzoom || 22}
onChange={maxzoom => this.props.onChange({
...this.props.source,
maxzoom: maxzoom
})}
/>
{this.props.children}
</div>
}
}
const createCornerLabels: (t: TFunction) => { label: string, key: string }[] = (t) => ([
{ label: t("Coord top left"), key: "top left" },
{ label: t("Coord top right"), key: "top right" },
{ label: t("Coord bottom right"), key: "bottom right" },
{ label: t("Coord bottom left"), key: "bottom left" },
]);
type ImageSourceEditorProps = {
source: {
coordinates: [number, number][]
url: string
}
onChange(...args: unknown[]): unknown
} & WithTranslation;
class ImageSourceEditor extends React.Component<ImageSourceEditorProps> {
render() {
const t = this.props.t;
const changeCoord = (idx: number, val: [number, number]) => {
const coordinates = this.props.source.coordinates.slice(0);
coordinates[idx] = val;
this.props.onChange({
...this.props.source,
coordinates,
});
}
return <div>
<FieldUrl
label={t("Image URL")}
fieldSpec={latest.source_image.url}
value={this.props.source.url}
onChange={url => this.props.onChange({
...this.props.source,
url,
})}
/>
{createCornerLabels(t).map(({label, key}, idx) => {
return (
<FieldArray
label={label}
key={key}
length={2}
type="number"
value={this.props.source.coordinates[idx]}
default={[0, 0]}
onChange={(val: [number, number]) => changeCoord(idx, val)}
/>
);
})}
</div>
}
}
type VideoSourceEditorProps = {
source: {
coordinates: [number, number][]
urls: string[]
}
onChange(...args: unknown[]): unknown
} & WithTranslation;
class VideoSourceEditor extends React.Component<VideoSourceEditorProps> {
render() {
const t = this.props.t;
const changeCoord = (idx: number, val: [number, number]) => {
const coordinates = this.props.source.coordinates.slice(0);
coordinates[idx] = val;
this.props.onChange({
...this.props.source,
coordinates,
});
}
const changeUrls = (urls: string[]) => {
this.props.onChange({
...this.props.source,
urls,
});
}
return <div>
<FieldDynamicArray
label={t("Video URL")}
fieldSpec={latest.source_video.urls}
type="string"
value={this.props.source.urls}
default={[]}
onChange={changeUrls}
/>
{createCornerLabels(t).map(({label, key}, idx) => {
return (
<FieldArray
label={label}
key={key}
length={2}
type="number"
value={this.props.source.coordinates[idx]}
default={[0, 0]}
onChange={(val: [number, number]) => changeCoord(idx, val)}
/>
);
})}
</div>
}
}
type GeoJSONSourceUrlEditorProps = {
source: {
data: string
}
onChange(...args: unknown[]): unknown
} & WithTranslation;
class GeoJSONSourceUrlEditor extends React.Component<GeoJSONSourceUrlEditorProps> {
render() {
const t = this.props.t;
return <FieldUrl
label={t("GeoJSON URL")}
fieldSpec={latest.source_geojson.data}
value={this.props.source.data}
onChange={data => this.props.onChange({
...this.props.source,
data: data
})}
/>
}
}
type GeoJSONSourceFieldJsonEditorProps = {
source: {
data: any,
cluster: boolean
}
onChange(...args: unknown[]): unknown
} & WithTranslation;
class GeoJSONSourceFieldJsonEditor extends React.Component<GeoJSONSourceFieldJsonEditorProps> {
render() {
const t = this.props.t;
return <div>
<Block label={t("GeoJSON")} fieldSpec={latest.source_geojson.data}>
<FieldJson
layer={this.props.source.data}
maxHeight={200}
mode={{
name: "javascript",
json: true
}}
lint={true}
onChange={data => {
this.props.onChange({
...this.props.source,
data,
})
}}
/>
</Block>
<FieldCheckbox
label={t('Cluster')}
value={this.props.source.cluster}
onChange={cluster => {
this.props.onChange({
...this.props.source,
cluster: cluster,
})
}}
/>
</div>
}
}
type PMTilesSourceEditorProps = {
source: {
url: string
}
onChange(...args: unknown[]): unknown
children?: React.ReactNode
} & WithTranslation;
class PMTilesSourceEditor extends React.Component<PMTilesSourceEditorProps> {
render() {
const t = this.props.t;
return <div>
<FieldUrl
label={t("PMTiles URL")}
fieldSpec={latest.source_vector.url}
value={this.props.source.url}
data-wd-key="modal:sources.add.source_url"
onChange={(url: string) => this.props.onChange({
...this.props.source,
url: url.startsWith("pmtiles://") ? url : `pmtiles://${url}`
})}
/>
{this.props.children}
</div>
}
}
type ModalSourcesTypeEditorInternalProps = {
mode: EditorMode
source: any
onChange(...args: unknown[]): unknown
} & WithTranslation;
class ModalSourcesTypeEditorInternal extends React.Component<ModalSourcesTypeEditorInternalProps> {
render() {
const t = this.props.t;
const commonProps = {
source: this.props.source,
onChange: this.props.onChange,
t: this.props.t,
i18n: this.props.i18n,
tReady: this.props.tReady,
};
switch(this.props.mode) {
case 'geojson_url': return <GeoJSONSourceUrlEditor {...commonProps} />
case 'geojson_json': return <GeoJSONSourceFieldJsonEditor {...commonProps} />
case 'tilejson_vector': return <TileJSONSourceEditor {...commonProps} />
case 'tile_vector': return <TileURLSourceEditor {...commonProps} />
case 'tilejson_raster': return <TileJSONSourceEditor {...commonProps} />
case 'tile_raster': return <TileURLSourceEditor {...commonProps}>
<FieldNumber
label={t("Tile Size")}
fieldSpec={latest.source_raster.tileSize}
onChange={tileSize => this.props.onChange({
...this.props.source,
tileSize: tileSize
})}
value={this.props.source.tileSize || latest.source_raster.tileSize.default}
data-wd-key="modal:sources.add.tile_size"
/>
</TileURLSourceEditor>
case 'tilejson_raster-dem': return <TileJSONSourceEditor {...commonProps} />
case 'tilexyz_raster-dem': return <TileURLSourceEditor {...commonProps}>
<FieldNumber
label={t("Tile Size")}
fieldSpec={latest.source_raster_dem.tileSize}
onChange={tileSize => this.props.onChange({
...this.props.source,
tileSize: tileSize
})}
value={this.props.source.tileSize || latest.source_raster_dem.tileSize.default}
data-wd-key="modal:sources.add.tile_size"
/>
<FieldSelect
label={t("Encoding")}
fieldSpec={latest.source_raster_dem.encoding}
options={Object.keys(latest.source_raster_dem.encoding.values)}
onChange={encoding => this.props.onChange({
...this.props.source,
encoding: encoding
})}
value={this.props.source.encoding || latest.source_raster_dem.encoding.default}
/>
</TileURLSourceEditor>
case 'pmtiles_vector': return <PMTilesSourceEditor {...commonProps} />
case 'image': return <ImageSourceEditor {...commonProps} />
case 'video': return <VideoSourceEditor {...commonProps} />
default: return null
}
}
}
const ModalSourcesTypeEditor = withTranslation()(ModalSourcesTypeEditorInternal);
export default ModalSourcesTypeEditor;