draw/TSplinePainter.mjs

import { gStyle, clTH1I, kNoStats, createHistogram } from '../core.mjs';
import { DrawOptions, floatToString, buildSvgCurve } from '../base/BasePainter.mjs';
import { ObjectPainter } from '../base/ObjectPainter.mjs';
import { TH1Painter } from '../hist/TH1Painter.mjs';


/**
 * @summary Painter for TSpline objects.
 *
 * @private
 */

class TSplinePainter extends ObjectPainter {

   /** @summary Update TSpline object
     * @private */
   updateObject(obj, opt) {
      const spline = this.getObject();

      if (spline._typename !== obj._typename)
         return false;

      if (spline !== obj)
         Object.assign(spline, obj);

      if (opt !== undefined)
         this.decodeOptions(opt);

      return true;
   }

   /** @summary Evaluate spline at given position
     * @private */
   eval(knot, x) {
      const dx = x - knot.fX;

      if (knot._typename === 'TSplinePoly3')
         return knot.fY + dx*(knot.fB + dx*(knot.fC + dx*knot.fD));

      if (knot._typename === 'TSplinePoly5')
         return knot.fY + dx*(knot.fB + dx*(knot.fC + dx*(knot.fD + dx*(knot.fE + dx*knot.fF))));

      return knot.fY + dx;
   }

   /** @summary Find idex for x value
     * @private */
   findX(x) {
      const spline = this.getObject();
      let klow = 0, khig = spline.fNp - 1;

      if (x <= spline.fXmin) return 0;
      if (x >= spline.fXmax) return khig;

      if (spline.fKstep) {
         // Equidistant knots, use histogram
         klow = Math.round((x - spline.fXmin)/spline.fDelta);
         // Correction for rounding errors
         if (x < spline.fPoly[klow].fX)
            klow = Math.max(klow-1, 0);
          else if (klow < khig)
            if (x > spline.fPoly[klow+1].fX) ++klow;
      } else {
         // Non equidistant knots, binary search
         while (khig - klow > 1) {
            const khalf = Math.round((klow + khig)/2);
            if (x > spline.fPoly[khalf].fX) klow = khalf;
                                      else khig = khalf;
         }
      }
      return klow;
   }

   /** @summary Create histogram for axes drawing
     * @private */
   createDummyHisto() {
      const spline = this.getObject();
      let xmin = 0, xmax = 1, ymin = 0, ymax = 1;

      if (spline.fPoly) {
         xmin = xmax = spline.fPoly[0].fX;
         ymin = ymax = spline.fPoly[0].fY;

         spline.fPoly.forEach(knot => {
            xmin = Math.min(knot.fX, xmin);
            xmax = Math.max(knot.fX, xmax);
            ymin = Math.min(knot.fY, ymin);
            ymax = Math.max(knot.fY, ymax);
         });

         if (ymax > 0) ymax *= (1 + gStyle.fHistTopMargin);
         if (ymin < 0) ymin *= (1 + gStyle.fHistTopMargin);
      }

      const histo = createHistogram(clTH1I, 10);

      histo.fName = spline.fName + '_hist';
      histo.fTitle = spline.fTitle;
      histo.fBits |= kNoStats;

      histo.fXaxis.fXmin = xmin;
      histo.fXaxis.fXmax = xmax;
      histo.fYaxis.fXmin = ymin;
      histo.fYaxis.fXmax = ymax;
      histo.fMinimum = ymin;
      histo.fMaximum = ymax;

      return histo;
   }

   /** @summary Process tooltip event
     * @private */
   processTooltipEvent(pnt) {
      const spline = this.getObject(),
            funcs = this.getFramePainter()?.getGrFuncs(this.options.second_x, this.options.second_y);
      let cleanup = false, xx, yy, knot = null, indx = 0;

      if ((pnt === null) || !spline || !funcs)
         cleanup = true;
       else {
         xx = funcs.revertAxis('x', pnt.x);
         indx = this.findX(xx);
         knot = spline.fPoly[indx];
         yy = this.eval(knot, xx);

         if ((indx < spline.fN-1) && (Math.abs(spline.fPoly[indx+1].fX-xx) < Math.abs(xx-knot.fX))) knot = spline.fPoly[++indx];

         if (Math.abs(funcs.grx(knot.fX) - pnt.x) < 0.5*this.knot_size) {
            xx = knot.fX; yy = knot.fY;
         } else {
            knot = null;
            if ((xx < spline.fXmin) || (xx > spline.fXmax)) cleanup = true;
         }
      }

      let gbin = this.draw_g?.selectChild('.tooltip_bin');
      const radius = this.lineatt.width + 3;

      if (cleanup || !this.draw_g) {
         gbin?.remove();
         return null;
      }

      if (gbin.empty()) {
         gbin = this.draw_g.append('svg:circle')
                           .attr('class', 'tooltip_bin')
                           .style('pointer-events', 'none')
                           .attr('r', radius)
                           .style('fill', 'none')
                           .call(this.lineatt.func);
      }

      const res = { name: this.getObject().fName,
                  title: this.getObject().fTitle,
                  x: funcs.grx(xx),
                  y: funcs.gry(yy),
                  color1: this.lineatt.color,
                  lines: [],
                  exact: (knot !== null) || (Math.abs(funcs.gry(yy) - pnt.y) < radius) };

      res.changed = gbin.property('current_xx') !== xx;
      res.menu = res.exact;
      res.menu_dist = Math.sqrt((res.x-pnt.x)**2 + (res.y-pnt.y)**2);

      if (res.changed) {
         gbin.attr('cx', Math.round(res.x))
             .attr('cy', Math.round(res.y))
             .property('current_xx', xx);
      }

      const name = this.getObjectHint();
      if (name) res.lines.push(name);
      res.lines.push(`x = ${funcs.axisAsText('x', xx)}`,
                     `y = ${funcs.axisAsText('y', yy)}`);
      if (knot !== null) {
         res.lines.push(`knot = ${indx}`,
                        `B = ${floatToString(knot.fB, gStyle.fStatFormat)}`,
                        `C = ${floatToString(knot.fC, gStyle.fStatFormat)}`,
                        `D = ${floatToString(knot.fD, gStyle.fStatFormat)}`);
         if ((knot.fE !== undefined) && (knot.fF !== undefined)) {
            res.lines.push(`E = ${floatToString(knot.fE, gStyle.fStatFormat)}`,
                           `F = ${floatToString(knot.fF, gStyle.fStatFormat)}`);
         }
      }

      return res;
   }

