// Initializes the shared chartUtils namespace used by all D3 templates. (function (root) { const chartUtils = root.chartUtils || {}; chartUtils.schema = chartUtils.schema || {}; chartUtils.format = chartUtils.format || {}; chartUtils.color = chartUtils.color || {}; chartUtils.text = chartUtils.text || {}; chartUtils.legend = chartUtils.legend || {}; chartUtils.random = chartUtils.random || {}; root.chartUtils = chartUtils; })(globalThis); // Deterministic pseudo-random helpers for stable D3 renders. (function (root) { const chartUtils = root.chartUtils; const random = chartUtils.random; random.hashString = value => { let hash = 2166136261; const text = String(value == null ? "" : value); for (let i = 0; i < text.length; i++) { hash ^= text.charCodeAt(i); hash = Math.imul(hash, 16777619); } return hash >>> 0; }; random.seed = (jsonData, salt = "") => { const data = jsonData && jsonData.data ? jsonData.data : {}; const columns = Array.isArray(data.columns) ? data.columns : []; const rows = Array.isArray(data.data) ? data.data : []; return random.hashString([ salt, jsonData && jsonData.name, jsonData && jsonData.chart_name, rows.length, JSON.stringify(columns.map(column => [ column && column.name, column && column.role, column && column.data_type, ])), ].join("|")); }; random.generator = (jsonDataOrSeed, salt = "") => { let state = typeof jsonDataOrSeed === "number" ? jsonDataOrSeed >>> 0 : random.seed(jsonDataOrSeed, salt); return () => { state = (state + 0x6D2B79F5) >>> 0; let t = state; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; }; random.value = (jsonData, salt = "") => random.generator(jsonData, salt)(); random.id = (jsonData, salt = "") => random.seed(jsonData, salt).toString(36); })(globalThis); // Data schema and channel helpers. (function (root) { const chartUtils = root.chartUtils; const schema = chartUtils.schema; schema.normalizeUnit = unit => { if (unit == null) return ""; const text = String(unit).trim(); const lower = text.toLowerCase(); return ["", "none", "null", "nan", "undefined", "n/a", "na"].includes(lower) ? "" : text; }; schema.columns = jsonData => { const columns = jsonData && jsonData.data && jsonData.data.columns; return Array.isArray(columns) ? columns : []; }; const buildChannel = (column, role, index, fallbackKey) => { const key = column && column.name != null ? column.name : fallbackKey; const rawLabel = column ? (column.label || column.display_name || column.name || fallbackKey || "") : (fallbackKey || ""); const unit = schema.normalizeUnit(column && column.unit); const description = column && column.description ? column.description : rawLabel; const displayName = column && column.display_name ? column.display_name : rawLabel; return { role: column && column.role != null ? column.role : role, index: index == null ? -1 : index, key: key || "", type: column && column.data_type ? column.data_type : "", unit, rawLabel, description, displayName, label: unit ? `${rawLabel} (${unit})` : rawLabel, raw: column || null, }; }; schema.channel = (jsonData, role, options = {}) => { const columns = schema.columns(jsonData); let index = columns.findIndex(column => column.role === role); if (index < 0 && options.fallbackIndex != null) { index = options.fallbackIndex; } const column = index >= 0 ? columns[index] : null; const fallbackKey = Object.prototype.hasOwnProperty.call(options, "fallbackKey") ? options.fallbackKey : role; return buildChannel(column, role, index, fallbackKey); }; schema.channelByKey = (jsonData, key, options = {}) => { const columns = schema.columns(jsonData); let index = columns.findIndex(column => column.name === key); if (index < 0 && options.fallbackIndex != null) { index = options.fallbackIndex; } const column = index >= 0 ? columns[index] : null; const fallbackKey = Object.prototype.hasOwnProperty.call(options, "fallbackKey") ? options.fallbackKey : key; return buildChannel(column, options.role || (column && column.role) || "", index, fallbackKey); }; schema.column = (columns, index, options = {}) => { const list = Array.isArray(columns) ? columns : []; const column = index >= 0 ? list[index] : null; return buildChannel(column, options.role || (column && column.role) || "", index, options.fallbackKey); }; schema.columnField = (columns, index, fallbackKey = "", options = {}) => ( schema.column(columns, index, { ...options, fallbackKey }).key ); schema.field = (jsonData, index, fallbackKey = "", options = {}) => ( schema.columnField(schema.columns(jsonData), index, fallbackKey, options) ); schema.channels = (jsonData, spec) => { const result = {}; Object.keys(spec || {}).forEach(role => { result[role] = schema.channel(jsonData, role, spec[role]); }); return result; }; })(globalThis); // Number, unit, percent, and date formatting helpers. (function (root) { const chartUtils = root.chartUtils; const schema = chartUtils.schema; const format = chartUtils.format; const formatted = ({ text, valueText, unitText = "", scaleText = "", rawValue }) => ({ text, valueText, unitText, scaleText, displayUnit: `${scaleText}${unitText}`, rawValue, }); format.appendUnit = (valueText, unit, options = {}) => { const unitText = schema.normalizeUnit(unit); if (!unitText) return String(valueText); return options.spaceBeforeUnit ? `${valueText} ${unitText}` : `${valueText}${unitText}`; }; format.number = (value, options = {}) => { const numericValue = +value; const unitText = schema.normalizeUnit(options.unit); if (!Number.isFinite(numericValue)) { const valueText = value == null ? "" : String(value); return formatted({ text: format.appendUnit(valueText, unitText, options), valueText, unitText, rawValue: value, }); } const formatter = options.format ? d3.format(options.format) : d3.format("~g"); const valueText = formatter(numericValue); return formatted({ text: format.appendUnit(valueText, unitText, options), valueText, unitText, rawValue: value, }); }; format.compactNumber = (value, options = {}) => { const numericValue = +value; const unitText = schema.normalizeUnit(options.unit); if (!Number.isFinite(numericValue)) return format.number(value, options); const absValue = Math.abs(numericValue); let scale = 1; let scaleText = ""; if (absValue >= 1000000000) { scale = 1000000000; scaleText = "B"; } else if (absValue >= 1000000) { scale = 1000000; scaleText = "M"; } else if (absValue >= 1000) { scale = 1000; scaleText = "K"; } const formatter = options.format ? d3.format(options.format) : d3.format("~g"); const valueText = formatter(numericValue / scale); return formatted({ text: format.appendUnit(`${valueText}${scaleText}`, unitText, options), valueText, unitText, scaleText, rawValue: value, }); }; format.auto = (value, options = {}) => format.compactNumber(value, options); format.autoText = (value, options = {}) => format.auto(value, options).text; format.scaledNumber = (value, options = {}) => { const numericValue = +value; const unitText = schema.normalizeUnit(options.unit); if (!Number.isFinite(numericValue)) return format.number(value, options); const thresholds = options.thresholds || [ { value: 1000000000, suffix: "B", format: options.format || "~g" }, { value: 1000000, suffix: "M", format: options.format || "~g" }, { value: 1000, suffix: "K", format: options.format || "~g" }, ]; const compareValue = options.absoluteThreshold ? Math.abs(numericValue) : numericValue; const threshold = thresholds.find(item => compareValue >= item.value); const prefix = options.prefix || ""; const suffix = options.suffix || ""; if (threshold) { const formatter = d3.format(threshold.format || options.format || "~g"); const valueText = formatter(numericValue / threshold.value); const scaleText = threshold.suffix || ""; return formatted({ text: format.appendUnit(`${prefix}${valueText}${scaleText}${suffix}`, unitText, options), valueText, unitText, scaleText, rawValue: value, }); } const valueText = options.baseFormat === null ? String(value) : d3.format(options.baseFormat || options.format || "~g")(numericValue); return formatted({ text: format.appendUnit(`${prefix}${valueText}${suffix}`, unitText, options), valueText, unitText, rawValue: value, }); }; format.scaledText = (value, options = {}) => format.scaledNumber(value, options).text; format.compactFixedText = (value, options = {}) => format.scaledText(value, { thresholds: [ { value: 1000000000, suffix: "B", format: options.largeFormat || ".1f" }, { value: 1000000, suffix: "M", format: options.largeFormat || ".1f" }, { value: 1000, suffix: "K", format: options.thousandFormat || ".0f" }, ], baseFormat: options.baseFormat === undefined ? ".0f" : options.baseFormat, ...options, }); format.adaptiveNumber = (value, options = {}) => { const numericValue = +value; const unitText = schema.normalizeUnit(options.unit); if (!Number.isFinite(numericValue)) return format.number(value, options); const ranges = options.ranges || []; const compareValue = options.absoluteThreshold ? Math.abs(numericValue) : numericValue; const selected = ranges.find(item => compareValue >= item.min); const resolveFormat = formatOption => { const resolved = typeof formatOption === "function" ? formatOption(numericValue) : formatOption; return resolved === undefined ? "~g" : resolved; }; const prefix = options.prefix || ""; const suffix = options.suffix || ""; if (selected) { const formatOption = resolveFormat(selected.format || options.format); const scaledValue = selected.divisor ? numericValue / selected.divisor : numericValue; const valueText = formatOption === null ? String(scaledValue) : d3.format(formatOption)(scaledValue); const scaleText = selected.suffix || ""; return formatted({ text: format.appendUnit(`${prefix}${valueText}${scaleText}${suffix}`, unitText, options), valueText, unitText, scaleText, rawValue: value, }); } const baseFormat = resolveFormat(options.baseFormat); const valueText = baseFormat === null ? String(value) : d3.format(baseFormat)(numericValue); return formatted({ text: format.appendUnit(`${prefix}${valueText}${suffix}`, unitText, options), valueText, unitText, rawValue: value, }); }; format.adaptiveText = (value, options = {}) => format.adaptiveNumber(value, options).text; format.fixed = (value, digits = 1, options = {}) => { const numericValue = +value; const unitText = schema.normalizeUnit(options.unit); const valueText = Number.isFinite(numericValue) ? numericValue.toFixed(digits) : String(value); return formatted({ text: format.appendUnit(valueText, unitText, options), valueText, unitText, rawValue: value, }); }; format.integer = (value, options = {}) => format.number(value, { ...options, format: ".0f" }); format.comma = (value, options = {}) => format.number(value, { ...options, format: "," }); format.withUnit = (value, channel, options = {}) => { const unit = options.unit != null ? options.unit : channel && channel.unit; const opts = { compact: true, ...options, unit }; return opts.compact ? format.compactNumber(value, opts) : format.number(value, opts); }; format.signed = (value, channel, options = {}) => { const result = format.withUnit(value, channel, options); if (+value > 0) { return { ...result, text: `+${result.text}`, valueText: `+${result.valueText}` }; } return result; }; format.percent = (value, options = {}) => { const digits = options.digits == null ? 1 : options.digits; const numericValue = +value; const valueText = Number.isFinite(numericValue) ? numericValue.toFixed(digits) : String(value); return formatted({ text: `${valueText}%`, valueText, unitText: "%", rawValue: value, }); }; format.percentage = (part, total, options = {}) => { const value = total ? (+part / +total) * 100 : 0; return format.percent(value, options); }; format.parseDate = value => { if (value instanceof Date) return value; if (typeof value === "number") return new Date(value, 0, 1); if (typeof value === "string") { const parts = value.split("-"); if (parts.length === 3) return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); if (parts.length === 2) return new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, 1); if (parts.length === 1 && /^\d{4}$/.test(parts[0])) return new Date(parseInt(parts[0]), 0, 1); } return new Date(value); }; format.date = (value, options = {}) => { const date = format.parseDate(value); const output = options.output || "%Y"; const valueText = d3.timeFormat(output)(date); return formatted({ text: valueText, valueText, rawValue: value }); }; format.timeTickFormat = (dates, options = {}) => { const parsedDates = dates.map(format.parseDate); const extent = d3.extent(parsedDates); const daySpan = (extent[1] - extent[0]) / (1000 * 60 * 60 * 24); const yearSpan = daySpan / 365; const monthSpan = daySpan / 30; if (yearSpan > 2) return d => d3.timeFormat("%Y")(d); if (yearSpan > 1) { return d => `${d.getFullYear().toString().slice(-2)}Q${Math.floor(d.getMonth() / 3) + 1}`; } if (monthSpan > 6) return d => d3.timeFormat("%m %Y")(d); return d => d3.timeFormat("%d %m")(d); }; format.category = value => { const valueText = value == null ? "" : String(value); return formatted({ text: valueText, valueText, rawValue: value }); }; format.value = (value, channel, options = {}) => { if (channel && channel.type === "temporal") return format.date(value, options); if (channel && channel.type === "numerical") return format.withUnit(value, channel, options); return format.category(value, options); }; })(globalThis); // Color normalization, palette, contrast, gradient, and resolver helpers. (function (root) { const chartUtils = root.chartUtils; const color = chartUtils.color; const colorResult = ({ value, source, key = "", fallbackUsed = false }) => ({ value, source, key, fallbackUsed, }); const semanticColorDefaults = { primary: "#4682B4", secondary: "#5F9EA0", positive: "#2E7D32", negative: "#C62828", neutral: "#9CA3AF", text: "#333333", background: "#FFFFFF", }; const colorPalettes = { category10: () => d3.schemeCategory10, tableau10: () => d3.schemeTableau10 || d3.schemeCategory10, accent: () => d3.schemeAccent, dark2: () => d3.schemeDark2, paired: () => d3.schemePaired, set2: () => d3.schemeSet2, set3: () => d3.schemeSet3, }; color.normalize = (value, fallback = "") => { if (value == null) return fallback; const text = String(value).trim(); const lower = text.toLowerCase(); if (["", "none", "null", "nan", "undefined", "n/a", "na"].includes(lower)) return fallback; return text; }; color.isValid = value => { const normalized = color.normalize(value); return !!normalized && !!d3.color(normalized); }; color.palette = (index, options = {}) => { const customPalette = options.colors || options.paletteColors; const palette = Array.isArray(customPalette) ? customPalette : (colorPalettes[options.palette || "tableau10"] || colorPalettes.tableau10)(); const fallback = options.fallback || semanticColorDefaults.primary; if (!palette || palette.length === 0) return fallback; return color.normalize(palette[Math.abs(index || 0) % palette.length], fallback); }; color.paletteFor = (domain, options = {}) => { const values = Array.from(domain || []); return d3.scaleOrdinal() .domain(values) .range(values.map((_, index) => color.palette(index, options))); }; color.luminance = value => { const parsed = d3.rgb(color.normalize(value, "#000000")); const channel = v => { const c = v / 255; return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }; return 0.2126 * channel(parsed.r) + 0.7152 * channel(parsed.g) + 0.0722 * channel(parsed.b); }; color.brightness = (value, options = {}) => { const parsed = d3.rgb(color.normalize(value, options.fallback || "#808080")); return (parsed.r * 0.299 + parsed.g * 0.587 + parsed.b * 0.114) / 255; }; color.scaleRgb = (value, factor = 1, options = {}) => { const parsed = d3.rgb(color.normalize(value, options.fallback || "#000000")); const scaled = d3.rgb(parsed.r * factor, parsed.g * factor, parsed.b * factor); scaled.opacity = parsed.opacity; return scaled.toString(); }; color.thresholdText = (background, options = {}) => { const threshold = options.threshold == null ? 0.6 : options.threshold; const dark = options.dark || "#000000"; const light = options.light || "#ffffff"; return color.brightness(background, options) > threshold ? dark : light; }; color.contrastRatio = (a, b) => { const l1 = color.luminance(a); const l2 = color.luminance(b); return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); }; color.textOn = (background, options = {}) => { const light = options.light || "#FFFFFF"; const dark = options.dark || "#111827"; return color.contrastRatio(dark, background) >= color.contrastRatio(light, background) ? dark : light; }; color.ensureContrast = (foreground, background, options = {}) => { const minRatio = options.minRatio || 4.5; const normalized = color.normalize(foreground, options.fallback || semanticColorDefaults.text); if (color.contrastRatio(normalized, background) >= minRatio) return normalized; return color.textOn(background, options); }; color.variant = (baseColor, options = {}) => { const base = d3.rgb(color.normalize(baseColor, options.fallback || semanticColorDefaults.primary)); const mode = options.mode || "none"; const amount = options.amount == null ? 0.5 : options.amount; let result = base; if (mode === "brighter") result = base.brighter(amount); if (mode === "darker") result = base.darker(amount); if (options.opacity != null) result.opacity = options.opacity; return result.toString(); }; color.stroke = (baseColor, options = {}) => color.variant(baseColor, { mode: options.mode || "darker", amount: options.amount == null ? 0.8 : options.amount, opacity: options.opacity, fallback: options.fallback, }); color.gradient = (defs, id, baseColor, options = {}) => { const start = options.start || color.variant(baseColor, { mode: "brighter", amount: options.brighter == null ? 0.8 : options.brighter }); const end = options.end || color.variant(baseColor, { mode: "darker", amount: options.darker == null ? 0.5 : options.darker }); const gradient = defs.append("linearGradient") .attr("id", id) .attr("x1", options.x1 || "0%") .attr("y1", options.y1 || "0%") .attr("x2", options.x2 || "100%") .attr("y2", options.y2 || "0%"); gradient.append("stop").attr("offset", options.startOffset || "0%").attr("stop-color", start); gradient.append("stop").attr("offset", options.endOffset || "100%").attr("stop-color", end); return colorResult({ value: `url(#${id})`, source: "gradient", key: id }); }; color.resolver = (jsonData, options = {}) => { const sourceColors = options.colors || (options.mode === "dark" && jsonData && jsonData.colors_dark ? jsonData.colors_dark : jsonData && jsonData.colors) || {}; const otherColors = sourceColors.other || {}; const fieldColors = sourceColors.field || {}; const availableColors = Array.isArray(sourceColors.available_colors) ? sourceColors.available_colors : []; const resolver = {}; resolver.other = (key, opts = {}) => { const value = color.normalize(otherColors[key]); if (value) return colorResult({ value, source: "other", key }); const direct = color.normalize(sourceColors[key]); if (direct) return colorResult({ value: direct, source: "color", key }); const availableIndex = opts.availableIndex == null ? opts.index : opts.availableIndex; const resolvedAvailableIndex = opts.cycle && availableColors.length > 0 ? Math.abs(availableIndex || 0) % availableColors.length : availableIndex; const availableValue = opts.useAvailable === false || resolvedAvailableIndex == null ? "" : color.normalize(availableColors[resolvedAvailableIndex]); if (availableValue) { return colorResult({ value: availableValue, source: "available_colors", key, fallbackUsed: true }); } const fallback = color.normalize(opts.fallback); if (fallback) return colorResult({ value: fallback, source: "fallback", key, fallbackUsed: true }); if (semanticColorDefaults[key]) return colorResult({ value: semanticColorDefaults[key], source: "semantic_default", key, fallbackUsed: true }); return colorResult({ value: color.palette(opts.index || 0, { ...options, ...opts }), source: "palette", key, fallbackUsed: true }); }; resolver.field = (value, index = 0, opts = {}) => { const direct = color.normalize(fieldColors[value]); if (direct) return colorResult({ value: direct, source: "field", key: value }); const stringKey = value == null ? "" : String(value); const stringValue = color.normalize(fieldColors[stringKey]); if (stringValue) return colorResult({ value: stringValue, source: "field", key: stringKey }); const fieldName = opts.fieldName || opts.fieldKey; const fieldMap = fieldName && sourceColors[fieldName] && typeof sourceColors[fieldName] === "object" ? sourceColors[fieldName] : null; if (fieldMap) { const fieldMapValue = color.normalize(fieldMap[value]) || color.normalize(fieldMap[stringKey]); if (fieldMapValue) return colorResult({ value: fieldMapValue, source: "field_map", key: stringKey }); } if (opts.fallbackKey) { const semantic = resolver.other(opts.fallbackKey, { ...opts, index }); if (semantic.value) return { ...semantic, key: value, fallbackUsed: true }; } const fallback = color.normalize(opts.fallback); if (fallback) return colorResult({ value: fallback, source: "fallback", key: value, fallbackUsed: true }); const availableIndex = opts.availableIndex == null ? index : opts.availableIndex; const resolvedAvailableIndex = opts.cycle && availableColors.length > 0 ? Math.abs(availableIndex || 0) % availableColors.length : availableIndex; const availableValue = opts.useAvailable === false || resolvedAvailableIndex == null ? "" : color.normalize(availableColors[resolvedAvailableIndex]); if (availableValue) { return colorResult({ value: availableValue, source: "available_colors", key: value, fallbackUsed: true }); } return colorResult({ value: color.palette(index, { ...options, ...opts }), source: "palette", key: value, fallbackUsed: true }); }; resolver.available = (index = 0, opts = {}) => { const resolvedIndex = opts.cycle && availableColors.length > 0 ? Math.abs(index || 0) % availableColors.length : index; const key = String(index == null ? 0 : index); const availableValue = resolvedIndex == null ? "" : color.normalize(availableColors[resolvedIndex]); if (availableValue) return colorResult({ value: availableValue, source: "available_colors", key }); const fallbackColors = Array.isArray(opts.fallbackColors) ? opts.fallbackColors : []; const fallbackIndex = opts.cycle && fallbackColors.length > 0 ? Math.abs(index || 0) % fallbackColors.length : index; const fallbackColor = fallbackIndex == null ? "" : color.normalize(fallbackColors[fallbackIndex]); if (fallbackColor) return colorResult({ value: fallbackColor, source: "fallback_colors", key, fallbackUsed: true }); const fallback = color.normalize(opts.fallback); if (fallback) return colorResult({ value: fallback, source: "fallback", key, fallbackUsed: true }); if (opts.usePalette === false) { return colorResult({ value: "", source: "missing", key, fallbackUsed: true }); } const paletteIndex = opts.index == null ? index : opts.index; return colorResult({ value: color.palette(paletteIndex || 0, { ...options, ...opts }), source: "palette", key, fallbackUsed: true, }); }; resolver.availablePalette = (count, opts = {}) => { const explicitCount = Number.isFinite(Number(count)) ? Math.max(0, Math.floor(Number(count))) : null; const size = explicitCount == null ? availableColors.length : explicitCount; return d3.range(size).map(index => resolver.available(index, { ...opts, index }).value); }; resolver.scale = (domain, opts = {}) => { const values = Array.from(domain || []); return d3.scaleOrdinal() .domain(values) .range(values.map((value, index) => resolver.field(value, index, opts).value)); }; resolver.text = (opts = {}) => { const explicit = color.normalize(sourceColors.text_color); if (explicit) return colorResult({ value: explicit, source: "text_color", key: "text" }); const fallback = color.normalize(opts.fallback); if (fallback) return colorResult({ value: fallback, source: "fallback", key: "text", fallbackUsed: true }); const bg = opts.background || resolver.background(opts).value; return colorResult({ value: color.textOn(bg, opts), source: "contrast", key: "text", fallbackUsed: true }); }; resolver.background = (opts = {}) => { const explicit = color.normalize(sourceColors.background_color); if (explicit) return colorResult({ value: explicit, source: "background_color", key: "background" }); const direct = color.normalize(sourceColors.background); if (direct) return colorResult({ value: direct, source: "color", key: "background" }); return colorResult({ value: opts.fallback || semanticColorDefaults.background, source: "semantic_default", key: "background", fallbackUsed: true }); }; resolver.variant = (baseColor, opts = {}) => color.variant(baseColor && baseColor.value ? baseColor.value : baseColor, opts); resolver.stroke = (baseColor, opts = {}) => color.stroke(baseColor && baseColor.value ? baseColor.value : baseColor, opts); resolver.textOn = (baseColor, opts = {}) => color.textOn(baseColor && baseColor.value ? baseColor.value : baseColor, opts); resolver.gradient = (defs, id, baseColor, opts = {}) => color.gradient(defs, id, baseColor && baseColor.value ? baseColor.value : baseColor, opts); return resolver; }; })(globalThis); // Text sizing, measuring, and truncation helpers. (function (root) { const chartUtils = root.chartUtils; const text = chartUtils.text; text.font = (options = {}) => { const fontWeight = options.fontWeight || options.weight || "normal"; const fontSize = text.fontSize(options.fontSize || options.size || 12); const fontFamily = options.fontFamily || options.family || "Arial"; return `${fontWeight} ${fontSize}px ${fontFamily}`; }; text.fontSize = value => { if (typeof value === "number") return value; const parsed = parseFloat(value); return Number.isFinite(parsed) ? parsed : 12; }; text.estimate = (value, options = {}) => { const valueText = value == null ? "" : String(value); const fontSize = text.fontSize(options.fontSize || options.size || 12); const factor = options.factor == null ? 0.6 : options.factor; return { width: valueText.length * fontSize * factor, height: fontSize * 1.2, }; }; text.measure = (context, value, options = {}) => { const valueText = value == null ? "" : String(value); const fontSize = text.fontSize(options.fontSize || options.size || 12); const fontFamily = options.fontFamily || options.family || null; const fontWeight = options.fontWeight || options.weight || "normal"; if (context && typeof context.append === "function") { const node = context.append("text") .attr("visibility", "hidden") .attr("x", -9999) .attr("y", -9999) .style("font-size", `${fontSize}px`) .style("font-weight", fontWeight) .text(valueText); if (fontFamily) node.style("font-family", fontFamily); const element = node.node(); const width = element.getComputedTextLength ? element.getComputedTextLength() : element.getBBox().width; const height = element.getBBox().height || fontSize * 1.2; node.remove(); return { width, height }; } if (typeof document !== "undefined") { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); ctx.font = text.font({ fontSize, fontFamily, fontWeight }); return { width: ctx.measureText(valueText).width, height: fontSize * 1.2, }; } return text.estimate(valueText, options); }; text.contextSize = (context, value, options = {}) => { const valueText = value == null ? "" : String(value); const fontSize = text.fontSize(options.fontSize || options.size || 12); if (context && typeof context.measureText === "function") { return { width: context.measureText(valueText).width, height: fontSize * 1.2, }; } return text.measure(null, valueText, options); }; text.contextWidth = (context, value, options = {}) => text.contextSize(context, value, options).width; text.nodeSize = (node, options = {}) => { if (!node) return { width: 0, height: 0 }; const method = options.method || "auto"; const canUseComputed = typeof node.getComputedTextLength === "function"; const canUseBBox = typeof node.getBBox === "function"; let bbox = null; let width = null; const readBBox = () => { if (!canUseBBox) return null; try { return node.getBBox(); } catch (error) { return null; } }; if ((method === "computed" || method === "auto") && canUseComputed) { try { width = node.getComputedTextLength(); } catch (error) { width = null; } } if ((method === "bbox" || !Number.isFinite(width)) && canUseBBox) { bbox = readBBox(); if (bbox && Number.isFinite(bbox.width)) { width = bbox.width; } } if (!Number.isFinite(width)) { width = text.estimate(node.textContent || "", options).width; } if (!bbox && canUseBBox) { bbox = readBBox(); } const fallbackHeight = text.fontSize(options.fontSize || options.size || 12) * 1.2; const height = bbox && Number.isFinite(bbox.height) ? bbox.height : fallbackHeight; const x = bbox && Number.isFinite(bbox.x) ? bbox.x : 0; const y = bbox && Number.isFinite(bbox.y) ? bbox.y : 0; return { width, height, x, y }; }; text.nodeWidth = (node, options = {}) => text.nodeSize(node, options).width; text.truncate = (value, maxWidth, options = {}) => { const valueText = value == null ? "" : String(value); const ellipsis = options.ellipsis == null ? "..." : options.ellipsis; const measure = candidate => text.measure(options.context, candidate, options).width; if (measure(valueText) <= maxWidth) return valueText; if (maxWidth <= 0 || measure(ellipsis) > maxWidth) return ""; let low = 0; let high = valueText.length; while (low < high) { const mid = Math.ceil((low + high) / 2); if (measure(valueText.slice(0, mid) + ellipsis) <= maxWidth) { low = mid; } else { high = mid - 1; } } return valueText.slice(0, low) + ellipsis; }; })(globalThis); // 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); // Expose chartUtils as a global variable for legacy non-module templates. var chartUtils = globalThis.chartUtils; // Legacy global helpers kept for old D3 templates during migration. // 解析日期 var parseDate = d => { if (d instanceof Date) return d; if (typeof d === 'number') return new Date(d, 0, 1); if (typeof d === 'string') { const parts = d.split('-'); // YYYY-MM-DD 格式 if (parts.length === 3) { const year = parseInt(parts[0]); const month = parseInt(parts[1]) - 1; const day = parseInt(parts[2]); return new Date(year, month, day); } // YYYY-MM 格式 if (parts.length === 2) { const year = parseInt(parts[0]); const month = parseInt(parts[1]) - 1; return new Date(year, month, 1); } // YYYY 格式 if (parts.length === 1 && /^\d{4}$/.test(parts[0])) { const year = parseInt(parts[0]); return new Date(year, 0, 1); } } return new Date(); }; // 创建智能日期比例尺和刻度 var createXAxisScaleAndTicks = (data, xField, rangeStart = 0, rangeEnd = 100, padding = 0.05) => { // 解析所有日期 const dates = data.map(d => parseDate(d[xField])); const xExtent = d3.extent(dates); const xRange = xExtent[1] - xExtent[0]; const xPadding = xRange * padding; // 创建比例尺 const xScale = d3.scaleTime() .domain([ new Date(xExtent[0].getTime() - xPadding), new Date(xExtent[1].getTime() + xPadding) ]) .range([rangeStart, rangeEnd]); // 计算日期跨度(毫秒) const timeSpan = xExtent[1] - xExtent[0]; const daySpan = timeSpan / (1000 * 60 * 60 * 24); const monthSpan = daySpan / 30; const yearSpan = daySpan / 365; // 根据跨度选择合适的时间间隔 let timeInterval; let formatFunction; if (yearSpan > 35) { // 超过35年,每10年一个刻度 timeInterval = d3.timeYear.every(10); formatFunction = d => d3.timeFormat("%Y")(d); } else if (yearSpan > 15) { // 超过15年,每5年一个刻度 timeInterval = d3.timeYear.every(5); formatFunction = d => d3.timeFormat("%Y")(d); } else if (yearSpan > 7) { // 超过7年,每2年一个刻度 timeInterval = d3.timeYear.every(2); formatFunction = d => d3.timeFormat("%Y")(d); } else if (yearSpan > 2) { // 2-7年,每年一个刻度 timeInterval = d3.timeYear.every(1); formatFunction = d => d3.timeFormat("%Y")(d); } else if (yearSpan > 1) { // 1-2年,每季度一个刻度 timeInterval = d3.timeMonth.every(3); formatFunction = d => { const month = d.getMonth(); const quarter = Math.floor(month / 3) + 1; return `${d.getFullYear().toString().slice(-2)}Q${quarter}`; }; } else if (monthSpan > 6) { // 6个月-1年,每月一个刻度 timeInterval = d3.timeMonth.every(1); formatFunction = d => d3.timeFormat("%m %Y")(d); } else if (monthSpan > 2) { // 2-6个月,每周一个刻度 timeInterval = d3.timeWeek.every(1); formatFunction = d => d3.timeFormat("%d %m")(d); } else { // 少于2个月,每天一个刻度或每几天一个刻度 const dayInterval = Math.max(1, Math.ceil(daySpan / 10)); timeInterval = d3.timeDay.every(dayInterval); formatFunction = d => d3.timeFormat("%d %m")(d); } // 生成刻度 const xTicks = xScale.ticks(timeInterval); // 确保包含最后一个日期 if (xTicks.length > 0 && xTicks[xTicks.length - 1] < xExtent[1]) { if (xTicks.length > 7) { xTicks.pop(); // 先移除当前最后一个刻度 } xTicks.push(xExtent[1]); // 添加数据的最后一个日期作为刻度 } return { xScale: xScale, xTicks: xTicks, xFormat: formatFunction, timeSpan: { days: daySpan, months: monthSpan, years: yearSpan } }; }; var createNumericalFormatter = (data, yField) => { const yExtent = d3.extent(data, d => d[yField]); const yRange = yExtent[1] - yExtent[0]; const yPadding = yRange * 0.05; if (yRange > 1000000000) { return d => d3.format(".2f")(d / 1000000000) + "B"; } else if (yRange > 1000000) { return d => d3.format(".2f")(d / 1000000) + "M"; } else if (yRange > 1000) { return d => d3.format(".2f")(d / 1000) + "K"; } else { return d => d3.format(".2f")(d); } } /** * 根据数据点数量计算需要显示标签的点的索引 * @param {number} n - 数据点总数 * @returns {number[]} - 需要显示标签的点的索引数组 */ var sampleLabels = (n) => { // 少于10个点时显示所有标签 if (n <= 10) { return Array.from({length: n}, (_, i) => i); } // 超过10个点时每隔 n/10 个点显示一个标签 const step = Math.ceil(n / 10); const result = []; // 从0开始,每隔step个点取一个索引 for (let i = 0; i < n; i += step) { result.push(i); } // 确保包含最后一个点 if (result[result.length - 1] !== n - 1) { result.push(n - 1); } return result; }; var temporalFilter = (data, field) => { // 把data中不是temporal的点删除 return data.filter(d => { try { parseDate(d[field]); return true; } catch (e) { return false; } }); } /** * 计算文本在指定字体大小下的实际渲染宽度 * @param {string} text - 要测量的文本 * @param {number} fontSize - 字体大小(px) * @returns {number} - 文本宽度(px) */ var getTextWidth = (text, fontSize) => { return chartUtils.text.measure(null, text, { fontSize }).width; }; /** * 智能排版图例,将多个图例元素自动排布成多行 * @param {Object} g - D3 选择的 SVG 组元素,用于放置图例 * @param {Array} groups - 组名数组 * @param {Object} colors - 颜色对象,包含 field 属性 * @param {Object} options - 配置选项 * @param {number} options.maxWidth - 每行最大宽度 * @param {number} options.x - 图例起始 x 坐标 * @param {number} options.y - 图例起始 y 坐标 * @param {number} options.itemHeight - 每个图例项的高度 * @param {number} options.itemSpacing - 图例项之间的水平间距 * @param {number} options.rowSpacing - 行之间的垂直间距 * @param {number} options.symbolSize - 图例符号大小 * @param {string} options.textColor - 文本颜色 * @param {number} options.fontSize - 字体大小 * @param {string} options.fontWeight - 字体粗细 * @param {string} options.align - 对齐方式:'left', 'center', 'right' * @param {string} options.shape - 图例形状:'circle', 'rect', 'line' * @returns {Object} 包含图例尺寸信息的对象 {width, height} */ var layoutLegend = (g, groups, colors, options = {}) => { const legendOptions = { ...options }; if (options.symbolSize != null) legendOptions.markerSize = options.symbolSize; if (options.shape != null) legendOptions.markerShape = options.shape; if (options.itemSpacing != null) legendOptions.itemGap = options.itemSpacing; if (options.rowSpacing != null) legendOptions.rowGap = options.rowSpacing; return chartUtils.legend.draw(g, groups, colors, legendOptions); }; var formatValue = (value) => { if (value >= 1000000000) { return d3.format("~g")(value / 1000000000) + "B"; } else if (value >= 1000000) { return d3.format("~g")(value / 1000000) + "M"; } else if (value >= 1000) { return d3.format("~g")(value / 1000) + "K"; } else { console.log("formatValue", value, d3.format("~g")(value)); return d3.format("~g")(value); } }