mapbox-gl
Version:
A WebGL interactive maps library
403 lines (330 loc) • 12.8 kB
JavaScript
// @flow
import {Pane} from 'tweakpane';
import cloneDeep from 'lodash.clonedeep';
import serialize from 'serialize-to-js';
import assert from 'assert';
import {isWorker} from '../../../src/util/util.js';
import type {default as MapboxMap} from '../../../src/ui/map.js';
if (!isWorker()) {
const style = document.createElement('style');
style.innerHTML = `
.mapbox-devtools::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.mapbox-devtools::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.2);
border-radius: 10px;
}
.mapbox-devtools::-webkit-scrollbar-thumb {
background: rgba(110, 110, 110);
border-radius: 10px;
}
.mapbox-devtools::-webkit-scrollbar-thumb:hover {
background-color: rgba(90, 90, 90);
}
.mapboxgl-ctrl.mapbox-devtools {
max-height: 75vh;
overflow-y: auto;
}
.mapboxgl-ctrl.mapbox-devtools button.tp-btnv_b:hover {
background-color: var(--btn-bg-h);
}
`;
if (document.head) {
document.head.appendChild(style);
}
}
function deserialize(serialized: string): Object {
return [eval][0](`(${serialized})`);
}
// Serializable folder state
class FolderState {
isFolded: boolean;
current: Object;
constructor() {
this.isFolded = false;
this.current = {};
}
}
// Serializable whole debug pane state
class PaneState {
isMainPaneShown: boolean;
folders: Map<string, FolderState>;
constructor() {
this.isMainPaneShown = false;
this.folders = new Map <string, FolderState>();
}
}
function mergePaneParams(dest: PaneState, src: PaneState) {
dest.isMainPaneShown = src.isMainPaneShown;
const mergedFolderKeys = [...new Set([...src.folders.keys(), ...dest.folders.keys()])];
for (const key of mergedFolderKeys) {
const srcFolder = src.folders.get(key);
const destFolder = dest.folders.get(key);
if (srcFolder && destFolder) {
for (const parameterKey of Object.keys(srcFolder.current)) {
destFolder.current[parameterKey] = cloneDeep(srcFolder.current[parameterKey]);
}
destFolder.isFolded = srcFolder.isFolded;
} else if (srcFolder) {
dest.folders.set(key, srcFolder);
}
}
}
function deSerializePaneParams(input: ?string): PaneState {
let obj = {};
if (input) {
try {
obj = deserialize(input);
} catch (err) {
console.log(`Tracked parameters deserialization error: ${err}`);
}
}
const p = new PaneState();
// Replace properties if present
if ('isMainPaneShown' in obj) {
p.isMainPaneShown = obj.isMainPaneShown;
}
if ('folders' in obj) {
if (obj.folders instanceof Map) {
obj.folders.forEach((it, key) => {
const f = new FolderState();
if (`isFolded` in it) {
f.isFolded = it.isFolded;
}
if (`current` in it && it.current instanceof Object) {
f.current = cloneDeep(it.current);
}
p.folders.set(key, f);
});
}
}
return p;
}
// For fast prototyping in case of only one map present
let global: ?TrackedParameters;
export function registerParameter(object: Object, scope: Array<string>, name: string, description: Object, onChange: Function) {
if (global) {
global.registerParameter(object, scope, name, description, onChange);
console.warn(`Dev only "registerParameter" call. For production consider replacing with tracked parameters container method.`);
}
}
export function registerButton(scope: Array<string>, buttonTitle: string, onClick: Function) {
if (global) {
global.registerButton(scope, buttonTitle, onClick);
console.warn(`Dev only "registerButton" call. For production consider replacing with tracked parameters container method.`);
}
}
// Reference to actual object and default values
class ParameterInfo {
containerObject: Object;
parameterName: string;
defaultValue: any;
constructor(object: Object, parameterName: string, defaultValue: any) {
this.containerObject = object;
this.parameterName = parameterName;
this.defaultValue = defaultValue;
}
}
// Tracked parameters container
export class TrackedParameters {
_map: MapboxMap;
_container: HTMLElement;
// All TweakPane scopes
_folders: Map<string, any>;
// For (de)serialization
_paneState: PaneState;
// Store container object reference for each parameter
// Key = Scopes + parameter name
_parametersInfo: Map<string, ParameterInfo>;
_storageName: string;
constructor(map: MapboxMap) {
this._map = map;
this._folders = new Map <string, any>();
const id = map._getMapId();
const url = new URL(window.location.pathname, window.location.href).toString();
this._storageName = `TP_${id}_${url}`;
this._parametersInfo = new Map<string, ParameterInfo>();
this.initPane();
// Keep global reference, making it possible to register parameters
// without passing reference to TrackedParameters class
// Needed purely for dev purposes where only one map is present
global = this;
}
// Serialize pane state and write it to local storage
dump() {
const serialized = serialize(this._paneState);
localStorage.setItem(this._storageName, serialized);
}
resetToDefaults() {
this._parametersInfo.forEach((elem) => {
elem.containerObject[elem.parameterName] = elem.defaultValue;
});
this._folders.forEach((folder) => {
folder.expanded = true;
folder.refresh();
});
}
saveParameters() {
if (!("showSaveFilePicker" in window)) {
alert("File System Access API not supported, consider switching to recent versions of Chrome");
return;
}
const opts = {
types: [
{
description: "Parameters file",
accept: {"text/plain": [".params"]}
},
],
};
window.showSaveFilePicker(opts).then((fileHandle) => {
return fileHandle.createWritable();
}).then((writable) => {
const serialized = serialize(this._paneState);
return Promise.all([writable, writable.write(serialized)]);
}).then(([writable, _]) => {
writable.close();
}).catch((err) => {
console.error(err);
});
}
loadParameters() {
if (!("showSaveFilePicker" in window)) {
alert("File System Access API not supported, consider switching to recent versions of chrome");
return;
}
const opts = {
types: [
{
description: "Parameters file",
accept: {"text/plain": [".params"]}
},
],
};
window.showOpenFilePicker(opts).then((fileHandles) => {
return fileHandles[0].getFile();
}).then((file) => {
return file.text();
}).then((fileData) => {
const loadedPaneState = deSerializePaneParams(fileData);
mergePaneParams(this._paneState, loadedPaneState);
this._paneState.folders.forEach((folder, folderKey) => {
for (const [parameterKey, value] of Object.entries(folder.current)) {
const fullParameterName = `${folderKey}|${parameterKey}`;
const paramInfo = this._parametersInfo.get(fullParameterName);
if (paramInfo) {
paramInfo.containerObject[parameterKey] = cloneDeep(value);
}
}
const tpFolder = this._folders.get(folderKey);
if (tpFolder) {
tpFolder.expanded = !folder.isFolded;
}
});
this._folders.forEach((folder) => {
folder.refresh();
});
}).catch((err) => {
console.error(err);
});
}
initPane() {
// Load state
const serializedPaneState = localStorage.getItem(this._storageName);
this._paneState = deSerializePaneParams(serializedPaneState);
// Create containers for UI elements
this._container = window.document.createElement('div');
this._container.className = 'mapboxgl-ctrl mapbox-devtools';
const positionContainer = this._map._controlPositions['top-right'];
positionContainer.appendChild(this._container);
const pane = new Pane({
container: this._container,
expanded: this._paneState.isMainPaneShown,
title: 'devtools',
});
pane.on('fold', (e) => {
this._paneState.isMainPaneShown = e.expanded;
this.dump();
});
const resetToDefaultsButton = pane.addButton({
title: 'Reset To Defaults'
});
resetToDefaultsButton.on('click', () => {
this.resetToDefaults();
});
const saveButton = pane.addButton({
title: 'Save'
});
saveButton.on('click', () => {
this.saveParameters();
});
const loadButton = pane.addButton({
title: 'Load'
});
loadButton.on('click', () => {
this.loadParameters();
});
this._folders.set("_", pane);
}
createFoldersChainAndSelectScope(scope: Array<string>): {currentScope: any, fullScopeName: string } {
assert(scope.length >= 1);
// Iterate/create panes
let currentScope: any = this._folders.get("_");
let fullScopeName = "_";
for (let i = 0; i < scope.length; ++i) {
fullScopeName = scope.slice(0, i + 1).reduce((prev, cur) => { return `${prev}_${cur}`; }, "_");
if (this._folders.has(fullScopeName)) {
currentScope = this._folders.get(fullScopeName);
} else {
const folder = currentScope.addFolder({
title: scope[i],
expanded: true,
});
this._folders.set(fullScopeName, folder);
currentScope = folder;
if (!this._paneState.folders.has(fullScopeName)) {
const folderObj = new FolderState();
this._paneState.folders.set(fullScopeName, folderObj);
}
const folderObj: FolderState = (this._paneState.folders.get(fullScopeName): any);
currentScope.expanded = !folderObj.isFolded;
currentScope.on('fold', (ev) => {
folderObj.isFolded = !ev.expanded;
this.dump();
});
}
}
return {currentScope, fullScopeName};
}
registerParameter(containerObject: Object, scope: Array<string>, name: string, description: ?Object, changeValueCallback: ?Function) {
const {currentScope, fullScopeName} = this.createFoldersChainAndSelectScope(scope);
const folderObj: FolderState = (this._paneState.folders.get(fullScopeName): any);
// Full parameter name with scope prefix
const fullParameterName = `${fullScopeName}|${name}`;
if (!this._parametersInfo.has(fullParameterName)) {
this._parametersInfo.set(fullParameterName, new ParameterInfo(containerObject, name, cloneDeep(containerObject[name])));
}
if (folderObj.current.hasOwnProperty(name)) {
containerObject[name] = cloneDeep(folderObj.current[name]);
} else {
folderObj.current[name] = cloneDeep(containerObject[name]);
}
// Create binding to TweakPane UI
const binding = currentScope.addBinding(containerObject, name, description);
binding.on('change', (ev) => {
folderObj.current[name] = cloneDeep(ev.value);
this.dump();
if (changeValueCallback) { changeValueCallback(ev.value); }
});
}
registerButton(scope: Array<string>, buttonTitle: string, onClick: Function) {
const {currentScope} = this.createFoldersChainAndSelectScope(scope);
// Add button to TweakPane UI
const button = currentScope.addButton({title: buttonTitle});
button.on('click', () => {
onClick();
});
}
}