ChartPipeline / scripts /migrate_legend_unified.py
Ray1ee01's picture
Upload folder using huggingface_hub
58e6885 verified
Raw
History Blame Contribute Delete
38.6 kB
#!/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()