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