gpad/TCanvasPainter.mjs

import { BIT, settings, internals, browser, create, parse, toJSON, loadScript, isFunc, isStr, clTCanvas } from '../core.mjs';
import { select as d3_select } from '../d3.mjs';
import { closeCurrentWindow, showProgress, loadOpenui5, ToolbarIcons, getColorExec } from '../gui/utils.mjs';
import { GridDisplay, getHPainter } from '../gui/display.mjs';
import { cleanup, resize, selectActivePad, EAxisBits } from '../base/ObjectPainter.mjs';
import { TFramePainter } from './TFramePainter.mjs';
import { TPadPainter, clTButton, createWebObjectOptions } from './TPadPainter.mjs';

const kShowEventStatus = BIT(15),
     // kAutoExec = BIT(16),
      kMenuBar = BIT(17),
      kShowToolBar = BIT(18),
      kShowEditor = BIT(19),
     // kMoveOpaque = BIT(20),
     // kResizeOpaque = BIT(21),
     // kIsGrayscale = BIT(22),
      kShowToolTips = BIT(23);

/** @summary direct draw of TFrame object,
  * @desc pad or canvas should already exist
  * @private */
function directDrawTFrame(dom, obj, opt) {
   const fp = new TFramePainter(dom, obj);
   fp.addToPadPrimitives();
   if (opt === '3d') fp.mode3d = true;
   return fp.redraw();
}

/**
  * @summary Painter for TCanvas object
  *
  * @private
  */

class TCanvasPainter extends TPadPainter {

   /** @summary Constructor */
   constructor(dom, canvas) {
      super(dom, canvas, true);
      this._websocket = null;
      this.tooltip_allowed = settings.Tooltip;
      if ((dom === null) && (canvas === null)) {
         // for web canvas details are important
         settings.SmallPad.width = 20;
         settings.SmallPad.height = 10;
      }
   }

   /** @summary Cleanup canvas painter */
   cleanup() {
      if (this._changed_layout)
         this.setLayoutKind('simple');
      delete this._changed_layout;
      super.cleanup();
   }

   /** @summary Returns canvas name */
   getCanvasName() {
      return this.getObjectName();
   }

   /** @summary Returns layout kind */
   getLayoutKind() {
      const origin = this.selectDom('origin'),
         layout = origin.empty() ? '' : origin.property('layout');

      return layout || 'simple';
   }

   /** @summary Set canvas layout kind */
   setLayoutKind(kind, main_selector) {
      const origin = this.selectDom('origin');
      if (!origin.empty()) {
         if (!kind) kind = 'simple';
         origin.property('layout', kind);
         origin.property('layout_selector', (kind !== 'simple') && main_selector ? main_selector : null);
         this._changed_layout = (kind !== 'simple'); // use in cleanup
      }
   }

   /** @summary Changes layout
     * @return {Promise} indicating when finished */
   async changeLayout(layout_kind, mainid) {
      const current = this.getLayoutKind();
      if (current === layout_kind)
         return true;

      const origin = this.selectDom('origin'),
            sidebar2 = origin.select('.side_panel2'),
            lst = [];
      let sidebar = origin.select('.side_panel'),
          main = this.selectDom(), force;

      while (main.node().firstChild)
         lst.push(main.node().removeChild(main.node().firstChild));

      if (!sidebar.empty())
         cleanup(sidebar.node());
      if (!sidebar2.empty())
         cleanup(sidebar2.node());

      this.setLayoutKind('simple'); // restore defaults
      origin.html(''); // cleanup origin

      if (layout_kind === 'simple') {
         main = origin;
         for (let k = 0; k < lst.length; ++k)
            main.node().appendChild(lst[k]);
         this.setLayoutKind(layout_kind);
         force = true;
      } else {
         const grid = new GridDisplay(origin.node(), layout_kind);

         if (mainid === undefined)
            mainid = (layout_kind.indexOf('vert') === 0) ? 0 : 1;

         main = d3_select(grid.getGridFrame(mainid));
         main.classed('central_panel', true).style('position', 'relative');

         if (mainid === 2) {
            // left panel for Y
            sidebar = d3_select(grid.getGridFrame(0));
            sidebar.classed('side_panel2', true).style('position', 'relative');
            // bottom panel for X
            sidebar = d3_select(grid.getGridFrame(3));
            sidebar.classed('side_panel', true).style('position', 'relative');
         } else {
            sidebar = d3_select(grid.getGridFrame(1 - mainid));
            sidebar.classed('side_panel', true).style('position', 'relative');
         }

         // now append all childs to the new main
         for (let k = 0; k < lst.length; ++k)
            main.node().appendChild(lst[k]);

         this.setLayoutKind(layout_kind, '.central_panel');

         // remove reference to MDIDisplay, solves resize problem
         origin.property('mdi', null);
      }

      // resize main drawing and let draw extras
      resize(main.node(), force);
      return true;
   }

