gpad/RFramePainter.mjs

import { gStyle, settings, internals, create, isFunc, isStr, clTAxis, nsREX, urlClassPrefix } from '../core.mjs';
import { pointer as d3_pointer } from '../d3.mjs';
import { getSvgLineStyle } from '../base/TAttLineHandler.mjs';
import { makeTranslate } from '../base/BasePainter.mjs';
import { EAxisBits, TAxisPainter } from './TAxisPainter.mjs';
import { RAxisPainter } from './RAxisPainter.mjs';
import { FrameInteractive, getEarthProjectionFunc } from './TFramePainter.mjs';
import { RObjectPainter } from '../base/RObjectPainter.mjs';


/**
 * @summary Painter class for RFrame, main handler for interactivity
 *
 * @private
 */

class RFramePainter extends RObjectPainter {

   /** @summary constructor
     * @param {object|string} dom - DOM element for drawing or element id
     * @param {object} frame - RFrame object */
   constructor(dom, frame) {
      super(dom, frame, '', 'frame');
      this.mode3d = false;
      this.xmin = this.xmax = 0; // no scale specified, wait for objects drawing
      this.ymin = this.ymax = 0; // no scale specified, wait for objects drawing
      this.axes_drawn = false;
      this.keys_handler = null;
      this.projection = 0; // different projections
      this.v7_frame = true; // indicator of v7, used in interactive part
   }

   /** @summary Returns frame painter - object itself */
   getFramePainter() { return this; }

   /** @summary Returns true if it is ROOT6 frame
    * @private */
   is_root6() { return false; }

   /** @summary Set active flag for frame - can block some events
    * @private */
   setFrameActive(on) {
      this.enabledKeys = on && settings.HandleKeys;
      // used only in 3D mode
      if (this.control)
         this.control.enableKeys = this.enabledKeys;
   }

   setLastEventPos(pnt) {
      // set position of last context menu event, can be
      this.fLastEventPnt = pnt;
   }

   getLastEventPos() {
      // return position of last event
      return this.fLastEventPnt;
   }

   /** @summary Update graphical attributes */
   updateAttributes(force) {
      if ((this.fX1NDC === undefined) || (force && !this.$modifiedNDC)) {
         const rect = this.getPadPainter().getPadRect();
         this.fX1NDC = this.v7EvalLength('margins_left', rect.width, gStyle.fPadLeftMargin) / rect.width;
         this.fY1NDC = this.v7EvalLength('margins_bottom', rect.height, gStyle.fPadBottomMargin) / rect.height;
         this.fX2NDC = 1 - this.v7EvalLength('margins_right', rect.width, gStyle.fPadRightMargin) / rect.width;
         this.fY2NDC = 1 - this.v7EvalLength('margins_top', rect.height, gStyle.fPadTopMargin) / rect.height;
      }

      if (!this.fillatt)
         this.createv7AttFill();

      this.createv7AttLine('border_');
   }

   /** @summary Returns coordinates transformation func */
   getProjectionFunc() { return getEarthProjectionFunc(this.projection); }

   /** @summary Recalculate frame ranges using specified projection functions
     * @desc Not yet used in v7 */
   recalculateRange(Proj) {
      this.projection = Proj || 0;

      if ((this.projection === 2) && ((this.scale_ymin <= -90) || (this.scale_ymax >=90))) {
         console.warn(`Mercator Projection: latitude out of range ${this.scale_ymin} ${this.scale_ymax}`);
         this.projection = 0;
      }

      const func = this.getProjectionFunc();
      if (!func) return;

      const pnts = [func(this.scale_xmin, this.scale_ymin),
                   func(this.scale_xmin, this.scale_ymax),
                   func(this.scale_xmax, this.scale_ymax),
                   func(this.scale_xmax, this.scale_ymin)];
      if (this.scale_xmin < 0 && this.scale_xmax > 0) {
         pnts.push(func(0, this.scale_ymin));
         pnts.push(func(0, this.scale_ymax));
      }
      if (this.scale_ymin < 0 && this.scale_ymax > 0) {
         pnts.push(func(this.scale_xmin, 0));
         pnts.push(func(this.scale_xmax, 0));
      }

      this.original_xmin = this.scale_xmin;
      this.original_xmax = this.scale_xmax;
      this.original_ymin = this.scale_ymin;
      this.original_ymax = this.scale_ymax;

      this.scale_xmin = this.scale_xmax = pnts[0].x;
      this.scale_ymin = this.scale_ymax = pnts[0].y;

      for (let n = 1; n < pnts.length; ++n) {
         this.scale_xmin = Math.min(this.scale_xmin, pnts[n].x);
         this.scale_xmax = Math.max(this.scale_xmax, pnts[n].x);
         this.scale_ymin = Math.min(this.scale_ymin, pnts[n].y);
         this.scale_ymax = Math.max(this.scale_ymax, pnts[n].y);
      }
   }

