Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions client/dive-common/recipes/headtail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,17 @@ export default class HeadTail implements Recipe {
data: GeoJSON.Feature<GeoJSON.LineString | GeoJSON.Polygon | GeoJSON.Point>[],
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<GeoJSON.Point> | 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<GeoJSON.LineString>;
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/LayerManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@

getMode = vi.fn(() => 'disabled');

restoreHandleActions = vi.fn();

clear = vi.fn();

updatePoints = vi.fn();
Expand Down Expand Up @@ -139,7 +141,7 @@
* semantics for its own children.
*/
function mountLayerManager(props: Record<string, unknown> = {}) {
const Host = defineComponent({

Check warning on line 144 in client/src/components/LayerManager.spec.ts

View workflow job for this annotation

GitHub Actions / Client Tests (web)

There is more than one component in this file
setup: () => () => h(LayerManager, { props }),
});
return shallowMount(Host, { stubs: { LayerManager: false } });
Expand Down Expand Up @@ -365,7 +367,7 @@
// mounting it during setup detaches subsequent watches from LayerManager.
const widgets: Vue[] = [];
layerMocks.mountWidget.mockImplementation(() => {
const Tooltip = defineComponent({ setup: () => () => h('span') });

Check warning on line 370 in client/src/components/LayerManager.spec.ts

View workflow job for this annotation

GitHub Actions / Client Tests (web)

There is more than one component in this file
widgets.push(new Vue({ render: (createElement) => createElement(Tooltip) }).$mount());
});
const { cameraStore, trackFilters } = makeMultiCamFixture([['fish', 1]], [['fish', 1]], {});
Expand Down
32 changes: 32 additions & 0 deletions client/src/components/LayerManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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({
Expand All @@ -636,6 +666,7 @@ export default defineComponent({
attributeLayer,
attributeBoxLayer,
editAnnotationLayer,
boxEditLayer,
segmentationPointsLayer,
uiLayer,
},
Expand Down Expand Up @@ -731,6 +762,7 @@ export default defineComponent({
trackStore,
alignedView: alignedViewHelpers,
editAnnotationLayer,
boxEditLayer,
rectAnnotationLayer,
polyAnnotationLayer,
lineLayer,
Expand Down
50 changes: 50 additions & 0 deletions client/src/components/layerManager/lineCompanion.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
47 changes: 47 additions & 0 deletions client/src/components/layerManager/lineCompanion.ts
Original file line number Diff line number Diff line change
@@ -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) };
}
20 changes: 18 additions & 2 deletions client/src/components/layerManager/useAnnotationClickHandling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default function useAnnotationClickHandling(options: {
'alignedDisplayInverse' | 'mapNativePoint' | 'mapEditGeoJSONToNative'
>;
editAnnotationLayer: EditAnnotationLayer;
boxEditLayer?: EditAnnotationLayer;
rectAnnotationLayer: RectangleLayer;
polyAnnotationLayer: PolygonLayer;
lineLayer: LineLayer;
Expand All @@ -47,6 +48,7 @@ export default function useAnnotationClickHandling(options: {
trackStore,
alignedView,
editAnnotationLayer,
boxEditLayer,
rectAnnotationLayer,
polyAnnotationLayer,
lineLayer,
Expand Down Expand Up @@ -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<GeoJSON.Polygon | GeoJSON.LineString | GeoJSON.Point>,
Expand Down Expand Up @@ -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<GeoJSON.Polygon>,
type: string,
key = '',
cb: () => void = () => (undefined),
) => updateGeoJSON(mode, geometryCompleteEvent, data, type, key, () => {
cb();
editAnnotationLayer.skipNextExternalUpdate = true;
}));

editAnnotationLayer.bus.$on(
'update:selectedIndex',
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/layerManager/useLayerRefresh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface LayerRefreshContext {
attributeLayer: DisableableLayer;
attributeBoxLayer: DisableableLayer;
editAnnotationLayer: DisableableLayer;
boxEditLayer?: DisableableLayer;
segmentationPointsLayer: SegmentationPointsLayer;
uiLayer: UILayer;
};
Expand Down Expand Up @@ -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);
Expand Down
130 changes: 130 additions & 0 deletions client/src/layers/EditAnnotationLayer.companion.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
Loading
Loading