Skip to content

Commit

Permalink
feat: ellipse brushing
Browse files Browse the repository at this point in the history
Signed-off-by: Maarten A. Breddels <maartenbreddels@gmail.com>
  • Loading branch information
maartenbreddels committed Mar 6, 2020
1 parent 1398336 commit 09d7621
Show file tree
Hide file tree
Showing 4 changed files with 359 additions and 0 deletions.
46 changes: 46 additions & 0 deletions bqplot/interacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,52 @@ def _set_selected_xy(self, change):
_model_name = Unicode('BrushSelectorModel').tag(sync=True)



@register_interaction('bqplot.BrushEllipseSelector')
class BrushEllipseSelector(BrushSelector):

"""BrushEllipse interval selector interaction.
This 2-D selector interaction enables the user to select an ellipse
region using the brushing action of the mouse. A mouse-down marks the
center of the ellipse. The drag after the mouse down selects a point
on the ellipse, drawn with the same aspect ratio as the change in x and y
as measured in pixels. If pixel_aspect is set, the aspect ratio of the ellipse
will be used instead. Note that the aspect ratio is respected in the view
where the ellipse is drawn.
Once an ellipse is drawn, it can be moved dragging, or reshaped by dragging
the border.
The selected_x and selected_y arrays define the bounding box of the ellipse.
Attributes
----------
selected_x: numpy.ndarray
Two element array containing the start and end of the interval selected
in terms of the x_scale of the selector.
This attribute changes while the selection is being made with the
``BrushSelector``.
selected_y: numpy.ndarray
Two element array containing the start and end of the interval selected
in terms of the y_scale of the selector.
This attribute changes while the selection is being made with the
``BrushEllipseSelector``.
brushing: bool (default: False)
boolean attribute to indicate if the selector is being dragged.
It is True when the selector is being moved and False when it is not.
This attribute can be used to trigger computationally intensive code
which should be run only on the interval selection being completed as
opposed to code which should be run whenever selected is changing.
"""
pixel_aspect = Float(None, allow_none=True).tag(sync=True)
style = Dict({"fill": "green", "opacity": 0.3, "cursor": "grab"}).tag(sync=True)
border_style = Dict({"stroke": "green", "fill": "none", "stroke-width": "3px",
"opacity": 0.3, "cursor": "col-resize"}).tag(sync=True)
_view_name = Unicode('BrushEllipseSelector').tag(sync=True)
_model_name = Unicode('BrushEllipseSelectorModel').tag(sync=True)


@register_interaction('bqplot.MultiSelector')
class MultiSelector(BrushIntervalSelector):

Expand Down
219 changes: 219 additions & 0 deletions js/src/BrushSelector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,3 +645,222 @@ export class MultiSelector extends BrushMixinXSelector {
selecting_brush: boolean;
names: any;
}

import {drag} from "d3-drag";