   /** @summary Toggle projection
     * @return {Promise} indicating when ready
     * @private */
   async toggleProjection(kind) {
      delete this.proj_painter;

      if (kind) this.proj_painter = { X: false, Y: false }; // just indicator that drawing can be preformed

      if (isFunc(this.showUI5ProjectionArea))
         return this.showUI5ProjectionArea(kind);

      let layout = 'simple', mainid;

      switch (kind) {
         case 'XY': layout = 'projxy'; mainid = 2; break;
         case 'X':
         case 'bottom': layout = 'vert2_31'; mainid = 0; break;
         case 'Y':
         case 'left': layout = 'horiz2_13'; mainid = 1; break;
         case 'top': layout = 'vert2_13'; mainid = 1; break;
         case 'right': layout = 'horiz2_31'; mainid = 0; break;
      }

      return this.changeLayout(layout, mainid);
   }

   /** @summary Draw projection for specified histogram
     * @private */
   async drawProjection(kind, hist, hopt) {
      if (!this.proj_painter)
         return false; // ignore drawing if projection not configured

      if (hopt === undefined)
         hopt = 'hist';
      if (!kind) kind = 'X';

      if (!this.proj_painter[kind]) {
         this.proj_painter[kind] = 'init';

         const canv = create(clTCanvas),
               pad = this.pad,
               main = this.getFramePainter();
         let drawopt;

         if (kind === 'X') {
            canv.fLeftMargin = pad.fLeftMargin;
            canv.fRightMargin = pad.fRightMargin;
            canv.fLogx = main.logx;
            canv.fUxmin = main.logx ? Math.log10(main.scale_xmin) : main.scale_xmin;
            canv.fUxmax = main.logx ? Math.log10(main.scale_xmax) : main.scale_xmax;
            drawopt = 'fixframe';
         } else if (kind === 'Y') {
            canv.fBottomMargin = pad.fBottomMargin;
            canv.fTopMargin = pad.fTopMargin;
            canv.fLogx = main.logy;
            canv.fUxmin = main.logy ? Math.log10(main.scale_ymin) : main.scale_ymin;
            canv.fUxmax = main.logy ? Math.log10(main.scale_ymax) : main.scale_ymax;
            drawopt = 'rotate';
         }

         canv.fPrimitives.Add(hist, hopt);

         const promise = isFunc(this.drawInUI5ProjectionArea)
                          ? this.drawInUI5ProjectionArea(canv, drawopt, kind)
                          : this.drawInSidePanel(canv, drawopt, kind);

         return promise.then(painter => { this.proj_painter[kind] = painter; return painter; });
      } else if (isStr(this.proj_painter[kind])) {
         console.log('Not ready with first painting', kind);
         return true;
      }

      this.proj_painter[kind].getMainPainter()?.updateObject(hist, hopt);
      return this.proj_painter[kind].redrawPad();
   }

   /** @summary Checks if canvas shown inside ui5 widget
     * @desc Function should be used only from the func which supposed to be replaced by ui5
     * @private */
   testUI5() {
      return this.use_openui ?? false;
   }

   /** @summary Draw in side panel
     * @private */
   async drawInSidePanel(canv, opt, kind) {
      const sel = ((this.getLayoutKind() === 'projxy') && (kind === 'Y')) ? '.side_panel2' : '.side_panel',
            side = this.selectDom('origin').select(sel);
      return side.empty() ? null : this.drawObject(side.node(), canv, opt);
   }

   /** @summary Show message
     * @desc Used normally with web-based canvas and handled in ui5
     * @private */
   showMessage(msg) {
      if (!this.testUI5())
         showProgress(msg, 7000);
   }

   /** @summary Function called when canvas menu item Save is called */
   saveCanvasAsFile(fname) {
      const pnt = fname.indexOf('.');
      this.createImage(fname.slice(pnt+1))
          .then(res => this.sendWebsocket(`SAVE:${fname}:${res}`));
   }

