Spaces:
Sleeping
Sleeping
| // 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); | |