// Reusable legend layout and drawing helpers. (function (root) { const chartUtils = root.chartUtils; const color = chartUtils.color; const text = chartUtils.text; const legend = chartUtils.legend; const normalizeLegendItems = (items, options = {}) => Array.from(items || []).map((item, index) => { const value = item && typeof item === "object" ? (item.value == null ? item.label : item.value) : item; const label = item && typeof item === "object" ? (item.label == null ? value : item.label) : item; let itemColor = item && typeof item === "object" ? item.color : null; if (!itemColor && typeof options.color === "function") itemColor = options.color(value, index); if (!itemColor && typeof options.colorScale === "function") itemColor = options.colorScale(value); if (!itemColor && options.colorResolver) itemColor = options.colorResolver.field(value, index, options).value; if (!itemColor && options.colors && options.colors.field && options.colors.field[value]) itemColor = options.colors.field[value]; if (!itemColor) itemColor = color.palette(index, options); return { value, label: label == null ? "" : String(label), color: itemColor, index, raw: item, }; }); legend.resolveOptions = (colorsOrOptions = {}, maybeOptions) => ( maybeOptions == null ? colorsOrOptions : { ...maybeOptions, colors: colorsOrOptions } ); legend.layout = (items, colorsOrOptions = {}, maybeOptions) => { const options = legend.resolveOptions(colorsOrOptions, maybeOptions); const opts = { direction: "horizontal", maxWidth: Infinity, markerSize: 10, labelGap: 5, itemGap: 20, rowGap: 10, itemHeight: 20, itemPaddingEnd: 10, ...options, }; if (opts.shape && !opts.markerShape) opts.markerShape = opts.shape; if (opts.symbolSize != null) opts.markerSize = opts.symbolSize; if (opts.itemSpacing != null) opts.itemGap = opts.itemSpacing; if (opts.rowSpacing != null) opts.rowGap = opts.rowSpacing; const normalized = normalizeLegendItems(items, opts); const buildLayout = fontSizeValue => { const measureOpts = { ...opts, fontSize: fontSizeValue }; const rowItemHeight = opts.itemHeight || Math.max(opts.markerSize, text.fontSize(fontSizeValue) * 1.2); const placedItems = []; let cursorX = 0; let cursorY = 0; let currentRowHeight = rowItemHeight; let layoutWidth = 0; const markerWidth = opts.markerWidth || opts.markerSize; normalized.forEach(item => { const labelSize = text.measure(opts.context, item.label, measureOpts); const itemWidth = markerWidth + opts.labelGap + labelSize.width + opts.itemPaddingEnd; if ( opts.direction === "horizontal" && cursorX > 0 && cursorX + itemWidth > opts.maxWidth ) { cursorX = 0; cursorY += currentRowHeight + opts.rowGap; currentRowHeight = rowItemHeight; } placedItems.push({ ...item, x: cursorX, y: cursorY, width: itemWidth, height: rowItemHeight }); layoutWidth = Math.max(layoutWidth, cursorX + itemWidth); if (opts.direction === "vertical") { cursorY += rowItemHeight + opts.rowGap; } else { cursorX += itemWidth + opts.itemGap; } }); const rowGroups = d3.group(placedItems, item => item.y); rowGroups.forEach(rowItems => { const rowWidth = Math.max(...rowItems.map(item => item.x + item.width)); let offset = 0; if (Number.isFinite(opts.maxWidth) && opts.align === "center") offset = Math.max(0, (opts.maxWidth - rowWidth) / 2); if (Number.isFinite(opts.maxWidth) && opts.align === "right") offset = Math.max(0, opts.maxWidth - rowWidth); rowItems.forEach(item => { item.x += offset; }); }); const layoutHeight = placedItems.length ? Math.max(...placedItems.map(item => item.y + item.height)) : 0; return { items: placedItems, width: layoutWidth, height: layoutHeight }; }; let fontSizeValue = text.fontSize(opts.fontSize || 12); let layout = buildLayout(fontSizeValue); if (opts.fitToWidth && Number.isFinite(opts.maxWidth)) { const minFontSize = opts.minFontSize || 8; const fontStep = opts.fontStep || 0.5; while (layout.width > opts.maxWidth && fontSizeValue > minFontSize) { fontSizeValue -= fontStep; layout = buildLayout(fontSizeValue); } } return { ...layout, fontSize: fontSizeValue }; }; legend.wrapItems = (items, options = {}) => { const opts = { direction: "horizontal", maxWidth: Infinity, itemGap: 20, rowGap: 10, itemHeight: 20, align: "left", ...options, }; const source = Array.from(items || []); const placedItems = []; let cursorX = 0; let cursorY = 0; let currentRowHeight = opts.itemHeight; let layoutWidth = 0; source.forEach((item, index) => { const width = Number.isFinite(item.width) ? item.width : item.visualWidth || 0; const height = Number.isFinite(item.height) ? item.height : item.visualHeight || opts.itemHeight; if ( opts.direction === "horizontal" && cursorX > 0 && Number.isFinite(opts.maxWidth) && cursorX + width > opts.maxWidth ) { cursorX = 0; cursorY += currentRowHeight + opts.rowGap; currentRowHeight = opts.itemHeight; } placedItems.push({ ...item, index, x: cursorX, y: cursorY, width, height }); layoutWidth = Math.max(layoutWidth, cursorX + width); currentRowHeight = Math.max(currentRowHeight, height); if (opts.direction === "vertical") { cursorY += height + opts.rowGap; } else { cursorX += width + opts.itemGap; } }); const rows = Array.from(d3.group(placedItems, item => item.y).values()).map(rowItems => { const rowWidth = rowItems.length ? Math.max(...rowItems.map(item => item.x + item.width)) : 0; let offset = 0; if (Number.isFinite(opts.maxWidth) && opts.align === "center") offset = Math.max(0, (opts.maxWidth - rowWidth) / 2); if (Number.isFinite(opts.maxWidth) && opts.align === "right") offset = Math.max(0, opts.maxWidth - rowWidth); rowItems.forEach(item => { item.x += offset; }); return { items: rowItems, y: rowItems[0] ? rowItems[0].y : 0, width: rowWidth, height: rowItems.length ? Math.max(...rowItems.map(item => item.height)) : 0, }; }); const layoutHeight = placedItems.length ? Math.max(...placedItems.map(item => item.y + item.height)) : 0; return { items: placedItems, rows, width: layoutWidth, height: layoutHeight }; }; legend.draw = (group, items, colorsOrOptions = {}, maybeOptions) => { const options = legend.resolveOptions(colorsOrOptions, maybeOptions); const opts = { x: 0, y: 0, markerShape: "circle", markerSize: 10, labelGap: 5, itemGap: 20, rowGap: 10, itemHeight: 20, itemPaddingEnd: 10, fontSize: 12, fontFamily: null, fontWeight: "normal", textColor: "#333333", markerClass: null, markerOpacity: null, textClass: null, className: "legend", ...options, }; if (opts.shape && !opts.markerShape) opts.markerShape = opts.shape; if (opts.symbolSize != null) opts.markerSize = opts.symbolSize; if (opts.itemSpacing != null) opts.itemGap = opts.itemSpacing; if (opts.rowSpacing != null) opts.rowGap = opts.rowSpacing; const layout = legend.layout(items, { ...opts, context: group }); const legendFontSize = text.fontSize(layout.fontSize != null ? layout.fontSize : opts.fontSize); const markerWidth = opts.markerWidth || opts.markerSize; const markerHeight = opts.markerHeight || opts.markerSize; const legendGroup = group.append("g") .attr("class", opts.className) .attr("transform", `translate(${opts.x}, ${opts.y})`); const itemGroups = legendGroup.selectAll("g.legend-item") .data(layout.items) .enter() .append("g") .attr("class", "legend-item") .attr("transform", d => `translate(${d.x}, ${d.y})`); if (opts.markerShape === "capsule") { itemGroups.append("rect") .attr("class", opts.markerClass) .attr("x", 0) .attr("y", d => (d.height - markerHeight) / 2) .attr("width", markerWidth) .attr("height", markerHeight) .attr("rx", markerHeight / 2) .attr("ry", markerHeight / 2) .attr("fill", d => d.color) .attr("fill-opacity", opts.markerOpacity == null ? null : opts.markerOpacity); } else if (opts.markerShape === "rect") { const rects = itemGroups.append("rect") .attr("class", opts.markerClass) .attr("x", 0) .attr("y", d => (d.height - markerHeight) / 2) .attr("width", markerWidth) .attr("height", markerHeight) .attr("fill", d => d.color) .attr("fill-opacity", opts.markerOpacity == null ? null : opts.markerOpacity); if (opts.markerRadius) { rects.attr("rx", opts.markerRadius).attr("ry", opts.markerRadius); } } else if (opts.markerShape === "line") { itemGroups.append("line") .attr("class", opts.markerClass) .attr("x1", 0) .attr("x2", opts.markerSize) .attr("y1", d => d.height / 2) .attr("y2", d => d.height / 2) .attr("stroke", d => d.color) .attr("stroke-width", opts.strokeWidth || 3) .attr("stroke-opacity", opts.markerOpacity == null ? null : opts.markerOpacity); } else if (opts.markerShape === "triangle") { const size = Math.min(markerWidth, markerHeight); itemGroups.append("path") .attr("class", opts.markerClass) .attr("d", `M 0 ${-size / 2} L ${size / 2} ${size / 2} L ${-size / 2} ${size / 2} Z`) .attr("transform", d => `translate(${markerWidth / 2}, ${d.height / 2})`) .attr("fill", d => d.color) .attr("fill-opacity", opts.markerOpacity == null ? null : opts.markerOpacity); } else { itemGroups.append("circle") .attr("class", opts.markerClass) .attr("cx", markerWidth / 2) .attr("cy", d => d.height / 2) .attr("r", markerWidth / 2) .attr("fill", d => d.color) .attr("fill-opacity", opts.markerOpacity == null ? null : opts.markerOpacity); } itemGroups.append("text") .attr("class", opts.textClass) .attr("x", markerWidth + opts.labelGap) .attr("y", d => d.height / 2) .attr("dominant-baseline", "middle") .attr("fill", opts.textColor) .style("font-size", `${legendFontSize}px`) .style("font-weight", opts.fontWeight) .text(d => d.label); if (opts.fontFamily) { itemGroups.selectAll("text").style("font-family", opts.fontFamily); } return { ...layout, group: legendGroup }; }; legend.centered = (parent, items, colorsOrOptions, centerX, y, maybeOptions) => { const options = legend.resolveOptions(colorsOrOptions, maybeOptions); const layout = legend.layout(items, { ...options, context: parent }); return legend.draw(parent, items, options, { ...options, x: centerX - layout.width / 2, y }); }; legend.pair = (parent, entries, options = {}) => { const opts = { markerSize: 12, labelGap: 5, fontSize: 14, fontFamily: null, fontWeight: "bold", textColor: "#333333", className: "legend-pair", ...options, }; const fontSize = text.fontSize(opts.fontSize); const group = parent.append("g").attr("class", opts.className); entries.forEach(entry => { const itemG = group.append("g"); itemG.append("rect") .attr("x", entry.x) .attr("y", entry.y) .attr("width", opts.markerSize) .attr("height", opts.markerSize) .attr("fill", entry.color); const labelNode = itemG.append("text") .attr("x", entry.x + opts.markerSize + opts.labelGap) .attr("y", entry.y + opts.markerSize / 2) .attr("dy", "0.35em") .style("font-size", `${fontSize}px`) .style("font-weight", opts.fontWeight) .style("fill", opts.textColor) .text(entry.label); if (opts.fontFamily) labelNode.style("font-family", opts.fontFamily); }); return { group }; }; legend.row = (parent, items, colorsOrOptions = {}, maybeOptions) => { const options = legend.resolveOptions(colorsOrOptions, maybeOptions); const opts = { x: 0, y: 0, markerShape: "rect", markerSize: 12, labelGap: 5, itemGap: 10, fontSize: 12, fontFamily: null, fontWeight: "normal", textColor: "#333333", maxWidth: Infinity, minFontSize: 8, fontStep: 0.5, className: "legend-row", ...options, }; const normalized = normalizeLegendItems(items, opts); const rowGroup = parent.append("g") .attr("class", opts.className) .attr("transform", `translate(${opts.x}, ${opts.y})`); let fontSize = text.fontSize(opts.fontSize); const measureRow = size => { let x = 0; const widths = normalized.map(item => { const labelWidth = text.measure(rowGroup, item.label, { ...opts, fontSize: size }).width; const w = opts.markerSize + opts.labelGap + labelWidth; const startX = x; x += w + opts.itemGap; return { item, startX, width: w }; }); return { placements: widths, totalWidth: x > 0 ? x - opts.itemGap : 0 }; }; let row = measureRow(fontSize); while (row.totalWidth > opts.maxWidth && fontSize > opts.minFontSize) { fontSize -= opts.fontStep; row = measureRow(fontSize); } row.placements.forEach(({ item, startX }) => { const itemG = rowGroup.append("g").attr("transform", `translate(${startX}, 0)`); itemG.append("rect") .attr("width", opts.markerSize) .attr("height", opts.markerSize) .attr("fill", item.color); const labelNode = itemG.append("text") .attr("x", opts.markerSize + opts.labelGap) .attr("y", opts.markerSize / 2) .attr("dy", "0.35em") .style("font-size", `${fontSize}px`) .style("font-weight", opts.fontWeight) .style("fill", opts.textColor) .text(item.label); if (opts.fontFamily) labelNode.style("font-family", opts.fontFamily); }); return { group: rowGroup, width: row.totalWidth, height: opts.markerSize, fontSize }; }; legend.layoutWithIcons = (items, colorsOrOptions = {}, maybeOptions) => { const options = legend.resolveOptions(colorsOrOptions, maybeOptions); const opts = { markerSize: 12, labelGap: 6, itemGap: 12, rowGap: 6, maxWidth: Infinity, align: "center", ...options, }; const normalized = normalizeLegendItems(items, opts).map(item => { const raw = item.raw && typeof item.raw === "object" ? item.raw : {}; const imageHref = raw.imageHref == null ? raw.image : raw.imageHref; const labelWidth = text.measure(opts.context, item.label, opts).width; const visualWidth = opts.markerSize + opts.labelGap + opts.markerSize + opts.labelGap + labelWidth; return { ...item, imageHref, visualWidth }; }); const lines = []; let currentLine = []; let currentWidth = 0; normalized.forEach(item => { const gap = currentLine.length ? opts.itemGap : 0; if (currentLine.length && currentWidth + gap + item.visualWidth > opts.maxWidth) { lines.push({ items: currentLine, totalVisualWidth: currentWidth }); currentLine = [item]; currentWidth = item.visualWidth; } else { currentWidth += gap + item.visualWidth; currentLine.push(item); } }); if (currentLine.length) lines.push({ items: currentLine, totalVisualWidth: currentWidth }); const itemHeight = Math.max(opts.markerSize, text.fontSize(opts.fontSize || 12)); const height = lines.length ? lines.length * itemHeight + (lines.length - 1) * opts.rowGap : 0; return { lines, width: Math.max(...lines.map(line => line.totalVisualWidth), 0), height, itemHeight }; }; legend.drawWithIcons = (parent, items, colorsOrOptions = {}, maybeOptions) => { const options = legend.resolveOptions(colorsOrOptions, maybeOptions); const opts = { x: 0, y: 0, markerSize: 12, labelGap: 6, itemGap: 12, rowGap: 6, maxWidth: Infinity, align: "center", fontSize: 12, fontFamily: null, fontWeight: "normal", textColor: "#333333", className: "legend-icons", idPrefix: "clip-legend", ...options, }; const layout = legend.layoutWithIcons(items, { ...opts, context: parent }); const legendFontSize = text.fontSize(opts.fontSize); const container = parent.append("g") .attr("class", opts.className) .attr("transform", `translate(${opts.x}, ${opts.y})`); const defs = container.append("defs"); let lineY = 0; layout.lines.forEach((line, lineIndex) => { let offset = 0; if (opts.align === "center" && Number.isFinite(opts.maxWidth)) { offset = Math.max(0, (opts.maxWidth - line.totalVisualWidth) / 2); } let drawX = offset; const lineCenterY = lineY + layout.itemHeight / 2; const radius = opts.markerSize / 2; line.items.forEach((item, itemIndex) => { const colorGroup = container.append("g") .attr("transform", `translate(${drawX}, ${lineCenterY - radius})`); colorGroup.append("circle") .attr("cx", radius) .attr("cy", radius) .attr("r", radius) .attr("fill", item.color); const clipId = `${opts.idPrefix}-${lineIndex}-${itemIndex}`; defs.append("clipPath") .attr("id", clipId) .append("circle") .attr("cx", radius) .attr("cy", radius) .attr("r", radius - 0.5); const iconGroup = container.append("g") .attr("transform", `translate(${drawX + opts.markerSize + opts.labelGap}, ${lineCenterY - radius})`); iconGroup.append("circle") .attr("cx", radius) .attr("cy", radius) .attr("r", radius) .attr("fill", "none") .attr("stroke", "#000000") .attr("stroke-width", 0.2); if (item.imageHref) { iconGroup.append("image") .attr("x", 0) .attr("y", 0) .attr("width", opts.markerSize) .attr("height", opts.markerSize) .attr("xlink:href", item.imageHref) .attr("clip-path", `url(#${clipId})`); } else { iconGroup.append("circle") .attr("cx", radius) .attr("cy", radius) .attr("r", radius - 0.5) .attr("fill", "#ffffff"); } const labelNode = container.append("text") .attr("x", drawX + (opts.markerSize + opts.labelGap) * 2) .attr("y", lineCenterY) .attr("dominant-baseline", "middle") .style("font-size", `${legendFontSize}px`) .style("font-weight", opts.fontWeight) .style("fill", opts.textColor) .text(item.label); if (opts.fontFamily) labelNode.style("font-family", opts.fontFamily); drawX += item.visualWidth + opts.itemGap; }); lineY += layout.itemHeight + opts.rowGap; }); return { ...layout, group: container }; }; legend.sideDetailLayout = (context, items, options = {}) => { const opts = { markerRadius: 6, iconSizeRatio: 1.1, minIconSize: 10, rowPaddingRatio: 0.4, fontSize: 13, fontFamily: "Arial", fontWeight: "normal", valueGap: 10, labelGap: 10, markerToIconGap: 10, minItemHeight: 16, ...options, }; const legendFontSize = text.fontSize(opts.fontSize); const normalized = normalizeLegendItems(items, opts).map(item => { const raw = item.raw && typeof item.raw === "object" ? item.raw : {}; const valueLabel = raw.valueLabel == null ? "" : String(raw.valueLabel); const imageHref = raw.imageHref == null ? raw.image : raw.imageHref; const valueWidth = valueLabel ? text.measure(context, valueLabel, opts).width : 0; const labelWidth = text.measure(context, item.label, opts).width; return { ...item, valueLabel, imageHref, valueWidth, labelWidth }; }); const idealIconSize = Math.max(opts.minIconSize, legendFontSize * opts.iconSizeRatio); const idealItemHeight = Math.max(legendFontSize, idealIconSize) * (1 + opts.rowPaddingRatio); const availableHeight = opts.availableHeight == null ? Infinity : opts.availableHeight; const maxItemHeight = normalized.length > 0 ? availableHeight / normalized.length : idealItemHeight; const absoluteMinHeight = Math.max(opts.minItemHeight, legendFontSize + 4, opts.minIconSize + 4); let itemHeight = Math.max(absoluteMinHeight, Math.min(idealItemHeight, maxItemHeight)); let iconSize = itemHeight < idealItemHeight ? Math.max(opts.minIconSize, itemHeight * (idealIconSize / idealItemHeight)) : idealIconSize; if (!Number.isFinite(itemHeight)) itemHeight = idealItemHeight; if (!Number.isFinite(iconSize)) iconSize = idealIconSize; const maxValueWidth = normalized.length ? Math.max(...normalized.map(item => item.valueWidth), 0) : 0; const maxLabelWidth = normalized.length ? Math.max(...normalized.map(item => item.labelWidth), 0) : 0; const width = opts.markerRadius * 2 + opts.markerToIconGap + iconSize + opts.valueGap + maxValueWidth + opts.labelGap + maxLabelWidth; const height = normalized.length * itemHeight; return { items: normalized, width, height, itemHeight, iconSize, markerRadius: opts.markerRadius, maxValueWidth, }; }; legend.sideDetail = (parent, items, colorsOrOptions = {}, maybeOptions) => { const options = legend.resolveOptions(colorsOrOptions, maybeOptions); const opts = { x: 0, y: 0, className: "legend-side-detail", fontSize: 13, fontFamily: "Arial", fontWeight: "normal", textColor: "#333333", valueColor: null, markerToIconGap: 10, valueGap: 10, labelGap: 10, markerRadius: 6, ...options, }; const layout = legend.sideDetailLayout(parent, items, opts); const group = parent.append("g") .attr("class", opts.className) .attr("transform", `translate(${opts.x}, ${opts.y})`); layout.items.forEach((item, index) => { const y = index * layout.itemHeight; const centerY = y + layout.itemHeight / 2; const itemGroup = group.append("g") .attr("class", "legend-item") .attr("transform", "translate(0, 0)"); itemGroup.append("circle") .attr("cx", layout.markerRadius) .attr("cy", centerY) .attr("r", layout.markerRadius) .attr("fill", item.color); const iconX = layout.markerRadius * 2 + opts.markerToIconGap; if (item.imageHref) { itemGroup.append("image") .attr("x", iconX) .attr("y", centerY - layout.iconSize / 2) .attr("width", layout.iconSize) .attr("height", layout.iconSize) .attr("preserveAspectRatio", "xMidYMid meet") .attr("xlink:href", item.imageHref); } const valueX = iconX + layout.iconSize + opts.valueGap + layout.maxValueWidth; if (item.valueLabel) { const valueNode = itemGroup.append("text") .attr("x", valueX) .attr("y", centerY) .attr("text-anchor", "end") .attr("dominant-baseline", "middle") .style("font-size", `${text.fontSize(opts.fontSize)}px`) .style("font-weight", opts.fontWeight) .style("fill", opts.valueColor || opts.textColor) .text(item.valueLabel); if (opts.fontFamily) valueNode.style("font-family", opts.fontFamily); } const labelNode = itemGroup.append("text") .attr("x", valueX + opts.labelGap) .attr("y", centerY) .attr("text-anchor", "start") .attr("dominant-baseline", "middle") .style("font-size", `${text.fontSize(opts.fontSize)}px`) .style("font-weight", opts.fontWeight) .style("fill", opts.textColor) .text(item.label); if (opts.fontFamily) labelNode.style("font-family", opts.fontFamily); }); return { ...layout, group }; }; legend.strip = (parent, items, colorsOrOptions = {}, maybeOptions) => { const options = legend.resolveOptions(colorsOrOptions, maybeOptions); const opts = { x: 0, y: 0, width: 200, barHeight: 10, markerRadius: 3, labelGap: 10, maxFontSize: 14, minFontSize: 6, fontSize: null, fontFamily: "Arial", fontWeight: "normal", textColor: "#333333", className: "legend-strip", toUpperCase: false, trackFill: null, trackStroke: null, ...options, }; const normalized = normalizeLegendItems(items, opts).map(item => ({ ...item, label: opts.toUpperCase ? item.label.toUpperCase() : item.label, })); const group = parent.append("g") .attr("class", opts.className) .attr("transform", `translate(${opts.x}, ${opts.y})`); if (opts.trackFill || opts.trackStroke) { const track = group.append("rect") .attr("x", 0) .attr("y", 0) .attr("width", opts.width) .attr("height", opts.barHeight); if (opts.trackFill) track.attr("fill", opts.trackFill); if (opts.trackStroke) track.attr("stroke", opts.trackStroke).attr("stroke-width", 1); if (opts.markerRadius) track.attr("rx", opts.markerRadius).attr("ry", opts.markerRadius); } const itemWidth = normalized.length > 0 ? opts.width / normalized.length : opts.width; normalized.forEach((item, index) => { group.append("rect") .attr("x", index * itemWidth) .attr("y", 0) .attr("width", itemWidth) .attr("height", opts.barHeight) .attr("fill", item.color); }); let resolvedFontSize = opts.fontSize == null ? opts.maxFontSize : text.fontSize(opts.fontSize); const measure = size => normalized.reduce((acc, item) => { const width = text.measure(group, item.label, { ...opts, fontSize: size }).width; return Math.max(acc, width); }, 0); while (measure(resolvedFontSize) > itemWidth * 0.9 && resolvedFontSize > opts.minFontSize) { resolvedFontSize -= 0.5; } normalized.forEach((item, index) => { const labelNode = group.append("text") .attr("x", index * itemWidth + itemWidth / 2) .attr("y", opts.barHeight + opts.labelGap) .attr("text-anchor", "middle") .style("font-size", `${resolvedFontSize}px`) .style("font-weight", opts.fontWeight) .style("fill", opts.textColor) .text(item.label); if (opts.fontFamily) labelNode.style("font-family", opts.fontFamily); }); return { group, width: opts.width, height: opts.barHeight + opts.labelGap + resolvedFontSize, itemWidth, fontSize: resolvedFontSize }; }; legend.mixedCentered = (parent, entries, options = {}) => { const opts = { centerX: 0, y: 0, itemGap: 15, markerTextGap: 5, fontSize: 12, fontFamily: null, fontWeight: "normal", textColor: "#333333", className: "legend-mixed", ...options, }; const legendFontSize = text.fontSize(opts.fontSize); const normalized = (entries || []).map(entry => { const markerShape = entry.markerShape || "rect"; const markerSize = entry.markerSize == null ? 12 : entry.markerSize; const label = entry.label == null ? "" : String(entry.label); const markerWidth = markerShape === "circle" ? markerSize * 2 : markerSize; const labelWidth = text.measure(parent, label, { fontSize: legendFontSize, fontFamily: opts.fontFamily, fontWeight: opts.fontWeight, }).width; return { ...entry, markerShape, markerSize, markerWidth, label, itemWidth: markerWidth + opts.markerTextGap + labelWidth, }; }); const totalWidth = normalized.reduce((sum, item, index) => ( sum + item.itemWidth + (index > 0 ? opts.itemGap : 0) ), 0); const startX = opts.centerX - totalWidth / 2; const group = parent.append("g") .attr("class", opts.className) .attr("transform", `translate(${startX}, ${opts.y})`); let cursorX = 0; normalized.forEach((item, index) => { if (index > 0) cursorX += opts.itemGap; if (item.markerShape === "circle") { group.append("circle") .attr("cx", cursorX + item.markerSize) .attr("cy", 0) .attr("r", item.markerSize) .attr("fill", item.color); } else { group.append("rect") .attr("x", cursorX) .attr("y", -item.markerSize / 2) .attr("width", item.markerSize) .attr("height", item.markerSize) .attr("fill", item.color); } const textNode = group.append("text") .attr("x", cursorX + item.markerWidth + opts.markerTextGap) .attr("y", 0) .attr("dominant-baseline", "middle") .style("font-size", `${legendFontSize}px`) .style("font-weight", opts.fontWeight) .style("fill", opts.textColor) .text(item.label); if (opts.fontFamily) textNode.style("font-family", opts.fontFamily); cursorX += item.itemWidth; }); return { group, width: totalWidth }; }; legend.mirrorPair = (parent, left, right, options = {}) => { const opts = { centerX: 0, y: 0, circleRadius: 10, circleTextGap: 10, centerMargin: 10, fontSize: 12, fontFamily: null, fontWeight: "bold", textColor: "#333333", className: "legend-mirror-pair", ...options, }; const legendFontSize = text.fontSize(opts.fontSize); const group = parent.append("g") .attr("class", opts.className) .attr("transform", `translate(${opts.centerX}, ${opts.y})`); const leftTextWidth = text.measure(parent, left.label, { fontSize: legendFontSize, fontFamily: opts.fontFamily, fontWeight: opts.fontWeight, }).width; const leftTextRightEdge = -5 * opts.centerMargin; const leftTextLeftEdge = leftTextRightEdge - leftTextWidth; const leftCircleRightEdge = leftTextLeftEdge - opts.circleTextGap; const leftCircleCenter = leftCircleRightEdge - opts.circleRadius; const rightCircleLeftEdge = opts.centerMargin; const rightCircleCenter = rightCircleLeftEdge + opts.circleRadius; const rightTextLeftEdge = rightCircleCenter + opts.circleRadius + opts.circleTextGap; group.append("circle") .attr("cx", leftCircleCenter) .attr("cy", 0) .attr("r", opts.circleRadius) .attr("fill", left.color); const leftText = group.append("text") .attr("x", leftTextLeftEdge) .attr("y", 0) .attr("dy", "0.35em") .style("font-size", `${legendFontSize}px`) .style("font-weight", opts.fontWeight) .style("fill", opts.textColor) .text(left.label); if (opts.fontFamily) leftText.style("font-family", opts.fontFamily); group.append("circle") .attr("cx", rightCircleCenter) .attr("cy", 0) .attr("r", opts.circleRadius) .attr("fill", right.color); const rightText = group.append("text") .attr("x", rightTextLeftEdge) .attr("y", 0) .attr("dy", "0.35em") .style("font-size", `${legendFontSize}px`) .style("font-weight", opts.fontWeight) .style("fill", opts.textColor) .text(right.label); if (opts.fontFamily) rightText.style("font-family", opts.fontFamily); return { group }; }; legend.segmentsCentered = (parent, segments, options = {}) => { const opts = { centerX: 0, y: 0, itemGap: 5, sectionGap: 15, fontSize: 12, fontFamily: null, fontWeight: "normal", textColor: "#333333", className: "legend-segments", ...options, }; const legendFontSize = text.fontSize(opts.fontSize); const normalized = (segments || []).map(segment => { if (segment.type === "text") { const label = segment.label == null ? "" : String(segment.label); const labelWidth = text.measure(parent, label, { fontSize: legendFontSize, fontFamily: opts.fontFamily, fontWeight: opts.fontWeight, }).width; return { ...segment, width: labelWidth }; } const size = segment.size == null ? 12 : segment.size; return { ...segment, width: size }; }); const totalWidth = normalized.reduce((sum, segment, index) => { const gap = index > 0 ? (segment.type === "text" || normalized[index - 1].type === "text" ? opts.sectionGap : opts.itemGap) : 0; return sum + segment.width + gap; }, 0); const group = parent.append("g") .attr("class", opts.className) .attr("transform", `translate(${opts.centerX - totalWidth / 2}, ${opts.y})`); let cursorX = 0; normalized.forEach((segment, index) => { if (index > 0) { cursorX += (segment.type === "text" || normalized[index - 1].type === "text") ? opts.sectionGap : opts.itemGap; } if (segment.type === "text") { const textNode = group.append("text") .attr("x", cursorX) .attr("y", 0) .attr("dominant-baseline", "middle") .style("font-size", `${legendFontSize}px`) .style("font-weight", opts.fontWeight) .style("fill", opts.textColor) .text(segment.label); if (opts.fontFamily) textNode.style("font-family", opts.fontFamily); } else if (segment.type === "circle") { const radius = segment.radius == null ? segment.size / 2 : segment.radius; group.append("circle") .attr("cx", cursorX + radius) .attr("cy", 0) .attr("r", radius) .attr("fill", segment.color); } else if (segment.type === "path") { const pathNode = group.append("path") .attr("d", segment.d) .attr("fill", segment.color); if (segment.transform) pathNode.attr("transform", segment.transform); } else { group.append("rect") .attr("x", cursorX) .attr("y", -segment.size / 2) .attr("width", segment.size) .attr("height", segment.size) .attr("fill", segment.color); } cursorX += segment.width; }); return { group, width: totalWidth }; }; legend.withGradientFills = (defs, groups, colorScale, colorResolver, options = {}) => { const opts = { idPrefix: "legend-grad", ...options }; return (groups || []).map((group, index) => { const color = colorScale(group); const legendGradId = `${opts.idPrefix}-${index}`; const brighterColor = colorResolver.variant(color, { mode: "brighter", amount: 0.5 }); const darkerColor = colorResolver.variant(color, { mode: "darker", amount: 0.2 }); const legendGrad = defs.append("linearGradient") .attr("id", legendGradId) .attr("x1", "0%") .attr("y1", "0%") .attr("x2", "100%") .attr("y2", "100%"); legendGrad.append("stop").attr("offset", "0%").attr("stop-color", brighterColor.toString()); legendGrad.append("stop").attr("offset", "100%").attr("stop-color", darkerColor.toString()); return { label: group, color: `url(#${legendGradId})` }; }); }; legend.categoryValueRows = (parent, items, options = {}) => { const opts = { x: 0, y: 0, maxWidth: Infinity, markerSize: 15, labelGap: 6, itemGap: 10, rowHeight: 45, rowGap: 0, fontSize: 12, valueFontWeight: "bold", fontFamily: null, fontWeight: "normal", textColor: "#333333", markerRadius: 0, className: "legend-category-value", ...options, }; const legendFontSize = text.fontSize(opts.fontSize); const normalized = (items || []).map((item, index) => { const label = item.label == null ? "" : String(item.label); const valueLabel = item.valueLabel == null ? "" : String(item.valueLabel); const labelWidth = text.measure(parent, label, { fontSize: legendFontSize, fontFamily: opts.fontFamily, fontWeight: opts.fontWeight, }).width; const valueWidth = text.measure(parent, valueLabel, { fontSize: legendFontSize, fontFamily: opts.fontFamily, fontWeight: opts.valueFontWeight, }).width; const contentWidth = opts.markerSize + opts.labelGap + Math.max(labelWidth, valueWidth); let color = item.color; if (!color && typeof opts.color === "function") color = opts.color(item.raw, index); if (!color && opts.colorResolver) color = opts.colorResolver.field(item.label, index, opts).value; return { ...item, label, valueLabel, color, width: contentWidth + opts.itemGap, }; }); const rows = []; let currentRow = []; let currentRowWidth = 0; normalized.forEach(item => { if (currentRow.length > 0 && currentRowWidth + item.width > opts.maxWidth) { rows.push({ items: currentRow, width: currentRowWidth - opts.itemGap }); currentRow = [item]; currentRowWidth = item.width; } else { currentRow.push(item); currentRowWidth += item.width; } }); if (currentRow.length) rows.push({ items: currentRow, width: currentRowWidth - opts.itemGap }); const group = parent.append("g") .attr("class", opts.className) .attr("transform", `translate(${opts.x}, ${opts.y})`); rows.forEach((row, rowIndex) => { const rowY = rowIndex * (opts.rowHeight + opts.rowGap); const startX = opts.maxWidth < Infinity ? Math.max(0, (opts.maxWidth - row.width) / 2) : 0; let cursorX = startX; row.items.forEach(item => { const itemG = group.append("g") .attr("class", "legend-item") .attr("transform", `translate(${cursorX}, ${rowY})`); const rect = itemG.append("rect") .attr("width", opts.markerSize) .attr("height", opts.markerSize) .attr("fill", item.color); if (opts.markerRadius) rect.attr("rx", opts.markerRadius).attr("ry", opts.markerRadius); const labelNode = itemG.append("text") .attr("x", opts.markerSize + opts.labelGap) .attr("y", opts.markerSize / 2) .attr("dy", "0.32em") .style("font-size", `${legendFontSize}px`) .style("font-weight", opts.fontWeight) .style("fill", opts.textColor) .text(item.label); if (opts.fontFamily) labelNode.style("font-family", opts.fontFamily); const valueNode = itemG.append("text") .attr("x", opts.markerSize + opts.labelGap) .attr("y", opts.markerSize * 1.7) .attr("dy", "0.32em") .style("font-size", `${legendFontSize}px`) .style("font-weight", opts.valueFontWeight) .style("fill", opts.textColor) .text(item.valueLabel); if (opts.fontFamily) valueNode.style("font-family", opts.fontFamily); cursorX += item.width; }); }); const height = rows.length ? rows.length * opts.rowHeight + (rows.length - 1) * opts.rowGap : 0; return { group, height, rows }; }; legend.labeledStrip = (parent, items, options = {}) => { const opts = { y: 0, centerWidth: null, barHeight: 15, labelPaddingTop: 8, itemWidth: null, totalWidth: null, numberFontSize: 16, numberFontFamily: null, numberFontWeight: "bold", labelFontSize: 12, labelFontFamily: null, labelFontWeight: "normal", textColor: "#333333", numberColor: null, numberLeftPadding: 3, numberRightPadding: 5, showNumber: false, stroke: "#555", strokeWidth: 0.5, className: "legend-labeled-strip", wrapLabel: null, ...options, }; const normalized = (items || []).map((item, index) => ({ label: item.label == null ? String(item.value != null ? item.value : index) : String(item.label), fill: item.fill || item.color, number: item.number != null ? item.number : (opts.showNumber ? index + 1 : null), })); const count = normalized.length; const stripWidth = opts.totalWidth || (opts.itemWidth ? opts.itemWidth * count : opts.centerWidth); const itemWidth = opts.itemWidth || (count ? stripWidth / count : 0); const totalWidth = itemWidth * count; const startX = opts.centerWidth != null ? (opts.centerWidth - totalWidth) / 2 : 0; const group = parent.append("g") .attr("class", opts.className) .attr("transform", `translate(${startX}, ${opts.y})`); const numberColor = opts.numberColor || opts.textColor; const labelFontSize = text.fontSize(opts.labelFontSize); const numberFontSize = text.fontSize(opts.numberFontSize); const numberWidth = opts.showNumber ? text.measure(parent, "8", { fontSize: numberFontSize, fontFamily: opts.numberFontFamily, fontWeight: opts.numberFontWeight, }).width : 0; normalized.forEach((item, index) => { const x = index * itemWidth; const bar = group.append("rect") .attr("x", x) .attr("y", 0) .attr("width", itemWidth) .attr("height", opts.barHeight) .attr("fill", item.fill); if (opts.stroke) bar.attr("stroke", opts.stroke).attr("stroke-width", opts.strokeWidth); const labelY = opts.barHeight + opts.labelPaddingTop; const groupNameX = opts.showNumber ? x + opts.numberLeftPadding + numberWidth + opts.numberRightPadding : x + opts.numberLeftPadding; const labelAvailableWidth = Math.max( 10, itemWidth - opts.numberLeftPadding - (opts.showNumber ? numberWidth + opts.numberRightPadding + opts.numberLeftPadding : opts.numberLeftPadding) ); if (opts.showNumber) { const numberNode = group.append("text") .attr("x", x + opts.numberLeftPadding) .attr("y", labelY) .attr("text-anchor", "start") .attr("dominant-baseline", "hanging") .style("font-size", `${numberFontSize}px`) .style("font-weight", opts.numberFontWeight) .style("fill", numberColor) .text(item.number); if (opts.numberFontFamily) numberNode.style("font-family", opts.numberFontFamily); } const labelNode = group.append("text") .attr("x", groupNameX) .attr("y", labelY) .attr("dominant-baseline", "hanging") .style("font-size", `${labelFontSize}px`) .style("font-weight", opts.labelFontWeight) .style("fill", opts.textColor); if (opts.labelFontFamily) labelNode.style("font-family", opts.labelFontFamily); if (opts.wrapLabel) { opts.wrapLabel(labelNode, item.label, labelAvailableWidth); } else { labelNode.text(item.label); } }); return { group, width: totalWidth, height: opts.barHeight + opts.labelPaddingTop + labelFontSize }; }; legend.inlineTags = (parent, items, colorsOrOptions = {}, maybeOptions) => { const options = legend.resolveOptions(colorsOrOptions, maybeOptions); const opts = { x: 0, y: 0, maxWidth: Infinity, itemHeight: 20, itemGap: 10, rowGap: 5, itemPadding: 10, fontSize: 12, fontFamily: null, fontWeight: "normal", textColor: "#333333", className: "legend-inline-tags", ...options, }; const normalized = normalizeLegendItems(items, opts).map(item => { const labelWidth = text.measure(parent, item.label, { fontSize: opts.itemHeight, fontFamily: opts.fontFamily, fontWeight: opts.fontWeight, }).width; return { ...item, width: labelWidth + opts.itemPadding * 2 }; }); const group = parent.append("g") .attr("class", opts.className) .attr("transform", `translate(${opts.x}, ${opts.y})`); let currentX = 0; let currentY = 0; const rowHeight = opts.itemHeight + opts.rowGap; normalized.forEach((item, index) => { const gap = index > 0 && currentX > 0 ? opts.itemGap : 0; if (currentX > 0 && currentX + gap + item.width > opts.maxWidth) { currentX = 0; currentY += rowHeight; } if (currentX > 0) currentX += opts.itemGap; group.append("rect") .attr("x", currentX) .attr("y", currentY) .attr("width", item.width) .attr("height", opts.itemHeight) .attr("fill", item.color); const labelNode = group.append("text") .attr("x", currentX + item.width / 2) .attr("y", currentY + opts.itemHeight / 2) .attr("text-anchor", "middle") .attr("dominant-baseline", "middle") .style("font-size", `${text.fontSize(opts.fontSize)}px`) .style("font-weight", opts.fontWeight) .style("fill", opts.textColor) .text(item.label); if (opts.fontFamily) labelNode.style("font-family", opts.fontFamily); currentX += item.width; }); return { group, height: currentY + opts.itemHeight }; }; legend.stepLineCentered = (parent, options = {}) => { const opts = { centerX: 0, y: 60, fieldName: "", areaColor: "#333333", upColor: "#469377", downColor: "#c63310", changeLabel: "% change", circleRadius: 8, fontSize: 14, textColor: "#333333", className: "legend-step-line", ...options, }; const fieldNameWidth = text.measure(parent, opts.fieldName, { fontSize: opts.fontSize }).width; const triangleSize = 18; const triangleHeight = triangleSize * Math.sqrt(3) / 2; const triangleSpacing = -4; const triangleStartX = 30 + fieldNameWidth; const changeTextWidth = text.measure(parent, opts.changeLabel, { fontSize: opts.fontSize }).width; const downTriangleX = triangleStartX + triangleSize + triangleSpacing; const totalLegendWidth = downTriangleX + triangleSize / 2 + 10 + changeTextWidth; const group = parent.append("g") .attr("class", opts.className) .attr("transform", `translate(${opts.centerX - totalLegendWidth / 2}, ${opts.y})`); group.append("circle") .attr("cx", 0) .attr("cy", 0) .attr("r", opts.circleRadius) .attr("fill", opts.areaColor); group.append("text") .attr("x", 15) .attr("y", 0) .attr("dominant-baseline", "middle") .attr("fill", opts.textColor) .style("font-size", `${opts.fontSize}px`) .text(opts.fieldName); const upTop = [triangleStartX, -triangleHeight / 2]; const upBottomLeft = [triangleStartX - triangleSize / 2, triangleHeight / 2]; const upBottomRight = [triangleStartX + triangleSize / 2, triangleHeight / 2]; group.append("path") .attr("d", `M ${upBottomLeft[0]},${upBottomLeft[1]} L ${upBottomRight[0]},${upBottomRight[1]} L ${upTop[0]},${upTop[1]} Z`) .attr("fill", opts.upColor); const downBottom = [downTriangleX, triangleHeight / 2]; const downTopLeft = [downTriangleX - triangleSize / 2, -triangleHeight / 2]; const downTopRight = [downTriangleX + triangleSize / 2, -triangleHeight / 2]; group.append("path") .attr("d", `M ${downTopLeft[0]},${downTopLeft[1]} L ${downTopRight[0]},${downTopRight[1]} L ${downBottom[0]},${downBottom[1]} Z`) .attr("fill", opts.downColor); group.append("text") .attr("x", downTriangleX + triangleSize / 2 + 10) .attr("y", 0) .attr("dominant-baseline", "middle") .attr("fill", opts.textColor) .style("font-size", `${opts.fontSize}px`) .text(opts.changeLabel); return { group, width: totalLegendWidth }; }; legend.boxedVertical = (parent, items, colorsOrOptions = {}, maybeOptions) => { const options = legend.resolveOptions(colorsOrOptions, maybeOptions); const opts = { x: 0, y: 0, padding: 10, itemHeight: 20, markerSize: 15, labelGap: 5, fontSize: 12, fontFamily: null, fontWeight: "normal", textColor: "#333333", backgroundFill: "white", backgroundStroke: "#ccc", backgroundOpacity: 0.8, className: "legend-boxed-vertical", ...options, }; const normalized = normalizeLegendItems(items, opts); const legendFontSize = text.fontSize(opts.fontSize); const maxTextWidth = normalized.length ? Math.max(...normalized.map(item => text.measure(parent, item.label, { fontSize: legendFontSize, fontFamily: opts.fontFamily, fontWeight: opts.fontWeight, }).width), 0) : 0; const boxWidth = maxTextWidth + opts.markerSize + opts.labelGap + opts.padding * 2; const boxHeight = normalized.length * opts.itemHeight + opts.padding * 2; const group = parent.append("g") .attr("class", opts.className) .attr("transform", `translate(${opts.x}, ${opts.y})`); group.append("rect") .attr("x", -opts.padding) .attr("y", -opts.padding) .attr("width", boxWidth + opts.padding * 2) .attr("height", boxHeight) .attr("fill", opts.backgroundFill) .attr("stroke", opts.backgroundStroke) .attr("opacity", opts.backgroundOpacity); normalized.forEach((item, index) => { const itemY = index * opts.itemHeight + opts.padding; const itemG = group.append("g").attr("transform", `translate(${opts.padding}, ${itemY})`); itemG.append("rect") .attr("width", opts.markerSize) .attr("height", opts.markerSize) .attr("fill", item.color); const labelNode = itemG.append("text") .attr("x", opts.markerSize + opts.labelGap) .attr("y", opts.markerSize / 2) .attr("dy", "0.35em") .style("font-size", `${legendFontSize}px`) .style("font-weight", opts.fontWeight) .style("fill", opts.textColor) .text(item.label); if (opts.fontFamily) labelNode.style("font-family", opts.fontFamily); }); return { group, width: boxWidth + opts.padding * 2, height: boxHeight }; }; legend.semicircleRow = (parent, items, options = {}) => { const opts = { x: 0, y: 0, radius: 30, itemWidth: 120, centerY: 30, fontSize: 14, className: "legend-semicircle-row", ...options, }; const group = parent.append("g") .attr("class", opts.className) .attr("transform", `translate(${opts.x}, ${opts.y})`); const semicircle = d3.arc() .innerRadius(0) .outerRadius(opts.radius) .startAngle(-Math.PI / 2) .endAngle(Math.PI / 2); (items || []).forEach((item, index) => { const itemG = group.append("g").attr("transform", `translate(${index * opts.itemWidth}, 0)`); itemG.append("path") .attr("d", semicircle) .attr("transform", `translate(${opts.radius}, ${opts.centerY})`) .attr("fill", item.color); itemG.append("text") .attr("x", opts.radius) .attr("y", -12) .attr("text-anchor", "middle") .attr("fill", item.color) .attr("font-weight", "bold") .style("font-size", `${opts.fontSize}px`) .text(item.label); if (item.imageHref) { const iconSize = opts.radius * 1.5; itemG.append("image") .attr("x", opts.radius - iconSize / 2) .attr("y", opts.centerY - iconSize / 2) .attr("width", iconSize) .attr("height", iconSize) .attr("preserveAspectRatio", "xMidYMid meet") .attr("xlink:href", item.imageHref); } }); return { group, width: (items || []).length * opts.itemWidth }; }; })(globalThis);