   /** @summary Send command to server to save canvas with specified name
     * @desc Should be only used in web-based canvas
     * @private */
   sendSaveCommand(fname) {
      this.sendWebsocket('PRODUCE:' + fname);
   }

   /** @summary Submit menu request
     * @private */
   async submitMenuRequest(_painter, _kind, reqid) {
      // only single request can be handled, no limit better in RCanvas
      return new Promise(resolveFunc => {
         this._getmenu_callback = resolveFunc;
         this.sendWebsocket('GETMENU:' + reqid); // request menu items for given painter
      });
   }

   /** @summary Submit object exec request
     * @private */
   submitExec(painter, exec, snapid) {
      if (this._readonly || !painter) return;

      if (!snapid) snapid = painter.snapid;
      if (snapid && isStr(snapid) && exec)
         return this.sendWebsocket(`OBJEXEC:${snapid}:${exec}`);
   }

   /** @summary Return true if message can be send via web socket
    * @private */
   canSendWebSocket() { return this._websocket?.canSend(); }

   /** @summary Send text message with web socket
     * @desc used for communication with server-side of web canvas
     * @private */
   sendWebsocket(msg) {
      if (this._websocket?.canSend()) {
         this._websocket.send(msg);
         return true;
      }
      console.warn(`DROP SEND: ${msg}`);
      return false;
   }

   /** @summary Close websocket connection to canvas
     * @private */
   closeWebsocket(force) {
      if (this._websocket) {
         this._websocket.close(force);
         this._websocket.cleanup();
         delete this._websocket;
      }
   }

   /** @summary Use provided connection for the web canvas
     * @private */
   useWebsocket(handle) {
      this.closeWebsocket();

      this._websocket = handle;
      this._websocket.setReceiver(this);
      this._websocket.connect();
   }

   /** @summary set, test or reset timeout of specified name
     * @desc Used to prevent overloading of websocket for specific function */
   websocketTimeout(name, tm) {
      if (!this._websocket)
         return;
      if (!this._websocket._tmouts)
         this._websocket._tmouts = {};

      const handle = this._websocket._tmouts[name];
      if (tm === undefined)
         return handle !== undefined;

      if (tm === 'reset') {
         if (handle) { clearTimeout(handle); delete this._websocket._tmouts[name]; }
      } else if (!handle && Number.isInteger(tm))
         this._websocket._tmouts[name] = setTimeout(() => { delete this._websocket._tmouts[name]; }, tm);
   }

   /** @summary Handler for websocket open event
     * @private */
   onWebsocketOpened(/* handle */) {
      // indicate that we are ready to receive any following commands
   }

   /** @summary Handler for websocket close event
     * @private */
   onWebsocketClosed(/* handle */) {
      if (!this.embed_canvas)
         closeCurrentWindow();
   }