   /** @summary Redraw object
     * @private */
   redraw() {
      const spline = this.getObject(),
          pmain = this.getFramePainter(),
          funcs = pmain.getGrFuncs(this.options.second_x, this.options.second_y),
          w = pmain.getFrameWidth(),
          h = pmain.getFrameHeight();

      this.createG(true);

      this.knot_size = 5; // used in tooltip handling

      this.createAttLine({ attr: spline });

      if (this.options.Line || this.options.Curve) {
         const npx = Math.max(10, spline.fNpx), bins = []; // index of current knot
         let xmin = Math.max(funcs.scale_xmin, spline.fXmin),
             xmax = Math.min(funcs.scale_xmax, spline.fXmax),
             indx = this.findX(xmin);

         if (pmain.logx) {
            xmin = Math.log(xmin);
            xmax = Math.log(xmax);
         }

         for (let n = 0; n < npx; ++n) {
            let x = xmin + (xmax-xmin)/npx*(n-1);
            if (pmain.logx) x = Math.exp(x);

            while ((indx < spline.fNp-1) && (x > spline.fPoly[indx+1].fX)) ++indx;

            const y = this.eval(spline.fPoly[indx], x);

            bins.push({ x, y, grx: funcs.grx(x), gry: funcs.gry(y) });
         }

         this.draw_g.append('svg:path')
             .attr('class', 'line')
             .attr('d', buildSvgCurve(bins))
             .style('fill', 'none')
             .call(this.lineatt.func);
      }

      if (this.options.Mark) {
         // for tooltips use markers only if nodes where not created
         let path = '';

         this.createAttMarker({ attr: spline });

         this.markeratt.resetPos();

         this.knot_size = this.markeratt.getFullSize();

         for (let n = 0; n < spline.fPoly.length; n++) {
            const knot = spline.fPoly[n],
                grx = funcs.grx(knot.fX);
            if ((grx > -this.knot_size) && (grx < w + this.knot_size)) {
               const gry = funcs.gry(knot.fY);
               if ((gry > -this.knot_size) && (gry < h + this.knot_size))
                  path += this.markeratt.create(grx, gry);
            }
         }

         if (path) {
            this.draw_g.append('svg:path')
                       .attr('d', path)
                       .call(this.markeratt.func);
         }
      }
   }

   /** @summary Checks if it makes sense to zoom inside specified axis range */
   canZoomInside(axis /* , min, max */) {
      if (axis !== 'x') return false;

      // spline can always be calculated and therefore one can zoom inside
      return Boolean(this.getObject());
   }

   /** @summary Decode options for TSpline drawing */
   decodeOptions(opt) {
      const d = new DrawOptions(opt);

      if (!this.options) this.options = {};

      const has_main = Boolean(this.getMainPainter());

      Object.assign(this.options, {
         Same: d.check('SAME'),
         Line: d.check('L'),
         Curve: d.check('C'),
         Mark: d.check('P'),
         Hopt: '',
         second_x: false,
         second_y: false
      });

      if (!this.options.Line && !this.options.Curve && !this.options.Mark)
         this.options.Curve = true;

      if (d.check('X+')) { this.options.Hopt += 'X+'; this.options.second_x = has_main; }
      if (d.check('Y+')) { this.options.Hopt += 'Y+'; this.options.second_y = has_main; }

      this.storeDrawOpt(opt);
   }

   /** @summary Draw TSpline */
   static async draw(dom, spline, opt) {
      const painter = new TSplinePainter(dom, spline);
      painter.decodeOptions(opt);

      const no_main = !painter.getMainPainter();
      let promise = Promise.resolve();
      if (no_main || painter.options.second_x || painter.options.second_y) {
         if (painter.options.Same && no_main) {
            console.warn('TSpline painter requires histogram to be drawn');
            return null;
         }
         const histo = painter.createDummyHisto();
         promise = TH1Painter.draw(dom, histo, painter.options.Hopt);
      }

      return promise.then(() => {
         painter.addToPadPrimitives();
         painter.redraw();
         return painter;
      });
   }

} // class TSplinePainter

export { TSplinePainter };