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