From 5f781582c1daa753871ab314eb7a45d10073ac5d Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Thu, 17 Sep 2026 19:31:26 -0400 Subject: [PATCH 1/4] Keep a detection's box corners editable while editing its line --- client/src/components/LayerManager.spec.ts | 2 + client/src/components/LayerManager.vue | 30 +++++++ .../layerManager/lineBoxCompanion.spec.ts | 35 ++++++++ .../layerManager/lineBoxCompanion.ts | 20 +++++ .../useAnnotationClickHandling.ts | 21 ++++- .../layerManager/useLayerRefresh.ts | 2 + .../EditAnnotationLayer.companion.spec.ts | 79 +++++++++++++++++++ client/src/layers/EditAnnotationLayer.spec.ts | 31 ++++++++ client/src/layers/EditAnnotationLayer.ts | 64 ++++++++++++++- 9 files changed, 278 insertions(+), 6 deletions(-) create mode 100644 client/src/components/layerManager/lineBoxCompanion.spec.ts create mode 100644 client/src/components/layerManager/lineBoxCompanion.ts create mode 100644 client/src/layers/EditAnnotationLayer.companion.spec.ts 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..e634e3d78 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 lineBoxCompanionTracks from './layerManager/lineBoxCompanion'; /** 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,23 @@ export default defineComponent({ } else { editAnnotationLayer.disable(); } + + const boxTracks = selectedTrackId === null ? [] : lineBoxCompanionTracks( + editingTrack, + visibleModes.includes('rectangle'), + selectedKey, + editingTracks, + ); + if (boxTracks.length) { + boxEditLayer.changeData(boxTracks.map((trackFrame) => ({ + ...trackFrame, + features: featureToDisplay(trackFrame.features), + }))); + } else { + boxEditLayer.disable(); + } + editAnnotationLayer.restoreHandleActions(); + boxEditLayer.restoreHandleActions(); } const { refreshLayers } = useLayerRefresh({ @@ -636,6 +664,7 @@ export default defineComponent({ attributeLayer, attributeBoxLayer, editAnnotationLayer, + boxEditLayer, segmentationPointsLayer, uiLayer, }, @@ -731,6 +760,7 @@ export default defineComponent({ trackStore, alignedView: alignedViewHelpers, editAnnotationLayer, + boxEditLayer, rectAnnotationLayer, polyAnnotationLayer, lineLayer, diff --git a/client/src/components/layerManager/lineBoxCompanion.spec.ts b/client/src/components/layerManager/lineBoxCompanion.spec.ts new file mode 100644 index 000000000..66fda95f7 --- /dev/null +++ b/client/src/components/layerManager/lineBoxCompanion.spec.ts @@ -0,0 +1,35 @@ +import lineBoxCompanionTracks from './lineBoxCompanion'; +import type { FrameDataTrack } from '../../layers/LayerTypes'; + +function trackFrame(options: { bounds?: boolean; line?: number; key?: string } = {}): FrameDataTrack { + const { bounds = true, line = 2, key = 'HeadTails' } = 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]) }, + }] : [], + }, + }, + } as unknown as FrameDataTrack; +} + +it('offers the box only while an existing line is edited with boxes visible', () => { + const tracks = [trackFrame()]; + expect(lineBoxCompanionTracks('LineString', true, 'HeadTails', tracks)).toEqual(tracks); + expect(lineBoxCompanionTracks('LineString', false, 'HeadTails', tracks)).toEqual([]); + expect(lineBoxCompanionTracks('rectangle', true, 'HeadTails', tracks)).toEqual([]); + expect(lineBoxCompanionTracks(false, true, 'HeadTails', tracks)).toEqual([]); +}); + +it('waits for the line to exist so box handles cannot interrupt drawing it', () => { + expect(lineBoxCompanionTracks('LineString', true, 'HeadTails', [trackFrame({ line: 0 })])).toEqual([]); + expect(lineBoxCompanionTracks('LineString', true, 'HeadTails', [trackFrame({ line: 1 })])).toEqual([]); + expect(lineBoxCompanionTracks('LineString', true, '', [trackFrame()])).toEqual([]); + expect(lineBoxCompanionTracks('LineString', true, 'HeadTails', [trackFrame({ bounds: false })])).toEqual([]); +}); diff --git a/client/src/components/layerManager/lineBoxCompanion.ts b/client/src/components/layerManager/lineBoxCompanion.ts new file mode 100644 index 000000000..6cbd7afee --- /dev/null +++ b/client/src/components/layerManager/lineBoxCompanion.ts @@ -0,0 +1,20 @@ +import type { FrameDataTrack } from '../../layers/LayerTypes'; +import type { EditAnnotationTypes } from '../../layers/EditAnnotationLayer'; + +/** + * Tracks whose box corners stay editable while their line is being edited. + * A line still being drawn is excluded: hovering a box handle would strip the + * line's creation actions from the map. + */ +export default function lineBoxCompanionTracks( + editingTrack: false | EditAnnotationTypes, + rectanglesVisible: boolean, + selectedKey: string, + editingTracks: FrameDataTrack[], +): FrameDataTrack[] { + if (editingTrack !== 'LineString' || !rectanglesVisible) return []; + return editingTracks.filter(({ features }) => !!features?.bounds + && !!features.geometry?.features.some((feature) => feature.geometry.type === 'LineString' + && (feature.properties?.key ?? '') === selectedKey + && feature.geometry.coordinates.length >= 2)); +} diff --git a/client/src/components/layerManager/useAnnotationClickHandling.ts b/client/src/components/layerManager/useAnnotationClickHandling.ts index f05b29177..f6c28af09 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,22 @@ 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(); + // eslint-disable-next-line no-param-reassign + 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..631574bd7 --- /dev/null +++ b/client/src/layers/EditAnnotationLayer.companion.spec.ts @@ -0,0 +1,79 @@ +// @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 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 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, + }; +} + +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]); +}); 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..34d29f414 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.setShapeInProgress(e); + }); const arrowLayer = this.annotator.geoViewerRef.value.createLayer('feature', { features: ['line'] }); this.arrowFeatureLayer = arrowLayer.createFeature('line'); @@ -567,6 +585,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 +683,33 @@ 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. + */ + 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 +727,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 +822,7 @@ export default class EditAnnotationLayer extends BaseLayer { clearTimeout(this.leftButtonCheckTimeout); this.skipNextExternalUpdate = false; } - this.calculateCursorImage(); + if (!this.companion) this.calculateCursorImage(); this.redraw(); } @@ -923,6 +971,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 @@ -1084,6 +1133,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: { From f9272d4c16d8298cbeab3072cb7cb19cc991a0b5 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Thu, 17 Sep 2026 20:04:52 -0400 Subject: [PATCH 2/4] Ignore the peer layer's handle hover events so line vertices keep the hand cursor --- .../layers/EditAnnotationLayer.companion.spec.ts | 16 ++++++++++++++-- client/src/layers/EditAnnotationLayer.ts | 2 ++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/client/src/layers/EditAnnotationLayer.companion.spec.ts b/client/src/layers/EditAnnotationLayer.companion.spec.ts index 631574bd7..2ab0294de 100644 --- a/client/src/layers/EditAnnotationLayer.companion.spec.ts +++ b/client/src/layers/EditAnnotationLayer.companion.spec.ts @@ -15,8 +15,9 @@ async function harness() { 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: vi.fn(), setImageCursor: vi.fn() }, + 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; @@ -39,7 +40,7 @@ async function harness() { 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, + mouse, drag, lineUpdate, boxUpdate, lineCoords, boxCoords, line, box, frameData, cursors, }; } @@ -77,3 +78,14 @@ it('keeps a hovered box corner draggable after the line layer leaves and re-ente 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'); +}); diff --git a/client/src/layers/EditAnnotationLayer.ts b/client/src/layers/EditAnnotationLayer.ts index 34d29f414..f69757e14 100644 --- a/client/src/layers/EditAnnotationLayer.ts +++ b/client/src/layers/EditAnnotationLayer.ts @@ -482,6 +482,8 @@ 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; 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 From 10411f8c006e795b4bc2ebd047641d10006a446f Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Thu, 17 Sep 2026 21:21:46 -0400 Subject: [PATCH 3/4] Let a lone head or tail point be dragged in line mode instead of only extended --- client/dive-common/recipes/headtail.ts | 11 ++++ client/src/components/LayerManager.vue | 10 ++-- .../layerManager/lineBoxCompanion.spec.ts | 35 ------------- .../layerManager/lineBoxCompanion.ts | 20 -------- .../layerManager/lineCompanion.spec.ts | 50 +++++++++++++++++++ .../components/layerManager/lineCompanion.ts | 47 +++++++++++++++++ .../EditAnnotationLayer.companion.spec.ts | 39 +++++++++++++++ client/src/layers/EditAnnotationLayer.ts | 32 ++++++++++-- docs/Annotation-QuickStart.md | 1 + 9 files changed, 183 insertions(+), 62 deletions(-) delete mode 100644 client/src/components/layerManager/lineBoxCompanion.spec.ts delete mode 100644 client/src/components/layerManager/lineBoxCompanion.ts create mode 100644 client/src/components/layerManager/lineCompanion.spec.ts create mode 100644 client/src/components/layerManager/lineCompanion.ts 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.vue b/client/src/components/LayerManager.vue index e634e3d78..e1390f3a0 100644 --- a/client/src/components/LayerManager.vue +++ b/client/src/components/LayerManager.vue @@ -56,7 +56,7 @@ import useLayerRefresh from './layerManager/useLayerRefresh'; import useSegmentationPointsLayer from './layerManager/useSegmentationPointsLayer'; import useAnnotationClickHandling from './layerManager/useAnnotationClickHandling'; import { cameraAwaitingGeometry, isCreatingNewDetection } from './layerManager/multicamCreation'; -import lineBoxCompanionTracks from './layerManager/lineBoxCompanion'; +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 @@ -625,14 +625,16 @@ export default defineComponent({ editAnnotationLayer.disable(); } - const boxTracks = selectedTrackId === null ? [] : lineBoxCompanionTracks( + const companion = selectedTrackId === null ? null : lineCompanion( editingTrack, visibleModes.includes('rectangle'), selectedKey, editingTracks, ); - if (boxTracks.length) { - boxEditLayer.changeData(boxTracks.map((trackFrame) => ({ + if (companion) { + boxEditLayer.setType(companion.type); + boxEditLayer.setKey(companion.key); + boxEditLayer.changeData(companion.tracks.map((trackFrame) => ({ ...trackFrame, features: featureToDisplay(trackFrame.features), }))); diff --git a/client/src/components/layerManager/lineBoxCompanion.spec.ts b/client/src/components/layerManager/lineBoxCompanion.spec.ts deleted file mode 100644 index 66fda95f7..000000000 --- a/client/src/components/layerManager/lineBoxCompanion.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import lineBoxCompanionTracks from './lineBoxCompanion'; -import type { FrameDataTrack } from '../../layers/LayerTypes'; - -function trackFrame(options: { bounds?: boolean; line?: number; key?: string } = {}): FrameDataTrack { - const { bounds = true, line = 2, key = 'HeadTails' } = 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]) }, - }] : [], - }, - }, - } as unknown as FrameDataTrack; -} - -it('offers the box only while an existing line is edited with boxes visible', () => { - const tracks = [trackFrame()]; - expect(lineBoxCompanionTracks('LineString', true, 'HeadTails', tracks)).toEqual(tracks); - expect(lineBoxCompanionTracks('LineString', false, 'HeadTails', tracks)).toEqual([]); - expect(lineBoxCompanionTracks('rectangle', true, 'HeadTails', tracks)).toEqual([]); - expect(lineBoxCompanionTracks(false, true, 'HeadTails', tracks)).toEqual([]); -}); - -it('waits for the line to exist so box handles cannot interrupt drawing it', () => { - expect(lineBoxCompanionTracks('LineString', true, 'HeadTails', [trackFrame({ line: 0 })])).toEqual([]); - expect(lineBoxCompanionTracks('LineString', true, 'HeadTails', [trackFrame({ line: 1 })])).toEqual([]); - expect(lineBoxCompanionTracks('LineString', true, '', [trackFrame()])).toEqual([]); - expect(lineBoxCompanionTracks('LineString', true, 'HeadTails', [trackFrame({ bounds: false })])).toEqual([]); -}); diff --git a/client/src/components/layerManager/lineBoxCompanion.ts b/client/src/components/layerManager/lineBoxCompanion.ts deleted file mode 100644 index 6cbd7afee..000000000 --- a/client/src/components/layerManager/lineBoxCompanion.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { FrameDataTrack } from '../../layers/LayerTypes'; -import type { EditAnnotationTypes } from '../../layers/EditAnnotationLayer'; - -/** - * Tracks whose box corners stay editable while their line is being edited. - * A line still being drawn is excluded: hovering a box handle would strip the - * line's creation actions from the map. - */ -export default function lineBoxCompanionTracks( - editingTrack: false | EditAnnotationTypes, - rectanglesVisible: boolean, - selectedKey: string, - editingTracks: FrameDataTrack[], -): FrameDataTrack[] { - if (editingTrack !== 'LineString' || !rectanglesVisible) return []; - return editingTracks.filter(({ features }) => !!features?.bounds - && !!features.geometry?.features.some((feature) => feature.geometry.type === 'LineString' - && (feature.properties?.key ?? '') === selectedKey - && feature.geometry.coordinates.length >= 2)); -} 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/layers/EditAnnotationLayer.companion.spec.ts b/client/src/layers/EditAnnotationLayer.companion.spec.ts index 2ab0294de..dbab3123c 100644 --- a/client/src/layers/EditAnnotationLayer.companion.spec.ts +++ b/client/src/layers/EditAnnotationLayer.companion.spec.ts @@ -89,3 +89,42 @@ it('shows the hand over a line vertex and a resize cursor only over a box corner 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.ts b/client/src/layers/EditAnnotationLayer.ts index f69757e14..dbffda39c 100644 --- a/client/src/layers/EditAnnotationLayer.ts +++ b/client/src/layers/EditAnnotationLayer.ts @@ -309,7 +309,7 @@ export default class EditAnnotationLayer extends BaseLayer { }); this.featureLayer.geoOn(geo.event.actiondown, (e: GeoEvent) => { this.ownsDrag = !!this.featureLayer.currentAnnotation?._editHandle?.handle?.selected; - if (!this.companion) this.setShapeInProgress(e); + if (!this.companion && !this.peer?.handleSelected()) this.setShapeInProgress(e); }); const arrowLayer = this.annotator.geoViewerRef.value.createLayer('feature', { features: ['line'] }); @@ -484,6 +484,8 @@ 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 @@ -543,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) { @@ -704,6 +707,27 @@ export default class EditAnnotationLayer extends BaseLayer { * 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; @@ -855,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 = { @@ -983,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 || [], 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 From f04961d944d607dc46905c9b317d8b21fb207ab5 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Thu, 17 Sep 2026 21:26:15 -0400 Subject: [PATCH 4/4] Drop an unused eslint directive --- client/src/components/layerManager/useAnnotationClickHandling.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/components/layerManager/useAnnotationClickHandling.ts b/client/src/components/layerManager/useAnnotationClickHandling.ts index f6c28af09..d16af97f1 100644 --- a/client/src/components/layerManager/useAnnotationClickHandling.ts +++ b/client/src/components/layerManager/useAnnotationClickHandling.ts @@ -270,7 +270,6 @@ export default function useAnnotationClickHandling(options: { cb: () => void = () => (undefined), ) => updateGeoJSON(mode, geometryCompleteEvent, data, type, key, () => { cb(); - // eslint-disable-next-line no-param-reassign editAnnotationLayer.skipNextExternalUpdate = true; }));