   /** @summary Handle websocket messages
     * @private */
   onWebsocketMsg(handle, msg) {
      // console.log(`GET MSG len:${msg.length} ${msg.slice(0,60)}`);

      if (msg === 'CLOSE') {
         this.onWebsocketClosed();
         this.closeWebsocket(true);
      } else if (msg.slice(0, 6) === 'SNAP6:') {
         // This is snapshot, produced with TWebCanvas
         const p1 = msg.indexOf(':', 6),
               version = msg.slice(6, p1),
               snap = parse(msg.slice(p1+1));

         this.syncDraw(true)
             .then(() => {
                if (!this.snapid)
                   this.resizeBrowser(snap.fSnapshot.fWindowWidth, snap.fSnapshot.fWindowHeight);
                if (!this.snapid && isFunc(this.setFixedCanvasSize))
                   this._online_fixed_size = this.setFixedCanvasSize(snap.fSnapshot.fCw, snap.fSnapshot.fCh, snap.fFixedSize);
             })
             .then(() => this.redrawPadSnap(snap))
             .then(() => {
                this.completeCanvasSnapDrawing();
                let ranges = this.getWebPadOptions(); // all data, including sub-pads
                if (ranges) ranges = ':' + ranges;
                handle.send(`READY6:${version}${ranges}`); // send ready message back when drawing completed
                this.confirmDraw();
             }).catch(err => {
               if (isFunc(this.showConsoleError))
                  this.showConsoleError(err);
               else
                  console.log(err);
             });
      } else if (msg.slice(0, 5) === 'MENU:') {
         // this is menu with exact identifier for object
         const lst = parse(msg.slice(5));
         if (isFunc(this._getmenu_callback)) {
            this._getmenu_callback(lst);
            delete this._getmenu_callback;
         }
      } else if (msg.slice(0, 4) === 'CMD:') {
         msg = msg.slice(4);
         const p1 = msg.indexOf(':'),
               cmdid = msg.slice(0, p1),
               cmd = msg.slice(p1+1),
               reply = `REPLY:${cmdid}:`;
         if ((cmd === 'SVG') || (cmd === 'PNG') || (cmd === 'JPEG') || (cmd === 'WEBP') || (cmd === 'PDF')) {
            this.createImage(cmd.toLowerCase())
                .then(res => handle.send(reply + res));
         } else {
            console.log(`Unrecognized command ${cmd}`);
            handle.send(reply);
         }
      } else if ((msg.slice(0, 7) === 'DXPROJ:') || (msg.slice(0, 7) === 'DYPROJ:')) {
         const kind = msg[1],
               hist = parse(msg.slice(7));
         this.websocketTimeout(`proj${kind}`, 'reset');
         this.drawProjection(kind, hist);
      } else if (msg.slice(0, 5) === 'CTRL:') {
         const ctrl = parse(msg.slice(5)) || {};
         let resized = false;
         if ((ctrl.title !== undefined) && (typeof document !== 'undefined'))
            document.title = ctrl.title;
         if (ctrl.x && ctrl.y && typeof window !== 'undefined') {
            window.moveTo(ctrl.x, ctrl.y);
            resized = true;
         }
         if (ctrl.w && ctrl.h) {
            this.resizeBrowser(Number.parseInt(ctrl.w), Number.parseInt(ctrl.h));
            resized = true;
         }
         if (ctrl.cw && ctrl.ch && isFunc(this.setFixedCanvasSize)) {
            this._online_fixed_size = this.setFixedCanvasSize(Number.parseInt(ctrl.cw), Number.parseInt(ctrl.ch), true);
            resized = true;
         }
         const kinds = ['Menu', 'StatusBar', 'Editor', 'ToolBar', 'ToolTips'];
         kinds.forEach(kind => {
            if (ctrl[kind] !== undefined)
               this.showSection(kind, ctrl[kind] === '1');
         });

         if (ctrl.edit) {
            const obj_painter = this.findSnap(ctrl.edit);
            if (obj_painter) {
               this.showSection('Editor', true)
                   .then(() => this.producePadEvent('select', obj_painter.getPadPainter(), obj_painter));
            }
         }

         if (ctrl.winstate && typeof window !== 'undefined') {
            if (ctrl.winstate === 'iconify')
               window.blur();
            else
               window.focus();
         }

         if (resized)
            this.sendResized(true);
      } else
         console.log(`unrecognized msg ${msg}`);
   }

   /** @summary Send RESIZED message to client to inform about changes in canvas/window geometry
     * @private */
   sendResized(force) {
      if (!this.pad || (typeof window === 'undefined'))
         return;
      const cw = this.getPadWidth(), ch = this.getPadHeight(),
            wx = window.screenLeft, wy = window.screenTop,
            ww = window.outerWidth, wh = window.outerHeight,
            fixed = this._online_fixed_size ? 1 : 0;
      if (!force) {
         force = (cw > 0) && (ch > 0) && ((this.pad.fCw !== cw) || (this.pad.fCh !== ch));
         if (force) {
            this.pad.fCw = cw;
            this.pad.fCh = ch;
         }
      }
      if (force)
         this.sendWebsocket(`RESIZED:${JSON.stringify([wx, wy, ww, wh, cw, ch, fixed])}`);
   }

   /** @summary Handle pad button click event */
   clickPadButton(funcname, evnt) {
      if (funcname === 'ToggleGed')
         return this.activateGed(this, null, 'toggle');
      if (funcname === 'ToggleStatus')
         return this.activateStatusBar('toggle');
      return super.clickPadButton(funcname, evnt);
   }

   /** @summary Returns true if event status shown in the canvas */
   hasEventStatus() {
      if (this.testUI5())
         return false;
      if (this.brlayout)
         return this.brlayout.hasStatus();
      return getHPainter()?.hasStatusLine() ?? false;
   }

   /** @summary Check if status bar can be toggled
     * @private */
   canStatusBar() {
      return this.testUI5() || this.brlayout || getHPainter();
   }

