#!/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\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[^`]+)\)`\)[^\n]*\n" r"\s*let legendOffset = 0;\s*\n" r"\s*(?P\w+)\.forEach\(\((?P\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[^`]+)\)`\)[^\n]*\n" r"\s*let legendOffset = 0;\s*\n" r"\s*(?P\w+)\.forEach\(\((?P\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()