Skip to content
Snippets Groups Projects
Unverified Commit 611a85b6 authored by aeschylus's avatar aeschylus Committed by GitHub
Browse files

Merge pull request #2132 from ProjectMirador/2015-annotation-display

Adds basic xywh annotations for single canvas viewing
parents 7b4e6119 a617b00e
Branches
Tags
No related merge requests found
......@@ -2,15 +2,20 @@ import React from 'react';
import { shallow } from 'enzyme';
import OpenSeadragon from 'openseadragon';
import { OpenSeadragonViewer } from '../../../src/components/OpenSeadragonViewer';
import OpenSeadragonCanvasOverlay from '../../../src/lib/OpenSeadragonCanvasOverlay';
import Annotation from '../../../src/lib/Annotation';
import ZoomControls from '../../../src/containers/ZoomControls';
jest.mock('openseadragon');
jest.mock('../../../src/lib/OpenSeadragonCanvasOverlay');
describe('OpenSeadragonViewer', () => {
let wrapper;
let updateViewport;
beforeEach(() => {
OpenSeadragon.mockClear();
OpenSeadragonCanvasOverlay.mockClear();
updateViewport = jest.fn();
......@@ -126,6 +131,16 @@ describe('OpenSeadragonViewer', () => {
0.5, { x: 1, y: 0, zoom: 0.5 }, false,
);
});
it('sets up a OpenSeadragonCanvasOverlay', () => {
wrapper.instance().componentDidMount();
expect(OpenSeadragonCanvasOverlay).toHaveBeenCalledTimes(1);
});
it('sets up a listener on update-viewport', () => {
wrapper.instance().componentDidMount();
expect(addHandler).toHaveBeenCalledWith('update-viewport', expect.anything());
});
});
describe('componentDidUpdate', () => {
......@@ -153,6 +168,49 @@ describe('OpenSeadragonViewer', () => {
0.5, { x: 1, y: 0, zoom: 0.5 }, false,
);
});
it('sets up canvasUpdate to add annotations to the canvas', () => {
const clear = jest.fn();
const resize = jest.fn();
const canvasUpdate = jest.fn();
wrapper.instance().osdCanvasOverlay = {
clear,
resize,
canvasUpdate,
};
wrapper.setProps(
{
annotations: [
new Annotation(
{ '@id': 'foo', resources: [{ foo: 'bar' }] },
),
],
},
);
wrapper.setProps(
{
annotations: [
new Annotation(
{ '@id': 'foo', resources: [{ foo: 'bar' }] },
),
],
},
);
wrapper.setProps(
{
annotations: [
new Annotation(
{ '@id': 'bar', resources: [{ foo: 'bar' }] },
),
],
},
);
wrapper.instance().updateCanvas();
expect(clear).toHaveBeenCalledTimes(1);
expect(resize).toHaveBeenCalledTimes(1);
expect(canvasUpdate).toHaveBeenCalledTimes(1);
});
});
describe('onViewportChange', () => {
......@@ -173,4 +231,35 @@ describe('OpenSeadragonViewer', () => {
);
});
});
describe('onUpdateViewport', () => {
it('fires updateCanvas', () => {
const updateCanvas = jest.fn();
wrapper.instance().updateCanvas = updateCanvas;
wrapper.instance().onUpdateViewport();
expect(updateCanvas).toHaveBeenCalledTimes(1);
});
});
describe('annotationsToContext', () => {
it('converts the annotations to canvas', () => {
const strokeRect = jest.fn();
wrapper.instance().osdCanvasOverlay = {
context2d: {
strokeRect,
},
};
const annotations = [
new Annotation(
{ '@id': 'foo', resources: [{ on: 'www.example.com/#xywh=10,10,100,200' }] },
),
];
wrapper.instance().annotationsToContext(annotations);
const context = wrapper.instance().osdCanvasOverlay.context2d;
expect(context.strokeStyle).toEqual('yellow');
expect(context.lineWidth).toEqual(10);
expect(strokeRect).toHaveBeenCalledWith(10, 10, 100, 200);
});
});
});
......@@ -53,11 +53,11 @@ describe('AnnotationResource', () => {
describe('fragmentSelector', () => {
it('simple string', () => {
expect(new AnnotationResource({ on: 'www.example.com/#xywh=10,10,100,200' })
.fragmentSelector).toEqual(['10', '10', '100', '200']);
.fragmentSelector).toEqual([10, 10, 100, 200]);
});
it('more complex selector', () => {
expect(new AnnotationResource({ on: { selector: { value: 'www.example.com/#xywh=10,10,100,200' } } })
.fragmentSelector).toEqual(['10', '10', '100', '200']);
.fragmentSelector).toEqual([10, 10, 100, 200]);
});
});
});
import OpenSeadragon from 'openseadragon';
import OpenSeadragonCanvasOverlay from '../../../src/lib/OpenSeadragonCanvasOverlay';
jest.mock('openseadragon');
describe('OpenSeadragonCanvasOverlay', () => {
let canvasOverlay;
beforeEach(() => {
document.body.innerHTML = '<div id="canvas"></div>';
OpenSeadragon.mockClear();
OpenSeadragon.mockImplementation(() => ({
canvas: document.getElementById('canvas'),
container: {
clientHeight: 100,
clientWidth: 200,
},
viewport: {
getBounds: jest.fn(() => ({
x: 40, y: 80, width: 200, height: 300,
})),
getZoom: jest.fn(() => (0.75)),
},
world: {
getItemAt: jest.fn(() => ({
source: {
dimensions: {
x: 1000,
y: 2000,
},
},
viewportToImageZoom: jest.fn(() => (0.075)),
})),
},
}));
canvasOverlay = new OpenSeadragonCanvasOverlay(new OpenSeadragon());
});
describe('constructor', () => {
it('sets up initial values and canvas', () => {
expect(canvasOverlay.containerHeight).toEqual(0);
expect(canvasOverlay.containerWidth).toEqual(0);
expect(canvasOverlay.canvasDiv.outerHTML).toEqual(
'<div style="position: absolute; left: 0px; top: 0px; width: 100%; height: 100%;"><canvas></canvas></div>',
);
});
});
describe('context2d', () => {
it('calls getContext on canvas', () => {
const contextMock = jest.fn();
canvasOverlay.canvas = {
getContext: contextMock,
};
canvasOverlay.context2d; // eslint-disable-line no-unused-expressions
expect(contextMock).toHaveBeenCalledTimes(1);
});
});
describe('clear', () => {
it('calls getContext and clearRect on canvas', () => {
const clearRect = jest.fn();
const contextMock = jest.fn(() => ({
clearRect,
}));
canvasOverlay.canvas = {
getContext: contextMock,
};
canvasOverlay.clear();
expect(contextMock).toHaveBeenCalledTimes(1);
expect(clearRect).toHaveBeenCalledTimes(1);
});
});
describe('resize', () => {
it('sets various values based off of image and container sizes', () => {
canvasOverlay.resize();
expect(canvasOverlay.containerHeight).toEqual(100);
expect(canvasOverlay.containerWidth).toEqual(200);
expect(canvasOverlay.imgAspectRatio).toEqual(0.5);
});
it('when image is undefined returns early', () => {
OpenSeadragon.mockClear();
OpenSeadragon.mockImplementation(() => ({
canvas: document.getElementById('canvas'),
container: {
clientHeight: 100,
clientWidth: 200,
},
viewport: {
getBounds: jest.fn(() => (new OpenSeadragon.Rect(0, 0, 200, 200))),
},
world: {
getItemAt: jest.fn(),
},
}));
canvasOverlay = new OpenSeadragonCanvasOverlay(new OpenSeadragon());
canvasOverlay.resize();
expect(canvasOverlay.imgHeight).toEqual(undefined);
expect(canvasOverlay.imgWidth).toEqual(undefined);
});
});
describe('canvasUpdate', () => {
it('sets appropriate sizes and calls update argument', () => {
const scale = jest.fn();
const setAttribute = jest.fn();
const setTransform = jest.fn();
const translate = jest.fn();
const contextMock = jest.fn(() => ({
scale,
setTransform,
translate,
}));
canvasOverlay.canvas = {
getContext: contextMock,
setAttribute,
};
const update = jest.fn();
canvasOverlay.resize();
canvasOverlay.canvasUpdate(update);
expect(update).toHaveBeenCalledTimes(1);
expect(scale).toHaveBeenCalledWith(0.075, 0.075);
expect(translate).toHaveBeenCalledWith(-39.96, -26.65333333333333);
expect(setTransform).toHaveBeenCalledWith(1, 0, 0, 1, 0, 0);
});
});
});
......@@ -4,6 +4,7 @@ import OpenSeadragon from 'openseadragon';
import debounce from 'lodash/debounce';
import ns from '../config/css-ns';
import ZoomControls from '../containers/ZoomControls';
import OpenSeadragonCanvasOverlay from '../lib/OpenSeadragonCanvasOverlay';
/**
* Represents a OpenSeadragonViewer in the mirador workspace. Responsible for mounting
......@@ -17,7 +18,11 @@ export class OpenSeadragonViewer extends Component {
super(props);
this.viewer = null;
this.osdCanvasOverlay = null;
// An initial value for the updateCanvas method
this.updateCanvas = () => {};
this.ref = React.createRef();
this.onUpdateViewport = this.onUpdateViewport.bind(this);
this.onViewportChange = this.onViewportChange.bind(this);
}
......@@ -37,6 +42,9 @@ export class OpenSeadragonViewer extends Component {
showNavigationControl: false,
preserveImageSizeOnResize: true,
});
this.osdCanvasOverlay = new OpenSeadragonCanvasOverlay(this.viewer);
this.viewer.addHandler('update-viewport', this.onUpdateViewport);
this.viewer.addHandler('viewport-change', debounce(this.onViewportChange, 300));
if (viewer) {
......@@ -49,10 +57,21 @@ export class OpenSeadragonViewer extends Component {
/**
* When the tileSources change, make sure to close the OSD viewer.
* When the annotations change, reset the updateCanvas method to make sure
* they are added.
* When the viewport state changes, pan or zoom the OSD viewer as appropriate
*/
componentDidUpdate(prevProps) {
const { tileSources, viewer } = this.props;
const { tileSources, viewer, annotations } = this.props;
if (!this.annotationsMatch(prevProps.annotations)) {
this.updateCanvas = () => {
this.osdCanvasOverlay.clear();
this.osdCanvasOverlay.resize();
this.osdCanvasOverlay.canvasUpdate(() => {
this.annotationsToContext(annotations);
});
};
}
if (!this.tileSourcesMatch(prevProps.tileSources)) {
this.viewer.close();
Promise.all(
......@@ -82,6 +101,13 @@ export class OpenSeadragonViewer extends Component {
this.viewer.removeAllHandlers();
}
/**
* onUpdateViewport - fires during OpenSeadragon render method.
*/
onUpdateViewport(event) {
this.updateCanvas();
}
/**
* Forward OSD state to redux
*/
......@@ -97,6 +123,20 @@ export class OpenSeadragonViewer extends Component {
});
}
/**
* annotationsToContext - converts anontations to a canvas context
*/
annotationsToContext(annotations) {
const context = this.osdCanvasOverlay.context2d;
annotations.forEach((annotation) => {
annotation.resources.forEach((resource) => {
context.strokeStyle = 'yellow';
context.lineWidth = 10;
context.strokeRect(...resource.fragmentSelector);
});
});
}
/**
* boundsFromTileSources - calculates the overall width/height
* based on 0 -> n tileSources
......@@ -196,6 +236,25 @@ export class OpenSeadragonViewer extends Component {
});
}
/**
* annotationsMatch - compares previous annotations to current to determine
* whether to add a new updateCanvas method to draw annotations
* @param {Array} prevAnnotations
* @return {Boolean}
*/
annotationsMatch(prevAnnotations) {
const { annotations } = this.props;
return annotations.some((annotation, index) => {
if (!prevAnnotations[index]) {
return false;
}
if (annotation.id === prevAnnotations[index].id) {
return true;
}
return false;
});
}
/**
* Renders things
*/
......@@ -221,6 +280,7 @@ export class OpenSeadragonViewer extends Component {
}
OpenSeadragonViewer.defaultProps = {
annotations: [],
children: null,
tileSources: [],
viewer: null,
......@@ -228,6 +288,7 @@ OpenSeadragonViewer.defaultProps = {
};
OpenSeadragonViewer.propTypes = {
annotations: PropTypes.arrayOf(PropTypes.object),
children: PropTypes.element,
tileSources: PropTypes.arrayOf(PropTypes.object),
viewer: PropTypes.object, // eslint-disable-line react/forbid-prop-types
......
......@@ -126,6 +126,7 @@ export class WindowViewer extends Component {
<>
<OSDViewer
tileSources={this.tileInfoFetchedFromStore()}
currentCanvases={this.currentCanvases()}
windowId={window.id}
>
<ViewerNavigation window={window} canvases={this.canvases} />
......
......@@ -7,6 +7,7 @@ import * as actions from '../state/actions';
import {
getCanvasLabel,
getSelectedCanvas,
getSelectedCanvasAnnotations,
} from '../state/selectors';
/**
......@@ -14,12 +15,18 @@ import {
* @memberof Window
* @private
*/
const mapStateToProps = ({ viewers, windows, manifests }, { windowId }) => ({
const mapStateToProps = ({
viewers, windows, manifests, annotations,
}, { windowId, currentCanvases }) => ({
viewer: viewers[windowId],
label: getCanvasLabel(
getSelectedCanvas({ windows, manifests }, windowId),
windows[windowId].canvasIndex,
),
annotations: getSelectedCanvasAnnotations(
{ annotations },
currentCanvases.map(canvas => canvas.id),
),
});
/**
......
......@@ -37,9 +37,9 @@ export default class AnnotationResource {
const { on } = this.resource;
switch (typeof on) {
case 'string':
return on.match(/xywh=(.*)$/)[1].split(',');
return on.match(/xywh=(.*)$/)[1].split(',').map(str => parseInt(str, 10));
case 'object':
return on.selector.value.match(/xywh=(.*)$/)[1].split(',');
return on.selector.value.match(/xywh=(.*)$/)[1].split(',').map(str => parseInt(str, 10));
default:
return null;
}
......
import OpenSeadragon from 'openseadragon';
/**
* OpenSeadragonCanvasOverlay - adapted from https://github.com/altert/OpenSeadragonCanvasOverlay
* used rather than an "onRedraw" function we tap into our own method. Existing
* repository is not published as an npm package.
* Code ported from https://github.com/altert/OpenSeadragonCanvasOverlay
* carries a BSD 3-Clause license originally authored by @altert from
* https://github.com/altert/OpenseadragonFabricjsOverlay
*/
export default class OpenSeadragonCanvasOverlay {
/**
* constructor - sets up the Canvas overlay container
*/
constructor(viewer) {
this.viewer = viewer;
this.containerWidth = 0;
this.containerHeight = 0;
this.canvasDiv = document.createElement('div');
this.canvasDiv.style.position = 'absolute';
this.canvasDiv.style.left = 0;
this.canvasDiv.style.top = 0;
this.canvasDiv.style.width = '100%';
this.canvasDiv.style.height = '100%';
this.viewer.canvas.appendChild(this.canvasDiv);
this.canvas = document.createElement('canvas');
this.canvasDiv.appendChild(this.canvas);
this.imgAspectRatio = 1;
}
/** */
get context2d() {
return this.canvas.getContext('2d');
}
/** */
clear() {
this.canvas.getContext('2d').clearRect(0, 0, this.containerWidth, this.containerHeight);
}
/**
* resize - resizes the added Canvas overlay.
*/
resize() {
if (this.containerWidth !== this.viewer.container.clientWidth) {
this.containerWidth = this.viewer.container.clientWidth;
this.canvasDiv.setAttribute('width', this.containerWidth);
this.canvas.setAttribute('width', this.containerWidth);
}
if (this.containerHeight !== this.viewer.container.clientHeight) {
this.containerHeight = this.viewer.container.clientHeight;
this.canvasDiv.setAttribute('height', this.containerHeight);
this.canvas.setAttribute('height', this.containerHeight);
}
this.viewportOrigin = new OpenSeadragon.Point(0, 0);
const boundsRect = this.viewer.viewport.getBounds(true);
this.viewportOrigin.x = boundsRect.x;
this.viewportOrigin.y = boundsRect.y * this.imgAspectRatio;
this.viewportWidth = boundsRect.width;
this.viewportHeight = boundsRect.height * this.imgAspectRatio;
const image1 = this.viewer.world.getItemAt(0);
if (!image1) return;
this.imgWidth = image1.source.dimensions.x;
this.imgHeight = image1.source.dimensions.y;
this.imgAspectRatio = this.imgWidth / this.imgHeight;
}
/**
* canvasUpdate - sets up the dimensions for the canvas update to mimick image
* 0 dimensions. Then call provided update function.
* @param {Function} update
*/
canvasUpdate(update) {
const viewportZoom = this.viewer.viewport.getZoom(true);
const image1 = this.viewer.world.getItemAt(0);
if (!image1) return;
const zoom = image1.viewportToImageZoom(viewportZoom);
const x = (
(this.viewportOrigin.x / this.imgWidth - this.viewportOrigin.x) / this.viewportWidth
) * this.containerWidth;
const y = (
(this.viewportOrigin.y / this.imgHeight - this.viewportOrigin.y) / this.viewportHeight
) * this.containerHeight;
if (this.clearBeforeRedraw) this.clear();
this.canvas.getContext('2d').translate(x, y);
this.canvas.getContext('2d').scale(zoom, zoom);
update();
this.canvas.getContext('2d').setTransform(1, 0, 0, 1, 0, 0);
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment