Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| from pathlib import Path | |
| import re | |
| ROOT = Path(__file__).resolve().parents[1] | |
| TEMPLATE_ROOT = ROOT / "modules/chart_engine/template/d3-js" | |
| def subn(pattern: str, repl, text: str, count: int = 0) -> tuple[str, int]: | |
| return re.subn(pattern, repl, text, count=count, flags=re.MULTILINE) | |
| def remove_legend_width_measure(text: str) -> tuple[str, int]: | |
| pattern = ( | |
| r"\n\s*//[^\n]*图例项宽度[^\n]*\n" | |
| r"\s*const legendItemWidths = \[\];\s*\n" | |
| r"\s*let totalLegendWidth = 0;\s*\n" | |
| r"\s*const legendPadding = \d+[^\n]*\n" | |
| r"\s*\w+\.forEach\(\w+ => \{\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*totalLegendWidth \+= legendItemWidth;\s*\n" | |
| r"\s*tempText\.remove\(\);\s*\n" | |
| r"\s*\}\);\s*\n" | |
| ) | |
| return subn(pattern, "\n", text) | |
| def replace_centered_rect_legend(text: str) -> tuple[str, int]: | |
| pattern = ( | |
| r"\n\s*//[^\n]*图例[^\n]*\n" | |
| r"(?:\s*//[^\n]*\n)?" | |
| r"\s*const legend = svg\.append\(\"g\"\)\s*\n" | |
| r"\s*\.attr\(\"class\", \"legend\"\)\s*\n" | |
| r"\s*\.attr\(\"transform\", `translate\(\$\{width / 2\}, \$\{margin\.top / 2\}\)`\);\s*\n" | |
| r"\s*(?://[^\n]*\n)?" | |
| r"\s*let legendOffset = 0;\s*\n" | |
| r"\s*(\w+)\.forEach\(\((\w+), i\) => \{\s*\n" | |
| r"\s*const legendItem = legend\.append\(\"g\"\)\s*\n" | |
| r"\s*\.attr\(\"transform\", `translate\(\$\{legendOffset\}, 0\)`\);\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*legendOffset \+= legendItemWidths\[i\];\s*\n" | |
| r"\s*\}\);\s*\n" | |
| r"\s*//[^\n]*\n" | |
| r"\s*legend\.attr\(\"transform\", `translate\(\$\{\(width - totalLegendWidth\) / 2\}, \$\{margin\.top / 2\}\)`\);\s*\n" | |
| ) | |
| def repl(m: re.Match) -> str: | |
| gv = m.group(1) | |
| return ( | |
| f"\n chartUtils.legend.centered(svg, {gv}, {{\n" | |
| f" colorScale,\n" | |
| f" colorResolver,\n" | |
| f" colors,\n" | |
| f" }}, width / 2, margin.top / 2, {{\n" | |
| f" markerShape: \"rect\",\n" | |
| f" markerSize: 15,\n" | |
| f" labelGap: 5,\n" | |
| f" itemGap: legendPadding,\n" | |
| f" fontSize: typography.label.font_size,\n" | |
| f" fontFamily: typography.label.font_family,\n" | |
| f" fontWeight: typography.label.font_weight,\n" | |
| f" textColor: colorResolver.text({{ fallback: \"#333333\" }}).value,\n" | |
| f" markerRadius: variables.has_rounded_corners ? 2 : 0,\n" | |
| f" }});\n" | |
| ) | |
| return subn(pattern, repl, text) | |
| def replace_legend_g_wrap(text: str) -> tuple[str, int]: | |
| pattern = ( | |
| r"\n\s*//[^\n]*图例[^\n]*\n" | |
| r"\s*const legendG = svg\.append\(\"g\"\)\s*\n" | |
| r"\s*\.attr\(\"class\", \"legend\"\)\s*\n" | |
| r"\s*\.attr\(\"transform\", `translate\(\$\{margin\.left\}, 15\)`\);\s*\n" | |
| r"\s*//[^\n]*\n" | |
| r"\s*let legendX = 0;\s*\n" | |
| r"\s*let legendY = 0;\s*\n" | |
| r"\s*const legendSpacing = (?P<gap>\d+);\s*\n" | |
| r"\s*(\w+)\.forEach\(\((\w+), i\) => \{\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*legendX \+= itemWidth \+ legendSpacing;\s*\n" | |
| r"\s*\}\);\s*\n" | |
| ) | |
| def repl(m: re.Match) -> str: | |
| gv = m.group(2) | |
| gap = m.group("gap") | |
| return ( | |
| f"\n chartUtils.legend.draw(svg, {gv}, {{\n" | |
| f" colorScale,\n" | |
| f" colorResolver,\n" | |
| f" colors,\n" | |
| f" }}, {{\n" | |
| f" x: margin.left,\n" | |
| f" y: 15,\n" | |
| f" maxWidth: maxRowWidth,\n" | |
| f" markerShape: \"rect\",\n" | |
| f" markerSize: 12,\n" | |
| f" labelGap: 5,\n" | |
| f" itemGap: {gap},\n" | |
| f" rowGap: itemHeight,\n" | |
| f" fontSize: 12,\n" | |
| f" fontFamily: typography.label.font_family,\n" | |
| f" fontWeight: typography.label.font_weight,\n" | |
| f" textColor: colorResolver.text({{ fallback: \"#333333\" }}).value,\n" | |
| f" }});\n" | |
| ) | |
| return subn(pattern, repl, text) | |
| def replace_fullw_centered_legend(text: str) -> tuple[str, int]: | |
| pattern = ( | |
| r"\n\s*//[^\n]*图例[^\n]*\n" | |
| r"\s*const legend = svg\.append\(\"g\"\)\s*\n" | |
| r"\s*\.attr\(\"class\", \"legend\"\)\s*\n" | |
| r"\s*\.attr\(\"transform\", `translate\(\$\{fullW/2\}, 20\)`\);\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*\}\);\s*\n" | |
| r"(?=\s*//[^\n]*确保图例|/\* =+)" | |
| ) | |
| if "fullW/2" not in text or "legendItemWidth = 100" not in text and "legendItemWidth = 80" not in text: | |
| return text, 0 | |
| def repl(_: re.Match) -> str: | |
| return ( | |
| "\n chartUtils.legend.centered(svg, uniqueGroups, {\n" | |
| " colorScale,\n" | |
| " colorResolver,\n" | |
| " colors,\n" | |
| " }, fullW / 2, 20, {\n" | |
| " markerShape: \"rect\",\n" | |
| " markerSize: 12,\n" | |
| " labelGap: 5,\n" | |
| " itemGap: 20,\n" | |
| " fontSize: typography.label.font_size,\n" | |
| " fontFamily: typography.label.font_family,\n" | |
| " fontWeight: typography.label.font_weight,\n" | |
| " textColor: colorResolver.text({ fallback: \"#333333\" }).value,\n" | |
| " });\n" | |
| ) | |
| return subn(pattern, repl, text, count=1) | |
| def replace_pair_legend(text: str) -> tuple[str, int]: | |
| if "leftLegendX" not in text: | |
| return text, 0 | |
| text2 = re.sub( | |
| r"\n\s*// 创建临时SVG容器用于测量组标签文本宽度[\s\S]*?tempLabelSvg\.remove\(\);\s*\n", | |
| "\n", | |
| text, | |
| count=1, | |
| ) | |
| text2 = re.sub( | |
| r"\n\s*// 格式化组标签\s*\n\s*const formattedLeftGroup[\s\S]*?tempRightText\.remove\(\);\s*\n", | |
| "\n", | |
| text2, | |
| count=1, | |
| ) | |
| block = re.search( | |
| r"\n\s*// 计算图例左右位置[\s\S]*?\.text\(formattedRightGroup\);\s*\n", | |
| text2, | |
| ) | |
| if not block: | |
| return text, 0 | |
| if "chartUtils.legend.pair" in text: | |
| return text, 0 | |
| size_m = re.search(r"const legendSquareSize = (\d+)", text2) | |
| gap_m = re.search(r"const legendSpacing = (\d+)", text2) | |
| size = size_m.group(1) if size_m else "12" | |
| gap = gap_m.group(1) if gap_m else "5" | |
| insert = ( | |
| f"\n const formattedLeftGroup = groupUnit ? `${{leftGroup}}${{groupUnit}}` : `${{leftGroup}}`;\n" | |
| f" const formattedRightGroup = groupUnit ? `${{rightGroup}}${{groupUnit}}` : `${{rightGroup}}`;\n" | |
| f" const leftTextWidth = chartUtils.text.measure(svg, formattedLeftGroup, {{\n" | |
| f" fontSize: 14,\n" | |
| f" fontFamily: typography.label.font_family,\n" | |
| f" fontWeight: \"bold\",\n" | |
| f" }}).width;\n" | |
| f" const leftLegendX = centerX - 50 - leftTextWidth - legendSquareSize - legendSpacing;\n" | |
| f" const rightLegendX = centerX + 50;\n" | |
| f" chartUtils.legend.pair(svg, [\n" | |
| f" {{ label: formattedLeftGroup, color: getColor(leftGroup), x: leftLegendX, y: margin.top - 35 }},\n" | |
| f" {{ label: formattedRightGroup, color: getColor(rightGroup), x: rightLegendX, y: margin.top - 35 }},\n" | |
| f" ], {{\n" | |
| f" markerSize: legendSquareSize,\n" | |
| f" labelGap: legendSpacing,\n" | |
| f" fontSize: 14,\n" | |
| f" fontFamily: typography.label.font_family,\n" | |
| f" fontWeight: \"bold\",\n" | |
| f" textColor: colorResolver.text({{ fallback: \"#333333\" }}).value,\n" | |
| f" }});\n" | |
| ) | |
| text2 = text2[: block.start()] + insert + text2[block.end() :] | |
| text2 = re.sub( | |
| r"\n\s*// 添加左侧组标签\s*\n\s*svg\.append\(\"rect\"\)[\s\S]*?\.text\(formattedRightGroup\);\s*\n", | |
| "\n", | |
| text2, | |
| count=1, | |
| ) | |
| return text2, 1 | |
| def replace_bump_icon_legend(text: str) -> tuple[str, int]: | |
| if "clip-legend-" not in text and "custom-legend-container" not in text: | |
| return text, 0 | |
| if "chartUtils.legend.drawWithIcons" in text: | |
| return text, 0 | |
| if "legendItemsData = groups.map" not in text: | |
| return text, 0 | |
| pattern = ( | |
| r"\n\s*//[^\n]*图例[^\n]*\n" | |
| r"\s*//[^\n]*\n" | |
| r"\s*if \(groups && groups\.length > 0\) \{[\s\S]*?\n\s*\}\s*\n" | |
| r"(?=\s*//[^\n]*时间点|// ---------- 12\.|// ---------- 11\. 添加时间点)" | |
| ) | |
| replacement = ( | |
| "\n if (groups && groups.length > 0) {\n" | |
| " const legendItems = groups.map(group => ({\n" | |
| " value: group,\n" | |
| " label: String(group),\n" | |
| " color: colorResolver.field(group, 0, { fallback: colorScale }).value(group),\n" | |
| " imageHref: images.field && images.field[group] ? images.field[group] : null,\n" | |
| " }));\n" | |
| " chartUtils.legend.drawWithIcons(g, legendItems, {\n" | |
| " colorResolver,\n" | |
| " colors,\n" | |
| " }, {\n" | |
| " y: -margin.top + 15,\n" | |
| " maxWidth: innerWidth,\n" | |
| " align: \"center\",\n" | |
| " markerSize: 12,\n" | |
| " labelGap: 6,\n" | |
| " itemGap: 12,\n" | |
| " rowGap: 6,\n" | |
| " fontSize: typography.label.font_size,\n" | |
| " fontFamily: typography.label.font_family,\n" | |
| " fontWeight: typography.label.font_weight,\n" | |
| " textColor: colorResolver.text({ fallback: \"#333333\" }).value,\n" | |
| " idPrefix: \"clip-legend\",\n" | |
| " });\n" | |
| " }\n" | |
| ) | |
| return subn(pattern, replacement, text, count=1) | |
| def replace_stacked_row(text: str) -> tuple[str, int]: | |
| if "legend2RightEdge" not in text or "chartUtils.legend.row" in text: | |
| return text, 0 | |
| pattern = ( | |
| r"\n\s*//\s*7\.添加组图例\s*\n" | |
| r"\s*// Create legend group with better positioning\s*\n" | |
| r"\s*const legendGroup = svg\.append\(\"g\"\)[\s\S]*?" | |
| r"\s*if \(legend2RightEdge > availableWidth\) \{[\s\S]*?\}\s*\n" | |
| r"\s*\}\s*\n" | |
| ) | |
| replacement = ( | |
| "\n chartUtils.legend.row(svg, [firstGroup, secondGroup], {\n" | |
| " colorResolver,\n" | |
| " colors,\n" | |
| " color: group => getColor(group),\n" | |
| " }, {\n" | |
| " x: margin.left,\n" | |
| " y: margin.top - 20,\n" | |
| " maxWidth: innerWidth + margin.right - 20,\n" | |
| " fontSize: typography.label.font_size,\n" | |
| " fontFamily: typography.label.font_family,\n" | |
| " fontWeight: typography.label.font_weight,\n" | |
| " textColor: colorResolver.text({ fallback: \"#333333\" }).value,\n" | |
| " markerSize: legendSquareSize,\n" | |
| " labelGap: legendSpacing,\n" | |
| " itemGap: legendGap,\n" | |
| " });\n" | |
| ) | |
| return subn(pattern, replacement, text, count=1) | |
| def _legend_color_opts(block: str) -> str: | |
| if "getBarColor" in block: | |
| return "color: group => getBarColor(group),\n colorResolver,\n colors," | |
| if re.search(r'\.attr\("fill", colorScale\(', block): | |
| return "colorScale,\n colorResolver,\n colors," | |
| if "getColor(" in block: | |
| return "color: group => getColor(group),\n colorResolver,\n colors," | |
| return "colorResolver,\n colors," | |
| def _legend_marker_opts(block: str, text: str) -> tuple[str, str, str]: | |
| if 'legendItem.append("circle")' in block or "legendItem.append('circle')" in block: | |
| size_m = re.search(r'\.attr\("r", (\d+(?:\.\d+)?)\)', block) | |
| radius = float(size_m.group(1)) if size_m else 8 | |
| return "circle", str(radius * 2), "5" | |
| swatch = re.search(r"const legendSwatchSize = (\d+)", text) | |
| padding = re.search(r"const legendSwatchPadding = (\d+)", text) | |
| return ( | |
| "rect", | |
| swatch.group(1) if swatch else "15", | |
| padding.group(1) if padding else "5", | |
| ) | |
| def replace_generic_offset_legend(text: str) -> tuple[str, int]: | |
| if "chartUtils.legend.centered" in text and "legendOffset +=" not in text: | |
| return text, 0 | |
| pattern = ( | |
| r"\n\s*const legend = svg\.append\(\"g\"\)\s*\n" | |
| r"\s*\.attr\(\"class\", \"legend\"\)\s*\n" | |
| r"\s*\.attr\(\"transform\", `translate\(\$\{\(width - totalLegendWidth\) / 2\}, (?P<y>[^`]+)\)`\)[^\n]*\n" | |
| r"\s*let legendOffset = 0;\s*\n" | |
| r"\s*(?P<groups>\w+)\.forEach\(\((?P<g>\w+), i\) => \{\s*\n" | |
| r"\s*const legendItem = legend\.append\(\"g\"\)\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*legendOffset \+= legendItemWidths\[i\];\s*\n" | |
| r"\s*\}\);\s*\n" | |
| r"(?:\s*// --- END LEGEND[^\n]*\n)?" | |
| ) | |
| match = re.search(pattern, text) | |
| if not match: | |
| return text, 0 | |
| block = match.group(0) | |
| if "legendItem.append" not in block: | |
| return text, 0 | |
| groups = match.group("groups") | |
| y_expr = match.group("y").strip() | |
| if y_expr.startswith("${") and y_expr.endswith("}"): | |
| y_expr = y_expr[2:-1] | |
| color_opt = _legend_color_opts(block) | |
| marker_shape, marker_size, label_gap = _legend_marker_opts(block, text) | |
| spacing = re.search(r"const legendItemSpacing = (\d+)", text) | |
| item_gap = spacing.group(1) if spacing else "10" | |
| replacement = ( | |
| f"\n chartUtils.legend.centered(svg, {groups}, {{\n" | |
| f" {color_opt}\n" | |
| f" }}, width / 2, {y_expr}, {{\n" | |
| f" markerShape: \"{marker_shape}\",\n" | |
| f" markerSize: {marker_size},\n" | |
| f" labelGap: {label_gap},\n" | |
| f" itemGap: {item_gap},\n" | |
| f" fontSize: typography.label.font_size,\n" | |
| f" fontFamily: typography.label.font_family,\n" | |
| f" fontWeight: typography.label.font_weight,\n" | |
| f" textColor: colorResolver.text({{ fallback: \"#333333\" }}).value,\n" | |
| f" markerRadius: variables.has_rounded_corners ? 2 : 0,\n" | |
| f" }});\n" | |
| ) | |
| text = text[: match.start()] + replacement + text[match.end() :] | |
| text, _ = remove_legend_width_measure(text) | |
| text, _ = remove_legend_swatch_measure(text) | |
| return text, 1 | |
| def replace_scaled_legend_section(text: str) -> tuple[str, int]: | |
| pattern = ( | |
| r"\n\s*// ---------- 8\. 创建图例 ----------\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*legendOffset \+= legendItemWidths\[i\];\s*\n" | |
| r"\s*\}\);\s*\n" | |
| ) | |
| if "calculatedTotalLegendWidth" not in text: | |
| return text, 0 | |
| match = re.search(pattern, text) | |
| if not match: | |
| return text, 0 | |
| block = match.group(0) | |
| gm = re.search(r"(\w+)\.forEach\(\((\w+), i\)", block) | |
| if not gm: | |
| return text, 0 | |
| groups = gm.group(1) | |
| ym = re.search(r"translate\(\$\{width / 2\}, ([^`]+)\)", block) | |
| y_expr = ym.group(1).strip() if ym else "margin.top - 25" | |
| if y_expr.startswith("${") and y_expr.endswith("}"): | |
| y_expr = y_expr[2:-1] | |
| color_opt = _legend_color_opts(block) | |
| marker_shape, marker_size, label_gap = _legend_marker_opts(block, text) | |
| padding = re.search(r"const legendPadding = (\d+)", text) | |
| item_gap = padding.group(1) if padding else "10" | |
| if '.style("fill", "#ffffff")' in block or ".style('fill', '#ffffff')" in block: | |
| text_color_line = 'textColor: "#ffffff",' | |
| else: | |
| text_color_line = 'textColor: colorResolver.text({ fallback: "#333333" }).value,' | |
| replacement = ( | |
| f"\n chartUtils.legend.centered(svg, {groups}, {{\n" | |
| f" {color_opt}\n" | |
| f" }}, width / 2, {y_expr}, {{\n" | |
| f" markerShape: \"{marker_shape}\",\n" | |
| f" markerSize: {marker_size},\n" | |
| f" labelGap: {label_gap},\n" | |
| f" itemGap: {item_gap},\n" | |
| f" maxWidth: width - 10,\n" | |
| f" fitToWidth: true,\n" | |
| f" minFontSize: 8,\n" | |
| f" fontSize: typography.label.font_size,\n" | |
| f" fontFamily: typography.label.font_family,\n" | |
| f" fontWeight: typography.label.font_weight,\n" | |
| f" {text_color_line}\n" | |
| f" }});\n" | |
| ) | |
| text = text[: match.start()] + replacement + text[match.end() :] | |
| text, _ = remove_legend_width_measure(text) | |
| return text, 1 | |
| def replace_width_center_offset_legend(text: str) -> tuple[str, int]: | |
| pattern = ( | |
| r"\n\s*const legend = svg\.append\(\"g\"\)\s*\n" | |
| r"\s*\.attr\(\"class\", \"legend\"\)\s*\n" | |
| r"\s*\.attr\(\"transform\", `translate\(\$\{width / 2\}, (?P<y>[^`]+)\)`\)[^\n]*\n" | |
| r"\s*let legendOffset = 0;\s*\n" | |
| r"\s*(?P<groups>\w+)\.forEach\(\((?P<g>\w+), i\) => \{\s*\n" | |
| r"\s*const legendItem = legend\.append\(\"g\"\)\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*legendOffset \+= legendItemWidths\[i\];\s*\n" | |
| r"\s*\}\);\s*\n" | |
| ) | |
| match = re.search(pattern, text) | |
| if not match: | |
| return text, 0 | |
| block = match.group(0) | |
| groups = match.group("groups") | |
| y_expr = match.group("y").strip() | |
| if y_expr.startswith("${") and y_expr.endswith("}"): | |
| y_expr = y_expr[2:-1] | |
| color_opt = _legend_color_opts(block) | |
| marker_shape, marker_size, label_gap = _legend_marker_opts(block, text) | |
| spacing = re.search(r"const legendPadding = (\d+)", text) or re.search(r"const legendItemSpacing = (\d+)", text) | |
| item_gap = spacing.group(1) if spacing else "10" | |
| replacement = ( | |
| f"\n chartUtils.legend.centered(svg, {groups}, {{\n" | |
| f" {color_opt}\n" | |
| f" }}, width / 2, {y_expr}, {{\n" | |
| f" markerShape: \"{marker_shape}\",\n" | |
| f" markerSize: {marker_size},\n" | |
| f" labelGap: {label_gap},\n" | |
| f" itemGap: {item_gap},\n" | |
| f" fontSize: typography.label.font_size,\n" | |
| f" fontFamily: typography.label.font_family,\n" | |
| f" fontWeight: typography.label.font_weight,\n" | |
| f" textColor: colorResolver.text({{ fallback: \"#333333\" }}).value,\n" | |
| f" }});\n" | |
| ) | |
| text = text[: match.start()] + replacement + text[match.end() :] | |
| text, _ = remove_legend_width_measure(text) | |
| text, _ = remove_legend_swatch_measure(text) | |
| return text, 1 | |
| def remove_legend_swatch_measure(text: str) -> tuple[str, int]: | |
| pattern = ( | |
| r"\n\s*const legendItemWidths = \[\];\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*const totalLegendWidth = [\s\S]*?;\s*\n" | |
| r"(?=\s*const legendHeight|\s*// --- BEGIN LEGEND|\s*const legend = svg)" | |
| ) | |
| return subn(pattern, "\n", text) | |
| def replace_legend_group_foreach(text: str) -> tuple[str, int]: | |
| pattern = ( | |
| r"\n\s*const legendGroup = (?:svg|g)\.append\(\"g\"\)\s*\n" | |
| r"(?:\s*\.attr\([^\n]+\)\s*\n)?" | |
| r"\s*;\s*\n" | |
| r"\s*(?:const )?(\w+)\.forEach\(\((\w+), i\) => \{\s*\n" | |
| r"\s*const legendItem = legendGroup\.append\(\"g\"\)\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*\}\);\s*\n" | |
| r"\s*legendGroup\.attr\(\"transform\", `translate\(\$\{\(chartWidth - totalLegendWidth\)/2\}, 60\)`\);\s*\n" | |
| ) | |
| if "totalLegendWidth" not in text: | |
| return text, 0 | |
| def repl(m: re.Match) -> str: | |
| gv = m.group(1) | |
| return ( | |
| f"\n chartUtils.legend.centered(g, {gv}, {{\n" | |
| f" colorScale,\n" | |
| f" colorResolver,\n" | |
| f" colors,\n" | |
| f" }}, chartWidth / 2, 60, {{\n" | |
| f" markerShape: \"circle\",\n" | |
| f" markerSize: 8,\n" | |
| f" labelGap: 8,\n" | |
| f" itemGap: 16,\n" | |
| f" fontSize: 12,\n" | |
| f" textColor: \"#333\",\n" | |
| f" }});\n" | |
| ) | |
| return subn(pattern, repl, text, count=1) | |
| def replace_pattern_capsule_rows(text: str) -> tuple[str, int]: | |
| if "url(#${patternId})" not in text and "url(#${patternId}" not in text and "url(#pattern" not in text: | |
| if 'attr("fill", `url(#${patternId})`)' not in text: | |
| return text, 0 | |
| pattern = ( | |
| r"\n\s*// ---------- 8\. 创建图例 ----------\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*currentY \+= rowHeight;\s*\n" | |
| r"\s*\}\);\s*\n" | |
| ) | |
| if "legendRows.forEach" not in text or "legendCapsuleHeightH" not in text: | |
| return text, 0 | |
| replacement = ( | |
| "\n chartUtils.legend.draw(svg, groups, {\n" | |
| " color: (group, index) => `url(#pattern-${group.replace(/[^a-zA-Z0-9]/g, '-')}-${index})`,\n" | |
| " colorResolver,\n" | |
| " colors,\n" | |
| " }, {\n" | |
| " y: margin.top * 0.7,\n" | |
| " maxWidth: width - 100,\n" | |
| " align: \"center\",\n" | |
| " markerShape: \"capsule\",\n" | |
| " markerWidth: 15,\n" | |
| " markerHeight: 10,\n" | |
| " labelGap: legendTextPadding,\n" | |
| " itemGap: legendItemPadding,\n" | |
| " rowGap: legendVerticalSpacing || 10,\n" | |
| " fontSize: typography.label.font_size,\n" | |
| " fontFamily: typography.label.font_family,\n" | |
| " fontWeight: typography.label.font_weight,\n" | |
| " textColor: colorResolver.text({ fallback: \"#333333\" }).value,\n" | |
| " });\n" | |
| ) | |
| return subn(pattern, replacement, text, count=1) | |
| def replace_wrapped_rows_legend(text: str) -> tuple[str, int]: | |
| if "legendRows.forEach" not in text or "chartUtils.legend." in text: | |
| return text, 0 | |
| if "legendCapsuleHeightH" in text or "url(#pattern" in text: | |
| return text, 0 | |
| pattern = ( | |
| r"\n\s*//[^\n]*创建图例[^\n]*\n" | |
| r"[\s\S]*?" | |
| r"\s*const legendRows = \[[\s\S]*?" | |
| r"\s*legendRows\.forEach\(\([^)]+\) => \{[\s\S]*?" | |
| r"\s*\}\);\s*\n" | |
| r"(?=\s*//[^\n]*创建主图表|// ---------- 6\.|// ---------- 7\.|margin\.top = actualLegendHeight)" | |
| ) | |
| groups_m = re.search(r"const (allGroups|groups|legendGroups) =", text) | |
| groups_var = groups_m.group(1) if groups_m else "allGroups" | |
| y_m = re.search(r"const legendY = ([^;]+);", text) | |
| y_expr = y_m.group(1).strip() if y_m else "10" | |
| max_m = re.search(r"const legendMaxWidth = ([^;]+);", text) | |
| max_expr = max_m.group(1).strip() if max_m else "width - 40" | |
| margin_m = re.search(r"margin\.top = ([^;]+);", text[text.find("legendRows"):text.find("legendRows") + 2000] if "legendRows" in text else "") | |
| margin_line = "" | |
| if margin_m and "actualLegendHeight" in margin_m.group(1): | |
| margin_line = ( | |
| f"\n const legendLayoutInfo = chartUtils.legend.layout({groups_var}, {{\n" | |
| f" colorResolver,\n" | |
| f" colors,\n" | |
| f" maxWidth: {max_expr},\n" | |
| f" align: \"center\",\n" | |
| f" context: svg,\n" | |
| f" fontSize: typography.annotation.font_size,\n" | |
| f" fontFamily: typography.annotation.font_family,\n" | |
| f" fontWeight: typography.annotation.font_weight,\n" | |
| f" }});\n" | |
| f" margin.top = legendLayoutInfo.height + 20;\n" | |
| ) | |
| marker = "circle" if 'legendItem.append("circle")' in text else "rect" | |
| marker_size = "legendRectSize" if "legendRectSize" in text else "15" | |
| replacement = ( | |
| f"{margin_line}" | |
| f"\n chartUtils.legend.draw(svg, {groups_var}, {{\n" | |
| f" colorResolver,\n" | |
| f" colors,\n" | |
| f" }}, {{\n" | |
| f" x: margin.left,\n" | |
| f" y: {y_expr},\n" | |
| f" maxWidth: {max_expr},\n" | |
| f" align: \"center\",\n" | |
| f" markerShape: \"{marker}\",\n" | |
| f" markerSize: {marker_size},\n" | |
| f" labelGap: legendRectTextPadding || 5,\n" | |
| f" itemGap: legendItemPadding || 15,\n" | |
| f" rowGap: legendRowSpacing || 12,\n" | |
| f" fontSize: typography.annotation.font_size,\n" | |
| f" fontFamily: typography.annotation.font_family,\n" | |
| f" fontWeight: typography.annotation.font_weight,\n" | |
| f" textColor: colorResolver.text({{ fallback: \"#333333\" }}).value,\n" | |
| f" }});\n" | |
| ) | |
| text2, n = subn(pattern, replacement, text, count=1) | |
| if n: | |
| text2 = re.sub( | |
| r"\n\s*const legendTempSvg = d3\.select\(containerSelector\)[\s\S]*?legendTempSvg\.remove\(\);\s*\n", | |
| "\n", | |
| text2, | |
| count=1, | |
| ) | |
| text2 = re.sub( | |
| r"\n\s*const legendTextWidths = \{[\s\S]*?const singleRowHeight = [^;]+;\s*\n", | |
| "\n", | |
| text2, | |
| count=1, | |
| ) | |
| text2 = re.sub( | |
| r"\n\s*const legendItemWidths = allGroups\.map[\s\S]*?legendRows\.push\(currentRow\);\s*\n\s*\}\s*\n", | |
| "\n", | |
| text2, | |
| count=1, | |
| ) | |
| text2 = re.sub( | |
| r"\n\s*const actualLegendHeight = legendRows\.length[\s\S]*?const legendY = [^;]+;\s*\n", | |
| "\n", | |
| text2, | |
| count=1, | |
| ) | |
| return text2, n | |
| def replace_mirror_pair_legend(text: str) -> tuple[str, int]: | |
| if "leftCircleCenter" not in text or "chartUtils.legend.mirrorPair" in text: | |
| return text, 0 | |
| pattern = ( | |
| r"\n\s*// 绘制图例\s*\n" | |
| r"\s*const legendGroup = svg\.append\(\"g\"\)\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*\.text\(rightGender\);\s*\n" | |
| ) | |
| replacement = ( | |
| "\n chartUtils.legend.mirrorPair(svg, " | |
| "{ label: leftGender, color: getGenderColor(leftGender) }, " | |
| "{ label: rightGender, color: getGenderColor(rightGender) }, " | |
| "{\n" | |
| " centerX: width / 2,\n" | |
| " y: margin.top * 0.8,\n" | |
| " circleRadius,\n" | |
| " circleTextGap: spaceBetweenCircleAndText,\n" | |
| " centerMargin,\n" | |
| " fontSize: typography.label.font_size,\n" | |
| " fontFamily: typography.label.font_family,\n" | |
| " fontWeight: \"bold\",\n" | |
| " textColor: colorResolver.text({ fallback: \"#333\" }).value,\n" | |
| " });\n" | |
| ) | |
| text2, n = subn(pattern, replacement, text, count=1) | |
| if n: | |
| text2 = re.sub( | |
| r"\n\s*// 获取左侧文本宽度[\s\S]*?const rightTextLeftEdge = [^;]+;\s*\n", | |
| "\n", | |
| text2, | |
| count=1, | |
| ) | |
| return text2, n | |
| def replace_segments_chart_legend(text: str) -> tuple[str, int]: | |
| if 'attr("class", "chart-legend")' not in text or "positiveColor" not in text: | |
| return text, 0 | |
| if "chartUtils.legend.segmentsCentered" in text: | |
| return text, 0 | |
| pattern = ( | |
| r"\n\s*// \*\*\* 恢复: 计算图例总宽度[\s\S]*?" | |
| r"\s*\.text\(y2Name\);\s*\n" | |
| ) | |
| replacement = ( | |
| "\n chartUtils.legend.segmentsCentered(svg, [\n" | |
| " { type: \"rect\", size: legendSquareSize, color: positiveColor },\n" | |
| " { type: \"rect\", size: legendSquareSize, color: negativeColor },\n" | |
| " { type: \"text\", label: yName },\n" | |
| " { type: \"rect\", size: legendSquareSize, color: shapeLegendColor },\n" | |
| " { type: \"text\", label: y2Name },\n" | |
| " ], {\n" | |
| " centerX: margin.left + innerWidth / 2,\n" | |
| " y: legendY,\n" | |
| " itemGap: legendItemPadding,\n" | |
| " sectionGap: legendPadding,\n" | |
| " fontSize: legendFontSize,\n" | |
| " fontFamily: legendFontFamily,\n" | |
| " fontWeight: legendFontWeight,\n" | |
| " textColor: colorResolver.text({ fallback: \"#000000\" }).value,\n" | |
| " });\n" | |
| ) | |
| return subn(pattern, replacement, text, count=1) | |
| def replace_radial_vertical_legend(text: str) -> tuple[str, int]: | |
| pattern = ( | |
| r"\n\s*// 添加图例\s*\n" | |
| r"\s*const legend = svg\.append\(\"g\"\)\s*\n" | |
| r"\s*\.attr\(\"class\", \"legend\"\)\s*\n" | |
| r"\s*\.attr\(\"transform\", `translate\(\$\{width - 150\}, 20\)`\);\s*\n" | |
| r"\s*groups\.forEach\(\(group, i\) => \{\s*\n" | |
| r"\s*const legendRow = legend\.append\(\"g\"\)\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*\}\);\s*\n" | |
| ) | |
| if "chartUtils.legend.draw" in text: | |
| return text, 0 | |
| replacement = ( | |
| "\n chartUtils.legend.draw(svg, groups, {\n" | |
| " colorScale,\n" | |
| " colorResolver,\n" | |
| " colors,\n" | |
| " }, {\n" | |
| " x: width - 150,\n" | |
| " y: 20,\n" | |
| " direction: \"vertical\",\n" | |
| " markerShape: \"rect\",\n" | |
| " markerSize: 15,\n" | |
| " labelGap: 5,\n" | |
| " itemGap: 5,\n" | |
| " rowGap: 5,\n" | |
| " fontSize: 10,\n" | |
| " textColor: colorResolver.text({ fallback: \"#333\" }).value,\n" | |
| " itemHeight: 20,\n" | |
| " });\n" | |
| ) | |
| return subn(pattern, replacement, text, count=1) | |
| def replace_histogram_bottom_legend(text: str) -> tuple[str, int]: | |
| pattern = ( | |
| r"\n\s*// 添加图例\s*\n" | |
| r"\s*const legendGroup = svg\.append\(\"g\"\)\s*\n" | |
| r"\s*\.attr\(\"transform\", `translate\(\$\{margin\.left \+ chartWidth/2\}, \$\{height - margin\.bottom/2\}\)`\);\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*currentX \+= legendItemWidths\[i\];\s*\n" | |
| r"\s*\}\);\s*\n" | |
| ) | |
| if "groupsToUse" not in text: | |
| return text, 0 | |
| replacement = ( | |
| "\n chartUtils.legend.centered(svg, groupsToUse, {\n" | |
| " color: group => groupColors[group],\n" | |
| " colorResolver,\n" | |
| " colors,\n" | |
| " }, margin.left + chartWidth / 2, height - margin.bottom / 2, {\n" | |
| " markerShape: \"rect\",\n" | |
| " markerSize: 20,\n" | |
| " labelGap: 10,\n" | |
| " itemGap: 20,\n" | |
| " fontSize: `calc(${typography.label.font_size} * 1.5)`,\n" | |
| " fontFamily: typography.label.font_family,\n" | |
| " fontWeight: typography.label.font_weight,\n" | |
| " textColor: colorResolver.text({ fallback: \"#333333\" }).value,\n" | |
| " });\n" | |
| ) | |
| return subn(pattern, replacement, text, count=1) | |
| def replace_waffle_category_value(text: str) -> tuple[str, int]: | |
| if "legendItemMinWidths" not in text or "categoryColors[category]" not in text: | |
| return text, 0 | |
| pattern = ( | |
| r"\n\s*// ---------- 8\. 创建图例[\s\S]*?" | |
| r"\s*const actualLegendHeight = \(currentRow \+ 1\) \* legendRowHeight;\s*\n" | |
| ) | |
| replacement = ( | |
| "\n const legendItemsForDraw = chartData.map((d, i) => ({\n" | |
| " label: d[xField],\n" | |
| " valueLabel: `${formatValue(d[yField])} ${yUnit}`,\n" | |
| " color: categoryColors[d[xField]],\n" | |
| " raw: d,\n" | |
| " }));\n" | |
| " const legendDrawResult = chartUtils.legend.categoryValueRows(svg, legendItemsForDraw, {\n" | |
| " maxWidth: availableLegendWidth,\n" | |
| " markerSize: legendSquareSize,\n" | |
| " rowHeight: legendRowHeight,\n" | |
| " itemGap: legendSpacing,\n" | |
| " fontSize: typography.label.font_size,\n" | |
| " fontFamily: typography.label.font_family,\n" | |
| " fontWeight: typography.label.font_weight,\n" | |
| " valueFontWeight: \"bold\",\n" | |
| " textColor: colorResolver.text({ fallback: \"#333333\" }).value,\n" | |
| " markerRadius: variables.has_rounded_corners ? Math.max(1, legendSquareSize * 0.15) : 0,\n" | |
| " });\n" | |
| " const actualLegendHeight = legendDrawResult.height;\n" | |
| ) | |
| text2, n = subn(pattern, replacement, text, count=1) | |
| if n: | |
| text2 = re.sub( | |
| r"\n\s*// 计算图例所需高度[\s\S]*?const legendHeight = rowCount \* legendRowHeight;\s*\n", | |
| "\n", | |
| text2, | |
| count=1, | |
| ) | |
| return text2, n | |
| def replace_stacked_labeled_legend(text: str) -> tuple[str, int]: | |
| if "const createLegend = ()" not in text: | |
| return text, 0 | |
| pattern = ( | |
| r"\n\s*if \(totalLegendWidth < availableWidth\) \{[\s\S]*?" | |
| r"\s*chartGroup\.attr\(\"transform\", `translate\(\$\{margin\.left\}, \$\{margin\.top\}\)`\);\s*\n" | |
| r"\s*\}\s*\n" | |
| ) | |
| replacement = ( | |
| "\n const legendItemKeys = legendItems.map(item => item.key);\n" | |
| " const legendColorOpts = { colorScale, colorResolver, colors };\n" | |
| " const legendStyleBase = {\n" | |
| " markerShape: \"rect\",\n" | |
| " markerSize: 15,\n" | |
| " labelGap: 5,\n" | |
| " itemGap: 10,\n" | |
| " fontSize: legendFontSize,\n" | |
| " textColor: \"#333\",\n" | |
| " };\n" | |
| " if (totalLegendWidth < availableWidth) {\n" | |
| " const titleG = svg.append(\"g\").attr(\"transform\", \"translate(0, 30)\");\n" | |
| " titleG.append(\"text\")\n" | |
| " .attr(\"x\", 0).attr(\"y\", 0).attr(\"dominant-baseline\", \"middle\")\n" | |
| " .attr(\"fill\", \"#333\").style(\"font-size\", legendFontSize).style(\"font-weight\", \"bold\")\n" | |
| " .text(groupField);\n" | |
| " chartUtils.legend.draw(svg, legendItemKeys, legendColorOpts, { ...legendStyleBase, x: titleWidth, y: 30 });\n" | |
| " } else {\n" | |
| " const titleG = svg.append(\"g\").attr(\"transform\", \"translate(0, 30)\");\n" | |
| " titleG.append(\"text\")\n" | |
| " .attr(\"x\", 0).attr(\"y\", 0).attr(\"dominant-baseline\", \"middle\")\n" | |
| " .attr(\"fill\", \"#333\").style(\"font-size\", legendFontSize).style(\"font-weight\", \"bold\")\n" | |
| " .text(groupField);\n" | |
| " chartUtils.legend.draw(svg, legendItemKeys, legendColorOpts, { ...legendStyleBase, x: 0, y: 55 });\n" | |
| " margin.top = 75;\n" | |
| " chartGroup.attr(\"transform\", `translate(${margin.left}, ${margin.top})`);\n" | |
| " }\n" | |
| ) | |
| text2, n = subn(pattern, replacement, text, count=1) | |
| if n: | |
| text2 = re.sub( | |
| r"\n\s*const createLegend = \(\) => \{[\s\S]*?return legendGroup;\s*\n\s*\};\s*\n", | |
| "\n", | |
| text2, | |
| count=1, | |
| ) | |
| return text2, n | |
| def replace_simple_group_foreach_legend(text: str) -> tuple[str, int]: | |
| if "chartUtils.legend." in text: | |
| return text, 0 | |
| pattern = ( | |
| r"\n\s*// 绘制图例\s*\n" | |
| r"\s*const legendGroup = svg\.append\(\"g\"\)\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*legendGroup\.append\(\"text\"\)\s*\n" | |
| r"[\s\S]*?" | |
| r"\s*\.text\([^)]+\);\s*\n" | |
| ) | |
| if "legendGroup.append(\"circle\")" not in text and "legendGroup.append('circle')" not in text: | |
| return text, 0 | |
| transform_m = re.search( | |
| r'const legendGroup = svg\.append\("g"\)\s*\n\s*\.attr\("transform", `translate\(\$\{([^}]+)\}, \$\{([^}]+)\}\)`\)', | |
| text, | |
| ) | |
| if not transform_m: | |
| return text, 0 | |
| x_expr, y_expr = transform_m.group(1).strip(), transform_m.group(2).strip() | |
| groups_m = re.search(r"\.text\((\w+)\)", text[text.find("legendGroup"):]) | |
| label_var = groups_m.group(1) if groups_m else "group" | |
| replacement = ( | |
| f"\n chartUtils.legend.draw(svg, [{label_var}], {{\n" | |
| f" color: () => getGenderColor({label_var}),\n" | |
| f" colorResolver,\n" | |
| f" colors,\n" | |
| f" }}, {{\n" | |
| f" x: {x_expr},\n" | |
| f" y: {y_expr},\n" | |
| f" markerShape: \"circle\",\n" | |
| f" markerSize: circleRadius * 2,\n" | |
| f" labelGap: spaceBetweenCircleAndText,\n" | |
| f" fontSize: typography.label.font_size,\n" | |
| f" fontFamily: typography.label.font_family,\n" | |
| f" fontWeight: \"bold\",\n" | |
| f" textColor: colorResolver.text({{ fallback: \"#333\" }}).value,\n" | |
| f" }});\n" | |
| ) | |
| return subn(pattern, replacement, text, count=0) | |
| def migrate_file(path: Path) -> dict: | |
| original = path.read_text(encoding="utf-8") | |
| text = original | |
| stats = {} | |
| text, stats["width"] = remove_legend_width_measure(text) | |
| text, stats["centered"] = replace_centered_rect_legend(text) | |
| text, stats["legendG"] = replace_legend_g_wrap(text) | |
| text, stats["fullW"] = replace_fullw_centered_legend(text) | |
| text, stats["pair"] = replace_pair_legend(text) | |
| text, stats["bump"] = replace_bump_icon_legend(text) | |
| text, stats["stacked"] = replace_stacked_row(text) | |
| text, stats["legendGroup"] = replace_legend_group_foreach(text) | |
| text, stats["genericOffset"] = replace_generic_offset_legend(text) | |
| text, stats["widthCenterOffset"] = replace_width_center_offset_legend(text) | |
| text, stats["scaledSection"] = replace_scaled_legend_section(text) | |
| text, stats["patternCapsule"] = replace_pattern_capsule_rows(text) | |
| text, stats["wrappedRows"] = replace_wrapped_rows_legend(text) | |
| text, stats["mirrorPair"] = replace_mirror_pair_legend(text) | |
| text, stats["segments"] = replace_segments_chart_legend(text) | |
| text, stats["radial"] = replace_radial_vertical_legend(text) | |
| text, stats["histogram"] = replace_histogram_bottom_legend(text) | |
| text, stats["waffle"] = replace_waffle_category_value(text) | |
| text, stats["stackedLabeled"] = replace_stacked_labeled_legend(text) | |
| changed = text != original | |
| if changed: | |
| path.write_text(text, encoding="utf-8") | |
| return {"file": str(path), "changed": changed, **stats} | |
| def main() -> None: | |
| changed = [] | |
| for path in sorted(TEMPLATE_ROOT.rglob("*.js")): | |
| result = migrate_file(path) | |
| if result["changed"]: | |
| changed.append(result) | |
| print(f"migrated {len(changed)} files") | |
| keys = [ | |
| "width", "centered", "legendG", "fullW", "pair", "bump", "stacked", "legendGroup", | |
| "genericOffset", "widthCenterOffset", "scaledSection", "patternCapsule", "wrappedRows", | |
| "mirrorPair", "segments", "radial", "histogram", "waffle", "stackedLabeled", | |
| ] | |
| for key in keys: | |
| print(f" {key}: {sum(r.get(key, 0) for r in changed)}") | |
| if __name__ == "__main__": | |
| main() | |