Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files
modules/chart_engine/template/d3-js/type21_horizontal_dot_bar_chart/horizontal_dot_bar_plain_chart_01.js
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "horizontal dot bar chart",
|
| 5 |
+
"chart_name": "horizontal_dot_bar_plain_chart_01",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical", "temporal"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[2, 30], [0, "inf"]],
|
| 10 |
+
"required_fields_icons": [],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": [],
|
| 13 |
+
"required_other_colors": ["primary"],
|
| 14 |
+
"supported_effects": ["shadow", "gradient", "stroke", "spacing"],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "none",
|
| 20 |
+
"has_x_axis": "yes",
|
| 21 |
+
"has_y_axis": "yes"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
function makeChart(containerSelector, data) {
|
| 27 |
+
const jsonData = data || {};
|
| 28 |
+
const chartData = (jsonData.data && jsonData.data.data) || [];
|
| 29 |
+
const dataColumns = chartUtils.schema.columns(jsonData);
|
| 30 |
+
const colorResolver = chartUtils.color.resolver(jsonData);
|
| 31 |
+
const chartUtilsFormatSample = chartUtils.format.autoText;
|
| 32 |
+
const chartUtilsTextSample = chartUtils.text.estimate;
|
| 33 |
+
const chartUtilsStandard = {
|
| 34 |
+
schema: chartUtils.schema,
|
| 35 |
+
format: chartUtils.format,
|
| 36 |
+
text: chartUtils.text,
|
| 37 |
+
color: chartUtils.color,
|
| 38 |
+
legendLayout: chartUtils.legend.layout,
|
| 39 |
+
legendDraw: chartUtils.legend.draw,
|
| 40 |
+
random: chartUtils.random.generator(jsonData, "dev-wz-style")
|
| 41 |
+
};
|
| 42 |
+
const standardChannels = chartUtils.schema.channels(jsonData, {
|
| 43 |
+
x: { fallbackIndex: 0 },
|
| 44 |
+
y: { fallbackIndex: 1 },
|
| 45 |
+
y2: { fallbackIndex: 2 },
|
| 46 |
+
y3: { fallbackIndex: 3 },
|
| 47 |
+
size: { fallbackIndex: 2 },
|
| 48 |
+
group: { fallbackIndex: 2 },
|
| 49 |
+
group2: { fallbackIndex: 3 },
|
| 50 |
+
group3: { fallbackIndex: 4 }
|
| 51 |
+
});
|
| 52 |
+
const variables = jsonData.variables || {};
|
| 53 |
+
const typography = jsonData.typography || {
|
| 54 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 55 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 56 |
+
};
|
| 57 |
+
const sourceColors = jsonData.colors || {};
|
| 58 |
+
const colorOther = sourceColors.other || {};
|
| 59 |
+
const colorField = sourceColors.field || {};
|
| 60 |
+
|
| 61 |
+
d3.select(containerSelector).html("");
|
| 62 |
+
|
| 63 |
+
const width = Number(variables.width) || 800;
|
| 64 |
+
const baseHeight = Number(variables.height) || 600;
|
| 65 |
+
const rowCount = Math.max(chartData.length, 1);
|
| 66 |
+
const height = Math.max(baseHeight, rowCount * 43 + 222);
|
| 67 |
+
|
| 68 |
+
const labelTypography = typography.label || {};
|
| 69 |
+
const valueTypography = typography.annotation || labelTypography;
|
| 70 |
+
const labelFamily = labelTypography.font_family || "Arial";
|
| 71 |
+
const labelWeight = labelTypography.font_weight || "normal";
|
| 72 |
+
const valueFamily = valueTypography.font_family || "Arial";
|
| 73 |
+
const valueWeight = valueTypography.font_weight || "normal";
|
| 74 |
+
const baseLabelSize = parseFloat(labelTypography.font_size) || 12;
|
| 75 |
+
const baseValueSize = parseFloat(valueTypography.font_size) || 12;
|
| 76 |
+
const textColor = sourceColors.text_color || "#27323f";
|
| 77 |
+
const styleGreen = colorOther.primary || colorField.y || "#2f8f3a";
|
| 78 |
+
const primaryColor = d3.interpolateRgb("#2f8f3a", styleGreen)(0.22);
|
| 79 |
+
const palePrimary = "#b9e873";
|
| 80 |
+
const darkPrimary = "#155f2a";
|
| 81 |
+
const tableGreen = "#2f8f3a";
|
| 82 |
+
|
| 83 |
+
function rgba(color, opacity) {
|
| 84 |
+
const c = d3.rgb(color);
|
| 85 |
+
return `rgba(${c.r}, ${c.g}, ${c.b}, ${opacity})`;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const axisColor = sourceColors.axis_color || rgba(textColor, 0.48);
|
| 89 |
+
const gridColor = sourceColors.grid_color || "rgba(47, 143, 58, 0.14)";
|
| 90 |
+
|
| 91 |
+
const xColumn = chartUtils.schema.channel(jsonData, "x", { fallbackIndex: 0 }).raw || {};
|
| 92 |
+
const yColumn = chartUtils.schema.channel(jsonData, "y", { fallbackIndex: 1 }).raw || {};
|
| 93 |
+
const dimensionField = xColumn.name || chartUtils.schema.column(dataColumns, 0).raw?.name || "dimension";
|
| 94 |
+
const valueField = yColumn.name || chartUtils.schema.column(dataColumns, 1).raw?.name || "value";
|
| 95 |
+
const dimensionUnit = xColumn.unit && xColumn.unit !== "none" ? xColumn.unit : "";
|
| 96 |
+
const valueUnit = yColumn.unit && yColumn.unit !== "none" ? yColumn.unit : "";
|
| 97 |
+
|
| 98 |
+
function parseTemporalSortValue(value) {
|
| 99 |
+
const text = String(value ?? "").trim();
|
| 100 |
+
let match = text.match(/^(-?\d{1,6})$/);
|
| 101 |
+
if (match) return Number(match[1]);
|
| 102 |
+
match = text.match(/^(-?\d{1,6})[-/.](\d{1,2})(?:[-/.](\d{1,2}))?$/);
|
| 103 |
+
if (match) {
|
| 104 |
+
return Number(match[1]) * 10000 + Number(match[2]) * 100 + Number(match[3] || 1);
|
| 105 |
+
}
|
| 106 |
+
const dateValue = Date.parse(text);
|
| 107 |
+
return Number.isNaN(dateValue) ? NaN : dateValue;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
function formatNumber(value) {
|
| 111 |
+
const numericValue = Number(value);
|
| 112 |
+
if (!Number.isFinite(numericValue)) return String(value ?? "");
|
| 113 |
+
const absValue = Math.abs(numericValue);
|
| 114 |
+
if (absValue >= 1000000000) return `${d3.format(".3~g")(numericValue / 1000000000)}B`;
|
| 115 |
+
if (absValue >= 1000000) return `${d3.format(".3~g")(numericValue / 1000000)}M`;
|
| 116 |
+
if (absValue >= 1000) return `${d3.format(".3~g")(numericValue / 1000)}K`;
|
| 117 |
+
return d3.format(",.4~g")(numericValue);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function formatWithUnit(value, unit) {
|
| 121 |
+
const formatted = formatNumber(value);
|
| 122 |
+
if (!unit) return formatted;
|
| 123 |
+
if (unit === "$") return `$${formatted}`;
|
| 124 |
+
if (unit === "%") return `${formatted}%`;
|
| 125 |
+
return `${formatted} ${unit}`;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
function dimensionLabel(d) {
|
| 129 |
+
const value = d[dimensionField];
|
| 130 |
+
return dimensionUnit ? `${value} ${dimensionUnit}` : String(value ?? "");
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function prettifyHeader(value, fallback) {
|
| 134 |
+
const text = String(value || fallback || "").trim();
|
| 135 |
+
if (!text) return "";
|
| 136 |
+
return text
|
| 137 |
+
.replace(/[_-]+/g, " ")
|
| 138 |
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
| 139 |
+
.replace(/\s+/g, " ");
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const enrichedData = chartData.map((d, index) => ({
|
| 143 |
+
...d,
|
| 144 |
+
__index: index,
|
| 145 |
+
__dimension: dimensionLabel(d),
|
| 146 |
+
__value: Number(d[valueField])
|
| 147 |
+
})).filter(d => Number.isFinite(d.__value));
|
| 148 |
+
|
| 149 |
+
const temporalValues = enrichedData.map(d => parseTemporalSortValue(d[dimensionField]));
|
| 150 |
+
const isTemporal = xColumn.data_type === "temporal" || (
|
| 151 |
+
temporalValues.length > 0 && temporalValues.every(Number.isFinite)
|
| 152 |
+
);
|
| 153 |
+
|
| 154 |
+
const sortedData = [...enrichedData].sort((a, b) => {
|
| 155 |
+
if (isTemporal) {
|
| 156 |
+
const aTime = parseTemporalSortValue(a[dimensionField]);
|
| 157 |
+
const bTime = parseTemporalSortValue(b[dimensionField]);
|
| 158 |
+
if (Number.isFinite(aTime) && Number.isFinite(bTime)) return aTime - bTime;
|
| 159 |
+
}
|
| 160 |
+
const byValue = b.__value - a.__value;
|
| 161 |
+
if (byValue !== 0) return byValue;
|
| 162 |
+
return String(a.__dimension).localeCompare(String(b.__dimension));
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
const svg = d3.select(containerSelector)
|
| 166 |
+
.append("svg")
|
| 167 |
+
.attr("width", "100%")
|
| 168 |
+
.attr("height", height)
|
| 169 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 170 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 171 |
+
.attr("xmlns", "http://www.w3.org/2000/svg");
|
| 172 |
+
|
| 173 |
+
const defs = svg.append("defs");
|
| 174 |
+
const gradientId = "horizontal-dot-bar-gradient";
|
| 175 |
+
const gradient = defs.append("linearGradient")
|
| 176 |
+
.attr("id", gradientId)
|
| 177 |
+
.attr("x1", "0%")
|
| 178 |
+
.attr("x2", "100%")
|
| 179 |
+
.attr("y1", "0%")
|
| 180 |
+
.attr("y2", "0%");
|
| 181 |
+
gradient.append("stop")
|
| 182 |
+
.attr("offset", "0%")
|
| 183 |
+
.attr("stop-color", palePrimary);
|
| 184 |
+
gradient.append("stop")
|
| 185 |
+
.attr("offset", "100%")
|
| 186 |
+
.attr("stop-color", primaryColor);
|
| 187 |
+
|
| 188 |
+
if (variables.has_shadow) {
|
| 189 |
+
const shadow = defs.append("filter")
|
| 190 |
+
.attr("id", "dot-shadow")
|
| 191 |
+
.attr("x", "-40%")
|
| 192 |
+
.attr("y", "-40%")
|
| 193 |
+
.attr("width", "180%")
|
| 194 |
+
.attr("height", "180%");
|
| 195 |
+
shadow.append("feDropShadow")
|
| 196 |
+
.attr("dx", 0)
|
| 197 |
+
.attr("dy", 1)
|
| 198 |
+
.attr("stdDeviation", 1.4)
|
| 199 |
+
.attr("flood-color", "#000000")
|
| 200 |
+
.attr("flood-opacity", 0.18);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
const measureSvg = svg.append("g").attr("visibility", "hidden");
|
| 204 |
+
function measureLabelText(text, fontSize, family, weight) {
|
| 205 |
+
const node = measureSvg.append("text")
|
| 206 |
+
.style("font-family", family)
|
| 207 |
+
.style("font-size", `${fontSize}px`)
|
| 208 |
+
.style("font-weight", weight)
|
| 209 |
+
.text(text)
|
| 210 |
+
.node();
|
| 211 |
+
const width = node ? node.textContent.length * 7 : String(text).length * fontSize * 0.55;
|
| 212 |
+
measureSvg.selectAll("text").remove();
|
| 213 |
+
return width;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
function breakLongWord(word, maxWidth, fontSize, family, weight) {
|
| 217 |
+
const lines = [];
|
| 218 |
+
let current = "";
|
| 219 |
+
for (const char of String(word)) {
|
| 220 |
+
const next = current + char;
|
| 221 |
+
if (current && measureLabelText(next, fontSize, family, weight) > maxWidth) {
|
| 222 |
+
lines.push(`${current}-`);
|
| 223 |
+
current = char;
|
| 224 |
+
} else {
|
| 225 |
+
current = next;
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
if (current) lines.push(current);
|
| 229 |
+
return lines.length ? lines : [String(word)];
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function wrapText(text, maxWidth, fontSize, family, weight) {
|
| 233 |
+
const words = String(text || "").split(/\s+/).filter(Boolean);
|
| 234 |
+
if (!words.length) return [String(text || "")];
|
| 235 |
+
const lines = [];
|
| 236 |
+
let current = "";
|
| 237 |
+
for (const word of words) {
|
| 238 |
+
const candidate = current ? `${current} ${word}` : word;
|
| 239 |
+
if (measureLabelText(candidate, fontSize, family, weight) <= maxWidth) {
|
| 240 |
+
current = candidate;
|
| 241 |
+
continue;
|
| 242 |
+
}
|
| 243 |
+
if (current) lines.push(current);
|
| 244 |
+
if (measureLabelText(word, fontSize, family, weight) <= maxWidth) {
|
| 245 |
+
current = word;
|
| 246 |
+
} else {
|
| 247 |
+
const broken = breakLongWord(word, maxWidth, fontSize, family, weight);
|
| 248 |
+
lines.push(...broken.slice(0, -1));
|
| 249 |
+
current = broken[broken.length - 1];
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
if (current) lines.push(current);
|
| 253 |
+
return lines.length ? lines : [String(text || "")];
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
function appendMiniIcon(parent, type, x, y, size, color, className) {
|
| 257 |
+
const g = parent.append("g")
|
| 258 |
+
.attr("class", className || "mini-icon")
|
| 259 |
+
.attr("transform", `translate(${x}, ${y})`);
|
| 260 |
+
const strokeWidth = Math.max(1.4, size * 0.11);
|
| 261 |
+
|
| 262 |
+
if (type === "calendar") {
|
| 263 |
+
g.append("rect")
|
| 264 |
+
.attr("x", 0)
|
| 265 |
+
.attr("y", size * 0.14)
|
| 266 |
+
.attr("width", size)
|
| 267 |
+
.attr("height", size * 0.78)
|
| 268 |
+
.attr("rx", size * 0.12)
|
| 269 |
+
.attr("fill", "none")
|
| 270 |
+
.attr("stroke", color)
|
| 271 |
+
.attr("stroke-width", strokeWidth);
|
| 272 |
+
g.append("line")
|
| 273 |
+
.attr("x1", 0)
|
| 274 |
+
.attr("x2", size)
|
| 275 |
+
.attr("y1", size * 0.38)
|
| 276 |
+
.attr("y2", size * 0.38)
|
| 277 |
+
.attr("stroke", color)
|
| 278 |
+
.attr("stroke-width", strokeWidth);
|
| 279 |
+
[0.28, 0.72].forEach(px => {
|
| 280 |
+
g.append("line")
|
| 281 |
+
.attr("x1", size * px)
|
| 282 |
+
.attr("x2", size * px)
|
| 283 |
+
.attr("y1", 0)
|
| 284 |
+
.attr("y2", size * 0.26)
|
| 285 |
+
.attr("stroke", color)
|
| 286 |
+
.attr("stroke-width", strokeWidth)
|
| 287 |
+
.attr("stroke-linecap", "round");
|
| 288 |
+
});
|
| 289 |
+
return g;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
if (type === "trend") {
|
| 293 |
+
[0.15, 0.38, 0.61].forEach((px, index) => {
|
| 294 |
+
const barHeight = size * (0.28 + index * 0.18);
|
| 295 |
+
g.append("rect")
|
| 296 |
+
.attr("x", size * px)
|
| 297 |
+
.attr("y", size * (0.88) - barHeight)
|
| 298 |
+
.attr("width", size * 0.12)
|
| 299 |
+
.attr("height", barHeight)
|
| 300 |
+
.attr("fill", color);
|
| 301 |
+
});
|
| 302 |
+
g.append("path")
|
| 303 |
+
.attr("d", `M${size * 0.12},${size * 0.58} L${size * 0.38},${size * 0.38} L${size * 0.58},${size * 0.46} L${size * 0.86},${size * 0.18}`)
|
| 304 |
+
.attr("fill", "none")
|
| 305 |
+
.attr("stroke", color)
|
| 306 |
+
.attr("stroke-width", strokeWidth)
|
| 307 |
+
.attr("stroke-linecap", "round")
|
| 308 |
+
.attr("stroke-linejoin", "round");
|
| 309 |
+
g.append("path")
|
| 310 |
+
.attr("d", `M${size * 0.74},${size * 0.18} L${size * 0.86},${size * 0.18} L${size * 0.86},${size * 0.31}`)
|
| 311 |
+
.attr("fill", "none")
|
| 312 |
+
.attr("stroke", color)
|
| 313 |
+
.attr("stroke-width", strokeWidth)
|
| 314 |
+
.attr("stroke-linecap", "round")
|
| 315 |
+
.attr("stroke-linejoin", "round");
|
| 316 |
+
return g;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
if (type === "pulse") {
|
| 320 |
+
g.append("path")
|
| 321 |
+
.attr("d", `M0,${size * 0.55} L${size * 0.18},${size * 0.55} L${size * 0.28},${size * 0.28} L${size * 0.42},${size * 0.78} L${size * 0.55},${size * 0.2} L${size * 0.68},${size * 0.55} L${size},${size * 0.55}`)
|
| 322 |
+
.attr("fill", "none")
|
| 323 |
+
.attr("stroke", color)
|
| 324 |
+
.attr("stroke-width", strokeWidth)
|
| 325 |
+
.attr("stroke-linecap", "round")
|
| 326 |
+
.attr("stroke-linejoin", "round");
|
| 327 |
+
return g;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
g.append("circle")
|
| 331 |
+
.attr("cx", size / 2)
|
| 332 |
+
.attr("cy", size / 2)
|
| 333 |
+
.attr("r", size * 0.36)
|
| 334 |
+
.attr("fill", "none")
|
| 335 |
+
.attr("stroke", color)
|
| 336 |
+
.attr("stroke-width", strokeWidth);
|
| 337 |
+
return g;
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
function splitValueLabel(label, unit) {
|
| 341 |
+
const text = String(label || "");
|
| 342 |
+
if (unit && text.endsWith(` ${unit}`)) {
|
| 343 |
+
return [text.slice(0, -unit.length - 1), unit];
|
| 344 |
+
}
|
| 345 |
+
return [text, ""];
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
svg.append("rect")
|
| 349 |
+
.attr("class", "chart-style-background")
|
| 350 |
+
.attr("width", width)
|
| 351 |
+
.attr("height", height)
|
| 352 |
+
.attr("fill", "#f6fbf1");
|
| 353 |
+
|
| 354 |
+
const cardX = Math.max(18, width * 0.035);
|
| 355 |
+
const cardY = 18;
|
| 356 |
+
const cardWidth = width - cardX * 2;
|
| 357 |
+
const cardHeight = height - cardY - 24;
|
| 358 |
+
const legendHeight = 82;
|
| 359 |
+
const legendGap = 20;
|
| 360 |
+
const headerHeight = 52;
|
| 361 |
+
const footerHeight = 44;
|
| 362 |
+
const footerGap = 20;
|
| 363 |
+
const tableY = cardY + legendHeight + legendGap;
|
| 364 |
+
const tableHeight = Math.max(
|
| 365 |
+
rowCount * 34 + headerHeight,
|
| 366 |
+
cardHeight - legendHeight - legendGap - footerHeight - footerGap
|
| 367 |
+
);
|
| 368 |
+
const innerHeight = Math.max(120, tableHeight - headerHeight);
|
| 369 |
+
const footerY = tableY + tableHeight + footerGap;
|
| 370 |
+
const labelMaxWidth = Math.min(230, cardWidth * 0.3);
|
| 371 |
+
const valueLabels = sortedData.map(d => formatWithUnit(d.__value, valueUnit));
|
| 372 |
+
const maxValueLabelWidth = Math.max(
|
| 373 |
+
46,
|
| 374 |
+
...valueLabels.map(label => measureLabelText(label, baseValueSize, valueFamily, valueWeight))
|
| 375 |
+
);
|
| 376 |
+
const rightColumnWidth = Math.max(122, maxValueLabelWidth + 62);
|
| 377 |
+
const labelFontSize = Math.max(8, Math.min(baseLabelSize, innerHeight / rowCount * 0.34));
|
| 378 |
+
const wrappedLabels = sortedData.map(d => wrapText(d.__dimension, labelMaxWidth, labelFontSize, labelFamily, labelWeight));
|
| 379 |
+
const maxLabelLineWidth = Math.max(
|
| 380 |
+
54,
|
| 381 |
+
...wrappedLabels.flat().map(line => measureLabelText(line, labelFontSize, labelFamily, labelWeight))
|
| 382 |
+
);
|
| 383 |
+
const leftColumnWidth = Math.max(132, Math.min(cardWidth * 0.36, maxLabelLineWidth + 66));
|
| 384 |
+
measureSvg.remove();
|
| 385 |
+
|
| 386 |
+
const innerWidth = Math.max(220, cardWidth - leftColumnWidth - rightColumnWidth);
|
| 387 |
+
const chartX = cardX + leftColumnWidth;
|
| 388 |
+
const chartY = tableY + headerHeight;
|
| 389 |
+
const yScale = d3.scaleBand()
|
| 390 |
+
.domain(sortedData.map(d => `${d.__dimension}__${d.__index}`))
|
| 391 |
+
.range([0, innerHeight])
|
| 392 |
+
.padding(0);
|
| 393 |
+
|
| 394 |
+
const values = sortedData.map(d => d.__value);
|
| 395 |
+
let minValue = Math.min(0, d3.min(values) ?? 0);
|
| 396 |
+
let maxValue = Math.max(0, d3.max(values) ?? 0);
|
| 397 |
+
if (minValue === maxValue) {
|
| 398 |
+
maxValue = maxValue === 0 ? 1 : maxValue * 1.1;
|
| 399 |
+
minValue = 0;
|
| 400 |
+
}
|
| 401 |
+
const xScale = d3.scaleLinear()
|
| 402 |
+
.domain([minValue, maxValue])
|
| 403 |
+
.nice(5)
|
| 404 |
+
.range([0, innerWidth]);
|
| 405 |
+
const zeroX = xScale(0);
|
| 406 |
+
|
| 407 |
+
const panel = svg.append("g")
|
| 408 |
+
.attr("class", "chart-style-panel");
|
| 409 |
+
|
| 410 |
+
panel.append("rect")
|
| 411 |
+
.attr("class", "legend-card")
|
| 412 |
+
.attr("x", cardX)
|
| 413 |
+
.attr("y", cardY)
|
| 414 |
+
.attr("width", cardWidth)
|
| 415 |
+
.attr("height", legendHeight)
|
| 416 |
+
.attr("rx", 8)
|
| 417 |
+
.attr("fill", "#ffffff")
|
| 418 |
+
.attr("stroke", "rgba(47, 143, 58, 0.18)")
|
| 419 |
+
.attr("stroke-width", 1);
|
| 420 |
+
|
| 421 |
+
const legendTitle = `${prettifyHeader(yColumn.name, "Value")} Level`;
|
| 422 |
+
const legendTextColor = "#1f2937";
|
| 423 |
+
const legendY = cardY;
|
| 424 |
+
const legendSeparatorX = cardX + cardWidth * 0.66;
|
| 425 |
+
const legendSegments = 18;
|
| 426 |
+
const legendSegmentStart = cardX + Math.min(118, cardWidth * 0.18);
|
| 427 |
+
const legendSegmentWidth = Math.max(7, Math.min(10, cardWidth * 0.016));
|
| 428 |
+
const legendSegmentGap = Math.max(5, Math.min(8, cardWidth * 0.012));
|
| 429 |
+
const legendSegmentY = legendY + 48;
|
| 430 |
+
|
| 431 |
+
panel.append("text")
|
| 432 |
+
.attr("class", "legend-title")
|
| 433 |
+
.attr("x", cardX + 22)
|
| 434 |
+
.attr("y", legendY + 28)
|
| 435 |
+
.attr("text-anchor", "start")
|
| 436 |
+
.style("font-family", labelFamily)
|
| 437 |
+
.style("font-size", `${Math.max(12, baseLabelSize * 1.05)}px`)
|
| 438 |
+
.style("font-weight", 700)
|
| 439 |
+
.style("fill", legendTextColor)
|
| 440 |
+
.text(legendTitle);
|
| 441 |
+
|
| 442 |
+
panel.append("text")
|
| 443 |
+
.attr("class", "legend-low")
|
| 444 |
+
.attr("x", cardX + 22)
|
| 445 |
+
.attr("y", legendY + 60)
|
| 446 |
+
.attr("text-anchor", "start")
|
| 447 |
+
.style("font-family", labelFamily)
|
| 448 |
+
.style("font-size", `${Math.max(12, baseLabelSize * 1.05)}px`)
|
| 449 |
+
.style("font-weight", 700)
|
| 450 |
+
.style("fill", tableGreen)
|
| 451 |
+
.text("Low");
|
| 452 |
+
|
| 453 |
+
d3.range(legendSegments).forEach(index => {
|
| 454 |
+
panel.append("rect")
|
| 455 |
+
.attr("class", "legend-segment")
|
| 456 |
+
.attr("x", legendSegmentStart + index * (legendSegmentWidth + legendSegmentGap))
|
| 457 |
+
.attr("y", legendSegmentY)
|
| 458 |
+
.attr("width", legendSegmentWidth)
|
| 459 |
+
.attr("height", 24)
|
| 460 |
+
.attr("rx", legendSegmentWidth / 2)
|
| 461 |
+
.attr("fill", d3.interpolateRgb("#dff4bf", tableGreen)(index / Math.max(1, legendSegments - 1)));
|
| 462 |
+
});
|
| 463 |
+
|
| 464 |
+
panel.append("text")
|
| 465 |
+
.attr("class", "legend-high")
|
| 466 |
+
.attr("x", Math.min(legendSeparatorX - 44, legendSegmentStart + legendSegments * (legendSegmentWidth + legendSegmentGap) + 10))
|
| 467 |
+
.attr("y", legendY + 60)
|
| 468 |
+
.attr("text-anchor", "start")
|
| 469 |
+
.style("font-family", labelFamily)
|
| 470 |
+
.style("font-size", `${Math.max(12, baseLabelSize * 1.05)}px`)
|
| 471 |
+
.style("font-weight", 700)
|
| 472 |
+
.style("fill", darkPrimary)
|
| 473 |
+
.text("High");
|
| 474 |
+
|
| 475 |
+
panel.append("line")
|
| 476 |
+
.attr("class", "legend-separator")
|
| 477 |
+
.attr("x1", legendSeparatorX)
|
| 478 |
+
.attr("x2", legendSeparatorX)
|
| 479 |
+
.attr("y1", legendY + 22)
|
| 480 |
+
.attr("y2", legendY + legendHeight - 22)
|
| 481 |
+
.attr("stroke", "rgba(47, 143, 58, 0.18)")
|
| 482 |
+
.attr("stroke-width", 1.5);
|
| 483 |
+
|
| 484 |
+
panel.append("circle")
|
| 485 |
+
.attr("class", "legend-dot")
|
| 486 |
+
.attr("cx", legendSeparatorX + 34)
|
| 487 |
+
.attr("cy", legendY + 42)
|
| 488 |
+
.attr("r", 7)
|
| 489 |
+
.attr("fill", primaryColor)
|
| 490 |
+
.attr("stroke", "#ffffff")
|
| 491 |
+
.attr("stroke-width", 2);
|
| 492 |
+
|
| 493 |
+
panel.append("text")
|
| 494 |
+
.attr("class", "legend-note")
|
| 495 |
+
.attr("x", legendSeparatorX + 58)
|
| 496 |
+
.attr("y", legendY + 35)
|
| 497 |
+
.attr("text-anchor", "start")
|
| 498 |
+
.style("font-family", valueFamily)
|
| 499 |
+
.style("font-size", `${Math.max(10, baseValueSize * 0.9)}px`)
|
| 500 |
+
.style("font-weight", valueWeight)
|
| 501 |
+
.style("fill", "rgba(31, 41, 55, 0.82)")
|
| 502 |
+
.text("Each bar represents");
|
| 503 |
+
panel.append("text")
|
| 504 |
+
.attr("class", "legend-note")
|
| 505 |
+
.attr("x", legendSeparatorX + 58)
|
| 506 |
+
.attr("y", legendY + 58)
|
| 507 |
+
.attr("text-anchor", "start")
|
| 508 |
+
.style("font-family", valueFamily)
|
| 509 |
+
.style("font-size", `${Math.max(10, baseValueSize * 0.9)}px`)
|
| 510 |
+
.style("font-weight", valueWeight)
|
| 511 |
+
.style("fill", "rgba(31, 41, 55, 0.82)")
|
| 512 |
+
.text("the relative level.");
|
| 513 |
+
|
| 514 |
+
panel.append("rect")
|
| 515 |
+
.attr("class", "plot-background")
|
| 516 |
+
.attr("x", cardX)
|
| 517 |
+
.attr("y", tableY)
|
| 518 |
+
.attr("width", cardWidth)
|
| 519 |
+
.attr("height", tableHeight)
|
| 520 |
+
.attr("rx", 8)
|
| 521 |
+
.attr("fill", "#ffffff")
|
| 522 |
+
.attr("stroke", "rgba(47, 143, 58, 0.18)")
|
| 523 |
+
.attr("stroke-width", 1);
|
| 524 |
+
|
| 525 |
+
panel.append("rect")
|
| 526 |
+
.attr("class", "table-header")
|
| 527 |
+
.attr("x", cardX)
|
| 528 |
+
.attr("y", tableY)
|
| 529 |
+
.attr("width", cardWidth)
|
| 530 |
+
.attr("height", headerHeight)
|
| 531 |
+
.attr("rx", 8)
|
| 532 |
+
.attr("fill", tableGreen);
|
| 533 |
+
|
| 534 |
+
panel.append("rect")
|
| 535 |
+
.attr("x", cardX)
|
| 536 |
+
.attr("y", tableY + headerHeight - 8)
|
| 537 |
+
.attr("width", cardWidth)
|
| 538 |
+
.attr("height", 8)
|
| 539 |
+
.attr("fill", tableGreen);
|
| 540 |
+
|
| 541 |
+
[
|
| 542 |
+
cardX + leftColumnWidth,
|
| 543 |
+
cardX + leftColumnWidth + innerWidth
|
| 544 |
+
].forEach(x => {
|
| 545 |
+
panel.append("line")
|
| 546 |
+
.attr("class", "column-separator")
|
| 547 |
+
.attr("x1", x)
|
| 548 |
+
.attr("x2", x)
|
| 549 |
+
.attr("y1", tableY)
|
| 550 |
+
.attr("y2", tableY + tableHeight)
|
| 551 |
+
.attr("stroke", "rgba(47, 143, 58, 0.18)")
|
| 552 |
+
.attr("stroke-width", 1);
|
| 553 |
+
});
|
| 554 |
+
|
| 555 |
+
const headerFontSize = Math.max(11, Math.min(15, baseLabelSize * 1.02));
|
| 556 |
+
const headerIconSize = Math.max(16, Math.min(22, headerHeight * 0.42));
|
| 557 |
+
const levelHeaderText = "Relative Level";
|
| 558 |
+
const headerLabels = [
|
| 559 |
+
{
|
| 560 |
+
text: prettifyHeader(xColumn.name, "Category"),
|
| 561 |
+
x: cardX + 58,
|
| 562 |
+
anchor: "start",
|
| 563 |
+
icon: isTemporal ? "calendar" : "circle",
|
| 564 |
+
iconX: cardX + 24
|
| 565 |
+
},
|
| 566 |
+
{
|
| 567 |
+
text: levelHeaderText,
|
| 568 |
+
x: chartX + innerWidth / 2 + 14,
|
| 569 |
+
anchor: "middle",
|
| 570 |
+
icon: "pulse",
|
| 571 |
+
iconX: chartX + innerWidth / 2 - 92
|
| 572 |
+
},
|
| 573 |
+
{
|
| 574 |
+
text: "Value",
|
| 575 |
+
x: cardX + cardWidth - 22,
|
| 576 |
+
anchor: "end",
|
| 577 |
+
icon: "trend",
|
| 578 |
+
iconX: cardX + cardWidth - rightColumnWidth + 22
|
| 579 |
+
}
|
| 580 |
+
];
|
| 581 |
+
headerLabels.forEach(label => {
|
| 582 |
+
appendMiniIcon(
|
| 583 |
+
panel,
|
| 584 |
+
label.icon,
|
| 585 |
+
label.iconX,
|
| 586 |
+
tableY + headerHeight / 2 - headerIconSize / 2,
|
| 587 |
+
headerIconSize,
|
| 588 |
+
"#ffffff",
|
| 589 |
+
"table-header-icon"
|
| 590 |
+
);
|
| 591 |
+
});
|
| 592 |
+
panel.selectAll(".table-header-label")
|
| 593 |
+
.data(headerLabels)
|
| 594 |
+
.enter()
|
| 595 |
+
.append("text")
|
| 596 |
+
.attr("class", "table-header-label")
|
| 597 |
+
.attr("x", d => d.x)
|
| 598 |
+
.attr("y", tableY + headerHeight / 2)
|
| 599 |
+
.attr("dy", "0.35em")
|
| 600 |
+
.attr("text-anchor", d => d.anchor)
|
| 601 |
+
.style("font-family", labelFamily)
|
| 602 |
+
.style("font-size", `${headerFontSize}px`)
|
| 603 |
+
.style("font-weight", 700)
|
| 604 |
+
.style("fill", "#ffffff")
|
| 605 |
+
.text(d => d.text);
|
| 606 |
+
|
| 607 |
+
const plot = svg.append("g")
|
| 608 |
+
.attr("transform", `translate(${chartX}, ${chartY})`);
|
| 609 |
+
|
| 610 |
+
plot.selectAll(".row-separator")
|
| 611 |
+
.data(sortedData)
|
| 612 |
+
.enter()
|
| 613 |
+
.append("line")
|
| 614 |
+
.attr("class", "row-separator")
|
| 615 |
+
.attr("x1", -leftColumnWidth)
|
| 616 |
+
.attr("x2", innerWidth + rightColumnWidth)
|
| 617 |
+
.attr("y1", d => (yScale(`${d.__dimension}__${d.__index}`) || 0) + yScale.bandwidth())
|
| 618 |
+
.attr("y2", d => (yScale(`${d.__dimension}__${d.__index}`) || 0) + yScale.bandwidth())
|
| 619 |
+
.attr("stroke", "rgba(47, 143, 58, 0.13)")
|
| 620 |
+
.attr("stroke-width", 1);
|
| 621 |
+
|
| 622 |
+
const rowGroups = plot.selectAll(".row")
|
| 623 |
+
.data(sortedData)
|
| 624 |
+
.enter()
|
| 625 |
+
.append("g")
|
| 626 |
+
.attr("class", "row")
|
| 627 |
+
.attr("transform", d => `translate(0, ${yScale(`${d.__dimension}__${d.__index}`)})`);
|
| 628 |
+
|
| 629 |
+
rowGroups.append("rect")
|
| 630 |
+
.attr("class", "row-band")
|
| 631 |
+
.attr("x", -leftColumnWidth)
|
| 632 |
+
.attr("y", 0)
|
| 633 |
+
.attr("width", cardWidth)
|
| 634 |
+
.attr("height", yScale.bandwidth())
|
| 635 |
+
.attr("fill", (d, i) => i % 2 === 0 ? "rgba(47, 143, 58, 0.035)" : "transparent");
|
| 636 |
+
|
| 637 |
+
const trackHeight = Math.max(5, Math.min(9, yScale.bandwidth() * 0.18));
|
| 638 |
+
const dotRadius = Math.max(4.5, Math.min(7.5, yScale.bandwidth() * 0.2));
|
| 639 |
+
const valueFontSize = Math.max(8, Math.min(baseValueSize, yScale.bandwidth() * 0.35));
|
| 640 |
+
const rowCenter = yScale.bandwidth() / 2;
|
| 641 |
+
const trackStart = xScale(Math.max(0, minValue));
|
| 642 |
+
const trackEnd = xScale(maxValue);
|
| 643 |
+
|
| 644 |
+
rowGroups.append("line")
|
| 645 |
+
.attr("class", "dot-bar-track")
|
| 646 |
+
.attr("x1", trackStart)
|
| 647 |
+
.attr("x2", trackEnd)
|
| 648 |
+
.attr("y1", rowCenter)
|
| 649 |
+
.attr("y2", rowCenter)
|
| 650 |
+
.attr("stroke", "rgba(47, 143, 58, 0.11)")
|
| 651 |
+
.attr("stroke-width", trackHeight)
|
| 652 |
+
.attr("stroke-linecap", "round");
|
| 653 |
+
|
| 654 |
+
rowGroups.append("line")
|
| 655 |
+
.attr("class", "dot-bar bar")
|
| 656 |
+
.attr("x1", zeroX)
|
| 657 |
+
.attr("x2", d => xScale(d.__value))
|
| 658 |
+
.attr("y1", rowCenter)
|
| 659 |
+
.attr("y2", rowCenter)
|
| 660 |
+
.attr("stroke", "transparent")
|
| 661 |
+
.attr("stroke-width", 1)
|
| 662 |
+
.attr("stroke-linecap", "round")
|
| 663 |
+
.attr("opacity", 0);
|
| 664 |
+
|
| 665 |
+
const segmentGap = Math.max(7.5, trackHeight * 1.05);
|
| 666 |
+
const segmentWidth = Math.max(2.6, trackHeight * 0.38);
|
| 667 |
+
const segmentHeight = Math.max(8, trackHeight * 1.22);
|
| 668 |
+
rowGroups.each(function(d) {
|
| 669 |
+
const valueX = xScale(d.__value);
|
| 670 |
+
const startX = Math.min(zeroX, valueX);
|
| 671 |
+
const endX = Math.max(zeroX, valueX);
|
| 672 |
+
const span = Math.max(1, endX - startX);
|
| 673 |
+
const segmentXs = d3.range(startX + segmentGap * 0.55, Math.max(startX + segmentGap, endX - dotRadius * 0.65), segmentGap);
|
| 674 |
+
d3.select(this).selectAll(".dot-segment")
|
| 675 |
+
.data(segmentXs)
|
| 676 |
+
.enter()
|
| 677 |
+
.append("rect")
|
| 678 |
+
.attr("class", "dot-segment")
|
| 679 |
+
.attr("x", x => x - segmentWidth / 2)
|
| 680 |
+
.attr("y", rowCenter - segmentHeight / 2)
|
| 681 |
+
.attr("width", segmentWidth)
|
| 682 |
+
.attr("height", segmentHeight)
|
| 683 |
+
.attr("rx", segmentWidth / 2)
|
| 684 |
+
.attr("fill", x => d3.interpolateRgb(palePrimary, primaryColor)(Math.min(1, Math.max(0, (x - startX) / span))))
|
| 685 |
+
.attr("opacity", 0.96);
|
| 686 |
+
});
|
| 687 |
+
|
| 688 |
+
rowGroups.append("circle")
|
| 689 |
+
.attr("class", "dot mark")
|
| 690 |
+
.attr("cx", d => xScale(d.__value))
|
| 691 |
+
.attr("cy", rowCenter)
|
| 692 |
+
.attr("r", dotRadius)
|
| 693 |
+
.attr("fill", primaryColor)
|
| 694 |
+
.attr("stroke", variables.has_stroke ? darkPrimary : "#ffffff")
|
| 695 |
+
.attr("stroke-width", variables.has_stroke ? 1.4 : 1.8)
|
| 696 |
+
.style("filter", variables.has_shadow ? "url(#dot-shadow)" : "none");
|
| 697 |
+
|
| 698 |
+
rowGroups.each(function(d, rowIndex) {
|
| 699 |
+
const lines = wrappedLabels[rowIndex] || [d.__dimension];
|
| 700 |
+
const lineHeight = labelFontSize * 1.08;
|
| 701 |
+
const startY = rowCenter - ((lines.length - 1) * lineHeight) / 2;
|
| 702 |
+
appendMiniIcon(
|
| 703 |
+
d3.select(this),
|
| 704 |
+
isTemporal ? "calendar" : "circle",
|
| 705 |
+
-leftColumnWidth + 22,
|
| 706 |
+
rowCenter - 8,
|
| 707 |
+
16,
|
| 708 |
+
tableGreen,
|
| 709 |
+
"row-icon"
|
| 710 |
+
);
|
| 711 |
+
const label = d3.select(this).append("text")
|
| 712 |
+
.attr("class", "label")
|
| 713 |
+
.attr("x", -leftColumnWidth + 50)
|
| 714 |
+
.attr("y", startY)
|
| 715 |
+
.attr("text-anchor", "start")
|
| 716 |
+
.style("font-family", labelFamily)
|
| 717 |
+
.style("font-size", `${labelFontSize}px`)
|
| 718 |
+
.style("font-weight", labelWeight)
|
| 719 |
+
.style("fill", textColor);
|
| 720 |
+
lines.forEach((line, lineIndex) => {
|
| 721 |
+
label.append("tspan")
|
| 722 |
+
.attr("x", -leftColumnWidth + 50)
|
| 723 |
+
.attr("dy", lineIndex === 0 ? "0.35em" : `${lineHeight}px`)
|
| 724 |
+
.text(line);
|
| 725 |
+
});
|
| 726 |
+
});
|
| 727 |
+
|
| 728 |
+
rowGroups.each(function(d) {
|
| 729 |
+
const [valueNumber, valueSuffix] = splitValueLabel(formatWithUnit(d.__value, valueUnit), valueUnit);
|
| 730 |
+
const value = d3.select(this).append("text")
|
| 731 |
+
.attr("class", "value")
|
| 732 |
+
.attr("x", innerWidth + rightColumnWidth - 22)
|
| 733 |
+
.attr("y", rowCenter)
|
| 734 |
+
.attr("dy", "0.35em")
|
| 735 |
+
.attr("text-anchor", "end")
|
| 736 |
+
.style("font-family", valueFamily)
|
| 737 |
+
.style("font-size", `${valueFontSize}px`)
|
| 738 |
+
.style("fill", textColor);
|
| 739 |
+
value.append("tspan")
|
| 740 |
+
.attr("class", "value-number")
|
| 741 |
+
.style("font-weight", 700)
|
| 742 |
+
.text(valueNumber);
|
| 743 |
+
if (valueSuffix) {
|
| 744 |
+
value.append("tspan")
|
| 745 |
+
.attr("class", "value-unit")
|
| 746 |
+
.style("font-weight", valueWeight)
|
| 747 |
+
.style("fill", "rgba(31, 41, 55, 0.72)")
|
| 748 |
+
.text(` ${valueSuffix}`);
|
| 749 |
+
}
|
| 750 |
+
});
|
| 751 |
+
|
| 752 |
+
panel.append("rect")
|
| 753 |
+
.attr("class", "source-footer")
|
| 754 |
+
.attr("x", cardX)
|
| 755 |
+
.attr("y", footerY)
|
| 756 |
+
.attr("width", cardWidth)
|
| 757 |
+
.attr("height", footerHeight)
|
| 758 |
+
.attr("rx", 8)
|
| 759 |
+
.attr("fill", "rgba(255, 255, 255, 0.74)")
|
| 760 |
+
.attr("stroke", "rgba(47, 143, 58, 0.14)")
|
| 761 |
+
.attr("stroke-width", 1);
|
| 762 |
+
|
| 763 |
+
panel.append("circle")
|
| 764 |
+
.attr("class", "source-footer-icon")
|
| 765 |
+
.attr("cx", cardX + 30)
|
| 766 |
+
.attr("cy", footerY + footerHeight / 2)
|
| 767 |
+
.attr("r", 10)
|
| 768 |
+
.attr("fill", primaryColor)
|
| 769 |
+
.attr("stroke", "#ffffff")
|
| 770 |
+
.attr("stroke-width", 2);
|
| 771 |
+
|
| 772 |
+
panel.append("text")
|
| 773 |
+
.attr("class", "source-footer-label")
|
| 774 |
+
.attr("x", cardX + 30)
|
| 775 |
+
.attr("y", footerY + footerHeight / 2)
|
| 776 |
+
.attr("dy", "0.35em")
|
| 777 |
+
.attr("text-anchor", "middle")
|
| 778 |
+
.style("font-family", valueFamily)
|
| 779 |
+
.style("font-size", `${Math.max(9, baseValueSize * 0.86)}px`)
|
| 780 |
+
.style("font-weight", 700)
|
| 781 |
+
.style("fill", "#ffffff")
|
| 782 |
+
.text("i");
|
| 783 |
+
|
| 784 |
+
panel.append("text")
|
| 785 |
+
.attr("class", "source-footer-text")
|
| 786 |
+
.attr("x", cardX + 56)
|
| 787 |
+
.attr("y", footerY + footerHeight / 2)
|
| 788 |
+
.attr("dy", "0.35em")
|
| 789 |
+
.attr("text-anchor", "start")
|
| 790 |
+
.style("font-family", valueFamily)
|
| 791 |
+
.style("font-size", `${Math.max(9, baseValueSize * 0.86)}px`)
|
| 792 |
+
.style("font-weight", valueWeight)
|
| 793 |
+
.style("fill", "rgba(31, 41, 55, 0.72)")
|
| 794 |
+
.text("Data values are generated from the provided source sample.");
|
| 795 |
+
|
| 796 |
+
return svg.node();
|
| 797 |
+
}
|
modules/chart_engine/template/d3-js/type21_horizontal_dot_bar_chart/horizontal_dot_bar_plain_chart_02.js
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
REQUIREMENTS_BEGIN
|
| 3 |
+
{
|
| 4 |
+
"chart_type": "Horizontal Dot Bar Chart",
|
| 5 |
+
"chart_name": "horizontal_dot_bar_plain_chart_02",
|
| 6 |
+
"is_composite": false,
|
| 7 |
+
"required_fields": ["x", "y"],
|
| 8 |
+
"required_fields_type": [["categorical"], ["numerical"]],
|
| 9 |
+
"required_fields_range": [[5, 30], [0, 20]],
|
| 10 |
+
"required_fields_icons": ["x"],
|
| 11 |
+
"required_other_icons": [],
|
| 12 |
+
"required_fields_colors": [],
|
| 13 |
+
"required_other_colors": [],
|
| 14 |
+
"supported_effects": ["shadow", "gradient", "stroke", "spacing"],
|
| 15 |
+
"min_height": 400,
|
| 16 |
+
"min_width": 400,
|
| 17 |
+
"background": "no",
|
| 18 |
+
"icon_mark": "none",
|
| 19 |
+
"icon_label": "none",
|
| 20 |
+
"has_x_axis": "no",
|
| 21 |
+
"has_y_axis": "no"
|
| 22 |
+
}
|
| 23 |
+
REQUIREMENTS_END
|
| 24 |
+
*/
|
| 25 |
+
|
| 26 |
+
// 百分比竖条图实现 - 使用D3.js
|
| 27 |
+
function makeChart(containerSelector, data) {
|
| 28 |
+
const colorResolver = chartUtils.color.resolver(data);
|
| 29 |
+
// ---------- 1. 数据准备阶段 ----------
|
| 30 |
+
|
| 31 |
+
// 提取数据和配置
|
| 32 |
+
const jsonData = data; // 完整的JSON数据对象
|
| 33 |
+
const chartData = jsonData.data.data // 实际数据点数组
|
| 34 |
+
const variables = jsonData.variables || {}; // 图表配置
|
| 35 |
+
const typography = jsonData.typography || { // 字体设置,如果不存在则使用默认值
|
| 36 |
+
title: { font_family: "Arial", font_size: "18px", font_weight: "bold" },
|
| 37 |
+
label: { font_family: "Arial", font_size: "12px", font_weight: "normal" },
|
| 38 |
+
description: { font_family: "Arial", font_size: "14px", font_weight: "normal" },
|
| 39 |
+
annotation: { font_family: "Arial", font_size: "12px", font_weight: "normal" }
|
| 40 |
+
};
|
| 41 |
+
const colors = jsonData.colors || {
|
| 42 |
+
text_color: "#333333",
|
| 43 |
+
other: { primary: "#FFBB33" } // 默认橙黄色
|
| 44 |
+
};
|
| 45 |
+
const dataColumns = chartUtils.schema.columns(jsonData); // 数据列定义
|
| 46 |
+
|
| 47 |
+
// 设置视觉效果变量的默认值
|
| 48 |
+
variables.has_rounded_corners = variables.has_rounded_corners !== undefined ? variables.has_rounded_corners : true;
|
| 49 |
+
variables.has_shadow = variables.has_shadow !== undefined ? variables.has_shadow : false;
|
| 50 |
+
variables.has_gradient = true; // 确保渐变被启用
|
| 51 |
+
variables.has_stroke = variables.has_stroke !== undefined ? variables.has_stroke : false;
|
| 52 |
+
variables.has_spacing = variables.has_spacing !== undefined ? variables.has_spacing : true;
|
| 53 |
+
|
| 54 |
+
// 数值格式化函数
|
| 55 |
+
|
| 56 |
+
// 计算类别label布局(统一字号+智能换行+测宽+margin.left计算)
|
| 57 |
+
const buildDimLabelLayout = (params) => {
|
| 58 |
+
const {
|
| 59 |
+
containerSelector,
|
| 60 |
+
labels,
|
| 61 |
+
fontConfig,
|
| 62 |
+
maxWidth,
|
| 63 |
+
maxHeight,
|
| 64 |
+
lineSpacing,
|
| 65 |
+
targetFontSize
|
| 66 |
+
} = params;
|
| 67 |
+
|
| 68 |
+
const tempMeasureSvg = d3.select(containerSelector)
|
| 69 |
+
.append("svg")
|
| 70 |
+
.attr("width", 0)
|
| 71 |
+
.attr("height", 0)
|
| 72 |
+
.style("visibility", "hidden");
|
| 73 |
+
|
| 74 |
+
const measureWidth = (text, fontSize) => chartUtils.text.measure(tempMeasureSvg, text, {
|
| 75 |
+
fontFamily: fontConfig.font_family,
|
| 76 |
+
fontSize,
|
| 77 |
+
fontWeight: fontConfig.font_weight
|
| 78 |
+
}).width;
|
| 79 |
+
|
| 80 |
+
const breakLongWord = (word, maxWidth, fontSize) => {
|
| 81 |
+
const lines = [];
|
| 82 |
+
let currentLine = "";
|
| 83 |
+
for (let i = 0; i < word.length; i++) {
|
| 84 |
+
const char = word[i];
|
| 85 |
+
const testWithChar = currentLine + char;
|
| 86 |
+
const testWithHyphen = currentLine + "-";
|
| 87 |
+
if (measureWidth(testWithChar, fontSize) > maxWidth && currentLine !== "") {
|
| 88 |
+
lines.push(testWithHyphen);
|
| 89 |
+
currentLine = char;
|
| 90 |
+
} else {
|
| 91 |
+
currentLine = testWithChar;
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
if (currentLine !== "") lines.push(currentLine);
|
| 95 |
+
return lines.length > 0 ? lines : [word];
|
| 96 |
+
};
|
| 97 |
+
|
| 98 |
+
const breakWordIntoCurrentLine = (currentLine, word, maxWidth, fontSize) => {
|
| 99 |
+
const completedLines = [];
|
| 100 |
+
let chars = "";
|
| 101 |
+
for (let i = 0; i < word.length; i++) {
|
| 102 |
+
const char = word[i];
|
| 103 |
+
const testLine = currentLine + chars + char;
|
| 104 |
+
const testLineWithHyphen = currentLine + chars + "-";
|
| 105 |
+
if (measureWidth(testLine, fontSize) > maxWidth) {
|
| 106 |
+
if (chars === "") {
|
| 107 |
+
completedLines.push(currentLine);
|
| 108 |
+
const brokenLines = breakLongWord(word, maxWidth, fontSize);
|
| 109 |
+
return {
|
| 110 |
+
completedLines: [...completedLines, ...brokenLines.slice(0, -1)],
|
| 111 |
+
remaining: brokenLines[brokenLines.length - 1]
|
| 112 |
+
};
|
| 113 |
+
}
|
| 114 |
+
completedLines.push(testLineWithHyphen);
|
| 115 |
+
const remaining = word.substring(i);
|
| 116 |
+
const remainingWidth = measureWidth(remaining, fontSize);
|
| 117 |
+
if (remainingWidth > maxWidth) {
|
| 118 |
+
const brokenLines = breakLongWord(remaining, maxWidth, fontSize);
|
| 119 |
+
return {
|
| 120 |
+
completedLines: [...completedLines, ...brokenLines.slice(0, -1)],
|
| 121 |
+
remaining: brokenLines[brokenLines.length - 1]
|
| 122 |
+
};
|
| 123 |
+
}
|
| 124 |
+
return { completedLines, remaining };
|
| 125 |
+
}
|
| 126 |
+
chars += char;
|
| 127 |
+
}
|
| 128 |
+
return { completedLines, remaining: currentLine + chars };
|
| 129 |
+
};
|
| 130 |
+
|
| 131 |
+
const wrapSmart = (text, maxWidth, fontSize) => {
|
| 132 |
+
const words = String(text || "").split(/\s+/).filter(w => w !== "");
|
| 133 |
+
if (words.length === 0) return [String(text || "")];
|
| 134 |
+
|
| 135 |
+
const lines = [];
|
| 136 |
+
let currentLine = "";
|
| 137 |
+
|
| 138 |
+
for (let i = 0; i < words.length; i++) {
|
| 139 |
+
const word = words[i];
|
| 140 |
+
const separator = currentLine === "" ? "" : " ";
|
| 141 |
+
const testLine = currentLine + separator + word;
|
| 142 |
+
if (measureWidth(testLine, fontSize) <= maxWidth) {
|
| 143 |
+
currentLine = testLine;
|
| 144 |
+
continue;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
if (currentLine === "") {
|
| 148 |
+
const broken = breakLongWord(word, maxWidth, fontSize);
|
| 149 |
+
lines.push(...broken.slice(0, -1));
|
| 150 |
+
currentLine = broken[broken.length - 1];
|
| 151 |
+
continue;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
const utilization = measureWidth(currentLine, fontSize) / maxWidth;
|
| 155 |
+
if (utilization < 0.5) {
|
| 156 |
+
const result = breakWordIntoCurrentLine(currentLine, word, maxWidth, fontSize);
|
| 157 |
+
lines.push(...result.completedLines);
|
| 158 |
+
currentLine = result.remaining;
|
| 159 |
+
continue;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
lines.push(currentLine);
|
| 163 |
+
if (measureWidth(word, fontSize) > maxWidth) {
|
| 164 |
+
const broken = breakLongWord(word, maxWidth, fontSize);
|
| 165 |
+
lines.push(...broken.slice(0, -1));
|
| 166 |
+
currentLine = broken[broken.length - 1];
|
| 167 |
+
} else {
|
| 168 |
+
currentLine = word;
|
| 169 |
+
}
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
if (currentLine !== "") lines.push(currentLine);
|
| 173 |
+
|
| 174 |
+
const out = [...lines];
|
| 175 |
+
for (let i = 1; i < out.length; i++) {
|
| 176 |
+
const prevTrim = (out[i - 1] || "").trimEnd();
|
| 177 |
+
if (!prevTrim.endsWith("-")) continue;
|
| 178 |
+
|
| 179 |
+
const curTrim = String(out[i] || "").trim();
|
| 180 |
+
const match = curTrim.match(/^([A-Za-z])(\s+.*)?$/);
|
| 181 |
+
if (!match) continue;
|
| 182 |
+
|
| 183 |
+
const firstLetter = match[1];
|
| 184 |
+
const rest = (match[2] || "").trimStart();
|
| 185 |
+
const candidatePrev = prevTrim.slice(0, -1) + firstLetter;
|
| 186 |
+
if (measureWidth(candidatePrev, fontSize) > maxWidth) continue;
|
| 187 |
+
|
| 188 |
+
out[i - 1] = candidatePrev;
|
| 189 |
+
if (rest === "") {
|
| 190 |
+
out.splice(i, 1);
|
| 191 |
+
i -= 1;
|
| 192 |
+
} else {
|
| 193 |
+
out[i] = rest;
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
return out.length > 0 ? out : [String(text || "")];
|
| 198 |
+
};
|
| 199 |
+
|
| 200 |
+
const fitsAll = (fontSize) => {
|
| 201 |
+
const lineHeight = fontSize * lineSpacing;
|
| 202 |
+
for (let i = 0; i < labels.length; i++) {
|
| 203 |
+
const labelText = String(labels[i] || "");
|
| 204 |
+
const lines = wrapSmart(labelText, maxWidth, fontSize);
|
| 205 |
+
const totalHeight = (lines.length <= 1)
|
| 206 |
+
? fontSize
|
| 207 |
+
: ((lines.length - 1) * lineHeight + fontSize);
|
| 208 |
+
if (totalHeight > maxHeight) return false;
|
| 209 |
+
}
|
| 210 |
+
return true;
|
| 211 |
+
};
|
| 212 |
+
|
| 213 |
+
let low = 1;
|
| 214 |
+
let high = targetFontSize;
|
| 215 |
+
for (let i = 0; i < 18; i++) {
|
| 216 |
+
const mid = (low + high) / 2;
|
| 217 |
+
if (fitsAll(mid)) low = mid;
|
| 218 |
+
else high = mid;
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
const bestFontSize = low;
|
| 222 |
+
const lineHeight = bestFontSize * lineSpacing;
|
| 223 |
+
const dimLabelConfig = {};
|
| 224 |
+
let maxLabelWidth = 0;
|
| 225 |
+
|
| 226 |
+
for (let i = 0; i < labels.length; i++) {
|
| 227 |
+
const label = labels[i];
|
| 228 |
+
const labelText = String(label || "");
|
| 229 |
+
const lines = wrapSmart(labelText, maxWidth, bestFontSize);
|
| 230 |
+
dimLabelConfig[label] = {
|
| 231 |
+
fontSize: bestFontSize,
|
| 232 |
+
lineHeight: lineHeight,
|
| 233 |
+
lines: lines,
|
| 234 |
+
needsWrapping: lines.length > 1
|
| 235 |
+
};
|
| 236 |
+
for (let j = 0; j < lines.length; j++) {
|
| 237 |
+
maxLabelWidth = Math.max(maxLabelWidth, measureWidth(lines[j], bestFontSize));
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
tempMeasureSvg.remove();
|
| 242 |
+
return { dimLabelConfig, maxLabelWidth, fontSize: bestFontSize, lineHeight };
|
| 243 |
+
};
|
| 244 |
+
|
| 245 |
+
// 清空容器
|
| 246 |
+
d3.select(containerSelector).html("");
|
| 247 |
+
|
| 248 |
+
// ---------- 2. 尺寸和布局设置 ----------
|
| 249 |
+
|
| 250 |
+
// 设置图表总尺寸
|
| 251 |
+
const width = variables.width || 800;
|
| 252 |
+
const height = variables.height || 600;
|
| 253 |
+
|
| 254 |
+
// 设置边距
|
| 255 |
+
const margin = {
|
| 256 |
+
top: 90, // 顶部留出标题空间
|
| 257 |
+
right: 30, // 右侧足够显示数值
|
| 258 |
+
bottom: 40, // 底部边距
|
| 259 |
+
left: 100 // 左侧足够空间用于维度标签
|
| 260 |
+
};
|
| 261 |
+
|
| 262 |
+
// ---------- 3. 提取字段名和单位 ----------
|
| 263 |
+
|
| 264 |
+
// 根据数据列顺序提取字段名
|
| 265 |
+
const dimensionField = chartUtils.schema.channel(jsonData, "x").key || "dimension";
|
| 266 |
+
const valueField = chartUtils.schema.channel(jsonData, "y").key || "value";
|
| 267 |
+
|
| 268 |
+
// 获取字段单位(如果存在)
|
| 269 |
+
let dimensionUnit = "";
|
| 270 |
+
let valueUnit = ""; // 默认为百分比
|
| 271 |
+
|
| 272 |
+
if (chartUtils.schema.channel(jsonData, "x").unit !== "none") {
|
| 273 |
+
dimensionUnit = chartUtils.schema.channel(jsonData, "x").unit || "";
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
if (chartUtils.schema.channel(jsonData, "y").unit !== "none") {
|
| 277 |
+
valueUnit = chartUtils.schema.channel(jsonData, "y").unit || "";
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
// ---------- 4. 数据处理 ----------
|
| 281 |
+
|
| 282 |
+
// 获取唯一维度值并按数值降序排列数据
|
| 283 |
+
const dimensions = [...new Set(chartData.map(d => d[dimensionField]))];
|
| 284 |
+
const maxValue_ = d3.max(chartData, d => Math.abs(+d[valueField]));
|
| 285 |
+
|
| 286 |
+
// 计算每个棒子表示的单位数量
|
| 287 |
+
// 目标:最大值对应的棒子数量在20-50之间
|
| 288 |
+
const targetMaxBars = 35; // 选择目标范围(20-50)的中间值
|
| 289 |
+
const unitPerBar = Math.ceil(maxValue_ / targetMaxBars);
|
| 290 |
+
|
| 291 |
+
// 为每个数据点计算应显示的棒子数量
|
| 292 |
+
chartData.forEach(d => {
|
| 293 |
+
// 原始数值保存在 valueField
|
| 294 |
+
// 转换后的棒子数量保存在 valueField_
|
| 295 |
+
d[`${valueField}_`] = Math.max(0.1, +d[valueField] / unitPerBar);
|
| 296 |
+
});
|
| 297 |
+
|
| 298 |
+
// 按数值降序排序数据
|
| 299 |
+
const sortedData = [...chartData].sort((a, b) => +b[valueField] - +a[valueField]);
|
| 300 |
+
const sortedDimensions = sortedData.map(d => d[dimensionField]);
|
| 301 |
+
|
| 302 |
+
// ---------- 5. 布局计算和字体调整 ----------
|
| 303 |
+
|
| 304 |
+
const dimLabelMaxWidth = width * 0.25;
|
| 305 |
+
const dimLabelLineSpacing = 1.01;
|
| 306 |
+
const targetLabelFontSize = parseFloat(typography.label.font_size) || 12;
|
| 307 |
+
|
| 308 |
+
// 计算内部绘图区域尺寸(初始值)
|
| 309 |
+
let innerWidth = width - margin.left - margin.right;
|
| 310 |
+
let innerHeight = height - margin.top - margin.bottom;
|
| 311 |
+
|
| 312 |
+
// 计算行间距
|
| 313 |
+
const rowPadding = variables.has_spacing ? 0.3 : 0.2;
|
| 314 |
+
|
| 315 |
+
// Y轴比例尺(用于维度)
|
| 316 |
+
const yScale = d3.scaleBand()
|
| 317 |
+
.domain(sortedDimensions)
|
| 318 |
+
.range([0, innerHeight])
|
| 319 |
+
.padding(rowPadding);
|
| 320 |
+
|
| 321 |
+
const rowHeight = yScale.bandwidth();
|
| 322 |
+
|
| 323 |
+
// 调用智能换行布局计算
|
| 324 |
+
const dimLabelResult = buildDimLabelLayout({
|
| 325 |
+
containerSelector: containerSelector,
|
| 326 |
+
labels: sortedDimensions,
|
| 327 |
+
fontConfig: typography.label,
|
| 328 |
+
maxWidth: dimLabelMaxWidth,
|
| 329 |
+
maxHeight: rowHeight,
|
| 330 |
+
lineSpacing: dimLabelLineSpacing,
|
| 331 |
+
targetFontSize: targetLabelFontSize
|
| 332 |
+
});
|
| 333 |
+
|
| 334 |
+
const { dimLabelConfig, maxLabelWidth } = dimLabelResult;
|
| 335 |
+
|
| 336 |
+
// 根据标签宽度调整左边距
|
| 337 |
+
margin.left = maxLabelWidth + 15;
|
| 338 |
+
|
| 339 |
+
// 重新计算内部绘图区域尺寸
|
| 340 |
+
innerWidth = width - margin.left - margin.right;
|
| 341 |
+
innerHeight = height - margin.top - margin.bottom;
|
| 342 |
+
|
| 343 |
+
// ---------- 6. 创建SVG容器 ----------
|
| 344 |
+
|
| 345 |
+
const svg = d3.select(containerSelector)
|
| 346 |
+
.append("svg")
|
| 347 |
+
.attr("width", "100%")
|
| 348 |
+
.attr("height", height)
|
| 349 |
+
.attr("viewBox", `0 0 ${width} ${height}`)
|
| 350 |
+
.attr("style", "max-width: 100%; height: auto;")
|
| 351 |
+
.attr("xmlns", "http://www.w3.org/2000/svg")
|
| 352 |
+
.attr("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
// 添加defs用于视觉效果
|
| 356 |
+
const defs = svg.append("defs");
|
| 357 |
+
|
| 358 |
+
// 添加阴影滤镜(如果启用)
|
| 359 |
+
if (variables.has_shadow) {
|
| 360 |
+
const filter = defs.append("filter")
|
| 361 |
+
.attr("id", "shadow")
|
| 362 |
+
.attr("filterUnits", "userSpaceOnUse")
|
| 363 |
+
.attr("width", "200%")
|
| 364 |
+
.attr("height", "200%");
|
| 365 |
+
|
| 366 |
+
filter.append("feGaussianBlur")
|
| 367 |
+
.attr("in", "SourceAlpha")
|
| 368 |
+
.attr("stdDeviation", 2);
|
| 369 |
+
|
| 370 |
+
filter.append("feOffset")
|
| 371 |
+
.attr("dx", 2)
|
| 372 |
+
.attr("dy", 2)
|
| 373 |
+
.attr("result", "offsetblur");
|
| 374 |
+
|
| 375 |
+
const feMerge = filter.append("feMerge");
|
| 376 |
+
feMerge.append("feMergeNode");
|
| 377 |
+
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
// 获取主要颜色
|
| 381 |
+
const primaryColor = colorResolver.other("primary", { fallback: "#FFBB33" }).value;
|
| 382 |
+
|
| 383 |
+
// 创建每10个棒子一组的渐变定义
|
| 384 |
+
const maxGroups = 10; // 假设最多10组(即最大100%)
|
| 385 |
+
for (let group = 0; group < maxGroups; group++) {
|
| 386 |
+
// 为每组创建渐变
|
| 387 |
+
const gradient = defs.append("linearGradient")
|
| 388 |
+
.attr("id", `bar-gradient-${group}`)
|
| 389 |
+
.attr("x1", "0%")
|
| 390 |
+
.attr("y1", "0%")
|
| 391 |
+
.attr("x2", "100%")
|
| 392 |
+
.attr("y2", "0%");
|
| 393 |
+
|
| 394 |
+
// 计算此组的亮度偏移
|
| 395 |
+
// 随着组号增加,整体颜色变深
|
| 396 |
+
const groupDarkenFactor = 0.2 * group;
|
| 397 |
+
|
| 398 |
+
gradient.append("stop")
|
| 399 |
+
.attr("offset", "0%")
|
| 400 |
+
.attr("stop-color", colorResolver.variant(primaryColor, { mode: "brighter", amount: 1.0 - groupDarkenFactor }));
|
| 401 |
+
|
| 402 |
+
gradient.append("stop")
|
| 403 |
+
.attr("offset", "100%")
|
| 404 |
+
.attr("stop-color", colorResolver.variant(primaryColor, { mode: "darker", amount: 0.0 + groupDarkenFactor }));
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
// ---------- 7. 创建比例尺 ----------
|
| 408 |
+
|
| 409 |
+
// 计算最大值,确保所有棒子能够显示
|
| 410 |
+
const maxValue = d3.max(chartData, d => +d[`${valueField}_`]);
|
| 411 |
+
|
| 412 |
+
// 计算适合当前宽度的棒子尺寸
|
| 413 |
+
const availableWidth = innerWidth;
|
| 414 |
+
|
| 415 |
+
// 计算合适的棒子尺寸,确保所有数据都能显示
|
| 416 |
+
// 默认值
|
| 417 |
+
const defaultBarWidth = 30;
|
| 418 |
+
const defaultBarSpacing = 5;
|
| 419 |
+
const largerGroupSpacing = 15; // 每10个棒子后的额外间距
|
| 420 |
+
|
| 421 |
+
// 棒子组大小(每组10个)
|
| 422 |
+
const groupSize = 5;
|
| 423 |
+
|
| 424 |
+
// 计算一组10个棒子需要的宽度(包括普通间距)
|
| 425 |
+
const groupBaseWidth = groupSize * defaultBarWidth + (groupSize - 1) * defaultBarSpacing;
|
| 426 |
+
|
| 427 |
+
// 计算需要多少组
|
| 428 |
+
const requiredGroups = Math.ceil(maxValue / groupSize);
|
| 429 |
+
|
| 430 |
+
// 计算总需要宽度(包括组间额外间距)
|
| 431 |
+
const requiredWidth = requiredGroups * groupBaseWidth + (requiredGroups - 1) * largerGroupSpacing;
|
| 432 |
+
|
| 433 |
+
// 如果需要的宽度大于可用宽度,则缩放
|
| 434 |
+
let barWidth, barSpacing, groupSpacing;
|
| 435 |
+
|
| 436 |
+
if (requiredWidth > availableWidth) {
|
| 437 |
+
const scaleFactor = availableWidth / requiredWidth;
|
| 438 |
+
barWidth = Math.max(3, Math.floor(defaultBarWidth * scaleFactor));
|
| 439 |
+
barSpacing = Math.max(1, Math.floor(defaultBarSpacing * scaleFactor));
|
| 440 |
+
groupSpacing = Math.max(3, Math.floor(largerGroupSpacing * scaleFactor));
|
| 441 |
+
} else {
|
| 442 |
+
barWidth = defaultBarWidth;
|
| 443 |
+
barSpacing = defaultBarSpacing;
|
| 444 |
+
groupSpacing = largerGroupSpacing;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
// ---------- 8. 创建主图表组 ----------
|
| 448 |
+
|
| 449 |
+
const g = svg.append("g")
|
| 450 |
+
.attr("transform", `translate(${margin.left}, ${margin.top})`);
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
// ---------- 10. 绘制百分比竖条图和标签 ----------
|
| 455 |
+
|
| 456 |
+
// 为每个维度绘制竖条组和标签
|
| 457 |
+
sortedDimensions.forEach(dimension => {
|
| 458 |
+
const dataPoint = chartData.find(d => d[dimensionField] === dimension);
|
| 459 |
+
|
| 460 |
+
if (dataPoint) {
|
| 461 |
+
const rowHeight = yScale.bandwidth();
|
| 462 |
+
const barHeight = rowHeight; // 竖条高度为行高
|
| 463 |
+
const barY = yScale(dimension) + (rowHeight - barHeight) / 2; // 竖条垂直居中
|
| 464 |
+
const barCount = Math.floor(dataPoint[`${valueField}_`]); // 向下取整到整数
|
| 465 |
+
const partialBar = dataPoint[`${valueField}_`] - barCount; // 计算小数部分
|
| 466 |
+
|
| 467 |
+
// 绘制竖条组
|
| 468 |
+
for (let i = 0; i < barCount; i++) {
|
| 469 |
+
// 计算当前棒子所在的组(每5个一组)
|
| 470 |
+
const groupIndex = Math.floor(i / groupSize);
|
| 471 |
+
// 计算在当前组内的索引(0-4)
|
| 472 |
+
const inGroupIndex = i % groupSize;
|
| 473 |
+
|
| 474 |
+
// 计算x位置(考虑组间的额外间距)
|
| 475 |
+
const barX = (groupIndex * (groupSize * (barWidth + barSpacing) + groupSpacing)) +
|
| 476 |
+
(inGroupIndex * (barWidth + barSpacing));
|
| 477 |
+
|
| 478 |
+
// 获取当前组的渐变ID
|
| 479 |
+
const gradientId = `bar-gradient-${groupIndex}`;
|
| 480 |
+
|
| 481 |
+
// 绘制单个竖条
|
| 482 |
+
g.append("image")
|
| 483 |
+
.attr("x", barX)
|
| 484 |
+
.attr("y", barY)
|
| 485 |
+
.attr("width", barWidth)
|
| 486 |
+
.attr("height", barHeight)
|
| 487 |
+
.attr("xlink:href", jsonData.images.field[dataPoint[dimensionField]]);
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
// 如果有小数部分,绘制部分宽度的最后一个竖条
|
| 491 |
+
if (partialBar > 0) {
|
| 492 |
+
const groupIndex = Math.floor(barCount / groupSize);
|
| 493 |
+
const inGroupIndex = barCount % groupSize;
|
| 494 |
+
|
| 495 |
+
const barX = (groupIndex * (groupSize * (barWidth + barSpacing) + groupSpacing)) +
|
| 496 |
+
(inGroupIndex * (barWidth + barSpacing));
|
| 497 |
+
|
| 498 |
+
// 使用SVG裁剪方式实现部分图像显示
|
| 499 |
+
const imageWidth = barWidth * partialBar;
|
| 500 |
+
|
| 501 |
+
// 方法一:使用裁剪路径实现部分图像显示
|
| 502 |
+
// 创建唯一的裁剪路径ID
|
| 503 |
+
const clipPathId = `clip-path-${dimension.replace(/\s+/g, '-')}-${barCount}`;
|
| 504 |
+
|
| 505 |
+
// 在defs中定义裁剪路径 - 注意:clipPath采用用户坐标系统
|
| 506 |
+
defs.append("clipPath")
|
| 507 |
+
.attr("id", clipPathId)
|
| 508 |
+
.attr("clipPathUnits", "userSpaceOnUse") // 使用用户坐标系而非对象坐标系
|
| 509 |
+
.append("rect")
|
| 510 |
+
.attr("x", barX) // 使用与图像相同的x坐标
|
| 511 |
+
.attr("y", barY) // 使用与图像相同的y坐标
|
| 512 |
+
.attr("width", imageWidth)
|
| 513 |
+
.attr("height", barHeight);
|
| 514 |
+
|
| 515 |
+
// 使用裁剪路径显示部分图像
|
| 516 |
+
g.append("image")
|
| 517 |
+
.attr("x", barX)
|
| 518 |
+
.attr("y", barY)
|
| 519 |
+
.attr("width", barWidth) // 保持原始宽度
|
| 520 |
+
.attr("height", barHeight)
|
| 521 |
+
.attr("clip-path", `url(#${clipPathId})`)
|
| 522 |
+
.attr("xlink:href", jsonData.images.field[dataPoint[dimensionField]]);
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
// 添加维度标签(支持多行)
|
| 526 |
+
const labelConfig = dimLabelConfig[dimension];
|
| 527 |
+
const centerY = yScale(dimension) + rowHeight / 2;
|
| 528 |
+
const labelGroup = g.append("text")
|
| 529 |
+
.attr("x", -10)
|
| 530 |
+
.attr("text-anchor", "end")
|
| 531 |
+
.style("font-family", typography.label.font_family)
|
| 532 |
+
.style("font-size", `${labelConfig.fontSize}px`)
|
| 533 |
+
.style("font-weight", typography.label.font_weight)
|
| 534 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value);
|
| 535 |
+
|
| 536 |
+
if (labelConfig.needsWrapping) {
|
| 537 |
+
const lineHeight = labelConfig.lineHeight;
|
| 538 |
+
const startY = centerY - ((labelConfig.lines.length - 1) * lineHeight) / 2;
|
| 539 |
+
labelGroup.attr("y", startY);
|
| 540 |
+
|
| 541 |
+
labelConfig.lines.forEach((line, i) => {
|
| 542 |
+
labelGroup.append("tspan")
|
| 543 |
+
.attr("x", -10)
|
| 544 |
+
.attr("dy", i === 0 ? "0.35em" : `${lineHeight}`)
|
| 545 |
+
.text(line);
|
| 546 |
+
});
|
| 547 |
+
} else {
|
| 548 |
+
labelGroup
|
| 549 |
+
.attr("y", centerY)
|
| 550 |
+
.attr("dy", "0.35em")
|
| 551 |
+
.text(labelConfig.lines[0]);
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
// 计算动态字体大小(条形高度的60%)
|
| 555 |
+
const dynamicFontSize = `${barHeight * 0.8}px`;
|
| 556 |
+
|
| 557 |
+
// 计算最后一个棒子的位置,用于放置数值标签
|
| 558 |
+
const lastGroupIndex = Math.floor(barCount / groupSize);
|
| 559 |
+
const lastInGroupIndex = barCount % groupSize;
|
| 560 |
+
let lastBarX = (lastGroupIndex * (groupSize * (barWidth + barSpacing) + groupSpacing)) +
|
| 561 |
+
(lastInGroupIndex * (barWidth + barSpacing));
|
| 562 |
+
|
| 563 |
+
// 如果有小数部分,调整标签位置
|
| 564 |
+
if (partialBar > 0) {
|
| 565 |
+
lastBarX += barWidth * partialBar;
|
| 566 |
+
} else {
|
| 567 |
+
lastBarX += barWidth;
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
g.append("text")
|
| 571 |
+
.attr("x", lastBarX + 5)
|
| 572 |
+
.attr("y", yScale(dimension) + rowHeight / 2)
|
| 573 |
+
.attr("dy", "0.35em")
|
| 574 |
+
.attr("text-anchor", "start")
|
| 575 |
+
.style("font-family", typography.annotation.font_family)
|
| 576 |
+
.style("font-size", dynamicFontSize)
|
| 577 |
+
.style("font-weight", typography.annotation.font_weight)
|
| 578 |
+
.style("fill", colorResolver.text({ fallback: "#333333" }).value)
|
| 579 |
+
.text(valueUnit ?
|
| 580 |
+
`${chartUtils.format.autoText(+dataPoint[valueField])}${valueUnit}` :
|
| 581 |
+
`${chartUtils.format.autoText(+dataPoint[valueField])}`);
|
| 582 |
+
}
|
| 583 |
+
});
|
| 584 |
+
|
| 585 |
+
// 返回SVG节点
|
| 586 |
+
return svg.node();
|
| 587 |
+
}
|