import Widget from '../../js/widget';
import { t } from 'enketo/translator';
import events from '../../js/event';
import { getSiblingElement } from '../../js/dom-utils';
const SELECTORS = 'path[id], g[id], circle[id]';
/**
* Image Map widget that turns an SVG image into a clickable map
* by matching radiobutton/checkbox values with id attribute values in the SVG.
*
* @augments Widget
*/
class ImageMap extends Widget {
/**
* @type {string}
*/
static get selector() {
return '.simple-select.or-appearance-image-map label:first-child > input';
}
_init() {
const img = this.question.querySelector( 'img' );
this.question.classList.add( 'or-image-map-initialized' );
/*
* To facilitate Enketo Express' offline webforms,
* where the img source is populated after form loading, we initialize upon image load
* if the src attribute is not yet populated.
*
* We could use the same with online-only forms, but that would cause a loading delay.
*/
if ( !img ) {
this._showSvgNotFoundError();
} else if ( img.getAttribute( 'src' ) ) {
// return a promise, resolving with instance for asynchronous initialization
return this._addMarkup( img )
.then( this._addFunctionality.bind( this ) )
.then( () => this );
} else {
return new Promise( resolve => {
img.addEventListener( 'load', () => {
this._addMarkup( img ).then( this._addFunctionality.bind( this ) );
resolve( this );
} );
} );
// Ignore errors, because an img element without source may throw one.
// E.g. in Enketo Express inside a repeat: https://github.com/kobotoolbox/enketo-express/issues/961
}
}
/**
* @param {object} widget - the widget element
*/
_addFunctionality( widget ) {
if ( widget ) {
this.svg = widget.querySelector( 'svg' );
this.tooltip = widget.querySelector( '.image-map__ui__tooltip' );
if ( this.props.readonly ) {
this.disable();
}
this._setSvgClickHandler();
this._setChangeHandler();
this._setHoverHandler();
this._updateImage();
this._setPageHandler();
}
}
/**
* @param {Element} img - the image element
* @return {Promise} the widget element
*/
_addMarkup( img ) {
const that = this;
const src = img.getAttribute( 'src' );
/**
* For translated forms, we now discard everything except the first image,
* since we're assuming the images will be the same in all languages.
*/
return fetch( src )
.then( response => response.text() )
.then( txt => ( new DOMParser() ).parseFromString( txt, 'text/xml' ) )
.then( doc => {
if ( that._isSvgDoc( doc ) ) {
const svgFragment = that._removeUnmatchedIds( doc.querySelector( 'svg' ) );
const fragment = document.createRange().createContextualFragment(
`<div class="widget image-map">
<div class="image-map__ui">
<span class="image-map__ui__tooltip"></span>
</div>
</div>`
);
fragment.querySelector( '.widget' ).append( svgFragment );
// remove images in all languages
that.question.querySelectorAll( 'img' ).forEach( el => el.remove() );
that.question.querySelector( 'fieldset > .option-wrapper' ).before( fragment );
const widget = that.question.querySelector( '.image-map' );
const svg = widget.querySelector( 'svg' );
// Use any explicitly defined viewPort and else define one using bounding box or attributes
if ( !svg.getAttribute( 'viewBox' ) ) {
this._setViewBox( svg );
}
return widget;
} else {
throw ( 'Image is not an SVG doc' );
}
} )
.catch( this._showSvgNotFoundError.bind( that ) );
}
_setViewBox( svg ){
let viewBox;
try {
// Resize, using original unscaled SVG dimensions
// Note that width and height will be zero if the SVG is currently not visible
const bbox = svg.getBBox();
viewBox = `${bbox.x} ${bbox.y} ${bbox.width} ${bbox.height}`;
} catch ( e ) {
// svg.getBBox() only works after SVG has been added to DOM.
// In FF getBBox causes an "NS_ERROR_FAILURE" exception likely because the SVG
// image has not finished rendering. This doesn't always happen though.
// For now, we just log the FF error, and hope that resizing is done correctly via
// attributes.
console.error( 'Could not obtain Boundary Box of SVG element', e );
let width = svg.getAttribute( 'width' );
let height = svg.getAttribute( 'height' );
if ( width && height ) {
viewBox = `0 0 ${parseInt( width, 10 )} ${parseInt( height, 10 )}`;
}
}
svg.setAttribute( 'viewBox', viewBox );
}
/**
* @param {Error} err - error message
*/
_showSvgNotFoundError( err ) {
console.error( err );
const fragment = document.createRange().createContextualFragment(
`<div class="widget image-map">
<div class="image-map__error" data-i18n="imagemap.svgNotFound">${t( 'imagemap.svgNotFound' )}</div>
</div>`
);
this.question.querySelector( '.option-wrapper' ).before( fragment );
}
/**
* Removes id attributes from unmatched path elements in order to prevent hover effect (and click listener).
*
* @param {Element} svg - SVG element
* @return {Element} cleaned up SVG
*/
_removeUnmatchedIds( svg ) {
svg.querySelectorAll( SELECTORS ).forEach( el => {
if ( !this._getInput( el.id ) ) {
el.removeAttribute( 'id' );
}
} );
return svg;
}
/**
* @param {string} id - the option ID
* @return {Element} input element with matching ID
*/
_getInput( id ) {
return this.question.querySelector( `input[value="${CSS.escape( id )}"]` );
}
/**
* Handles SVG click listener
*/
_setSvgClickHandler() {
this.svg.addEventListener( 'click', ev => {
if ( !ev.target.closest( 'svg' ).matches( '[or-readonly]' ) && ( ev.target.matches( SELECTORS ) || ev.target.closest( SELECTORS ) ) ) {
const id = ev.target.id || ev.target.closest( 'g[id]' ).id;
const input = this._getInput( id );
if ( input ) {
input.checked = !input.checked;
input.dispatchEvent( events.Change() );
input.dispatchEvent( events.FakeFocus() );
}
}
} );
}
/**
* Handles change listener
*/
_setChangeHandler() {
this.question.addEventListener( 'change', this._updateImage.bind( this ) );
}
/**
* Handles hover listener
*/
_setHoverHandler() {
this.svg.querySelectorAll( SELECTORS ).forEach( el => {
el.addEventListener( 'mouseenter', ev => {
const id = ev.target.id || ev.target.closest( 'g[id]' ).id;
const label = getSiblingElement( this._getInput( id ), '.option-label.active' );
const optionLabel = label ? label.textContent : '';
this.tooltip.textContent = optionLabel;
} );
el.addEventListener( 'mouseleave', ev => {
if ( ev.target.matches( SELECTORS ) ) {
this.tooltip.textContent = '';
}
} );
} );
}
/**
* Handles page flip of page in which the widget is placed.
*/
_setPageHandler(){
const page = this.element.closest( '[role="page"]' );
if ( page ){
page.addEventListener( events.PageFlip().type, () => this._setViewBox( this.svg ) );
}
}
/**
* @param {object} data - an object
* @return {boolean} whether provided object is an SVG document
*/
_isSvgDoc( data ) {
return typeof data === 'object' && data.querySelector( 'svg' );
}
/**
* Updates 'selected' attributes in SVG
* Always update the map after the value has changed in the original input elements
*/
_updateImage() {
let values = this.originalInputValue;
this.svg.querySelectorAll( 'path[or-selected], g[or-selected], circle[or-selected]' ).forEach( el => el.removeAttribute( 'or-selected' ) );
if ( typeof values === 'string' ) {
values = [ values ];
}
values.forEach( value => {
if ( value ) {
// if multiple values have the same id, change all of them (e.g. a province that is not contiguous)
this.svg.querySelectorAll( `path#${CSS.escape( value )},g#${CSS.escape( value )},circle#${CSS.escape( value )}` ).forEach( el => el.setAttribute( 'or-selected', '' ) );
}
} );
}
/**
* Disables widget
*/
disable() {
this.svg.setAttribute( 'or-readonly', '' );
}
/**
* Enables widget
*/
enable() {
this.svg.removeAttribute( 'or-readonly' );
}
/**
* Updates widget image
*/
update() {
this._updateImage();
}
/**
* @type {string}
*/
get value() {
// This widget is unusual. It would better to get the value from the map.
return this.originalInputValue;
}
set value( value ) {
// This widget is unusual. It would more consistent to set the value in the map perhaps.
this.originalInputValue = value;
}
}
export default ImageMap;