   /** @summary Draw axes grids
     * @desc Called immediately after axes drawing */
   drawGrids() {
      const layer = this.getFrameSvg().selectChild('.axis_layer');

      layer.selectAll('.xgrid').remove();
      layer.selectAll('.ygrid').remove();

      const h = this.getFrameHeight(),
            w = this.getFrameWidth(),
            gridx = this.v7EvalAttr('gridX', false),
            gridy = this.v7EvalAttr('gridY', false),
            grid_style = getSvgLineStyle(gStyle.fGridStyle),
            grid_color = (gStyle.fGridColor > 0) ? this.getColor(gStyle.fGridColor) : 'black';

      if (this.x_handle)
         this.x_handle.draw_grid = gridx;

      // add a grid on x axis, if the option is set
      if (this.x_handle?.draw_grid) {
         let grid = '';
         for (let n = 0; n < this.x_handle.ticks.length; ++n) {
            grid += this.swap_xy
                  ? `M0,${h+this.x_handle.ticks[n]}h${w}`
                  : `M${this.x_handle.ticks[n]},0v${h}`;
         }

         if (grid) {
            layer.append('svg:path')
                 .attr('class', 'xgrid')
                 .attr('d', grid)
                 .style('stroke', grid_color)
                 .style('stroke-width', gStyle.fGridWidth)
                 .style('stroke-dasharray', grid_style);
         }
      }

      if (this.y_handle)
         this.y_handle.draw_grid = gridy;

      // add a grid on y axis, if the option is set
      if (this.y_handle?.draw_grid) {
         let grid = '';
         for (let n = 0; n < this.y_handle.ticks.length; ++n) {
            grid += this.swap_xy
                     ? `M${this.y_handle.ticks[n]},0v${h}`
                     : `M0,${h+this.y_handle.ticks[n]}h${w}`;
         }

         if (grid) {
            layer.append('svg:path')
               .attr('class', 'ygrid')
               .attr('d', grid)
               .style('stroke', grid_color)
               .style('stroke-width', gStyle.fGridWidth)
               .style('stroke-dasharray', grid_style);
         }
      }
   }

   /** @summary Converts 'raw' axis value into text */
   axisAsText(axis, value) {
      const handle = this[`${axis}_handle`];

      return handle ? handle.axisAsText(value, settings[axis.toUpperCase() + 'ValuesFormat']) : value.toPrecision(4);
   }

   /** @summary Set axis range */
   _setAxisRange(prefix, vmin, vmax) {
      const nmin = `${prefix}min`, nmax = `${prefix}max`;
      if (this[nmin] !== this[nmax]) return;
      let min = this.v7EvalAttr(`${prefix}_min`),
          max = this.v7EvalAttr(`${prefix}_max`);

      if (min !== undefined) vmin = min;
      if (max !== undefined) vmax = max;

      if (vmin < vmax) {
         this[nmin] = vmin;
         this[nmax] = vmax;
      }

      const nzmin = `zoom_${prefix}min`, nzmax = `zoom_${prefix}max`;

      if ((this[nzmin] === this[nzmax]) && !this.zoomChangedInteractive(prefix)) {
         min = this.v7EvalAttr(`${prefix}_zoomMin`);
         max = this.v7EvalAttr(`${prefix}_zoomMax`);

         if ((min !== undefined) || (max !== undefined)) {
            this[nzmin] = (min === undefined) ? this[nmin] : min;
            this[nzmax] = (max === undefined) ? this[nmax] : max;
         }
      }
   }

   /** @summary Set axes ranges for drawing, check configured attributes if range already specified */
   setAxesRanges(xaxis, xmin, xmax, yaxis, ymin, ymax, zaxis, zmin, zmax) {
      if (this.axes_drawn) return;
      this.xaxis = xaxis;
      this._setAxisRange('x', xmin, xmax);
      this.yaxis = yaxis;
      this._setAxisRange('y', ymin, ymax);
      this.zaxis = zaxis;
      this._setAxisRange('z', zmin, zmax);
   }

   /** @summary Set secondary axes ranges */
   setAxes2Ranges(second_x, xaxis, xmin, xmax, second_y, yaxis, ymin, ymax) {
      if (second_x) {
         this.x2axis = xaxis;
         this._setAxisRange('x2', xmin, xmax);
      }
      if (second_y) {
         this.y2axis = yaxis;
         this._setAxisRange('y2', ymin, ymax);
      }
   }

   /** @summary Create x,y objects which maps user coordinates into pixels
     * @desc Must be used only for v6 objects, see TFramePainter for more details
     * @private */
   createXY(opts) {
      if (this.self_drawaxes) return;

      this.cleanXY(); // remove all previous configurations

      if (!opts) opts = { ndim: 1 };

      this.v6axes = true;
      this.swap_xy = opts.swap_xy || false;
      this.reverse_x = opts.reverse_x || false;
      this.reverse_y = opts.reverse_y || false;

      this.logx = this.v7EvalAttr('x_log', 0);
      this.logy = this.v7EvalAttr('y_log', 0);

      const w = this.getFrameWidth(), h = this.getFrameHeight(), pp = this.getPadPainter();

      this.scales_ndim = opts.ndim;

      this.scale_xmin = this.xmin;
      this.scale_xmax = this.xmax;

      this.scale_ymin = this.ymin;
      this.scale_ymax = this.ymax;

      if (opts.extra_y_space) {
         const log_scale = this.swap_xy ? this.logx : this.logy;
         if (log_scale && (this.scale_ymax > 0))
            this.scale_ymax = Math.exp(Math.log(this.scale_ymax)*1.1);
         else
            this.scale_ymax += (this.scale_ymax - this.scale_ymin)*0.1;
      }

      if ((opts.zoom_xmin !== opts.zoom_xmax) && ((this.zoom_xmin === this.zoom_xmax) || !this.zoomChangedInteractive('x'))) {
         this.zoom_xmin = opts.zoom_xmin;
         this.zoom_xmax = opts.zoom_xmax;
      }

      if ((opts.zoom_ymin !== opts.zoom_ymax) && ((this.zoom_ymin === this.zoom_ymax) || !this.zoomChangedInteractive('y'))) {
         this.zoom_ymin = opts.zoom_ymin;
         this.zoom_ymax = opts.zoom_ymax;
      }

      if (this.zoom_xmin !== this.zoom_xmax) {
         this.scale_xmin = this.zoom_xmin;
         this.scale_xmax = this.zoom_xmax;
      }

      if (this.zoom_ymin !== this.zoom_ymax) {
         this.scale_ymin = this.zoom_ymin;
         this.scale_ymax = this.zoom_ymax;
      }

      let xaxis = this.xaxis, yaxis = this.yaxis;
      if (xaxis?._typename !== clTAxis) xaxis = create(clTAxis);
      if (yaxis?._typename !== clTAxis) yaxis = create(clTAxis);

      this.x_handle = new TAxisPainter(pp, xaxis, true);
      this.x_handle.optionUnlab = this.v7EvalAttr('x_labels_hide', false);

      this.x_handle.configureAxis('xaxis', this.xmin, this.xmax, this.scale_xmin, this.scale_xmax, this.swap_xy, this.swap_xy ? [0, h] : [0, w],
                                      { reverse: this.reverse_x,
                                        log: this.swap_xy ? this.logy : this.logx,
                                        symlog: this.swap_xy ? opts.symlog_y : opts.symlog_x,
                                        logcheckmin: (opts.ndim > 1) || !this.swap_xy,
                                        logminfactor: 0.0001 });

      this.x_handle.assignFrameMembers(this, 'x');

      this.y_handle = new TAxisPainter(pp, yaxis, true);
      this.y_handle.optionUnlab = this.v7EvalAttr('y_labels_hide', false);

      this.y_handle.configureAxis('yaxis', this.ymin, this.ymax, this.scale_ymin, this.scale_ymax, !this.swap_xy, this.swap_xy ? [0, w] : [0, h],
                                      { reverse: this.reverse_y,
                                        log: this.swap_xy ? this.logx : this.logy,
                                        symlog: this.swap_xy ? opts.symlog_x : opts.symlog_y,
                                        logcheckmin: (opts.ndim > 1) || this.swap_xy,
                                        log_min_nz: opts.ymin_nz && (opts.ymin_nz < this.ymax) ? 0.5 * opts.ymin_nz : 0,
                                        logminfactor: 3e-4 });

      this.y_handle.assignFrameMembers(this, 'y');
   }

