Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/site/concepts/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ Node drag emits a sequence of `node` + position events. Dragging is controlled b
| `NODE_DRAG` | `node`, position, `event` | The node moves during a drag. |
| `NODE_DRAG_END` | `node`, position, `event` | The drag ends. |

Dragging the empty background emits a separate, subject-less sequence, off by default and
enabled with `interaction.backgroundDrag` - see [Selection & interaction](/concepts/interaction).

| Event | Payload | Fires when |
| --- | --- | --- |
| `BACKGROUND_DRAG_START` | position, `event` | A background drag begins (modifier held, no node hit). |
| `BACKGROUND_DRAG` | position, `event` | The cursor moves during a background drag. |
| `BACKGROUND_DRAG_END` | position, `event` | The background drag ends. |

## Examples

### A tooltip that follows the cursor
Expand Down
78 changes: 78 additions & 0 deletions docs/site/concepts/interaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,19 @@ const orb = new OrbView(container, {
interaction: {
isDragEnabled: true, // drag nodes (default: true)
isZoomEnabled: true, // scroll to zoom, drag background to pan (default: true)
backgroundDrag: {
isEnabled: false, // emit background-drag events on a modifier + drag (default: false)
modifier: 'shift', // 'shift' | 'ctrl' | 'alt' | 'meta' | null (default: 'shift')
},
},
});
```

- **Background drag** - off by default. When enabled, dragging the empty background with the
modifier held emits neutral `BACKGROUND_DRAG_*` [events](/concepts/events) instead of
panning; a plain drag still pans. It's the gesture [rectangle selection](#rectangle-selection)
is built on, and is equally usable for custom box-zoom or lasso.

To disable Orb's built-in selection entirely and handle it yourself, turn off the strategy
flags and drive state from [events](/concepts/events).

Expand Down Expand Up @@ -80,6 +89,12 @@ orb.interaction.unselectNodeById(1);
orb.interaction.unselectEdgeById(10);
orb.interaction.unselectAll();

// Select many at once (non-cascading by default), returns the matched count
orb.interaction.selectNodesByIds([1, 2, 3]);
orb.interaction.unselectNodesByIds([1, 2, 3]);
orb.interaction.selectEdgesByIds([10, 11]);
orb.interaction.unselectEdgesByIds([10, 11]);

// Hover
orb.interaction.hoverNodeById(1);
orb.interaction.hoverEdgeById(10);
Expand Down Expand Up @@ -107,6 +122,69 @@ searchInput.addEventListener('change', (e) => {
});
```

## Rectangle selection

Selecting a whole region at once - drag a box, select the nodes inside - ships as an opt-in
module, `@memgraph/orb/interactions`, kept out of the core bundle so you only pay for it when
you use it.

<OrbDemo src="/demos/rectangle-selection.html" :height="460" />

It takes **two steps**: enable the background-drag gesture on the view, then attach a
`RectangleSelection` to it.

```typescript
import { OrbView } from '@memgraph/orb';
import { RectangleSelection } from '@memgraph/orb/interactions';

const orb = new OrbView(container, {
interaction: { backgroundDrag: { isEnabled: true, modifier: 'shift' } },
});

const selection = new RectangleSelection(orb);
selection.on('select', ({ nodes, area, mode }) => {
// nodes are now selected; mode is 'add' or 'replace'. Want edges too? You have the
// nodes, so select whichever edges you like - e.g. those fully inside the box:
const ids = new Set(nodes.map((n) => n.getId()));
const edges = orb.data.getEdges((e) => ids.has(e.startNode?.getId()) && ids.has(e.endNode?.getId()));
orb.interaction.selectEdgesByIds(edges.map((e) => e.getId()));
orb.render();
});
```

By default, **Shift-drag** over the empty background draws the box and replaces the
selection with the nodes inside (like a fresh marquee); holding **Ctrl/Cmd** as well adds
to the current selection instead. A too-small drag counts as a click and leaves the
selection untouched. Dragging a node still moves it, and a plain drag still pans. Call
`selection.destroy()` to detach it.

