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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ patchmap.stateManager.setState('custom', { message: 'Hello World' });
The default state that handles user selection and drag events. It is automatically registered with the `stateManager` under the name 'selection' when `patchmap.draw()` is executed. You can activate it and pass configuration by calling `stateManager.setState('selection', options)`.

- `draggable` (optional, boolean): Determines whether to enable multi-selection via dragging.
- `paintSelection` (optional, boolean): Enables 'Paint Selection' mode to select objects by brushing over them in real-time. When active, it replaces the default rectangular box selection with a freeform path-based selection.
- `selectUnit` (optional, string): Specifies the logical unit to be returned upon selection. The default is `'entity'`.
- `'entity'`: Selects the individual object.
- `'closestGroup'`: Selects the nearest parent group of the selected object.
Expand Down
1 change: 1 addition & 0 deletions README_KR.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ patchmap.stateManager.setState('custom', { message: 'Hello World' });
사용자의 선택 및 드래그 이벤트를 처리하는 기본 상태(State)입니다. `patchmap.draw()`가 실행되면 'selection'이라는 이름으로 `stateManager`에 자동으로 등록됩니다. `stateManager.setState('selection', options)`를 호출하여 활성화하고 설정을 전달할 수 있습니다.

- `draggable` (optional, boolean): 드래그를 통한 다중 선택 활성화 여부를 결정합니다.
- `paintSelection` (optional, boolean): 마우스를 누른 채 이동하는 경로상의 객체들을 실시간으로 누적 선택하는 '페인트 선택' 기능을 활성화합니다. 활성화 시 기존의 사각형 범위 선택 대신, 붓으로 칠하듯 자유로운 궤적을 따라 원하는 객체들을 훑어서 선택할 수 있습니다.
- `selectUnit` (optional, string): 선택 시 반환될 논리적 단위를 지정합니다. 기본값은 `'entity'` 입니다.
- `'entity'`: 개별 객체를 선택합니다.
- `'closestGroup'`: 선택된 객체에서 가장 가까운 상위 그룹을 선택합니다.
Expand Down
5 changes: 4 additions & 1 deletion src/display/draw.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import Element from './elements/Element';
export const draw = (context, data) => {
const { viewport } = context;
destroyChildren(viewport);
viewport.apply({ type: 'canvas', children: data });
viewport.apply(
{ type: 'canvas', children: data },
{ mergeStrategy: 'replace' },
);
};

