Spaces:
Sleeping
Sleeping
| import os | |
| import json | |
| import subprocess | |
| import tempfile | |
| from modules.chart_engine.utils.file_utils import create_temp_file, create_temp_dir, cleanup_temp_file, cleanup_temp_dir | |
| from modules.chart_engine.utils.html_to_svg import html_to_svg | |
| import importlib | |
| import logging | |
| logger = logging.getLogger(__name__) | |
| def _save_to_file(content, output_file=None, prefix="", suffix=".html"): | |
| """ | |
| Helper function to save content to a file, creating a temporary file if needed | |
| Args: | |
| content: Content to write to the file | |
| output_file: Path to save the file (optional) | |
| prefix: Prefix for temp filename | |
| suffix: Suffix for temp filename | |
| Returns: | |
| Path to the created file | |
| """ | |
| if output_file is None: | |
| prefix = f"{prefix}_" | |
| output_file = create_temp_file(prefix=prefix, suffix=suffix, content=content) | |
| else: | |
| with open(output_file, 'w', encoding='utf-8') as f: | |
| f.write(content) | |
| f.close() | |
| return output_file | |
| def _get_dimensions(options, default_width=1200, default_height=800): | |
| """ | |
| Helper function to extract width and height from options | |
| Args: | |
| options: Chart options | |
| default_width: Default width if not specified in options | |
| default_height: Default height if not specified in options | |
| Returns: | |
| Tuple of (width, height) | |
| """ | |
| if not isinstance(options, dict) or "variables" not in options: | |
| return default_width, default_height | |
| width = options["variables"].get("width", default_width) | |
| height = options["variables"].get("height", default_height) | |
| return width, height | |
| def _load_js_code(js_file, base_dirs=None): | |
| """ | |
| Helper function to load JavaScript code from a file | |
| Args: | |
| js_file: Path to the JavaScript file (optional) | |
| base_dirs: List of base directories to search for JavaScript files | |
| Returns: | |
| String containing the JavaScript code | |
| """ | |
| # If js_file is provided and exists, use it | |
| if js_file and os.path.exists(js_file): | |
| with open(js_file, 'r', encoding='utf-8') as f: | |
| return f.read() | |
| raise ValueError(f"No JavaScript file found for chart path: {js_file}. Please provide a valid JS file.") | |
| CHART_UTILS_FILES = [ | |
| "core.js", | |
| "schema.js", | |
| "format.js", | |
| "color.js", | |
| "text.js", | |
| "legend.js", | |
| "random.js", | |
| "index.js", | |
| "compat.js", | |
| ] | |
| def _load_chart_utils_code(): | |
| """Load the D3 chartUtils library from split source files. | |
| Falls back to the bundled utils.js so older checkouts and partial edits still | |
| render D3 templates. | |
| """ | |
| lib_dir = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'lib')) | |
| chart_utils_dir = os.path.join(lib_dir, 'chart_utils') | |
| if os.path.isdir(chart_utils_dir): | |
| paths = [os.path.join(chart_utils_dir, file_name) for file_name in CHART_UTILS_FILES] | |
| if all(os.path.exists(path) for path in paths): | |
| return "\n\n".join(_load_js_code(path).rstrip() for path in paths) + "\n" | |
| utils_lib_path = os.path.join(lib_dir, 'utils.js') | |
| return _load_js_code(utils_lib_path) | |
| def load_js_echarts(json_data=None, output_file=None, js_file=None, width=None, height=None): | |
| """ | |
| Generate an ECharts chart using JavaScript. | |
| This function directly generates an HTML file with the JavaScript code. | |
| Args: | |
| json_data: Dict containing the JSON data for the chart | |
| output_file: Path to save the HTML output file (optional, will create a temp file if None) | |
| js_file: Path to the JavaScript file containing the make_option function | |
| Returns: | |
| Path to the generated HTML file | |
| """ | |
| if json_data is None: | |
| raise ValueError("JSON data must be provided") | |
| # Default HTML template | |
| html_template = """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>ECharts Chart</title> | |
| <script src="%s"></script> | |
| <style> | |
| #chart-container { | |
| width: %dpx; | |
| height: %dpx; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="chart-container"></div> | |
| <script> | |
| // 立即初始化图表,使用SVG渲染器并禁用所有动画 | |
| var chart = echarts.init(document.getElementById('chart-container'), null, { | |
| renderer: 'svg', | |
| animation: false, | |
| useUTC: true | |
| }); | |
| // 禁用全局动画 | |
| echarts.disableAllAnimation = true; | |
| // 准备数据 | |
| const jsonData = JSON_DATA_PLACEHOLDER; | |
| JS_CODE_PLACEHOLDER | |
| // 创建图表选项 | |
| let option; | |
| try { | |
| option = make_option(jsonData); | |
| // 禁用所有动画 | |
| option.animation = false; | |
| option.animationDuration = 0; | |
| option.animationDurationUpdate = 0; | |
| option.animationDelay = 0; | |
| option.animationDelayUpdate = 0; | |
| } catch (e) { | |
| console.error("Error creating chart:", e); | |
| option = { | |
| title: { text: "Error: " + e.message, left: 'center' } | |
| }; | |
| } | |
| // 立即设置选项并渲染 | |
| chart.setOption(option); | |
| // 导出SVG的优化函数 | |
| function exportSvg() { | |
| try { | |
| chart.setOption(option, {notMerge: true}); | |
| const svgContent = chart.renderToSVGString(); | |
| if (svgContent && svgContent.length > 0) { | |
| const svgContainer = document.createElement('div'); | |
| svgContainer.id = 'svg-output'; | |
| svgContainer.style.display = 'none'; | |
| svgContainer.innerHTML = svgContent; | |
| document.body.appendChild(svgContainer); | |
| } | |
| } catch (e) { | |
| console.error("Error exporting SVG:", e); | |
| } | |
| } | |
| // 立即尝试导出 | |
| exportSvg(); | |
| // 设置备用导出计时器,500ms后再尝试一次 | |
| setTimeout(exportSvg, 500); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # 获取本地库文件的绝对路径 | |
| echarts_lib_path = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'lib', 'echarts.min.js')) | |
| # 使用文件协议的URL (用于SVG渲染) | |
| echarts_lib_url = f"file://{echarts_lib_path}" | |
| # Load JavaScript code | |
| if js_file: | |
| js_code = _load_js_code(js_file) | |
| else: | |
| js_code = f""" | |
| function make_option(jsonData) {{ | |
| return JSON.parse(jsonData); | |
| }} | |
| """ | |
| # Create HTML content | |
| formatted_html = html_template % (echarts_lib_url, width, height) | |
| formatted_html = formatted_html.replace('JSON_DATA_PLACEHOLDER', json.dumps(json_data)) | |
| formatted_html = formatted_html.replace('JS_CODE_PLACEHOLDER', js_code) | |
| # Save the HTML to a file | |
| output_file = _save_to_file(formatted_html, output_file) | |
| # 简化日志输出 | |
| return output_file | |
| def load_d3js(json_data=None, output_file=None, js_file=None, width=None, height=None): | |
| """ | |
| Generate a D3.js chart using JavaScript. | |
| This function generates an HTML file with the D3.js code. | |
| Args: | |
| json_data: Dict containing the JSON data for the chart | |
| output_file: Path to save the HTML output file (optional, will create a temp file if None) | |
| js_file: Path to the JavaScript file containing the D3.js implementation | |
| Returns: | |
| Path to the generated HTML file | |
| """ | |
| if json_data is None: | |
| raise ValueError("JSON data must be provided") | |
| # Default HTML template for D3.js - use %% to escape % characters | |
| html_template = """ | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>D3.js Chart</title> | |
| %s | |
| <style> | |
| body { | |
| font-family: Arial, sans-serif; | |
| margin: 20px; | |
| } | |
| #chart-container { | |
| width: 100%%; | |
| max-width: %dpx; | |
| height: %dpx; | |
| margin: 0 auto; | |
| background-color: white; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="chart-container"></div> | |
| <script> | |
| // 准备数据 | |
| const json_data = JSON_DATA_PLACEHOLDER; | |
| // 导入utils.js | |
| UTILS_LIB_PLACEHOLDER | |
| // D3.js实现 | |
| JS_CODE_PLACEHOLDER | |
| // 文档就绪时立即创建图表 | |
| makeChart('#chart-container', json_data); | |
| // 500ms后检查SVG是否已生成 | |
| setTimeout(function() { | |
| const svg = document.querySelector('#chart-container svg'); | |
| if (svg) { | |
| // 创建一个包含SVG内容的容器供提取使用 | |
| const svgContainer = document.createElement('div'); | |
| svgContainer.id = 'svg-output'; | |
| svgContainer.style.display = 'none'; | |
| svgContainer.innerHTML = svg.outerHTML; | |
| document.body.appendChild(svgContainer); | |
| } | |
| }, 500); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # 获取D3.js库文件的绝对路径 | |
| d3_lib_path = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'lib', 'd3.min.js')) | |
| d3_voronoi_lib_path = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'lib', 'd3-voronoi-map.min.js')) | |
| d3_weighted_voronoi_lib_path = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'lib', 'd3-weighted-voronoi.min.js')) | |
| d3_sankey_lib_path = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'lib', 'd3-sankey.min.js')) | |
| d3_rough_lib_path = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'static', 'lib', 'svg2roughjs.umd.min.js')) | |
| # 使用文件协议的URL (用于SVG渲染) | |
| d3_lib_url = f"file://{d3_lib_path}" | |
| d3_voronoi_lib_url = f"file://{d3_voronoi_lib_path}" | |
| d3_weighted_voronoi_lib_url = f"file://{d3_weighted_voronoi_lib_path}" | |
| d3_sankey_lib_url = f"file://{d3_sankey_lib_path}" | |
| d3_rough_lib_url = f"file://{d3_rough_lib_path}" | |
| lib_urls = [d3_lib_url, d3_voronoi_lib_url, d3_weighted_voronoi_lib_url, d3_sankey_lib_url, d3_rough_lib_url] | |
| lib_urls_str = "\n".join([f"<script src='{url}'></script>" for url in lib_urls]) | |
| utils_code = _load_chart_utils_code() | |
| js_code = _load_js_code(js_file) | |
| # Create HTML content | |
| formatted_html = html_template % (lib_urls_str, width, height) | |
| formatted_html = formatted_html.replace('JSON_DATA_PLACEHOLDER', json.dumps(json_data)) | |
| formatted_html = formatted_html.replace('JS_CODE_PLACEHOLDER', js_code) | |
| formatted_html = formatted_html.replace('UTILS_LIB_PLACEHOLDER', utils_code) | |
| # Save the HTML to a file | |
| output_file = _save_to_file(formatted_html, output_file, prefix="_d3") | |
| # 简化日志输出 | |
| return output_file | |
| def load_py_echarts(json_data=None): | |
| """ | |
| Process ECharts options for SVG conversion | |
| Args: | |
| json_data (dict): 包含图表数据和配置的JSON数据 | |
| Returns: | |
| Dict containing the processed JSON data | |
| """ | |
| if not json_data: | |
| raise ValueError("JSON data must be provided") | |
| # 不需要特殊处理,直接返回处理好的选项数据 | |
| return json_data | |
| def _normalize_unit_sentinels(json_data): | |
| """ | |
| ChartPipeline 数据约定里 column.unit 字段使用字符串 "none" / Python None | |
| 表示"无单位",但各个 d3 模板对该 sentinel 的处理并不一致:部分模板正确 | |
| 判断 ``unit !== "none"``,部分仅做 ``unit || ""`` 兜底,导致后者把字符串 | |
| "none" 当作真单位画到 axis label 上 (例: vertical_bar_chart_new_02 的 | |
| Y 轴显示 "none")。 | |
| 在 framework 渲染前统一把这些 sentinel 归一化为 "" — 这样不需要逐个修 | |
| 20+ 个旧模板,新增模板也不会再踩到同一坑。 | |
| """ | |
| SENTINELS = {"none", "null", "nan", "undefined", "n/a", "na"} | |
| try: | |
| cols = (json_data.get("data") or {}).get("columns") | |
| if isinstance(cols, list): | |
| for c in cols: | |
| if not isinstance(c, dict): | |
| continue | |
| u = c.get("unit") | |
| if u is None: | |
| c["unit"] = "" | |
| elif isinstance(u, str) and u.strip().lower() in SENTINELS: | |
| c["unit"] = "" | |
| except Exception: | |
| pass | |
| return json_data | |
| def render_chart_to_svg(json_data, output_svg_path, \ | |
| js_file=None, width=None, height=None, \ | |
| framework="echarts", framework_type='js', html_output_path=None): | |
| """ | |
| 通用的图表渲染函数,支持多种图表框架 | |
| Args: | |
| json_data (dict): 完整的JSON数据,包含图表数据和配置信息 | |
| output_svg_path (str): SVG文件保存路径 | |
| js_file (str, optional): JavaScript文件路径 | |
| width (int, optional): 图表宽度(像素) | |
| height (int, optional): 图表高度(像素) | |
| framework (str): 使用的图表框架,可选"echarts"或"d3" | |
| html_output_path (str, optional): 保存中间HTML文件的路径 | |
| Returns: | |
| Path to the generated SVG file | |
| """ | |
| if width is None or height is None: | |
| w, h = _get_dimensions(json_data) | |
| width = width or w | |
| height = height or h | |
| json_data = _normalize_unit_sentinels(json_data) | |
| if framework.lower() == "vegalite": | |
| # Use vegalite_py template | |
| template = js_file | |
| template_root = "modules.chart_engine.template.vegalite_py" | |
| general_chart_type = template.split('/')[-2] | |
| module_name = template.split('/')[-1].split('.')[0] | |
| # print("template_root: ", template_root) | |
| # print("general_chart_type: ", general_chart_type) | |
| # print("module_name: ", module_name) | |
| module_path = f"{template_root}.{general_chart_type}.{module_name}" | |
| # print(f"module_path: {module_path}") | |
| module = importlib.import_module(module_path) | |
| chart_words = module_name.split('_') | |
| chart_words = [word.capitalize() for word in chart_words] | |
| chart_type = ''.join(chart_words) | |
| template_class = getattr(module, chart_type) | |
| template_object = template_class(json_data) | |
| vega_spec = template_object.make_specification(json_data) | |
| vega_spec_file = create_temp_file(prefix="vega_spec_", suffix=".json", | |
| content=json.dumps(vega_spec, indent=2)) | |
| # try: | |
| svg_file, svg_content = template_object.specification_to_svg(vega_spec, output_svg_path) | |
| if svg_file is None: | |
| raise ValueError("SVG chart generation failed (returned None)") | |
| element_tree = template_object.svg_to_element_tree(svg_content) | |
| template_object.apply_variation(json_data) | |
| svg_file = output_svg_path | |
| svg_content = template_object.element_tree_to_svg(template_object.elements_tree) | |
| with open(output_svg_path, 'w', encoding='utf-8') as f: | |
| f.write(svg_content) | |
| logger.info(f"VegaLite SVG chart generated successfully") | |
| return output_svg_path | |
| # except Exception as e: | |
| # print(f"Error: {e}") | |
| # return None | |
| # 为引擎创建临时目录,用于生成HTML文件 | |
| temp_dir = create_temp_dir(prefix=f"{framework}_svg_") | |
| html_file = os.path.join(temp_dir, 'chart.html') | |
| try: | |
| # 根据框架类型生成HTML文件 | |
| if framework.lower() == "echarts" and framework_type == 'js': | |
| load_js_echarts(json_data=json_data, output_file=html_file, js_file=js_file, width=width, height=height) | |
| elif framework.lower() == "echarts" and framework_type == 'py': | |
| template = js_file | |
| options = template.make_options(json_data) | |
| option_data = json.dumps(options) | |
| load_js_echarts(json_data=option_data, output_file=html_file, width=width, height=height) | |
| elif framework.lower() == "d3": | |
| load_d3js(json_data=json_data, output_file=html_file, js_file=js_file, width=width, height=height) | |
| else: | |
| raise ValueError(f"Unsupported framework: {framework}") | |
| if html_output_path and os.path.exists(html_file): | |
| import shutil | |
| import re | |
| with open(html_file, 'r', encoding='utf-8') as f: | |
| html_content = f.read() | |
| if framework.lower().startswith('echarts'): | |
| lib_file = 'echarts.min.js' | |
| file_pattern = r'file://.*?/' + lib_file | |
| cdn_url = "https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js" | |
| html_content = re.sub(file_pattern, cdn_url, html_content) | |
| else: # D3.js | |
| # For D3.js, we need to replace three different URLs | |
| # Replace d3.min.js | |
| d3_file_pattern = r'file://.*?/d3\.min\.js' | |
| cdn_url = "https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js" | |
| html_content = re.sub(d3_file_pattern, cdn_url, html_content) | |
| # Replace d3-voronoi-map.min.js | |
| voronoi_file_pattern = r'file://.*?/d3-voronoi-map\.min\.js' | |
| cdn_url_voronoi = "https://cdn.jsdelivr.net/npm/d3-voronoi-map@2.1.1/build/d3-voronoi-map.min.js" | |
| html_content = re.sub(voronoi_file_pattern, cdn_url_voronoi, html_content) | |
| # Replace d3-weighted-voronoi.min.js | |
| weighted_voronoi_file_pattern = r'file://.*?/d3-weighted-voronoi\.min\.js' | |
| cdn_url_weighted_voronoi = "https://cdn.jsdelivr.net/npm/d3-weighted-voronoi@1.1.3/build/d3-weighted-voronoi.min.js" | |
| html_content = re.sub(weighted_voronoi_file_pattern, cdn_url_weighted_voronoi, html_content) | |
| # Replace d3-sankey.min.js | |
| sankey_file_pattern = r'file://.*?/d3-sankey\.min\.js' | |
| cdn_url_sankey = "https://cdn.jsdelivr.net/npm/d3-sankey@0.12.3/dist/d3-sankey.min.js" | |
| html_content = re.sub(sankey_file_pattern, cdn_url_sankey, html_content) | |
| # Replace svg2roughjs.umd.min.js | |
| rough_file_pattern = r'file://.*?/svg2roughjs\.umd\.min\.js' | |
| cdn_url_rough = "https://unpkg.com/svg2roughjs@3.2.1/dist/svg2roughjs.umd.min.js" | |
| html_content = re.sub(rough_file_pattern, cdn_url_rough, html_content) | |
| with open(html_output_path, 'w', encoding='utf-8') as f: | |
| f.write(html_content) | |
| print(f"Saved intermediate HTML file to: {html_output_path}") | |
| # 使用html_to_svg转换为SVG | |
| svg_file = html_to_svg(html_file, output_svg_path, width=width, height=height) | |
| if svg_file is not None and os.path.exists(svg_file): | |
| # 简化日志输出,只返回路径,不打印 | |
| return svg_file | |
| else: | |
| raise Exception(f"Failed to create SVG file from {framework.upper()} HTML") | |
| except Exception as e: | |
| print(f"Error load charts: {e}") | |
| return None | |
| finally: | |
| if os.path.exists(temp_dir): | |
| cleanup_temp_dir(temp_dir) | |
| # 保持向后兼容的函数 | |
| def render_d3js_chart_to_svg(json_data, output_svg_path, js_file=None, width=None, height=None, html_output_path=None): | |
| """ | |
| 渲染D3.js图表为SVG (向后兼容函数) | |
| Args: | |
| json_data (dict): 图表数据和选项 | |
| output_svg_path (str): SVG文件保存路径 | |
| js_file (str): D3.js实现文件的路径 | |
| width (int, optional): 图表宽度(像素) | |
| height (int, optional): 图表高度(像素) | |
| html_output_path (str, optional): 保存中间HTML文件的路径 | |
| Returns: | |
| Path to the generated SVG file | |
| """ | |
| # 调用统一的渲染函数 | |
| return render_chart_to_svg( | |
| json_data=json_data, | |
| output_svg_path=output_svg_path, | |
| js_file=js_file, | |
| width=width, | |
| height=height, | |
| framework="d3", | |
| html_output_path=html_output_path | |
| ) | |
| def render_vegalite_specification_to_svg(vegalite_specification, output_svg_path): | |
| import subprocess | |
| import json | |
| import os | |
| import random | |
| class NodeBridge: | |
| def execute_node_script(script_path: str, data: dict) -> str: | |
| # 生成一个随机种子 | |
| random.seed(random.randint(0, 1000000)) | |
| # 将数据写入临时JSON文件 | |
| tmp_input = f'temp_input_{random.randint(0, 1000000)}.json' | |
| with open(tmp_input, 'w', encoding='utf-8') as f: | |
| json.dump(data, f) | |
| # 执行Node.js脚本 | |
| result = subprocess.run([ | |
| 'node', script_path, tmp_input | |
| ], capture_output=True, encoding='utf-8') | |
| # 清理临时文件 | |
| os.remove(tmp_input) | |
| if result.returncode != 0: | |
| raise Exception(f"Node.js执行错误: {result.stderr}") | |
| return result.stdout | |
| script_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'template', 'vegalite_py', 'vega_spec.js') | |
| result = NodeBridge.execute_node_script(script_path, { | |
| "spec": vegalite_specification, | |
| }) | |
| # 把result写入output_svg_path | |
| with open(output_svg_path, 'w', encoding='utf-8') as f: | |
| f.write(result) | |
| return output_svg_path | |