   /** @summary Show/toggle event status bar
     * @private */
   activateStatusBar(state) {
      if (this.testUI5())
         return;
      if (this.brlayout)
         this.brlayout.createStatusLine(23, state);
      else
         getHPainter()?.createStatusLine(23, state);
      this.processChanges('sbits', this);
   }

   /** @summary Show online canvas status
     * @private */
   showCanvasStatus(...msgs) {
      if (this.testUI5()) return;

      const br = this.brlayout || getHPainter()?.brlayout;

      br?.showStatus(...msgs);
   }

   /** @summary Returns true if GED is present on the canvas */
   hasGed() {
      if (this.testUI5()) return false;
      return this.brlayout?.hasContent() ?? false;
   }

   /** @summary Function used to de-activate GED
     * @private */
   removeGed() {
      if (this.testUI5()) return;

      this.registerForPadEvents(null);

      if (this.ged_view) {
         this.ged_view.getController().cleanupGed();
         this.ged_view.destroy();
         delete this.ged_view;
      }
      this.brlayout?.deleteContent(true);
      this.processChanges('sbits', this);
   }

   /** @summary Get view data for ui5 panel
     * @private */
   getUi5PanelData(/* panel_name */) {
      return { jsroot: { settings, create, parse, toJSON, loadScript, EAxisBits, getColorExec } };
   }

   /** @summary Function used to activate GED
     * @return {Promise} when GED is there
     * @private */
   async activateGed(objpainter, kind, mode) {
      if (this.testUI5() || !this.brlayout)
         return false;

      if (this.brlayout.hasContent()) {
         if ((mode === 'toggle') || (mode === false))
            this.removeGed();
         else
            objpainter?.getPadPainter()?.selectObjectPainter(objpainter);

         return true;
      }

      if (mode === false)
         return false;

      const btns = this.brlayout.createBrowserBtns();

      ToolbarIcons.createSVG(btns, ToolbarIcons.diamand, 15, 'toggle fix-pos mode', 'browser')
                  .style('margin', '3px').on('click', () => this.brlayout.toggleKind('fix'));

      ToolbarIcons.createSVG(btns, ToolbarIcons.circle, 15, 'toggle float mode', 'browser')
                  .style('margin', '3px').on('click', () => this.brlayout.toggleKind('float'));

      ToolbarIcons.createSVG(btns, ToolbarIcons.cross, 15, 'delete GED', 'browser')
                  .style('margin', '3px').on('click', () => this.removeGed());

      // be aware, that jsroot_browser_hierarchy required for flexible layout that element use full browser area
      this.brlayout.setBrowserContent('<div class=\'jsroot_browser_hierarchy\' id=\'ged_placeholder\'>Loading GED ...</div>');
      this.brlayout.setBrowserTitle('GED');
      this.brlayout.toggleBrowserKind(kind || 'float');

      return new Promise(resolveFunc => {
         loadOpenui5().then(sap => {
            d3_select('#ged_placeholder').text('');

            sap.ui.require(['sap/ui/model/json/JSONModel', 'sap/ui/core/mvc/XMLView'], (JSONModel, XMLView) => {
               const oModel = new JSONModel({ handle: null });

               XMLView.create({
                  viewName: 'rootui5.canv.view.Ged',
                  viewData: this.getUi5PanelData('Ged')
               }).then(oGed => {
                  oGed.setModel(oModel);

                  oGed.placeAt('ged_placeholder');

                  this.ged_view = oGed;

                  // TODO: should be moved into Ged controller - it must be able to detect canvas painter itself
                  this.registerForPadEvents(oGed.getController().padEventsReceiver.bind(oGed.getController()));

                  objpainter?.getPadPainter()?.selectObjectPainter(objpainter);

                  this.processChanges('sbits', this);

                  resolveFunc(true);
               });
            });
         });
      });
   }

   /** @summary Show section of canvas  like menu or editor */
   async showSection(that, on) {
      if (this.testUI5())
         return false;

      switch (that) {
         case 'Menu': break;
         case 'StatusBar': this.activateStatusBar(on); break;
         case 'Editor': return this.activateGed(this, null, !!on);
         case 'ToolBar': break;
         case 'ToolTips': this.setTooltipAllowed(on); break;
      }
      return true;
   }

   /** @summary Send command to start fit panel code on the server
     * @private */
   startFitPanel(standalone) {
      if (!this._websocket)
         return false;

      const new_conn = standalone ? null : this._websocket.createChannel();

      this.sendWebsocket('FITPANEL:' + (standalone ? 'standalone' : new_conn.getChannelId()));

      return new_conn;
   }

