/**
# This file part of: VisiOmatic
* @file Base User Interface for VisiOmatic dialogs.
* @requires util/VUtil.js
* @requires control/widget/FlipSwitch.js
* @requires control/widget/Spinbox.js
* @copyright (c) 2014-2023 CNRS/IAP/CFHT/SorbonneU
* @author Emmanuel Bertin <bertin@cfht.hawaii.edu>
*/
import {
Browser,
Control,
DomEvent,
DomUtil,
Util
} from 'leaflet';
import Spectrum from 'spectrum-vanilla';
import {FlipSwitch, Spinbox} from './widget';
import {VUtil} from '../util';
// Callback definitions
/**
* Callbacks for UI control changes.
* @callback UI~controlCallback
* @param {object} UI - control object.
*/
/**
* Callbacks for color picker changes.
* @callback UI~colorCallback
*/
/**
* Callbacks for layer attribute changes.
* @callback UI~layerCallback
* @param {VTileLayer} layer - VisiOmatic layer.
*/
export const UI = Control.extend( /** @lends UI */ {
options: {
title: 'a control related to VisiOmatic',
collapsed: true,
position: 'topleft'
},
/**
* Base class for VisiOmatic dialog controls.
* @extends leaflet.Control
* @memberof module:control/UI.js
* @constructs
* @param {VTileLayer[]} baseLayers - Array of layers
* @param {object} [options] - Options.
* @param {string} [options.title='a control related to VisiOmatic']
Layer title. Defaults to the basename of the tile URL with extension
removed.
* @param {boolean} [options.collapsed=true]
Start dialog in the collapsed state?
* @param {boolean} [options.position='topleft']
Position of the dialog on the map.
* @see Leaflet API reference]{@link https://leafletjs.com/reference.html#control}
* @returns {UI} VisiOmatic UI instance.
*/
initialize: function (baseLayers, options) {
Util.setOptions(this, options);
this._className = 'visiomatic-control';
this._id = 'visiomatic-image';
this._layers = baseLayers;
},
/**
* Add the control to the map or to a sidebar.
* @override
* @param {object} dest - Destination map or sidebar.
* @returns {object} Destination object.
* @listens layeradd
*/
addTo: function (dest) {
if (dest._sidebar) {
this._sidebar = dest;
// dest is a sidebar class instance
this._map = dest._map;
this._dialog = DomUtil.create('div', this._className + '-dialog');
dest.addTab(
this._id,
this._className,
this.options.title,
this._dialog,
this._sideClass
);
this._map.on('layeradd', this._checkVisiomatic, this);
return dest;
} else {
return Control.prototype.addTo.call(this, dest);
}
},
/**
* Add the control dialog directly to the map.
* @param {object} map - Leaflet map the control has been added to.
* @returns {object} The newly created container of the dialog.
*/
onAdd: function (map) {
const className = this._className,
id = this._id,
container = this._container = DomUtil.create(
'div',
className + ' leaflet-bar'
);
// Makes this work on IE10 Touch devices by stopping it
// from firing a mouseout event when the touch is released
container.setAttribute('aria-haspopup', true);
DomEvent
.disableClickPropagation(container)
.disableScrollPropagation(container);
this._dialog = DomUtil.create('div', className + '-dialog', container);
if (this.options.collapsed) {
if (!Browser.android) {
DomEvent
.on(container, 'mouseover', this._expand, this)
.on(container, 'mouseout', this._collapse, this);
}
const toggle = this._toggle = DomUtil.create(
'a',
className + '-toggle leaflet-bar',
container
);
toggle.href = '#';
toggle.id = id + '-toggle';
toggle.title = this.options.title;
if (Browser.touch) {
DomEvent
.on(toggle, 'click', DomEvent.stop, this)
.on(toggle, 'click', this._expand, this);
}
else {
DomEvent.on(toggle, 'focus', this._expand, this);
}
this._map.on('click', this._collapse, this);
// TODO keyboard accessibility
} else {
this._expand();
}
// this._checkVisiomatic();
this._map.on('layeradd', this._checkVisiomatic, this);
return this._container;
},
/**
* Check that the layer being loaded is a VisiOmatic layer.
* @private
* @param {leaflet.LayerEvent} e - Leaflet layer event object.
*/
_checkVisiomatic: function (e) {
const layer = e.layer;
// Exit if not a VisiOmatic layer
if (!layer || !layer.visioDefault) {
return;
}
this._layer = layer;
if (this._reloadFlag) {
layer.once('load', this._resetDialog, this);
} else {
this._initDialog();
this._reloadFlag = true;
}
},
/**
* Initialize the UI dialog (dummy in the base class, just a placeholder).
* @private
*/
_initDialog: function () {
/*
const className = this._className,
container = this._container,
dialog = this._dialog,
toggle = this._toggle,
layer = this._layer;
dialog.innerHTML = '';
*/
// Setup the rest of the dialog window here
},
/**
* Reset the UI dialog.
* @private
*/
_resetDialog: function () {
this._dialog.innerHTML = '';
this._initDialog();
},
/**
* Add a new dialog box to the UI.
* @private
* @param {string} [id] - DOM id property of the box element.
* @returns {object} The newly created dialog box.
*/
_addDialogBox: function (id) {
const box = DomUtil.create(
'div',
this._className + '-box',
this._dialog
);
if (id) {
box.id = id;
}
return box;
},
/**
* Add a new dialog line to the provided dialog box.
* @private
* @param {string} label - Default text in the dialog line.
* @param {object} dialogBox - The destination dialog box.
* @returns {object} The newly created dialog line.
*/
_addDialogLine: function (label, dialogBox) {
const line = DomUtil.create(
'div',
this._className + '-line',
dialogBox
),
text = DomUtil.create('div', this._className + '-label', line);
text.innerHTML = label;
return line;
},
/**
* Add a new dialog element to the provided dialog line.
* @private
* @param {object} line - The destination dialog line.
* @returns {object} The newly created dialog element.
*/
_addDialogElement: function (line) {
return DomUtil.create('div', this._className + '-element', line);
},
/**
* Expand a DOM element (by adding '-expanded' to its class name).
* @private
*/
_expand: function () {
DomUtil.addClass(this._container, this._className + '-expanded');
},
/**
* Collapse a DOM element (by removing '-expanded' from its class name).
* @private
*/
_collapse: function () {
this._container.className = this._container.className.replace(
' ' + this._className + '-expanded',
''
);
},
/**
* Get the base layer currently active on the map.
* @returns {object} Tile- or overlay layer.
*/
getActiveBaseLayer: function () {
return this._activeBaseLayer;
},
/**
* Find the base VisiOmatic layer currently active on the map.
* @returns {object} The active VisiOmatic layer, or `undefined` otherwise.
*/
_findActiveBaseLayer: function () {
const layers = this._layers;
this._prelayer = undefined;
for (var l in layers) {
var layer = layers[l];
if (!layer.overlay) {
if (!layer._map) {
this._prelayer = layer;
} else if (this._map.hasLayer(layer) && layer.visioDefault) {
return layer;
}
}
}
return undefined;
},
/**
* Add a new button to the provided parent element.
* @private
* @param {string} className
Class name for the button.
* @param {object} parent
The parent element.
* @param {string} [subClassName]
Sub-class name for the button (will be combined with ClassName to
generate a unique element id).
* @param {string} [title]
Title of the button (for, e.g., display as a tooltip).
* @param {UI~controlCallback} [fn]
Callback function for when the button is pressed.
* @returns {object} The newly created button.
*/
_addButton: function (
className,
parent,
subClassName=undefined,
title=undefined,
fn=undefined
) {
const button = DomUtil.create('div', className, parent),
icon = DomUtil.create('div', className + '-icon', button);
button.target = '_blank';
if (subClassName) {
button.id = className + '-' + subClassName;
}
if (fn) {
DomEvent.on(button, 'click touch', fn, this);
}
if (title) {
button.title = title;
}
return button;
},
/**
* Add a new radio button to the provided parent element.
* @private
* @param {string} className
Class name for the radio button.
* @param {object} parent
The parent element.
* @param {*} value
Value associated with the radio button (e.g., button index or label).
* @param {boolean} checked
Initial status of the button.
* @param {string} [title]
Title of the button (for, e.g., display as a tooltip).
* @param {UI~controlCallback} [fn]
Callback function for when the button is pressed.
* @returns {object} The newly created radio button.
*/
_addRadioButton: function (
className,
parent,
value,
checked,
title=undefined,
fn=undefined
) {
const label = DomUtil.create('label', className, parent),
input = DomUtil.create('input', className, label),
div = DomUtil.create('div', className, label);
input.type = 'radio';
input.name = className;
input.value = value;
input.checked = checked;
if (fn) {
DomEvent.on(input, 'click touch', function () {
fn(value);
}, this);
}
label.htmlFor = input.id = className + '-' + value;
if (title) {
label.title = title;
}
return input;
},
/**
* Add a new selection menu to the provided parent element.
* @private
* @param {string} className
Class name for the selection menu.
* @param {object} parent
The parent element.
* @param {string[]}
Item list.
* @param {boolean[]} [disabled]
Item-disabling flag list.
* @param {number} [selected]
Selected item index.
* @param {string} [title]
Title of the selection menu (for, e.g., display as a tooltip).
* @param {UI~controlCallback} [fn]
Callback function for when an item is selected.
* @returns {object} The newly created selection menu.
*/
_addSelectMenu: function (
className,
parent,
items,
disabled=undefined,
selected=undefined,
title=undefined,
fn=undefined
) {
// Wrapper around the select element for better positioning and sizing
const div = DomUtil.create('div', className, parent),
select = DomUtil.create('select', className, div),
choose = document.createElement('option'),
opt = select.opt = [];
choose.text = 'choose';
choose.disabled = true;
if (!selected || selected < 0) {
choose.selected = true;
}
select.add(choose, null);
for (var i in items) {
var index = parseInt(i, 10);
opt[index] = document.createElement('option');
opt[index].text = items[index];
opt[index].style['background-color'] = 'orange';
opt[index].value = index;
if (disabled && disabled[index]) {
opt[index].disabled = true;
} else if (index === selected) {
opt[index].selected = true;
}
select.add(opt[index], null);
}
// Fix collapsing dialog issue when selecting a channel
if (this._container && !Browser.android && this.options.collapsed) {
DomEvent.on(select, 'mousedown', function () {
DomEvent.off(
this._container,
'mouseout',
this._collapse,
this
);
this.collapsedOff = true;
}, this);
DomEvent.on(this._container, 'mouseover', function () {
if (this.collapsedOff) {
DomEvent.on(
this._container,
'mouseout',
this._collapse,
this
);
this.collapsedOff = false;
}
}, this);
}
if (fn) {
DomEvent.on(select, 'change keyup', fn, this);
}
if (title) {
div.title = title;
}
return select;
},
/**
* Add a new color picker to the provided parent element.
* @private
* @param {string} className
Class name for the color picker.
* @param {object} parent
The parent element.
* @param {string} subClassName
Sub-class name for the color picker (will be combined with ClassName to
generate a unique element id).
* @param {string} defaultColor
Default color picked by default.
* @param {string} storageKey
String to be used as a local browser storage key.
* @param {string} [title]
Title of the color picker (for, e.g., display as a tooltip).
* @param {UI~colorCallback} [fn]
Callback function for when a color has been picked.
* @returns {object} The newly created color picker.
*/
_addColorPicker: function (
className,
parent,
subClassName,
defaultColor,
storageKey,
title=undefined,
fn=undefined
) {
const _this = this,
colpick = DomUtil.create('color', className, parent),
sp = Spectrum.create(
colpick,
{
color: defaultColor,
type: 'color',
allowEmpty: false,
appendTo: this._map._container,
cancelText: "CANCEL",
chooseText: "OK",
clickoutFiresChange: false,
localStorageKey: storageKey,
showAlpha: false,
showInput: true,
change: (e) => {
const color = e.detail.color ?
e.detail.color.toHexString() : null;
colpick.style.backgroundColor = colpick.value = color;
if (fn) {
fn(color);
};
}
}
),
container = sp.spectrum.container;
// Add tooltips
container.getElementsByClassName("sp-input")[0].title =
"Current color. Click to edit";
container.getElementsByClassName("sp-cancel")[0].title =
"Cancel selection";
container.getElementsByClassName("sp-choose")[0].title =
"Accept selection";
container.getElementsByClassName("sp-dragger")[0].title =
"Drag to adjust saturation/luminosity";
container.getElementsByClassName("sp-slider")[0].title =
"Slide to adjust hue";
// Disable propagation of mouse/touch events on colorpicker
DomEvent.disableClickPropagation(container);
DomEvent.disableScrollPropagation(container);
colpick.type = 'text';
colpick.style.backgroundColor = colpick.value = defaultColor;
colpick.id = className + '-' + subClassName;
if (title) {
colpick.title = title
}
return colpick;
},
/**
* Add a new layer attribute flip switch to the provided box element.
* @private
* @param {VTileLayer} layer
Layer affected by the flip switch action.
* @param {string} attr
Name of the (boolean) layer attribute to be flipped.
* @param {object} box
The parent box element.
* @param {string} label
Text for the created dialog line.
* @param {string} [title]
Title of the flip switch (for, e.g., display as a tooltip).
* @param {boolean} checked
Initial switch position.
* @returns {object} The newly created flip switch.
*/
_addSwitchInput: function (
layer,
attr,
box,
label,
title=undefined,
checked
) {
const line = this._addDialogLine(label, box),
elem = this._addDialogElement(line),
flip = elem.flip = new FlipSwitch(elem, {
checked: checked,
title: title
});
flip.on('change', function () {
layer._setAttr(attr, flip.value());
}, this);
return elem;
},
/**
* Add a new numerical input widget to the provided box element for
updating a layer attribute .
* @private
* @param {VTileLayer} layer
Layer affected by the numerical update.
* @param {string} attr
Name of the (numerical) layer attribute to be updated.
* @param {object} box
The parent box element.
* @param {string} label
Text for the created dialog line.
* @param {string} [title]
Title of the numerical input (for, e.g., display as a tooltip).
* @param {number} initValue
Initial numerical value.
* @param {number} step
Starting step value.
* @param {number} [min]
Minimum value.
* @param {number} [max]
Maximum value.
* @param {UI~layerCallback} [fn]
Callback function for when the numerical input has been updated.
* @returns {object} The newly created numerical input.
*/
_addNumericalInput: function (
layer,
attr,
box,
label,
title=undefined,
initValue,
step,
min=undefined,
max=undefined,
fn=undefined
) {
const line = this._addDialogLine(label, box),
elem = this._addDialogElement(line),
spinbox = elem.spinbox = new Spinbox(elem, {
step: step,
dmin: min,
dmax: max,
initValue: initValue,
title: title
});
spinbox.on('change', function () {
VUtil.flashElement(spinbox._input);
layer._setAttr(attr, spinbox.value(), fn);
}, this);
return elem;
},
/**
* Update the value stored in a widget element.
* @private
* @param {object} element
Widget element.
* @param {*} value
New value.
*/
_updateInput: function (elem, value) {
if (elem.spinbox) {
elem.spinbox.value(value);
} else if (elem.flip) {
elem.flip.value(value);
}
},
/**
* Compute a step for the spinbox widget from the provided min and max
values.
* @private
* @param {number} min
Minimum value.
* @param {number} max
Maximum value.
* @returns {number} The computed step value.
*/
_spinboxStep: function (min, max) {
const step = parseFloat((Math.abs(max === min ? max : max - min) *
0.001).toPrecision(1));
return step === 0.0 ? 1.0 : step;
},
/**
* Update the control list of layers.
* @private
* @returns {object} This (control).
*/
_updateLayerList: function () {
if (!this._dialog) {
return this;
}
if (this._layerList) {
DomUtil.empty(this._layerList);
} else {
this._layerList = DomUtil.create(
'div',
'visiomatic-control' + '-layerlist',
this._dialog
);
}
for (var i in this._layers) {
this._addLayerItem(this._layers[i]);
}
return this;
},
/**
* Add control item element for the provided layer parent object.
* @private
* @param {object} obj - Layer parent object.
* @returns {object} The control item element.
*/
_addLayerItem: function (obj) {
const _this = this,
layerItem = DomUtil.create('div', 'visiomatic-control-layer'),
inputdiv = DomUtil.create(
'div',
'visiomatic-control-layerswitch',
layerItem
);
if (obj.layer.notReady) {
DomUtil.create('div', 'visiomatic-control-activity', inputdiv);
} else {
const checked = this._map.hasLayer(obj.layer),
newInput = document.createElement('input');
newInput.type = 'checkbox';
newInput.className = 'visiomatic-control-selector';
newInput.defaultChecked = checked;
newInput.layerId = Util.stamp(obj.layer);
DomEvent.on(newInput, 'click', function () {
const inputs = this._layerList.getElementsByTagName('input'),
inputsLen = inputs.length;
this._handlingClick = true;
for (i = 0; i < inputsLen; i++) {
var input = inputs[i];
if (!('layerId' in input)) {
continue;
}
var obj = this._layers[input.layerId];
if (input.checked && !this._map.hasLayer(obj.layer)) {
obj.layer.addTo(this._map);
} else if (!input.checked && this._map.hasLayer(obj.layer)) {
this._map.removeLayer(obj.layer);
}
}
this._handlingClick = false;
}, this);
inputdiv.appendChild(newInput);
}
const layerName = DomUtil.create(
'div',
'visiomatic-control-layername',
layerItem
);
layerName.innerHTML = ' ' + obj.name;
layerName.style.textShadow = '0px 0px 5px ' + obj.layer.nameColor;
// Bring layer to front by clicking on the layer name.
DomEvent.on(layerName,
'click touch',
() => {obj.layer.bringToFront()},
this
);
this._addButton('visiomatic-control-trash',
layerItem,
undefined,
'Delete layer',
function () {
_this.removeLayer(obj.layer);
if (!obj.notReady) {
_this._map.removeLayer(obj.layer);
}
}
);
this._layerList.appendChild(layerItem);
return layerItem;
},
/**
* Add (overlay) layer from the present control.
* @param {leaflet.Layer} layer - Layer to be added.
* @param {string} name - Layer name.
* @param {number} [index] - Layer depth index.
* @returns {object} This (control).
*/
addLayer: function (layer, name, index) {
layer.on('add remove', this._onLayerChange, this);
const id = Util.stamp(layer);
this._layers[id] = {
layer: layer,
name: name,
index: index
};
return this._updateLayerList();
},
/**
* Remove (overlay) layer from the present control.
* @param {leaflet.Layer} layer - Layer to be removed.
* @returns {object} This (control).
*/
removeLayer: function (layer) {
layer.off('add remove', this._onLayerChange, this);
layer.fire('trash', {index: this._layers[Util.stamp(layer)].index});
layer.off('trash');
delete this._layers[Util.stamp(layer)];
return this._updateLayerList();
},
/**
* Trigger layer list update when an (overlay) layer is added or removed.
* @private
* @param {leaflet.LayerEvent} e - Leaflet layer event object.
*/
_onLayerChange: function (e) {
if (!this._handlingClick) {
this._updateLayerList();
}
const obj = this._layers[Util.stamp(e.target)],
type = e.type === 'add' ? 'overlayadd' : 'overlayremove';
this._map.fire(type, obj);
}
});
/**
* Instantiate a VisiOmatic UI.
* @memberof module:control/UI.js
* @function
* @param {VTileLayer[]} baseLayers - Array of layers.
* @param {object} [options] - Options: see {@link UI}.
* @returns {UI} VisiOmatic UI instance.
*/
export const ui = function (baseLayers, options) {
return new UI(baseLayers, options);
};
source