   /** @summary Identify if requested axes are drawn
     * @desc Checks if x/y axes are drawn. Also if second side is already there */
   hasDrawnAxes(second_x, second_y) {
      return !second_x && !second_y ? this.axes_drawn : false;
   }

   /** @summary Draw configured axes on the frame
     * @desc axes can be drawn only for main histogram  */
   async drawAxes() {
      if (this.axes_drawn || (this.xmin === this.xmax) || (this.ymin === this.ymax))
         return this.axes_drawn;

      const ticksx = this.v7EvalAttr('ticksX', 1),
            ticksy = this.v7EvalAttr('ticksY', 1);
      let sidex = 1, sidey = 1;

      if (this.v7EvalAttr('swapX', false)) sidex = -1;
      if (this.v7EvalAttr('swapY', false)) sidey = -1;

      const w = this.getFrameWidth(), h = this.getFrameHeight(), pp = this.getPadPainter();

      if (!this.v6axes) {
         // this is partially same as v6 createXY method

         this.cleanupAxes();

         this.swap_xy = false;

         if (this.zoom_xmin !== this.zoom_xmax) {
            this.scale_xmin = this.zoom_xmin;
            this.scale_xmax = this.zoom_xmax;
         } else {
            this.scale_xmin = this.xmin;
            this.scale_xmax = this.xmax;
         }

         if (this.zoom_ymin !== this.zoom_ymax) {
            this.scale_ymin = this.zoom_ymin;
            this.scale_ymax = this.zoom_ymax;
         } else {
            this.scale_ymin = this.ymin;
            this.scale_ymax = this.ymax;
         }

         this.recalculateRange(0);

         this.x_handle = new RAxisPainter(pp, this, this.xaxis, 'x_');
         this.x_handle.assignSnapId(this.snapid);
         this.x_handle.draw_swapside = (sidex < 0);
         this.x_handle.draw_ticks = ticksx;

         this.y_handle = new RAxisPainter(pp, this, this.yaxis, 'y_');
         this.y_handle.assignSnapId(this.snapid);
         this.y_handle.draw_swapside = (sidey < 0);
         this.y_handle.draw_ticks = ticksy;

         this.z_handle = new RAxisPainter(pp, this, this.zaxis, 'z_');
         this.z_handle.assignSnapId(this.snapid);

         this.x_handle.configureAxis('xaxis', this.xmin, this.xmax, this.scale_xmin, this.scale_xmax, false, [0, w], w, { reverse: false });
         this.x_handle.assignFrameMembers(this, 'x');

         this.y_handle.configureAxis('yaxis', this.ymin, this.ymax, this.scale_ymin, this.scale_ymax, true, [h, 0], -h, { reverse: false });
         this.y_handle.assignFrameMembers(this, 'y');

         // only get basic properties like log scale
         this.z_handle.configureZAxis('zaxis', this);
      }

      const layer = this.getFrameSvg().selectChild('.axis_layer');

      this.x_handle.has_obstacle = false;

      const draw_horiz = this.swap_xy ? this.y_handle : this.x_handle,
            draw_vertical = this.swap_xy ? this.x_handle : this.y_handle;
      let pr;

      if (this.getPadPainter()?._fast_drawing)
         pr = Promise.resolve(true); // do nothing
       else if (this.v6axes) {
         // in v7 ticksx/y values shifted by 1 relative to v6
         // In v7 ticksx === 0 means no ticks, ticksx === 1 equivalent to === 0 in v6

         const can_adjust_frame = false, disable_x_draw = false, disable_y_draw = false;

         draw_horiz.disable_ticks = (ticksx <= 0);
         draw_vertical.disable_ticks = (ticksy <= 0);

         const pr1 = draw_horiz.drawAxis(layer, w, h,
                                   draw_horiz.invert_side ? null : `translate(0,${h})`,
                                   (ticksx > 1) ? -h : 0, disable_x_draw,
                                   undefined, false, this.getPadPainter().getPadHeight() - h - this.getFrameY()),

          pr2 = draw_vertical.drawAxis(layer, w, h,
                                   draw_vertical.invert_side ? `translate(${w})` : null,
                                   (ticksy > 1) ? w : 0, disable_y_draw,
                                   draw_vertical.invert_side ? 0 : this._frame_x, can_adjust_frame);

         pr = Promise.all([pr1, pr2]).then(() => this.drawGrids());
      } else {
         let arr = [];

         if (ticksx > 0)
            arr.push(draw_horiz.drawAxis(layer, makeTranslate(0, sidex > 0 ? h : 0), sidex));

         if (ticksy > 0)
            arr.push(draw_vertical.drawAxis(layer, makeTranslate(sidey > 0 ? 0 : w, h), sidey));

         pr = Promise.all(arr).then(() => {
            arr = [];
            if (ticksx > 1)
               arr.push(draw_horiz.drawAxisOtherPlace(layer, makeTranslate(0, sidex < 0 ? h : 0), -sidex, ticksx === 2));

            if (ticksy > 1)
               arr.push(draw_vertical.drawAxisOtherPlace(layer, makeTranslate(sidey < 0 ? 0 : w, h), -sidey, ticksy === 2));
            return Promise.all(arr);
         }).then(() => this.drawGrids());
      }

      return pr.then(() => {
         this.axes_drawn = true;
         return true;
      });
   }