   /** @summary Complete handling of online canvas drawing
     * @private */
   completeCanvasSnapDrawing() {
      if (!this.pad) return;

      this.addPadInteractive();

      if ((typeof document !== 'undefined') && !this.embed_canvas && this._websocket)
         document.title = this.pad.fTitle;

      if (this._all_sections_showed) return;
      this._all_sections_showed = true;

      // used in Canvas.controller.js to avoid browser resize because of initial sections show/hide
      this._ignore_section_resize = true;

      this.showSection('Menu', this.pad.TestBit(kMenuBar));
      this.showSection('StatusBar', this.pad.TestBit(kShowEventStatus));
      this.showSection('ToolBar', this.pad.TestBit(kShowToolBar));
      this.showSection('Editor', this.pad.TestBit(kShowEditor));
      this.showSection('ToolTips', this.pad.TestBit(kShowToolTips) || this._highlight_connect);

      this._ignore_section_resize = false;
   }

   /** @summary Handle highlight in canvas - deliver information to server
     * @private */
   processHighlightConnect(hints) {
      if (!hints || hints.length === 0 || !this._highlight_connect ||
           !this._websocket || this.doingDraw() || !this._websocket.canSend(2)) return;

      const hint = hints[0] || hints[1];
      if (!hint || !hint.painter || !hint.painter.snapid || !hint.user_info) return;
      const pp = hint.painter.getPadPainter() || this;
      if (!pp.snapid) return;

      const arr = [pp.snapid, hint.painter.snapid, '0', '0'];

      if ((hint.user_info.binx !== undefined) && (hint.user_info.biny !== undefined)) {
         arr[2] = hint.user_info.binx.toString();
         arr[3] = hint.user_info.biny.toString();
      } else if (hint.user_info.bin !== undefined)
         arr[2] = hint.user_info.bin.toString();


      const msg = JSON.stringify(arr);

      if (this._last_highlight_msg !== msg) {
         this._last_highlight_msg = msg;
         this.sendWebsocket(`HIGHLIGHT:${msg}`);
      }
   }

   /** @summary Method informs that something was changed in the canvas
     * @desc used to update information on the server (when used with web6gui)
     * @private */
   processChanges(kind, painter, subelem) {
      // check if we could send at least one message more - for some meaningful actions
      if (!this._websocket || this._readonly || !this._websocket.canSend(2) || !isStr(kind)) return;

      let msg = '';
      if (!painter) painter = this;
      switch (kind) {
         case 'sbits':
            msg = 'STATUSBITS:' + this.getStatusBits();
            break;
         case 'frame': // when changing frame
         case 'zoom':  // when changing zoom inside frame
            if (!isFunc(painter.getWebPadOptions))
               painter = painter.getPadPainter();
            if (isFunc(painter.getWebPadOptions))
               msg = 'OPTIONS6:' + painter.getWebPadOptions('only_this');
            break;
         case 'padpos': // when changing pad position
            msg = 'OPTIONS6:' + painter.getWebPadOptions('with_subpads');
            break;
         case 'drawopt':
            if (painter.snapid)
               msg = 'DRAWOPT:' + JSON.stringify([painter.snapid.toString(), painter.getDrawOpt() || '']);
            break;
         case 'pave_moved': {
            const info = createWebObjectOptions(painter);
            if (info) msg = 'PRIMIT6:' + toJSON(info);
            break;
         }
         case 'logx':
         case 'logy':
         case 'logz': {
            const pp = painter.getPadPainter();

            if (pp?.snapid && pp?.pad) {
               const name = 'SetLog' + kind[3], value = pp.pad['fLog' + kind[3]];
               painter = pp;
               kind = `exec:${name}(${value})`;
            }
            break;
         }
      }

      if (!msg && isFunc(painter?.getSnapId) && (kind.slice(0, 5) === 'exec:')) {
         const snapid = painter.getSnapId(subelem);
         if (snapid) {
            msg = 'PRIMIT6:' + toJSON({ _typename: 'TWebObjectOptions',
                     snapid, opt: kind.slice(5), fcust: 'exec', fopt: [] });
         }
      }

      if (msg) {
         // console.log(`Sending ${msg.length} ${msg.slice(0,40)}`);
         this._websocket.send(msg);
      } else
         console.log(`Unprocessed changes ${kind} for painter of ${painter?.getObject()?._typename} subelem ${subelem}`);
   }

