From 85aa2deeb4d1ffc3ac651f5e62b836dffbda2266 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 15 Jul 2025 10:36:33 +0900 Subject: [PATCH 01/11] fix intersect point --- src/events/drag-select.js | 10 +++++----- src/events/single-select.js | 4 ++-- src/events/utils.js | 6 ------ src/utils/intersects/intersect-point.js | 2 +- 4 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/events/drag-select.js b/src/events/drag-select.js index 2a021d51..d316c035 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)) { diff --git a/src/events/single-select.js b/src/events/single-select.js index 548a31a9..54154a6c 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'; @@ -73,7 +73,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..6f337e2e 100644 --- a/src/events/utils.js +++ b/src/events/utils.js @@ -41,9 +41,3 @@ const getHighestParentByType = (obj, typeName) => { } return highest; }; - -export const getPointerPosition = (viewport) => { - const renderer = viewport?.app?.renderer; - const global = renderer?.events.pointer.global; - return viewport ? viewport.toWorld(global.x, global.y) : global; -}; diff --git a/src/utils/intersects/intersect-point.js b/src/utils/intersects/intersect-point.js index cc9ecda9..611ca49d 100644 --- a/src/utils/intersects/intersect-point.js +++ b/src/utils/intersects/intersect-point.js @@ -6,7 +6,7 @@ export const intersectPoint = (obj, point) => { if (!viewport) return false; if ('containsPoint' in obj) { - return obj.containsPoint(point); + return obj.getBounds().containsPoint(point); } const points = getPoints(viewport, obj); From 444c0a5fc8f259dc9177df13568d670b5be93874 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 15 Jul 2025 12:24:43 +0900 Subject: [PATCH 02/11] delete get-points --- src/events/drag-select.js | 8 ++------ src/utils/intersects/get-points.js | 17 ----------------- src/utils/intersects/intersect-point.js | 10 +++------- src/utils/intersects/intersect.js | 13 ++++++++++--- src/utils/intersects/sat.js | 4 ++-- src/utils/transform.js | 17 +++++++++++++++++ 6 files changed, 34 insertions(+), 35 deletions(-) delete mode 100644 src/utils/intersects/get-points.js diff --git a/src/events/drag-select.js b/src/events/drag-select.js index d316c035..e2d4ec5a 100644 --- a/src/events/drag-select.js +++ b/src/events/drag-select.js @@ -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/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 611ca49d..9ddaf89f 100644 --- a/src/utils/intersects/intersect-point.js +++ b/src/utils/intersects/intersect-point.js @@ -1,15 +1,11 @@ import { Polygon } from 'pixi.js'; -import { getPoints } from './get-points'; +import { getObjectLocalCorners } from '../transform'; export const intersectPoint = (obj, point) => { const viewport = obj?.context?.viewport; if (!viewport) return false; - if ('containsPoint' in obj) { - return obj.getBounds().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..dd37fddb 100644 --- a/src/utils/intersects/intersect.js +++ b/src/utils/intersects/intersect.js @@ -1,11 +1,18 @@ -import { getPoints } from './get-points'; +import { getObjectLocalCorners } from '../transform'; import { sat } from './sat'; export const intersect = (obj1, obj2) => { const viewport = obj1?.context?.viewport ?? obj2?.context?.viewport; 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. * From 0a463958114a43788d21e9cd2656c5fb3f87a30e Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 15 Jul 2025 12:29:37 +0900 Subject: [PATCH 03/11] fix --- src/display/elements/Relations.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index abd2c601..c569b8c0 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -69,10 +69,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, ); From 0cdf491488ca706a72befa001a1b42988104bbdf Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 15 Jul 2025 15:45:08 +0900 Subject: [PATCH 04/11] fix intersect --- src/tests/render/patchmap.test.js | 300 ++++++++++++++++++++++-- src/utils/get.js | 5 + src/utils/intersects/intersect-point.js | 9 +- 3 files changed, 286 insertions(+), 28 deletions(-) diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index 7b1c4a01..ac1c2863 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,239 @@ 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, + isSelectGrid: true, + 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 #7', + 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(); + }, + ); + }); + }); }); }); 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/intersect-point.js b/src/utils/intersects/intersect-point.js index 9ddaf89f..62f1a211 100644 --- a/src/utils/intersects/intersect-point.js +++ b/src/utils/intersects/intersect-point.js @@ -1,10 +1,15 @@ -import { Polygon } from 'pixi.js'; +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 (obj instanceof Graphics) { + return obj.containsPoint(point); + } + const localCorners = getObjectLocalCorners(obj, viewport); const polygon = new Polygon(localCorners); return polygon.contains(point.x, point.y); From da51645d4b26f4e2e758ce19193f03574a0b9807 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Tue, 15 Jul 2025 16:02:43 +0900 Subject: [PATCH 05/11] chore --- src/tests/render/patchmap.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index ac1c2863..0227519b 100644 --- a/src/tests/render/patchmap.test.js +++ b/src/tests/render/patchmap.test.js @@ -284,7 +284,7 @@ describe('patchmap test', () => { expectedId: null, }, { - case: 'clicking on an empty area #7', + case: 'clicking on an empty area #6', position: { x: 200, y: 280 }, expectedId: null, }, From 4e3f0c025c97c846ee454d5e90e8eeaecb91f8e0 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 16 Jul 2025 10:52:37 +0900 Subject: [PATCH 06/11] fix select schema --- src/display/elements/Element.js | 3 + src/display/elements/Item.js | 3 + src/display/elements/Relations.js | 3 + src/events/find.js | 111 ++++++++++++++++++++---------- src/events/schema.js | 5 +- src/events/utils.js | 62 +++++++++++++---- src/tests/render/patchmap.test.js | 2 +- 7 files changed, 134 insertions(+), 55 deletions(-) diff --git a/src/display/elements/Element.js b/src/display/elements/Element.js index b027f4df..9637bc9c 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 selectionScope = '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..82542943 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 selectionScope = 'children'; + constructor(context) { super({ type: 'item', context }); } diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index c569b8c0..b3c537ce 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 selectionScope = 'children'; + _renderDirty = true; _renderOnNextTick = false; diff --git a/src/events/find.js b/src/events/find.js index f0d11de7..6342f59f 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.selectionScope === '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.selectionScope === '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..8f173f92 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), + scope: z + .enum(['entity', 'closestGroup', 'highestGroup', 'grid']) + .default('entity'), }); export const selectEventSchema = selectDefaultSchema.extend({ diff --git a/src/events/utils.js b/src/events/utils.js index 6f337e2e..823f8c8c 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, { scope }) => { + if (!obj || !obj.constructor.isSelectable) { + return null; } - if (isSelectGrid) { - const gridParent = getHighestParentByType(obj, 'grid'); - if (gridParent) return gridParent; - } + switch (scope) { + 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,14 +45,31 @@ export const isMoved = (viewport, point1, point2) => { ); }; -const getHighestParentByType = (obj, typeName) => { - let highest = null; - let current = obj.parent; +/** + * 가장 가까운 부모 중 지정된 type을 가진 객체를 찾습니다. + */ +const findClosestParent = (obj, type) => { + let current = obj; + while (current && current.type !== 'canvas') { + if (current.type === type) { + return current; + } + current = current.parent; + } + return null; // 해당하는 부모가 없음 +}; + +/** + * 최상위 부모 중 지정된 type을 가진 객체를 찾습니다. + */ +const findHighestParent = (obj, type) => { + let topParent = null; + let current = obj; while (current && current.type !== 'canvas') { - if (current.type === typeName) { - highest = current; + if (current.type === type) { + topParent = current; } current = current.parent; } - return highest; + return topParent; }; diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index 0227519b..d8ef4edb 100644 --- a/src/tests/render/patchmap.test.js +++ b/src/tests/render/patchmap.test.js @@ -205,7 +205,7 @@ describe('patchmap test', () => { patchmap.select({ enabled: true, draggable: false, - isSelectGrid: true, + scope: 'grid', onSelect, onOver, onDragSelect, From d0b454218dc6b535b40da552f3c6a0bbc4767089 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 16 Jul 2025 11:14:28 +0900 Subject: [PATCH 07/11] fix naming --- src/display/elements/Element.js | 2 +- src/display/elements/Item.js | 2 +- src/display/elements/Relations.js | 2 +- src/events/find.js | 4 ++-- src/events/schema.js | 2 +- src/events/utils.js | 10 ++-------- src/tests/render/patchmap.test.js | 2 +- 7 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/display/elements/Element.js b/src/display/elements/Element.js index 9637bc9c..a3810e0f 100644 --- a/src/display/elements/Element.js +++ b/src/display/elements/Element.js @@ -7,7 +7,7 @@ const ComposedElement = mixins(Container, Base, Showable); export default class Element extends ComposedElement { static isSelectable = false; - static selectionScope = 'self'; // 'self' | 'children' + 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 82542943..4fd96529 100644 --- a/src/display/elements/Item.js +++ b/src/display/elements/Item.js @@ -8,7 +8,7 @@ const ComposedItem = mixins(Element, Componentsable, ItemSizeable); export class Item extends ComposedItem { static isSelectable = true; - static selectionScope = 'children'; + static hitScope = 'children'; constructor(context) { super({ type: 'item', context }); diff --git a/src/display/elements/Relations.js b/src/display/elements/Relations.js index b3c537ce..774a1a12 100644 --- a/src/display/elements/Relations.js +++ b/src/display/elements/Relations.js @@ -11,7 +11,7 @@ const ComposedRelations = mixins(Element, Linksable, Relationstyleable); export class Relations extends ComposedRelations { static isSelectable = true; - static selectionScope = 'children'; + static hitScope = 'children'; _renderDirty = true; _renderOnNextTick = false; diff --git a/src/events/find.js b/src/events/find.js index 6342f59f..5bf381ac 100644 --- a/src/events/find.js +++ b/src/events/find.js @@ -30,7 +30,7 @@ export const findIntersectObject = (viewport, state, options) => { for (const candidate of sortedCandidates) { const targets = - candidate.constructor.selectionScope === 'children' + candidate.constructor.hitScope === 'children' ? candidate.children : [candidate]; @@ -57,7 +57,7 @@ export const findIntersectObjects = (viewport, state, options) => { for (const candidate of allCandidates) { const targets = - candidate.constructor.selectionScope === 'children' + candidate.constructor.hitScope === 'children' ? candidate.children : [candidate]; diff --git a/src/events/schema.js b/src/events/schema.js index 8f173f92..ad261f82 100644 --- a/src/events/schema.js +++ b/src/events/schema.js @@ -3,7 +3,7 @@ import { z } from 'zod'; const selectDefaultSchema = z.object({ enabled: z.boolean().default(false), filter: z.nullable(z.function()).default(null), - scope: z + selectUnit: z .enum(['entity', 'closestGroup', 'highestGroup', 'grid']) .default('entity'), }); diff --git a/src/events/utils.js b/src/events/utils.js index 823f8c8c..f9c52c38 100644 --- a/src/events/utils.js +++ b/src/events/utils.js @@ -4,12 +4,12 @@ export const checkEvents = (viewport, eventId) => { return eventId.split(' ').every((id) => event.getEvent(viewport, id)); }; -export const getSelectObject = (obj, { scope }) => { +export const getSelectObject = (obj, { selectUnit }) => { if (!obj || !obj.constructor.isSelectable) { return null; } - switch (scope) { + switch (selectUnit) { case 'entity': return obj; @@ -45,9 +45,6 @@ export const isMoved = (viewport, point1, point2) => { ); }; -/** - * 가장 가까운 부모 중 지정된 type을 가진 객체를 찾습니다. - */ const findClosestParent = (obj, type) => { let current = obj; while (current && current.type !== 'canvas') { @@ -59,9 +56,6 @@ const findClosestParent = (obj, type) => { return null; // 해당하는 부모가 없음 }; -/** - * 최상위 부모 중 지정된 type을 가진 객체를 찾습니다. - */ const findHighestParent = (obj, type) => { let topParent = null; let current = obj; diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index d8ef4edb..81f5231b 100644 --- a/src/tests/render/patchmap.test.js +++ b/src/tests/render/patchmap.test.js @@ -205,7 +205,7 @@ describe('patchmap test', () => { patchmap.select({ enabled: true, draggable: false, - scope: 'grid', + selectUnit: 'grid', onSelect, onOver, onDragSelect, From 009fe01ebe79d57c43abb938e6cad38fb1d9ef9a Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 16 Jul 2025 11:24:14 +0900 Subject: [PATCH 08/11] fix test --- src/tests/render/patchmap.test.js | 77 +++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/src/tests/render/patchmap.test.js b/src/tests/render/patchmap.test.js index 81f5231b..354883f5 100644 --- a/src/tests/render/patchmap.test.js +++ b/src/tests/render/patchmap.test.js @@ -324,5 +324,82 @@ describe('patchmap test', () => { ); }); }); + + 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(); + } + }, + ); + }); }); }); From 8d5440d1c6287d5f5a42a75e6ab6ee086f78ace6 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 16 Jul 2025 11:35:22 +0900 Subject: [PATCH 09/11] fix --- src/events/drag-select.js | 2 +- src/events/single-select.js | 12 +++--------- src/utils/intersects/intersect.js | 3 ++- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/events/drag-select.js b/src/events/drag-select.js index e2d4ec5a..5fdb9f77 100644 --- a/src/events/drag-select.js +++ b/src/events/drag-select.js @@ -58,7 +58,7 @@ const addEvents = (viewport, state) => { fn: (e) => { if (!state.isDragging || !e.global) return; - state.point.end = { ...viewport.toWorld(e.global) }; + state.point.end = viewport.toWorld(e.global); drawSelectionBox(state); if (isMoved(viewport, state.point.move, state.point.end)) { diff --git a/src/events/single-select.js b/src/events/single-select.js index 54154a6c..f39e2fbe 100644 --- a/src/events/single-select.js +++ b/src/events/single-select.js @@ -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 && diff --git a/src/utils/intersects/intersect.js b/src/utils/intersects/intersect.js index dd37fddb..123872fb 100644 --- a/src/utils/intersects/intersect.js +++ b/src/utils/intersects/intersect.js @@ -1,8 +1,9 @@ +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 = getObjectLocalCorners(obj1, viewport).flatMap((point) => [ From c654c3825af1a76526abbcc5f150c43a7c87ddf2 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 16 Jul 2025 11:57:24 +0900 Subject: [PATCH 10/11] fix docs --- README.md | 10 ++++++---- README_KR.md | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) 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); From 05cfb58783daa645a41bb64e32ca4f42428a7281 Mon Sep 17 00:00:00 2001 From: MinHo Lim Date: Wed, 16 Jul 2025 12:01:33 +0900 Subject: [PATCH 11/11] fix --- src/events/drag-select.js | 4 ++-- src/events/single-select.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/events/drag-select.js b/src/events/drag-select.js index 5fdb9f77..f6f58056 100644 --- a/src/events/drag-select.js +++ b/src/events/drag-select.js @@ -42,7 +42,7 @@ const addEvents = (viewport, state) => { fn: (e) => { resetState(state); - const point = viewport.toWorld(e.global); + const point = viewport.toWorld({ ...e.global }); state.isDragging = true; state.box.renderable = true; state.point.start = { ...point }; @@ -58,7 +58,7 @@ const addEvents = (viewport, state) => { fn: (e) => { if (!state.isDragging || !e.global) return; - state.point.end = viewport.toWorld(e.global); + state.point.end = viewport.toWorld({ ...e.global }); drawSelectionBox(state); if (isMoved(viewport, state.point.move, state.point.end)) { diff --git a/src/events/single-select.js b/src/events/single-select.js index f39e2fbe..72e87699 100644 --- a/src/events/single-select.js +++ b/src/events/single-select.js @@ -31,7 +31,7 @@ const addEvents = (viewport, state) => { id: 'select-down', action: 'mousedown touchstart', fn: (e) => { - state.position.start = viewport.toWorld(e.global); + state.position.start = viewport.toWorld({ ...e.global }); }, }); } @@ -41,7 +41,7 @@ const addEvents = (viewport, state) => { id: 'select-up', action: 'mouseup touchend', fn: (e) => { - state.position.end = viewport.toWorld(e.global); + state.position.end = viewport.toWorld({ ...e.global }); if ( state.position.start && @@ -67,7 +67,7 @@ const addEvents = (viewport, state) => { } function executeFn(fnName, e) { - const point = viewport.toWorld(e.global); + const point = viewport.toWorld({ ...e.global }); if (fnName in state.config) { state.config[fnName]( findIntersectObject(viewport, { point }, state.config),