/*
good resource: https://en.wikipedia.org/wiki/Ellipse
Throughout we use rx==a and ry==b, assuming like the article above:
x**2/a**2 + y**2/b**2==1
Useful equations:
y = +/- b/a * sqrt(a**2 -x**2)
a = sqrt(y**2 a**2/b**2 + x**2)
*/
export class BrushEllipseSelector extends selector.BaseXYSelector {
brush: d3.BrushBehavior<unknown>;
brushsel: any;
private d3ellipse: any;
private d3ellipseHandle: any;
// for testing purposes we need to keep track of this at the instance level
private brushStartPosition = {x:0, y: 0};
private moveStartPosition = {x: 0, y:0};

async render() {
super.render();
const scale_creation_promise = this.create_scales();
await Promise.all([this.mark_views_promise, scale_creation_promise]);

this.d3ellipseHandle = this.d3el.append("ellipse");
this.d3ellipse = this.d3el.attr("class", "selector brushintsel").append("ellipse");

// events for brushing (a new ellipse)
this.parent.bg_events.call(drag().on("start", () => {
const e = d3GetEvent();
this._brushStart({x: e.x, y:e.y})
}).on("drag", () => {
const e = d3GetEvent();
this._brushDrag({x: e.x, y:e.y})
}).on("end", () => {
const e = d3GetEvent();
this._brushEnd({x: e.x, y:e.y})
}));


// events for moving the existing ellipse
this.d3ellipse.call(drag().on("start", () => {
const e = d3GetEvent();
this._moveStart({x: e.x, y:e.y})
}).on("drag", () => {
const e = d3GetEvent();
this._moveDrag({x: e.x, y:e.y})
}).on("end", () => {
const e = d3GetEvent();
this._moveEnd({x: e.x, y:e.y})
}));

// events for reshaping the existing ellipse
this.d3ellipseHandle.call(drag().on("start", () => {
const e = d3GetEvent();
this._reshapeStart({x: e.x, y:e.y})
}).on("drag", () => {
const e = d3GetEvent();
this._reshapeDrag({x: e.x, y:e.y})
}).on("end", () => {
const e = d3GetEvent();
this._reshapeEnd({x: e.x, y:e.y})
}));
this.updateEllipse();
this.syncSelectionToMarks();
this.listenTo(this.model, 'change:selected_x change:selected_y change:color change:style change:border_style', () => this.updateEllipse());
this.listenTo(this.model, 'change:selected_x change:selected_y', this.syncSelectionToMarks);
}
// these methods are not private, but are used for testing, they should not be used as a public API.
_brushStart({x, y}) {
this.brushStartPosition = {x, y}
this.model.set("brushing", true);
this.touch();
}
_brushDrag({x, y}) {
this._brush({x, y})
}
_brushEnd({x, y}) {
this._brush({x, y})
this.model.set("brushing", false);
this.touch();
}
_brush({x, y}) {
const cx = this.brushStartPosition.x;
const cy = this.brushStartPosition.y;
const relX = Math.abs(x - cx);
const relY = Math.abs(y - cy);

// if 'feels' natural to have a/b == relX/relY, meaning the aspect ratio of the ellipse equals that of the pixels moved
// but the aspect can be overridden by the model, to draw for instance circles
let ratio = this.model.get('pixel_aspect') || (relX / relY)
// using ra = a = sqrt(y**2 a**2/b**2 + x**2) we can solve a, from x, y, and the ratio a/b
const rx = Math.sqrt(relY*relY * ratio*ratio + relX*relX);
// and from that solve ry == b
const ry = rx / ratio;
// bounding box of the ellipse in pixel coordinates:
const [px1, px2, py1, py2] = [cx - rx, cx + rx, cy - ry, cy + ry];
let selectedX = [px1, px2].map((pixel) => this.x_scale.scale.invert(pixel));
let selectedY = [py1, py2].map((pixel) => this.y_scale.scale.invert(pixel));
this.model.set('selected_x', new Float32Array(selectedX));
this.model.set('selected_y', new Float32Array(selectedY));
this.touch();
}
_moveStart({x, y}) {
this.moveStartPosition = {x, y}
this.model.set("brushing", true);
this.touch();
}
_moveDrag({x, y}) {
this._move({dx: x - this.moveStartPosition.x, dy: y - this.moveStartPosition.y})
this.moveStartPosition = {x, y}
}
_moveEnd({x, y}) {
this._move({dx: x - this.moveStartPosition.x, dy: y - this.moveStartPosition.y})
this.model.set("brushing", false);
this.touch();
}
_move({dx, dy}) {
// move is in pixels, so we need to transform to the domain
const {px1, px2, py1, py2} = this.calculatePixelCoordinates();
let selectedX = [px1, px2].map((pixel) => this.x_scale.scale.invert(pixel + dx));
let selectedY = [py1, py2].map((pixel) => this.y_scale.scale.invert(pixel + dy));
this.model.set('selected_x', new Float32Array(selectedX));
this.model.set('selected_y', new Float32Array(selectedY));
this.touch();
}
_reshapeStart({x, y}) {
// reshaping is done equivalent to starting a new brush on the current ellipse coordinate
// this is actually not the case, and can cause to jumping in the aspect ratio
const {cx, cy} = this.calculatePixelCoordinates();
this._brushStart({x: cx, y: cy});
this._brushDrag({x, y});
}
_reshapeDrag({x, y}) {
this._brushDrag({x, y})
}
_reshapeEnd({x, y}) {
this._brushEnd({x, y})
}
reset() {
this.model.set('selected_x', null);
this.model.set('selected_y', null);
this.touch()
}
selected_changed() {
// I don't think this should be an abstract method we should implement
// would be good to refactor the interact part a bit
}
private canDraw() {
const selectedX = this.model.get('selected_x');
const selectedY = this.model.get('selected_y');
return Boolean(selectedX) && Boolean(selectedY);
}

private calculatePixelCoordinates() {
if(!this.canDraw()) {
throw new Error("No selection present")
}
const selectedX = this.model.get('selected_x');
const selectedY = this.model.get('selected_y');
var sortFunction = (a: number, b: number) => a - b;
let x = [...selectedX].sort(sortFunction);
let y = [...selectedY].sort(sortFunction);
// convert to pixel coordinates
let [px1, px2] = x.map((v) => this.x_scale.scale(v));
let [py1, py2] = y.map((v) => this.y_scale.scale(v));
// bounding box, and svg coordinates
return {px1, px2, py1, py2, cx: (px1 + px2)/2, cy: (py1 + py2)/2, rx: Math.abs(px2 - px1)/2, ry: Math.abs(py2 - py1)/2}
}
private updateEllipse(offsetX = 0, offsetY = 0, extraRx=0, extraRy=0) {
if(!this.canDraw()) {
this.d3el.node().style.visible = false;
} else {
const {cx, cy, rx, ry} = this.calculatePixelCoordinates();
this.d3ellipse
.attr("cx", cx + offsetX)
.attr("cy", cy + offsetY)
.attr("rx", rx + extraRx)
.attr("ry", ry + extraRy)
.styles(this.model.get('style'))
;
this.d3ellipseHandle
.attr("cx", cx + offsetX)
.attr("cy", cy + offsetY)
.attr("rx", rx + extraRx)
.attr("ry", ry + extraRy)
.styles(this.model.get('border_style'))
;
this.d3el.node().style.visible = true;
}
}

private syncSelectionToMarks() {
if(!this.canDraw()) return
const {cx, cy, rx, ry} = this.calculatePixelCoordinates();

const point_selector = function(p) {
const [pointX, pointY] = p;
const dx = (cx - pointX)/rx;
const dy = (cy - pointY)/ry;
const insideCircle = (dx * dx + dy * dy) <= 1;
return insideCircle;
};
const rect_selector = function(xy) {
// TODO: Leaving this to someone who has a clear idea on how this should be implemented
// and who needs it. I don't see a good use case for this (Maarten Breddels).
console.error('Rectangle selector not implemented')
return false;
};

this.mark_views.forEach((markView) => {
markView.selector_changed(point_selector, rect_selector);
});

}

}
26 changes: 26 additions & 0 deletions js/src/SelectorModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,32 @@ export class BrushSelectorModel extends TwoDSelectorModel {
}
}