   /** @summary Select active pad on the canvas */
   selectActivePad(pad_painter, obj_painter, click_pos) {
      if (!this.snapid || !pad_painter) return; // only interactive canvas

      let arg = null, ischanged = false;
      const is_button = pad_painter.matchObjectType(clTButton);

      if (pad_painter.snapid && this._websocket)
         arg = { _typename: 'TWebPadClick', padid: pad_painter.snapid.toString(), objid: '', x: -1, y: -1, dbl: false };

      if (!pad_painter.is_active_pad && !is_button) {
         ischanged = true;
         this.forEachPainterInPad(pp => pp.drawActiveBorder(null, pp === pad_painter), 'pads');
      }

      if ((obj_painter?.snapid !== undefined) && arg) {
         ischanged = true;
         arg.objid = obj_painter.snapid.toString();
      }

      if (click_pos && arg) {
         ischanged = true;
         arg.x = Math.round(click_pos.x || 0);
         arg.y = Math.round(click_pos.y || 0);
         if (click_pos.dbl) arg.dbl = true;
      }

      if (arg && (ischanged || is_button))
         this.sendWebsocket('PADCLICKED:' + toJSON(arg));
   }

   /** @summary Return actual TCanvas status bits  */
   getStatusBits() {
      let bits = 0;
      if (this.hasEventStatus()) bits |= kShowEventStatus;
      if (this.hasGed()) bits |= kShowEditor;
      if (this.isTooltipAllowed()) bits |= kShowToolTips;
      if (this.use_openui) bits |= kMenuBar;
      return bits;
   }

   /** @summary produce JSON for TCanvas, which can be used to display canvas once again */
   produceJSON(spacing) {
      const canv = this.getObject(),
            fill0 = (canv.fFillStyle === 0),
            axes = [], hists = [];

      if (fill0) canv.fFillStyle = 1001;

      // write selected range into TAxis properties
      this.forEachPainterInPad(pp => {
         const main = pp.getMainPainter(),
               fp = pp.getFramePainter();
         if (!isFunc(main?.getHisto) || !isFunc(main?.getDimension)) return;

         const hist = main.getHisto(),
               ndim = main.getDimension();
         if (!hist?.fXaxis) return;

         const setAxisRange = (name, axis) => {
            if (fp?.zoomChangedInteractive(name)) {
               axes.push({ axis, f: axis.fFirst, l: axis.fLast, b: axis.fBits });
               axis.fFirst = main.getSelectIndex(name, 'left', 1);
               axis.fLast = main.getSelectIndex(name, 'right');
               const has_range = (axis.fFirst > 0) || (axis.fLast < axis.fNbins);
               if (has_range !== axis.TestBit(EAxisBits.kAxisRange))
                  axis.InvertBit(EAxisBits.kAxisRange);
            }
         };

         setAxisRange('x', hist.fXaxis);
         if (ndim > 1) setAxisRange('y', hist.fYaxis);
         if (ndim > 2) setAxisRange('z', hist.fZaxis);
         if ((ndim === 2) && fp?.zoomChangedInteractive('z')) {
            hists.push({ hist, min: hist.fMinimum, max: hist.fMaximum });
            hist.fMinimum = fp.zoom_zmin ?? fp.zmin;
            hist.fMaximum = fp.zoom_zmax ?? fp.zmax;
         }
      }, 'pads');

      if (!this.normal_canvas) {
         // fill list of primitives from painters
         this.forEachPainterInPad(p => {
            // ignore all secondary painters
            if (p.isSecondary())
               return;
            const subobj = p.getObject();
            if (subobj?._typename)
               canv.fPrimitives.Add(subobj, p.getDrawOpt());
         }, 'objects');
      }

      // const fp = this.getFramePainter();
      // fp?.setRootPadRange(this.getRootPad());

      const res = toJSON(canv, spacing);

      if (fill0) canv.fFillStyle = 0;

      axes.forEach(e => {
         e.axis.fFirst = e.f;
         e.axis.fLast = e.l;
         e.axis.fBits = e.b;
      });

      hists.forEach(e => {
         e.hist.fMinimum = e.min;
         e.hist.fMaximum = e.max;
      });

      if (!this.normal_canvas)
         canv.fPrimitives.Clear();

      return res;
   }