const destroyChildren = (parent) => {
Expand Down
17 changes: 13 additions & 4 deletions src/display/elements/Relations.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,19 @@ export class Relations extends ComposedRelations {
}

apply(changes, options) {
super.apply(changes, relationsSchema, {
...options,
mergeStrategy: 'replace',
});
// Filter out duplicates that already exist in the current props.
if (options?.mergeStrategy === 'merge') {
const existingLinks = this.props?.links;
if (changes?.links && existingLinks) {
const existingKeys = new Set(
existingLinks.map(({ source, target }) => `${source}|${target}`),
);
changes.links = changes.links.filter(
({ source, target }) => !existingKeys.has(`${source}|${target}`),
);
}
}
super.apply(changes, relationsSchema, options);
}

initPath() {
Expand Down
47 changes: 47 additions & 0 deletions src/events/find.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { collectCandidates } from '../utils/get';
import { intersect } from '../utils/intersects/intersect';
import { intersectPoint } from '../utils/intersects/intersect-point';
import { getSegmentEntryT } from '../utils/intersects/segment-polygon-t';
import { getObjectLocalCorners } from '../utils/transform';
import { getSelectObject } from './utils';

export const findIntersectObject = (
Expand Down Expand Up @@ -95,6 +97,51 @@ export const findIntersectObjects = (
return Array.from(new Set(found));
};

export const findIntersectObjectsBySegment = (
parent,
p1,
p2,
{ filter, selectUnit, filterParent } = {},
) => {
const allCandidates = collectCandidates(
parent,
(child) => child.constructor.isSelectable,
);
const foundMap = new Map();

for (const candidate of allCandidates) {
const targets =
candidate.constructor.hitScope === 'children'
? candidate.children
: [candidate];

for (const target of targets) {
const corners = getObjectLocalCorners(target, parent);
const t = getSegmentEntryT(target, p1, p2, corners);

if (t !== null) {
const selectObject = getSelectObject(
parent,
candidate,
selectUnit,
filterParent,
);
if (selectObject && (!filter || filter(selectObject))) {
const currentT = foundMap.get(selectObject);
if (currentT === undefined || t < currentT) {
foundMap.set(selectObject, t);
}
break;
}
}
}
}

return Array.from(foundMap.entries())
.toSorted((a, b) => a[1] - b[1])
.map((entry) => entry[0]);
};

const getAncestorPath = (obj, stopAt) => {
const path = [];
let current = obj;
Expand Down
43 changes: 39 additions & 4 deletions src/events/states/SelectionState.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import { Graphics } from 'pixi.js';
import { deepMerge } from '../../utils/deepmerge/deepmerge';
import { findIntersectObject, findIntersectObjects } from '../find';
import {
findIntersectObject,
findIntersectObjects,
findIntersectObjectsBySegment,
} from '../find';
import { isMoved } from '../utils';
import State from './State';

const stateSymbol = {
IDLE: Symbol('IDLE'),
PRESSING: Symbol('PRESSING'),
DRAGGING: Symbol('DRAGGING'),
PAINTING: Symbol('PAINTING'),
};

/**
* @typedef {object} SelectionStateConfig
* @property {boolean} [draggable=false] - Enables drag-to-select functionality.
* @property {boolean} [paintSelection=false] - Enables paint-to-select functionality.
* @property {(obj: PIXI.DisplayObject) => boolean} [filter=() => true] - A function to filter which objects can be selected.
* @property {'entity' | 'closestGroup' | 'highestGroup' | 'grid'} [selectUnit='entity'] - The logical unit of selection.
* @property {boolean} [drillDown=false] - Enables drill-down selection on double click.
Expand Down Expand Up @@ -43,17 +49,18 @@ const stateSymbol = {
* Callback fired *once* when the pointer moves beyond the movement threshold after a `pointerdown`.
*
* @property {(selected: PIXI.DisplayObject[], event: PIXI.FederatedPointerEvent) => void} [onDrag]
* Callback fired repeatedly during `pointermove` *after* a drag has started.
* Callback fired repeatedly during `pointermove` *after* a drag or paint has started.
*
* @property {(selected: PIXI.DisplayObject[], event: PIXI.FederatedPointerEvent) => void} [onDragEnd]
* Callback fired on `pointerup` *only if* a drag operation was in progress.
* Callback fired on `pointerup` *only if* a drag or paint operation was in progress.
*
* @property {(hovered: PIXI.DisplayObject | null, event: PIXI.FederatedPointerEvent) => void} [onOver]
* Callback fired on `pointerover` when the pointer enters a new object (and not dragging).
*/

const defaultConfig = {
draggable: false,
paintSelection: false,
filter: () => true,
selectUnit: 'entity',
drillDown: false,
Expand Down Expand Up @@ -83,11 +90,15 @@ export default class SelectionState extends State {

/** @type {SelectionStateConfig} */
config = {};

interactionState = stateSymbol.IDLE;
dragStartPoint = null;
movedViewport = false;
_selectionBox = new Graphics();

_paintedObjects = new Set();
_lastPaintPoint = null;

/**
* Enters the selection state with a given context and configuration.
* @param {...*} args - Additional arguments passed to the state.
Expand Down Expand Up @@ -116,6 +127,7 @@ export default class SelectionState extends State {
this.#clear({ gesture: true });
this.interactionState = stateSymbol.PRESSING;
this.dragStartPoint = this.viewport.toWorld(e.global);
this._lastPaintPoint = this.dragStartPoint;

const target = this.#searchObject(this.dragStartPoint, e);
this.config.onDown(target, e);
Expand All @@ -138,7 +150,9 @@ export default class SelectionState extends State {
this.interactionState === stateSymbol.PRESSING &&
isMoved(this.dragStartPoint, currentPoint, this.viewport.scale)
) {
this.interactionState = stateSymbol.DRAGGING;
this.interactionState = this.config.paintSelection
? stateSymbol.PAINTING
: stateSymbol.DRAGGING;
this.viewport.plugin.start('mouse-edges');
this.config.onDragStart(e);
}
Expand All @@ -147,7 +161,23 @@ export default class SelectionState extends State {
this.#drawSelectionBox(this.dragStartPoint, currentPoint);
const selected = this.#searchObjects(this._selectionBox);
this.config.onDrag(selected, e);
} else if (this.interactionState === stateSymbol.PAINTING) {
const targets = findIntersectObjectsBySegment(
this.viewport,
this._lastPaintPoint,
currentPoint,
{ ...this.config, filterParent: this.#getSelectionAncestors() },
);

const initialSize = this._paintedObjects.size;
targets.forEach((target) => this._paintedObjects.add(target));

if (this._paintedObjects.size > initialSize) {
this.config.onDrag(Array.from(this._paintedObjects), e);
}
}

this._lastPaintPoint = currentPoint;
}

onpointerup(e) {
Expand All @@ -158,6 +188,9 @@ export default class SelectionState extends State {
const selected = this.#searchObjects(this._selectionBox);
this.config.onDragEnd(selected, e);
this.viewport.plugin.stop('mouse-edges');
} else if (this.interactionState === stateSymbol.PAINTING) {
this.config.onDragEnd(Array.from(this._paintedObjects), e);
this.viewport.plugin.stop('mouse-edges');
}
this.#clear({ state: true, selectionBox: true, gesture: true });
}
Expand Down Expand Up @@ -301,6 +334,8 @@ export default class SelectionState extends State {
if (gesture) {
this.dragStartPoint = null;
this.movedViewport = false;
this._paintedObjects.clear();
this._lastPaintPoint = null;
}
}
}
3 changes: 2 additions & 1 deletion src/tests/render/patchmap.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Transformer } from '../../patch-map';
import { setupPatchmapTests } from './patchmap.setup';

const sampleData = [
Expand Down Expand Up @@ -226,6 +227,7 @@ describe('patchmap test', () => {
beforeEach(() => {
vi.useFakeTimers();
patchmap = getPatchmap();
patchmap.transformer = new Transformer();
patchmap.draw(sampleData);
onClick = vi.fn();
onDrag = vi.fn();
Expand Down Expand Up @@ -395,7 +397,6 @@ describe('patchmap test', () => {
])(
'should return the correct object when selectUnit is "$selectUnit"',
async ({ selectUnit, clickPosition, expectedId }) => {
const patchmap = getPatchmap();
patchmap.draw([
{ type: 'group', id: 'group-2', children: sampleData },
]);
Expand Down
59 changes: 59 additions & 0 deletions src/utils/intersects/segment-polygon-t.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { intersectPoint } from './intersect-point';

/**
* Calculates the smallest t (0 to 1) along the segment (p1, p2) where it first enters the object.
* Returns 0 if p1 is already inside the object.
* Returns null if the segment does not intersect the object.
*
* @param {PIXI.DisplayObject} obj - The object to check.
* @param {PIXI.Point} p1 - The start point of the segment.
* @param {PIXI.Point} p2 - The end point of the segment.
* @param {PIXI.Point[]} corners - The corners of the object in the same coordinate space as p1 and p2.
* @returns {number|null} The minimum t value (0 to 1) or null.
*/
export const getSegmentEntryT = (obj, p1, p2, corners) => {
if (intersectPoint(obj, p1)) {
return 0;
}

let minT = 1.1;

for (let i = 0; i < corners.length; i++) {
const v1 = corners[i];
const v2 = corners[(i + 1) % corners.length];

const t = intersectSegments(p1.x, p1.y, p2.x, p2.y, v1.x, v1.y, v2.x, v2.y);
if (t !== null && t < minT) {
minT = t;
}
}

return minT > 1 ? null : minT;
};

/**
* Calculates the intersection t of two line segments.
*
* @private
*/
function intersectSegments(x1, y1, x2, y2, x3, y3, x4, y4) {
const dx12 = x2 - x1;
const dy12 = y2 - y1;
const dx34 = x4 - x3;
const dy34 = y4 - y3;
const denominator = dy34 * dx12 - dx34 * dy12;

if (denominator === 0) {
return null;
}

const dx13 = x1 - x3;
const dy13 = y1 - y3;
const t = (dx34 * dy13 - dy34 * dx13) / denominator;
const u = (dx12 * dy13 - dy12 * dx13) / denominator;

if (t >= 0 && t <= 1 && u >= 0 && u <= 1) {
return t;
}
return null;
}