   /** @summary Draw secondary configured axes */
   drawAxes2(second_x, second_y) {
      const w = this.getFrameWidth(), h = this.getFrameHeight(),
            pp = this.getPadPainter(),
            layer = this.getFrameSvg().selectChild('.axis_layer');
      let pr1, pr2;

      if (second_x) {
         if (this.zoom_x2min !== this.zoom_x2max) {
            this.scale_x2min = this.zoom_x2min;
            this.scale_x2max = this.zoom_x2max;
         } else {
           this.scale_x2min = this.x2min;
           this.scale_x2max = this.x2max;
         }
         this.x2_handle = new RAxisPainter(pp, this, this.x2axis, 'x2_');
         this.x2_handle.assignSnapId(this.snapid);

         this.x2_handle.configureAxis('x2axis', this.x2min, this.x2max, this.scale_x2min, this.scale_x2max, false, [0, w], w, { reverse: false });
         this.x2_handle.assignFrameMembers(this, 'x2');

         pr1 = this.x2_handle.drawAxis(layer, null, -1);
      }

      if (second_y) {
         if (this.zoom_y2min !== this.zoom_y2max) {
            this.scale_y2min = this.zoom_y2min;
            this.scale_y2max = this.zoom_y2max;
         } else {
            this.scale_y2min = this.y2min;
            this.scale_y2max = this.y2max;
         }

         this.y2_handle = new RAxisPainter(pp, this, this.y2axis, 'y2_');
         this.y2_handle.assignSnapId(this.snapid);

         this.y2_handle.configureAxis('y2axis', this.y2min, this.y2max, this.scale_y2min, this.scale_y2max, true, [h, 0], -h, { reverse: false });
         this.y2_handle.assignFrameMembers(this, 'y2');

         pr2 = this.y2_handle.drawAxis(layer, makeTranslate(w, h), -1);
      }

      return Promise.all([pr1, pr2]);
   }

   /** @summary Return functions to create x/y points based on coordinates
     * @desc In default case returns frame painter itself
     * @private */
   getGrFuncs(second_x, second_y) {
      const use_x2 = second_x && this.grx2,
          use_y2 = second_y && this.gry2;
      if (!use_x2 && !use_y2) return this;

      return {
         use_x2,
         grx: use_x2 ? this.grx2 : this.grx,
         x_handle: use_x2 ? this.x2_handle : this.x_handle,
         logx: use_x2 ? this.x2_handle.log : this.x_handle.log,
         scale_xmin: use_x2 ? this.scale_x2min : this.scale_xmin,
         scale_xmax: use_x2 ? this.scale_x2max : this.scale_xmax,
         use_y2,
         gry: use_y2 ? this.gry2 : this.gry,
         y_handle: use_y2 ? this.y2_handle : this.y_handle,
         logy: use_y2 ? this.y2_handle.log : this.y_handle.log,
         scale_ymin: use_y2 ? this.scale_y2min : this.scale_ymin,
         scale_ymax: use_y2 ? this.scale_y2max : this.scale_ymax,
         swap_xy: this.swap_xy,
         fp: this,
         revertAxis(name, v) {
            if ((name === 'x') && this.use_x2) name = 'x2';
            if ((name === 'y') && this.use_y2) name = 'y2';
            return this.fp.revertAxis(name, v);
         },
         axisAsText(name, v) {
            if ((name === 'x') && this.use_x2) name = 'x2';
            if ((name === 'y') && this.use_y2) name = 'y2';
            return this.fp.axisAsText(name, v);
         }
      };
   }

   /** @summary function called at the end of resize of frame
     * @desc Used to update attributes on the server
     * @private */
   sizeChanged() {
      const changes = {};
      this.v7AttrChange(changes, 'margins_left', this.fX1NDC);
      this.v7AttrChange(changes, 'margins_bottom', this.fY1NDC);
      this.v7AttrChange(changes, 'margins_right', 1 - this.fX2NDC);
      this.v7AttrChange(changes, 'margins_top', 1 - this.fY2NDC);
      this.v7SendAttrChanges(changes, false); // do not invoke canvas update on the server

      this.redrawPad();
   }

   /** @summary Remove all x/y functions
     * @private */
   cleanXY() {
      // remove all axes drawings
      const clean = (name, grname) => {
         this[name]?.cleanup();
         delete this[name];
         delete this[grname];
      };

      clean('x_handle', 'grx');
      clean('y_handle', 'gry');
      clean('z_handle', 'grz');
      clean('x2_handle', 'grx2');
      clean('y2_handle', 'gry2');

      delete this.v6axes; // marker that v6 axes are used
   }

   /** @summary Remove all axes drawings
     * @private */
   cleanupAxes() {
      this.cleanXY();

      this.draw_g?.selectChild('.axis_layer').selectAll('*').remove();
      this.axes_drawn = false;
   }