   /** @summary resize browser window */
   resizeBrowser(fullW, fullH) {
      if (!fullW || !fullH || this.isBatchMode() || this.embed_canvas || this.batch_mode)
         return;

      // workaround for qt5-based display where inner window size is used
      if ((browser.qt5 || browser.qt6) && fullW > 100 && fullH > 60) {
         fullW -= 3;
         fullH -= 30;
      }

      this._websocket?.resizeWindow(fullW, fullH);
   }

   /** @summary draw TCanvas */
   static async draw(dom, can, opt) {
      const nocanvas = !can;
      if (nocanvas) can = create(clTCanvas);

      const painter = new TCanvasPainter(dom, can);
      painter.checkSpecialsInPrimitives(can, true);

      if (!nocanvas && can.fCw && can.fCh) {
         const d = painter.selectDom();
         let apply_size = false;
         if (!painter.isBatchMode()) {
            const rect0 = d.node().getBoundingClientRect();
            apply_size = !rect0.height && (rect0.width > 0.1*can.fCw);
         } else {
            const arg = d.property('_batch_use_canvsize');
            apply_size = arg || (arg === undefined);
         }
         if (apply_size) {
            d.style('width', can.fCw + 'px').style('height', can.fCh + 'px')
              .attr('width', can.fCw).attr('height', can.fCh);
            painter._fixed_size = true;
         }
      }

      painter.decodeOptions(opt);
      painter.normal_canvas = !nocanvas;
      painter.createCanvasSvg(0);

      painter.addPadButtons();

      if (nocanvas && opt.indexOf('noframe') < 0)
         directDrawTFrame(painter, null);

      // select global reference - required for keys handling
      selectActivePad({ pp: painter, active: true });

      return painter.drawPrimitives().then(() => {
         painter.addPadInteractive();
         painter.showPadButtons();
         return painter;
      });
   }

} // class TCanvasPainter


/** @summary Ensure TCanvas and TFrame for the painter object
  * @param {Object} painter  - painter object to process
  * @param {string|boolean} frame_kind  - false for no frame or '3d' for special 3D mode
  * @desc Assign dom, creates TCanvas if necessary, add to list of pad painters */
async function ensureTCanvas(painter, frame_kind) {
   if (!painter)
      return Promise.reject(Error('Painter not provided in ensureTCanvas'));

   // simple check - if canvas there, can use painter
   const noframe = (frame_kind === false) || (frame_kind === '3d') ? 'noframe' : '',
         createCanv = () => {
            if ((noframe !== 'noframe') || !isFunc(painter.getUserRanges))
               return null;
            const ranges = painter.getUserRanges();
            if (!ranges)
               return null;
            const canv = create(clTCanvas),
                  dx = (ranges.maxx - ranges.minx) || 1,
                  dy = (ranges.maxy - ranges.miny) || 1;
            canv.fX1 = ranges.minx - dx * 0.1;
            canv.fX2 = ranges.maxx + dx * 0.1;
            canv.fY1 = ranges.miny - dy * 0.1;
            canv.fY2 = ranges.maxy + dy * 0.1;
            return canv;
         },
         promise = painter.getCanvSvg().empty()
                   ? TCanvasPainter.draw(painter.getDom(), createCanv(), noframe)
                   : Promise.resolve(true);

   return promise.then(() => {
      if ((frame_kind !== false) && painter.getFrameSvg().selectChild('.main_layer').empty() && !painter.getFramePainter())
         directDrawTFrame(painter.getPadPainter(), null, frame_kind);

      painter.addToPadPrimitives();
      return painter;
   });
}

/** @summary draw TPad snapshot from TWebCanvas
  * @private */
async function drawTPadSnapshot(dom, snap /* , opt */) {
   const can = create(clTCanvas),
         painter = new TCanvasPainter(dom, can);
   painter.normal_canvas = false;
   painter.addPadButtons();

   return painter.syncDraw(true).then(() => painter.redrawPadSnap(snap)).then(() => {
      painter.confirmDraw();
      painter.showPadButtons();
      return painter;
   });
}

/** @summary draw TFrame object
  * @private */
async function drawTFrame(dom, obj, opt) {
   const fp = new TFramePainter(dom, obj);
   fp.mode3d = opt === '3d';
   return ensureTCanvas(fp, false).then(() => fp.redraw());
}

Object.assign(internals.jsroot, { ensureTCanvas, TPadPainter, TCanvasPainter });

export { ensureTCanvas, drawTPadSnapshot, drawTFrame, TPadPainter, TCanvasPainter };