imagemapster
Version:
jQuery plugin that activates areas in HTML image maps with support for highlighting, selecting, tooltips, resizing and more
1,647 lines (1,485 loc) • 138 kB
JavaScript
/*!
* imagemapster - v1.9.2 - 2024-12-31
* https://jamietre.github.io/ImageMapster
* Copyright (c) 2011 - 2024 James Treworgy
* License: MIT
*/
import jQuery from 'jquery';
function imagemapsterFactory(jQuery) {
/*
jqueryextensions.js
Extend/intercept jquery behavior
*/
(function ($) {
'use strict';
function setupPassiveListeners() {
// Test via a getter in the options object to see if the passive property is accessed
var supportsPassive = false;
try {
var opts = Object.defineProperty({}, 'passive', {
get: function () {
supportsPassive = true;
return true;
}
});
window.addEventListener('testPassive.mapster', function () {}, opts);
window.removeEventListener('testPassive.mapster', function () {}, opts);
// eslint-disable-next-line no-unused-vars -- intentionally ignoring
} catch (e) {
// intentionally ignored
}
if (supportsPassive) {
// In order to not interrupt scrolling on touch devices
// we commit to not calling preventDefault from within listeners
// There is a plan to handle this natively in jQuery 4.0 but for
// now we are on our own.
// TODO: Migrate to jQuery 4.0 approach if/when released
// https://www.chromestatus.com/feature/5745543795965952
// https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md
// https://github.com/jquery/jquery/issues/2871#issuecomment-175175180
// https://jsbin.com/bupesajoza/edit?html,js,output
var setupListener = function (ns, type, listener) {
if (ns.includes('mapster') && ns.includes('noPreventDefault')) {
this.addEventListener(type, listener, { passive: true });
} else {
return false;
}
};
// special events for mapster.noPreventDefault
$.event.special.touchstart = {
setup: function (_, ns, listener) {
return setupListener.call(this, ns, 'touchstart', listener);
}
};
$.event.special.touchend = {
setup: function (_, ns, listener) {
return setupListener.call(this, ns, 'touchend', listener);
}
};
}
}
function supportsSpecialEvents() {
return $.event && $.event.special;
}
// Zepto does not support special events
// TODO: Remove when Zepto support is removed
if (supportsSpecialEvents()) {
setupPassiveListeners();
}
})(jQuery);
/*
When autoresize is enabled, we obtain the width of the wrapper element and resize to that, however when we're hidden because of
one of our ancenstors, jQuery width function returns 0. Ideally, we could use ResizeObserver/MutationObserver to detect
when we hide/show and resize on that event instead of resizing while we are not visible but until official support of older
browsers is dropped, we need to go this route. The plugin below will provide the actual width even when we're not visible.
Source: https://raw.githubusercontent.com/dreamerslab/jquery.actual/master/jquery.actual.js
*/
/*! Copyright 2012, Ben Lin (http://dreamerslab.com/)
* Licensed under the MIT License (LICENSE.txt).
*
* Version: 1.0.19
*
* Requires: jQuery >= 1.2.3
*/
/* eslint-disable one-var -- code directly from jquery source */
(function ($) {
'use strict';
$.fn.addBack = $.fn.addBack || $.fn.andSelf;
$.fn.extend({
actual: function (method, options) {
// check if the jQuery method exist
if (!this[method]) {
throw (
'$.actual => The jQuery method "' +
method +
'" you called does not exist'
);
}
var defaults = {
absolute: false,
clone: false,
includeMargin: false,
display: 'block'
};
var configs = $.extend(defaults, options);
var $target = this.eq(0);
var fix, restore;
if (configs.clone === true) {
fix = function () {
var style = 'position: absolute !important; top: -1000 !important; ';
// this is useful with css3pie
$target = $target.clone().attr('style', style).appendTo('body');
};
restore = function () {
// remove DOM element after getting the width
$target.remove();
};
} else {
var tmp = [];
var style = '';
var $hidden;
fix = function () {
// get all hidden parents
$hidden = $target.parents().addBack().filter(':hidden');
style +=
'visibility: hidden !important; display: ' +
configs.display +
' !important; ';
if (configs.absolute === true)
style += 'position: absolute !important; ';
// save the origin style props
// set the hidden el css to be got the actual value later
$hidden.each(function () {
// Save original style. If no style was set, attr() returns undefined
var $this = $(this);
var thisStyle = $this.attr('style');
tmp.push(thisStyle);
// Retain as much of the original style as possible, if there is one
$this.attr('style', thisStyle ? thisStyle + ';' + style : style);
});
};
restore = function () {
// restore origin style values
$hidden.each(function (i) {
var $this = $(this);
var _tmp = tmp[i];
if (_tmp === undefined) {
$this.removeAttr('style');
} else {
$this.attr('style', _tmp);
}
});
};
}
fix();
// get the actual value with user specific methed
// it can be 'width', 'height', 'outerWidth', 'innerWidth'... etc
// configs.includeMargin only works for 'outerWidth' and 'outerHeight'
var actual = /(outer)/.test(method)
? $target[method](configs.includeMargin)
: $target[method]();
restore();
// IMPORTANT, this plugin only return the value of the first element
return actual;
}
});
})(jQuery);
/* eslint-enable one-var */
/*
core.js
ImageMapster core
*/
(function ($) {
'use strict';
var mapster_version = '1.9.2';
// all public functions in $.mapster.impl are methods
$.fn.mapster = function (method) {
var m = $.mapster.impl;
if ($.mapster.utils.isFunction(m[method])) {
return m[method].apply(this, Array.prototype.slice.call(arguments, 1));
} else if (typeof method === 'object' || !method) {
return m.bind.apply(this, arguments);
} else {
$.error('Method ' + method + ' does not exist on jQuery.mapster');
}
};
$.mapster = {
version: mapster_version,
render_defaults: {
isSelectable: true,
isDeselectable: true,
fade: false,
fadeDuration: 150,
fill: true,
fillColor: '000000',
fillColorMask: 'FFFFFF',
fillOpacity: 0.7,
highlight: true,
stroke: false,
strokeColor: 'ff0000',
strokeOpacity: 1,
strokeWidth: 1,
includeKeys: '',
altImage: null,
altImageId: null, // used internally
altImages: {},
altImageOpacity: null
},
defaults: {
clickNavigate: false,
navigateMode: 'location', // location|open
wrapClass: null,
wrapCss: null,
onGetList: null,
sortList: false,
// listenToList: false, // not used - see mapdata.js line 1002
mapKey: '',
mapValue: '',
singleSelect: false,
listKey: 'value',
listSelectedAttribute: 'selected',
listSelectedClass: null,
onClick: null,
onMouseover: null,
onMouseout: null,
mouseoutDelay: 0,
onStateChange: null,
boundList: null,
onConfigured: null,
configTimeout: 30000,
noHrefIsMask: true,
scaleMap: true,
scaleMapBounds: { below: 0.98, above: 1.02 },
enableAutoResizeSupport: false, // TODO: Remove in next major release
autoResize: false,
autoResizeDelay: 0,
autoResizeDuration: 0,
onAutoResize: null,
areas: []
},
shared_defaults: {
render_highlight: { fade: true },
render_select: { fade: false },
staticState: null,
selected: null
},
area_defaults: {
includeKeys: '',
isMask: false
},
canvas_style: {
position: 'absolute',
left: 0,
top: 0,
padding: 0,
border: 0
},
hasCanvas: null,
map_cache: [],
hooks: {},
addHook: function (name, callback) {
this.hooks[name] = (this.hooks[name] || []).push(callback);
},
callHooks: function (name, context) {
$.each(this.hooks[name] || [], function (_, e) {
e.apply(context);
});
},
utils: {
when: {
all: function (deferredArray) {
// TODO: Promise breaks ES5 support
// eslint-disable-next-line no-undef -- requires polyfill per imagemapster docs
return Promise.all(deferredArray);
},
defer: function () {
// Deferred is frequently referred to as an anti-pattern largely
// due to error handling, however to avoid reworking existing
// APIs and support backwards compat, creating a "deferred"
// polyfill via native promise
var Deferred = function () {
// TODO: Promise breaks ES5 support
// eslint-disable-next-line no-undef -- requires polyfill per imagemapster docs
this.promise = new Promise(
function (resolve, reject) {
this.resolve = resolve;
this.reject = reject;
}.bind(this)
);
this.then = this.promise.then.bind(this.promise);
this.catch = this.promise.catch.bind(this.promise);
};
return new Deferred();
}
},
defer: function () {
return this.when.defer();
},
// extends the constructor, returns a new object prototype. Does not refer to the
// original constructor so is protected if the original object is altered. This way you
// can "extend" an object by replacing it with its subclass.
subclass: function (BaseClass, constr) {
var Subclass = function () {
var me = this,
args = Array.prototype.slice.call(arguments, 0);
me.base = BaseClass.prototype;
me.base.init = function () {
BaseClass.prototype.constructor.apply(me, args);
};
constr.apply(me, args);
};
Subclass.prototype = new BaseClass();
Subclass.prototype.constructor = Subclass;
return Subclass;
},
asArray: function (obj) {
return obj.constructor === Array ? obj : this.split(obj);
},
// clean split: no padding or empty elements
split: function (text, cb) {
var i,
el,
arr = text.split(',');
for (i = 0; i < arr.length; i++) {
// backwards compat for $.trim which would return empty string on null
// which theoertically should not happen here
el = arr[i] ? arr[i].trim() : '';
if (el === '') {
arr.splice(i, 1);
} else {
arr[i] = cb ? cb(el) : el;
}
}
return arr;
},
// similar to $.extend but does not add properties (only updates), unless the
// first argument is an empty object, then all properties will be copied
updateProps: function (_target, _template) {
var onlyProps,
target = _target || {},
template = $.isEmptyObject(target) ? _template : _target;
//if (template) {
onlyProps = [];
$.each(template, function (prop) {
onlyProps.push(prop);
});
//}
$.each(Array.prototype.slice.call(arguments, 1), function (_, src) {
$.each(src || {}, function (prop) {
if (!onlyProps || $.inArray(prop, onlyProps) >= 0) {
var p = src[prop];
if ($.isPlainObject(p)) {
// not recursive - only copies 1 level of subobjects, and always merges
target[prop] = $.extend(target[prop] || {}, p);
} else if (p && p.constructor === Array) {
target[prop] = p.slice(0);
} else if (typeof p !== 'undefined') {
target[prop] = src[prop];
}
}
});
});
return target;
},
isElement: function (o) {
return typeof HTMLElement === 'object'
? o instanceof HTMLElement
: o &&
typeof o === 'object' &&
o.nodeType === 1 &&
typeof o.nodeName === 'string';
},
/**
* Basic indexOf implementation for IE7-8. Though we use $.inArray, some jQuery versions will try to
* use a prototpye on the calling object, defeating the purpose of using $.inArray in the first place.
*
* This will be replaced with the array prototype if it's available.
*
* @param {Array} arr The array to search
* @param {Object} target The item to search for
* @return {Number} The index of the item, or -1 if not found
*/
indexOf: function (arr, target) {
if (Array.prototype.indexOf) {
return Array.prototype.indexOf.call(arr, target);
} else {
for (var i = 0; i < arr.length; i++) {
if (arr[i] === target) {
return i;
}
}
return -1;
}
},
// finds element of array or object with a property "prop" having value "val"
// if prop is not defined, then just looks for property with value "val"
indexOfProp: function (obj, prop, val) {
var result = obj.constructor === Array ? -1 : null;
$.each(obj, function (i, e) {
if (e && (prop ? e[prop] : e) === val) {
result = i;
return false;
}
});
return result;
},
// returns "obj" if true or false, or "def" if not true/false
boolOrDefault: function (obj, def) {
return this.isBool(obj) ? obj : def || false;
},
isBool: function (obj) {
return typeof obj === 'boolean';
},
isUndef: function (obj) {
return typeof obj === 'undefined';
},
isFunction: function (obj) {
return typeof obj === 'function';
},
isNumeric: function (obj) {
return !isNaN(parseFloat(obj));
},
// evaluates "obj", if function, calls it with args
// (todo - update this to handle variable lenght/more than one arg)
ifFunction: function (obj, that, args) {
if (this.isFunction(obj)) {
obj.call(that, args);
}
},
size: function (image, raw) {
var u = $.mapster.utils;
return {
width: raw
? image.width || image.naturalWidth
: u.imgWidth(image, true),
height: raw
? image.height || image.naturalHeight
: u.imgHeight(image, true),
complete: function () {
return !!this.height && !!this.width;
}
};
},
/**
* Set the opacity of the element. This is an IE<8 specific function for handling VML.
* When using VML we must override the "setOpacity" utility function (monkey patch ourselves).
* jQuery does not deal with opacity correctly for VML elements. This deals with that.
*
* @param {Element} el The DOM element
* @param {double} opacity A value between 0 and 1 inclusive.
*/
setOpacity: function (el, opacity) {
if ($.mapster.hasCanvas()) {
el.style.opacity = opacity;
} else {
$(el).each(function (_, e) {
if (typeof e.opacity !== 'undefined') {
e.opacity = opacity;
} else {
$(e).css('opacity', opacity);
}
});
}
},
// fade "el" from opacity "op" to "endOp" over a period of time "duration"
fader: (function () {
var elements = {},
lastKey = 0,
fade_func = function (el, op, endOp, duration) {
var index,
cbIntervals = duration / 15,
obj,
u = $.mapster.utils;
if (typeof el === 'number') {
obj = elements[el];
if (!obj) {
return;
}
} else {
index = u.indexOfProp(elements, null, el);
if (index) {
delete elements[index];
}
elements[++lastKey] = obj = el;
el = lastKey;
}
endOp = endOp || 1;
op =
op + endOp / cbIntervals > endOp - 0.01
? endOp
: op + endOp / cbIntervals;
u.setOpacity(obj, op);
if (op < endOp) {
setTimeout(function () {
fade_func(el, op, endOp, duration);
}, 15);
}
};
return fade_func;
})(),
getShape: function (areaEl) {
// per HTML spec, invalid value and missing value default is 'rect'
// Handling as follows:
// - Missing/Empty value will be treated as 'rect' per spec
// - Avoid handling invalid values do to perf impact
// Note - IM currently does not support shape of 'default' so while its technically
// a valid attribute value it should not be used.
// https://html.spec.whatwg.org/multipage/image-maps.html#the-area-element
return (areaEl.shape || 'rect').toLowerCase();
},
hasAttribute: function (el, attrName) {
var attr = $(el).attr(attrName);
// For some browsers, `attr` is undefined; for others, `attr` is false.
return typeof attr !== 'undefined' && attr !== false;
}
},
getBoundList: function (opts, key_list) {
if (!opts.boundList) {
return null;
}
var index,
key,
result = $(),
list = $.mapster.utils.split(key_list);
opts.boundList.each(function (_, e) {
for (index = 0; index < list.length; index++) {
key = list[index];
if ($(e).is('[' + opts.listKey + '="' + key + '"]')) {
result = result.add(e);
}
}
});
return result;
},
getMapDataIndex: function (obj) {
var img, id;
switch (obj.tagName && obj.tagName.toLowerCase()) {
case 'area':
id = $(obj).parent().attr('name');
img = $("img[usemap='#" + id + "']")[0];
break;
case 'img':
img = obj;
break;
}
return img ? this.utils.indexOfProp(this.map_cache, 'image', img) : -1;
},
getMapData: function (obj) {
var index = this.getMapDataIndex(obj.length ? obj[0] : obj);
if (index >= 0) {
return index >= 0 ? this.map_cache[index] : null;
}
},
/**
* Queue a command to be run after the active async operation has finished
* @param {MapData} map_data The target MapData object
* @param {jQuery} that jQuery object on which the command was invoked
* @param {string} command the ImageMapster method name
* @param {object[]} args arguments passed to the method
* @return {bool} true if the command was queued, false if not (e.g. there was no need to)
*/
queueCommand: function (map_data, that, command, args) {
if (!map_data) {
return false;
}
if (!map_data.complete || map_data.currentAction) {
map_data.commands.push({
that: that,
command: command,
args: args
});
return true;
}
return false;
},
unload: function () {
this.impl.unload();
this.utils = null;
this.impl = null;
$.fn.mapster = null;
$.mapster = null;
return $('*').off('.mapster');
}
};
// Config for object prototypes
// first: use only first object (for things that should not apply to lists)
/// calls back one of two fuinctions, depending on whether an area was obtained.
// opts: {
// name: 'method name',
// key: 'key,
// args: 'args'
//
//}
// name: name of method (required)
// args: arguments to re-call with
// Iterates through all the objects passed, and determines whether it's an area or an image, and calls the appropriate
// callback for each. If anything is returned from that callback, the process is stopped and that data return. Otherwise,
// the object itself is returned.
var m = $.mapster,
u = m.utils,
ap = Array.prototype;
// jQuery's width() and height() are broken on IE9 in some situations. This tries everything.
$.each(['width', 'height'], function (_, e) {
var capProp = e.substr(0, 1).toUpperCase() + e.substr(1);
// when jqwidth parm is passed, it also checks the jQuery width()/height() property
// the issue is that jQUery width() can report a valid size before the image is loaded in some browsers
// without it, we can read zero even when image is loaded in other browsers if its not visible
// we must still check because stuff like adblock can temporarily block it
// what a goddamn headache
u['img' + capProp] = function (img, jqwidth) {
return (
(jqwidth ? $(img)[e]() : 0) ||
img[e] ||
img['natural' + capProp] ||
img['client' + capProp] ||
img['offset' + capProp]
);
};
});
/**
* The Method object encapsulates the process of testing an ImageMapster method to see if it's being
* invoked on an image, or an area; then queues the command if the MapData is in an active state.
*
* @param {[jQuery]} that The target of the invocation
* @param {[function]} func_map The callback if the target is an image map
* @param {[function]} func_area The callback if the target is an area
* @param {[object]} opt Options: { key: a map key if passed explicitly
* name: the command name, if it can be queued,
* args: arguments to the method
* }
*/
m.Method = function (that, func_map, func_area, opts) {
var me = this;
me.name = opts.name;
me.output = that;
me.input = that;
me.first = opts.first || false;
me.args = opts.args ? ap.slice.call(opts.args, 0) : [];
me.key = opts.key;
me.func_map = func_map;
me.func_area = func_area;
//$.extend(me, opts);
me.name = opts.name;
me.allowAsync = opts.allowAsync || false;
};
m.Method.prototype = {
constructor: m.Method,
go: function () {
var i,
data,
ar,
len,
result,
src = this.input,
area_list = [],
me = this;
len = src.length;
for (i = 0; i < len; i++) {
data = $.mapster.getMapData(src[i]);
if (data) {
if (
!me.allowAsync &&
m.queueCommand(data, me.input, me.name, me.args)
) {
if (this.first) {
result = '';
}
continue;
}
ar = data.getData(src[i].nodeName === 'AREA' ? src[i] : this.key);
if (ar) {
if ($.inArray(ar, area_list) < 0) {
area_list.push(ar);
}
} else {
result = this.func_map.apply(data, me.args);
}
if (this.first || typeof result !== 'undefined') {
break;
}
}
}
// if there were areas, call the area function for each unique group
$(area_list).each(function (_, e) {
result = me.func_area.apply(e, me.args);
});
if (typeof result !== 'undefined') {
return result;
} else {
return this.output;
}
}
};
$.mapster.impl = (function () {
var me = {},
addMap = function (map_data) {
return m.map_cache.push(map_data) - 1;
},
removeMap = function (map_data) {
m.map_cache.splice(map_data.index, 1);
for (var i = m.map_cache.length - 1; i >= map_data.index; i--) {
m.map_cache[i].index--;
}
};
/**
* Test whether the browser supports VML. Credit: google.
* http://stackoverflow.com/questions/654112/how-do-you-detect-support-for-vml-or-svg-in-a-browser
*
* @return {bool} true if vml is supported, false if not
*/
function hasVml() {
var a = $('<div />').appendTo('body');
a.html('<v:shape id="vml_flag1" adj="1" />');
var b = a[0].firstChild;
b.style.behavior = 'url(#default#VML)';
var has = b ? typeof b.adj === 'object' : true;
a.remove();
return has;
}
/**
* Return a reference to the IE namespaces object, if available, or an empty object otherwise
* @return {obkect} The document.namespaces object.
*/
function namespaces() {
return typeof document.namespaces === 'object'
? document.namespaces
: null;
}
/**
* Test for the presence of HTML5 Canvas support. This also checks to see if excanvas.js has been
* loaded and is faking it; if so, we assume that canvas is not supported.
*
* @return {bool} true if HTML5 canvas support, false if not
*/
function hasCanvas() {
var d = namespaces();
// when g_vml_ is present, then we can be sure excanvas is active, meaning there's not a real canvas.
return d && d.g_vml_
? false
: $('<canvas />')[0].getContext
? true
: false;
}
/**
* Merge new area data into existing area options on a MapData object. Used for rebinding.
*
* @param {[MapData]} map_data The MapData object
* @param {[object[]]} areas areas array to merge
*/
function merge_areas(map_data, areas) {
var ar,
index,
map_areas = map_data.options.areas;
if (areas) {
$.each(areas, function (_, e) {
// Issue #68 - ignore invalid data in areas array
if (!e || !e.key) {
return;
}
index = u.indexOfProp(map_areas, 'key', e.key);
if (index >= 0) {
$.extend(map_areas[index], e);
} else {
map_areas.push(e);
}
ar = map_data.getDataForKey(e.key);
if (ar) {
$.extend(ar.options, e);
}
});
}
}
function merge_options(map_data, options) {
var temp_opts = u.updateProps({}, options);
delete temp_opts.areas;
u.updateProps(map_data.options, temp_opts);
merge_areas(map_data, options.areas);
// refresh the area_option template
u.updateProps(map_data.area_options, map_data.options);
}
// Most methods use the "Method" object which handles figuring out whether it's an image or area called and
// parsing key parameters. The constructor wants:
// this, the jQuery object
// a function that is called when an image was passed (with a this context of the MapData)
// a function that is called when an area was passed (with a this context of the AreaData)
// options: first = true means only the first member of a jQuery object is handled
// key = the key parameters passed
// defaultReturn: a value to return other than the jQuery object (if its not chainable)
// args: the arguments
// Returns a comma-separated list of user-selected areas. "staticState" areas are not considered selected for the purposes of this method.
me.get = function (key) {
var md = m.getMapData(this);
if (!(md && md.complete)) {
throw "Can't access data until binding complete.";
}
return new m.Method(
this,
function () {
// map_data return
return this.getSelected();
},
function () {
return this.isSelected();
},
{
name: 'get',
args: arguments,
key: key,
first: true,
allowAsync: true,
defaultReturn: ''
}
).go();
};
me.data = function (key) {
return new m.Method(
this,
null,
function () {
return this;
},
{ name: 'data', args: arguments, key: key }
).go();
};
// Set or return highlight state.
// $(img).mapster('highlight') -- return highlighted area key, or null if none
// $(area).mapster('highlight') -- highlight an area
// $(img).mapster('highlight','area_key') -- highlight an area
// $(img).mapster('highlight',false) -- remove highlight
me.highlight = function (key) {
return new m.Method(
this,
function () {
if (key === false) {
this.ensureNoHighlight();
} else {
var id = this.highlightId;
return id >= 0 ? this.data[id].key : null;
}
},
function () {
this.highlight();
},
{ name: 'highlight', args: arguments, key: key, first: true }
).go();
};
// Return the primary keys for an area or group key.
// $(area).mapster('key')
// includes all keys (not just primary keys)
// $(area).mapster('key',true)
// $(img).mapster('key','group-key')
// $(img).mapster('key','group-key', true)
me.keys = function (key, all) {
var keyList = [],
md = m.getMapData(this);
if (!(md && md.complete)) {
throw "Can't access data until binding complete.";
}
function addUniqueKeys(ad) {
var areas,
keys = [];
if (!all) {
keys.push(ad.key);
} else {
areas = ad.areas();
$.each(areas, function (_, e) {
keys = keys.concat(e.keys);
});
}
$.each(keys, function (_, e) {
if ($.inArray(e, keyList) < 0) {
keyList.push(e);
}
});
}
if (!(md && md.complete)) {
return '';
}
if (typeof key === 'string') {
if (all) {
addUniqueKeys(md.getDataForKey(key));
} else {
keyList = [md.getKeysForGroup(key)];
}
} else {
all = key;
this.each(function (_, e) {
if (e.nodeName === 'AREA') {
addUniqueKeys(md.getDataForArea(e));
}
});
}
return keyList.join(',');
};
me.select = function () {
me.set.call(this, true);
};
me.deselect = function () {
me.set.call(this, false);
};
/**
* Select or unselect areas. Areas can be identified by a single string key, a comma-separated list of keys,
* or an array of strings.
*
*
* @param {boolean} selected Determines whether areas are selected or deselected
* @param {string|string[]} key A string, comma-separated string, or array of strings indicating
* the areas to select or deselect
* @param {object} options Rendering options to apply when selecting an area
*/
me.set = function (selected, key, options) {
var lastMap,
map_data,
opts = options,
key_list,
area_list; // array of unique areas passed
function setSelection(ar) {
var newState = selected;
if (ar) {
switch (selected) {
case true:
ar.select(opts);
break;
case false:
ar.deselect(true);
break;
default:
newState = ar.toggle(opts);
break;
}
return newState;
}
}
function addArea(ar) {
if (ar && $.inArray(ar, area_list) < 0) {
area_list.push(ar);
key_list += (key_list === '' ? '' : ',') + ar.key;
}
}
// Clean up after a group that applied to the same map
function finishSetForMap(map_data) {
$.each(area_list, function (_, el) {
setSelection(el);
});
if (!selected) {
map_data.removeSelectionFinish();
}
}
this.filter('img,area').each(function (_, e) {
var keys;
map_data = m.getMapData(e);
if (map_data !== lastMap) {
if (lastMap) {
finishSetForMap(lastMap);
}
area_list = [];
key_list = '';
}
if (map_data) {
keys = '';
if (e.nodeName.toUpperCase() === 'IMG') {
if (!m.queueCommand(map_data, $(e), 'set', [selected, key, opts])) {
if (key instanceof Array) {
if (key.length) {
keys = key.join(',');
}
} else {
keys = key;
}
if (keys) {
$.each(u.split(keys), function (_, key) {
addArea(map_data.getDataForKey(key.toString()));
lastMap = map_data;
});
}
}
} else {
opts = key;
if (!m.queueCommand(map_data, $(e), 'set', [selected, opts])) {
addArea(map_data.getDataForArea(e));
lastMap = map_data;
}
}
}
});
if (map_data) {
finishSetForMap(map_data);
}
return this;
};
me.unbind = function (preserveState) {
return new m.Method(
this,
function () {
this.clearEvents();
this.clearMapData(preserveState);
removeMap(this);
},
null,
{ name: 'unbind', args: arguments }
).go();
};
// refresh options and update selection information.
me.rebind = function (options) {
return new m.Method(
this,
function () {
var me = this;
me.complete = false;
me.configureOptions(options);
me.bindImages().then(function () {
me.buildDataset(true);
me.complete = true;
me.onConfigured();
});
//this.redrawSelections();
},
null,
{
name: 'rebind',
args: arguments
}
).go();
};
// get options. nothing or false to get, or "true" to get effective options (versus passed options)
me.get_options = function (key, effective) {
var eff = u.isBool(key) ? key : effective; // allow 2nd parm as "effective" when no key
return new m.Method(
this,
function () {
var opts = $.extend({}, this.options);
if (eff) {
opts.render_select = u.updateProps(
{},
m.render_defaults,
opts,
opts.render_select
);
opts.render_highlight = u.updateProps(
{},
m.render_defaults,
opts,
opts.render_highlight
);
}
return opts;
},
function () {
return eff ? this.effectiveOptions() : this.options;
},
{
name: 'get_options',
args: arguments,
first: true,
allowAsync: true,
key: key
}
).go();
};
// set options - pass an object with options to set,
me.set_options = function (options) {
return new m.Method(
this,
function () {
merge_options(this, options);
},
null,
{
name: 'set_options',
args: arguments
}
).go();
};
me.unload = function () {
var i;
for (i = m.map_cache.length - 1; i >= 0; i--) {
if (m.map_cache[i]) {
me.unbind.call($(m.map_cache[i].image));
}
}
me.graphics = null;
};
me.snapshot = function () {
return new m.Method(
this,
function () {
$.each(this.data, function (_, e) {
e.selected = false;
});
this.base_canvas = this.graphics.createVisibleCanvas(this);
$(this.image).before(this.base_canvas);
},
null,
{ name: 'snapshot' }
).go();
};
// do not queue this function
me.state = function () {
var md,
result = null;
$(this).each(function (_, e) {
if (e.nodeName === 'IMG') {
md = m.getMapData(e);
if (md) {
result = md.state();
}
return false;
}
});
return result;
};
me.bind = function (options) {
return this.each(function (_, e) {
var img, map, usemap, md;
// save ref to this image even if we can't access it yet. commands will be queued
img = $(e);
md = m.getMapData(e);
// if already bound completely, do a total rebind
if (md) {
me.unbind.apply(img);
if (!md.complete) {
// in most situations, queueCommand should return true since we just queued unbind, however
// if not successfully queued, nothing remains in-process so we can continue
if (m.queueCommand(md, img, 'bind', [options])) {
return true;
}
}
md = null;
}
// ensure it's a valid image
// jQuery bug with Opera, results in full-url#usemap being returned from jQuery's attr.
// So use raw getAttribute instead.
usemap = this.getAttribute('usemap');
map = usemap && $('map[name="' + usemap.substr(1) + '"]');
if (!(img.is('img') && usemap && map.length > 0)) {
return true;
}
// sorry - your image must have border:0, things are too unpredictable otherwise.
img.css('border', 0);
if (!md) {
md = new m.MapData(this, options);
md.index = addMap(md);
md.map = map;
md.bindImages().then(function () {
md.initialize();
});
}
});
};
me.init = function (useCanvas) {
var style, shapes;
// for testing/debugging, use of canvas can be forced by initializing
// manually with "true" or "false". But generally we test for it.
m.hasCanvas = function () {
if (!u.isBool(m.hasCanvas.value)) {
m.hasCanvas.value = u.isBool(useCanvas) ? useCanvas : hasCanvas();
}
return m.hasCanvas.value;
};
m.hasVml = function () {
if (!u.isBool(m.hasVml.value)) {
// initialize VML the first time we detect its presence.
var d = namespaces();
if (d && !d.v) {
d.add('v', 'urn:schemas-microsoft-com:vml');
style = document.createStyleSheet();
shapes = [
'shape',
'rect',
'oval',
'circ',
'fill',
'stroke',
'imagedata',
'group',
'textbox'
];
$.each(shapes, function (_, el) {
style.addRule(
'v\\:' + el,
'behavior: url(#default#VML); antialias:true'
);
});
}
m.hasVml.value = hasVml();
}
return m.hasVml.value;
};
$.extend(m.defaults, m.render_defaults, m.shared_defaults);
$.extend(m.area_defaults, m.render_defaults, m.shared_defaults);
};
me.test = function (obj) {
return eval(obj);
};
return me;
})();
$.mapster.impl.init();
})(jQuery);
/*
graphics.js
Graphics object handles all rendering.
*/
(function ($) {
'use strict';
var p,
m = $.mapster,
u = m.utils,
canvasMethods,
vmlMethods;
/**
* Implemenation to add each area in an AreaData object to the canvas
* @param {Graphics} graphics The target graphics object
* @param {AreaData} areaData The AreaData object (a collection of area elements and metadata)
* @param {object} options Rendering options to apply when rendering this group of areas
*/
function addShapeGroupImpl(graphics, areaData, options) {
var me = graphics,
md = me.map_data,
isMask = options.isMask;
// first get area options. Then override fade for selecting, and finally merge in the
// "select" effect options.
$.each(areaData.areas(), function (_, e) {
options.isMask = isMask || (e.nohref && md.options.noHrefIsMask);
me.addShape(e, options);
});
// it's faster just to manipulate the passed options isMask property and restore it, than to
// copy the object each time
options.isMask = isMask;
}
/**
* Convert a hex value to decimal
* @param {string} hex A hexadecimal toString
* @return {int} Integer represenation of the hex string
*/
function hex_to_decimal(hex) {
return Math.max(0, Math.min(parseInt(hex, 16), 255));
}
function css3color(color, opacity) {
return (
'rgba(' +
hex_to_decimal(color.substr(0, 2)) +
',' +
hex_to_decimal(color.substr(2, 2)) +
',' +
hex_to_decimal(color.substr(4, 2)) +
',' +
opacity +
')'
);
}
/**
* An object associated with a particular map_data instance to manage renderin.
* @param {MapData} map_data The MapData object bound to this instance
*/
m.Graphics = function (map_data) {
//$(window).unload($.mapster.unload);
// create graphics functions for canvas and vml browsers. usage:
// 1) init with map_data, 2) call begin with canvas to be used (these are separate b/c may not require canvas to be specified
// 3) call add_shape_to for each shape or mask, 4) call render() to finish
var me = this;
me.active = false;
me.canvas = null;
me.width = 0;
me.height = 0;
me.shapes = [];
me.masks = [];
me.map_data = map_data;
};
p = m.Graphics.prototype = {
constructor: m.Graphics,
/**
* Initiate a graphics request for a canvas
* @param {Element} canvas The canvas element that is the target of this operation
* @param {string} [elementName] The name to assign to the element (VML only)
*/
begin: function (canvas, elementName) {
var c = $(canvas);
this.elementName = elementName;
this.canvas = canvas;
this.width = c.width();
this.height = c.height();
this.shapes = [];
this.masks = [];
this.active = true;
},
/**
* Add an area to be rendered to this canvas.
* @param {MapArea} mapArea The MapArea object to render
* @param {object} options An object containing any rendering options that should override the
* defaults for the area
*/
addShape: function (mapArea, options) {
var addto = options.isMask ? this.masks : this.shapes;
addto.push({ mapArea: mapArea, options: options });
},
/**
* Create a canvas that is sized and styled for the MapData object
* @param {MapData} mapData The MapData object that will receive this new canvas
* @return {Element} A canvas element
*/
createVisibleCanvas: function (mapData) {
return $(this.createCanvasFor(mapData))
.addClass('mapster_el')
.css(m.canvas_style)[0];
},
/**
* Add a group of shapes from an AreaData object to the canvas
*
* @param {AreaData} areaData An AreaData object (a set of area elements)
* @param {string} mode The rendering mode, "select" or "highlight". This determines the target
* canvas and which default options to use.
* @param {striong} options Rendering options
*/
addShapeGroup: function (areaData, mode, options) {
// render includeKeys first - because they could be masks
var me = this,
list,
name,
canvas,
map_data = this.map_data,
opts = areaData.effectiveRenderOptions(mode);
if (options) {
$.extend(opts, options);
}
if (mode === 'select') {
name = 'static_' + areaData.areaId.toString();
canvas = map_data.base_canvas;
} else {
canvas = map_data.overlay_canvas;
}
me.begin(canvas, name);
if (opts.includeKeys) {
list = u.split(opts.includeKeys);
$.each(list, function (_, e) {
var areaData = map_data.getDataForKey(e.toString());
addShapeGroupImpl(
me,
areaData,
areaData.effectiveRenderOptions(mode)
);
});
}
addShapeGroupImpl(me, areaData, opts);
me.render();
if (opts.fade) {
// fading requires special handling for IE. We must access the fill elements directly. The fader also has to deal with
// the "opacity" attribute (not css)
u.fader(
m.hasCanvas()
? canvas
: $(canvas).find('._fill').not('.mapster_mask'),
0,
m.hasCanvas() ? 1 : opts.fillOpacity,
opts.fadeDuration
);
}
}
// These prototype methods are implementation dependent
};
function noop() {}
// configure remaining prototype methods for ie or canvas-supporting browser
canvasMethods = {
renderShape: function (context, mapArea, offset) {
var i,
c = mapArea.coords(null, offset);
switch (mapArea.shape) {
case 'rect':
case 'rectangle':
context.rect(c[0], c[1], c[2] - c[0], c[3] - c[1]);
break;
case 'poly':
case 'polygon':
context.moveTo(c[0], c[1]);
for (i = 2; i < mapArea.length; i += 2) {
context.lineTo(c[i], c[i + 1]);
}
context.lineTo(c[0], c[1]);
break;
case 'circ':
case 'circle':
context.arc(c[0], c[1], c[2], 0, Math.PI * 2, false);
break;
}
},
addAltImage: function (context, image, mapArea, options) {
context.beginPath();
this.renderShape(context, mapArea);
context.closePath();
context.clip();
context.globalAlpha = options.altImageOpacity || options.fillOpacity;
context.drawImage(
image,
0,
0,
mapArea.owner.scaleInfo.width,
mapArea.owner.scaleInfo.height
);
},
render: function () {
// firefox 6.0 context.save() seems to be broken. to work around, we have to draw the contents on one temp canvas,
// the mask on another, and merge everything. ugh. fixed in 1.2.2. unfortunately this is a lot more code for masks,
// but no other way around it that i can see.
var maskCanvas,
maskContext,
me = this,
md = me.map_data,
hasMasks = me.masks.length,
shapeCanvas = me.createCanvasFor(md),
shapeContext = shapeCanvas.getContext('2d'),
context = me.canvas.getContext('2d');
if (hasMasks) {
maskCanvas = me.createCanvasFor(md);
maskContext = maskCanvas.getContext('2d');
maskContext.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
$.each(me.masks, function (_, e) {
maskContext.save();
maskContext.beginPath();
me.renderShape(maskContext, e.mapArea);
maskContext.closePath();
maskContext.clip();
maskContext.lineWidth = 0;
maskContext.fillStyle = '#000';
maskContext.fill();
maskContext.restore();
});
}
$.each(me.shapes, function (_, s) {
shapeContext.save();
if (s.options.fill) {
if (s.options.altImageId) {
me.addAltImage(
shapeContext,
md.images[s.options.altImageId],
s.mapArea,
s.options
);
} else {
shapeContext.beginPath();
me.renderShape(shapeContext, s.mapArea);
shapeContext.closePath();
//shapeContext.clip();
shapeContext.fillStyle = css3color(
s.options.fillColor,
s.options.fillOpacity
);
shapeContext.fill();
}
}
shapeContext.restore();
});
// render strokes at end since masks get stroked too
$.each(me.shapes.concat(me.masks), function (_, s) {
var offset = s.options.strokeWidth === 1 ? 0.5 : 0;
// offset applies only when stroke width is 1 and stroke would render between pixels.
if (s.options.stroke) {
shapeContext.save();
shapeContext.strokeStyle = css3color(
s.options.strokeColor,
s.options.strokeOpacity
);
shapeContext.lineWidth = s.options.strokeWidth;
shapeContext.beginPath();
me.renderShape(shapeContext, s.mapArea, offset);
shapeContext.closePath();
shapeContext.stroke();
shapeContext.restore();
}
});
if (hasMasks) {
// render the new shapes against the mask
maskContext.globalCompositeOperation = 'source-out';
maskContext.drawImage(shapeCanvas, 0, 0);
// flatten into the main canvas
context.drawImage(maskCanvas, 0, 0);
} else {
context.drawImage(shapeCanvas, 0, 0);
}
me.active = false;
return me.canvas;
},
// create a canvas mimicing dimensions of an existing element
createCanvasFor: function (md) {
return $(
'<canvas width="' +
md.scaleInfo.width +
'" hei