VisiOmatic web client

source

control/ChannelUI.js

/**
 #	This file part of:	VisiOmatic
 * @file User Interface for managing the channels of a VisiOmatic layer.

 * @requires control/UI.js

 * @copyright (c) 2014-2023 CNRS/IAP/CFHT/SorbonneU
 * @author Emmanuel Bertin <bertin@cfht.hawaii.edu>
*/

import {DomEvent, DomUtil, Util}  from 'leaflet';

import {rgb} from '../util';
import {UI} from './UI';


export const ChannelUI = UI.extend( /** @lends ChannelUI */ {
	options: {
		title: 'Channel mixing',
		mixingMode : undefined,
		cMap: 'grey',
		collapsed: true,
		position: 'topleft'
	},

	/**
	 * Create a VisiOmatic dialog for managing the VisiOmatic layer channels.

	 * @extends UI
	 * @memberof module:control/ChannelUI.js
	 * @constructs
	 * @param {object} [options] - Options.

	 * @param {string} [options.title='Channel mixing']
	   Title of the dialog window or panel.

	 * @param {'grey'|'jet'|'cool'|'hot'} [options.color='grey']
	   Default color map in ``'mono'`` mixing mode.

	 * @param {'mono' | 'color'} [options.mixingMode]
	   Mixing mode: single channel (``'mono'``) or color mix (``'color'``).
	   Defaults to [layer settings]{@link VTileLayer}.

	 * @see {@link UI} for additional control options.

	 * @returns {ChannelUI} Instance of a channel mixing user interface.
	 */
	initialize: function (options) {
		Util.setOptions(this, options);

		this._className = 'visiomatic-control';
		this._id = 'visiomatic-channel';
		this._sideClass = 'channel';
		this._settings = [];
		this._initsettings = [];
	},

	/**
	 * Copy channel mixing settings from a VisiOmatic layer.
	 * @param {VTileLayer} layer
	   VisiOmatic layer.
	 * @param {object} settings
	   Object where to save the settings properties.
	 * @param {'mono' | 'color'} mode
	   Mixing mode: single channel (``'mono'``) or color mix (``'color'``).
	 */
	saveSettings: function (layer, settings, mode) {
		if (!settings[mode]) {
			settings[mode] = {};
		}

		const	visio = layer.visio,
			setting = settings[mode];

		setting.channel = visio.channel;
		setting.cMap = visio.cMap;
		setting.rgb = [];
		for (let c in visio.rgb) {
			setting.rgb[c] = visio.rgb[c].clone();
		}
	},

	/**
	 * Copy channel mixing settings to a VisiOmatic layer.
	 * @param {VTileLayer} layer
	   VisiOmatic layer.
	 * @param {object} settings
	   Object where to save the settings properties.
	 * @param {'mono' | 'color'} mode
	   Mixing mode: single channel (``'mono'``) or color mix (``'color'``).
	 * @param {boolean} [keepChannel=false]
	   Overwrite the current layer channel?
	 */
	loadSettings: function (layer, settings, mode, keepChannel) {
		const	setting = settings[mode];

		if (!setting) {
			return;
		}

		const	visio = layer.visio,
			vrgb = visio.rgb,
			srgb = setting.rgb;

		if (!keepChannel) {
			visio.channel = setting.channel;
		}
		visio.cMap = setting.cMap;
		for (let c in srgb) {
			vrgb[c] = srgb[c].clone();
		}
	},

	/**
	 * Initialize the channel mixing dialog.
	 * @private
	 */
	_initDialog: function () {
		const _this = this,
			layer = this._layer,
			className = this._className,
			dialog = this._dialog;

		// copy initial VisiOmatic mixing parameters from the layer object
		this.saveSettings(layer, this._initsettings, 'mono');
		this.saveSettings(layer, this._initsettings, 'color');

		// copy current VisiOmatic mixing parameters from the layer object
		this.saveSettings(layer, this._settings, 'mono');
		this.saveSettings(layer, this._settings, 'color');

		this._mode = this.options.mixingMode ?
			this.options.mixingMode : layer.visio.mixingMode;

		const	box = this._addDialogBox(),
			modeline = this._addDialogLine('Mode:', box),
			modelem = this._addDialogElement(modeline),
			modeinput = DomUtil.create('div', className + '-radios', modelem);

		// Create Mode selection control section
		this._addRadioButton(
			className + '-radio',
			modeinput,
			'mono',
			(this._mode === 'mono'),
			'Select mono-channel palettized mode',
			function () {
				// Save previous settings 
				_this.saveSettings(layer, _this._settings, _this._mode);
				// Remove previous dialogs
				for (let elem = box.lastChild;
					elem !== modeline;
					elem = box.lastChild) {
					box.removeChild(elem);
				}
				for (let elem = dialog.lastChild;
					elem !== box;
					elem = dialog.lastChild) {
					dialog.removeChild(elem);
				}
				_this._channelList = undefined;
				_this.loadSettings(layer, _this._settings, 'mono');
				_this._initMonoDialog(layer, box);
				_this._mode = 'mono';
			}
		);
		this._addRadioButton(
			className + '-radio',
			modeinput,
			'color',
			(this._mode !== 'mono'),
			'Select color mixing mode',
			function () {
				// Save previous settings 
				_this.saveSettings(layer, _this._settings, _this._mode);
				// Remove previous dialogs
				for (let elem = box.lastChild;
					elem !== modeline;
					elem = box.lastChild) {
					box.removeChild(elem);
				}
				for (let elem = dialog.lastChild;
					elem !== box;
					elem = dialog.lastChild) {
					dialog.removeChild(elem);
				}
				_this.loadSettings(layer, _this._settings, 'color');
				_this._channelList = undefined;
				_this._initColorDialog(layer, box);
				_this._mode = 'color';
			}
		);

		if (_this._mode === 'mono') {
			_this._initMonoDialog(layer, box);
		} else {
			_this._initColorDialog(layer, box);
		}
	},

	/**
	 * Initialize the ``'mono'`` flavor of the channel mixing dialog.
	 * @private
	 * @param {VTileLayer} layer
	   VisiOmatic layer.
	 * @param {object} box
	   The parent box element.
	 */
	_initMonoDialog: function (layer, box) {
		// Single Channels with colour map
		const	_this = this,
			visio = layer.visio,
			channels = layer.visio.channelLabels,
			className = this._className;


		layer.setMono();
		if (visio.cMap === undefined) {
			layer._setAttr('cMap', this.options.cMap);
		}

		if (visio.nChannel > 1) {
			const	line = this._addDialogLine('Channel:', box),
				elem = this._addDialogElement(line);

			this._chanSelect = this._addSelectMenu(
				this._className + '-select',
				elem,
				visio.channelLabels,
				undefined,
				visio.channel,
				'Select image channel',
				function () {
					visio.channel = parseInt(
						this._chanSelect.selectedIndex - 1,
						10
					);
					this._updateChannel(layer, visio.channel);
					layer.redraw();
				}
			);


			const line2 = this._addDialogLine('Animate:', box),
				elem2 = this._addDialogElement(line2);
			// Go to first frame
			this._addButton(
				className + '-button',
				elem2,
				'channel-first',
				'Go to first frame/channel',
				() => {
					_this._gotoChannel(layer, 0);
				}
			);
			// Play animation backward
			const	playbackward = this._addButton(
				className + '-button',
				elem2,
				'channel-reverse',
				'Animate frames/channels in reverse',
				() => {
					if (visio.playAnimation && visio.reverseAnimation) {
						DomUtil.removeClass(playbackward, 'playing');
						_this._pauseAnimation(layer);
					} else {
						if (visio.playAnimation && !visio.reverseAnimation) {
							DomUtil.removeClass(playforward, 'playing');
						}
						DomUtil.addClass(playbackward, 'playing');
						_this._playAnimation(layer, reverse=true);
					}
				}
			);
			// Go to previous frame
			this._addButton(
				className + '-button',
				elem2,
				'channel-previous',
				'Go to previous frame/channel',
				() => {
					if (visio.playAnimation) {
						DomUtil.removeClass(
							visio.reverseAnimation? playbackward : playforward,
							'playing'
						);
						_this._pauseAnimation(layer);
					}
					const	chan = visio.channel - 1;
					_this._gotoChannel(
						layer,
						chan < 0 ? visio.nChannel - 1 : chan
					);
				}
			);
			// Go to next frame
			this._addButton(
				className + '-button',
				elem2,
				'channel-next',
				'Go to next frame/channel',
				() => {
					if (visio.playAnimation) {
						DomUtil.removeClass(
							visio.reverseAnimation? playbackward : playforward,
							'playing'
						);
						_this._pauseAnimation(layer);
					}
					const	chan = visio.channel + 1;
					_this._gotoChannel(
						layer,
						chan < visio.nChannel ? chan: 0
					);
				}
			);
			// Play animation forward
			const	playforward = this._addButton(
				className + '-button',
				elem2,
				'channel-play',
				'Animate channels/frames',
				() => {
					if (visio.playAnimation && !visio.reverseAnimation) {
						DomUtil.removeClass(playforward, 'playing');
						_this._pauseAnimation(layer);
					} else {
						if (visio.playAnimation && visio.reverseAnimation) {
							DomUtil.removeClass(playbackward, 'playing');
						}
						DomUtil.addClass(playforward, 'playing');
						_this._playAnimation(layer);
					}
				}
			);
			// Go to last frame
			this._addButton(
				className + '-button',
				elem2,
				'channel-last',
				'Go to last frame/channel',
				() => {
					_this._gotoChannel(layer, visio.nChannel - 1);
				}
			);

			// Frame rate adjusment
			this._addNumericalInput(
				layer,
				'framerate',
				box,
				'Framerate:',
				'Adjust animation framerate',
				visio.framerate,
				0.2, 0.2, 30,
				() => {
					if (visio.playAnimation) {
						this._playAnimation(
							layer,
							reverse=visio.reverseAnimation
						);
					};
				},
			);

		}

		const	line3 = this._addDialogLine('LUT:', box),
			elem3 = this._addDialogElement(line3);

		const	cmapinput = DomUtil.create('div', className + '-cmaps', elem3),
			cbutton = [],
			cmaps = ['grey', 'jet', 'cold', 'hot'],
			_changeMap = function (value) {
				layer._setAttr('cMap', value);
			};

		for (let c in cmaps) {
			cbutton[c] = this._addRadioButton(
				'leaflet-cmap',
				cmapinput,
				cmaps[c],
				(cmaps[c] === visio.cMap),
				'Select "' + cmaps[c].charAt(0).toUpperCase() +
				    cmaps[c].substr(1) + '" color-map',
				_changeMap
			);
		}

		this._addMinMax(layer, visio.channel, box);
		layer.redraw();
	},
 
	/**
	 * Initialize the ``'color'`` flavor of the channel mixing dialog.
	 * @private
	 * @param {VTileLayer} layer
	   VisiOmatic layer.
	 * @param {object} box
	   The parent box element.
	 */
	_initColorDialog: function (layer, box) {
		// Multiple Channels with mixing matrix

		const	_this = this,
			visio = layer.visio,
			className = this._className,
			line = this._addDialogLine('Channel:', box),
			elem = this._addDialogElement(line),
			colpick = this._chanColPick = this._addColorPicker(
				className + '-color',
				elem,
				'channel',
				layer.getChannelColor(visio.channel),
				'visiomaticChannel',
				title='Channel color. Click to edit',
				fn=(colorStr) => {
					this._updateChannelMix(layer, visio.channel, rgb(colorStr));
				}
			);

		this._pauseAnimation(layer);
		layer.setColor()
		layer.updateMix();

		this._chanSelect = this._addSelectMenu(
			this._className + '-select',
			elem,
			visio.channelLabels,
			undefined,
			visio.channel,
			'Select image channel',
			function () {
				visio.channel =  this._chanSelect.selectedIndex - 1;
				this._updateChannel(layer, visio.channel, updateColor=true);
			}
		);

		this._addMinMax(layer, visio.channel, box);

		const line2 = this._addDialogLine('Colors:', box),
			elem2 = this._addDialogElement(line2);

		// Create reset color settings button
		this._addButton(
			className + '-button',
			elem2,
			'colormix-reset',
			'Reset color mix',
			function () {
				_this.loadSettings(layer, _this._initsettings, 'color', true);
				layer.updateMix();
				this._updateColPick(layer, layer.visio.channel);
				this._updateChannelList(layer);
				layer.redraw();
			}
		);

		// Create automated color settings button
		this._addButton(
			className + '-button',
			elem2,
			'colormix-auto',
			'Re-color active channels',
			function () {
				const	nchan = visio.nChannel,
					vrgb = visio.rgb,
					defcol = layer.visioDefault.channelColors;
				let	nchanon = 0;

				for (const c in vrgb) {
					nchanon++;
				}
				if (nchanon >= defcol.length) {
					nchanon = defcol.length - 1;
				}

				let	cc = 0;
				for (const c in vrgb) {
					if (cc < nchanon) {
						vrgb[c] = rgb(defcol[nchanon][cc++]);
					}
				}
				layer.updateMix();
				this._updateColPick(layer, layer.visio.channel);
				this._updateChannelList(layer);
				layer.redraw();

			}
		);


		_this._updateChannelList(layer);
		layer.redraw();
	},

	/**
	 * Add a pair of spinboxes for setting the min and max clipping limits of
	   pixel values.
	 * @private
	 * @param {VTileLayer} layer
	   VisiOmatic layer.
	 * @param {number} channel
	   Image channel.
	 * @param {object} box
	   The parent box element.
	 */
	_addMinMax: function (layer, channel, box) {
		const	visio = layer.visio,
			step = this._spinboxStep(
				visio.minValue[channel],
				visio.maxValue[channel]
			);

		// Min
		this._minElem = this._addNumericalInput(
			layer,
			'minValue[' + channel + ']',
			box,
			'Min:',
			'Adjust lower clipping limit in ' + visio.channelUnits[channel],
			visio.minValue[channel], step
		);

		// Max
		this._maxElem = this._addNumericalInput(
			layer,
			'maxValue[' + channel + ']',
			box,
			'Max:',
			'Adjust upper clipping limit in ' + visio.channelUnits[channel],
			visio.maxValue[channel], step
		);
	},

	/**
	   Play Animation by iterating over channels/slices.
	 * @private
	 * @param {VTileLayer} layer
	   VisiOmatic layer.
	 * @param {boolean) [reverse=false]
	   Play in reverse?
	 */
	_playAnimation: function (layer, reverse=false) {
		const visio = layer.visio;
		if (visio.nChannel <= 1) {
			return;
		}
		this._pauseAnimation(layer);
		visio.reverseAnimation = reverse;
		visio.playAnimation = true;
		visio.intervalID = setInterval(
			reverse ?
				() => {
					const	chan = visio.channel - 1;
					visio.channel = chan < 0 ? visio.nChannel - 1 : chan;
					this._updateChannel(layer, visio.channel);
					layer.redraw();
				} : () => {
					const	chan = visio.channel + 1;
					visio.channel = chan < visio.nChannel ? chan : 0;
					this._updateChannel(layer, visio.channel);
					layer.redraw();
				},
			visio.framerate > 0.? 1000. / visio.framerate : 1000. 
		)
	},

	/**
	   Pause Animation.
	 * @private
	 * @param {VTileLayer} layer
	   VisiOmatic layer.
	 */
	_pauseAnimation: function (layer) {
		const visio = layer.visio;
		visio.playAnimation = false;
		if (visio.intervalID) {
			clearInterval(visio.intervalID);
			visio.intervalID = 0;
		}
	},

	/**
	   Set current channel.
	 * @private
	 * @param {VTileLayer} layer
	   VisiOmatic layer.
	 * @param {number} [channel=0]
	   Image channel.
	 */
	_gotoChannel: function (layer, channel=0) {
		const visio = layer.visio;
		if (visio.nChannel <= 1) {
			return;
		}
		this._updateChannel(layer, visio.channel=channel);
		layer.redraw();
	},

	/**
	   Set/update the channel controls for a given channel.
	 * @private
	 * @param {VTileLayer} layer
	   VisiOmatic layer.
	 * @param {number} channel
	   Image channel.
	 * @param {boolean) [updateColor=false]
	   Update Color patch element?
	 */
	_updateChannel: function (layer, channel, updateColor=false) {
		const	_this = this,
			visio = layer.visio,
			step = this._spinboxStep(
				visio.minValue[channel],
				visio.maxValue[channel]);
		_this._chanSelect.selectedIndex = channel + 1;

		/**
		 * Fired when the image channel is being updated.
		 * @event channelupdate
		 * @memberof VTileLayer
		 */
		layer.fire('channelupdate');
		if (updateColor) {
			this._updateColPick(layer, channel);
		}

		this._minElem.spinbox
			.value(visio.minValue[channel])
			.step(step)
			.off('change')
			.on('change', function () {
				layer._setAttr(
					'minValue[' + channel + ']',
					_this._minElem.spinbox.value()
				);
			}, this);

		this._maxElem.spinbox
			.value(visio.maxValue[channel])
			.step(step)
			.off('change')
			.on('change', function () {
				layer._setAttr(
					'maxValue[' + channel + ']',
					_this._maxElem.spinbox.value()
				);
			}, this);
	},

	/**
	   Update the color mixing matrix with the RGB contribution of a given
	   channel and redraw the VisiOmatic layer.
	 * @private
	 * @param {VTileLayer} layer
	   VisiOmatic layer.
	 * @param {number} channel
	   Image channel.
	 * @param {RGB} channel_rgb
	   RGB color.
	 */
	_updateChannelMix: function (layer, channel, channel_rgb) {
		layer.rgbToMix(channel, channel_rgb);
		this._updateChannelList(layer);
		layer.redraw();
	},

	/**
	   Update the list of channels in the dialog.
	 * @private
	 * @param {VTileLayer} layer
	 * VisiOmatic layer.
	 */
	_updateChannelList: function (layer) {
		const	visio = layer.visio,
			chanLabels = visio.channelLabels;
		let	chanList = this._channelList,
			chanElems = this._channelElems,
			trashElems = this._trashElems;
		if (chanList) {
		/*
			for (c in chanElems) {
				DomEvent.off(chanElems[c], 'click touch');
				DomEvent.off(trashElems[c], 'click touch');
			}
		*/
			DomUtil.empty(this._channelList);
		} else {
			chanList = this._channelList = DomUtil.create(
				'div',
				this._className + '-chanlist',
				this._dialog
			);
		}

		chanElems = this._channelElems = [];
		trashElems = this._trashElems = [];

		for (c in visio.rgb) {
			var	chan = parseInt(c, 10),
				vrgb = visio.rgb[chan],
				chanElem = DomUtil.create(
					'div',
					this._className + '-channel',
					chanList
				),
				color = DomUtil.create(
					'div',
					this._className + '-chancolor',
					chanElem
				);
			color.style.backgroundColor = vrgb.toStr();
			this._activateChanElem(color, layer, chan);
			var	label = DomUtil.create(
				'div',
				this._className + '-chanlabel',
				chanElem
			);
			label.innerHTML = chanLabels[c];
			this._activateChanElem(label, layer, chan);
			var	trashElem = this._addButton(
				'visiomatic-control-trash',
				chanElem,
				undefined,
				'Delete channel'
			);
			this._activateTrashElem(trashElem, layer, chan);
			chanElems.push(chanElem);
			trashElems.push(trashElem);
		}
	},

	/**
	   Update the color picker value based on the given channel color.
	 * @private
	 * @param {number} layer
	   VisiOmatic layer.
	 * @param {number} channel
	   Image channel.
	 */
	_updateColPick: function (layer, channel) {
		const	rgbStr = layer.getChannelColor(channel);
		
		this._chanColPick.style.backgroundColor =
			this._chanColPick.value = rgbStr;
	},

	/**
	   Add listener to a trash element for blackening the current channel color
	   of a given VisiOmatic layer.
	 * @private
	 * @param {object} trashElem
	   Trash element.
	 * @param {VTileLayer} layer
	   VisiOmatic layer.
	 * @param {number} channel
	   Image channel.
	 */
	_activateTrashElem: function (trashElem, layer, channel) {
		DomEvent.on(trashElem, 'click touch', function () {
			this._updateChannelMix(layer, channel, false);
			if (layer === this._layer && channel === layer.visio.channel) {
				this._updateColPick(layer, channel);
			}
		}, this);
	},

	/**
	   Add listener to a channel element for setting the current channel
	   of a given VisiOmatic layer.
	 * @private
	 * @param {object} trashElem
	   Trash element.
	 * @param {VTileLayer} layer
	   VisiOmatic layer.
	 * @param {number} channel
	   Image channel.
	 */
	_activateChanElem: function (chanElem, layer, channel) {
		DomEvent.on(chanElem, 'click touch', function () {
			layer.visio.channel = channel;
			this._updateChannel(layer, channel, updateColor=true);
		}, this);
	}

});

/**
 * Instantiate a VisiOmatic dialog for managing channels in a VisiOmatic layer.
 * @function
 * @param {object} [options] - Options: see {@link ChannelUI}
 * @returns {ChannelUI} Instance of a channel mixing user interface.
 */
export const channelUI = function (options) {
	return new ChannelUI(options);
};