::: warning Requires background drag
`RectangleSelection` only listens - it does not enable the gesture. If
`interaction.backgroundDrag.isEnabled` is not set on the view, attaching it does nothing and
Shift-drag is a no-op.
:::

`RectangleSelection` accepts `IRectangleSelectionOptions`:

| Option | Type | Default |
| --- | --- | --- |
| `resolveMode` | `(event: MouseEvent) => 'add' \| 'replace'` | ctrl/meta → `add`, else `replace` |
| `style` | `Partial<IRectangleSelectionStyle>` | dashed blue overlay |

The overlay element carries the `orb-selection-rectangle` class, so you can also style it
from CSS.

The module is built entirely on public API, so the same primitives are available if you want
a different gesture (lasso, custom modifiers):

```typescript
import { RectangleArea } from '@memgraph/orb';

const area = new RectangleArea({ x, y, width, height });
const nodes = orb.data.getNodesInArea(area); // nodes whose center is inside
orb.interaction.selectNodesByIds(nodes.map((n) => n.getId()));
Comment on lines +181 to +185

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

```

## Dimming the rest of the graph

On selection or hover, Orb dims everything else so the focus stands out. That transparency
Expand Down
169 changes: 169 additions & 0 deletions docs/site/public/demos/rectangle-selection.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Orb - rectangle selection demo</title>
<script src="../orb.min.js"></script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600&display=swap" />
<style>
:root { --black:#231f20; --orange:#fb6e00; --g4:#e6e6e6; --g5:#f9f9f9; --g2:#646265; --bg:#ffffff; }
:root[data-theme="dark"] { --black:#e6e6e6; --g4:#3a3536; --g5:#2b2727; --g2:#bab8bb; --bg:#231f20; }
html, body { height:100%; margin:0; font-family:'Inter Tight',system-ui,sans-serif; color:var(--black); background:var(--bg); }
.wrap { display:flex; flex-direction:column; height:100%; }
.toolbar { display:flex; flex-wrap:wrap; gap:8px 16px; align-items:center; padding:12px 16px; border-bottom:1px solid var(--g4); background:var(--g5); }
label.chk { display:flex; align-items:center; gap:6px; font-size:13px; font-weight:500; }
button { font-family:inherit; font-size:13px; font-weight:500; padding:6px 12px; border:1px solid var(--g4); border-radius:4px; background:var(--bg); color:var(--black); cursor:pointer; }
button:hover { background:var(--g5); }
kbd { font-family:inherit; font-size:12px; background:var(--g4); border-radius:3px; padding:1px 5px; }
.hint { margin-left:auto; font-size:13px; color:var(--g2); }
.hint b { color:var(--black); }
#graph { flex:1; width:100%; min-height:0; }
</style>
</head>
<body>
<div class="wrap">
<div class="toolbar">
<span class="chk"><kbd>Shift</kbd>+drag to select &middot; <kbd>Ctrl</kbd>/<kbd>Cmd</kbd> to add</span>
<label class="chk"><input type="checkbox" id="edges" /> Include edges</label>
<button id="clear">Clear</button>
<span class="hint"><b id="sel">0</b> nodes, <b id="seledge">0</b> edges</span>
</div>
<div id="graph"></div>
</div>

<script>
const container = document.getElementById('graph');
const { OrbView, OrbEventType, RectangleArea } = Orb;
let __dark = false;
const labelColor = () => (__dark ? '#e6e6e6' : '#231f20');

// Enable the neutral background-drag gesture in core (shift-drag over empty canvas).
const orb = new OrbView(container, {
interaction: { backgroundDrag: { isEnabled: true, modifier: 'shift' } },
});

orb.data.setDefaultStyle({
getNodeStyle(n) {
return {
size: 7,
color: '#fb6e00',
colorHover: '#ff8f40',
colorSelected: '#b34e00',
borderColor: '#e36300',
borderColorSelected: '#8a3f00',
borderWidth: 1,
borderWidthSelected: 2,
label: n.getData().label,
fontSize: 4,
fontColor: labelColor(),
};
},
getEdgeStyle() {
return { color: '#bab8bb', colorSelected: '#fb6e00', width: 0.6, widthSelected: 1.6 };
},
});

// A larger random graph so there's plenty to marquee over.
const NODE_COUNT = 60;
const nodes = [];
const edges = [];
for (let i = 0; i < NODE_COUNT; i++) nodes.push({ id: i, label: String(i) });
let edgeId = 0;
for (let i = 1; i < NODE_COUNT; i++) {
// Connect each node back to a couple of earlier ones -> one connected, spread-out graph.
edges.push({ id: edgeId++, start: i, end: Math.floor(Math.random() * i) });
if (i > 3 && Math.random() < 0.4) edges.push({ id: edgeId++, start: i, end: Math.floor(Math.random() * i) });
}
orb.data.setup({ nodes, edges });
orb.events.on(OrbEventType.SIMULATION_END, () => orb.recenter());
orb.render();

window.__orbSetTheme = (dark) => {
__dark = !!dark;
document.documentElement.dataset.theme = __dark ? 'dark' : 'light';
orb.data.getNodes().forEach((n) => n.patchStyle({ fontColor: labelColor() }, { isNotifySkipped: true }));
orb.render();
};

const selEl = document.getElementById('sel');
const selEdgeEl = document.getElementById('seledge');
const includeEdgesEl = document.getElementById('edges');
const updateCount = () => {
selEl.textContent = orb.data.getSelectedNodes().length;
selEdgeEl.textContent = orb.data.getSelectedEdges().length;
};

document.getElementById('clear').addEventListener('click', () => {
orb.interaction.unselectAll();
orb.render();
updateCount();
});

// Marquee overlay + selection. In an app you'd use `@memgraph/orb/interactions`
// RectangleSelection; the UMD bundle here is core-only, so the same logic is inlined.
let overlay = null;
let start = null;
const overlayStyle = { fill: 'rgba(251,110,0,0.10)', border: '#fb6e00' };

const setOverlay = (a, b) => {
const canvas = orb.canvas;
const parent = canvas.parentElement;
if (!overlay) {
overlay = document.createElement('div');
overlay.style.cssText =
'position:absolute;pointer-events:none;box-sizing:border-box;background:' +
overlayStyle.fill + ';border:1px dashed ' + overlayStyle.border + ';border-radius:2px';
parent.appendChild(overlay);
canvas.style.cursor = 'crosshair';
}
const left = Math.min(a.x, b.x), top = Math.min(a.y, b.y);
overlay.style.left = left + 'px';
overlay.style.top = top + 'px';
overlay.style.width = Math.abs(a.x - b.x) + 'px';
overlay.style.height = Math.abs(a.y - b.y) + 'px';
};
const clearOverlay = () => {
if (overlay) { overlay.remove(); overlay = null; }
orb.canvas.style.cursor = '';
};

orb.events.on(OrbEventType.BACKGROUND_DRAG_START, (e) => {
start = { canvas: e.globalPoint, sim: e.localPoint };
setOverlay(e.globalPoint, e.globalPoint);
});
orb.events.on(OrbEventType.BACKGROUND_DRAG, (e) => {
if (start) setOverlay(start.canvas, e.globalPoint);
});
// Below this canvas-pixel span in both axes, treat the gesture as a click, not a
// marquee: leave the selection alone so a stray Shift-click doesn't clear it.
const MIN_DRAG_PX = 3;

orb.events.on(OrbEventType.BACKGROUND_DRAG_END, (e) => {
if (!start) return;
if (Math.abs(e.globalPoint.x - start.canvas.x) < MIN_DRAG_PX &&
Math.abs(e.globalPoint.y - start.canvas.y) < MIN_DRAG_PX) {
clearOverlay();
start = null;
return;
}
const area = RectangleArea.fromPoints(start.sim, e.localPoint);
const selected = orb.data.getNodesInArea(area);
// Shift-drag replaces (fresh marquee); holding Ctrl/Cmd adds to the selection instead.
if (!(e.event.ctrlKey || e.event.metaKey)) orb.interaction.unselectAll();
orb.interaction.selectNodesByIds(selected.map((n) => n.getId()));
if (includeEdgesEl.checked) {
const set = new Set(selected.map((n) => n.getId()));
const edgeIds = orb.data
.getEdges((edge) => set.has(edge.startNode?.getId()) && set.has(edge.endNode?.getId()))
.map((edge) => edge.getId());
orb.interaction.selectEdgesByIds(edgeIds);
}
orb.render();
clearOverlay();
start = null;
updateCount();
});
</script>
</body>
</html>
2 changes: 1 addition & 1 deletion docs/site/public/orb.min.js

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./interactions": {
"types": "./dist/interactions/index.d.ts",
"default": "./dist/interactions/index.js"
}
},
"contributors": [
{
"name": "David Lozic",
Expand Down
28 changes: 28 additions & 0 deletions src/common/area/area.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { IPosition } from '../position';
import { IRectangle } from '../rectangle';

/**
* A 2D region used to test which graph objects fall within a selection.
*
* Implementations describe an arbitrary shape (a rectangle today, e.g. a polygon
* later) through a point-containment predicate. This keeps area-based queries
* such as `IGraph.getNodesInArea` shape-agnostic.
*/
export interface ISelectionArea {
/**
* Checks if the point (x, y) is inside the area.
*
* @param {IPosition} point Point (x, y) in simulation coordinates
* @return {boolean} True if the point is inside the area, otherwise false
*/
contains(point: IPosition): boolean;

/**
* Returns the axis-aligned bounding box of the area.
*
* Used as a cheap pre-filter before the (possibly more expensive) contains check.
*
* @return {IRectangle} Bounding box of the area
*/
getBoundingBox(): IRectangle;
}
2 changes: 2 additions & 0 deletions src/common/area/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { ISelectionArea } from './area';
export { RectangleArea } from './rectangle';
33 changes: 33 additions & 0 deletions src/common/area/rectangle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { IPosition } from '../position';
import { getRectangleFromPoints, IRectangle, isPointInRectangle } from '../rectangle';
import { ISelectionArea } from './area';

/**
* Rectangular {@link ISelectionArea} defined by an axis-aligned rectangle.
*/
export class RectangleArea implements ISelectionArea {
private readonly _rectangle: IRectangle;

constructor(rectangle: IRectangle) {
this._rectangle = rectangle;
}

/**
* Creates a rectangular area from two opposite corner points, given in any order.
*
* @param {IPosition} pointA First corner (x, y)
* @param {IPosition} pointB Opposite corner (x, y)
* @return {RectangleArea} Rectangular area spanning the two corners
*/
static fromPoints(pointA: IPosition, pointB: IPosition): RectangleArea {
return new RectangleArea(getRectangleFromPoints(pointA, pointB));
}

contains(point: IPosition): boolean {
return isPointInRectangle(this._rectangle, point);
}

getBoundingBox(): IRectangle {
return this._rectangle;
}
}
3 changes: 2 additions & 1 deletion src/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export { ICircle } from './circle';
export { Color, IColorRGB } from './color';
export { getDistanceToLine } from './distance';
export { IPosition, isEqualPosition } from './position';
export { IRectangle, isPointInRectangle } from './rectangle';
export { IRectangle, isPointInRectangle, getRectangleFromPoints } from './rectangle';
export { ISelectionArea, RectangleArea } from './area';
14 changes: 14 additions & 0 deletions src/common/rectangle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,17 @@ export const isPointInRectangle = (rectangle: IRectangle, point: IPosition): boo
const endY = rectangle.y + rectangle.height;
return point.x >= rectangle.x && point.x <= endX && point.y >= rectangle.y && point.y <= endY;
};

/**
* Builds a normalized rectangle spanning two opposite corner points, given in any order.
*
* @param {IPosition} pointA First corner (x, y)
* @param {IPosition} pointB Opposite corner (x, y)
* @return {IRectangle} Rectangle spanning the two corners
*/
export const getRectangleFromPoints = (pointA: IPosition, pointB: IPosition): IRectangle => ({
x: Math.min(pointA.x, pointB.x),
y: Math.min(pointA.y, pointB.y),
width: Math.abs(pointA.x - pointB.x),
height: Math.abs(pointA.y - pointB.y),
});
Loading