Ray1ee01's picture
Add runnable ChartPipeline Space
216c0a4 verified
Raw
History Blame Contribute Delete
60.5 kB
// 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);