/** @module svgLegend */
import * as d3 from 'd3'
/**
* @typedef module:svgLegend.legendOpts
* @type {Object}
* @property {boolean} display - indicates whether or not a legend is to be drawn.
* @property {number} scale - a number between 0 and 1 which scales the size of the legend.
* @property {number} x - an offset of the top-left corner of the legend from the left margin of the SVG.
* @property {number} y - an offset of the top-left corner of the legend from the top margin of the SVG.
* @property {number} width - can be used to specify a width (for leaflet legend).
* @property {number} height - can be used to specify a height (for leaflet legend).
* @property {legendDefintion} data - a legend defition.
*/
/**
* @typedef {Object} legendDefintion
* @property {string} title - a title caption for the legend.
* @property {string} colour - a colour for the legend symbols which can be hex format, e.g. #FFA500,
* RGB format, e.g. rgb(100, 255, 0) or a named colour, e.g. red. (Can be overriden by individual legend lines.)
* @property {string} colour2 - second colour for legend symbols of bullseye shape which can be hex format, e.g. #FFA500,
* RGB format, e.g. rgb(100, 255, 0) or a named colour, e.g. red. (Can be overriden by individual legend lines.)
* @property {string} stroke - a colour for the border of the legend symbols which can be hex format, e.g. #FFA500,
* RGB format, e.g. rgb(100, 255, 0) or a named colour, e.g. red. (Can be overriden by individual legend lines.)
* If not specified, no border is drawn.
* @property {string} shape - describes symbol shapes for the legend.
* Valid values are: circle, bullseye, square, diamond, triangle-up, triangle-down. (Can be overriden by individual legend lines.)
* @property {number} size - a number between 0 and 1.
* This can be used to scale the size of the legend dots. (Can be overriden by individual legend lines.)
* @property {number} opacity - a number between 0 and 1 indicating the opacity of the legend symbols for the whole legend. 0 is completely
* transparent and 1 is completely opaque. (Can be overriden by individual legend lines.)
* @property {number} padding - a number that indicates the padding, in pixels, that should be used between the elements
* of a legend line (e.g. the symbol and the text).
* @property {boolean[]} raligned - an array of boolean values to indicate whether text elements in a tabulated legend
* lines should be right-aligned.
* @property {legendLine[]} lines - an arry of objects representing lines in a legend.
*/
/**
* @typedef {Object} legendLine
* @property {string} colour - a colour for the legend symbol which can be hex format, e.g. #FFA500,
* RGB format, e.g. rgb(100, 255, 0) or a named colour, e.g. red. Overrides any value set for the whole legend.
* @property {string} colour2 - second colour for legend symbols of bullseye symbol which can be hex format, e.g. #FFA500,
* RGB format, e.g. rgb(100, 255, 0) or a named colour, e.g. red. Overrides any value set for the whole legend.
* @property {string} stroke - a colour for the border of the legend symbol which can be hex format, e.g. #FFA500,
* RGB format, e.g. rgb(100, 255, 0) or a named colour, e.g. red. Overrides any value set for the whole legend.
* If not specified, no border is drawn.
* @property {string} shape - describes symbol shape for the legend line.
* Valid values are: circle, bullseye, square, diamond, triangle-up, triangle-down. Overrides any value set for the whole legend.
* @property {number} size - a number between 0 and 1.
* This can be used to scale the size of the legend dots. Overrides any value set for the whole legend.
* @property {number} opacity - a number between 0 and 1 indicating the opacity of the legend symbol. 0 is completely
* transparent and 1 is completely opaque. Overrides any value set for the whole legend.
* @property {boolean} underline - If set to true, indicates that the legend line is to be underlined.
* @property {string|string[]} text - Specifies the text for the legend line either as a single text string or an
* array of strings for a tabulated legend layout. For tabulated legend layout, one of the strings can be set
* to the special value of 'symbol' to indicate the position where the legend symbol should be generated in the
* tabualted layout. In a tabulated legend layout, the various array elements in each line are aligned with those
* in the other lines to form columns. You can use the HTML tags '<i></i>' and <b></b>' to italicise and bolden text
* in the legend lines.
* @property {string} key - a string key to match legend items to map dots. Only required if legend interactivity is required. If used
* then each element in the data for the map must have a property called 'legend-key' which is used to match against this value.
*/
export function svgLegend(svg, legendOpts, legendFontSize, legendFont, legendInteractivity, highlightClass) {
const legendData = legendOpts.data ? legendOpts.data : legendOpts.accessorData
const legendX = legendOpts.x ? legendOpts.x : 0
const legendY = legendOpts.y ? legendOpts.y : 0
const legendScale = legendOpts.scale ? legendOpts.scale : 1
const lineHeight = 20
const swatchPixels = lineHeight / 3
legendData.padding = legendData.padding ? legendData.padding : lineHeight / 3
legendData.raligned = legendData.raligned ? legendData.raligned : []
legendData.size = legendData.size ? legendData.size : 1
legendData.opacity = legendData.opacity ? legendData.opacity : 1
legendData.shape = legendData.shape ? legendData.shape : 'circle'
const gLegend = svg.append('g').attr('id','legend')
const parseText = (text) => {
let legText = text
legText = legText.replaceAll('<i>', '<tspan style="font-style: italic">' )
legText = legText.replaceAll('</i>', '</tspan>' )
legText = legText.replaceAll('<b>', '<tspan style="font-weight: bold">' )
legText = legText.replaceAll('</b>', '</tspan>' )
return legText
}
let iUnderlinePad = 0
let iOffset
if (legendData.title) {
gLegend.append('text')
.classed('svg-map-legend-title', true)
.attr('x', 0)
.attr('y', lineHeight)
.attr('font-weight', 'bold')
.style('font-size', legendFontSize)
.style('font-family', legendFont)
.text(legendData.title)
iOffset = 0
} else {
iOffset = 1
}
// If legend line text is not an array, turn into one
// Also add textWidths array
legendData.lines.forEach(l => {
if (!Array.isArray(l.text)) {
l.text = ['symbol', String(l.text)]
} else {
// Coerce all text elements to strings
l.text = l.text.map(t => String(t))
}
l.textWidth=[]
})
// Set nCells to the max number of elements in line text arrays
const nCells = legendData.lines.reduce((a, l) => l.text.length > a ? l.text.length : a, 0)
const maxWidths = Array(nCells).fill(0)
// Calculate the max width of each legend table column.
// Also add the calculated width of each text item to the legend line
// array for use in right justifying if required.
for (let i = 0; i < nCells; i++) {
legendData.lines.forEach(l => {
if (l.text[i]) {
let iLength
if (l.text[i] === 'symbol') {
iLength = swatchPixels * 2
} else {
// Generate a temporary SVG text object in order to get width
const t = gLegend
.append('text')
.classed('svg-map-legend-text', true)
.style('font-size', legendFontSize)
.style('font-family', legendFont)
.html(parseText(l.text[i]))
iLength = t.node().getBBox().width
t.remove()
l.textWidth[i] = iLength
}
maxWidths[i] = maxWidths[i] > iLength ? maxWidths[i] : iLength
}
})
}
// Set offsets
const offsets = Array(nCells)
for (let i = 0; i < offsets.length; i++) {
offsets[i] = 0
for (let j = 1; j <= i; j++) {
offsets[i] = offsets[i] + maxWidths[j-1] + legendData.padding
}
}
//console.log('max text lengths', maxWidths)
//console.log('offsets', offsets)
legendData.lines.forEach((l, iLine) => {
const y = iLine - iOffset
let shape = l.shape ? l.shape : legendData.shape
let size = l.size ? l.size : legendData.size
let opacity = l.opacity ? l.opacity : legendData.opacity
let colour = l.colour ? l.colour : legendData.colour
let colour2 = l.colour2 ? l.colour2 : legendData.colour2
let stroke = l.stroke ? l.stroke : legendData.stroke ? legendData.stroke : null
let dot
for (let i = 0; i < nCells; i++) {
if (l.text[i]) {
if (l.text[i] === 'symbol') {
if (shape === 'circle') {
dot = gLegend.append('circle')
.attr("r", swatchPixels * size)
//.attr("cx", swatchPixels * 1)
.attr("cx", offsets[i] + swatchPixels)
.attr("cy", lineHeight * (y + 2.5) - swatchPixels + iUnderlinePad)
.attr('clip-path', 'circle()')
} else if (shape === 'bullseye') {
dot = gLegend.append('circle')
.attr("r", swatchPixels * size)
//.attr("cx", swatchPixels * 1)
.attr("cx", offsets[i] + swatchPixels)
.attr("cy", lineHeight * (y + 2.5) - swatchPixels + iUnderlinePad)
gLegend.append('circle')
.attr("r", swatchPixels * size * 0.5)
//.attr("cx", swatchPixels * 1)
.attr("cx", offsets[i] + swatchPixels)
.attr("cy", lineHeight * (y + 2.5) - swatchPixels + iUnderlinePad)
.style('fill', colour2)
.style('fill-opacity', opacity)
} else if (shape === 'square') {
dot = gLegend.append('rect')
.attr ("width", swatchPixels * 2 * size)
.attr ("height", swatchPixels * 2 * size)
//.attr("x", swatchPixels * (1 - size))
.attr("x", offsets[i] + swatchPixels * (1 - size))
.attr("y", lineHeight * (y + 2.5) - 2 * swatchPixels + swatchPixels * (1 - size) + iUnderlinePad)
} else if (shape === 'diamond') {
dot = gLegend.append('path')
.attr("d", d3.symbol().type(d3.symbolSquare).size(swatchPixels * swatchPixels * 2 * size))
//.attr("transform", `translate(${swatchPixels * 1},${lineHeight * (y + 2.5) - swatchPixels}) rotate(45)`)
.attr("transform", `translate(${offsets[i] + swatchPixels},${lineHeight * (y + 2.5) - swatchPixels}) rotate(45)`)
} else if (shape === 'triangle-up') {
dot = gLegend.append('path')
.attr("d", d3.symbol().type(d3.symbolTriangle).size(swatchPixels * swatchPixels * 1.7 * size))
//.attr("transform", `translate(${swatchPixels * 1},${lineHeight * (y + 2.5) - swatchPixels})`)
.attr("transform", `translate(${offsets[i] + swatchPixels},${lineHeight * (y + 2.5) - swatchPixels})`)
} else if (shape === 'triangle-down') {
dot = gLegend.append('path')
.attr("d", d3.symbol().type(d3.symbolTriangle).size(swatchPixels * swatchPixels * 1.7 * size))
//.attr("transform", `translate(${swatchPixels * 1},${lineHeight * (y + 2.5) - swatchPixels}) rotate(180)`)
.attr("transform", `translate(${offsets[i] + swatchPixels},${lineHeight * (y + 2.5) - swatchPixels}) rotate(180)`)
}
dot.style('fill', colour).style('fill-opacity', opacity).style('stroke', stroke)
dot.attr('data-fill', colour).attr('data-fill-opacity', opacity).attr('data-stroke', stroke)
dot.attr('class', l.key ? `legend-swatch legend-swatch-${l.key}` : 'legend-swatch')
dot.attr('data-key', l.key ? l.key : '')
dot.attr('cursor', l.key ? 'pointer' : '')
} else {
//const y = iLine - iOffset
//const alignOffset = legendData.raligned[i] ? maxWidths[i] - l.textWidth[i] : 0
const alignOffset = legendData.raligned[i] ? maxWidths[i] : 0
gLegend.append('text')
.attr('class', l.key ? `svg-map-legend-text svg-map-legend-text-${l.key}` : 'svg-map-legend-text')
.style('text-anchor', legendData.raligned[i] ? 'end' : 'start')
.style('font-size', legendFontSize)
.style('font-family', legendFont)
.attr('x', offsets[i] + alignOffset)
.attr('y', lineHeight * (y + 2.5) - lineHeight/20 + iUnderlinePad)
.attr('data-key', l.key ? l.key : '')
.attr('cursor', l.key ? 'pointer' : '')
.html(parseText(l.text[i]))
}
}
}
if (l.underline) {
iUnderlinePad = iUnderlinePad + 3
gLegend.append('rect')
.attr("x", 0)
.attr("y", lineHeight * (y + 2.5) + iUnderlinePad)
.attr("width", offsets[nCells-1] + maxWidths[nCells-1])
.attr("height", 1)
.attr("style", "fill:black")
}
})
gLegend.attr("transform", `translate(${legendX},${legendY}) scale(${legendScale}, ${legendScale})`)
// Sort out the legend interactivity
gLegend.selectAll('.legend-swatch, .svg-map-legend-text')
.on("mouseover", function() {
if (legendInteractivity === 'mousemove') {
legendHighlight(d3.select(this).attr('data-key'))
}
})
.on("mouseout", function() {
if (legendInteractivity === 'mousemove') {
legendHighlight()
}
})
.on("click", function() {
if (legendInteractivity === 'mouseclick') {
legendHighlight(d3.select(this).attr('data-key'))
d3.event.stopPropagation()
}
})
svg.on("click", function() {
legendHighlight()
})
function legendHighlight(key) {
if (key) {
gLegend.selectAll('.legend-swatch').classed(highlightClass, false)
gLegend.selectAll('.legend-swatch').classed(`${highlightClass}-low`, true)
gLegend.selectAll(`.legend-swatch-${key}`).classed(`${highlightClass}-low`, false)
gLegend.selectAll(`.legend-swatch-${key}`).classed(highlightClass, true)
gLegend.selectAll('.svg-map-legend-text').classed(`${highlightClass}-text`, false)
gLegend.selectAll('.svg-map-legend-text').classed(`${highlightClass}-text-low`, true)
gLegend.selectAll(`.svg-map-legend-text-${key}`).classed(`${highlightClass}-text-low`, false)
gLegend.selectAll(`.svg-map-legend-text-${key}`).classed(`${highlightClass}-text`, true)
svg.selectAll('.dot').classed(highlightClass, false)
svg.selectAll('.dot').classed(`${highlightClass}-low`, true)
svg.selectAll(`.legend-key-${key}`).classed(`${highlightClass}-low`, false)
svg.selectAll(`.legend-key-${key}`).classed(highlightClass, true)
} else {
gLegend.selectAll('.legend-swatch').classed(highlightClass, false)
gLegend.selectAll('.legend-swatch').classed(`${highlightClass}-low`, false)
gLegend.selectAll('.svg-map-legend-text').classed(`${highlightClass}-text`, false)
gLegend.selectAll('.svg-map-legend-text').classed(`${highlightClass}-text-low`, false)
svg.selectAll(`.dot`).classed(`${highlightClass}-low`, false)
svg.selectAll(`.dot`).classed(highlightClass, false)
}
}
}