jsroot
Version:
JavaScript ROOT
1,421 lines (1,162 loc) • 119 kB
JavaScript
import { gStyle, settings, internals, isFunc, isStr, postponePromise, browser,
clTAxis, clTFrame, kNoZoom, urlClassPrefix } from '../core.mjs';
import { select as d3_select, pointer as d3_pointer,
pointers as d3_pointers, drag as d3_drag, rgb as d3_rgb } from '../d3.mjs';
import { getElementRect, getAbsPosInCanvas, makeTranslate, addHighlightStyle, getBoxDecorations } from '../base/BasePainter.mjs';
import { getActivePad, ObjectPainter, EAxisBits, kAxisLabels } from '../base/ObjectPainter.mjs';
import { getSvgLineStyle } from '../base/TAttLineHandler.mjs';
import { TAxisPainter } from './TAxisPainter.mjs';
import { FontHandler } from '../base/FontHandler.mjs';
import { createMenu, closeMenu, showPainterMenu, hasMenu } from '../gui/menu.mjs';
import { detectRightButton } from '../gui/utils.mjs';
const logminfactorX = 0.0001, logminfactorY = 3e-4;
/** @summary Configure tooltip enable flag for painter
* @private */
function setPainterTooltipEnabled(painter, on) {
if (!painter)
return;
const fp = painter.getFramePainter();
if (isFunc(fp?.setTooltipEnabled)) {
fp.setTooltipEnabled(on);
fp.processFrameTooltipEvent(null);
}
// this is 3D control object
if (isFunc(painter.control?.setTooltipEnabled))
painter.control.setTooltipEnabled(on);
}
/** @summary Return pointers on touch event
* @private */
function get_touch_pointers(event, node) {
return event.$touch_arr ?? d3_pointers(event, node);
}
/** @summary Returns coordinates transformation func
* @private */
function getEarthProjectionFunc(id) {
switch (id) {
// Aitoff2xy
case 1: return (l, b) => {
const DegToRad = Math.PI / 180,
alpha2 = (l / 2) * DegToRad,
delta = b * DegToRad,
r2 = Math.sqrt(2),
f = 2 * r2 / Math.PI,
cdec = Math.cos(delta),
denom = Math.sqrt(1.0 + cdec * Math.cos(alpha2));
return {
x: cdec * Math.sin(alpha2) * 2.0 * r2 / denom / f / DegToRad,
y: Math.sin(delta) * r2 / denom / f / DegToRad
};
};
// mercator
case 2: return (l, b) => { return { x: l, y: Math.log(Math.tan((Math.PI / 2 + b / 180 * Math.PI) / 2)) }; };
// sinusoidal
case 3: return (l, b) => { return { x: l * Math.cos(b / 180 * Math.PI), y: b }; };
// parabolic
case 4: return (l, b) => { return { x: l * (2.0 * Math.cos(2 * b / 180 * Math.PI / 3) - 1), y: 180 * Math.sin(b / 180 * Math.PI / 3) }; };
// Mollweide projection
case 5: return (l, b) => {
const theta0 = b * Math.PI / 180;
let theta = theta0, num, den;
for (let i = 0; i < 100; i++) {
num = 2 * theta + Math.sin(2 * theta) - Math.PI * Math.sin(theta0);
den = 4 * (Math.cos(theta) ** 2);
if (den < 1e-20) {
theta = theta0;
break;
}
theta -= num / den;
if (Math.abs(num / den) < 1e-4)
break;
}
return {
x: l * Math.cos(theta),
y: 90 * Math.sin(theta)
};
};
}
}
/** @summary Unzoom preselected range for main histogram painter
* @desc Used with TGraph where Y zooming selected with fMinimum/fMaximum but histogram
* axis range can be wider. Or for normal histogram drawing when preselected range smaller than histogram range
* @private */
function unzoomHistogramYRange(main) {
if (!isFunc(main?.getDimension) || main.getDimension() !== 1)
return;
const ymin = main.draw_content ? main.hmin : main.ymin,
ymax = main.draw_content ? main.hmax : main.ymax;
if ((main.zoom_ymin !== main.zoom_ymax) && (ymin !== ymax) &&
(ymin <= main.zoom_ymin) && (main.zoom_ymax <= ymax))
main.zoom_ymin = main.zoom_ymax = 0;
}
// global, allow single drag at once
let drag_rect = null, drag_kind = '', drag_painter = null;
/** @summary Check if dragging performed currently
* @private */
function is_dragging(painter, kind) {
return drag_rect && (drag_painter === painter) && (drag_kind === kind);
}
/** @summary Add drag for interactive rectangular elements for painter
* @private */
function addDragHandler(_painter, arg) {
if (!settings.MoveResize)
return;
const painter = _painter, pp = painter.getPadPainter();
if (pp?.isFastDrawing() || pp?.isBatchMode())
return;
// cleanup all drag elements when canvas is not editable
if (pp?.isEditable() === false)
arg.cleanup = true;
if (!isFunc(arg.getDrawG))
arg.getDrawG = () => painter?.getG();
function makeResizeElements(group, handler) {
function addElement(cursor, d) {
const clname = 'js_' + cursor.replace(/[-]/g, '_');
let elem = group.selectChild('.' + clname);
if (arg.cleanup)
return elem.remove();
if (elem.empty())
elem = group.append('path').classed(clname, true);
elem.style('opacity', 0).style('cursor', cursor).attr('d', d);
if (handler)
elem.call(handler);
}
addElement('nw-resize', 'M2,2h15v-5h-20v20h5Z');
addElement('ne-resize', `M${arg.width - 2},2h-15v-5h20v20h-5 Z`);
addElement('sw-resize', `M2,${arg.height - 2}h15v5h-20v-20h5Z`);
addElement('se-resize', `M${arg.width - 2},${arg.height - 2}h-15v5h20v-20h-5Z`);
if (!arg.no_change_x) {
addElement('w-resize', `M-3,18h5v${Math.max(0, arg.height - 2 * 18)}h-5Z`);
addElement('e-resize', `M${arg.width + 3},18h-5v${Math.max(0, arg.height - 2 * 18)}h5Z`);
}
if (!arg.no_change_y) {
addElement('n-resize', `M18,-3v5h${Math.max(0, arg.width - 2 * 18)}v-5Z`);
addElement('s-resize', `M18,${arg.height + 3}v-5h${Math.max(0, arg.width - 2 * 18)}v5Z`);
}
}
const complete_drag = (newx, newy, newwidth, newheight) => {
drag_painter = null;
drag_kind = '';
if (drag_rect) {
drag_rect.remove();
drag_rect = null;
}
const draw_g = arg.getDrawG();
if (!draw_g)
return false;
const oldx = arg.x, oldy = arg.y;
if (arg.minwidth && newwidth < arg.minwidth)
newwidth = arg.minwidth;
if (arg.minheight && newheight < arg.minheight)
newheight = arg.minheight;
const change_size = (newwidth !== arg.width) || (newheight !== arg.height),
change_pos = (newx !== oldx) || (newy !== oldy);
arg.x = newx;
arg.y = newy;
arg.width = newwidth;
arg.height = newheight;
if (!arg.no_transform)
makeTranslate(draw_g, newx, newy);
setPainterTooltipEnabled(painter, true);
makeResizeElements(draw_g);
if (change_size || change_pos) {
if (change_size && isFunc(arg.resize))
arg.resize(newwidth, newheight);
if (change_pos && isFunc(arg.move))
arg.move(newx, newy, newx - oldx, newy - oldy);
if (change_size || change_pos) {
if (arg.obj) {
const rect = arg.pad_rect ?? pp.getPadRect();
arg.obj.fX1NDC = newx / rect.width;
arg.obj.fX2NDC = (newx + newwidth) / rect.width;
arg.obj.fY1NDC = 1 - (newy + newheight) / rect.height;
arg.obj.fY2NDC = 1 - newy / rect.height;
arg.obj.$modifiedNDC = true; // indicate that NDC was interactively changed, block in updated
} else if (isFunc(arg.move_resize))
arg.move_resize(newx, newy, newwidth, newheight);
if (isFunc(arg.redraw))
arg.redraw(arg);
}
}
return change_size || change_pos;
}, drag_move = d3_drag().subject(Object), drag_move_off = d3_drag().subject(Object);
drag_move_off.on('start', null).on('drag', null).on('end', null);
drag_move
.on('start', evnt => {
if (detectRightButton(evnt.sourceEvent) || drag_kind)
return;
if (isFunc(arg.is_disabled) && arg.is_disabled('move'))
return;
closeMenu(); // close menu
setPainterTooltipEnabled(painter, false); // disable tooltip
evnt.sourceEvent.preventDefault();
evnt.sourceEvent.stopPropagation();
const pad_rect = arg.pad_rect ?? pp.getPadRect(), handle = {
x: arg.x, y: arg.y, width: arg.width, height: arg.height,
acc_x1: arg.x, acc_y1: arg.y,
pad_w: pad_rect.width - arg.width,
pad_h: pad_rect.height - arg.height,
drag_tm: new Date(),
path: `v${arg.height}h${arg.width}v${-arg.height}z`,
evnt_x: evnt.x, evnt_y: evnt.y
};
drag_painter = painter;
drag_kind = 'move';
drag_rect = d3_select(arg.getDrawG().node().parentNode).append('path')
.attr('d', `M${handle.acc_x1},${handle.acc_y1}${handle.path}`)
.style('cursor', 'move')
.style('pointer-events', 'none') // let forward double click to underlying elements
.property('drag_handle', handle)
.call(addHighlightStyle, true);
}).on('drag', evnt => {
if (!is_dragging(painter, 'move'))
return;
evnt.sourceEvent.preventDefault();
evnt.sourceEvent.stopPropagation();
const handle = drag_rect.property('drag_handle');
if (!arg.no_change_x)
handle.acc_x1 += evnt.dx;
if (!arg.no_change_y)
handle.acc_y1 += evnt.dy;
handle.x = Math.min(Math.max(handle.acc_x1, 0), handle.pad_w);
handle.y = Math.min(Math.max(handle.acc_y1, 0), handle.pad_h);
drag_rect.attr('d', `M${handle.x},${handle.y}${handle.path}`);
}).on('end', evnt => {
if (!is_dragging(painter, 'move'))
return;
evnt.sourceEvent.stopPropagation();
evnt.sourceEvent.preventDefault();
const handle = drag_rect.property('drag_handle');
if (complete_drag(handle.x, handle.y, arg.width, arg.height) === false) {
const spent = (new Date()).getTime() - handle.drag_tm.getTime();
if (arg.ctxmenu && (spent > 600))
showPainterMenu({ clientX: handle.evnt_x, clientY: handle.evnt_y, skip_close: 1 }, painter);
else if (arg.canselect && (spent <= 600))
painter.getPadPainter()?.selectObjectPainter(painter);
}
});
const drag_resize = d3_drag().subject(Object);
drag_resize
.on('start', function(evnt) {
if (detectRightButton(evnt.sourceEvent) || drag_kind)
return;
if (isFunc(arg.is_disabled) && arg.is_disabled('resize'))
return;
closeMenu(); // close menu
setPainterTooltipEnabled(painter, false); // disable tooltip
evnt.sourceEvent.stopPropagation();
evnt.sourceEvent.preventDefault();
const pad_rect = arg.pad_rect ?? pp.getPadRect(), handle = {
x: arg.x, y: arg.y, width: arg.width, height: arg.height,
acc_x1: arg.x, acc_y1: arg.y,
acc_x2: arg.x + arg.width, acc_y2: arg.y + arg.height,
pad_w: pad_rect.width, pad_h: pad_rect.height
};
drag_painter = painter;
drag_kind = 'resize';
drag_rect = d3_select(arg.getDrawG().node().parentNode)
.append('rect')
.style('cursor', d3_select(this).style('cursor'))
.attr('x', handle.acc_x1)
.attr('y', handle.acc_y1)
.attr('width', handle.acc_x2 - handle.acc_x1)
.attr('height', handle.acc_y2 - handle.acc_y1)
.property('drag_handle', handle)
.call(addHighlightStyle, true);
}).on('drag', function(evnt) {
if (!is_dragging(painter, 'resize'))
return;
evnt.sourceEvent.preventDefault();
evnt.sourceEvent.stopPropagation();
const handle = drag_rect.property('drag_handle'),
elem = d3_select(this),
dx = arg.no_change_x ? 0 : evnt.dx,
dy = arg.no_change_y ? 0 : evnt.dy;
if (elem.classed('js_nw_resize')) {
handle.acc_x1 += dx;
handle.acc_y1 += dy;
} else if (elem.classed('js_ne_resize')) {
handle.acc_x2 += dx;
handle.acc_y1 += dy;
} else if (elem.classed('js_sw_resize')) {
handle.acc_x1 += dx;
handle.acc_y2 += dy;
} else if (elem.classed('js_se_resize')) {
handle.acc_x2 += dx;
handle.acc_y2 += dy;
} else if (elem.classed('js_w_resize'))
handle.acc_x1 += dx;
else if (elem.classed('js_n_resize'))
handle.acc_y1 += dy;
else if (elem.classed('js_e_resize'))
handle.acc_x2 += dx;
else if (elem.classed('js_s_resize'))
handle.acc_y2 += dy;
const x1 = Math.max(0, handle.acc_x1), x2 = Math.min(handle.acc_x2, handle.pad_w),
y1 = Math.max(0, handle.acc_y1), y2 = Math.min(handle.acc_y2, handle.pad_h);
handle.x = Math.min(x1, x2);
handle.y = Math.min(y1, y2);
handle.width = Math.abs(x2 - x1);
handle.height = Math.abs(y2 - y1);
drag_rect.attr('x', handle.x).attr('y', handle.y).attr('width', handle.width).attr('height', handle.height);
}).on('end', evnt => {
if (!is_dragging(painter, 'resize'))
return;
evnt.sourceEvent.preventDefault();
const handle = drag_rect.property('drag_handle');
complete_drag(handle.x, handle.y, handle.width, handle.height);
});
if (!arg.only_resize)
arg.getDrawG().style('cursor', arg.cleanup ? null : 'move').call(arg.cleanup ? drag_move_off : drag_move);
if (!arg.only_move)
makeResizeElements(arg.getDrawG(), drag_resize);
}
/** @summary Tooltip handler class
* @private */
class TooltipHandler extends ObjectPainter {
// cannot use private members because of RFramePainter
/** @desc only canvas info_layer can be used while other pads can overlay
* @return layer where frame tooltips are shown */
hints_layer() {
return this.getCanvPainter()?.getLayerSvg('info_layer') ?? d3_select(null);
}
/** @return true if tooltip is shown, use to prevent some other action */
isTooltipShown() {
if (!this.tooltip_enabled || !this.isTooltipAllowed())
return false;
const hintsg = this.hints_layer().selectChild('.objects_hints');
return hintsg.empty() ? false : hintsg.property('hints_pad') === this.getPadPainter()?.getPadName();
}
/** @summary set tooltips enabled on/off */
setTooltipEnabled(enabled) {
if (enabled !== undefined)
this.tooltip_enabled = enabled;
}
/** @summary central function which let show selected hints for the object */
processFrameTooltipEvent(pnt, evnt) {
if (pnt?.handler) {
// special use of interactive handler in the frame painter
const rect = this.getG()?.selectChild('.main_layer');
if (!rect || rect.empty())
pnt = null; // disable
else if (pnt.touch && evnt) {
const pos = get_touch_pointers(evnt, rect.node());
pnt = (pos && pos.length === 1) ? { touch: true, x: pos[0][0], y: pos[0][1] } : null;
} else if (evnt) {
const pos = d3_pointer(evnt, rect.node());
pnt = { touch: false, x: pos[0], y: pos[1] };
}
}
let nhints = 0, nexact = 0, maxlen = 0, lastcolor1 = 0, usecolor1 = false;
const hmargin = 3, wmargin = 3, hstep = 1.2,
frame_rect = this.getFrameRect(),
pp = this.getPadPainter(),
pad_width = pp?.getPadWidth(),
scale = pp?.getPadScale() ?? 1,
textheight = (pnt?.touch ? 15 : 11) * scale,
font = new FontHandler(160, textheight),
disable_tootlips = !this.isTooltipAllowed() || !this.tooltip_enabled;
if (pnt) {
pnt.disabled = disable_tootlips; // indicate that highlighting is not required
pnt.painters = true; // get also painter
}
// collect tooltips from pad painter - it has list of all drawn objects
const hints = pp?.processPadTooltipEvent(pnt) ?? [];
if (pnt && frame_rect)
pp.deliverWebCanvasEvent('move', frame_rect.x + pnt.x, frame_rect.y + pnt.y, hints ? hints[0]?.painter?.getSnapId() : '');
for (let n = 0; n < hints.length; ++n) {
const hint = hints[n];
if (!hint)
continue;
if (hint.user_info !== undefined)
hint.painter?.provideUserTooltip(hint.user_info);
if (!hint.lines?.length) {
hints[n] = null;
continue;
}
// check if fully duplicated hint already exists
for (let k = 0; k < n; ++k) {
const hprev = hints[k];
let diff = false;
if (!hprev || (hprev.lines.length !== hint.lines.length))
continue;
for (let l = 0; l < hint.lines.length && !diff; ++l) {
if (hprev.lines[l] !== hint.lines[l])
diff = true;
}
if (!diff) {
hints[n] = null;
break;
}
}
if (!hints[n])
continue;
nhints++;
if (hint.exact)
nexact++;
hint.lines.forEach(line => { maxlen = Math.max(maxlen, line.length); });
hint.height = Math.round(hint.lines.length * textheight * hstep + 2 * hmargin - textheight * (hstep - 1));
if ((hint.color1 !== undefined) && (hint.color1 !== 'none')) {
if (lastcolor1 && (lastcolor1 !== hint.color1))
usecolor1 = true;
lastcolor1 = hint.color1;
}
}
let path_name = null, same_path = hints.length > 1;
for (let n = 0; n < hints.length; ++n) {
const hint = hints[n], p = hint?.lines ? hint.lines[0]?.lastIndexOf('/') : -1;
if (p > 0) {
const path = hint.lines[0].slice(0, p + 1);
if (path_name === null)
path_name = path;
else if (path_name !== path)
same_path = false;
} else
same_path = false;
}
const layer = this.hints_layer(),
show_only_best = nhints > 15,
coordinates = pnt ? Math.round(pnt.x) + ',' + Math.round(pnt.y) : '';
let hintsg = layer.selectChild('.objects_hints'), // group with all tooltips
title = '', name = '', info = '',
hint0 = null, best_dist2 = 1e10, best_hint = null;
// try to select hint with exact match of the position when several hints available
for (let k = 0; k < hints.length; ++k) {
if (!hints[k])
continue;
if (!hint0)
hint0 = hints[k];
// select exact hint if this is the only one
if (hints[k].exact && (nexact < 2) && (!hint0 || !hint0.exact)) {
hint0 = hints[k];
break;
}
if (!pnt || (hints[k].x === undefined) || (hints[k].y === undefined))
continue;
const dist2 = (pnt.x - hints[k].x) ** 2 + (pnt.y - hints[k].y) ** 2;
if (dist2 < best_dist2) {
best_dist2 = dist2;
best_hint = hints[k];
}
}
if ((!hint0 || !hint0.exact) && (best_dist2 < 400))
hint0 = best_hint;
if (hint0) {
name = (hint0.lines && hint0.lines.length > 1) ? hint0.lines[0] : hint0.name;
title = hint0.title || '';
info = hint0.line;
if (!info && hint0.lines)
info = hint0.lines.slice(1).join(' ');
}
this.showObjectStatus(name, title, info, coordinates);
// end of closing tooltips
if (!pnt || disable_tootlips || !hints.length || (maxlen === 0) || (show_only_best && !best_hint)) {
hintsg.remove();
return;
}
// we need to set pointer-events=none for all elements while hints
// placed in front of so-called interactive rect in frame, used to catch mouse events
if (hintsg.empty()) {
hintsg = layer.append('svg:g')
.attr('class', 'objects_hints')
.style('pointer-events', 'none');
}
let frame_shift = { x: 0, y: 0 }, trans = frame_rect.transform || '';
if (!pp?.isCanvas()) {
frame_shift = getAbsPosInCanvas(pp.getPadSvg(), frame_shift);
trans = `translate(${frame_shift.x},${frame_shift.y}) ${trans}`;
}
// copy transform attributes from frame itself
hintsg.attr('transform', trans)
.property('last_point', pnt)
.property('hints_pad', pp.getPadName());
let viewmode = hintsg.property('viewmode') || '',
actualw = 0, posx = pnt.x + frame_rect.hint_delta_x;
if (show_only_best || (nhints === 1)) {
viewmode = 'single';
posx += 15;
} else {
// if there are many hints, place them left or right
let bleft = 0.5, bright = 0.5;
if (viewmode === 'left')
bright = 0.7;
else if (viewmode === 'right')
bleft = 0.3;
if (posx <= bleft * frame_rect.width) {
viewmode = 'left';
posx = 20;
} else if (posx >= bright * frame_rect.width) {
viewmode = 'right';
posx = frame_rect.width - 60;
} else
posx = hintsg.property('startx');
}
if (viewmode !== hintsg.property('viewmode')) {
hintsg.property('viewmode', viewmode);
hintsg.selectAll('*').remove();
}
let curry = 10, // normal y coordinate
gapy = 10, // y coordinate, taking into account all gaps
gapminx = -1111, gapmaxx = -1111;
const minhinty = -frame_shift.y,
cp = this.getCanvPainter(),
maxhinty = cp.getPadHeight() - frame_rect.y - frame_shift.y;
for (let n = 0; n < hints.length; ++n) {
let hint = hints[n],
group = hintsg.selectChild(`.painter_hint_${n}`);
if (show_only_best && (hint !== best_hint))
hint = null;
if (hint === null) {
group.remove();
continue;
}
const was_empty = group.empty();
if (was_empty) {
group = hintsg.append('svg:svg')
.attr('class', `painter_hint_${n}`)
.attr('opacity', 0) // use attribute, not style to make animation with d3.transition()
.style('overflow', 'hidden')
.style('pointer-events', 'none');
}
if (viewmode === 'single')
curry = pnt.touch ? (pnt.y - hint.height - 5) : Math.min(pnt.y + 15, maxhinty - hint.height - 3) + frame_rect.hint_delta_y;
else {
for (let n2 = 0; (n2 < hints.length) && (gapy < maxhinty); ++n2) {
const hint2 = hints[n2];
if (!hint2)
continue;
if ((hint2.y >= gapy - 5) && (hint2.y <= gapy + hint2.height + 5)) {
gapy = hint2.y + 10;
n2 = -1;
}
}
if ((gapminx === -1111) && (gapmaxx === -1111))
gapminx = gapmaxx = hint.x;
gapminx = Math.min(gapminx, hint.x);
gapmaxx = Math.min(gapmaxx, hint.x);
}
group.attr('x', posx)
.attr('y', curry)
.property('curry', curry)
.property('gapy', gapy);
curry += hint.height + 5;
gapy += hint.height + 5;
if (!was_empty)
group.selectAll('*').remove();
group.attr('width', 60)
.attr('height', hint.height);
const r = group.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', 60)
.attr('height', hint.height)
.style('fill', 'lightgrey')
.style('pointer-events', 'none');
if (nhints > 1) {
const col = usecolor1 ? hint.color1 : hint.color2;
if (col && (col !== 'none'))
r.style('stroke', col);
}
r.attr('stroke-width', hint.exact ? 3 : 1);
for (let l = 0; l < (hint.lines?.length ?? 0); l++) {
let line = hint.lines[l];
if (l === 0 && path_name && same_path)
line = line.slice(path_name.length);
if (line) {
const txt = group.append('svg:text')
.attr('text-anchor', 'start')
.attr('x', wmargin)
.attr('y', hmargin + l * textheight * hstep)
.attr('dy', '.8em')
.style('fill', 'black')
.style('pointer-events', 'none')
.call(font.func)
.text(line),
box = getElementRect(txt, 'bbox');
actualw = Math.max(actualw, box.width);
}
}
function translateFn() {
// We only use 'd', but list d,i,a as params just to show can have them as params.
// Code only really uses d and t.
return function(/* d, i, a */) {
return function(t) {
return t < 0.8 ? '0' : (t - 0.8) * 5;
};
};
}
if (was_empty) {
if (settings.TooltipAnimation > 0)
group.transition().duration(settings.TooltipAnimation).attrTween('opacity', translateFn());
else
group.attr('opacity', 1);
}
}
actualw += 2 * wmargin;
const svgs = hintsg.selectAll('svg');
if ((viewmode === 'right') && (posx + actualw > frame_rect.width - 20)) {
posx = frame_rect.width - actualw - 20;
svgs.attr('x', posx);
}
if ((viewmode === 'single') && (posx + actualw > pad_width - frame_rect.x) && (posx > actualw + 20)) {
posx -= (actualw + 20);
svgs.attr('x', posx);
}
// if gap not very big, apply gapy coordinate to open view on the histogram
if ((viewmode !== 'single') && (gapy < maxhinty) && (gapy !== curry)) {
if ((gapminx <= posx + actualw + 5) && (gapmaxx >= posx - 5))
svgs.attr('y', function() { return d3_select(this).property('gapy'); });
} else if ((viewmode !== 'single') && (curry > maxhinty)) {
const shift = Math.max((maxhinty - curry - 10), minhinty);
if (shift < 0)
svgs.attr('y', function() { return d3_select(this).property('curry') + shift; });
}
if (actualw > 10)
svgs.attr('width', actualw).select('rect').attr('width', actualw);
hintsg.property('startx', posx);
if (cp._highlight_connect && isFunc(cp.processHighlightConnect))
cp.processHighlightConnect(hints);
}
} // class TooltipHandler
/** @summary Frame interactivity class
* @private */
class FrameInteractive extends TooltipHandler {
// cannot use private members because of RFramePainter
/** @summary Adding basic interactivity */
addBasicInteractivity() {
this.setTooltipEnabled(true);
if (this.$can_drag) {
addDragHandler(this, {
obj: this, x: this.getFrameX(), y: this.getFrameY(), width: this.getFrameWidth(), height: this.getFrameHeight(),
is_disabled: kind => { return (kind === 'move') && this.mode3d; },
only_resize: true, minwidth: 20, minheight: 20, redraw: () => this.sizeChanged()
});
}
const top_rect = this.getG().selectChild('path'),
main_svg = this.getG().selectChild('.main_layer');
top_rect.style('pointer-events', 'visibleFill') // let process mouse events inside frame
.style('cursor', 'default'); // show normal cursor
main_svg.style('pointer-events', 'visibleFill')
.style('cursor', 'default')
.property('handlers_set', 0);
const handlers_set = this.getPadPainter()?.isFastDrawing() ? 0 : 1;
if (main_svg.property('handlers_set') !== handlers_set) {
const close_handler = handlers_set ? evnt => this.processFrameTooltipEvent(null, evnt) : null,
mouse_handler = handlers_set ? evnt => this.processFrameTooltipEvent({ handler: true, touch: false }, evnt) : null;
main_svg.property('handlers_set', handlers_set)
.on('mouseenter', mouse_handler)
.on('mousemove', mouse_handler)
.on('mouseleave', close_handler);
if (browser.touches) {
const touch_handler = handlers_set ? evnt => this.processFrameTooltipEvent({ handler: true, touch: true }, evnt) : null;
main_svg.on('touchstart', touch_handler)
.on('touchmove', touch_handler)
.on('touchend', close_handler)
.on('touchcancel', close_handler);
}
}
main_svg.attr('x', 0)
.attr('y', 0)
.attr('width', this.getFrameWidth())
.attr('height', this.getFrameHeight());
const hintsg = this.hints_layer().selectChild('.objects_hints');
// if tooltips were visible before, try to reconstruct them after short timeout
if (!hintsg.empty() && this.isTooltipAllowed() && (hintsg.property('hints_pad') === this.getPadPainter()?.getPadName()))
setTimeout(() => this.processFrameTooltipEvent(hintsg.property('last_point'), null), 10);
}
getFrameSvg() { return this.getPadPainter().getFrameSvg(); }
/** @summary Add interactive handlers */
async addFrameInteractivity(for_second_axes) {
const pp = this.getPadPainter(),
svg = this.getFrameSvg();
if (pp?.isFastDrawing() || svg.empty())
return this;
if (for_second_axes) {
// add extra handlers for second axes
const svg_x2 = svg.selectAll('.x2axis_container'),
svg_y2 = svg.selectAll('.y2axis_container');
if (settings.ContextMenu) {
svg_x2.on('contextmenu', evnt => this.showContextMenu('x2', evnt));
svg_y2.on('contextmenu', evnt => this.showContextMenu('y2', evnt));
}
svg_x2.on('mousemove', evnt => this.showAxisStatus('x2', evnt));
svg_y2.on('mousemove', evnt => this.showAxisStatus('y2', evnt));
return this;
}
const svg_x = svg.selectAll('.xaxis_container'),
svg_y = svg.selectAll('.yaxis_container');
this.can_zoom_x = this.can_zoom_y = settings.Zooming;
if (pp?.options) {
if (pp.options.NoZoomX)
this.can_zoom_x = false;
if (pp.options.NoZoomY)
this.can_zoom_y = false;
}
if (!svg.property('interactive_set')) {
this.addKeysHandler();
this.zoom_kind = 0; // 0 - none, 1 - XY, 2 - only X, 3 - only Y, (+100 for touches)
this.zoom_rect = null;
this.zoom_origin = null; // original point where zooming started
this.zoom_curr = null; // current point for zooming
}
if (settings.Zooming) {
if (settings.ZoomMouse) {
svg.on('mousedown', evnt => this.startRectSel(evnt));
svg.on('dblclick', evnt => this.mouseDoubleClick(evnt));
}
if (settings.ZoomWheel)
svg.on('wheel', evnt => this.mouseWheel(evnt));
}
if (browser.touches && ((settings.Zooming && settings.ZoomTouch) || settings.ContextMenu))
svg.on('touchstart', evnt => this.startTouchZoom(evnt));
if (settings.ContextMenu) {
if (browser.touches) {
svg_x.on('touchstart', evnt => this.startSingleTouchHandling('x', evnt));
svg_y.on('touchstart', evnt => this.startSingleTouchHandling('y', evnt));
}
svg.on('contextmenu', evnt => this.showContextMenu('', evnt));
svg_x.on('contextmenu', evnt => this.showContextMenu('x', evnt));
svg_y.on('contextmenu', evnt => this.showContextMenu('y', evnt));
}
svg_x.on('mousemove', evnt => this.showAxisStatus('x', evnt));
svg_y.on('mousemove', evnt => this.showAxisStatus('y', evnt));
svg.property('interactive_set', true);
return this;
}
/** @summary Handle key press */
processKeyPress(evnt) {
// no custom keys handling when menu is present
if (hasMenu())
return true;
const allowed = ['PageUp', 'PageDown', 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'PrintScreen', 'Escape', '*'],
main = this.selectDom(),
pp = this.getPadPainter();
let key = evnt.key;
if (!settings.HandleKeys || main.empty() || (this.isEnabledKeys() === false) ||
(getActivePad() !== pp) || (allowed.indexOf(key) < 0))
return false;
if (evnt.shiftKey)
key = `Shift ${key}`;
if (evnt.altKey)
key = `Alt ${key}`;
if (evnt.ctrlKey)
key = `Ctrl ${key}`;
const zoom = { name: 'x', dleft: 0, dright: 0 };
switch (key) {
case 'ArrowLeft':
zoom.dleft = -1;
zoom.dright = 1;
break;
case 'ArrowRight':
zoom.dleft = 1;
zoom.dright = -1;
break;
case 'Ctrl ArrowLeft':
zoom.dleft = zoom.dright = -1;
break;
case 'Ctrl ArrowRight':
zoom.dleft = zoom.dright = 1;
break;
case 'ArrowUp':
zoom.name = 'y';
zoom.dleft = 1;
zoom.dright = -1;
break;
case 'ArrowDown':
zoom.name = 'y';
zoom.dleft = -1;
zoom.dright = 1;
break;
case 'Ctrl ArrowUp':
zoom.name = 'y';
zoom.dleft = zoom.dright = 1;
break;
case 'Ctrl ArrowDown':
zoom.name = 'y';
zoom.dleft = zoom.dright = -1;
break;
case 'Escape':
pp?.enlargePad(null, false, true);
return true;
}
if (zoom.dleft || zoom.dright) {
if (!settings.Zooming)
return false;
// in 3d mode with orbit control ignore simple arrows
if (this.mode3d && key.indexOf('Ctrl'))
return false;
this.analyzeMouseWheelEvent(null, zoom, 0.5);
if (zoom.changed)
this.zoomSingle(zoom.name, zoom.min, zoom.max, true);
evnt.stopPropagation();
evnt.preventDefault();
} else {
const func = pp?.findPadButton(key);
if (func) {
pp.clickPadButton(func);
evnt.stopPropagation();
evnt.preventDefault();
}
}
return true; // just process any key press
}
/** @summary Function called when frame is clicked and object selection can be performed
* @desc such event can be used to select */
processFrameClick(pnt, dblckick) {
const pp = this.getPadPainter();
if (!pp)
return;
pnt.painters = true; // provide painters reference in the hints
pnt.disabled = true; // do not invoke graphics
// collect tooltips from pad painter - it has list of all drawn objects
const hints = pp.processPadTooltipEvent(pnt);
let exact = null, res;
for (let k = 0; (k < hints.length) && !exact; ++k) {
if (hints[k] && hints[k].exact)
exact = hints[k];
}
if (exact) {
const handler = dblckick ? this.getDblclickHandler() : this.getClickHandler();
if (isFunc(handler))
res = handler(exact.user_info, pnt);
}
if (!dblckick) {
pp.selectObjectPainter(exact ? exact.painter : this,
{ x: pnt.x + this.getFrameX(), y: pnt.y + this.getFrameY() });
}
return res;
}
/** @summary Check mouse moving */
shiftMoveHanlder(evnt, pos0) {
if (evnt.buttons === this._shifting_buttons) {
const frame = this.getFrameSvg(),
pos = d3_pointer(evnt, frame.node()),
main_svg = this.getG().selectChild('.main_layer'),
dx = pos0[0] - pos[0],
dy = (this.scales_ndim === 1) ? 0 : pos0[1] - pos[1],
w = this.getFrameWidth(), h = this.getFrameHeight();
this._shifting_dx = dx;
this._shifting_dy = dy;
main_svg.attr('viewBox', `${dx} ${dy} ${w} ${h}`);
evnt.preventDefault();
evnt.stopPropagation();
}
}
/** @summary mouse up handler for shifting */
shiftUpHanlder(evnt) {
evnt.preventDefault();
d3_select(window).on('mousemove.shiftHandler', null)
.on('mouseup.shiftHandler', null);
if ((this._shifting_dx !== undefined) && (this._shifting_dy !== undefined))
this.performScalesShift();
}
/** @summary Shift scales on defined positions */
performScalesShift() {
const w = this.getFrameWidth(), h = this.getFrameHeight(),
main_svg = this.getG().selectChild('.main_layer'),
gr = this.getGrFuncs(),
xmin = gr.revertAxis('x', this._shifting_dx),
xmax = gr.revertAxis('x', this._shifting_dx + w),
ymin = gr.revertAxis('y', this._shifting_dy + h),
ymax = gr.revertAxis('y', this._shifting_dy);
main_svg.attr('viewBox', `0 0 ${w} ${h}`);
this._shifting_dx = this._shifting_dy = undefined;
setPainterTooltipEnabled(this, true);
if (this.scales_ndim === 1)
this.zoomSingle('x', xmin, xmax);
else
this.zoom(xmin, xmax, ymin, ymax);
}
/** @summary Start mouse rect zooming */
startRectSel(evnt) {
// ignore when touch selection is activated
if (this.zoom_kind > 100)
return;
const frame = this.getFrameSvg(),
pos = d3_pointer(evnt, frame.node());
if ((evnt.buttons === 3) || (evnt.button === 1)) {
this.clearInteractiveElements();
this._shifting_buttons = evnt.buttons;
if (!evnt.$emul) {
d3_select(window).on('mousemove.shiftHandler', evnt2 => this.shiftMoveHanlder(evnt2, pos))
.on('mouseup.shiftHandler', evnt2 => this.shiftUpHanlder(evnt2), true);
}
setPainterTooltipEnabled(this, false);
evnt.preventDefault();
evnt.stopPropagation();
return;
}
// ignore all events from non-left button
if (evnt.button)
return;
evnt.preventDefault();
this.clearInteractiveElements();
const w = this.getFrameWidth(), h = this.getFrameHeight();
this.zoom_lastpos = pos;
this.zoom_curr = [Math.max(0, Math.min(w, pos[0])), Math.max(0, Math.min(h, pos[1]))];
this.zoom_origin = [0, 0];
this.zoom_second = false;
if ((pos[0] < 0) || (pos[0] > w)) {
this.zoom_second = (pos[0] > w) && this.y2_handle;
this.zoom_kind = 3; // only y
this.zoom_origin[1] = this.zoom_curr[1];
this.zoom_curr[0] = w;
this.zoom_curr[1] += 1;
} else if ((pos[1] < 0) || (pos[1] > h)) {
this.zoom_second = (pos[1] < 0) && this.x2_handle;
this.zoom_kind = 2; // only x
this.zoom_origin[0] = this.zoom_curr[0];
this.zoom_curr[0] += 1;
this.zoom_curr[1] = h;
} else {
this.zoom_kind = 1; // x and y
this.zoom_origin[0] = this.zoom_curr[0];
this.zoom_origin[1] = this.zoom_curr[1];
}
if (!evnt.$emul) {
d3_select(window).on('mousemove.zoomRect', evnt2 => this.moveRectSel(evnt2))
.on('mouseup.zoomRect', evnt2 => this.endRectSel(evnt2), true);
}
this.zoom_rect = null;
// disable tooltips in frame painter
setPainterTooltipEnabled(this, false);
evnt.stopPropagation();
if (this.zoom_kind !== 1)
return postponePromise(() => this.startLabelsMove(), 500);
}
/** @summary Starts labels move */
startLabelsMove() {
if (this.zoom_rect)
return;
const handle = (this.zoom_kind === 2) ? this.x_handle : this.y_handle;
if (!isFunc(handle?.processLabelsMove) || !this.zoom_lastpos)
return;
if (handle.processLabelsMove('start', this.zoom_lastpos))
this.zoom_labels = handle;
}
/** @summary Process mouse rect zooming */
moveRectSel(evnt) {
if ((this.zoom_kind === 0) || (this.zoom_kind > 100))
return;
evnt.preventDefault();
const m = d3_pointer(evnt, this.getFrameSvg().node());
if (this.zoom_labels)
return this.zoom_labels.processLabelsMove('move', m);
this.zoom_lastpos[0] = m[0];
this.zoom_lastpos[1] = m[1];
m[0] = Math.max(0, Math.min(this.getFrameWidth(), m[0]));
m[1] = Math.max(0, Math.min(this.getFrameHeight(), m[1]));
switch (this.zoom_kind) {
case 1:
this.zoom_curr[0] = m[0];
this.zoom_curr[1] = m[1];
break;
case 2:
this.zoom_curr[0] = m[0];
break;
case 3:
this.zoom_curr[1] = m[1];
break;
}
const x = Math.min(this.zoom_origin[0], this.zoom_curr[0]),
y = Math.min(this.zoom_origin[1], this.zoom_curr[1]),
w = Math.abs(this.zoom_curr[0] - this.zoom_origin[0]),
h = Math.abs(this.zoom_curr[1] - this.zoom_origin[1]);
if (!this.zoom_rect) {
// ignore small changes, can be switching to labels move
if ((this.zoom_kind !== 1) && ((w < 2) || (h < 2)))
return;
this.zoom_rect = this.getFrameSvg()
.append('rect')
.style('pointer-events', 'none')
.call(addHighlightStyle, true);
}
this.zoom_rect.attr('x', x).attr('y', y).attr('width', w).attr('height', h);
}
/** @summary Finish mouse rect zooming */
endRectSel(evnt) {
if ((this.zoom_kind === 0) || (this.zoom_kind > 100))
return;
evnt.preventDefault();
if (!evnt.$emul) {
d3_select(window).on('mousemove.zoomRect', null)
.on('mouseup.zoomRect', null);
}
const m = d3_pointer(evnt, this.getFrameSvg().node());
let kind = this.zoom_kind, pr;
if (this.zoom_labels)
this.zoom_labels.processLabelsMove('stop', m);
else {
const changed = [this.can_zoom_x, this.can_zoom_y];
m[0] = Math.max(0, Math.min(this.getFrameWidth(), m[0]));
m[1] = Math.max(0, Math.min(this.getFrameHeight(), m[1]));
switch (this.zoom_kind) {
case 1:
this.zoom_curr[0] = m[0];
this.zoom_curr[1] = m[1];
break;
case 2: // only X
this.zoom_curr[0] = m[0];
changed[1] = false;
break;
case 3: // only Y
this.zoom_curr[1] = m[1];
changed[0] = false;
break;
}
let xmin, xmax, ymin, ymax, isany = false,
namex = 'x', namey = 'y';
if (changed[0] && (Math.abs(this.zoom_curr[0] - this.zoom_origin[0]) > 5)) {
if (this.zoom_second && (this.zoom_kind === 2))
namex = 'x2';
const v1 = this.revertAxis(namex, this.zoom_origin[0]),
v2 = this.revertAxis(namex, this.zoom_curr[0]);
xmin = Math.min(v1, v2);
xmax = Math.max(v1, v2);
isany = true;
}
if (changed[1] && (Math.abs(this.zoom_curr[1] - this.zoom_origin[1]) > 5)) {
if (this.zoom_second && (this.zoom_kind === 3))
namey = 'y2';
const v1 = this.revertAxis(namey, this.zoom_origin[1]),
v2 = this.revertAxis(namey, this.zoom_curr[1]);
ymin = Math.min(v1, v2);
ymax = Math.max(v1, v2);
isany = true;
}
if (this.swap_xy() && !this.zoom_second)
[xmin, xmax, ymin, ymax] = [ymin, ymax, xmin, xmax];
if (namex === 'x2') {
pr = this.zoomSingle(namex, xmin, xmax, true);
kind = 0;
} else if (namey === 'y2') {
pr = this.zoomSingle(namey, ymin, ymax, true);
kind = 0;
} else if (isany) {
pr = this.zoom(xmin, xmax, ymin, ymax, undefined, undefined, true);
kind = 0;
}
}
const pnt = (kind === 1) ? { x: this.zoom_origin[0], y: this.zoom_origin[1] } : null;
this.clearInteractiveElements();
// if no zooming was done, select active object instead
switch (kind) {
case 1:
this.processFrameClick(pnt);
break;
case 2:
this.getPadPainter()?.selectObjectPainter(this.x_handle);
break;
case 3:
this.getPadPainter()?.selectObjectPainter(this.y_handle);
break;
}
// return promise - if any
return pr;
}
/** @summary Handle mouse double click on frame */
mouseDoubleClick(evnt) {
evnt.preventDefault();
const m = d3_pointer(evnt, this.getFrameSvg().node()),
fw = this.getFrameWidth(), fh = this.getFrameHeight();
this.clearInteractiveElements();
const valid_x = (m[0] >= 0) && (m[0] <= fw),
valid_y = (m[1] >= 0) && (m[1] <= fh);
if (valid_x && valid_y && this.getDblclickHandler()) {
if (this.processFrameClick({ x: m[0], y: m[1] }, true))
return;
}
let kind = (this.can_zoom_x ? 'x' : '') + (this.can_zoom_y ? 'y' : '') + 'z';
if (!valid_x) {
if (!this.can_zoom_y)
return;
kind = this.swap_xy() ? 'x' : 'y';
if ((m[0] > fw) && this[kind + '2_handle'])
kind += '2'; // let unzoom second axis
} else if (!valid_y) {
if (!this.can_zoom_x)
return;
kind = this.swap_xy() ? 'y' : 'x';
if ((m[1] < 0) && this[kind + '2_handle'])
kind += '2'; // let unzoom second axis
}
return this.unzoom(kind).then(changed => {
if (changed)
return;
const pp = this.getPadPainter(), rect = this.getFrameRect();
return pp?.selectObjectPainter(pp, { x: m[0] + rect.x, y: m[1] + rect.y, dbl: true });
});
}
/** @summary Start touch zoom */
startTouchZoom(evnt) {
evnt.preventDefault();
evnt.stopPropagation();
// in case when zooming was started, block any other kind of events
// also prevent zooming together with active dragging
if (this.zoom_kind || drag_kind)
return;
const arr = get_touch_pointers(evnt, this.getFrameSvg().node());
// normally double-touch will be handled
// touch with single click used for context menu
if (arr.length === 1) {
// this is touch with single element
const now = new Date().getTime();
let tmdiff = 1e10, dx = 100, dy = 100;
if (this.last_touch_time && this.last_touch_pos) {
tmdiff = now - this.last_touch_time;
dx = Math.abs(arr[0][0] - this.last_touch_pos[0]);
dy = Math.abs(arr[0][1] - this.last_touch_pos[1]);
}
this.last_touch_time = now;
this.last_touch_pos = arr[0];
if ((tmdiff < 500) && (dx < 20) && (dy < 20)) {
this.clearInteractiveElements();
this.unzoom('xyz');
delete this.last_touch_time;
} else if (settings.ContextMenu)
this.startSingleTouchHandling('', evnt);
}
if ((arr.length !== 2) || !settings.Zooming || !settings.ZoomTouch)
return;
this.clearInteractiveElements();
// clear single touch handler
this.endSingleTouchHandling(null);
const pnt1 = arr[0], pnt2 = arr[1], w = this.getFrameWidth(), h = this.getFrameHeight();
this.zoom_curr = [Math.min(pnt1[0], pnt2[0]), Math.min(pnt1[1], pnt2[1])];
this.zoom_origin = [Math.max(pnt1[0], pnt2[0]), Math.max(pnt1[1], pnt2[1])];
this.zoom_second = false;
if ((this.zoom_curr[0] < 0) || (this.zoom_curr[0] > w)) {
this.zoom_second = (this.zoom_curr[0] > w) && this.y2_handle;
this.zoom_kind = 103; // only y
this.zoom_curr[0] = 0;
this.zoom_origin[0] = w;
} else if ((this.zoom_origin[1] > h) || (this.zoom_origin[1] < 0)) {
this.zoom_second = (this.zoom_origin[1] < 0) && this.x2_handle;
this.zoom_kind = 102; // only x
this.zoom_curr[1] = 0;
this.zoom_origin[1] = h;
} else
this.zoom_kind = 101; // x and y
drag_kind = 'zoom'; // block other possible dragging
setPainterTooltipEnabled(this, false);
this.zoom_rect = this.getFrameSvg().append('rect')
.attr('id', 'zoomRect')
.attr('x', this.zoom_curr[0])
.attr('y', this.zoom_curr[1])
.attr('width', this.zoom_origin[0] - this.zoom_curr[0])
.attr('height', this.zoom_origin[1] - this.zoom_curr[1])
.call(addHighlightStyle, true);
if (!evnt.$emul) {
d3_select(window).on('touchmove.zoomRect', evnt2 => this.moveTouchZoom(evnt2))
.on('touchcancel.zoomRect', evnt2 => this.endTouchZoom(evnt2))
.on('touchend.zoomRect', evnt2 => this.endTouchZoom(evnt2));
}
}
/** @summary Move touch zooming */
moveTouchZoom(evnt) {
if (this.zoom_kind < 100)
return;
evnt.preventDefault();
const arr = get_touch_pointers(evnt, this.getFrameSvg().node());
if (arr.length !== 2)
return this.clearInteractiveElements();
const pn