diff --git a/client/dive-common/recipes/headtail.ts b/client/dive-common/recipes/headtail.ts index c84d7a220..014323dda 100644 --- a/client/dive-common/recipes/headtail.ts +++ b/client/dive-common/recipes/headtail.ts @@ -189,6 +189,17 @@ export default class HeadTail implements Recipe { data: GeoJSON.Feature[], key?: string, ) { + // A lone head or tail dragged to a new spot (companion point editing). + const point = data.find((d) => d.geometry.type === 'Point') as GeoJSON.Feature | undefined; + if (point && mode === 'editing' && key && isHeadTailPoint(key)) { + const bounds = track.getFeature(frameNum)[0]?.bounds; + return { + ...EmptyResponse, + data: { [key]: [{ ...point, properties: {} }] }, + union: bounds ? HeadTail.encloseVertices(bounds, [point.geometry.coordinates]) : [], + done: true, + }; + } const linestrings = data.filter((d) => d.geometry.type === 'LineString'); if (linestrings.length) { const linestring = linestrings[0] as GeoJSON.Feature; diff --git a/client/src/components/LayerManager.spec.ts b/client/src/components/LayerManager.spec.ts index 5f824acbe..dd5d3700c 100644 --- a/client/src/components/LayerManager.spec.ts +++ b/client/src/components/LayerManager.spec.ts @@ -40,6 +40,8 @@ const layerMocks = vi.hoisted(() => { getMode = vi.fn(() => 'disabled'); + restoreHandleActions = vi.fn(); + clear = vi.fn(); updatePoints = vi.fn(); diff --git a/client/src/components/LayerManager.vue b/client/src/components/LayerManager.vue index c27596396..e1390f3a0 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -56,6 +56,7 @@ import useLayerRefresh from './layerManager/useLayerRefresh'; import useSegmentationPointsLayer from './layerManager/useSegmentationPointsLayer'; import useAnnotationClickHandling from './layerManager/useAnnotationClickHandling'; import { cameraAwaitingGeometry, isCreatingNewDetection } from './layerManager/multicamCreation'; +import lineCompanion from './layerManager/lineCompanion'; /** LayerManager is a component intended to be used as a child of an Annotator. * It provides logic for switching which layers are visible, but more importantly @@ -215,6 +216,16 @@ export default defineComponent({ type: 'rectangle', }); + const boxEditLayer = new EditAnnotationLayer({ + annotator, + stateStyling: trackStyleManager.stateStyles, + typeStyling: typeStylingRef, + type: 'rectangle', + companion: true, + }); + editAnnotationLayer.peer = boxEditLayer; + boxEditLayer.peer = editAnnotationLayer; + const lassoSelectionLayer = new LassoSelectionLayer( annotator, () => [rectAnnotationLayer.featureLayer, polyAnnotationLayer.featureLayer], @@ -613,6 +624,25 @@ export default defineComponent({ } else { editAnnotationLayer.disable(); } + + const companion = selectedTrackId === null ? null : lineCompanion( + editingTrack, + visibleModes.includes('rectangle'), + selectedKey, + editingTracks, + ); + if (companion) { + boxEditLayer.setType(companion.type); + boxEditLayer.setKey(companion.key); + boxEditLayer.changeData(companion.tracks.map((trackFrame) => ({ + ...trackFrame, + features: featureToDisplay(trackFrame.features), + }))); + } else { + boxEditLayer.disable(); + } + editAnnotationLayer.restoreHandleActions(); + boxEditLayer.restoreHandleActions(); } const { refreshLayers } = useLayerRefresh({ @@ -636,6 +666,7 @@ export default defineComponent({ attributeLayer, attributeBoxLayer, editAnnotationLayer, + boxEditLayer, segmentationPointsLayer, uiLayer, }, @@ -731,6 +762,7 @@ export default defineComponent({ trackStore, alignedView: alignedViewHelpers, editAnnotationLayer, + boxEditLayer, rectAnnotationLayer, polyAnnotationLayer, lineLayer, diff --git a/client/src/components/layerManager/lineCompanion.spec.ts b/client/src/components/layerManager/lineCompanion.spec.ts new file mode 100644 index 000000000..76a796278 --- /dev/null +++ b/client/src/components/layerManager/lineCompanion.spec.ts @@ -0,0 +1,50 @@ +import lineCompanion from './lineCompanion'; +import type { FrameDataTrack } from '../../layers/LayerTypes'; + +function trackFrame(options: { bounds?: boolean; line?: number; key?: string; points?: string[] } = {}): FrameDataTrack { + const { + bounds = true, line = 2, key = 'HeadTails', points = [], + } = options; + return { + features: { + frame: 0, + bounds: bounds ? [0, 0, 10, 10] : undefined, + geometry: { + type: 'FeatureCollection', + features: [ + ...(line ? [{ + type: 'Feature', + properties: { key }, + geometry: { type: 'LineString', coordinates: Array.from({ length: line }, (_, i) => [i, i]) }, + }] : []), + ...points.map((name) => ({ + type: 'Feature', properties: { key: name }, geometry: { type: 'Point', coordinates: [3, 4] }, + })), + ], + }, + }, + } as unknown as FrameDataTrack; +} + +it('offers the box corners while an existing line is edited with boxes visible', () => { + const tracks = [trackFrame({ points: ['head', 'tail'] })]; + expect(lineCompanion('LineString', true, 'HeadTails', tracks)).toEqual({ type: 'rectangle', key: '', tracks }); + expect(lineCompanion('LineString', false, 'HeadTails', tracks)).toBeNull(); + expect(lineCompanion('rectangle', true, 'HeadTails', tracks)).toBeNull(); + expect(lineCompanion(false, true, 'HeadTails', tracks)).toBeNull(); + expect(lineCompanion('LineString', true, 'HeadTails', [trackFrame({ bounds: false })])).toBeNull(); +}); + +it('offers a lone head or tail point so it can be moved instead of only extended', () => { + const head = [trackFrame({ line: 0, points: ['head'] })]; + expect(lineCompanion('LineString', false, 'HeadTails', head)).toEqual({ type: 'Point', key: 'head', tracks: head }); + const tail = [trackFrame({ line: 0, points: ['tail'], bounds: false })]; + expect(lineCompanion('LineString', true, 'HeadTails', tail)).toEqual({ type: 'Point', key: 'tail', tracks: tail }); +}); + +it('stays out of the way while a line is being drawn or has both points', () => { + expect(lineCompanion('LineString', true, 'HeadTails', [trackFrame({ line: 0 })])).toBeNull(); + expect(lineCompanion('LineString', true, 'HeadTails', [trackFrame({ line: 1 })])).toBeNull(); + expect(lineCompanion('LineString', true, 'HeadTails', [trackFrame({ line: 0, points: ['head', 'tail'] })])).toBeNull(); + expect(lineCompanion('LineString', true, '', [trackFrame()])).toBeNull(); +}); diff --git a/client/src/components/layerManager/lineCompanion.ts b/client/src/components/layerManager/lineCompanion.ts new file mode 100644 index 000000000..43e813383 --- /dev/null +++ b/client/src/components/layerManager/lineCompanion.ts @@ -0,0 +1,47 @@ +import { HeadPointKey, TailPointKey } from 'dive-common/recipes/headtail'; +import type { FrameDataTrack } from '../../layers/LayerTypes'; +import type { EditAnnotationTypes } from '../../layers/EditAnnotationLayer'; + +export interface LineCompanion { + type: 'rectangle' | 'Point'; + key: string; + tracks: FrameDataTrack[]; +} + +function lineAt(track: FrameDataTrack, key: string): boolean { + return !!track.features?.geometry?.features.some((feature) => feature.geometry.type === 'LineString' + && (feature.properties?.key ?? '') === key + && feature.geometry.coordinates.length >= 2); +} + +function pointKeys(track: FrameDataTrack): string[] { + return (track.features?.geometry?.features ?? []) + .filter((feature) => feature.geometry.type === 'Point' + && [HeadPointKey, TailPointKey].includes(feature.properties?.key ?? '')) + .map((feature) => feature.properties?.key as string); +} + +/** + * What the companion editor shows while the line tool is active: the box + * corners of a track whose line exists, or the lone head/tail point of a + * track that has one point and no line, so that point can be moved rather + * than only extended into a line. Nothing for a track still being drawn. + */ +export default function lineCompanion( + editingTrack: false | EditAnnotationTypes, + rectanglesVisible: boolean, + selectedKey: string, + editingTracks: FrameDataTrack[], +): LineCompanion | null { + if (editingTrack !== 'LineString') return null; + const withLine = editingTracks.filter((track) => lineAt(track, selectedKey)); + if (withLine.length) { + if (!rectanglesVisible) return null; + const tracks = withLine.filter(({ features }) => !!features?.bounds); + return tracks.length ? { type: 'rectangle', key: '', tracks } : null; + } + const single = editingTracks.filter((track) => pointKeys(track).length === 1); + if (!single.length) return null; + const [key] = pointKeys(single[0]); + return { type: 'Point', key, tracks: single.filter((track) => pointKeys(track)[0] === key) }; +} diff --git a/client/src/components/layerManager/useAnnotationClickHandling.ts b/client/src/components/layerManager/useAnnotationClickHandling.ts index f05b29177..d16af97f1 100644 --- a/client/src/components/layerManager/useAnnotationClickHandling.ts +++ b/client/src/components/layerManager/useAnnotationClickHandling.ts @@ -29,6 +29,7 @@ export default function useAnnotationClickHandling(options: { 'alignedDisplayInverse' | 'mapNativePoint' | 'mapEditGeoJSONToNative' >; editAnnotationLayer: EditAnnotationLayer; + boxEditLayer?: EditAnnotationLayer; rectAnnotationLayer: RectangleLayer; polyAnnotationLayer: PolygonLayer; lineLayer: LineLayer; @@ -47,6 +48,7 @@ export default function useAnnotationClickHandling(options: { trackStore, alignedView, editAnnotationLayer, + boxEditLayer, rectAnnotationLayer, polyAnnotationLayer, lineLayer, @@ -208,7 +210,7 @@ export default function useAnnotationClickHandling(options: { } }); - editAnnotationLayer.bus.$on('update:geojson', ( + const updateGeoJSON = ( mode: 'in-progress' | 'editing', geometryCompleteEvent: boolean, data: GeoJSON.Feature, @@ -255,7 +257,21 @@ export default function useAnnotationClickHandling(options: { window.setTimeout(() => { justFinalizedCreation = false; }, 0); refreshLayers(); } - }); + }; + editAnnotationLayer.bus.$on('update:geojson', updateGeoJSON); + // A box edit leaves the line untouched, so the line layer keeps its + // annotation (and any selected vertex) rather than rebuilding. + boxEditLayer?.bus.$on('update:geojson', ( + mode: 'in-progress' | 'editing', + geometryCompleteEvent: boolean, + data: GeoJSON.Feature, + type: string, + key = '', + cb: () => void = () => (undefined), + ) => updateGeoJSON(mode, geometryCompleteEvent, data, type, key, () => { + cb(); + editAnnotationLayer.skipNextExternalUpdate = true; + })); editAnnotationLayer.bus.$on( 'update:selectedIndex', diff --git a/client/src/components/layerManager/useLayerRefresh.ts b/client/src/components/layerManager/useLayerRefresh.ts index fe41015c2..f94640368 100644 --- a/client/src/components/layerManager/useLayerRefresh.ts +++ b/client/src/components/layerManager/useLayerRefresh.ts @@ -32,6 +32,7 @@ export interface LayerRefreshContext { attributeLayer: DisableableLayer; attributeBoxLayer: DisableableLayer; editAnnotationLayer: DisableableLayer; + boxEditLayer?: DisableableLayer; segmentationPointsLayer: SegmentationPointsLayer; uiLayer: UILayer; }; @@ -68,6 +69,7 @@ export default function useLayerRefresh(ctx: LayerRefreshContext) { layers.attributeLayer.disable(); layers.attributeBoxLayer.disable(); layers.editAnnotationLayer.disable(); + layers.boxEditLayer?.disable(); layers.segmentationPointsLayer.clear(); ctx.hoverOvered.value = []; layers.uiLayer.setToolTipWidget('customToolTip', false); diff --git a/client/src/layers/EditAnnotationLayer.companion.spec.ts b/client/src/layers/EditAnnotationLayer.companion.spec.ts new file mode 100644 index 000000000..dbab3123c --- /dev/null +++ b/client/src/layers/EditAnnotationLayer.companion.spec.ts @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +/* eslint-disable @typescript-eslint/no-explicit-any -- GeoJS boundary */ +import geo from 'geojs'; +import { ref } from 'vue'; +import EditAnnotationLayer from './EditAnnotationLayer'; +import Track from '../track'; +import { headTailFeatures } from '../headTail'; + +/** Real GeoJS: both layers sit in edit mode on one map, as in LayerManager. */ +beforeAll(() => (geo.util as any).mockWebglRenderer()); + +async function harness() { + const node = document.createElement('div'); + document.body.appendChild(node); + const map = geo.map({ + node, width: 800, height: 600, ...geo.util.pixelCoordinateParams(node, 800, 600, 800, 600).map, + }); + const cursors: string[] = []; + const params = { + annotator: { geoViewerRef: ref(map), setCursor: (c: string) => cursors.push(c), setImageCursor: vi.fn() }, + stateStyling: { standard: { color: '#f00' }, selected: { color: '#f00' } }, + typeStyling: ref({ color: () => '#f00', strokeWidth: () => 1, opacity: () => 1 }), + } as any; + const line = new EditAnnotationLayer({ ...params, type: 'LineString' }); + const box = new EditAnnotationLayer({ ...params, type: 'rectangle', companion: true }); + line.peer = box; box.peer = line; + line.setKey('HeadTails'); + const track = new Track(1, { begin: 0, end: 0, meta: {} }); + track.setFeature({ frame: 0, keyframe: true, bounds: [50, 50, 400, 400] }, headTailFeatures([[100, 100], [300, 300]])); + const frameData = [{ features: track.features[0], track }] as any; + await line.changeData(frameData); await box.changeData(frameData); + const lineUpdate = vi.fn(); const boxUpdate = vi.fn(); + line.bus.$on('update:geojson', lineUpdate); box.bus.$on('update:geojson', boxUpdate); + const mouse = (type: string, x: number, y: number) => map.interactor().simulateEvent(type, { map: { x, y }, button: 'left' }); + const drag = (x: number, y: number, dx: number, dy: number) => { + mouse('mousemove', x, y); mouse('mousedown', x, y); + mouse('mousemove', x + dx, y + dy); mouse('mouseup', x + dx, y + dy); + }; + const round = (coords: number[][]) => coords.map(([x, y]) => [Math.round(x), Math.round(y)]); + const lineCoords = () => round(line.featureLayer.annotations()[0].geojson().geometry.coordinates); + const boxCoords = () => round(box.featureLayer.annotations()[0].geojson().geometry.coordinates[0]); + return { + mouse, drag, lineUpdate, boxUpdate, lineCoords, boxCoords, line, box, frameData, cursors, + }; +} + +it('drags a box corner in line mode without disturbing a previously hovered line vertex', async () => { + const h = await harness(); + h.mouse('mousemove', 100, 100); h.mouse('mousemove', 75, 75); + h.drag(50, 50, -20, -20); + expect(h.boxUpdate).toHaveBeenCalledTimes(1); + expect(h.boxUpdate.mock.calls[0][3]).toBe('rectangle'); + expect(h.boxCoords()).toContainEqual([30, 30]); + expect(h.lineUpdate).not.toHaveBeenCalled(); + expect(h.lineCoords()).toEqual([[100, 100], [300, 300]]); +}); + +it('drags a line vertex without disturbing a previously hovered box corner', async () => { + const h = await harness(); + h.mouse('mousemove', 50, 50); h.mouse('mousemove', 75, 75); + h.drag(100, 100, 30, 10); + expect(h.lineUpdate).toHaveBeenCalledTimes(1); + expect(h.lineCoords()).toEqual([[130, 110], [300, 300]]); + expect(h.boxUpdate).not.toHaveBeenCalled(); + expect(h.boxCoords()).toContainEqual([50, 50]); +}); + +it('keeps a hovered box corner draggable after the line layer leaves and re-enters edit mode', async () => { + const h = await harness(); + h.mouse('mousemove', 50, 50); + h.line.disable(); + await h.line.changeData(h.frameData); + h.mouse('mousedown', 50, 50); h.mouse('mousemove', 30, 30); h.mouse('mouseup', 30, 30); + expect(h.boxUpdate).not.toHaveBeenCalled(); + + h.box.restoreHandleActions(); + h.mouse('mousedown', 50, 50); h.mouse('mousemove', 30, 30); h.mouse('mouseup', 30, 30); + expect(h.boxUpdate).toHaveBeenCalledTimes(1); + expect(h.boxCoords()).toContainEqual([30, 30]); +}); + +it('shows the hand over a line vertex and a resize cursor only over a box corner', async () => { + const h = await harness(); + h.cursors.length = 0; + h.mouse('mousemove', 100, 100); + expect(h.cursors).toEqual(['grab']); + h.cursors.length = 0; + h.mouse('mousemove', 200, 30); h.mouse('mousemove', 50, 50); + expect(h.cursors.at(-1)).toBe('nw-resize'); + expect(h.cursors).not.toContain('grab'); +}); + +it('moves a lone head point while the line tool waits for its tail', async () => { + const node = document.createElement('div'); + document.body.appendChild(node); + const map = geo.map({ + node, width: 800, height: 600, ...geo.util.pixelCoordinateParams(node, 800, 600, 800, 600).map, + }); + const params = { + annotator: { geoViewerRef: ref(map), setCursor: vi.fn(), setImageCursor: vi.fn() }, + stateStyling: { standard: { color: '#f00' }, selected: { color: '#f00' } }, + typeStyling: ref({ color: () => '#f00', strokeWidth: () => 1, opacity: () => 1 }), + } as any; + const line = new EditAnnotationLayer({ ...params, type: 'LineString' }); + const point = new EditAnnotationLayer({ ...params, type: 'rectangle', companion: true }); + line.peer = point; point.peer = line; + line.setKey('HeadTails'); point.setType('Point'); point.setKey('head'); + const track = new Track(1, { begin: 0, end: 0, meta: {} }); + track.setFeature({ frame: 0, keyframe: true, bounds: [50, 50, 400, 400] }, [{ + type: 'Feature', properties: { key: 'head' }, geometry: { type: 'Point', coordinates: [100, 100] }, + }]); + const frameData = [{ features: track.features[0], track }] as any; + await line.changeData(frameData); await point.changeData(frameData); + expect(line.getMode()).toBe('creation'); + expect(point.getMode()).toBe('editing'); + const lineUpdate = vi.fn(); const pointUpdate = vi.fn(); + line.bus.$on('update:geojson', lineUpdate); point.bus.$on('update:geojson', pointUpdate); + const mouse = (type: string, x: number, y: number) => map.interactor().simulateEvent(type, { map: { x, y }, button: 'left' }); + mouse('mousemove', 100, 100); mouse('mousedown', 100, 100); + mouse('mousemove', 130, 110); mouse('mouseup', 130, 110); + expect(pointUpdate).toHaveBeenCalledTimes(1); + expect(pointUpdate.mock.calls[0][2].geometry).toEqual({ type: 'Point', coordinates: [130, 110] }); + expect(pointUpdate.mock.calls[0][4]).toBe('head'); + expect(lineUpdate).not.toHaveBeenCalled(); + expect(line.shapeInProgress).toBeNull(); + // Leaving the handle gives the line tool its click-to-place actions back. + mouse('mousemove', 300, 300); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(map.interactor().hasAction(undefined, undefined, geo.annotation.actionOwner)).toBeTruthy(); +}); diff --git a/client/src/layers/EditAnnotationLayer.spec.ts b/client/src/layers/EditAnnotationLayer.spec.ts index 4bb2bd498..c4f2d0e07 100644 --- a/client/src/layers/EditAnnotationLayer.spec.ts +++ b/client/src/layers/EditAnnotationLayer.spec.ts @@ -130,3 +130,34 @@ it('synchronizes right-click exits and reopens the saved line repeatedly', async } expect(h.track.getFeatureGeometry(0, { key: 'HeadTails' })[0].geometry.coordinates).toHaveLength(5); }); + +it('moves and commits only the annotation whose handle was grabbed when a peer layer is live', async () => { + const h = harness(); await h.reopen(); + const annotation = h.featureLayer.annotations()[0]; + const process = vi.fn(() => true); + annotation.diveDragGuard = false; annotation.processEditAction = process; + h.layer.guardPeerDrags(annotation); + const drag = { annotation: { ...annotation, layer: () => h.featureLayer }, action: 'actionup' }; + + annotation.processEditAction({}); h.layer.handleEditAction(drag as any); + expect(process).toHaveBeenCalledTimes(1); + expect(h.update).toHaveBeenCalledTimes(1); + + h.layer.peer = h.layer; h.layer.ownsDrag = false; + annotation.processEditAction({}); h.layer.handleEditAction(drag as any); + expect(process).toHaveBeenCalledTimes(1); + expect(h.update).toHaveBeenCalledTimes(1); + + h.layer.ownsDrag = true; + annotation.processEditAction({}); h.layer.handleEditAction(drag as any); + expect(process).toHaveBeenCalledTimes(2); + expect(h.update).toHaveBeenCalledTimes(2); +}); + +it('limits a companion box editor to its corner handles', () => { + const { layer } = harness(); + layer.companion = true; + expect(layer.editHandleStyle().handles).toEqual({ + vertex: true, edge: false, center: false, rotate: false, resize: false, + }); +}); diff --git a/client/src/layers/EditAnnotationLayer.ts b/client/src/layers/EditAnnotationLayer.ts index 6b11a9675..dbffda39c 100644 --- a/client/src/layers/EditAnnotationLayer.ts +++ b/client/src/layers/EditAnnotationLayer.ts @@ -18,6 +18,8 @@ import BaseLayer, { BaseLayerParams, LayerStyle } from './BaseLayer'; export type EditAnnotationTypes = 'Point' | 'rectangle' | 'Polygon' | 'LineString'; interface EditAnnotationLayerParams { type: EditAnnotationTypes; + /** Edits a detection's box corners alongside another edit layer. */ + companion?: boolean; } interface EditHandleStyle { @@ -98,6 +100,14 @@ export default class EditAnnotationLayer extends BaseLayer { unrotatedGeoJSONCoords: GeoJSON.Position[] | null; + companion: boolean; + + /* The other edit layer live on this map, when a line and its box are edited together */ + peer: EditAnnotationLayer | null; + + /* GeoJS sends every edit drag to every annotation layer in edit mode */ + ownsDrag: boolean; + constructor(params: BaseLayerParams & EditAnnotationLayerParams) { super(params); this.skipNextExternalUpdate = false; @@ -113,6 +123,9 @@ export default class EditAnnotationLayer extends BaseLayer { this.lastClickWasBackground = false; this.lastShiftKeyState = false; this.unrotatedGeoJSONCoords = null; + this.companion = !!params.companion; + this.peer = null; + this.ownsDrag = false; // Bind event handlers once (listeners are added/removed dynamically based on type) this.boundTrackShiftKey = this.trackShiftKey.bind(this); @@ -223,6 +236,8 @@ export default class EditAnnotationLayer extends BaseLayer { (e: GeoEvent) => this.hoverEditHandle(e), ); this.featureLayer.geoOn(geo.event.mouseclick, (e: GeoEvent) => { + // The peer layer reports clicks that leave edit mode. + if (this.companion) return; if (this.type === 'LineString' && e.handled) return; // Right-click in creation mode (non-Point): cancel and fully deselect. // Point mode has its own right-click handler (handleContextMenu). @@ -292,7 +307,10 @@ export default class EditAnnotationLayer extends BaseLayer { } this.disableModeSync = false; }); - this.featureLayer.geoOn(geo.event.actiondown, (e: GeoEvent) => this.setShapeInProgress(e)); + this.featureLayer.geoOn(geo.event.actiondown, (e: GeoEvent) => { + this.ownsDrag = !!this.featureLayer.currentAnnotation?._editHandle?.handle?.selected; + if (!this.companion && !this.peer?.handleSelected()) this.setShapeInProgress(e); + }); const arrowLayer = this.annotator.geoViewerRef.value.createLayer('feature', { features: ['line'] }); this.arrowFeatureLayer = arrowLayer.createFeature('line'); @@ -464,6 +482,10 @@ export default class EditAnnotationLayer extends BaseLayer { } hoverEditHandle(e: GeoEvent) { + // The map rebroadcasts this to every layer; only our own handles count. + if (e.annotation && e.annotation.layer() !== this.featureLayer) return; + // GeoJS strips the annotation actions right after this event fires. + if (this.companion && !e.enable) window.setTimeout(() => this.peer?.restoreCreationActions(), 0); const divisor = 2; // Vertex/edge handles alternate for polygons and open lines. if (e.enable && e.handle.handle.type === 'vertex') { if (e.handle.handle.selected @@ -523,6 +545,7 @@ export default class EditAnnotationLayer extends BaseLayer { this.type = type; // Add or remove Point mode listeners based on type change + if (this.companion) return; if (!wasPoint && isPoint) { this.addPointModeListeners(); } else if (wasPoint && !isPoint) { @@ -567,6 +590,7 @@ export default class EditAnnotationLayer extends BaseLayer { throw new Error(`No such mode ${mode}`); } this.featureLayer.mode(newLayerMode, geom); + if (geom) this.guardPeerDrags(geom); } else { this.featureLayer.mode(null); } @@ -664,6 +688,54 @@ export default class EditAnnotationLayer extends BaseLayer { return false; } + /** + * With two edit layers on one map GeoJS applies a handle drag to both + * annotations; only the one whose handle was grabbed may move. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + guardPeerDrags(annotation: any) { + if (!annotation || annotation.diveDragGuard) return; + const process = annotation.processEditAction; + // eslint-disable-next-line no-param-reassign + annotation.processEditAction = (evt: GeoEvent) => ( + this.peer && !this.ownsDrag ? undefined : process(evt)); + // eslint-disable-next-line no-param-reassign + annotation.diveDragGuard = true; + } + + /** + * A mode change on the peer layer strips every annotation action from the + * interactor, including the one for a handle still hovered on this layer. + */ + handleSelected(): boolean { + return !!this.featureLayer.currentAnnotation?._editHandle?.handle?.selected; + } + + /** + * Hovering a peer's handle strips this layer's creation actions along with + * every other annotation action; put them back so drawing can continue. + */ + restoreCreationActions() { + if (this.getMode() !== 'creation') return; + const annotation = this.featureLayer.currentAnnotation; + if (!annotation) return; + const interactor = this.annotator.geoViewerRef.value.interactor(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + annotation.actions('create').forEach((action: any) => { + if (!interactor.hasAction(action.action, action.name, action.owner)) { + interactor.addAction(action); + } + }); + } + + restoreHandleActions() { + if (this.getMode() !== 'editing') return; + const handle = this.featureLayer.currentAnnotation?._editHandle?.handle; + if (handle?.selected) { + this.featureLayer._selectEditHandle({ data: handle }, true); + } + } + /** * Removes the current annotation and resets the mode when completed editing */ @@ -681,8 +753,10 @@ export default class EditAnnotationLayer extends BaseLayer { this.hoverHandleIndex = -1; this.bus.$emit('update:selectedIndex', this.selectedHandleIndex, this.type, this.selectedKey); } - this.annotator.setCursor('default'); - this.annotator.setImageCursor(''); + if (!this.companion) { + this.annotator.setCursor('default'); + this.annotator.setImageCursor(''); + } } } @@ -774,7 +848,7 @@ export default class EditAnnotationLayer extends BaseLayer { clearTimeout(this.leftButtonCheckTimeout); this.skipNextExternalUpdate = false; } - this.calculateCursorImage(); + if (!this.companion) this.calculateCursorImage(); this.redraw(); } @@ -805,7 +879,7 @@ export default class EditAnnotationLayer extends BaseLayer { // TODO: this assumes only one polygon geoJSONData = this.getGeoJSONData(track); } - if (!geoJSONData || this.type === 'Point') { + if (!geoJSONData || (this.type === 'Point' && !this.companion)) { this.setMode(this.type); } else { const geojsonFeature: GeoJSON.Feature = { @@ -923,6 +997,7 @@ export default class EditAnnotationLayer extends BaseLayer { * @param e geo.event */ handleEditAction(e: GeoEvent) { + if (this.peer && !this.ownsDrag) return; if (this.featureLayer === e.annotation.layer()) { if (e.action === geo.event.actionup) { // This will commit the change to the current annotation on mouse up while editing @@ -932,7 +1007,9 @@ export default class EditAnnotationLayer extends BaseLayer { ); const newCoords = newGeojson.geometry.coordinates[0] as GeoJSON.Position[]; let rotationBetween: number; - if (this.formattedData.length > 0 && this.type === 'rectangle') { + if (this.type === 'Point') { + rotationBetween = 0; + } else if (this.formattedData.length > 0 && this.type === 'rectangle') { const existingRotation = getRotationFromAttributes(this.formattedData[0].properties as Record) ?? 0; const oldCoords = rotateGeoJSONCoordinates( this.unrotatedGeoJSONCoords || [], @@ -1084,6 +1161,13 @@ export default class EditAnnotationLayer extends BaseLayer { * Styling for the handles used to drag the annotation for ediing */ editHandleStyle() { + if (this.companion) { + return { + handles: { + vertex: true, edge: false, center: false, rotate: false, resize: false, + }, + }; + } if (this.type === 'rectangle') { return { handles: { diff --git a/docs/Annotation-QuickStart.md b/docs/Annotation-QuickStart.md index 4c76bf626..6b02c9e40 100644 --- a/docs/Annotation-QuickStart.md +++ b/docs/Annotation-QuickStart.md @@ -85,6 +85,7 @@ The demo below shows how to use AdvanceFrame mode to travel through the video wh 1. Or press ++t++ to create a tail point. 1. The mouse cursor will become a crosshair. Click in the annotator to place each point. 1. Once the first marker is placed it automatically transitions to the second marker. If you start with head, the second one will be the tail and vice versa. +1. A detection that has only one of the two points (for example a computed head with no tail) shows that point as a draggable handle in this mode, so it can be moved without redrawing; clicking elsewhere still places the missing point. ### Creating new annotations using Head/Tail points