#!/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" DRAW_VERTICAL = ''' chartUtils.legend.draw(svg, groups, { color: group => groupColors[group], colorResolver, colors, }, { x: size - 150, y: 50, direction: "vertical", markerShape: "rect", markerSize: 15, labelGap: 5, rowGap: 10, itemHeight: 25, fontSize: typography.label.font_size, fontFamily: typography.label.font_family, fontWeight: typography.label.font_weight, textColor: colorResolver.text({ fallback: "#333333" }).value, });''' BOXED_RADIAL = ''' chartUtils.legend.boxedVertical(svg, legendItems, { color: d => getColor(d), colorResolver, colors, }, { x: legendX, y: legendY, padding: legendPadding, itemHeight: legendItemHeight, markerSize: 15, labelGap: 10, fontSize: 12, textColor: colorResolver.text({ fallback: "#333333" }).value, });''' ROSE_DRAW = ''' chartUtils.legend.draw(svg, groups, { colorScale, colorResolver, colors, }, { y: 15, maxWidth: width - 40, align: "center", markerShape: "circle", markerSize: 12, labelGap: 5, itemGap: 15, rowGap: 5, fontSize: 12, textColor: colorResolver.text({ fallback: "#333333" }).value, });''' GAUGE_DRAW = ''' chartUtils.legend.draw(svg, categoryData.map(d => d.category), { colorScale, colorResolver, colors, }, { y: 15, maxWidth: width - 40, align: "center", markerShape: "circle", markerSize: 12, labelGap: 5, itemGap: 15, rowGap: 5, fontSize: 12, textColor: colorResolver.text({ fallback: "#333" }).value, });''' def replace_block(text: str, start: str, end: str, replacement: str) -> str: i = text.find(start) if i < 0: return text j = text.find(end, i + len(start)) if j < 0: return text return text[:i] + replacement + text[j:] def migrate_labeled_strip(text: str, rel: str) -> str: if "chartUtils.legend." in text: return text markers = [ "// ---------- 11. 创建新样式图例", "// ---------- 9. 创建新样式图例", ] start = None for m in markers: if m in text: start = text.find(m) break if start is None: return text end_markers = ["return svg.node();", "// ---------- 9. 创建主图表组", "// ---------- 工具函数 ----------"] end = None for m in end_markers: pos = text.find(m, start) if pos > start: end = pos break if end is None: return text show_number = "horizontal_group_bar_chart_12" in rel pattern_fill = show_number width_expr = "width" if "width" in text[start:end] else "size" y_expr = "height - margin.bottom + 20" if "legendStartY = height" in text else "size - margin.bottom * 0.8 + 10" fill = ( "(group, index) => index % 2 === 0 ? \"url(#pattern-sketch-left)\" : \"url(#pattern-sketch-right)\"" if pattern_fill else "group => getGroupColor(group)" ) wrap = "wrapLabel: (node, label, w) => wrapText(node, label, w, 1.1)," if "function wrapText" in text else "" num = "showNumber: true," if show_number else "" font = ( "labelFontFamily: \"'Comic Sans MS', cursive\",\n" " numberFontFamily: \"'Comic Sans MS', cursive\"," if pattern_fill else "labelFontFamily: typography.annotation.font_family,\n" " numberFontFamily: typography.label.font_family," ) if not show_number and "grouped_circular" in rel: font = ( "labelFontFamily: typography.annotation.font_family,\n" " numberFontFamily: typography.label.font_family," ) num = "showNumber: true," replacement = ( f" const equalGroupRectWidth = Math.min(innerWidth / groups.length, {width_expr} * 0.8 / groups.length);\n" f" chartUtils.legend.labeledStrip(svg, groups.map((group, index) => ({{\n" f" label: group,\n" f" fill: {fill},\n" f" }})), {{\n" f" centerWidth: {width_expr},\n" f" y: {y_expr},\n" f" itemWidth: equalGroupRectWidth,\n" f" {num}\n" f" {font}\n" f" labelFontSize: 12,\n" f" textColor: colorResolver.text({{ fallback: \"#333333\" }}).value,\n" f" {wrap}\n" f" }});\n\n" ) return text[:start] + replacement + text[end:] def migrate_simple_vertical(text: str) -> str: if "chartUtils.legend." in text: return text pattern = re.compile( r"\n\s*// 添加图例\s*\n" r"\s*const legend = svg\.append\(\"g\"\)\s*\n" r"(?:\s*\.attr\(\"class\", \"other\"\)\s*\n)?" r"\s*\.attr\(\"transform\", `translate\(\$\{size - 150\}, 50\}\)`\);\s*\n" r"\s*groups\.forEach\(\(group, i\) => \{\s*\n" r"[\s\S]*?" r"\s*\}\);\s*\n", re.MULTILINE, ) return pattern.sub("\n" + DRAW_VERTICAL + "\n", text, count=1) def migrate_radial_boxed(text: str) -> str: if "chartUtils.legend." in text or "legendItemHeight = 20" not in text: return text pattern = re.compile( r"\n\s*const legend = svg\.append\(\"g\"\)\s*\n" r"\s*\.attr\(\"transform\", `translate\(\$\{legendX\}, \$\{legendY\}\)`\);\s*\n" r"\s*// 添加图例背景[\s\S]*?" r"\s*(?:stackedSeries|legendItems)\.forEach\(\([^)]+\) => \{\s*\n" r"\s*const legendItem = legend\.append\(\"g\"\)\s*\n" r"[\s\S]*?" r"\s*\}\);\s*\n", re.MULTILINE, ) return pattern.sub("\n" + BOXED_RADIAL + "\n", text, count=1) def migrate_step_line(text: str) -> str: if "chartUtils.legend." in text: return text pattern = re.compile( r"\n\s*// 添加图例 - 整体居中\s*\n" r"[\s\S]*?" r"\s*legendGroup\.attr\(\"transform\", `translate\(\$\{\(chartWidth - totalLegendWidth\)/2\}, 60\}`\);\s*\n", re.MULTILINE, ) repl = ( "\n chartUtils.legend.stepLineCentered(g, {\n" " centerX: chartWidth / 2,\n" " y: 60,\n" " fieldName: dataColumns[1].label || dataColumns[1].name,\n" " areaColor,\n" " });\n" ) return pattern.sub(repl, text, count=1) def migrate_bump(text: str) -> str: if "chartUtils.legend." in text: return text pattern = re.compile( r"\n\s*const legendGroup = g\.append\(\"g\"\)\s*\n" r"\s*\.attr\(\"class\", \"chart-legend\"\)\s*\n" r"\s*\.attr\(\"transform\", `translate\(\$\{legendStartX\}, \$\{legendY\}\)`\);\s*\n" r"\s*let currentLegendX = 0;\s*\n" r"\s*categoryGroups\.forEach\(\(cg, index\) => \{\s*\n" r"[\s\S]*?" r"\s*currentLegendX \+= itemWidth \+ legendColumnPadding;\s*\n" r"\s*\}\);\s*\n", re.MULTILINE, ) repl = ( "\n chartUtils.legend.draw(g, categoryGroups, {\n" " color: cg => colorScale(cg),\n" " colorResolver,\n" " colors,\n" " }, {\n" " x: legendStartX,\n" " y: legendY,\n" " maxWidth: innerWidth,\n" " fitToWidth: true,\n" " minFontSize: 8,\n" " markerShape: \"rect\",\n" " markerSize: legendSquareSize,\n" " labelGap: legendItemPadding,\n" " itemGap: legendColumnPadding,\n" " fontSize: finalLegendFontSize,\n" " fontFamily: legendFontFamily,\n" " fontWeight: legendFontWeight,\n" " textColor: legendColor,\n" " });\n" ) return pattern.sub(repl, text, count=1) def migrate_rose(text: str) -> str: if "chartUtils.legend." in text or "legendConfig.maxWidth" not in text: return text pattern = re.compile( r"\n\s*const legendGroup = svg\.append\(\"g\"\)\.attr\(\"class\", \"other\"\);\s*\n" r"\s*const legendConfig = \{[\s\S]*?\n\s*tempText\.remove\(\);\s*\n", re.MULTILINE, ) return pattern.sub("\n" + ROSE_DRAW + "\n", text, count=1) def migrate_gauge_sm(text: str) -> str: if "chartUtils.legend." in text or "legendConfig.maxWidth" not in text: return text pattern = re.compile( r"\n\s*const legendGroup = svg\.append\(\"g\"\)\.attr\(\"class\", \"other\"\);\s*\n" r"\s*const legendConfig = \{[\s\S]*?\n\s*tempText\.remove\(\);\s*\n", re.MULTILINE, ) return pattern.sub("\n" + GAUGE_DRAW + "\n", text, count=1) def migrate_type18(text: str) -> str: if "chartUtils.legend." in text: return text pattern = re.compile( r"\n\s*const legendY = margin\.top - 30;[\s\S]*?" r"\s*currentX \+= legendWidths\[i\] \+ \(i < legendData\.length - 1 \? 5 : 0\);\s*\n" r"\s*\}\);\s*\n", re.MULTILINE, ) repl = ( "\n const legendY = margin.top - 30;\n" " chartUtils.legend.segmentsCentered(svg, [\n" " { type: \"rect\", size: 12, color: colorScale(categories[0]) },\n" " { type: \"rect\", size: 12, color: colorScale(categories[1]) },\n" " { type: \"text\", label: valueField },\n" " { type: \"circle\", size: 12, radius: 6, color: colorScale(categories[0]) },\n" " { type: \"text\", label: valueField2 },\n" " ], {\n" " centerX: margin.left + innerWidth / 2,\n" " y: legendY,\n" " itemGap: 5,\n" " sectionGap: 5,\n" " fontSize: 12,\n" " fontFamily: typography.annotation.font_family,\n" " textColor: colorResolver.text({ fallback: \"#333333\" }).value,\n" " });\n" ) return pattern.sub(repl, text, count=1) def migrate_vg7(text: str) -> str: if "chartUtils.legend." in text: return text pattern = re.compile( r"\n\s*const legendY = margin\.top / 4\*3;[\s\S]*?" r"\s*\.text\(secondaryMetricName\);\s*\n", re.MULTILINE, ) s = "legendSquareSize" repl = ( f"\n const legendY = margin.top / 4 * 3;\n" f" const legendSquareSize = 12;\n" f" const swatchPath = size => [\n" f" `M0,0 L${{size}},0 L0,${{size}} Z`,\n" f" `M${{size}},${{size}} L0,${{size}} L${{size}},0 Z`,\n" f" ];\n" f" chartUtils.legend.segmentsCentered(svg, [\n" f" {{ type: \"path\", size: legendSquareSize, d: swatchPath(legendSquareSize)[0], color: legendG1Color1, transform: `translate(0, ${{-legendSquareSize / 2}})` }},\n" f" {{ type: \"path\", size: 0, d: swatchPath(legendSquareSize)[1], color: legendG1Color2, transform: `translate(0, ${{-legendSquareSize / 2}})` }},\n" f" {{ type: \"text\", label: primaryMetricName }},\n" f" {{ type: \"path\", size: legendSquareSize, d: swatchPath(legendSquareSize)[0], color: legendG1Color1, transform: `translate(0, ${{-legendSquareSize / 2}})` }},\n" f" {{ type: \"path\", size: 0, d: swatchPath(legendSquareSize)[1], color: legendG1Color2, transform: `translate(0, ${{-legendSquareSize / 2}})` }},\n" f" {{ type: \"text\", label: secondaryMetricName }},\n" f" ], {{\n" f" centerX: margin.left + innerWidth / 2,\n" f" y: legendY,\n" f" itemGap: legendItemPadding,\n" f" sectionGap: legendPadding,\n" f" fontSize: legendFontSize,\n" f" fontFamily: legendFontFamily,\n" f" fontWeight: legendFontWeight,\n" f" textColor: colorResolver.text({{ fallback: \"#000000\" }}).value,\n" f" }});\n" ) return pattern.sub(repl, text, count=1) def migrate_scatter(text: str) -> str: if "chartUtils.legend." in text: return text pattern = re.compile( r"\n\s*const legendGroup = svg\.append\(\"g\"\)\.attr\(\"class\", \"chart-legend\"\);\s*\n" r"[\s\S]*?" r"\s*currentX \+= legendSquareSize \+ legendItemPadding \+ textWidth \+ legendColumnPadding;\s*\n" r"\s*\}\);\s*\n", re.MULTILINE, ) repl = ( "\n const legendTitleText = groupField;\n" " const titleWidth = chartUtils.text.measure(svg, legendTitleText, {\n" " fontSize: legendFontSize + 1,\n" " fontFamily: legendFontFamily,\n" " fontWeight: \"bold\",\n" " }).width;\n" " const legendDraw = chartUtils.legend.draw(svg, groups, {\n" " color: group => colorScale(group),\n" " colorResolver,\n" " colors,\n" " }, {\n" " x: margin.left + titleWidth + 15,\n" " y: legendY,\n" " markerShape: \"rect\",\n" " markerSize: legendSquareSize,\n" " labelGap: legendItemPadding,\n" " itemGap: legendColumnPadding,\n" " fontSize: legendFontSize,\n" " fontFamily: legendFontFamily,\n" " fontWeight: legendFontWeight,\n" " textColor: legendColor,\n" " });\n" " const legendBgWidth = titleWidth + 15 + legendDraw.width + 20;\n" " const legendBgHeight = legendSquareSize + 20;\n" " const legendBgX = margin.left - 10;\n" " const legendBgY = legendY - legendBgHeight / 2;\n" " const legendGroup = svg.insert(\"g\", \".chart-legend\").attr(\"class\", \"chart-legend\");\n" " legendGroup.append(\"rect\")\n" " .attr(\"class\", \"legend-background\")\n" " .attr(\"x\", legendBgX)\n" " .attr(\"y\", legendBgY)\n" " .attr(\"width\", legendBgWidth)\n" " .attr(\"height\", legendBgHeight)\n" " .attr(\"fill\", \"white\")\n" " .attr(\"stroke\", \"#ddd\")\n" " .attr(\"rx\", 4)\n" " .attr(\"ry\", 4);\n" " legendGroup.append(\"text\")\n" " .attr(\"class\", \"legend-title\")\n" " .attr(\"x\", margin.left)\n" " .attr(\"y\", legendY)\n" " .attr(\"dominant-baseline\", \"middle\")\n" " .style(\"font-family\", legendFontFamily)\n" " .style(\"font-size\", `${legendFontSize + 1}px`)\n" " .style(\"font-weight\", \"bold\")\n" " .style(\"fill\", legendColor)\n" " .text(legendTitleText);\n" ) return pattern.sub(repl, text, count=1) def migrate_proportional(text: str) -> str: if "chartUtils.legend." in text or "legendG.append" not in text: return text pattern = re.compile( r"\n\s*const legendG = svg\.append\(\"g\"\)\s*\n" r"\s*\.attr\(\"class\", \"legend\"\)\s*\n" r"\s*\.attr\(\"transform\", `translate\(\$\{legendMargin\.left\}, \$\{legendMargin\.top\}\)`\);\s*\n" r"[\s\S]*?" r"\s*legendItems\.forEach\(\(item, i\) => \{\s*\n" r"[\s\S]*?" r"\s*\}\);\s*\n", re.MULTILINE, ) repl = ( "\n const legendItemsForSide = uniqueCategories.map(category => ({\n" " label: category,\n" " color: categoryColors[category],\n" " raw: {\n" " imageHref: images.field && images.field[category] ? images.field[category] : null,\n" " },\n" " }));\n" " chartUtils.legend.sideDetail(svg, legendItemsForSide, {\n" " colorResolver,\n" " colors,\n" " }, {\n" " x: legendMargin.left,\n" " y: legendMargin.top,\n" " fontSize: legendFontSize,\n" " fontFamily: legendFontFamily,\n" " textColor: colorResolver.text({ fallback: \"#333333\" }).value,\n" " });\n" ) return pattern.sub(repl, text, count=1) def migrate_stacked_area(text: str) -> str: if "chartUtils.legend." in text: return text pattern = re.compile( r"\n\s*const legendGroup = g\.append\(\"g\"\)\s*\n" r"[\s\S]*?" r"\s*legendGroup\.attr\(\"transform\", `translate\(\$\{\(chartWidth - totalLegendWidth\)/2\}, 60\}`\);\s*\n", re.MULTILINE, ) repl = ( "\n chartUtils.legend.draw(g, groups, {\n" " color: group => getColor(group),\n" " colorResolver,\n" " colors,\n" " }, {\n" " x: (chartWidth - chartUtils.legend.layout(groups, { color: group => getColor(group), colorResolver, colors, context: g, markerShape: \"line\", markerSize: 30 }).width) / 2,\n" " y: 60,\n" " markerShape: \"line\",\n" " markerSize: 30,\n" " labelGap: 10,\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 pattern.sub(repl, text, count=1) def migrate_line_box(text: str) -> str: if "chartUtils.legend." in text: return text pattern = re.compile( r"\n\s*const legend = svg\.append\(\"g\"\)\s*\n" r"\s*\.attr\(\"transform\", `translate\(\$\{legendX\}, \$\{legendY\}\)`\);\s*\n" r"\s*legend\.append\(\"rect\"\)[\s\S]*?" r"\s*legendItems\.forEach\(\(item, i\) => \{\s*\n" r"[\s\S]*?" r"\s*\}\);\s*\n", re.MULTILINE, ) repl = ( "\n const legendLayout = chartUtils.legend.layout(legendItems, {\n" " color: item => getColor(item),\n" " colorResolver,\n" " colors,\n" " context: svg,\n" " direction: \"vertical\",\n" " markerShape: \"line\",\n" " markerSize: legendLineWidth,\n" " labelGap: legendTextOffset,\n" " rowGap: legendItemHeight,\n" " itemHeight: legendItemHeight,\n" " fontSize: typography.label.font_size,\n" " fontFamily: typography.label.font_family,\n" " });\n" " const legend = svg.append(\"g\").attr(\"transform\", `translate(${legendX}, ${legendY})`);\n" " legend.append(\"rect\")\n" " .attr(\"width\", legendWidth)\n" " .attr(\"height\", legendHeight)\n" " .attr(\"fill\", \"white\")\n" " .attr(\"stroke\", \"#ccc\")\n" " .attr(\"opacity\", 0.9);\n" " chartUtils.legend.draw(legend, legendItems, {\n" " color: item => getColor(item),\n" " colorResolver,\n" " colors,\n" " }, {\n" " x: legendPadding,\n" " y: legendPadding,\n" " direction: \"vertical\",\n" " markerShape: \"line\",\n" " markerSize: legendLineWidth,\n" " labelGap: legendTextOffset,\n" " rowGap: legendItemSpacing,\n" " itemHeight: legendItemHeight,\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 pattern.sub(repl, text, count=1) def migrate_vg4(text: str) -> str: if "chartUtils.legend." in text: return text if "legendItems.find" not in text: return text start = text.find("// 添加图例") if start < 0: start = text.find("const legendItems = [];") end = text.find("// ---------- 8.", start) if end < 0: end = text.find("return svg.node();", start) if start < 0 or end < 0: return text repl = ( " chartUtils.legend.draw(svg, legendItems.map(item => item.label), {\n" " color: (_, i) => legendItems[i].color,\n" " colorResolver,\n" " colors,\n" " }, {\n" " x: width - legendTotalWidth,\n" " y: margin.top - 50,\n" " direction: \"vertical\",\n" " markerShape: \"rect\",\n" " markerSize: legendRectSize,\n" " labelGap: spaceBetweenRectAndText,\n" " rowGap: legendItemHeight - legendRectSize,\n" " itemHeight: legendItemHeight,\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\n" ) pre = text[:start] post = text[end:] pre = re.sub( r"\n\s*const legendItems = \[\];\s*\n[\s\S]*?const legendTotalWidth = [^;]+;\s*\n", "\n const legendItems = [];\n group1Values.forEach(g1 => {\n group2Values.forEach(g2 => {\n legendItems.push({ label: `${g2} / ${g1}`, color: getColor(g1, g2) });\n });\n });\n const legendTotalWidth = 180;\n", pre, count=1, ) return pre + repl + post def process(path: Path) -> bool: original = path.read_text(encoding="utf-8") if "chartUtils.legend." in original: return False rel = str(path.relative_to(TEMPLATE_ROOT)) text = original text = migrate_labeled_strip(text, rel) text = migrate_simple_vertical(text) text = migrate_radial_boxed(text) text = migrate_step_line(text) text = migrate_bump(text) text = migrate_rose(text) text = migrate_gauge_sm(text) text = migrate_type18(text) text = migrate_vg7(text) text = migrate_scatter(text) text = migrate_proportional(text) text = migrate_stacked_area(text) text = migrate_line_box(text) text = migrate_vg4(text) if text != original: path.write_text(text, encoding="utf-8") return True return False def main() -> None: changed = [] for path in sorted(TEMPLATE_ROOT.rglob("*.js")): if process(path): changed.append(str(path.relative_to(TEMPLATE_ROOT))) print(f"migrated {len(changed)} files") for f in changed: print(f) if __name__ == "__main__": main()