   /** @summary Removes all drawn elements of the frame
     * @private */
   cleanFrameDrawings() {
      // cleanup all 3D drawings if any
      if (isFunc(this.create3DScene))
         this.create3DScene(-1);

      this.cleanupAxes();

      const clean = (name) => {
         this[name+'min'] = this[name+'max'] = 0;
         this[`zoom_${name}min`] = this[`zoom_${name}max`] = 0;
         this[`scale_${name}min`] = this[`scale_${name}max`] = 0;
      };

      clean('x');
      clean('y');
      clean('z');
      clean('x2');
      clean('y2');

      this.draw_g?.selectChild('.main_layer').selectAll('*').remove();
      this.draw_g?.selectChild('.upper_layer').selectAll('*').remove();
   }

   /** @summary Fully cleanup frame
     * @private */
   cleanup() {
      this.cleanFrameDrawings();

      if (this.draw_g) {
         this.draw_g.selectAll('*').remove();
         this.draw_g.on('mousedown', null)
                    .on('dblclick', null)
                    .on('wheel', null)
                    .on('contextmenu', null)
                    .property('interactive_set', null);
      }

      if (this.keys_handler) {
         window.removeEventListener('keydown', this.keys_handler, false);
         this.keys_handler = null;
      }
      delete this.enabledKeys;
      delete this.self_drawaxes;

      delete this.xaxis;
      delete this.yaxis;
      delete this.zaxis;
      delete this.x2axis;
      delete this.y2axis;

      delete this.draw_g; // frame <g> element managed by the pad

      delete this._click_handler;
      delete this._dblclick_handler;

      const pp = this.getPadPainter();
      if (pp?.frame_painter_ref === this)
         delete pp.frame_painter_ref;

      super.cleanup();
   }

   /** @summary Redraw frame
     * @private */
   redraw() {
      const pp = this.getPadPainter();
      if (pp) pp.frame_painter_ref = this;

      // first update all attributes from objects
      this.updateAttributes();

      const rect = pp?.getPadRect() ?? { width: 10, height: 10 },
            lm = Math.round(rect.width * this.fX1NDC),
            tm = Math.round(rect.height * (1 - this.fY2NDC));
      let w = Math.round(rect.width * (this.fX2NDC - this.fX1NDC)),
          h = Math.round(rect.height * (this.fY2NDC - this.fY1NDC)),
          rotate = false, fixpos = false, trans;

      if (pp?.options) {
         if (pp.options.RotateFrame) rotate = true;
         if (pp.options.FixFrame) fixpos = true;
      }

      if (rotate) {
         trans = `rotate(-90,${lm},${tm}) translate(${lm-h},${tm})`;
         [w, h] = [h, w];
      } else
         trans = makeTranslate(lm, tm);


      // update values here to let access even when frame is not really updated
      this._frame_x = lm;
      this._frame_y = tm;
      this._frame_width = w;
      this._frame_height = h;
      this._frame_rotate = rotate;
      this._frame_fixpos = fixpos;
      this._frame_trans = trans;

      return this.mode3d ? this : this.createFrameG();
   }

   /** @summary Create frame element and update all attributes
     * @private */
   createFrameG() {
      // this is svg:g object - container for every other items belonging to frame
      this.draw_g = this.getFrameSvg();

      let top_rect, main_svg;

      if (this.draw_g.empty()) {
         this.draw_g = this.getLayerSvg('primitives_layer').append('svg:g').attr('class', 'root_frame');

         if (!this.isBatchMode())
            this.draw_g.append('svg:title').text('');

         top_rect = this.draw_g.append('svg:rect');

         main_svg = this.draw_g.append('svg:svg')
                           .attr('class', 'main_layer')
                           .attr('x', 0)
                           .attr('y', 0)
                           .attr('overflow', 'hidden');

         this.draw_g.append('svg:g').attr('class', 'axis_layer');
         this.draw_g.append('svg:g').attr('class', 'upper_layer');
      } else {
         top_rect = this.draw_g.selectChild('rect');
         main_svg = this.draw_g.selectChild('.main_layer');
      }

      this.axes_drawn = false;

      this.draw_g.attr('transform', this._frame_trans);

      top_rect.attr('x', 0)
              .attr('y', 0)
              .attr('width', this._frame_width)
              .attr('height', this._frame_height)
              .attr('rx', this.lineatt.rx || null)
              .attr('ry', this.lineatt.ry || null)
              .call(this.fillatt.func)
              .call(this.lineatt.func);

      main_svg.attr('width', this._frame_width)
              .attr('height', this._frame_height)
              .attr('viewBox', `0 0 ${this._frame_width} ${this._frame_height}`);

      let pr = Promise.resolve(true);

      if (this.v7EvalAttr('drawAxes')) {
         this.self_drawaxes = true;
         this.setAxesRanges();
         pr = this.drawAxes().then(() => this.addInteractivity());
      }

      return pr.then(() => { return this; });
   }

   /** @summary Returns frame X position */
   getFrameX() { return this._frame_x || 0; }

   /** @summary Returns frame Y position */
   getFrameY() { return this._frame_y || 0; }

   /** @summary Returns frame width */
   getFrameWidth() { return this._frame_width || 0; }

   /** @summary Returns frame height */
   getFrameHeight() { return this._frame_height || 0; }

   /** @summary Returns frame rectangle plus extra info for hint display */
   getFrameRect() {
      return {
         x: this._frame_x || 0,
         y: this._frame_y || 0,
         width: this.getFrameWidth(),
         height: this.getFrameHeight(),
         transform: this.draw_g?.attr('transform') || '',
         hint_delta_x: 0,
         hint_delta_y: 0
      };
   }

   /** @summary Returns palette associated with frame */
   getHistPalette() {
      return this.getPadPainter().getHistPalette();
   }

   /** @summary Configure user-defined click handler
     * @desc Function will be called every time when frame click was performed
     * As argument, tooltip object with selected bins will be provided
     * If handler function returns true, default handling of click will be disabled */
   configureUserClickHandler(handler) {
      this._click_handler = isFunc(handler) ? handler : null;
   }