export class BrushEllipseSelectorModel extends BrushSelectorModel {

defaults() {
return {...BrushSelectorModel.prototype.defaults(),
_model_name: "BrushEllipseSelectorModel",
_view_name: "BrushEllipseSelector",
pixel_aspect: null,
style: {
fill: "green",
opacity: 0.3,
cursor: "grab",
},
border_style: {
fill: "none",
stroke: "green",
opacity: 0.3,
cursor: "col-resize",
"stroke-width": "3px",
}
}
}

static serializers = {...BrushSelectorModel.serializers,
}
}

export class MultiSelectorModel extends OneDSelectorModel {

defaults() {
Expand Down
68 changes: 68 additions & 0 deletions js/src/test/interacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,74 @@ describe("interacts >", () => {
expect(indices).to.be.an.instanceof(Uint32Array)
});

it.only("brush selector ellipse", async function() {
let x = {dtype: 'float32', value: new DataView((new Float32Array([0,1])).buffer)};
let y = {dtype: 'float32', value: new DataView((new Float32Array([2,3])).buffer)};
let {figure, scatter} = await create_figure_scatter(this.manager, x, y);

let brush_selector = await create_model_bqplot(this.manager, 'BrushEllipseSelector', 'brush_selector_1', {
'x_scale': figure.model.get('scale_x').toJSON(), 'y_scale': figure.model.get('scale_y').toJSON(),
'marks': [scatter.model.toJSON()]
});
let brush_selector_view = await figure.set_interaction(brush_selector);
await brush_selector_view.displayed;
// all the events in figure are async, and we don't have access
// to the promises, so we manually call these methods
await brush_selector_view.create_scales()
await brush_selector_view.mark_views_promise;
brush_selector_view.relayout()
brush_selector.set('selected_x', [-0.5, 0.5])
brush_selector.set('selected_y', [1.5, 2.5])
let indices = scatter.model.get('selected')
expect([...indices]).to.deep.equal([0]);
expect(indices).to.be.an.instanceof(Uint32Array)

brush_selector.set('selected_x', [0.5, 1.5])
brush_selector.set('selected_y', [2.5, 3.5])
indices = scatter.model.get('selected')
expect([...indices]).to.deep.equal([1]);

brush_selector.set('selected_x', [0, 1])
brush_selector.set('selected_y', [2, 3])
indices = scatter.model.get('selected')
expect([...indices]).to.deep.equal([]);

brush_selector.set('selected_x', [-2, 3])
brush_selector.set('selected_y', [0, 5])
indices = scatter.model.get('selected')
expect([...indices]).to.deep.equal([0, 1]);

// div/figure size in pixels
const width = 400;
const height = 500;
// test drag events
brush_selector_view._moveStart({x: 0, y:0})
expect(brush_selector.get('brushing')).to.equal(true);

brush_selector_view._moveDrag({x: width, y:0})
expect([...brush_selector.get('selected_x')]).to.deep.equal([-1, 4])
expect([...brush_selector.get('selected_y')]).to.deep.equal([0, 5])

brush_selector_view._moveEnd({x: width, y:height})
expect([...brush_selector.get('selected_x')]).to.deep.equal([-1, 4])
expect([...brush_selector.get('selected_y')]).to.deep.equal([-1, 4])
expect(brush_selector.get('brushing')).to.equal(false);

// test brushing a new ellipse
brush_selector_view._brushStart({x: 0, y:height/2}); // left, center
expect(brush_selector.get('brushing')).to.equal(true);
brush_selector_view._brushDrag({x: 1, y:height/2}); // drag a bit
indices = scatter.model.get('selected')
expect([...indices]).to.deep.equal([]);
brush_selector_view._brushDrag({x: width-1, y:1}); // drag to the top right, but not on top of the other point
indices = scatter.model.get('selected')
expect([...indices]).to.deep.equal([0]);
brush_selector_view._brushEnd({x: width+1, y:-1}); // drag to the top right
indices = scatter.model.get('selected')
expect([...indices]).to.deep.equal([0, 1]);
expect(brush_selector.get('brushing')).to.equal(false);
});

it("brush interval selector basics", async function() {
let x = {dtype: 'float32', value: new DataView((new Float32Array([0,1])).buffer)};
let y = {dtype: 'float32', value: new DataView((new Float32Array([2,3])).buffer)};
Expand Down

0 comments on commit 09d7621

Please sign in to comment.