mirador
Version:
An open-source, web-based 'multi-up' viewer that supports zoom-pan-rotate functionality, ability to display/compare simple images, and images with annotations.
270 lines (228 loc) • 9.86 kB
JSX
import {
useRef, useEffect, useCallback, useMemo,
} from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import { useDebouncedCallback } from 'use-debounce';
import flatten from 'lodash/flatten';
import sortBy from 'lodash/sortBy';
import xor from 'lodash/xor';
import OpenSeadragonCanvasOverlay from '../lib/OpenSeadragonCanvasOverlay';
import CanvasWorld from '../lib/CanvasWorld';
import CanvasAnnotationDisplay from '../lib/CanvasAnnotationDisplay';
/** @private */
function isAnnotationAtPoint(canvasWorld, osdCanvasOverlay, resource, canvas, point) {
const [canvasX, canvasY] = canvasWorld.canvasToWorldCoordinates(canvas.id);
const relativeX = point.x - canvasX;
const relativeY = point.y - canvasY;
if (resource.svgSelector) {
const context = osdCanvasOverlay.context2d;
const { svgPaths } = new CanvasAnnotationDisplay({ resource });
return [...svgPaths].some(path => (
context.isPointInPath(new Path2D(path.attributes.d.nodeValue), relativeX, relativeY)
));
}
if (resource.fragmentSelector) {
const [x, y, w, h] = resource.fragmentSelector;
return x <= relativeX && relativeX <= (x + w)
&& y <= relativeY && relativeY <= (y + h);
}
return false;
}
/**
* Represents a OpenSeadragonViewer in the mirador workspace. Responsible for mounting
* and rendering OSD.
*/
export function AnnotationsOverlay({
annotations = [], canvasWorld, deselectAnnotation = () => {}, drawAnnotations = true, drawSearchAnnotations = true,
highlightAllAnnotations = false, hoverAnnotation = () => {}, hoveredAnnotationIds = [],
palette = {}, searchAnnotations = [], selectAnnotation = () => {}, selectedAnnotationId = null,
viewer = null, windowId,
}) {
const ref = useRef();
const osdCanvasOverlay = useMemo(() => new OpenSeadragonCanvasOverlay(viewer, ref), [ref, viewer]);
const toggleAnnotation = useCallback((id) => {
if (selectedAnnotationId === id) {
deselectAnnotation(windowId, id);
} else {
selectAnnotation(windowId, id);
}
}, [selectedAnnotationId, deselectAnnotation, selectAnnotation, windowId]);
/**
* annotationsToContext - converts anontations to a canvas context
*/
const annotationsToContext = useCallback((renderedAnnotations, currentPalette) => {
const context = osdCanvasOverlay.context2d;
const zoomRatio = viewer.viewport.getZoom(true) / viewer.viewport.getMaxZoom();
renderedAnnotations.forEach((annotation) => {
annotation.resources.forEach((resource) => {
if (!canvasWorld.canvasIds.includes(resource.targetId)) return;
const offset = canvasWorld.offsetByCanvas(resource.targetId);
const canvasAnnotationDisplay = new CanvasAnnotationDisplay({
hovered: hoveredAnnotationIds.includes(resource.id),
offset,
palette: {
...currentPalette,
default: {
...currentPalette.default,
...(!highlightAllAnnotations && currentPalette.hidden),
},
},
resource,
selected: selectedAnnotationId === resource.id,
zoomRatio,
});
canvasAnnotationDisplay.toContext(context);
});
});
}, [osdCanvasOverlay, viewer, canvasWorld, highlightAllAnnotations, hoveredAnnotationIds, selectedAnnotationId]);
const renderAnnotations = useCallback(() => {
if (drawSearchAnnotations) {
annotationsToContext(searchAnnotations, palette.search);
}
if (drawAnnotations) {
annotationsToContext(annotations, palette.annotations);
}
}, [annotations, annotationsToContext, drawAnnotations, drawSearchAnnotations, palette, searchAnnotations]);
const updateCanvas = useCallback(() => {
if (!osdCanvasOverlay) return;
osdCanvasOverlay.clear();
osdCanvasOverlay.resize();
osdCanvasOverlay.canvasUpdate(renderAnnotations);
}, [osdCanvasOverlay, renderAnnotations]);
const annotationsAtPoint = useCallback((canvas, point) => {
const lists = [...annotations, ...searchAnnotations];
const annos = flatten(lists.map(l => l.resources)).filter((resource) => {
if (canvas.id !== resource.targetId) return false;
return isAnnotationAtPoint(canvasWorld, osdCanvasOverlay, resource, canvas, point);
});
return annos;
}, [annotations, canvasWorld, osdCanvasOverlay, searchAnnotations]);
const onCanvasClick = useCallback((event) => {
const { position: webPosition, eventSource: { viewport } } = event;
const point = viewport.pointFromPixel(webPosition);
const canvas = canvasWorld.canvasAtPoint(point);
if (!canvas) return;
const [
_canvasX, _canvasY, canvasWidth, canvasHeight, // eslint-disable-line no-unused-vars
] = canvasWorld.canvasToWorldCoordinates(canvas.id);
// get all the annotations that contain the click
const annos = annotationsAtPoint(canvas, point);
if (annos.length > 0) {
event.preventDefaultAction = true; // eslint-disable-line no-param-reassign
}
if (annos.length === 1) {
toggleAnnotation(annos[0].id);
} else if (annos.length > 0) {
/**
* Try to find the "right" annotation to select after a click.
*
* This is perhaps a naive method, but seems to deal with rectangles and SVG shapes:
*
* - figure out how many points around a circle are inside the annotation shape
* - if there's a shape with the fewest interior points, it's probably the one
* with the closest boundary?
* - if there's a tie, make the circle bigger and try again.
*/
const annosWithClickScore = (radius) => {
const degreesToRadians = Math.PI / 180;
return (anno) => {
let score = 0;
for (let degrees = 0; degrees < 360; degrees += 1) {
const x = Math.cos(degrees * degreesToRadians) * radius + point.x;
const y = Math.sin(degrees * degreesToRadians) * radius + point.y;
if (isAnnotationAtPoint(canvasWorld, osdCanvasOverlay, anno, canvas, { x, y })) score += 1;
}
return { anno, score };
};
};
let annosWithScore = [];
let radius = 1;
annosWithScore = sortBy(annos.map(annosWithClickScore(radius)), 'score');
while (radius < Math.max(canvasWidth, canvasHeight)
&& annosWithScore[0].score === annosWithScore[1].score) {
radius *= 2;
annosWithScore = sortBy(annos.map(annosWithClickScore(radius)), 'score');
}
toggleAnnotation(annosWithScore[0].anno.id);
}
}, [annotationsAtPoint, canvasWorld, osdCanvasOverlay, toggleAnnotation]);
const onCanvasMouseMove = useDebouncedCallback(useCallback((event) => {
if (annotations.length === 0 && searchAnnotations.length === 0) return;
const { position: webPosition } = event;
const point = viewer.viewport.pointFromPixel(webPosition);
const canvas = canvasWorld.canvasAtPoint(point);
if (!canvas) {
hoverAnnotation(windowId, []);
return;
}
const annos = annotationsAtPoint(canvas, point);
if (xor(hoveredAnnotationIds, annos.map(a => a.id)).length > 0) {
hoverAnnotation(windowId, annos.map(a => a.id));
}
}, [annotations, annotationsAtPoint, canvasWorld, hoverAnnotation,
hoveredAnnotationIds, searchAnnotations, viewer, windowId]), 10);
const onCanvasExit = useCallback(() => {
// a move event may be queued up by the debouncer
onCanvasMouseMove.cancel();
hoverAnnotation(windowId, []);
}, [hoverAnnotation, onCanvasMouseMove, windowId]);
const onUpdateViewport = useCallback(() => {
updateCanvas();
}, [updateCanvas]);
useEffect(() => {
if (!viewer) return undefined;
viewer.addHandler('canvas-click', onCanvasClick);
viewer.addHandler('canvas-exit', onCanvasExit);
viewer.addHandler('mouse-move', onCanvasMouseMove);
viewer.addHandler('update-viewport', onUpdateViewport);
return () => {
viewer.removeHandler('canvas-click', onCanvasClick);
viewer.removeHandler('canvas-exit', onCanvasExit);
viewer.removeHandler('mouse-move', onCanvasMouseMove);
viewer.removeHandler('update-viewport', onUpdateViewport);
};
}, [onCanvasClick, onCanvasExit, onCanvasMouseMove, onUpdateViewport, viewer]);
useEffect(() => {
if (viewer) viewer.forceRedraw();
}, [annotations, drawAnnotations, drawSearchAnnotations, highlightAllAnnotations,
hoveredAnnotationIds, searchAnnotations, selectedAnnotationId, viewer]);
useEffect(() => {
if (!ref.current) return;
if (hoveredAnnotationIds.length > 0) {
ref.current.style.cursor = 'pointer';
} else {
ref.current.style.cursor = '';
}
}, [hoveredAnnotationIds, ref]);
if (!viewer) return null;
return ReactDOM.createPortal(
(
<div
ref={ref}
style={{
height: '100%', left: 0, position: 'absolute', top: 0, width: '100%',
}}
>
<canvas />
</div>
),
viewer.canvas,
);
}
AnnotationsOverlay.propTypes = {
annotations: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
canvasWorld: PropTypes.instanceOf(CanvasWorld).isRequired,
deselectAnnotation: PropTypes.func,
drawAnnotations: PropTypes.bool,
drawSearchAnnotations: PropTypes.bool,
highlightAllAnnotations: PropTypes.bool,
hoverAnnotation: PropTypes.func,
hoveredAnnotationIds: PropTypes.arrayOf(PropTypes.string),
palette: PropTypes.object, // eslint-disable-line react/forbid-prop-types
searchAnnotations: PropTypes.arrayOf(PropTypes.object), // eslint-disable-line react/forbid-prop-types
selectAnnotation: PropTypes.func,
selectedAnnotationId: PropTypes.string,
viewer: PropTypes.object, // eslint-disable-line react/forbid-prop-types
windowId: PropTypes.string.isRequired,
};