   /** @summary Configure user-defined dblclick handler
     * @desc Function will be called every time when double click was called
     * As argument, tooltip object with selected bins will be provided
     * If handler function returns true, default handling of dblclick (unzoom) will be disabled */
   configureUserDblclickHandler(handler) {
      this._dblclick_handler = isFunc(handler) ? handler : null;
   }

   /** @summary function can be used for zooming into specified range
     * @desc if both limits for each axis 0 (like xmin === xmax === 0), axis will be unzoomed
     * @return {Promise} with boolean flag if zoom operation was performed */
   async zoom(xmin, xmax, ymin, ymax, zmin, zmax, interactive) {
      // disable zooming when axis conversion is enabled
      if (this.projection)
         return false;

      if (xmin === 'x') {
         xmin = xmax; xmax = ymin; interactive = ymax; ymin = ymax = undefined;
      } else if (xmin === 'y') {
         interactive = ymax; ymax = ymin; ymin = xmax; xmin = xmax = undefined;
      } else if (xmin === 'z') {
         interactive = ymax; zmin = xmax; zmax = ymin; xmin = xmax = ymin = ymax = undefined;
      }

      let zoom_x = (xmin !== xmax), zoom_y = (ymin !== ymax), zoom_z = (zmin !== zmax),
          unzoom_x = false, unzoom_y = false, unzoom_z = false;

      if (zoom_x) {
         let cnt = 0;
         if (xmin <= this.xmin) { xmin = this.xmin; cnt++; }
         if (xmax >= this.xmax) { xmax = this.xmax; cnt++; }
         if (cnt === 2) { zoom_x = false; unzoom_x = true; }
      } else
         unzoom_x = (xmin === xmax) && (xmin === 0);

      if (zoom_y) {
         let cnt = 0;
         if (ymin <= this.ymin) { ymin = this.ymin; cnt++; }
         if (ymax >= this.ymax) { ymax = this.ymax; cnt++; }
         if (cnt === 2) { zoom_y = false; unzoom_y = true; }
      } else
         unzoom_y = (ymin === ymax) && (ymin === 0);

      if (zoom_z) {
         let cnt = 0;
         if (zmin <= this.zmin) { zmin = this.zmin; cnt++; }
         if (zmax >= this.zmax) { zmax = this.zmax; cnt++; }
         if (cnt === 2) { zoom_z = false; unzoom_z = true; }
      } else
         unzoom_z = (zmin === zmax) && (zmin === 0);

      let changed = false, r_x = '', r_y = '', r_z = '', is_any_check = false;
      const req = {
         _typename: `${nsREX}RFrame::RUserRanges`,
         values: [0, 0, 0, 0, 0, 0],
         flags: [false, false, false, false, false, false]
      }, checkZooming = (painter, force) => {
         if (!force && !isFunc(painter.canZoomInside)) return;

         is_any_check = true;

         if (zoom_x && (force || painter.canZoomInside('x', xmin, xmax))) {
            this.zoom_xmin = xmin;
            this.zoom_xmax = xmax;
            changed = true; r_x = '0';
            zoom_x = false;
            req.values[0] = xmin; req.values[1] = xmax;
            req.flags[0] = req.flags[1] = true;
            if (interactive)
               this.zoomChangedInteractive('x', interactive);
         }
         if (zoom_y && (force || painter.canZoomInside('y', ymin, ymax))) {
            this.zoom_ymin = ymin;
            this.zoom_ymax = ymax;
            changed = true; r_y = '1';
            zoom_y = false;
            req.values[2] = ymin; req.values[3] = ymax;
            req.flags[2] = req.flags[3] = true;
            if (interactive)
               this.zoomChangedInteractive('y', interactive);
         }
         if (zoom_z && (force || painter.canZoomInside('z', zmin, zmax))) {
            this.zoom_zmin = zmin;
            this.zoom_zmax = zmax;
            changed = true; r_z = '2';
            zoom_z = false;
            req.values[4] = zmin; req.values[5] = zmax;
            req.flags[4] = req.flags[5] = true;
            if (interactive)
               this.zoomChangedInteractive('z', interactive);
         }
      };

      // first process zooming (if any)
      if (zoom_x || zoom_y || zoom_z)
         this.forEachPainter(painter => checkZooming(painter));

      // force zooming when no any other painter can verify zoom range
      if (!is_any_check && this.self_drawaxes)
         checkZooming(null, true);

      // and process unzoom, if any
      if (unzoom_x || unzoom_y || unzoom_z) {
         if (unzoom_x) {
            if (this.zoom_xmin !== this.zoom_xmax) { changed = true; r_x = '0'; }
            this.zoom_xmin = this.zoom_xmax = 0;
            req.values[0] = req.values[1] = -1;
            if (interactive)
               this.zoomChangedInteractive('x', interactive);
         }
         if (unzoom_y) {
            if (this.zoom_ymin !== this.zoom_ymax) { changed = true; r_y = '1'; }
            this.zoom_ymin = this.zoom_ymax = 0;
            req.values[2] = req.values[3] = -1;
            if (interactive)
               this.zoomChangedInteractive('y', interactive);
         }
         if (unzoom_z) {
            if (this.zoom_zmin !== this.zoom_zmax) { changed = true; r_z = '2'; }
            this.zoom_zmin = this.zoom_zmax = 0;
            req.values[4] = req.values[5] = -1;
            if (interactive)
               this.zoomChangedInteractive('z', interactive);
         }
      }

      if (!changed)
         return false;

      if (this.v7NormalMode())
         this.v7SubmitRequest('zoom', { _typename: `${nsREX}RFrame::RZoomRequest`, ranges: req });

      return this.interactiveRedraw('pad', 'zoom' + r_x + r_y + r_z).then(() => true);
   }

   /** @summary Zooming of single axis
     * @param {String} name - axis name like x/y/z but also second axis x2 or y2
     * @param {Number} vmin - axis minimal value, 0 for unzoom
     * @param {Number} vmax - axis maximal value, 0 for unzoom
     * @param {Boolean} [interactive] - if change was performed interactively
     * @protected */
   async zoomSingle(name, vmin, vmax, interactive) {
      const names = ['x', 'y', 'z', 'x2', 'y2'], indx = names.indexOf(name);

      // disable zooming when axis conversion is enabled
      if (this.projection || (!this[`${name}_handle`] && (name !== 'z')) || (indx < 0))
         return false;

      let zoom_v = (vmin !== vmax), unzoom_v = false;

      if (zoom_v) {
         let cnt = 0;
         if (vmin <= this[name+'min']) { vmin = this[name+'min']; cnt++; }
         if (vmax >= this[name+'max']) { vmax = this[name+'max']; cnt++; }
         if (cnt === 2) { zoom_v = false; unzoom_v = true; }
      } else
         unzoom_v = (vmin === vmax) && (vmin === 0);


      let changed = false, is_any_check = false;
      const req = {
             _typename: `${nsREX}RFrame::RUserRanges`,
             values: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
             flags: [false, false, false, false, false, false, false, false, false, false]
       },

       checkZooming = (painter, force) => {
         if (!force && !isFunc(painter?.canZoomInside)) return;

         is_any_check = true;

         if (zoom_v && (force || painter.canZoomInside(name[0], vmin, vmax))) {
            this[`zoom_${name}min`] = vmin;
            this[`zoom_${name}max`] = vmax;
            changed = true;
            zoom_v = false;
            req.values[indx*2] = vmin; req.values[indx*2+1] = vmax;
            req.flags[indx*2] = req.flags[indx*2+1] = true;
         }
      };

      // first process zooming (if any)
      if (zoom_v)
         this.forEachPainter(painter => checkZooming(painter));

      // force zooming when no any other painter can verify zoom range
      if (!is_any_check && this.self_drawaxes)
         checkZooming(null, true);

      if (unzoom_v) {
         if (this[`zoom_${name}min`] !== this[`zoom_${name}max`]) changed = true;
         this[`zoom_${name}min`] = this[`zoom_${name}max`] = 0;
         req.values[indx*2] = req.values[indx*2+1] = -1;
      }

      if (!changed) return false;

      if (interactive)
         this.zoomChangedInteractive(name, interactive);

      if (this.v7NormalMode())
         this.v7SubmitRequest('zoom', { _typename: `${nsREX}RFrame::RZoomRequest`, ranges: req });

      return this.interactiveRedraw('pad', `zoom${indx}`).then(() => true);
   }

   /** @summary Unzoom single axis */
   async unzoomSingle(name, interactive) {
      return this.zoomSingle(name, 0, 0, typeof interactive === 'undefined' ? 'unzoom' : interactive);
   }

   /** @summary Checks if specified axis zoomed */
   isAxisZoomed(axis) {
      return this[`zoom_${axis}min`] !== this[`zoom_${axis}max`];
   }

   /** @summary Unzoom specified axes
     * @return {Promise} with boolean flag if zoom is changed */
   async unzoom(dox, doy, doz) {
      if (dox === 'all')
         return this.unzoom('x2').then(() => this.unzoom('y2')).then(() => this.unzoom('xyz'));

      if ((dox === 'x2') || (dox === 'y2'))
         return this.unzoomSingle(dox);

      if (typeof dox === 'undefined')
         dox = doy = doz = true;
      else if (isStr(dox)) {
         doz = dox.indexOf('z') >= 0;
         doy = dox.indexOf('y') >= 0;
         dox = dox.indexOf('x') >= 0;
      }

      return this.zoom(dox ? 0 : undefined, dox ? 0 : undefined,
                       doy ? 0 : undefined, doy ? 0 : undefined,
                       doz ? 0 : undefined, doz ? 0 : undefined,
                       'unzoom');
   }

   /** @summary Reset all zoom attributes
    * @private */
   resetZoom() {
      ['x', 'y', 'z', 'x2', 'y2'].forEach(n => {
         this[`zoom_${n}min`] = undefined;
         this[`zoom_${n}max`] = undefined;
         this[`zoom_changed_${n}`] = undefined;
      });
   }

   /** @summary Mark/check if zoom for specific axis was changed interactively
     * @private */
   zoomChangedInteractive(axis, value) {
      if (axis === 'reset') {
         this.zoom_changed_x = this.zoom_changed_y = this.zoom_changed_z = undefined;
         return;
      }
      if (!axis || axis === 'any')
         return this.zoom_changed_x || this.zoom_changed_y || this.zoom_changed_z;

      if ((axis !== 'x') && (axis !== 'y') && (axis !== 'z')) return;

      const fld = 'zoom_changed_' + axis;
      if (value === undefined) return this[fld];

      if (value === 'unzoom') {
         // special handling of unzoom, only if was never changed before flag set to true
         this[fld] = (this[fld] === undefined);
         return;
      }

      if (value) this[fld] = true;
   }

   /** @summary Fill menu for frame when server is not there */
   fillObjectOfflineMenu(menu, kind) {
      if ((kind !== 'x') && (kind !== 'y')) return;

      menu.add('Unzoom', () => this.unzoom(kind));

      // here should be all axes attributes in offline
   }

   /** @summary Set grid drawing for specified axis */
   changeFrameAttr(attr, value) {
      const changes = {};
      this.v7AttrChange(changes, attr, value);
      this.v7SetAttr(attr, value);
      this.v7SendAttrChanges(changes, false); // do not invoke canvas update on the server
      this.redrawPad();
   }

