svg-map-extra
Version:
svg-map-extra is an extended JavaScript library that lets you easily create an interactable world map comparing customizable data for each country inspired by the original svgMap.
236 lines (209 loc) • 11.2 kB
JavaScript
import SVGPanZoom from 'svg-pan-zoom';
import $ from 'cash-dom';
import countries from './svg-map/countries';
import defaultOptions from './svg-map/default-options';
import emojiFlags from './svg-map/emoji-flags';
import mapPaths from './svg-map/map-paths';
import { resetMapZoom, setControlStatuses } from './svg-map/map';
import { createTooltip, hideTooltip, moveTooltip, setTooltipContent, showTooltip } from './svg-map/tooltip';
import { getColor, getCountryName } from './svg-map/utils';
export default class SVGMap {
constructor(options = {}) {
if (!options.targetElementID || !document.getElementById(options.targetElementID)) {
if (!options.targetElement) throw new TypeError('Target element not found');
}
this.options = { ...defaultOptions, ...options };
const container = this.options.targetElementID ? document.getElementById(this.options.targetElementID) : this.options.targetElement;
// Create the map
// Create the tooltip content
const getTooltipContent = countryCode => {
const tooltipContentWrapper = $('<div class="svg-map-tooltip-content-container">');
if (this.options.hideFlag === false) {
// Flag
const flagContainer = $(`<div class="svg-map-tooltip-flag-container svg-map-tooltip-flag-container-${this.options.flagType}">`).appendTo(tooltipContentWrapper);
switch (this.options.flagType) {
case "image":
flagContainer.append($('<img class="svg-map-tooltip-flag">').attr('src', this.options.flagURL.replace('{0}', countryCode.toLowerCase())));
break;
case "emoji":
flagContainer.html(emojiFlags[countryCode]);
break;
}
}
// Title
tooltipContentWrapper.append($('<div class="svg-map-tooltip-title">').html(getCountryName(countryCode, this.options.countryNames)));
// Content
const tooltipContent = $('<div class="svg-map-tooltip-content">').appendTo(tooltipContentWrapper);
if (this.options.data && this.options.data.values[countryCode]) {
const tooltipContentBody = this.options.getTooltipContent(countryCode, this.options.data.schema, this.options.data.values);
typeof tooltipContentBody === "string" ? tooltipContent.html(tooltipContentBody) : tooltipContent.append(tooltipContentBody);
} else {
tooltipContent.append($('<div class="svg-map-tooltip-no-data">').html(this.options.noDataText));
}
return tooltipContentWrapper;
};
// Zoom map
const zoomMap = (buttonControl, direction) => {
if (buttonControl.hasClass('svg-map-disabled')) return false;
this.panZoom[direction == 'in' ? 'zoomIn' : 'zoomOut']();
};
// Create the tooltip
const { tooltip, tooltipContentContainer, tooltipPointer } = createTooltip(this.options.rootElement);
// Create map wrappers
this.wrapper = $('<div class="svg-map-wrapper">').appendTo(container);
const mapImage = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
mapImage.setAttribute('viewBox', '0 0 2000 1001');
mapImage.classList.add('svg-map-image');
this.wrapper.append(mapImage);
// Add controls
const mapControlsWrapper = $('<div class="svg-map-controls-wrapper">').appendTo(this.wrapper);
const zoomContainer = $('<div class="svg-map-controls-zoom">').appendTo(mapControlsWrapper);
const zoomControlIn = $('<button class="svg-map-control-button svg-map-zoom-button svg-map-zoom-in-button">').appendTo(zoomContainer);
const zoomControlOut = $('<button class="svg-map-control-button svg-map-zoom-button svg-map-zoom-out-button">').appendTo(zoomContainer);
[[zoomControlIn, 'in'], [zoomControlOut, 'out']].forEach(([buttonControl, direction]) => {
buttonControl.type = 'button';
buttonControl.on('click', () => zoomMap(buttonControl, direction));
buttonControl.attr('aria-label', `Zoom ${direction}`);
});
// Fix countries
const localMapPaths = { ...mapPaths };
if (!this.options.countries.EH) {
localMapPaths.MA.d = localMapPaths['MA-EH'].d;
delete localMapPaths.EH;
}
delete localMapPaths['MA-EH'];
// Add map elements
Object.keys(localMapPaths).filter(countryCode => localMapPaths[countryCode].d).forEach(countryCode => {
const countryElement = document.createElementNS('http://www.w3.org/2000/svg', 'path');
countryElement.setAttribute('d', localMapPaths[countryCode].d);
countryElement.setAttribute('data-id', countryCode);
countryElement.classList.add('svg-map-country');
mapImage.appendChild(countryElement);
['mouseenter', 'touchdown'].forEach(event => countryElement.addEventListener(event, () => countryElement.closest('g').appendChild(countryElement)));
countryElement.addEventListener('click', event => {
typeof this.options.onClick === "function" && this.options.onClick.call(countryElement, countryCode, event);
});
const updateTooltip = event => {
setTooltipContent(tooltipContentContainer, getTooltipContent(countryCode));
moveTooltip(event, { tooltip, tooltipPointer });
this.updateTooltip = () => updateTooltip(event);
};
// Tooltip events
// Add tooltip when touch is used
countryElement.addEventListener('touchstart', event => {
showTooltip(event, { tooltip, tooltipPointer });
updateTooltip(event);
});
countryElement.addEventListener('mouseenter', event => {
showTooltip(event, { tooltip, tooltipPointer });
updateTooltip(event);
});
countryElement.addEventListener('mousemove', event => moveTooltip(event, { tooltip, tooltipPointer }));
// Hide tooltip when event is mouseleave or touchend
['mouseleave', 'touchend'].forEach(event => countryElement.addEventListener(event, () => hideTooltip(tooltip)));
});
// Init pan zoom
this.panZoom = SVGPanZoom(mapImage, {
minZoom: this.options.minZoom,
maxZoom: this.options.maxZoom,
zoomScaleSensitivity: this.options.zoomScaleSensitivity,
mouseWheelZoomEnabled: this.options.mouseWheelZoomEnabled, // TODO Only with key pressed
onZoom: () => setControlStatuses({ mapPanZoom: this.panZoom, maxZoom: this.options.maxZoom, minZoom: this.options.minZoom, zoomControlIn: zoomControlIn, zoomControlOut: zoomControlOut }),
beforePan: (oldPan, newPan) => {
const gutterWidth = this.wrapper.offsetWidth * 0.85;
const gutterHeight = this.wrapper.offsetHeight * 0.85;
const sizes = this.panZoom.getSizes();
const leftLimit = -((sizes.viewBox.x + sizes.viewBox.width) * sizes.realZoom) + gutterWidth;
const rightLimit = sizes.width - gutterWidth - (sizes.viewBox.x * sizes.realZoom);
const topLimit = -((sizes.viewBox.y + sizes.viewBox.height) * sizes.realZoom) + gutterHeight;
const bottomLimit = sizes.height - gutterHeight - (sizes.viewBox.y * sizes.realZoom);
return {
x: Math.max(leftLimit, Math.min(rightLimit, newPan.x)),
y: Math.max(topLimit, Math.min(bottomLimit, newPan.y))
}
}
});
// Init pan zoom
this.panZoom.zoom(this.options.initialZoom);
this.panZoom.initialLoad = true;
// Initial zoom statuses
setControlStatuses({ mapPanZoom: this.panZoom, maxZoom: this.options.maxZoom, minZoom: this.options.minZoom, zoomControlIn: zoomControlIn, zoomControlOut: zoomControlOut });
// Apply map data
this.options.data && this.applyData(this.options.data);
this.destroy = () => {
tooltip.remove();
};
}
applyData(data) {
this.options.data = data;
// Get highest and lowest value
const values = Object.keys(data.values).filter(countryCode => {
return typeof data.values[countryCode][data.applyData] === "number";
}).map(countryCode => {
return data.values[countryCode][data.applyData];
});
let min = Math.min(...values);
let max = Math.max(...values);
data.schema[data.applyData].thresholdMax && (max = Math.min(max, data.schema[data.applyData].thresholdMax));
data.schema[data.applyData].thresholdMin && (min = Math.max(min, data.schema[data.applyData].thresholdMin));
// Loop through countries and set colors
Object.keys(countries).map(countryCode => {
return [this.wrapper.find(`[data-id="${countryCode}"]`)[0], countryCode];
}).filter(([path]) => path != null).forEach(([path, countryCode]) => {
if (data.values[countryCode]) {
const value = Math.max(min, parseInt(data.values[countryCode][data.applyData], 10));
const ratio = max === min ? 1 : Math.max(0, Math.min(1, (value - min) / (max - min)));
const color = getColor(this.options.colorMax, this.options.colorMin, ratio);
return path.setAttribute('fill', color);
}
path.setAttribute('fill', this.options.colorNoData);
});
if (this.options.fitToData) {
const { offsetWidth: mapWidth, offsetHeight: mapHeight } = this.wrapper[0];
const scaleFactor = mapWidth / (mapWidth > mapHeight ? 2000 : 1001);
const mapCenterPoint = [mapWidth / 2, mapHeight / 2];
const points = Object.keys(data.values).map(countryCode => {
return this.wrapper.find(`[data-id="${countryCode}"]`)[0];
}).filter(path => path != null).reduce((accumulator, path) => {
const pathDefinition = (path.attributes.d.value.match(/[A-Za-z][\d.,-]+/g) || []).map(string => {
const command = string.charAt(0);
const coordinates = string.substring(1).split(string.match(',') ? ',' : /(?<=\d)(?=-)/g).map(coordinate => parseFloat(coordinate.trim()));
command.match(/^[Hh]$/g) && coordinates.push(0);
command.match(/^[Vv]$/g) && coordinates.unshift(0);
return { command, coordinates };
});
let currentPoint = [...pathDefinition[0].coordinates];
pathDefinition.forEach(definition => {
if (definition.command.match(/^[A-Z]$/g)) {
currentPoint = [...definition.coordinates];
} else {
const [x, y] = currentPoint;
currentPoint = [x + definition.coordinates[0], y + definition.coordinates[1]];
}
definition.absoluteCoordinates = currentPoint;
});
pathDefinition.forEach(definition => {
definition.absoluteCoordinates = [definition.absoluteCoordinates[0] * scaleFactor, definition.absoluteCoordinates[1] * scaleFactor];
});
return [...accumulator, ...pathDefinition.map(a => a.absoluteCoordinates)];
}, []);
this.resetTransformations();
if (points.length > 0) {
const minX = Math.min(...points.map(([x]) => x));
const minY = Math.min(...points.map(([, y]) => y));
const maxX = Math.max(...points.map(([x]) => x));
const maxY = Math.max(...points.map(([, y]) => y));
const boundingBoxWidth = maxX - minX;
const boundingBoxHeight = maxY - minY;
const xZoomFactor = 2000 * scaleFactor / boundingBoxWidth;
const yZoomFactor = 1001 * scaleFactor / boundingBoxHeight;
this.panZoom.pan({ x: mapCenterPoint[0] - (minX + boundingBoxWidth / 2), y: mapCenterPoint[1] - (minY + boundingBoxHeight / 2) });
this.panZoom.zoom(Math.round(Math.min(xZoomFactor, yZoomFactor) * .8));
}
}
typeof this.updateTooltip === "function" && this.updateTooltip();
}
resetTransformations() {
resetMapZoom({ mapWrapper: this.wrapper, mapPanZoom: this.panZoom });
}
}