diff --git a/README.md b/README.md index 4c249582..0103aa1e 100644 --- a/README.md +++ b/README.md @@ -407,8 +407,11 @@ The selection event is activated to detect objects that the user selects on the This should be executed after the `draw` method. - `enabled` (optional, boolean): Determines whether the selection event is enabled. - `draggable` (optional, boolean): Determines whether dragging is enabled. -- `isSelectGroup` (optional, boolean): Decides whether to select group objects. -- `isSelectGrid` (optional, boolean): Decides whether to select grid objects. +- `selectUnit` (optional, string): Specifies the logical unit to return when selecting. The default is `'entity'`. + - `'entity'`: Selects individual objects. + - `'closestGroup'`: Selects the closest parent group of the selected object. + - `'highestGroup'`: Selects the highest-level group of the selected object. + - `'grid'`: Selects the grid to which the selected object belongs. - `filter` (optional, function): A function that filters the target objects based on specific conditions. - `onSelect` (optional, function): The callback function that is called when a selection occurs. - `onOver` (optional, function): The callback function that is called when a pointer-over event occurs. @@ -418,8 +421,7 @@ This should be executed after the `draw` method. patchmap.select({ enabled: true, draggable: true, - isSelectGroup: false, - isSelectGrid: true, + selectUnit: 'grid', filter: (obj) => obj.type !== 'relations', onSelect: (obj) => { console.log(obj); diff --git a/README_KR.md b/README_KR.md index d91ffbd8..4ac15c13 100644 --- a/README_KR.md +++ b/README_KR.md @@ -406,8 +406,11 @@ const result = patchmap.selector('$..[?(@.label=="group-label-1")]') `draw` 메소드 이후에 실행되어야 합니다. - `enabled` (optional, boolean): 선택 이벤트의 활성화 여부를 결정합니다. - `draggable` (optional, boolean): 드래그 활성화 여부를 결정합니다. -- `isSelectGroup` (optional, boolean): group 객체를 선택할지 결정합니다. -- `isSelectGrid` (optional, boolean): grid 객체를 선택할지 결정합니다. +- `selectUnit` (optional, string): 선택 시 반환될 논리적 단위를 지정합니다. 기본값은 'entity' 입니다. + - `'entity'`: 개별 객체를 선택합니다. + - `'closestGroup'`: 선택된 객체에서 가장 가까운 상위 그룹을 선택합니다. + - `'highestGroup'`: 선택된 객체에서 가장 최상위 그룹을 선택합니다. + - `'grid'`: 선택된 객체가 속한 그리드를 선택합니다. - `filter` (optional, function): 선택 대상 객체를 조건에 따라 필터링할 수 있는 함수입니다. - `onSelect` (optional, function): 선택이 발생할 때 호출될 콜백 함수입니다. - `onOver` (optional, function): 포인터 오버가 발생할 때 호출될 콜백 함수입니다. @@ -417,8 +420,7 @@ const result = patchmap.selector('$..[?(@.label=="group-label-1")]') patchmap.select({ enabled: true, draggable: true, - isSelectGroup: false, - isSelectGrid: true, + selectUnit: 'grid', filter: (obj) => obj.type !== 'relations', onSelect: (obj) => { console.log(obj); diff --git a/src/display/elements/Element.js b/src/display/elements/Element.js index b027f4df..a3810e0f 100644 --- a/src/display/elements/Element.js +++ b/src/display/elements/Element.js @@ -6,6 +6,9 @@ import { mixins } from '../mixins/utils'; const ComposedElement = mixins(Container, Base, Showable); export default class Element extends ComposedElement { + static isSelectable = false; + static hitScope = 'self'; // 'self' | 'children' + constructor(options) { super(Object.assign(options, { eventMode: 'static' })); } diff --git a/src/display/elements/Item.js b/src/display/elements/Item.js index b3153cf3..4fd96529 100644 --- a/src/display/elements/Item.js +++ b/src/display/elements/Item.js @@ -7,6 +7,9 @@ import Element from './Element'; const ComposedItem = mixins(Element, Componentsable, ItemSizeable); export class Item extends ComposedItem { + static isSelectable = true; + static hitScope = 'children'; + constructor(context) { super({ type: 'item', context }); } diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index abd2c601..774a1a12 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -10,6 +10,9 @@ import Element from './Element'; const ComposedRelations = mixins(Element, Linksable, Relationstyleable); export class Relations extends ComposedRelations { + static isSelectable = true; + static hitScope = 'children'; + _renderDirty = true; _renderOnNextTick = false; @@ -69,10 +72,10 @@ export class Relations extends ComposedRelations { continue; } - const sourceBounds = this.toLocal( + const sourceBounds = this.context.viewport.toLocal( calcOrientedBounds(sourceObject).center, ); - const targetBounds = this.toLocal( + const targetBounds = this.context.viewport.toLocal( calcOrientedBounds(targetObject).center, ); diff --git a/src/events/drag-select.js b/src/events/drag-select.js index 2a021d51..f6f58056 100644 --- a/src/events/drag-select.js +++ b/src/events/drag-select.js @@ -4,7 +4,7 @@ import { event } from '../utils/event/canvas'; import { validate } from '../utils/validator'; import { findIntersectObjects } from './find'; import { dragSelectEventSchema } from './schema'; -import { checkEvents, getPointerPosition, isMoved } from './utils'; +import { checkEvents, isMoved } from './utils'; const DRAG_SELECT_EVENT_ID = 'drag-select-down drag-select-move drag-select-up'; const DEBOUNCE_FN_INTERVAL = 25; // ms @@ -39,10 +39,10 @@ const addEvents = (viewport, state) => { event.addEvent(viewport, { id: 'drag-select-down', action: 'mousedown touchstart', - fn: () => { + fn: (e) => { resetState(state); - const point = getPointerPosition(viewport); + const point = viewport.toWorld({ ...e.global }); state.isDragging = true; state.box.renderable = true; state.point.start = { ...point }; @@ -56,9 +56,9 @@ const addEvents = (viewport, state) => { id: 'drag-select-move', action: 'mousemove touchmove moved', fn: (e) => { - if (!state.isDragging) return; + if (!state.isDragging || !e.global) return; - state.point.end = { ...getPointerPosition(viewport) }; + state.point.end = viewport.toWorld({ ...e.global }); drawSelectionBox(state); if (isMoved(viewport, state.point.move, state.point.end)) { @@ -94,14 +94,10 @@ const drawSelectionBox = (state) => { if (!point.start || !point.end) return; box.clear(); - box.position.set( - Math.min(point.start.x, point.end.x), - Math.min(point.start.y, point.end.y), - ); box .rect( - 0, - 0, + Math.min(point.start.x, point.end.x), + Math.min(point.start.y, point.end.y), Math.abs(point.start.x - point.end.x), Math.abs(point.start.y - point.end.y), ) diff --git a/src/events/find.js b/src/events/find.js index f0d11de7..5bf381ac 100644 --- a/src/events/find.js +++ b/src/events/find.js @@ -3,59 +3,96 @@ import { intersectPoint } from '../utils/intersects/intersect-point'; import { getSelectObject } from './utils'; export const findIntersectObject = (viewport, state, options) => { - return searchIntersect(viewport); - - function searchIntersect(parent) { - const children = [...parent.children].sort((a, b) => { - const zDiff = (b.zIndex || 0) - (a.zIndex || 0); - if (zDiff !== 0) return zDiff; - return parent.getChildIndex(b) - parent.getChildIndex(a); - }); - - for (const child of children) { - if ( - child.renderPipeId || - child.type === 'item' || - (options.isSelectGrid && child.type === 'grid') - ) { - const isIntersecting = intersectPoint(child, state.point); - const selectObject = isIntersecting - ? getSelectObject(child, options) - : null; + const allCandidates = collectCandidates( + viewport, + (child) => child.constructor.isSelectable, + ); + const sortedCandidates = allCandidates.sort((a, b) => { + const zDiff = (b.zIndex || 0) - (a.zIndex || 0); + if (zDiff !== 0) return zDiff; + + const pathA = getAncestorPath(a, viewport); + const pathB = getAncestorPath(b, viewport); + + const minLength = Math.min(pathA.length, pathB.length); + for (let i = 0; i < minLength; i++) { + if (pathA[i] !== pathB[i]) { + const commonParent = pathA[i].parent; + return ( + commonParent.getChildIndex(pathB[i]) - + commonParent.getChildIndex(pathA[i]) + ); + } + } + return pathB.length - pathA.length; + }); + + for (const candidate of sortedCandidates) { + const targets = + candidate.constructor.hitScope === 'children' + ? candidate.children + : [candidate]; + + for (const target of targets) { + const isIntersecting = intersectPoint(target, state.point); + if (isIntersecting) { + const selectObject = getSelectObject(candidate, options); if (selectObject && (!options.filter || options.filter(selectObject))) { return selectObject; } } - - const found = searchIntersect(child); - if (found) return found; } - return null; } + + return null; }; export const findIntersectObjects = (viewport, state, options) => { - return Array.from(new Set(searchIntersect(viewport))); - - function searchIntersect(parent) { - let found = []; - const children = [...parent.children]; + const allCandidates = collectCandidates( + viewport, + (child) => child.constructor.isSelectable, + ); + const found = []; - for (const child of children) { - if (child.renderPipeId || ['item', 'relations'].includes(child.type)) { - const isIntersecting = intersect(state.box, child); - const selectObject = isIntersecting - ? getSelectObject(child, options) - : null; + for (const candidate of allCandidates) { + const targets = + candidate.constructor.hitScope === 'children' + ? candidate.children + : [candidate]; + for (const target of targets) { + const isIntersecting = intersect(state.box, target); + if (isIntersecting) { + const selectObject = getSelectObject(candidate, options); if (selectObject && (!options.filter || options.filter(selectObject))) { found.push(selectObject); + break; } - } else { - found = found.concat(searchIntersect(child)); } } - return found; } + + return Array.from(new Set(found)); +}; + +const collectCandidates = (parent, filterFn) => { + let candidates = []; + for (const child of parent.children) { + if (filterFn(child)) { + candidates.push(child); + } + candidates = candidates.concat(collectCandidates(child, filterFn)); + } + return candidates; +}; + +const getAncestorPath = (obj, stopAt) => { + const path = []; + let current = obj; + while (current && current !== stopAt) { + path.unshift(current); + current = current.parent; + } + return path; }; diff --git a/src/events/schema.js b/src/events/schema.js index f2fdf70c..ad261f82 100644 --- a/src/events/schema.js +++ b/src/events/schema.js @@ -3,8 +3,9 @@ import { z } from 'zod'; const selectDefaultSchema = z.object({ enabled: z.boolean().default(false), filter: z.nullable(z.function()).default(null), - isSelectGroup: z.boolean().default(false), - isSelectGrid: z.boolean().default(false), + selectUnit: z + .enum(['entity', 'closestGroup', 'highestGroup', 'grid']) + .default('entity'), }); export const selectEventSchema = selectDefaultSchema.extend({ diff --git a/src/events/single-select.js b/src/events/single-select.js index 548a31a9..72e87699 100644 --- a/src/events/single-select.js +++ b/src/events/single-select.js @@ -4,7 +4,7 @@ import { event } from '../utils/event/canvas'; import { validate } from '../utils/validator'; import { findIntersectObject } from './find'; import { selectEventSchema } from './schema'; -import { checkEvents, getPointerPosition, isMoved } from './utils'; +import { checkEvents, isMoved } from './utils'; const SELECT_EVENT_ID = 'select-down select-up select-over'; @@ -30,11 +30,8 @@ const addEvents = (viewport, state) => { event.addEvent(viewport, { id: 'select-down', action: 'mousedown touchstart', - fn: () => { - state.position.start = { - x: viewport.position.x, - y: viewport.position.y, - }; + fn: (e) => { + state.position.start = viewport.toWorld({ ...e.global }); }, }); } @@ -44,10 +41,7 @@ const addEvents = (viewport, state) => { id: 'select-up', action: 'mouseup touchend', fn: (e) => { - state.position.end = { - x: viewport.position.x, - y: viewport.position.y, - }; + state.position.end = viewport.toWorld({ ...e.global }); if ( state.position.start && @@ -73,7 +67,7 @@ const addEvents = (viewport, state) => { } function executeFn(fnName, e) { - const point = getPointerPosition(viewport); + const point = viewport.toWorld({ ...e.global }); if (fnName in state.config) { state.config[fnName]( findIntersectObject(viewport, { point }, state.config), diff --git a/src/events/utils.js b/src/events/utils.js index fe820a1a..f9c52c38 100644 --- a/src/events/utils.js +++ b/src/events/utils.js @@ -4,18 +4,33 @@ export const checkEvents = (viewport, eventId) => { return eventId.split(' ').every((id) => event.getEvent(viewport, id)); }; -export const getSelectObject = (obj, { isSelectGroup, isSelectGrid }) => { - if (isSelectGroup) { - const groupParent = getHighestParentByType(obj, 'group'); - if (groupParent) return groupParent; +export const getSelectObject = (obj, { selectUnit }) => { + if (!obj || !obj.constructor.isSelectable) { + return null; } - if (isSelectGrid) { - const gridParent = getHighestParentByType(obj, 'grid'); - if (gridParent) return gridParent; - } + switch (selectUnit) { + case 'entity': + return obj; + + case 'closestGroup': { + const closestGroup = findClosestParent(obj, 'group'); + return closestGroup || obj; + } + + case 'highestGroup': { + const highestGroup = findHighestParent(obj, 'group'); + return highestGroup || obj; + } + + case 'grid': { + const parentGrid = findClosestParent(obj, 'grid'); + return parentGrid || obj; + } - return obj.renderPipeId ? obj.parent : obj; + default: + return obj; + } }; const MOVE_DELTA = 4; @@ -30,20 +45,25 @@ export const isMoved = (viewport, point1, point2) => { ); }; -const getHighestParentByType = (obj, typeName) => { - let highest = null; - let current = obj.parent; +const findClosestParent = (obj, type) => { + let current = obj; while (current && current.type !== 'canvas') { - if (current.type === typeName) { - highest = current; + if (current.type === type) { + return current; } current = current.parent; } - return highest; + return null; // 해당하는 부모가 없음 }; -export const getPointerPosition = (viewport) => { - const renderer = viewport?.app?.renderer; - const global = renderer?.events.pointer.global; - return viewport ? viewport.toWorld(global.x, global.y) : global; +const findHighestParent = (obj, type) => { + let topParent = null; + let current = obj; + while (current && current.type !== 'canvas') { + if (current.type === type) { + topParent = current; + } + current = current.parent; + } + return topParent; }; diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index 7b1c4a01..354883f5 100644 --- a/src/tests/render/patchmap.test.js +++ b/src/tests/render/patchmap.test.js @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { Patchmap } from '../../patchmap'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setupPatchmapTests } from './patchmap.setup'; const sampleData = [ { @@ -11,8 +11,11 @@ const sampleData = [ type: 'grid', id: 'grid-1', label: 'grid-label-1', - cells: [[1, 0, 1]], - gap: 4, + cells: [ + [1, 1, 1], + [0, 1, 1], + ], + gap: 5, item: { size: { width: 40, height: 80 }, components: [ @@ -23,47 +26,59 @@ const sampleData = [ }, ], }, + attrs: { x: 100, y: 100 }, }, { type: 'item', id: 'item-1', label: 'item-label-1', size: 50, - components: [], + components: [ + { + type: 'background', + id: 'item-background', + source: { + type: 'rect', + fill: 'white', + borderWidth: 2, + borderColor: 'primary.default', + radius: 6, + }, + }, + ], + attrs: { x: 200, y: 300 }, }, ], - attrs: { x: 100, y: 100 }, + }, + { + type: 'relations', + id: 'relations-1', + label: 'relations-label-1', + links: [ + { source: 'grid-1.0.0', target: 'grid-1.0.1' }, + { source: 'grid-1.0.1', target: 'grid-1.0.2' }, + { source: 'grid-1.0.2', target: 'grid-1.1.1' }, + { source: 'grid-1.1.1', target: 'grid-1.1.2' }, + { source: 'grid-1.1.2', target: 'item-1' }, + ], + style: { width: 5 }, }, ]; describe('patchmap test', () => { - let patchmap; - let element; - - beforeEach(async () => { - element = document.createElement('div'); - element.style.height = '100svh'; - document.body.appendChild(element); - - patchmap = new Patchmap(); - await patchmap.init(element); - }); - - afterEach(() => { - // patchmap.destroy(); - // document.body.removeChild(element); - }); + const { getPatchmap } = setupPatchmapTests(); it('draw', () => { + const patchmap = getPatchmap(); patchmap.draw(sampleData); - expect(patchmap.viewport.children.length).toBe(1); + expect(patchmap.viewport.children.length).toBe(2); const group = patchmap.selector('$..[?(@.id=="group-1")]')[0]; expect(group).toBeDefined(); expect(group.id).toBe('group-1'); expect(group.type).toBe('group'); - expect(group.x).toBe(100); - expect(group.y).toBe(100); + expect(group.x).toBe(0); + expect(group.y).toBe(0); const grid = patchmap.selector('$..[?(@.id=="grid-1")]')[0]; expect(grid).toBeDefined(); @@ -75,6 +90,316 @@ describe('patchmap test', () => { expect(item.id).toBe('item-1'); const gridItems = grid.children; - expect(gridItems.length).toBe(2); + expect(gridItems.length).toBe(5); + }); + + describe('update', () => { + let patchmap = null; + beforeEach(() => { + patchmap = getPatchmap(); + patchmap.draw(sampleData); + }); + + it('should update a single property', () => { + patchmap.update({ + path: '$..[?(@.id=="group-1")]', + changes: { attrs: { x: 200 } }, + }); + const group = patchmap.selector('$..[?(@.id=="group-1")]')[0]; + expect(group.x).toBe(200); + expect(group.y).toBe(0); + }); + + it('should update multiple properties simultaneously', () => { + patchmap.update({ + path: '$..[?(@.id=="group-1")]', + changes: { attrs: { x: 300, y: 300 } }, + }); + const group = patchmap.selector('$..[?(@.id=="group-1")]')[0]; + expect(group.x).toBe(300); + expect(group.y).toBe(300); + }); + + it('should update a property of a nested object', () => { + patchmap.update({ + path: '$..[?(@.id=="grid-1")]', + changes: { + item: { + components: [{ type: 'background', source: { fill: 'blue' } }], + }, + }, + }); + const background = patchmap.selector( + '$..[?(@.id=="grid-1")]..[?(@.type=="background")]', + )[0]; + expect(background.props.source.fill).toBe('blue'); + }); + + it('should replace an array completely when arrayMerge is "replace"', () => { + const initialGridItemCount = patchmap.selector( + '$..[?(@.id=="grid-1")]', + )[0].children.length; + expect(initialGridItemCount).toBe(5); + patchmap.update({ + path: '$..[?(@.id=="grid-1")]', + changes: { + cells: [[1, 1, 1, 1]], + }, + arrayMerge: 'replace', + }); + const gridItems = patchmap.selector('$..[?(@.id=="grid-1")]')[0].children; + expect(gridItems.length).toBe(4); + }); + + it('should fail silently when updating a non-existent element', () => { + expect(() => { + patchmap.update({ + path: '$..[?(@.id=="non-existent-id")]', + changes: { attrs: { x: 999 } }, + }); + }).not.toThrow(); + }); + + it('should update an element using a direct reference via the "elements" property', () => { + const itemToUpdate = patchmap.selector('$..[?(@.id=="item-1")]')[0]; + patchmap.update({ + elements: itemToUpdate, + changes: { label: 'updated-label' }, + }); + expect(itemToUpdate.label).toBe('updated-label'); + }); + + it('should apply a relative transform with relativeTransform: true', () => { + patchmap.update({ + path: '$..[?(@.id=="group-1")]', + changes: { attrs: { x: 300, y: 300 } }, + }); + patchmap.update({ + path: '$..[?(@.id=="group-1")]', + changes: { attrs: { x: 50, y: -50 } }, + relativeTransform: true, + }); + const group = patchmap.selector('$..[?(@.id=="group-1")]')[0]; + expect(group.x).toBe(350); + expect(group.y).toBe(250); + }); + }); + + describe('select', () => { + let patchmap; + let onSelect; + let onOver; + let onDragSelect; + + beforeEach(async () => { + patchmap = getPatchmap(); + patchmap.draw(sampleData); + onSelect = vi.fn(); + onOver = vi.fn(); + onDragSelect = vi.fn(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + describe('when draggable is false', () => { + beforeEach(() => { + patchmap.select({ + enabled: true, + draggable: false, + selectUnit: 'grid', + onSelect, + onOver, + onDragSelect, + }); + }); + + describe.each([ + { + state: 'default viewport', + transform: () => {}, + }, + { + state: 'scaled 0.5 viewport', + transform: (viewport) => viewport.setZoom(0.5, true), + }, + { + state: 'scaled 5 viewport', + transform: (viewport) => viewport.setZoom(5, true), + }, + { + state: 'panned viewport', + transform: (viewport) => viewport.position.set(400, 400), + }, + { + state: 'scaled and panned viewport #2', + transform: (viewport) => { + viewport.setZoom(5, true); + patchmap.viewport.moveCenter(200, 200); + }, + }, + ])('with $state', ({ transform }) => { + it.each([ + { + case: 'clicking inside the Grid #1', + position: { x: 100, y: 100 }, + expectedId: 'grid-1', + }, + { + case: 'clicking inside the Grid #2', + position: { x: 229, y: 100 }, + expectedId: 'grid-1', + }, + { + case: 'clicking inside the Grid #3', + position: { x: 229, y: 140 }, + expectedId: 'grid-1', + }, + { + case: 'clicking inside the Item', + position: { x: 215, y: 315 }, + expectedId: 'item-1', + }, + { + case: 'clicking on an empty area #1', + position: { x: 0, y: 0 }, + expectedId: null, + }, + { + case: 'clicking on an empty area #2', + position: { x: 231, y: 100 }, + expectedId: null, + }, + { + case: 'clicking on an empty area #3', + position: { x: 235, y: 100 }, + expectedId: null, + }, + { + case: 'clicking on an empty area #4', + position: { x: 235, y: 220 }, + expectedId: null, + }, + { + case: 'clicking on an empty area #5', + position: { x: 210, y: 266 }, + expectedId: null, + }, + { + case: 'clicking on an empty area #6', + position: { x: 200, y: 280 }, + expectedId: null, + }, + { + case: 'clicking inside the Relations', + position: { x: 220, y: 280 }, + expectedId: 'relations-1', + }, + ])( + 'should select the correct element when $case', + async ({ position, expectedId }) => { + const viewport = patchmap.viewport; + transform(viewport); + await new Promise((resolve) => setTimeout(resolve, 50)); + + viewport.emit('mousedown', { + global: viewport.toGlobal(position), + stopPropagation: () => {}, + }); + viewport.emit('mouseup', { + global: viewport.toGlobal(position), + stopPropagation: () => {}, + }); + + expect(onSelect).toHaveBeenCalledTimes(1); + const receivedElement = onSelect.mock.calls[0][0]; + + if (expectedId === null) { + expect(receivedElement).toBeNull(); + } else { + expect(receivedElement).toBeDefined(); + expect(receivedElement.id).toBe(expectedId); + } + + expect(onDragSelect).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('with selectUnit option', () => { + it.each([ + { + selectUnit: 'entity', + clickPosition: { x: 105, y: 105 }, + expectedId: 'grid-1.0.0', + }, + { + selectUnit: 'grid', + clickPosition: { x: 105, y: 105 }, + expectedId: 'grid-1', + }, + { + selectUnit: 'closestGroup', + clickPosition: { x: 105, y: 105 }, + expectedId: 'group-1', + }, + { + selectUnit: 'highestGroup', + clickPosition: { x: 105, y: 105 }, + expectedId: 'group-2', + }, + { + selectUnit: 'entity', + clickPosition: { x: 210, y: 310 }, + expectedId: 'item-1', + }, + { + selectUnit: 'closestGroup', + clickPosition: { x: 210, y: 310 }, + expectedId: 'group-1', + }, + { + selectUnit: 'highestGroup', + clickPosition: { x: 210, y: 310 }, + expectedId: 'group-2', + }, + ])( + '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 }, + ]); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const onSelect = vi.fn(); + + patchmap.select({ + enabled: true, + selectUnit: selectUnit, + onSelect: onSelect, + }); + + const viewport = patchmap.viewport; + viewport.emit('mousedown', { + global: viewport.toGlobal(clickPosition), + stopPropagation: () => {}, + }); + viewport.emit('mouseup', { + global: viewport.toGlobal(clickPosition), + stopPropagation: () => {}, + }); + + expect(onSelect).toHaveBeenCalledTimes(1); + const selectedObject = onSelect.mock.calls[0][0]; + + if (expectedId) { + expect(selectedObject).toBeDefined(); + expect(selectedObject.id).toBe(expectedId); + } else { + expect(selectedObject).toBeNull(); + } + }, + ); + }); }); }); diff --git a/src/utils/get.js b/src/utils/get.js index 29c403d9..ce214416 100644 --- a/src/utils/get.js +++ b/src/utils/get.js @@ -15,3 +15,8 @@ export const getColor = (theme, color) => { const themeColor = getNestedValue(theme, color); return themeColor ?? color; }; + +export const getViewport = (displayObject) => { + if (!displayObject) return null; + return displayObject?.context?.viewport ?? getViewport(displayObject.parent); +}; diff --git a/src/utils/intersects/get-points.js b/src/utils/intersects/get-points.js deleted file mode 100644 index 9d0bfaf7..00000000 --- a/src/utils/intersects/get-points.js +++ /dev/null @@ -1,17 +0,0 @@ -import { Point } from 'pixi.js'; - -export const getPoints = (viewport, obj) => { - const bounds = obj.getLocalBounds(); - const { x, y, width, height } = bounds; - - const localCorners = [ - new Point(x, y), - new Point(x + width, y), - new Point(x + width, y + height), - new Point(x, y + height), - ]; - return localCorners.flatMap((corner) => { - const point = viewport.toWorld(obj.toGlobal(corner)); - return [point.x, point.y]; - }); -}; diff --git a/src/utils/intersects/intersect-point.js b/src/utils/intersects/intersect-point.js index cc9ecda9..62f1a211 100644 --- a/src/utils/intersects/intersect-point.js +++ b/src/utils/intersects/intersect-point.js @@ -1,15 +1,16 @@ -import { Polygon } from 'pixi.js'; -import { getPoints } from './get-points'; +import { Graphics, Polygon } from 'pixi.js'; +import { getViewport } from '../get'; +import { getObjectLocalCorners } from '../transform'; export const intersectPoint = (obj, point) => { - const viewport = obj?.context?.viewport; + const viewport = getViewport(obj); if (!viewport) return false; - if ('containsPoint' in obj) { + if (obj instanceof Graphics) { return obj.containsPoint(point); } - const points = getPoints(viewport, obj); - const polygon = new Polygon(points); + const localCorners = getObjectLocalCorners(obj, viewport); + const polygon = new Polygon(localCorners); return polygon.contains(point.x, point.y); }; diff --git a/src/utils/intersects/intersect.js b/src/utils/intersects/intersect.js index 7c16e8f6..123872fb 100644 --- a/src/utils/intersects/intersect.js +++ b/src/utils/intersects/intersect.js @@ -1,11 +1,19 @@ -import { getPoints } from './get-points'; +import { getViewport } from '../get'; +import { getObjectLocalCorners } from '../transform'; import { sat } from './sat'; export const intersect = (obj1, obj2) => { - const viewport = obj1?.context?.viewport ?? obj2?.context?.viewport; + const viewport = getViewport(obj2) ?? getViewport(obj1); if (!viewport) return false; - const points1 = getPoints(viewport, obj1); - const points2 = getPoints(viewport, obj2); + const points1 = getObjectLocalCorners(obj1, viewport).flatMap((point) => [ + point.x, + point.y, + ]); + const points2 = getObjectLocalCorners(obj2, viewport).flatMap((point) => [ + point.x, + point.y, + ]); + return sat(points1, points2); }; diff --git a/src/utils/intersects/sat.js b/src/utils/intersects/sat.js index a492ff34..680db157 100644 --- a/src/utils/intersects/sat.js +++ b/src/utils/intersects/sat.js @@ -1,6 +1,6 @@ /** - * @param {Point[]} points1 - [{x, y}, ...] - * @param {Point[]} points2 - [{x, y}, ...] + * @param {Point[]} points1 - [x1, y1, ...] + * @param {Point[]} points2 - [x1, y1, ...] * @returns {boolean} */ export function sat(points1, points2) { diff --git a/src/utils/transform.js b/src/utils/transform.js index 804dd5ad..5ac78194 100644 --- a/src/utils/transform.js +++ b/src/utils/transform.js @@ -33,6 +33,23 @@ export const getObjectWorldCorners = (displayObject) => { return corners.map((point) => point.clone()); }; +/** + * Calculates the four corners of a DisplayObject and transforms them into the local space of the Viewport. + * This is useful for positioning elements that are children of the Viewport. + * + * @param {PIXI.DisplayObject} displayObject - The DisplayObject to measure. + * @param {PIXI.Viewport} viewport - The Viewport to which the coordinates will be relative. + * @returns {Array} An array of 4 new Point instances for the local-space corners relative to the viewport. + */ +export const getObjectLocalCorners = (displayObject, viewport) => { + if (!displayObject || !viewport) { + return []; + } + return getObjectWorldCorners(displayObject).map((point) => + viewport.toLocal(point), + ); +}; + /** * Calculates the geometric center (centroid) of an array of points. *