   /** @summary Fill context menu */
   fillContextMenu(menu, kind, obj) {
      if (kind === 'pal') kind = 'z';

      if ((kind === 'x') || (kind === 'y') || (kind === 'x2') || (kind === 'y2')) {
         const handle = this[kind+'_handle'],
               faxis = obj || this[kind+'axis'];
         if (!handle) return false;
         menu.header(`${kind.toUpperCase()} axis`, `${urlClassPrefix}ROOT_1_1Experimental_1_1RAxisBase.html`);

         if (isFunc(faxis?.TestBit)) {
            const main = this.getMainPainter(true);
            menu.addTAxisMenu(EAxisBits, main || this, faxis, kind);
            return true;
         }

         return handle.fillAxisContextMenu(menu, kind);
      }

      const alone = menu.size() === 0;

      if (alone)
         menu.header('Frame', `${urlClassPrefix}ROOT_1_1Experimental_1_1RFrame.html`);
      else
         menu.separator();

      if (this.zoom_xmin !== this.zoom_xmax)
         menu.add('Unzoom X', () => this.unzoom('x'));
      if (this.zoom_ymin !== this.zoom_ymax)
         menu.add('Unzoom Y', () => this.unzoom('y'));
      if (this.zoom_zmin !== this.zoom_zmax)
         menu.add('Unzoom Z', () => this.unzoom('z'));
      if (this.zoom_x2min !== this.zoom_x2max)
         menu.add('Unzoom X2', () => this.unzoom('x2'));
      if (this.zoom_y2min !== this.zoom_y2max)
         menu.add('Unzoom Y2', () => this.unzoom('y2'));
      menu.add('Unzoom all', () => this.unzoom('all'));

      menu.separator();

      menu.addchk(this.isTooltipAllowed(), 'Show tooltips', () => this.setTooltipAllowed('toggle'));

      if (this.x_handle)
         menu.addchk(this.x_handle.draw_grid, 'Grid x', flag => this.changeFrameAttr('gridX', flag));
      if (this.y_handle)
         menu.addchk(this.y_handle.draw_grid, 'Grid y', flag => this.changeFrameAttr('gridY', flag));
      if (this.x_handle && !this.x2_handle)
         menu.addchk(this.x_handle.draw_swapside, 'Swap x', flag => this.changeFrameAttr('swapX', flag));
      if (this.y_handle && !this.y2_handle)
         menu.addchk(this.y_handle.draw_swapside, 'Swap y', flag => this.changeFrameAttr('swapY', flag));
      if (this.x_handle && !this.x2_handle) {
         menu.sub('Ticks x');
         menu.addchk(this.x_handle.draw_ticks === 0, 'off', () => this.changeFrameAttr('ticksX', 0));
         menu.addchk(this.x_handle.draw_ticks === 1, 'normal', () => this.changeFrameAttr('ticksX', 1));
         menu.addchk(this.x_handle.draw_ticks === 2, 'ticks on both sides', () => this.changeFrameAttr('ticksX', 2));
         menu.addchk(this.x_handle.draw_ticks === 3, 'labels on both sides', () => this.changeFrameAttr('ticksX', 3));
         menu.endsub();
       }
      if (this.y_handle && !this.y2_handle) {
         menu.sub('Ticks y');
         menu.addchk(this.y_handle.draw_ticks === 0, 'off', () => this.changeFrameAttr('ticksY', 0));
         menu.addchk(this.y_handle.draw_ticks === 1, 'normal', () => this.changeFrameAttr('ticksY', 1));
         menu.addchk(this.y_handle.draw_ticks === 2, 'ticks on both sides', () => this.changeFrameAttr('ticksY', 2));
         menu.addchk(this.y_handle.draw_ticks === 3, 'labels on both sides', () => this.changeFrameAttr('ticksY', 3));
         menu.endsub();
       }

      menu.addAttributesMenu(this, alone ? '' : 'Frame ');
      menu.separator();

      menu.sub('Save as');
      const fmts = ['svg', 'png', 'jpeg', 'webp'];
      if (internals.makePDF) fmts.push('pdf');
      fmts.forEach(fmt => menu.add(`frame.${fmt}`, () => this.getPadPainter().saveAs(fmt, 'frame', `frame.${fmt}`)));
      menu.endsub();

      return true;
   }

   /** @summary Convert graphical coordinate into axis value */
   revertAxis(axis, pnt) { return this[`${axis}_handle`]?.revertPoint(pnt) ?? 0; }

   /** @summary Show axis status message
     * @desc method called normally when mouse enter main object element
     * @private */
   showAxisStatus(axis_name, evnt) {
      const hint_name = axis_name, hint_title = 'axis',
            m = d3_pointer(evnt, this.getFrameSvg().node());
      let id = (axis_name === 'x') ? 0 : 1;

      if (this.swap_xy) id = 1 - id;

      const axis_value = this.revertAxis(axis_name, m[id]);

      this.showObjectStatus(hint_name, hint_title, `${axis_name} : ${this.axisAsText(axis_name, axis_value)}`, `${Math.round(m[0])},${Math.round(m[1])}`);
   }

   /** @summary Add interactive keys handlers
    * @private */
   addKeysHandler() {
      if (this.isBatchMode()) return;
      FrameInteractive.assign(this);
      this.addFrameKeysHandler();
   }

   /** @summary Add interactive functionality to the frame
    * @private */
   addInteractivity(for_second_axes) {
      if (this.isBatchMode() || (!settings.Zooming && !settings.ContextMenu))
         return true;

      FrameInteractive.assign(this);
      if (!for_second_axes)
         this.addBasicInteractivity();
      return this.addFrameInteractivity(for_second_axes);
   }

   /** @summary Set selected range back to pad object - to be implemented
     * @private */
   setRootPadRange(/* pad, is3d */) {
      // TODO: change of pad range and send back to root application
   }

   /** @summary Toggle log scale on the specified axes */
   toggleAxisLog(axis) {
      const handle = this[axis+'_handle'];
      return handle?.changeAxisLog('toggle');
   }

} // class RFramePainter

export { RFramePainter };