/** @module eSvgMap */
import * as d3 from 'd3'
import proj4 from 'proj4'
import { constants } from './eConstants.js'
/**
* @param {Object} opts - Initialisation options.
* @param {string} opts.selector - The CSS selector of the element which will be the parent of the SVG. (Default - body.)
* @param {string} opts.mapid - The id for the map to be created. (Default - es=SvgMap.)
* @param {number} opts.outputWidth - The width of the map in pixels. If this is set to zero then the
* width is set to the client width of the element indicated by the opts.selector parameter. (Default - 0.)
* @param {number} opts.outputHeight - The height of the map in pixels. If this is set to zero then the
* height is set to the client height of the element indicated by the opts.selector parameter. (Default - 0.)
* @param {Array.<number>} opts.mapBB - The MBR of an area that must be fully visible in the map to start. This
* iniitally positions the map view. The array has the form [minx, miny, maxx, maxy]. The numbers are in the
* coordinate system EPSG:3035 (ETRS89-extended / LAEA Europe) which is that used for the map.
* (Default - [1000000, 800000, 6600000, 5500000].)
* @param {boolean} opts.expand - Indicates whether or not the map will expand to fill parent element. (Default - false.)
* @param {boolean} opts.hightlightAllEurope - Indicates whether or not *all* European countries are to be
* highlighted.
* @param {Array.<string>} opts.highlightCountries - An array of country names that are to be highlighted. The country names
* must match those used in the SOVEREIGNT property of the boundaryEuropeGjson asset. Only useful if the hightlightAllEurope
* option is set to false.
* @param {string} opts.fillEurope - Specifies the fill colour to use for the countries considered to be part of 'Europe'
* for the purposes of the visualisation. Can use any recognised JS way to specify a colour. (Default - black.)
* @param {string} opts.strokeEurope - Specifies the stroke colour to use for the countries considered to be part of 'Europe'
* for the purposes of the visualisation. Can use any recognised JS way to specify a colour. (Default - rgb(100,100,100).)
* @param {string} opts.fillWord - Specifies the fill colour to use for countries not considered to be part of 'Europe'
* for the purposes of the visualisation. Can use any recognised JS way to specify a colour. (Default - rgb(50,50,50).)
* @param {string} opts.fillOcean - Specifies the fill colour to use for Ocean. Can use any recognised JS way to specify
* a colour. (Default - rgb(100,100,100).)
* @param {string} opts.fillDot - Specifies the fill colour to use for map dots. Can use any recognised JS way to specify
* a colour. (Default - red.)
* @param {string} opts.dotSize1 - Specifies a size multiplier for dot1. The base dotsize corresponds to a 30km radius on the
* ground. For the 'traces' map, dot1 represents records found in the current week. (Default - 1.)
* @param {string} opts.dotSize2 - Specifies a size multiplier for dot2. The base dotsize corresponds to a 30km radius on the
* ground. For the 'traces' map, dot2 represents records found in the previous week. (Default - 3.)
* @param {string} opts.dotSize3 - Specifies a size multiplier for dot3. The base dotsize corresponds to a 30km radius on the
* ground. For the 'traces' map, dot3 represents records found in the week two weeks before current. (Default - 6.)
* @param {string} opts.dotOpacity1 - Specifies an opacity for dot1. For the 'traces' map, dot1 represents records
* found in the current week. (Default - 1.)
* @param {string} opts.dotOpacity2 - Specifies an opacity for dot2. For the 'traces' map, dot2 represents records
* found in the previous week. (Default - 0.4.)
* @param {string} opts.dotOpacity3 - Specifies an opacity for dot3. For the 'traces' map, dot3 represents records
* found in the week two weeks before current. (Default - 0.1.)
* @param {boolean} opts.showZoomControls - Indicates whether zoom controls are to be displayed on the map. (Default - true.)
* @param {boolean} opts.aggregate - Indicates data locations are to be shifted to the centre of the nearest
* 30km grid square. (Default - true.)
* @returns {module:eSvgMap~api} api - Returns an API for the map.
*/
export function eSvgMap({
// Default options in here
selector = 'body',
mapid = 'eSvgMap',
outputWidth = 0,
outputHeight = 0,
mapBB = [1000000, 800000, 6600000, 5500000], // [minx, miny, maxx, maxy]
fillEurope = 'black',
fillWorld = 'rgb(50,50,50)',
fillOcean = 'rgb(100,100,100)',
strokeEurope = 'rgb(100,100,100)',
fillDot = 'red',
expand = false,
highlightCountries = [],
hightlightAllEurope = false,
dotSize1 = 1,
dotSize2 = 3,
dotSize3 = 6,
dotOpacity1 = 1,
dotOpacity2 = 0.4,
dotOpacity3 = 0.1,
showZoomControls = true,
aggregate = true
}) {
// Function level variables
let dataGridded = [] // Transformed data
let transform // Transformation function from EPSG 3505 to SVG coords
let geoPath // D3 geopath transformation function from EPSG 3505 to SVG coords
let d30 // Size of 30 km in pixels
let currentWeek // Currently displayed week
let currentYear // Currently displayed year
// Create a parent div for the SVG within the parent element passed
// as an argument. Allows us to style correctly for positioning etc.
const mainDiv = d3.select(`${selector}`)
.append("div")
.attr('id', mapid)
.style("position", "relative")
.style("display", "inline")
// Map loading spinner
let mapLoaderShowExplicit = false
const mapLoader = mainDiv.append("div")
.classed('map-loader', true)
const mapLoaderInner = mapLoader.append("div")
.classed('map-loader-inner', true)
mapLoaderInner.append("div")
.classed('map-loader-spinner', true)
mapLoaderInner.append("div").text("Loading map data...")
.classed('map-loader-text', true)
// Zoom control
const zoomControls = mainDiv.append("div")
.classed('zoom-controls', true)
.style('display', showZoomControls ? 'block' : 'none')
const zoomIn = zoomControls.append("div").text("+")
.classed('zoom-control-button', true)
.classed('zoom-control-top-button', true)
const zoomOut = zoomControls.append("div").html("–")
.classed('zoom-control-button', true)
// Create the SVG.
const svg = mainDiv.append("svg")
.style("background-color", fillOcean)
sizeSvg()
// Zoom g element
const zoomG = svg.append("g")
function handleZoom(e) {
//console.log('e', e)
zoomG.attr('transform', e.transform);
}
const zoom = d3.zoom()
.on ('zoom', handleZoom)
svg.call(zoom)
// Attach actions to zoom buttons
zoomIn.on('click', () => svg.transition().call(zoom.scaleBy, 2))
zoomOut.on('click', () => svg.transition().call(zoom.scaleBy, 0.5))
// Group element for european boundary and world boundary
const boundaryWorld = zoomG.append("g").attr("id", "boundaryWorld")
//const boundaryEurope = svg.append("g").attr("id", "boundaryEurope")
const boundaryEurope = zoomG.append("g").attr("id", "boundaryEurope")
// Group element for dots
const dotsWeek0= zoomG.append("g").attr("id", "dotsWeek0")
const dotsWeek1= zoomG.append("g").attr("id", "dotsWeek1")
const dotsWeek2= zoomG.append("g").attr("id", "dotsWeek2")
// Load the boundary data
const boundaryEuropeGjson=`${constants.thisCdn}/assets/european/european-countries-3035.geojson`
const boundaryWorldGjson=`${constants.thisCdn}/assets/european/world-land-trimmed-3035.geojson`
// Create transformation and get dot size
transform = getTransformation()
geoPath = getGeoPath()
d30 = transform([30000,0])[0] - transform([0,0])[0]
displayMapBackground()
function sizeSvg() {
// Get dimensions of parent element
if (!outputWidth) {
outputWidth = document.querySelector(selector).clientWidth
}
if (!outputHeight) {
outputHeight = document.querySelector(selector).clientHeight
}
console.log('outputHeight', outputHeight)
console.log('outputWidth', outputWidth)
if (expand) {
svg.attr("viewBox", "0 0 " + outputWidth + " " + outputHeight)
} else {
svg.attr("width", outputWidth)
svg.attr("height", outputHeight)
}
}
function getTransformation() {
// Work out the extents for the transformation.
// The full extent of the area denoted by opts.mapBB
// must be visible in the SVG. Unless the SVG width/height
// is exactly the same aspect ratio of mapBB, then that
// means adjusting the real world mapBB.
let minxMap = mapBB[0]
let minyMap = mapBB[1]
let maxxMap = mapBB[2]
let maxyMap = mapBB[3]
const xCentreMap = minxMap + (maxxMap-minxMap) / 2
const yCentreMap = minyMap + (maxyMap-minyMap) / 2
if (outputWidth / outputHeight > (maxxMap - minxMap) / (maxyMap - minyMap)) {
const mapWidth = outputWidth / outputHeight * (maxyMap - minyMap)
minxMap = xCentreMap - mapWidth / 2
maxxMap = minxMap + mapWidth
} else {
const mapHeight = outputHeight / outputWidth * (maxxMap - minxMap)
minyMap = yCentreMap - mapHeight / 2
maxyMap = minyMap + mapHeight
}
// Return the transformation function
return function(p) {
const x = p[0]
const y = p[1]
let tX, tY
const realWidth = maxxMap-minxMap
const realHeight = maxyMap-minyMap
tX = outputWidth * (x-minxMap)/realWidth
tY = outputHeight - outputHeight * (y-minyMap)/realHeight
return [tX, tY]
}
}
function getGeoPath () {
return d3.geoPath()
.projection(
d3.geoTransform({
point: function(x, y) {
const tP = transform([x,y])
const tX = tP[0]
const tY = tP[1]
this.stream.point(tX, tY)
}
})
)
}
function displayMapBackground() {
d3.json(boundaryEuropeGjson).then(data => {
const dataFeaturesHighlight = data.features.filter(d => hightlightAllEurope || highlightCountries.includes(d.properties.SOVEREIGNT))
data.features = dataFeaturesHighlight
boundaryEurope.selectAll("path").remove()
boundaryEurope.append("path")
.datum(data)
.attr("d", geoPath)
.style("fill", fillEurope)
.style("stroke", strokeEurope)
})
d3.json(boundaryWorldGjson).then(data => {
// console.log('data', data)
boundaryWorld.selectAll("path").remove()
boundaryWorld.append("path")
.datum(data)
.attr("d", geoPath)
.style("fill", fillWorld)
})
if (!mapLoaderShowExplicit) {
mapLoader.classed('map-loader-hidden', true)
}
}
// API functions
/** @function mapData
* @param {number} week - A number indicating the week of the year for which to display data.
* @param {number} year - A number indicating the year for which to display data can be blank to include all years.
* @description <b>This function is exposed as a method on the API returned from the eSvgMap function</b>.
* Display the data for a given year/week. If no year is specified, then data from all years are included.
*/
function mapData(week, year) {
currentWeek = week
currentYear = year
// First filter the gridded data based on week and, optionally, year.
const dYear = dataGridded.filter(d => !year || d.year === year)
let dWeek0 = dYear.filter(d => d.week === week)
let dWeek1 = dYear.filter(d => d.week === week - 1)
let dWeek2 = dYear.filter(d => d.week === week - 2)
// Remove all duplicates
dWeek0 = dWeek0.filter((v, i, self) => i === self.findIndex(d => d.id === v.id))
dWeek1 = dWeek1.filter((v, i, self) => i === self.findIndex(d => d.id === v.id))
dWeek2 = dWeek2.filter((v, i, self) => i === self.findIndex(d => d.id === v.id))
//
dotsWeek0.selectAll(".dot0")
.data(dWeek0, d => d.id)
.join (
enter => enter.append("circle")
.classed("dot0", true)
.attr("fill", fillDot)
.attr("opacity", dotOpacity1)
.attr("cx", d => transform([d.x, d.y])[0])
.attr("cy", d => transform([d.x, d.y])[1])
.attr("r", d30/2*dotSize1)
)
dotsWeek1.selectAll(".dot1")
.data(dWeek1, d => d.id)
.join (
enter => enter.append("circle")
.classed("dot1", true)
.attr("fill", fillDot)
.attr("opacity", dotOpacity2)
.attr("cx", d => transform([d.x, d.y])[0])
.attr("cy", d => transform([d.x, d.y])[1])
.attr("r", d30/2*dotSize2)
)
dotsWeek2.selectAll(".dot2")
.data(dWeek2, d => d.id)
.join (
enter => enter.append("circle")
.classed("dot2", true)
.attr("fill", fillDot)
.attr("opacity", dotOpacity3)
.attr("cx", d => transform([d.x, d.y])[0])
.attr("cy", d => transform([d.x, d.y])[1])
.attr("r", d30/2*dotSize3)
)
}
/** @function loadData
* @param {Array.<object>} data - An array of data objects which should have the format: {year, week, lat, lon}.
* @description <b>This function is exposed as a method on the API returned from the eSvgMap function</b>.
* This method is called to load fresh data. Doesn't itself display any data.
*/
function loadData(data) {
// console.log('data loaded', data)
// The data arrives with these columns: year, week, lat, lon.
const epsg3035 = '+proj=laea +lat_0=52 +lon_0=10 +x_0=4321000 +y_0=3210000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs'
dataGridded = data.map(d => {
const xy = proj4(epsg3035,[d.lon,d.lat])
const x = xy[0]
const y = xy[1]
let gx, gy
if (aggregate) {
gx = Math.floor(x/30000) * 30000 + 15000
gy = Math.floor(y/30000) * 30000 + 15000
} else {
gx = x
gy = y
}
return {
year: d.year,
week: d.week,
x: gx,
y: gy,
id: `${gx}-${gy}`
}
})
// console.log('dataGridded', dataGridded)
}
/** @function getWeekDates
* @param {number} week - A number indicating the week of the year for which to get dates.
* @param {number} year - A number indicating the week for which to get dates.
* @description <b>This function is exposed as a method on the API returned from the eSvgMap function</b>.
* Returns the start and end dates for a particular week and year. Where no year is specified, a leap
* year is assumed.
*/
function getWeekDates (week, year) {
// Given week number and a year, return start and end dates of week.
// Where there's no year, assume a non-leap year.
// Set up arrays
const mnthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
const mnthAbbrv = mnthNames.map(d => d.substring(0,3))
const mnthLengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
if (year && year%4 === 0) mnthLengths[1] = 29
let dsum = 0
const mnthDays = mnthLengths.map(d => dsum += d)
// Return variables
let dds, dde, mms, mme
// Convert week to start day
const startDay = (week-1) * 7 + 1
const endDay = startDay + 6
for (let m=0; m<12; m++) {
if (startDay <= mnthDays[m] && !dds) {
dds = m ? startDay - mnthDays[m-1] : startDay
mms = mnthAbbrv[m]
}
if (endDay <= mnthDays[m] && !dde) {
dde = m ? endDay - mnthDays[m-1] : endDay
mme = mnthAbbrv[m]
}
}
if (mms === mme) {
return `${dds} - ${dde} ${mme}`
} else {
return `${dds} ${mms} - ${dde} ${mme}`
}
}
/** @function resize
* @param {number} width - A number indicating the width of the map in pixels.
* @param {number} height - A number indicating the height of the map in pixels.
* @description <b>This function is exposed as a method on the API returned from the eSvgMap function</b>.
* Resize the map.
*/
function resize(width, height) {
outputWidth = width
outputHeight = height
sizeSvg()
transform = getTransformation()
geoPath = getGeoPath()
d30 = transform([30000,0])[0] - transform([0,0])[0]
displayMapBackground()
console.log('Remap data')
dotsWeek0.selectAll(".dot0").remove()
dotsWeek1.selectAll(".dot1").remove()
dotsWeek2.selectAll(".dot2").remove()
mapData(currentWeek, currentYear)
}
/** @function setDisplayOpts
* @param {Object} opts - Initialisation options. The options are the color and dot size/opacity options.
* @description <b>This function is exposed as a method on the API returned from the eSvgMap function</b>.
* Set display options for the map.
*/
function setDisplayOpts(opts) {
if (opts.fillOcean) {
fillOcean = opts.fillOcean
svg.style("background-color", fillOcean)
}
if (opts.fillWorld) {
fillWorld = opts.fillWorld
boundaryWorld.selectAll("path").style("fill", fillWorld)
}
if (opts.fillEurope) {
fillEurope = opts.fillEurope
boundaryEurope.selectAll("path").style("fill", fillEurope)
}
if (opts.strokeEurope) {
strokeEurope = opts.strokeEurope
boundaryEurope.selectAll("path").style("stroke", strokeEurope)
}
if (opts.fillDot) {
fillDot = opts.fillDot
dotsWeek0.selectAll(".dot0").style("fill", fillDot)
dotsWeek1.selectAll(".dot1").style("fill", fillDot)
dotsWeek2.selectAll(".dot2").style("fill", fillDot)
}
if (opts.dotSize1) {
dotSize1 = opts.dotSize1
mapData(currentWeek, currentYear)
}
if (opts.dotSize2) {
dotSize2 = opts.dotSize2
mapData(currentWeek, currentYear)
}
if (opts.dotSize3) {
dotSize3 = opts.dotSize3
mapData(currentWeek, currentYear)
}
if (opts.dotOpacity1) {
dotOpacity1 = opts.dotOpacity1
mapData(currentWeek, currentYear)
}
if (opts.dotOpacity2) {
dotOpacity2 = opts.dotOpacity2
mapData(currentWeek, currentYear)
}
if (opts.dotOpacity3) {
dotOpacity3 = opts.dotOpacity3
mapData(currentWeek, currentYear)
}
}
/** @function showBusy
* @param {boolean} show - A boolean value to indicate whether or not to show map data loading.
* @description <b>This function is exposed as a method on the API returned from the eSvgMap function</b>.
* Allows calling application to display/hide an indicator showing the map data is loading.
*/
function showBusy(show) {
mapLoaderShowExplicit = show
mapLoader.classed('map-loader-hidden', !mapLoaderShowExplicit)
}
/**
* @typedef {Object} api
* @property {module:eSvgMap~loadData} loadData - Set the data to use for the map.
* @property {module:eSvgMap~mapData} mapData - Display a subset of the data.
* @property {module:eSvgMap~getWeekDates} getWeekDates - For a given weeka and year, return the start and end dates.
* @property {module:eSvgMap~resize} resize - Resize the map to the specified size.
* @property {module:eSvgMap~setDisplayOpts} setDisplayOpts - Set the display options for the map.
* @property {module:eSvgMap~showBusy} showBusy - Set a boolean value to indicate whether or not to show map data loading.
*/
return ({
loadData: loadData,
mapData: mapData,
getWeekDates: getWeekDates,
resize: resize,
setDisplayOpts: setDisplayOpts,
showBusy: showBusy,
})
}