diff --git a/README.md b/README.md index 0d3c9ffd..0751c634 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README_KR.md b/README_KR.md index b88de8b7..5d9cd31c 100644 --- a/README_KR.md +++ b/README_KR.md @@ -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'`: 선택된 객체에서 가장 가까운 상위 그룹을 선택합니다. diff --git a/src/display/draw.js b/src/display/draw.js index 16971bfe..4a978f67 100644 --- a/src/display/draw.js +++ b/src/display/draw.js @@ -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) => { diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index 4472f15f..89a1495a 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -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() { diff --git a/src/events/find.js b/src/events/find.js index ea10ead7..37e8e72d 100644 --- a/src/events/find.js +++ b/src/events/find.js @@ -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 = ( @@ -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; diff --git a/src/events/states/SelectionState.js b/src/events/states/SelectionState.js index c3658efd..ab789723 100644 --- a/src/events/states/SelectionState.js +++ b/src/events/states/SelectionState.js @@ -1,6 +1,10 @@ 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'; @@ -8,11 +12,13 @@ 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. @@ -43,10 +49,10 @@ 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). @@ -54,6 +60,7 @@ const stateSymbol = { const defaultConfig = { draggable: false, + paintSelection: false, filter: () => true, selectUnit: 'entity', drillDown: false, @@ -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. @@ -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); @@ -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); } @@ -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) { @@ -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 }); } @@ -301,6 +334,8 @@ export default class SelectionState extends State { if (gesture) { this.dragStartPoint = null; this.movedViewport = false; + this._paintedObjects.clear(); + this._lastPaintPoint = null; } } } diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index 8f1b1ea1..308b60e1 100644 --- a/src/tests/render/patchmap.test.js +++ b/src/tests/render/patchmap.test.js @@ -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 = [ @@ -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(); @@ -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 }, ]); diff --git a/src/utils/intersects/segment-polygon-t.js b/src/utils/intersects/segment-polygon-t.js new file mode 100644 index 00000000..92aa9b28 --- /dev/null +++ b/src/utils/intersects/segment-polygon-t.js @@ -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; +}