From c0896a151fff0ed49032221663263e722a1ffd7a Mon Sep 17 00:00:00 2001 From: terapyon Date: Wed, 24 Dec 2025 15:02:33 +0900 Subject: [PATCH 1/9] Feature Release: Standalone HTML Export --- CHANGES.md | 65 ++++ README.md | 30 +- docs/source/examples/html_export.nblink | 3 + docs/source/examples/index.rst | 29 +- docs/source/index.rst | 19 +- examples/html_export.ipynb | 333 ++++++++++++++++++ net_vis/__init__.py | 1 + net_vis/_version.py | 2 +- net_vis/html_exporter.py | 252 ++++++++++++++ net_vis/plotter.py | 136 ++++++++ net_vis/resources/netvis-standalone.min.js | 1 + net_vis/templates/standalone.html | 41 +++ net_vis/tests/test_html_exporter.py | 382 +++++++++++++++++++++ package.json | 3 +- pyproject.toml | 2 +- src/standalone.ts | 17 + webpack.standalone.js | 42 +++ 17 files changed, 1351 insertions(+), 7 deletions(-) create mode 100644 docs/source/examples/html_export.nblink create mode 100644 examples/html_export.ipynb create mode 100644 net_vis/html_exporter.py create mode 100644 net_vis/resources/netvis-standalone.min.js create mode 100644 net_vis/templates/standalone.html create mode 100644 net_vis/tests/test_html_exporter.py create mode 100644 src/standalone.ts create mode 100644 webpack.standalone.js diff --git a/CHANGES.md b/CHANGES.md index ced8275..e3b00f8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,70 @@ # Changelog +## 0.6.0 (2025-12-25) + +**Feature Release: Standalone HTML Export** + +### New Features + +- **HTML Export API**: Export visualizations as self-contained HTML files + - `Plotter.export_html()` method for saving graphs as standalone HTML + - Works offline without internet connection or JupyterLab + - Preserves all interactive features (zoom, pan, node selection, drag) + +- **Export Customization**: + - Custom title and description for HTML documents + - Configurable container width (CSS values) and height (pixels) + - Default responsive layout (100% width x 600px height) + +- **Flexible Output Options**: + - File export with automatic .html extension + - Automatic parent directory creation + - HTML string return for programmatic use + - Browser download trigger for remote environments (JupyterHub, Google Colab) + +### API Examples + +```python +from net_vis import Plotter +import networkx as nx + +G = nx.karate_club_graph() +plotter = Plotter(title="Karate Club") +plotter.add_networkx(G) + +# Export to file +path = plotter.export_html("my_graph.html") + +# Export with customization +plotter.export_html( + "report.html", + title="Network Analysis", + description="Karate club social network", + width="800px", + height=700 +) + +# Get HTML string +html = plotter.export_html() + +# Remote environment download +plotter.export_html("graph.html", download=True) +``` + +### Implementation Details + +- **HTMLExporter**: Template-based HTML generation using string.Template +- **Standalone Bundle**: D3.js + rendering code bundled via webpack (~280KB) +- **Test Coverage**: 26 new tests covering all export functionality +- **Error Handling**: Proper exception propagation for file system errors + +### Compatibility + +- All modern browsers (Chrome, Firefox, Safari, Edge) +- Offline capable (no CDN or internet dependency) +- JupyterLab: 3.x and 4.x +- Python: 3.10+ + ## 0.5.0 (2025-12-24) **Major Feature Release: NetworkX Plotter API** (terapyon) diff --git a/README.md b/README.md index 4a65e03..2ba531f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ NetVis is a package for interactive visualization of Python NetworkX graphs within JupyterLab. It leverages D3.js for dynamic rendering and provides a high-level Plotter API for effortless network analysis. -**Version 0.5.0** introduces the NetworkX Plotter API, enabling direct visualization of NetworkX graph objects without manual JSON conversion. +**Version 0.6.0** adds standalone HTML export, enabling you to share visualizations as self-contained HTML files that work anywhere—no JupyterLab or internet connection required. ## Installation @@ -69,6 +69,34 @@ plotter.add_networkx( - **Styling**: Attribute-based or function-based color/label mapping - **Automatic**: Node/edge attribute preservation in metadata +#### HTML Export (New in v0.6.0) + +Export your visualizations as standalone HTML files: + +```python +# Export to file +path = plotter.export_html("my_graph.html") +print(f"Exported to {path}") + +# Export with customization +plotter.export_html( + "report.html", + title="Network Analysis Report", + description="Generated from NetworkX graph", + width="800px", + height=700 +) + +# Get HTML as string for embedding +html = plotter.export_html() +``` + +The exported HTML files: +- Work offline (no internet required) +- Include all interactive features (zoom, pan, node selection) +- Are self-contained (no external dependencies) +- Open in any modern browser + ### Low-Level API (Advanced) For manual control over the visualization data structure: diff --git a/docs/source/examples/html_export.nblink b/docs/source/examples/html_export.nblink new file mode 100644 index 0000000..f84e997 --- /dev/null +++ b/docs/source/examples/html_export.nblink @@ -0,0 +1,3 @@ +{ + "path": "../../../examples/html_export.ipynb" +} diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index 6e8c566..a3f693c 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -8,7 +8,7 @@ This section contains examples of using NetVis for interactive graph visualizati NetworkX Plotter API (Recommended) ----------------------------------- -**New in v0.5.0**: The easiest way to visualize NetworkX graphs:: +The easiest way to visualize NetworkX graphs:: from net_vis import Plotter import networkx as nx @@ -21,6 +21,32 @@ NetworkX Plotter API (Recommended) For comprehensive examples including custom styling, layouts, and multi-graph support, see the :ref:`NetworkX Plotter API notebook ` below. +HTML Export (New in v0.6.0) +--------------------------- + +Export visualizations as self-contained HTML files:: + + # Export to file + plotter.export_html("my_graph.html") + + # Export with customization + plotter.export_html( + "report.html", + title="Network Analysis", + description="Generated from NetworkX graph", + width="800px", + height=700 + ) + + # Get HTML as string + html = plotter.export_html() + + # Browser download for remote environments + plotter.export_html("graph.html", download=True) + +The exported HTML files work offline and include all interactive features. See the :ref:`HTML Export notebook ` for more examples. + + Low-Level API ------------- @@ -91,4 +117,5 @@ Detailed Examples :maxdepth: 2 networkx_plotter + html_export introduction diff --git a/docs/source/index.rst b/docs/source/index.rst index c7dd332..5ab5f01 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -6,7 +6,7 @@ Version: |release| NetVis is a package for interactive visualization of Python NetworkX graphs within JupyterLab. It leverages D3.js for dynamic rendering and provides a high-level Plotter API for effortless network analysis. -**Version 0.5.0** introduces the NetworkX Plotter API, enabling direct visualization of NetworkX graph objects without manual JSON conversion. +**Version 0.6.0** adds standalone HTML export, enabling you to share visualizations as self-contained HTML files that work anywhere—no JupyterLab or internet connection required. Quickstart @@ -16,7 +16,7 @@ To get started with net_vis, install with pip:: pip install net_vis -**NetworkX Plotter API (New in v0.5.0)**:: +**NetworkX Plotter API**:: from net_vis import Plotter import networkx as nx @@ -26,6 +26,21 @@ To get started with net_vis, install with pip:: plotter = Plotter(title="Karate Club Network") plotter.add_networkx(G) +**HTML Export (New in v0.6.0)**:: + + # Export to standalone HTML file + plotter.export_html("my_graph.html") + + # Export with customization + plotter.export_html( + "report.html", + title="Network Analysis", + description="Karate club social network" + ) + + # Get HTML as string for embedding + html = plotter.export_html() + **Note**: NetVis uses a MIME renderer that works automatically in JupyterLab 3.x and 4.x. Manual extension enabling is not required. diff --git a/examples/html_export.ipynb b/examples/html_export.ipynb new file mode 100644 index 0000000..9c4f8ef --- /dev/null +++ b/examples/html_export.ipynb @@ -0,0 +1,333 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# HTML Export (New in v0.6.0)\n", + "\n", + "This notebook demonstrates the HTML export feature introduced in version 0.6.0.\n", + "\n", + "The `export_html()` method allows you to save visualizations as self-contained HTML files that:\n", + "- Work offline (no internet connection required)\n", + "- Include all interactive features (zoom, pan, node selection, drag)\n", + "- Can be opened in any modern browser\n", + "- Are fully self-contained (no external dependencies)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from net_vis import Plotter\n", + "import networkx as nx\n", + "from pathlib import Path" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Basic Export to File\n", + "\n", + "The simplest way to export a visualization:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a sample graph\n", + "G = nx.karate_club_graph()\n", + "\n", + "# Create plotter and add graph\n", + "plotter = Plotter(title=\"Karate Club Network\")\n", + "plotter.add_networkx(G, node_color=\"club\", layout=\"kamada_kawai\")\n", + "\n", + "# Export to HTML file\n", + "output_path = plotter.export_html(\"karate_club.html\")\n", + "print(f\"Exported to: {output_path}\")\n", + "print(f\"File size: {output_path.stat().st_size / 1024:.1f} KB\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Export with Custom Title and Description\n", + "\n", + "Add metadata to the exported HTML:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a social network graph\n", + "G2 = nx.Graph()\n", + "G2.add_node(1, name=\"Alice\", department=\"Engineering\")\n", + "G2.add_node(2, name=\"Bob\", department=\"Marketing\")\n", + "G2.add_node(3, name=\"Charlie\", department=\"Engineering\")\n", + "G2.add_node(4, name=\"Diana\", department=\"Sales\")\n", + "G2.add_node(5, name=\"Eve\", department=\"Marketing\")\n", + "G2.add_edges_from([(1, 2), (1, 3), (2, 4), (2, 5), (3, 4), (4, 5)])\n", + "\n", + "plotter2 = Plotter(title=\"Team Connections\")\n", + "plotter2.add_networkx(\n", + " G2,\n", + " node_color=\"department\",\n", + " node_label=\"name\",\n", + " layout=\"spring\"\n", + ")\n", + "\n", + "# Export with custom metadata\n", + "output_path = plotter2.export_html(\n", + " \"team_network.html\",\n", + " title=\"Team Connection Analysis\",\n", + " description=\"Network visualization showing connections between team members across departments.\"\n", + ")\n", + "print(f\"Exported to: {output_path}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Custom Size Configuration\n", + "\n", + "Control the width and height of the visualization:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a larger graph\n", + "G3 = nx.random_geometric_graph(50, 0.2)\n", + "\n", + "plotter3 = Plotter(title=\"Random Geometric Graph\")\n", + "plotter3.add_networkx(G3, layout=\"spring\")\n", + "\n", + "# Export with custom dimensions\n", + "output_path = plotter3.export_html(\n", + " \"large_graph.html\",\n", + " title=\"Large Graph Visualization\",\n", + " width=\"800px\", # Fixed width (can also use \"100%\" for responsive)\n", + " height=700 # Height in pixels\n", + ")\n", + "print(f\"Exported to: {output_path}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Get HTML as String\n", + "\n", + "For programmatic use, you can get the HTML content as a string instead of writing to a file:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a simple graph\n", + "G4 = nx.path_graph(5)\n", + "\n", + "plotter4 = Plotter(title=\"Path Graph\")\n", + "plotter4.add_networkx(G4)\n", + "\n", + "# Get HTML as string (no filepath)\n", + "html_content = plotter4.export_html()\n", + "\n", + "print(f\"HTML content length: {len(html_content)} characters\")\n", + "print(f\"First 200 characters:\\n{html_content[:200]}...\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Browser Download (for Remote Environments)\n", + "\n", + "When working in remote environments like JupyterHub or Google Colab, use the `download=True` option to trigger a browser download:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a graph\n", + "G5 = nx.complete_graph(6)\n", + "\n", + "plotter5 = Plotter(title=\"Complete Graph K6\")\n", + "plotter5.add_networkx(G5, layout=\"circular\")\n", + "\n", + "# Export and trigger browser download\n", + "# Note: This will display a download link in Jupyter environments\n", + "output_path = plotter5.export_html(\n", + " \"complete_graph.html\",\n", + " download=True # Triggers browser download\n", + ")\n", + "print(f\"File saved to: {output_path}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Export to Subdirectory\n", + "\n", + "Parent directories are created automatically:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a directed graph\n", + "DG = nx.DiGraph()\n", + "DG.add_edges_from([(\"A\", \"B\"), (\"A\", \"C\"), (\"B\", \"D\"), (\"C\", \"D\"), (\"D\", \"E\")])\n", + "\n", + "plotter6 = Plotter(title=\"Directed Graph\")\n", + "plotter6.add_networkx(DG, layout=\"spring\")\n", + "\n", + "# Export to subdirectory (created automatically)\n", + "output_path = plotter6.export_html(\"exports/directed/dag.html\")\n", + "print(f\"Exported to: {output_path}\")\n", + "print(f\"Directory exists: {output_path.parent.exists()}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Using Path Objects\n", + "\n", + "You can use `pathlib.Path` objects for file paths:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create a graph\n", + "G6 = nx.star_graph(8)\n", + "\n", + "plotter7 = Plotter(title=\"Star Graph\")\n", + "plotter7.add_networkx(G6, layout=\"circular\")\n", + "\n", + "# Use Path object\n", + "output_dir = Path(\"exports\")\n", + "output_file = output_dir / \"star_graph.html\"\n", + "\n", + "result = plotter7.export_html(output_file)\n", + "print(f\"Type: {type(result)}\")\n", + "print(f\"Exported to: {result}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Automatic .html Extension\n", + "\n", + "If you forget the `.html` extension, it's added automatically:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "G7 = nx.cycle_graph(10)\n", + "\n", + "plotter8 = Plotter(title=\"Cycle Graph\")\n", + "plotter8.add_networkx(G7)\n", + "\n", + "# Extension is added automatically\n", + "output_path = plotter8.export_html(\"cycle_graph\") # No .html\n", + "print(f\"Exported to: {output_path}\")\n", + "print(f\"Extension: {output_path.suffix}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Cleanup\n", + "\n", + "Clean up the exported files:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import shutil\n", + "\n", + "# Remove exported files\n", + "files_to_remove = [\n", + " \"karate_club.html\",\n", + " \"team_network.html\",\n", + " \"large_graph.html\",\n", + " \"complete_graph.html\",\n", + " \"cycle_graph.html\",\n", + "]\n", + "\n", + "for f in files_to_remove:\n", + " p = Path(f)\n", + " if p.exists():\n", + " p.unlink()\n", + " print(f\"Removed: {f}\")\n", + "\n", + "# Remove exports directory\n", + "if Path(\"exports\").exists():\n", + " shutil.rmtree(\"exports\")\n", + " print(\"Removed: exports/\")\n", + "\n", + "print(\"Cleanup complete!\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/net_vis/__init__.py b/net_vis/__init__.py index fd08357..e912388 100644 --- a/net_vis/__init__.py +++ b/net_vis/__init__.py @@ -1,3 +1,4 @@ from ._version import __version__, version_info +from .html_exporter import ExportOptions, HTMLExporter from .netvis import NetVis from .plotter import Plotter diff --git a/net_vis/_version.py b/net_vis/_version.py index 009fe53..ca8f4ca 100644 --- a/net_vis/_version.py +++ b/net_vis/_version.py @@ -1,2 +1,2 @@ -version_info = (0, 5, 0) +version_info = (0, 6, 0) __version__ = ".".join(map(str, version_info)) diff --git a/net_vis/html_exporter.py b/net_vis/html_exporter.py new file mode 100644 index 0000000..97300c1 --- /dev/null +++ b/net_vis/html_exporter.py @@ -0,0 +1,252 @@ +"""HTML export functionality for standalone visualization files.""" + +import json +from dataclasses import dataclass +from pathlib import Path +from string import Template + +from .models import Scene + + +@dataclass +class ExportOptions: + """Options for HTML export customization. + + Attributes: + title: Custom title for the HTML document. + Overrides Scene.title if provided. + description: Description text to display in the HTML. + width: Container width as CSS value (e.g., "100%", "800px"). + Default: "100%" + height: Container height in pixels. + Default: 600 + """ + + title: str | None = None + description: str | None = None + width: str = "100%" + height: int = 600 + + +class HTMLExporter: + """Converts Scene objects to standalone HTML documents. + + This class handles the template-based generation of self-contained + HTML files that embed all necessary resources (D3.js, CSS, data) + for offline visualization. + + This is an internal implementation class. Users should use + Plotter.export_html() instead. + """ + + def __init__(self) -> None: + """Initialize exporter with cached template and resources. + + Loads the HTML template and JS bundle once at initialization + for efficient reuse across multiple export calls. + """ + self._template: Template = self._load_template() + self._js_bundle: str = self._load_js_bundle() + + def export( + self, + scene: Scene, + options: ExportOptions | None = None, + ) -> str: + """Generate standalone HTML from scene. + + Args: + scene: Scene object containing graph layers to export. + options: Optional ExportOptions for customization. + If None, default options are used. + + Returns: + Complete HTML document as UTF-8 string. + + Notes: + - Empty scenes produce valid HTML with empty visualization + - All scene layers are included in the export + - Node/edge metadata is preserved in embedded JSON + """ + if options is None: + options = ExportOptions() + + # Resolve title + title = self._resolve_title(options, scene) + + # Generate components + css_styles = self._generate_css() + json_data = self._serialize_data(scene) + + # Substitute template variables + html = self._template.substitute( + title=title, + display_title=title if title != "Network Visualization" else "", + description=options.description or "", + width=options.width, + height=options.height, + css_styles=css_styles, + js_bundle=self._js_bundle, + json_data=json_data, + ) + + return html + + def _load_template(self) -> Template: + """Load HTML template from package resources. + + Returns: + string.Template object with placeholder variables. + + Raises: + FileNotFoundError: If template file is missing from package. + """ + template_path = Path(__file__).parent / "templates" / "standalone.html" + if not template_path.exists(): + raise FileNotFoundError(f"HTML template not found: {template_path}") + template_content = template_path.read_text(encoding="utf-8") + return Template(template_content) + + def _load_js_bundle(self) -> str: + """Load minified JavaScript bundle. + + The bundle includes: + - D3.js (selection, force, zoom, drag modules) + - NetVis rendering code (adapted from graph.ts) + - Settings and color schemes + + Returns: + Minified JavaScript code as string. + + Raises: + FileNotFoundError: If bundle file is missing from package. + """ + bundle_path = Path(__file__).parent / "resources" / "netvis-standalone.min.js" + if not bundle_path.exists(): + raise FileNotFoundError(f"JavaScript bundle not found: {bundle_path}") + return bundle_path.read_text(encoding="utf-8") + + def _generate_css(self) -> str: + """Generate CSS styles for the visualization. + + Returns: + CSS stylesheet as string. + + Styles include: + - Container layout (responsive width, fixed height) + - Title and description typography + - SVG element sizing + - Node and edge default styles + - Hover and selection states + """ + return """ + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, sans-serif; + background-color: #f5f5f5; + color: #333; + } + + .netvis-container { + max-width: 100%; + margin: 0 auto; + padding: 20px; + } + + .netvis-title { + font-size: 24px; + font-weight: 600; + margin-bottom: 10px; + color: #222; + } + + .netvis-description { + font-size: 14px; + color: #666; + margin-bottom: 20px; + line-height: 1.5; + } + + #netvis-graph { + background-color: #fff; + border: 1px solid #ddd; + border-radius: 4px; + overflow: hidden; + } + + #netvis-graph svg { + width: 100%; + height: 100%; + display: block; + } + + .node-group circle { + cursor: pointer; + stroke: #fff; + stroke-width: 1.5px; + } + + .node-group circle:hover { + stroke: #333; + stroke-width: 2px; + } + + .node-group.clicked circle { + stroke: #000; + stroke-width: 3px; + } + + .node-group text { + pointer-events: none; + font-size: 12px; + fill: #333; + text-shadow: 0 1px 2px rgba(255,255,255,0.8); + } + """ + + def _serialize_data(self, scene: Scene) -> str: + """Serialize scene to JSON for embedding. + + Uses Scene.to_dict() to convert the scene to the netvis + JSON format, then serializes to a JSON string suitable + for embedding in a + + + diff --git a/net_vis/tests/test_html_exporter.py b/net_vis/tests/test_html_exporter.py new file mode 100644 index 0000000..9b16619 --- /dev/null +++ b/net_vis/tests/test_html_exporter.py @@ -0,0 +1,382 @@ +"""Tests for HTML export functionality.""" + +from pathlib import Path + +import pytest + +from net_vis import Plotter +from net_vis.html_exporter import HTMLExporter +from net_vis.models import Edge, GraphLayer, Node, Scene + + +@pytest.fixture +def sample_scene() -> Scene: + """Create a sample scene with one layer for testing.""" + nodes = [ + Node(id="1", label="Node 1", x=0, y=0, color="TYPE_A"), + Node(id="2", label="Node 2", x=100, y=100, color="TYPE_B"), + Node(id="3", label="Node 3", x=50, y=150, color="TYPE_A"), + ] + edges = [ + Edge(source="1", target="2", label="edge1"), + Edge(source="2", target="3", label="edge2"), + ] + layer = GraphLayer(layer_id="test_layer", nodes=nodes, edges=edges) + return Scene(layers=[layer], title="Test Scene") + + +@pytest.fixture +def sample_plotter(sample_scene: Scene) -> Plotter: + """Create a plotter with sample data for testing.""" + plotter = Plotter(title="Test Graph") + # Directly set the internal scene for testing + plotter._scene = sample_scene + return plotter + + +@pytest.fixture +def exporter() -> HTMLExporter: + """Create HTMLExporter instance.""" + return HTMLExporter() + + +class TestHTMLExporterBasic: + """Basic HTML export tests for User Story 1.""" + + def test_exporter_initialization(self, exporter: HTMLExporter) -> None: + """Test that HTMLExporter initializes with template and bundle.""" + assert exporter._template is not None + assert exporter._js_bundle is not None + assert len(exporter._js_bundle) > 0 + + def test_export_returns_string(self, exporter: HTMLExporter, sample_scene: Scene) -> None: + """Test that export() returns HTML as string.""" + html = exporter.export(sample_scene) + assert isinstance(html, str) + assert len(html) > 0 + + +class TestExportToFile: + """Tests for file export functionality (T015-T017).""" + + def test_export_to_file(self, sample_plotter: Plotter, tmp_path: Path) -> None: + """T015: Verify file is created with .html extension.""" + filepath = tmp_path / "test_graph.html" + result = sample_plotter.export_html(filepath) + + assert isinstance(result, Path) + assert result.exists() + assert result.suffix == ".html" + + def test_export_auto_adds_html_extension(self, sample_plotter: Plotter, tmp_path: Path) -> None: + """Verify .html extension is auto-added if missing.""" + filepath = tmp_path / "test_graph" # No extension + result = sample_plotter.export_html(filepath) + + assert result.suffix == ".html" + assert result.name == "test_graph.html" + assert result.exists() + + def test_export_creates_directories(self, sample_plotter: Plotter, tmp_path: Path) -> None: + """T016: Verify parent directories are created automatically.""" + filepath = tmp_path / "subdir1" / "subdir2" / "test_graph.html" + assert not filepath.parent.exists() + + result = sample_plotter.export_html(filepath) + + assert result.exists() + assert filepath.parent.exists() + + def test_export_overwrites_existing(self, sample_plotter: Plotter, tmp_path: Path) -> None: + """T017: Verify file overwrite behavior.""" + filepath = tmp_path / "test_graph.html" + + # Write initial file + filepath.write_text("original content") + assert filepath.read_text() == "original content" + + # Export should overwrite + sample_plotter.export_html(filepath) + + content = filepath.read_text() + assert "original content" not in content + assert "" in content + + +class TestExportedHTMLValidity: + """Tests for HTML content validity (T018-T021).""" + + def test_exported_html_is_valid(self, sample_plotter: Plotter, tmp_path: Path) -> None: + """T018: Verify valid HTML5 structure.""" + filepath = tmp_path / "test.html" + sample_plotter.export_html(filepath) + html = filepath.read_text(encoding="utf-8") + + # Check HTML5 doctype + assert html.startswith("") + + # Check essential HTML structure + assert "" in html + assert "" in html + assert "" in html + assert "" in html + assert "" in html + + # Check meta tags + assert 'charset="UTF-8"' in html + assert 'name="viewport"' in html + + def test_exported_html_contains_data(self, sample_plotter: Plotter, tmp_path: Path) -> None: + """T019: Verify graph data embedded as JSON.""" + filepath = tmp_path / "test.html" + sample_plotter.export_html(filepath) + html = filepath.read_text(encoding="utf-8") + + # Check that node data is embedded + assert '"nodes"' in html + assert '"links"' in html + + # Check specific node IDs from sample data + assert '"id": "1"' in html or '"id":"1"' in html + assert '"id": "2"' in html or '"id":"2"' in html + + def test_exported_html_contains_js(self, sample_plotter: Plotter, tmp_path: Path) -> None: + """T020: Verify JavaScript bundle embedded.""" + filepath = tmp_path / "test.html" + sample_plotter.export_html(filepath) + html = filepath.read_text(encoding="utf-8") + + # Check that JS bundle is embedded + assert "'); + }); + + it('should include graph container with specified dimensions', () => { + const html = generateStandaloneHtml({ + ...defaultConfig, + width: '800px', + height: 700, + }); + + expect(html).toContain('width: 800px'); + expect(html).toContain('height: 700px'); + }); + + it('should handle empty graph data', () => { + const html = generateStandaloneHtml({ + ...defaultConfig, + graphData: { nodes: [], links: [] }, + }); + + // Should still generate valid HTML + expect(html).toContain(''); + expect(html).toContain('"nodes":[]'); + expect(html).toContain('"links":[]'); + }); +}); + +// T071: test_download_button_click() - verify click triggers download +describe('downloadHtml', () => { + let mockCreateObjectURL: jest.Mock; + let mockRevokeObjectURL: jest.Mock; + let mockAppendChild: jest.SpyInstance; + let mockRemoveChild: jest.SpyInstance; + let mockClick: jest.Mock; + let mockLink: HTMLAnchorElement; + let originalCreateObjectURL: typeof URL.createObjectURL; + let originalRevokeObjectURL: typeof URL.revokeObjectURL; + + beforeEach(() => { + // Save originals + originalCreateObjectURL = URL.createObjectURL; + originalRevokeObjectURL = URL.revokeObjectURL; + + // Mock URL methods by direct assignment (jsdom doesn't have these) + mockCreateObjectURL = jest.fn().mockReturnValue('blob:mock-url'); + mockRevokeObjectURL = jest.fn(); + URL.createObjectURL = mockCreateObjectURL; + URL.revokeObjectURL = mockRevokeObjectURL; + + // Mock DOM methods + mockClick = jest.fn(); + mockLink = { + href: '', + download: '', + click: mockClick, + } as unknown as HTMLAnchorElement; + + jest.spyOn(document, 'createElement').mockReturnValue(mockLink); + mockAppendChild = jest + .spyOn(document.body, 'appendChild') + .mockImplementation(() => mockLink); + mockRemoveChild = jest + .spyOn(document.body, 'removeChild') + .mockImplementation(() => mockLink); + }); + + afterEach(() => { + // Restore originals + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + jest.restoreAllMocks(); + }); + + it('should create Blob with HTML content', () => { + const htmlContent = ''; + downloadHtml(htmlContent, 'test.html'); + + expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob)); + }); + + it('should set correct filename on link element', () => { + downloadHtml('', 'my_graph.html'); + + expect(mockLink.download).toBe('my_graph.html'); + }); + + it('should trigger click on link element', () => { + downloadHtml('', 'test.html'); + + expect(mockClick).toHaveBeenCalled(); + }); + + it('should clean up blob URL after download', () => { + downloadHtml('', 'test.html'); + + expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url'); + }); + + it('should append and remove link from document body', () => { + downloadHtml('', 'test.html'); + + expect(mockAppendChild).toHaveBeenCalledWith(mockLink); + expect(mockRemoveChild).toHaveBeenCalledWith(mockLink); + }); +}); + +// T070: test_download_button_renders() - verify button appears in renderer output +describe('createDownloadButton', () => { + let mockGraphData: { nodes: any[]; links: any[] }; + + beforeEach(() => { + mockGraphData = { + nodes: [{ id: 'A' }], + links: [], + }; + }); + + it('should create a button element', () => { + const button = createDownloadButton(mockGraphData); + + expect(button.tagName).toBe('BUTTON'); + }); + + it('should have correct CSS class', () => { + const button = createDownloadButton(mockGraphData); + + expect(button.classList.contains('netvis-download-btn')).toBe(true); + }); + + // T074: test_button_accessibility() - verify aria-label attribute + it('should have aria-label for accessibility', () => { + const button = createDownloadButton(mockGraphData); + + expect(button.getAttribute('aria-label')).toBe('Download HTML'); + }); + + it('should contain SVG icon', () => { + const button = createDownloadButton(mockGraphData); + + const svg = button.querySelector('svg'); + expect(svg).not.toBeNull(); + }); + + it('should trigger download on click', () => { + // Save originals + const originalCreateObjectURL = URL.createObjectURL; + const originalRevokeObjectURL = URL.revokeObjectURL; + const originalCreateElement = document.createElement.bind(document); + + // Mock URL methods + URL.createObjectURL = jest.fn().mockReturnValue('blob:mock'); + URL.revokeObjectURL = jest.fn(); + + const mockClick = jest.fn(); + + // Mock createElement to only intercept anchor creation + jest + .spyOn(document, 'createElement') + .mockImplementation((tagName: string) => { + if (tagName === 'a') { + return { + href: '', + download: '', + click: mockClick, + } as unknown as HTMLAnchorElement; + } + return originalCreateElement(tagName); + }); + + jest + .spyOn(document.body, 'appendChild') + .mockImplementation((node) => node as Node); + jest + .spyOn(document.body, 'removeChild') + .mockImplementation((node) => node as Node); + + const button = createDownloadButton(mockGraphData); + button.click(); + + expect(mockClick).toHaveBeenCalled(); + + // Restore originals + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + jest.restoreAllMocks(); + }); +}); + +// T092: Empty graph button test +describe('download button with empty graph', () => { + it('should work with empty graph data', () => { + const emptyGraphData = { nodes: [], links: [] }; + const button = createDownloadButton(emptyGraphData); + + expect(button).toBeDefined(); + expect(button.classList.contains('netvis-download-btn')).toBe(true); + }); + + it('should generate valid HTML for empty graph', () => { + const config: ExportConfig = { + title: 'Empty Graph', + width: '100%', + height: 600, + graphData: { nodes: [], links: [] }, + }; + const html = generateStandaloneHtml(config); + + expect(html).toContain(''); + expect(html).toContain('"nodes":[]'); + }); +}); diff --git a/src/htmlExport.ts b/src/htmlExport.ts new file mode 100644 index 0000000..9555713 --- /dev/null +++ b/src/htmlExport.ts @@ -0,0 +1,307 @@ +/** + * HTML Export functionality for download button in JupyterLab. + * + * This module provides client-side HTML generation and download capabilities + * for exporting NetVis graphs as standalone HTML files. + */ + +/** + * Configuration for HTML export. + */ +export interface ExportConfig { + /** HTML document title */ + title: string; + /** CSS width value (e.g., "100%", "800px") */ + width: string; + /** Height in pixels */ + height: number; + /** Graph data to embed */ + graphData: { + nodes: any[]; + links: any[]; + }; +} + +/** + * Default export configuration values. + */ +export const DEFAULT_EXPORT_CONFIG: Partial = { + title: 'Network Visualization', + width: '100%', + height: 600, +}; + +/** + * Generate filename for HTML export. + * Format: netvis_export_YYYY-MM-DD.html + * + * @returns Generated filename with current date + */ +export function generateFilename(): string { + const date = new Date().toISOString().split('T')[0]; + return `netvis_export_${date}.html`; +} + +/** + * Generate standalone HTML document from export configuration. + * + * @param config - Export configuration with title, dimensions, and graph data + * @returns Complete HTML document as string + */ +export function generateStandaloneHtml(config: ExportConfig): string { + const title = config.title || DEFAULT_EXPORT_CONFIG.title!; + const width = config.width || DEFAULT_EXPORT_CONFIG.width!; + const height = config.height || DEFAULT_EXPORT_CONFIG.height!; + const graphData = config.graphData; + + // Serialize graph data as JSON + const jsonData = JSON.stringify(graphData); + + // Generate inline CSS + const css = generateCss(); + + // Generate inline JavaScript (D3.js rendering code) + const js = generateJs(); + + return ` + + + + + ${escapeHtml(title)} + + + +
+
+
+ + + +`; +} + +/** + * Trigger browser download of HTML content. + * + * @param htmlContent - HTML document content as string + * @param filename - Filename for the downloaded file + */ +export function downloadHtml(htmlContent: string, filename: string): void { + const blob = new Blob([htmlContent], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); // Prevent memory leak +} + +/** + * Create a download button element for the visualization. + * + * @param graphData - Graph data to include in downloaded HTML + * @returns Button element configured for download + */ +export function createDownloadButton(graphData: { + nodes: any[]; + links: any[]; +}): HTMLButtonElement { + const button = document.createElement('button'); + button.className = 'netvis-download-btn'; + button.setAttribute('aria-label', 'Download HTML'); + + // SVG download icon (no external dependencies) + button.innerHTML = ` + + + + + + `; + + // Handle click - generate and download HTML + button.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const config: ExportConfig = { + title: 'Network Visualization', + width: '100%', + height: 600, + graphData: graphData, + }; + + const html = generateStandaloneHtml(config); + const filename = generateFilename(); + downloadHtml(html, filename); + }); + + return button; +} + +/** + * Escape HTML special characters to prevent XSS. + * + * @param str - String to escape + * @returns Escaped string safe for HTML insertion + */ +function escapeHtml(str: string): string { + const htmlEscapes: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return str.replace(/[&<>"']/g, (char) => htmlEscapes[char]); +} + +/** + * Generate CSS styles for standalone HTML. + */ +function generateCss(): string { + return ` + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background-color: #f5f5f5; + } + .netvis-container { + width: 100%; + max-width: 100%; + margin: 0 auto; + padding: 20px; + } + #netvis-graph { + background-color: #ffffff; + border: 1px solid #e0e0e0; + border-radius: 4px; + } + .netvis-graph svg { + width: 100%; + height: 100%; + } + .netvis-node circle { + stroke: #fff; + stroke-width: 1.5px; + } + .netvis-link { + stroke: #999; + stroke-opacity: 0.6; + } + .netvis-node-label { + font-size: 12px; + pointer-events: none; + } + `; +} + +/** + * Generate JavaScript code for standalone HTML. + * This is a minimal D3.js-based graph renderer. + */ +function generateJs(): string { + // For the standalone export, we need to include the D3.js bundle + // In the actual implementation, this would be loaded from the build + return ` + // NetVis standalone renderer + var netvis = (function() { + // Minimal graph rendering (placeholder for full D3.js bundle) + function renderGraph(container, data) { + if (!container) return; + + const width = container.clientWidth || 800; + const height = container.clientHeight || 600; + + // Create SVG + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', width); + svg.setAttribute('height', height); + svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height); + container.appendChild(svg); + + // Simple force-directed layout simulation + const nodes = data.nodes || []; + const links = data.links || []; + + // Initialize node positions + nodes.forEach(function(node, i) { + node.x = node.x || width / 2 + (Math.random() - 0.5) * 200; + node.y = node.y || height / 2 + (Math.random() - 0.5) * 200; + }); + + // Draw links + const linkGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + linkGroup.setAttribute('class', 'netvis-links'); + svg.appendChild(linkGroup); + + links.forEach(function(link) { + const source = nodes.find(function(n) { return n.id === link.source || n.id === link.source.id; }); + const target = nodes.find(function(n) { return n.id === link.target || n.id === link.target.id; }); + if (source && target) { + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('class', 'netvis-link'); + line.setAttribute('x1', source.x); + line.setAttribute('y1', source.y); + line.setAttribute('x2', target.x); + line.setAttribute('y2', target.y); + line.setAttribute('stroke', '#999'); + line.setAttribute('stroke-opacity', '0.6'); + linkGroup.appendChild(line); + } + }); + + // Draw nodes + const nodeGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + nodeGroup.setAttribute('class', 'netvis-nodes'); + svg.appendChild(nodeGroup); + + nodes.forEach(function(node) { + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + g.setAttribute('class', 'netvis-node'); + g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')'); + + const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + circle.setAttribute('r', 8); + circle.setAttribute('fill', node.color || '#69b3a2'); + circle.setAttribute('stroke', '#fff'); + circle.setAttribute('stroke-width', '1.5'); + g.appendChild(circle); + + if (node.name || node.label) { + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + text.setAttribute('class', 'netvis-node-label'); + text.setAttribute('dx', '12'); + text.setAttribute('dy', '4'); + text.textContent = node.name || node.label; + g.appendChild(text); + } + + nodeGroup.appendChild(g); + }); + } + + return { + renderGraph: renderGraph + }; + })(); + `; +} diff --git a/src/mimePlugin.ts b/src/mimePlugin.ts index 024a224..f071be7 100644 --- a/src/mimePlugin.ts +++ b/src/mimePlugin.ts @@ -1,6 +1,7 @@ import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; import { Widget } from '@lumino/widgets'; import packageJson from '../package.json'; +import { createDownloadButton } from './htmlExport'; /** * MIME type for NetVis graph data @@ -115,8 +116,28 @@ export class NetVisMimeRenderer // Clear any existing content this.node.textContent = ''; + // Add download button styles if not already present + this._ensureDownloadButtonStyles(); + + // Create container for relative positioning of button + const container = document.createElement('div'); + container.style.position = 'relative'; + container.style.width = '100%'; + container.style.height = '100%'; + this.node.appendChild(container); + + // Create graph container + const graphContainer = document.createElement('div'); + graphContainer.style.width = '100%'; + graphContainer.style.height = '100%'; + container.appendChild(graphContainer); + + // Create and add download button + const downloadButton = createDownloadButton(graphData); + container.appendChild(downloadButton); + // Render the graph (handles empty graphs gracefully) - renderGraph(this.node, graphData); + renderGraph(graphContainer, graphData); } catch (error: any) { console.error('Error rendering NetVis graph:', error); this.node.innerHTML = ` @@ -127,6 +148,50 @@ export class NetVisMimeRenderer `; } } + + /** + * Ensure download button CSS styles are added to the document. + */ + private _ensureDownloadButtonStyles(): void { + const styleId = 'netvis-download-btn-styles'; + if (document.getElementById(styleId)) { + return; // Styles already added + } + + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .netvis-download-btn { + position: absolute; + top: 8px; + right: 8px; + width: 32px; + height: 32px; + border: none; + border-radius: 4px; + background-color: rgba(255, 255, 255, 0.9); + cursor: pointer; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s; + } + .netvis-download-btn:hover { + background-color: #e0e0e0; + } + .netvis-download-btn:focus { + outline: 2px solid #0066cc; + outline-offset: 2px; + } + .netvis-download-btn svg { + width: 20px; + height: 20px; + stroke: #333; + } + `; + document.head.appendChild(style); + } } /** From ae3f5394196eda391c3bfc8f8dec7e1bd7d8e77e Mon Sep 17 00:00:00 2001 From: terapyon Date: Thu, 25 Dec 2025 12:05:38 +0900 Subject: [PATCH 5/9] added docs and fix download function --- .gitignore | 3 + CHANGES.md | 8 ++- docs/source/index.rst | 2 + docs/source/introduction.rst | 46 ++++++++++++++ package.json | 6 +- scripts/generate-bundle-module.js | 51 +++++++++++++++ src/htmlExport.ts | 100 +++--------------------------- 7 files changed, 122 insertions(+), 94 deletions(-) create mode 100644 scripts/generate-bundle-module.js diff --git a/.gitignore b/.gitignore index 200ecfd..f69f721 100644 --- a/.gitignore +++ b/.gitignore @@ -155,6 +155,9 @@ net_vis/nbextension/index.* # Packed lab extensions net_vis/labextension +# Auto-generated bundle content for TypeScript +src/standaloneBundleContent.ts + VSCode *.code-workspace diff --git a/CHANGES.md b/CHANGES.md index 3bb2d38..1012484 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -11,6 +11,12 @@ - Works offline without internet connection or JupyterLab - Preserves all interactive features (zoom, pan, node selection, drag) +- **One-Click Download Button**: Download HTML directly from JupyterLab + - Download button appears in top-right corner of visualization + - Click to instantly save as `netvis_export_YYYY-MM-DD.html` + - Works independently of kernel state (client-side generation) + - No code required for quick exports + - **Export Customization**: - Custom title and description for HTML documents - Configurable container width (CSS values) and height (pixels) @@ -55,7 +61,7 @@ plotter.export_html("graph.html", download=True) - **HTMLExporter**: Template-based HTML generation using string.Template - **Standalone Bundle**: D3.js + rendering code bundled via webpack (~280KB) -- **Test Coverage**: 26 new tests covering all export functionality +- **Test Coverage**: 50 new tests (26 Python + 24 TypeScript) covering all export functionality - **Error Handling**: Proper exception propagation for file system errors ### Compatibility diff --git a/docs/source/index.rst b/docs/source/index.rst index 5ab5f01..cf779f6 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -41,6 +41,8 @@ To get started with net_vis, install with pip:: # Get HTML as string for embedding html = plotter.export_html() +**One-Click Download Button**: When viewing a graph in JupyterLab, click the download button (top-right corner) to instantly save the visualization as an HTML file. + **Note**: NetVis uses a MIME renderer that works automatically in JupyterLab 3.x and 4.x. Manual extension enabling is not required. diff --git a/docs/source/introduction.rst b/docs/source/introduction.rst index 0e82513..4c1f202 100644 --- a/docs/source/introduction.rst +++ b/docs/source/introduction.rst @@ -8,6 +8,8 @@ NetVis is a package for interactive visualization of Python NetworkX graphs with Key Features ------------ +- **Standalone HTML Export (v0.6.0)**: Export visualizations as self-contained HTML files that work offline +- **One-Click Download Button (v0.6.0)**: Download HTML directly from JupyterLab visualization with a single click - **NetworkX Plotter API (v0.5.0)**: Direct visualization of NetworkX graphs without JSON conversion - **Interactive D3.js Visualization**: Force-directed graph layout with interactive node dragging, zooming, and panning - **Multiple Graph Types**: Support for Graph, DiGraph, MultiGraph, and MultiDiGraph @@ -107,6 +109,50 @@ Version 0.5.0 introduces the **NetworkX Plotter API**, a high-level interface fo See the :doc:`examples/index` for complete usage examples. +What's New in 0.6.0 +------------------- + +Version 0.6.0 introduces **Standalone HTML Export**, enabling you to share visualizations without JupyterLab: + +**HTML Export API** + Export visualizations as self-contained HTML files:: + + # Export to file + plotter.export_html("my_graph.html") + + # Export with customization + plotter.export_html( + "report.html", + title="Network Analysis Report", + description="Generated analysis results", + width="800px", + height=700 + ) + + # Get HTML as string for embedding + html = plotter.export_html() + +**One-Click Download Button** + When viewing a graph in JupyterLab, a download button appears in the top-right corner: + + - Click the button to instantly download the visualization as HTML + - Files are automatically named ``netvis_export_YYYY-MM-DD.html`` + - Works even when the kernel is stopped (client-side generation) + - No code required for quick exports + +**Exported HTML Features** + - Works offline (no internet connection required) + - All JavaScript and CSS embedded inline + - Interactive features preserved (zoom, pan, node dragging) + - Opens in any modern browser (Chrome, Firefox, Safari, Edge) + +**Remote Environment Support** + For JupyterHub, Google Colab, or Binder environments:: + + # Trigger browser download to local PC + plotter.export_html("graph.html", download=True) + + Architecture (v0.4.0) --------------------- diff --git a/package.json b/package.json index f78d45e..f8df201 100644 --- a/package.json +++ b/package.json @@ -29,9 +29,9 @@ "url": "https://github.com/cmscom/netvis" }, "scripts": { - "build": "yarn run build:lib && yarn run build:labextension:dev", - "build:prod": "yarn run build:lib && yarn run build:labextension", - "build:standalone": "webpack --config webpack.standalone.js", + "build": "yarn run build:standalone && yarn run build:lib && yarn run build:labextension:dev", + "build:prod": "yarn run build:standalone && yarn run build:lib && yarn run build:labextension", + "build:standalone": "webpack --config webpack.standalone.js && node scripts/generate-bundle-module.js", "build:labextension": "jupyter labextension build .", "build:labextension:dev": "jupyter labextension build --development True .", "build:lib": "tsc", diff --git a/scripts/generate-bundle-module.js b/scripts/generate-bundle-module.js new file mode 100644 index 0000000..4811a01 --- /dev/null +++ b/scripts/generate-bundle-module.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * Generate TypeScript module containing the standalone bundle content. + * + * This script reads the built netvis-standalone.min.js and generates + * a TypeScript file that exports the bundle content as a string constant. + * + * Usage: node scripts/generate-bundle-module.js + * + * Output: src/standaloneBundleContent.ts + */ + +const fs = require('fs'); +const path = require('path'); + +const bundlePath = path.join( + __dirname, + '..', + 'net_vis', + 'resources', + 'netvis-standalone.min.js' +); +const outputPath = path.join(__dirname, '..', 'src', 'standaloneBundleContent.ts'); + +// Read the bundle +if (!fs.existsSync(bundlePath)) { + console.error(`Error: Bundle not found at ${bundlePath}`); + console.error('Run "yarn run build:standalone" first.'); + process.exit(1); +} + +const bundleContent = fs.readFileSync(bundlePath, 'utf8'); + +// Generate TypeScript module +const tsContent = `/** + * Auto-generated file containing the standalone bundle content. + * DO NOT EDIT MANUALLY - regenerate with: node scripts/generate-bundle-module.js + * + * This module exports the minified D3.js + NetVis rendering code + * for embedding in standalone HTML exports. + */ + +// eslint-disable-next-line max-len +export const STANDALONE_BUNDLE: string = ${JSON.stringify(bundleContent)}; +`; + +// Write the TypeScript file +fs.writeFileSync(outputPath, tsContent, 'utf8'); + +console.log(`Generated: ${outputPath}`); +console.log(`Bundle size: ${(bundleContent.length / 1024).toFixed(1)} KB`); diff --git a/src/htmlExport.ts b/src/htmlExport.ts index 9555713..bdc613d 100644 --- a/src/htmlExport.ts +++ b/src/htmlExport.ts @@ -5,6 +5,8 @@ * for exporting NetVis graphs as standalone HTML files. */ +import { STANDALONE_BUNDLE } from './standaloneBundleContent'; + /** * Configuration for HTML export. */ @@ -60,8 +62,8 @@ export function generateStandaloneHtml(config: ExportConfig): string { // Generate inline CSS const css = generateCss(); - // Generate inline JavaScript (D3.js rendering code) - const js = generateJs(); + // Get the pre-built JavaScript bundle (D3.js + rendering code) + const js = getJsBundle(); return ` @@ -215,93 +217,11 @@ function generateCss(): string { } /** - * Generate JavaScript code for standalone HTML. - * This is a minimal D3.js-based graph renderer. + * Get JavaScript bundle for standalone HTML. + * + * Returns the pre-built D3.js + NetVis rendering code bundle. + * This is the same bundle used by Python's HTMLExporter. */ -function generateJs(): string { - // For the standalone export, we need to include the D3.js bundle - // In the actual implementation, this would be loaded from the build - return ` - // NetVis standalone renderer - var netvis = (function() { - // Minimal graph rendering (placeholder for full D3.js bundle) - function renderGraph(container, data) { - if (!container) return; - - const width = container.clientWidth || 800; - const height = container.clientHeight || 600; - - // Create SVG - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('width', width); - svg.setAttribute('height', height); - svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height); - container.appendChild(svg); - - // Simple force-directed layout simulation - const nodes = data.nodes || []; - const links = data.links || []; - - // Initialize node positions - nodes.forEach(function(node, i) { - node.x = node.x || width / 2 + (Math.random() - 0.5) * 200; - node.y = node.y || height / 2 + (Math.random() - 0.5) * 200; - }); - - // Draw links - const linkGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - linkGroup.setAttribute('class', 'netvis-links'); - svg.appendChild(linkGroup); - - links.forEach(function(link) { - const source = nodes.find(function(n) { return n.id === link.source || n.id === link.source.id; }); - const target = nodes.find(function(n) { return n.id === link.target || n.id === link.target.id; }); - if (source && target) { - const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - line.setAttribute('class', 'netvis-link'); - line.setAttribute('x1', source.x); - line.setAttribute('y1', source.y); - line.setAttribute('x2', target.x); - line.setAttribute('y2', target.y); - line.setAttribute('stroke', '#999'); - line.setAttribute('stroke-opacity', '0.6'); - linkGroup.appendChild(line); - } - }); - - // Draw nodes - const nodeGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - nodeGroup.setAttribute('class', 'netvis-nodes'); - svg.appendChild(nodeGroup); - - nodes.forEach(function(node) { - const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - g.setAttribute('class', 'netvis-node'); - g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')'); - - const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('r', 8); - circle.setAttribute('fill', node.color || '#69b3a2'); - circle.setAttribute('stroke', '#fff'); - circle.setAttribute('stroke-width', '1.5'); - g.appendChild(circle); - - if (node.name || node.label) { - const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - text.setAttribute('class', 'netvis-node-label'); - text.setAttribute('dx', '12'); - text.setAttribute('dy', '4'); - text.textContent = node.name || node.label; - g.appendChild(text); - } - - nodeGroup.appendChild(g); - }); - } - - return { - renderGraph: renderGraph - }; - })(); - `; +function getJsBundle(): string { + return STANDALONE_BUNDLE; } From e0d3cac9db737dcdcf689a6fff116832ad2affe1 Mon Sep 17 00:00:00 2001 From: terapyon Date: Thu, 25 Dec 2025 12:20:27 +0900 Subject: [PATCH 6/9] fix js build error --- .eslintignore | 4 +++- package.json | 2 +- scripts/generate-bundle-module.js | 22 +++++++++++++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.eslintignore b/.eslintignore index e8a2221..2830805 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,4 +2,6 @@ node_modules dist coverage **/*.d.ts -tests \ No newline at end of file +tests +# Auto-generated bundle content (see scripts/generate-bundle-module.js) +src/standaloneBundleContent.ts \ No newline at end of file diff --git a/package.json b/package.json index f8df201..110a48e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "scripts": { "build": "yarn run build:standalone && yarn run build:lib && yarn run build:labextension:dev", "build:prod": "yarn run build:standalone && yarn run build:lib && yarn run build:labextension", - "build:standalone": "webpack --config webpack.standalone.js && node scripts/generate-bundle-module.js", + "build:standalone": "node scripts/generate-bundle-module.js --stub && webpack --config webpack.standalone.js && node scripts/generate-bundle-module.js", "build:labextension": "jupyter labextension build .", "build:labextension:dev": "jupyter labextension build --development True .", "build:lib": "tsc", diff --git a/scripts/generate-bundle-module.js b/scripts/generate-bundle-module.js index 4811a01..7ac13ee 100644 --- a/scripts/generate-bundle-module.js +++ b/scripts/generate-bundle-module.js @@ -5,7 +5,9 @@ * This script reads the built netvis-standalone.min.js and generates * a TypeScript file that exports the bundle content as a string constant. * - * Usage: node scripts/generate-bundle-module.js + * Usage: + * node scripts/generate-bundle-module.js # Generate from built bundle + * node scripts/generate-bundle-module.js --stub # Create placeholder stub * * Output: src/standaloneBundleContent.ts */ @@ -22,6 +24,24 @@ const bundlePath = path.join( ); const outputPath = path.join(__dirname, '..', 'src', 'standaloneBundleContent.ts'); +// Check if we should create a stub (placeholder) +const isStub = process.argv.includes('--stub'); + +if (isStub) { + // Create a placeholder that allows TypeScript to compile + const stubContent = `/** + * Auto-generated stub file - will be replaced with actual bundle content. + * DO NOT EDIT MANUALLY - regenerate with: node scripts/generate-bundle-module.js + */ + +// Placeholder - will be replaced after webpack build +export const STANDALONE_BUNDLE: string = ''; +`; + fs.writeFileSync(outputPath, stubContent, 'utf8'); + console.log(`Created stub: ${outputPath}`); + process.exit(0); +} + // Read the bundle if (!fs.existsSync(bundlePath)) { console.error(`Error: Bundle not found at ${bundlePath}`); From c870bc20e94be4699b1207daf96fbf17cc230487 Mon Sep 17 00:00:00 2001 From: terapyon Date: Thu, 25 Dec 2025 16:00:22 +0900 Subject: [PATCH 7/9] bugfix for download html --- src/htmlExport.ts | 60 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/src/htmlExport.ts b/src/htmlExport.ts index bdc613d..c2cf144 100644 --- a/src/htmlExport.ts +++ b/src/htmlExport.ts @@ -24,6 +24,62 @@ export interface ExportConfig { }; } +/** + * Normalize graph data for standalone HTML export. + * + * After D3.js simulation runs, link.source and link.target become + * object references instead of IDs. This function converts them back + * to IDs so the standalone HTML can create its own simulation. + * + * @param graphData - Graph data potentially containing object references + * @returns Normalized graph data with IDs for source/target + */ +function normalizeGraphData(graphData: { + nodes: any[]; + links: any[]; +}): { nodes: any[]; links: any[] } { + // Normalize links: convert source/target objects back to IDs + const normalizedLinks = graphData.links.map((link) => { + const source = + typeof link.source === 'object' && link.source !== null + ? link.source.id + : link.source; + const target = + typeof link.target === 'object' && link.target !== null + ? link.target.id + : link.target; + + // Keep other link properties (weight, etc.) but exclude D3 simulation props + const { index: _index, ...rest } = link; + + return { + ...rest, + source, + target, + }; + }); + + // Normalize nodes: remove D3 simulation properties + const normalizedNodes = graphData.nodes.map((node) => { + const { + x: _x, + y: _y, + vx: _vx, + vy: _vy, + fx: _fx, + fy: _fy, + index: _index, + ...rest + } = node; + return rest; + }); + + return { + nodes: normalizedNodes, + links: normalizedLinks, + }; +} + /** * Default export configuration values. */ @@ -54,7 +110,9 @@ export function generateStandaloneHtml(config: ExportConfig): string { const title = config.title || DEFAULT_EXPORT_CONFIG.title!; const width = config.width || DEFAULT_EXPORT_CONFIG.width!; const height = config.height || DEFAULT_EXPORT_CONFIG.height!; - const graphData = config.graphData; + + // Normalize graph data to ensure source/target are IDs, not object references + const graphData = normalizeGraphData(config.graphData); // Serialize graph data as JSON const jsonData = JSON.stringify(graphData); From 16e8db98a95f97698bde6ed312d0ddb9388d4d20 Mon Sep 17 00:00:00 2001 From: terapyon Date: Thu, 25 Dec 2025 16:12:09 +0900 Subject: [PATCH 8/9] modify GH Actions --- .github/workflows/build.yml | 1 + .github/workflows/release.yml | 9 +-------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b4ff695..1f92d94 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,6 +36,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -U codecov + yarn --version yarn install - name: Install package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9411255..ef249e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,20 +26,13 @@ jobs: with: node-version: '18' - - name: Setup Yarn - run: | - corepack enable - corepack prepare yarn@4.6.0 --activate - echo 'nodeLinker: node-modules' > .yarnrc.yml - - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install build python -m pip install -U codecov yarn --version - yarn config set nodeLinker node-modules - YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install + yarn install - name: Install package run: | From 76423f78e3127e5d3ea114ed80693b4a2b8d7459 Mon Sep 17 00:00:00 2001 From: terapyon Date: Thu, 25 Dec 2025 16:21:03 +0900 Subject: [PATCH 9/9] fix lint --- .eslintrc.js | 2 +- src/htmlExport.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 9fb27ea..27b4f36 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -12,7 +12,7 @@ module.exports = { }, plugins: ['@typescript-eslint'], rules: { - '@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }], + '@typescript-eslint/no-unused-vars': ['warn', { args: 'none', varsIgnorePattern: '^_' }], '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-namespace': 'off', '@typescript-eslint/no-use-before-define': 'off', diff --git a/src/htmlExport.ts b/src/htmlExport.ts index c2cf144..789b0e1 100644 --- a/src/htmlExport.ts +++ b/src/htmlExport.ts @@ -34,10 +34,10 @@ export interface ExportConfig { * @param graphData - Graph data potentially containing object references * @returns Normalized graph data with IDs for source/target */ -function normalizeGraphData(graphData: { +function normalizeGraphData(graphData: { nodes: any[]; links: any[] }): { nodes: any[]; links: any[]; -}): { nodes: any[]; links: any[] } { +} { // Normalize links: convert source/target objects back to IDs const normalizedLinks = graphData.links.map((link) => { const source =