Преглед изворни кода

feat: 添加人设图谱可视化Vue项目及游走配置功能

- 新增 Vue3 + Vite + TailwindCSS + DaisyUI 可视化项目
- 人设树展示:层级布局、节点高亮、滚动定位
- 相关图展示:力导向布局、游走配置面板
- 游走配置:步数、边类型、分数阈值,支持整体/分步设置
- 新增 build_persona_graph.py 和 build_post_graph.py 数据处理脚本

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
yangxiaohui пре 3 дана
родитељ
комит
1c7d73fb4b

+ 5 - 0
.gitignore

@@ -3,3 +3,8 @@ data
 __pycache__
 cache
 .DS_Store
+
+# Node.js
+node_modules/
+dist/
+*.timestamp-*.mjs

+ 1108 - 0
script/data_processing/build_persona_graph.py

@@ -0,0 +1,1108 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+构建人设图谱
+
+================================================================================
+输入文件:
+================================================================================
+1. pattern聚合结果.json     - 分类节点、标签节点、属于/包含边
+2. dimension_associations_analysis.json    - 分类共现边(跨点)
+3. intra_dimension_associations_analysis.json - 分类共现边(点内)
+4. 历史帖子解构目录/*.json  - 标签共现边
+
+================================================================================
+输出文件: 人设图谱.json
+================================================================================
+{
+    "meta": {                    # 元信息
+        "description": "...",
+        "account": "账号名",
+        "createdAt": "时间戳",
+        "stats": { ... }         # 统计信息
+    },
+    "nodes": {                   # 节点字典 (nodeId -> nodeData)
+        "{domain}:{dimension}:{type}:{name}": {
+            "name": "显示名称",
+            "type": "人设|灵感点|目的点|关键点|分类|标签",
+            "domain": "人设",
+            "dimension": "人设|灵感点|目的点|关键点",
+            "detail": { ... }
+        }
+    },
+    "edges": {                   # 边字典 (edgeId -> edgeData)
+        "{source}|{type}|{target}": {
+            "source": "源节点ID",
+            "target": "目标节点ID",
+            "type": "属于|包含|标签共现|分类共现|分类共现_点内",
+            "score": 0.5,
+            "detail": { ... }
+        }
+    },
+    "index": {                   # 游走索引
+        "outEdges": { nodeId: { edgeType: [{ target, score }] } },
+        "inEdges": { nodeId: { edgeType: [{ source, score }] } }
+    },
+    "tree": { ... }              # 嵌套树结构(从根节点沿"包含"边构建)
+}
+
+================================================================================
+核心逻辑:
+================================================================================
+1. 提取节点
+   - 从 pattern 提取分类节点(按维度分组的层级分类)
+   - 从 pattern 提取标签节点(具体特征标签)
+   - 添加根节点(人设)和维度节点(灵感点/目的点/关键点)
+
+2. 提取边
+   - 属于/包含边:根据节点的 parentPath 构建层级关系
+   - 分类共现边(跨点):从关联分析结果提取
+   - 分类共现边(点内):从点内关联分析提取
+   - 标签共现边:遍历历史帖子,统计标签同现
+
+3. 构建索引
+   - outEdges: 从该节点出发能到达的节点
+   - inEdges: 能到达该节点的源节点
+
+4. 构建树
+   - 从根节点开始,沿"包含"边递归构建嵌套树结构
+
+================================================================================
+节点ID格式: {domain}:{dimension}:{type}:{name}
+================================================================================
+- 根节点:   人设:人设:人设:人设
+- 维度节点: 人设:灵感点:灵感点:灵感点
+- 分类节点: 人设:灵感点:分类:视觉呈现
+- 标签节点: 人设:灵感点:标签:手绘风格
+
+================================================================================
+边类型:
+================================================================================
+- 属于:         子节点 -> 父节点(层级关系)
+- 包含:         父节点 -> 子节点(层级关系)
+- 标签共现:     标签 <-> 标签(同一帖子出现)
+- 分类共现:     分类 <-> 分类(跨维度共现)
+- 分类共现_点内: 分类 <-> 分类(点内组合共现)
+
+================================================================================
+图游走函数:
+================================================================================
+1. walk_graph(index, start_node, edge_types, direction, min_score)
+   - 从起始节点出发,按边类型序列游走N步
+   - 示例: walk_graph(index, "人设:灵感点:标签:手绘风格", ["属于", "分类共现"])
+   - 返回: 到达的节点ID集合
+
+2. get_neighbors(index, node_id, edge_type, direction, min_score)
+   - 获取节点的邻居
+   - 示例: get_neighbors(index, "人设:灵感点:分类:视觉呈现", "包含")
+   - 返回: 邻居列表 [{"target": "...", "score": 0.5}, ...]
+
+================================================================================
+"""
+
+import json
+from pathlib import Path
+from typing import Dict, List, Set, Any
+from datetime import datetime
+import sys
+
+# 添加项目根目录到路径
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+from script.data_processing.path_config import PathConfig
+
+
+# ==================== 节点和边构建工具 ====================
+
+def build_node_id(domain: str, dimension: str, node_type: str, name: str) -> str:
+    """构建节点ID"""
+    return f"{domain}:{dimension}:{node_type}:{name}"
+
+
+def build_edge_id(source: str, edge_type: str, target: str) -> str:
+    """构建边ID"""
+    return f"{source}|{edge_type}|{target}"
+
+
+def create_node(
+    domain: str,
+    dimension: str,
+    node_type: str,
+    name: str,
+    detail: Dict = None
+) -> Dict:
+    """创建节点"""
+    return {
+        "name": name,
+        "type": node_type,
+        "dimension": dimension,
+        "domain": domain,
+        "detail": detail or {}
+    }
+
+
+def create_edge(
+    source: str,
+    target: str,
+    edge_type: str,
+    score: float = None,
+    detail: Dict = None
+) -> Dict:
+    """创建边"""
+    return {
+        "source": source,
+        "target": target,
+        "type": edge_type,
+        "score": score,
+        "detail": detail or {}
+    }
+
+
+# ==================== 从 pattern 提取分类节点 ====================
+
+def extract_category_nodes_from_pattern(
+    pattern_data: Dict,
+    dimension_key: str,
+    dimension_name: str
+) -> Dict[str, Dict]:
+    """
+    从 pattern 聚合结果中提取分类节点
+
+    Returns:
+        { nodeId: nodeData }
+    """
+    nodes = {}
+
+    if dimension_key not in pattern_data:
+        return nodes
+
+    def collect_sources_recursively(node: Dict) -> List[Dict]:
+        """递归收集节点及其所有子节点的特征来源"""
+        sources = []
+
+        if "特征列表" in node:
+            for feature in node["特征列表"]:
+                source = {
+                    "pointName": feature.get("所属点", ""),
+                    "pointDesc": feature.get("点描述", ""),
+                    "postId": feature.get("帖子id", "")
+                }
+                sources.append(source)
+
+        for key, value in node.items():
+            if key in ["特征列表", "_meta", "帖子数", "特征数", "帖子列表"]:
+                continue
+            if isinstance(value, dict):
+                sources.extend(collect_sources_recursively(value))
+
+        return sources
+
+    def traverse_node(node: Dict, parent_path: List[str]):
+        """递归遍历节点"""
+        for key, value in node.items():
+            if key in ["特征列表", "_meta", "帖子数", "特征数", "帖子列表"]:
+                continue
+
+            if isinstance(value, dict):
+                current_path = parent_path + [key]
+
+                # 获取帖子列表
+                post_ids = value.get("帖子列表", [])
+
+                # 构建节点来源
+                node_sources = []
+                if "特征列表" in value:
+                    for feature in value["特征列表"]:
+                        source = {
+                            "pointName": feature.get("所属点", ""),
+                            "pointDesc": feature.get("点描述", ""),
+                            "postId": feature.get("帖子id", "")
+                        }
+                        node_sources.append(source)
+                else:
+                    node_sources = collect_sources_recursively(value)
+
+                # 计算帖子数
+                if post_ids:
+                    post_count = len(post_ids)
+                else:
+                    post_count = len(set(s.get("postId", "") for s in node_sources if s.get("postId")))
+
+                # 构建节点
+                node_id = build_node_id("人设", dimension_name, "分类", key)
+                nodes[node_id] = create_node(
+                    domain="人设",
+                    dimension=dimension_name,
+                    node_type="分类",
+                    name=key,
+                    detail={
+                        "parentPath": parent_path.copy(),
+                        "postCount": post_count,
+                        "sources": node_sources
+                    }
+                )
+
+                # 递归处理子节点
+                traverse_node(value, current_path)
+
+    traverse_node(pattern_data[dimension_key], [])
+    return nodes
+
+
+# ==================== 从 pattern 提取标签节点 ====================
+
+def extract_tag_nodes_from_pattern(
+    pattern_data: Dict,
+    dimension_key: str,
+    dimension_name: str
+) -> Dict[str, Dict]:
+    """
+    从 pattern 聚合结果中提取标签节点
+
+    Returns:
+        { nodeId: nodeData }
+    """
+    nodes = {}
+    tag_map = {}  # 用于合并同名标签: tagId -> { sources, postIds, parentPath }
+
+    if dimension_key not in pattern_data:
+        return nodes
+
+    def traverse_node(node: Dict, parent_path: List[str]):
+        """递归遍历节点"""
+        # 处理特征列表(标签)
+        if "特征列表" in node:
+            for feature in node["特征列表"]:
+                tag_name = feature.get("特征名称", "")
+                if not tag_name:
+                    continue
+
+                source = {
+                    "pointName": feature.get("所属点", ""),
+                    "pointDesc": feature.get("点描述", ""),
+                    "postId": feature.get("帖子id", "")
+                }
+
+                tag_id = build_node_id("人设", dimension_name, "标签", tag_name)
+
+                if tag_id not in tag_map:
+                    tag_map[tag_id] = {
+                        "name": tag_name,
+                        "sources": [],
+                        "postIds": set(),
+                        "parentPath": parent_path.copy()
+                    }
+
+                tag_map[tag_id]["sources"].append(source)
+                if source["postId"]:
+                    tag_map[tag_id]["postIds"].add(source["postId"])
+
+        # 递归处理子节点
+        for key, value in node.items():
+            if key in ["特征列表", "_meta", "帖子数", "特征数", "帖子列表"]:
+                continue
+
+            if isinstance(value, dict):
+                current_path = parent_path + [key]
+                traverse_node(value, current_path)
+
+    traverse_node(pattern_data[dimension_key], [])
+
+    # 转换为节点
+    for tag_id, tag_info in tag_map.items():
+        nodes[tag_id] = create_node(
+            domain="人设",
+            dimension=dimension_name,
+            node_type="标签",
+            name=tag_info["name"],
+            detail={
+                "parentPath": tag_info["parentPath"],
+                "postCount": len(tag_info["postIds"]),
+                "sources": tag_info["sources"]
+            }
+        )
+
+    return nodes
+
+
+# ==================== 从 pattern 提取属于/包含边 ====================
+
+def extract_belong_contain_edges(
+    pattern_data: Dict,
+    dimension_key: str,
+    dimension_name: str,
+    nodes: Dict[str, Dict]
+) -> Dict[str, Dict]:
+    """
+    从 pattern 聚合结果中提取属于/包含边
+
+    Returns:
+        { edgeId: edgeData }
+    """
+    edges = {}
+
+    if dimension_key not in pattern_data:
+        return edges
+
+    # 构建分类名称到ID的映射
+    category_name_to_id = {}
+    for node_id, node_data in nodes.items():
+        if node_data["type"] == "分类" and node_data["dimension"] == dimension_name:
+            category_name_to_id[node_data["name"]] = node_id
+
+    # 为每个节点创建属于边(子→父)
+    for node_id, node_data in nodes.items():
+        if node_data["dimension"] != dimension_name:
+            continue
+
+        parent_path = node_data["detail"].get("parentPath", [])
+        if not parent_path:
+            continue
+
+        # 取最后一个作为直接父分类
+        parent_name = parent_path[-1]
+        parent_id = category_name_to_id.get(parent_name)
+
+        if parent_id:
+            # 属于边:子 → 父
+            edge_id = build_edge_id(node_id, "属于", parent_id)
+            edges[edge_id] = create_edge(
+                source=node_id,
+                target=parent_id,
+                edge_type="属于",
+                score=1.0,
+                detail={}
+            )
+
+            # 包含边:父 → 子
+            edge_id_contain = build_edge_id(parent_id, "包含", node_id)
+            edges[edge_id_contain] = create_edge(
+                source=parent_id,
+                target=node_id,
+                edge_type="包含",
+                score=1.0,
+                detail={}
+            )
+
+    return edges
+
+
+# ==================== 从关联分析提取分类共现边(跨点)====================
+
+def extract_category_cooccur_edges(associations_data: Dict) -> Dict[str, Dict]:
+    """
+    从 dimension_associations_analysis.json 中提取分类共现边(跨点)
+
+    Returns:
+        { edgeId: edgeData }
+    """
+    edges = {}
+
+    if "单维度关联分析" not in associations_data:
+        return edges
+
+    single_dim = associations_data["单维度关联分析"]
+
+    # 维度映射
+    dimension_map = {
+        "灵感点维度": "灵感点",
+        "目的点维度": "目的点",
+        "关键点维度": "关键点"
+    }
+
+    def get_last_segment(path: str) -> str:
+        """获取路径的最后一段"""
+        return path.split("/")[-1]
+
+    for dim_key, dim_data in single_dim.items():
+        if dim_key not in dimension_map:
+            continue
+
+        source_dimension = dimension_map[dim_key]
+
+        for direction_key, direction_data in dim_data.items():
+            if direction_key == "说明" or "→" not in direction_key:
+                continue
+
+            for source_path, source_info in direction_data.items():
+                source_name = get_last_segment(source_path)
+                source_node_id = build_node_id("人设", source_dimension, "分类", source_name)
+
+                for field_name, associations in source_info.items():
+                    if not field_name.startswith("与") or not field_name.endswith("的关联"):
+                        continue
+
+                    target_dimension = field_name[1:-3]
+
+                    if not isinstance(associations, list):
+                        continue
+
+                    for assoc in associations:
+                        target_path = assoc.get("目标分类", "")
+                        if not target_path:
+                            continue
+
+                        target_name = get_last_segment(target_path)
+                        target_node_id = build_node_id("人设", target_dimension, "分类", target_name)
+
+                        # 使用 Jaccard 作为 score
+                        jaccard = assoc.get("Jaccard相似度", 0)
+
+                        edge_id = build_edge_id(source_node_id, "分类共现", target_node_id)
+                        edges[edge_id] = create_edge(
+                            source=source_node_id,
+                            target=target_node_id,
+                            edge_type="分类共现",
+                            score=jaccard,
+                            detail={
+                                "jaccard": jaccard,
+                                "overlapCoef": assoc.get("重叠系数", 0),
+                                "cooccurCount": assoc.get("共同帖子数", 0),
+                                "cooccurPosts": assoc.get("共同帖子ID", [])
+                            }
+                        )
+
+    return edges
+
+
+# ==================== 从关联分析提取分类共现边(点内)====================
+
+def extract_intra_category_cooccur_edges(intra_data: Dict) -> Dict[str, Dict]:
+    """
+    从 intra_dimension_associations_analysis.json 中提取点内分类共现边
+
+    Returns:
+        { edgeId: edgeData }
+    """
+    edges = {}
+
+    if "叶子分类组合聚类" not in intra_data:
+        return edges
+
+    clusters_by_dim = intra_data["叶子分类组合聚类"]
+
+    for dimension, clusters in clusters_by_dim.items():
+        if dimension not in ("灵感点", "目的点", "关键点"):
+            continue
+
+        for cluster_key, cluster_data in clusters.items():
+            leaf_categories = cluster_data.get("叶子分类组合", [])
+            point_count = cluster_data.get("点数", 0)
+            point_details = cluster_data.get("点详情列表", [])
+
+            # 提取点名称列表
+            point_names = [p.get("点名称", "") for p in point_details if p.get("点名称")]
+
+            # 两两组合生成共现边
+            for i in range(len(leaf_categories)):
+                for j in range(i + 1, len(leaf_categories)):
+                    cat1 = leaf_categories[i]
+                    cat2 = leaf_categories[j]
+
+                    cat1_id = build_node_id("人设", dimension, "分类", cat1)
+                    cat2_id = build_node_id("人设", dimension, "分类", cat2)
+
+                    # 确保顺序一致(按字典序)
+                    if cat1_id > cat2_id:
+                        cat1_id, cat2_id = cat2_id, cat1_id
+
+                    edge_id = build_edge_id(cat1_id, "分类共现_点内", cat2_id)
+
+                    if edge_id in edges:
+                        # 累加
+                        edges[edge_id]["detail"]["pointCount"] += point_count
+                        edges[edge_id]["detail"]["pointNames"].extend(point_names)
+                    else:
+                        edges[edge_id] = create_edge(
+                            source=cat1_id,
+                            target=cat2_id,
+                            edge_type="分类共现_点内",
+                            score=point_count,  # 先用点数作为 score,后续可归一化
+                            detail={
+                                "pointCount": point_count,
+                                "pointNames": point_names.copy()
+                            }
+                        )
+
+    return edges
+
+
+# ==================== 从历史帖子提取标签共现边 ====================
+
+def extract_tag_cooccur_edges(historical_posts_dir: Path) -> Dict[str, Dict]:
+    """
+    从历史帖子解构结果中提取标签共现边
+
+    Returns:
+        { edgeId: edgeData }
+    """
+    edges = {}
+    cooccur_map = {}  # (tag1_id, tag2_id, dimension) -> { cooccurPosts: set() }
+
+    if not historical_posts_dir.exists():
+        print(f"  警告: 历史帖子目录不存在: {historical_posts_dir}")
+        return edges
+
+    json_files = list(historical_posts_dir.glob("*.json"))
+    print(f"  找到 {len(json_files)} 个历史帖子文件")
+
+    def extract_post_id_from_filename(filename: str) -> str:
+        """从文件名中提取帖子ID"""
+        import re
+        match = re.match(r'^([^_]+)_', filename)
+        return match.group(1) if match else ""
+
+    def extract_tags_from_post(post_data: Dict) -> Dict[str, List[str]]:
+        """从帖子解构结果中提取所有标签"""
+        tags_by_dimension = {
+            "灵感点": [],
+            "目的点": [],
+            "关键点": []
+        }
+
+        if "三点解构" not in post_data:
+            return tags_by_dimension
+
+        three_points = post_data["三点解构"]
+
+        # 灵感点
+        if "灵感点" in three_points:
+            inspiration = three_points["灵感点"]
+            for section in ["全新内容", "共性差异", "共性内容"]:
+                if section in inspiration and isinstance(inspiration[section], list):
+                    for item in inspiration[section]:
+                        if "提取的特征" in item and isinstance(item["提取的特征"], list):
+                            for feature in item["提取的特征"]:
+                                tag_name = feature.get("特征名称", "")
+                                if tag_name:
+                                    tags_by_dimension["灵感点"].append(tag_name)
+
+        # 目的点
+        if "目的点" in three_points:
+            purpose = three_points["目的点"]
+            if "purposes" in purpose and isinstance(purpose["purposes"], list):
+                for item in purpose["purposes"]:
+                    if "提取的特征" in item and isinstance(item["提取的特征"], list):
+                        for feature in item["提取的特征"]:
+                            tag_name = feature.get("特征名称", "")
+                            if tag_name:
+                                tags_by_dimension["目的点"].append(tag_name)
+
+        # 关键点
+        if "关键点" in three_points:
+            key_points = three_points["关键点"]
+            if "key_points" in key_points and isinstance(key_points["key_points"], list):
+                for item in key_points["key_points"]:
+                    if "提取的特征" in item and isinstance(item["提取的特征"], list):
+                        for feature in item["提取的特征"]:
+                            tag_name = feature.get("特征名称", "")
+                            if tag_name:
+                                tags_by_dimension["关键点"].append(tag_name)
+
+        return tags_by_dimension
+
+    # 遍历所有帖子文件
+    for file_path in json_files:
+        post_id = extract_post_id_from_filename(file_path.name)
+        if not post_id:
+            continue
+
+        try:
+            with open(file_path, "r", encoding="utf-8") as f:
+                post_data = json.load(f)
+
+            tags_by_dimension = extract_tags_from_post(post_data)
+
+            # 对每个维度内的标签两两组合
+            for dimension, tags in tags_by_dimension.items():
+                unique_tags = list(set(tags))
+                for i in range(len(unique_tags)):
+                    for j in range(i + 1, len(unique_tags)):
+                        tag1 = unique_tags[i]
+                        tag2 = unique_tags[j]
+
+                        tag1_id = build_node_id("人设", dimension, "标签", tag1)
+                        tag2_id = build_node_id("人设", dimension, "标签", tag2)
+
+                        # 确保顺序一致
+                        if tag1_id > tag2_id:
+                            tag1_id, tag2_id = tag2_id, tag1_id
+
+                        key = (tag1_id, tag2_id)
+
+                        if key not in cooccur_map:
+                            cooccur_map[key] = {"cooccurPosts": set()}
+
+                        cooccur_map[key]["cooccurPosts"].add(post_id)
+
+        except Exception as e:
+            print(f"  警告: 处理文件 {file_path.name} 时出错: {e}")
+
+    # 转换为边
+    for (tag1_id, tag2_id), info in cooccur_map.items():
+        cooccur_posts = list(info["cooccurPosts"])
+        cooccur_count = len(cooccur_posts)
+
+        edge_id = build_edge_id(tag1_id, "标签共现", tag2_id)
+        edges[edge_id] = create_edge(
+            source=tag1_id,
+            target=tag2_id,
+            edge_type="标签共现",
+            score=cooccur_count,  # 先用共现次数,后续可归一化
+            detail={
+                "cooccurCount": cooccur_count,
+                "cooccurPosts": cooccur_posts
+            }
+        )
+
+    return edges
+
+
+# ==================== 构建嵌套树结构 ====================
+
+def build_nested_tree(nodes: Dict[str, Dict], edges: Dict[str, Dict]) -> Dict:
+    """
+    从根节点开始,沿"包含"边递归构建嵌套树结构
+
+    包含边:父节点 -> 子节点
+    从根节点开始,递归找所有包含的子节点
+
+    Returns:
+        嵌套的树结构
+    """
+    # 从"包含"边构建 父节点 -> [子节点] 的映射
+    parent_to_children = {}  # parent_id -> [child_id, ...]
+
+    for edge_id, edge_data in edges.items():
+        if edge_data["type"] == "包含":
+            parent_id = edge_data["source"]
+            child_id = edge_data["target"]
+
+            if parent_id not in parent_to_children:
+                parent_to_children[parent_id] = []
+            parent_to_children[parent_id].append(child_id)
+
+    # 递归构建子树
+    def build_subtree(node_id: str) -> Dict:
+        node_data = nodes[node_id]
+
+        subtree = {
+            "id": node_id,
+            "name": node_data["name"],
+            "type": node_data["type"],
+            "domain": node_data["domain"],
+            "dimension": node_data["dimension"],
+            "detail": node_data.get("detail", {}),
+            "children": []
+        }
+
+        # 获取子节点
+        child_ids = parent_to_children.get(node_id, [])
+
+        for child_id in child_ids:
+            if child_id in nodes:
+                subtree["children"].append(build_subtree(child_id))
+
+        return subtree
+
+    # 从根节点开始构建
+    root_id = "人设:人设:人设:人设"
+    return build_subtree(root_id)
+
+
+# ==================== 图游走工具 ====================
+
+def walk_graph(
+    index: Dict,
+    start_node: str,
+    edge_types: List[str],
+    direction: str = "out",
+    min_score: float = None
+) -> Set[str]:
+    """
+    从起始节点出发,按指定边类型序列游走N步
+
+    Args:
+        index: 游走索引 {"outEdges": {...}, "inEdges": {...}}
+        start_node: 起始节点ID
+        edge_types: 边类型序列,如 ["属于", "分类共现"]
+        direction: 游走方向 "out"(沿出边) / "in"(沿入边)
+        min_score: 最小分数过滤
+
+    Returns:
+        到达的节点ID集合
+
+    Example:
+        # 从标签出发,沿"属于"边走1步,再沿"分类共现"边走1步
+        result = walk_graph(
+            index,
+            "人设:灵感点:标签:手绘风格",
+            ["属于", "分类共现"]
+        )
+    """
+    edge_index = index["outEdges"] if direction == "out" else index["inEdges"]
+    target_key = "target" if direction == "out" else "source"
+
+    current_nodes = {start_node}
+
+    for edge_type in edge_types:
+        next_nodes = set()
+        for node in current_nodes:
+            neighbors = edge_index.get(node, {}).get(edge_type, [])
+            for neighbor in neighbors:
+                # 分数过滤
+                if min_score is not None and neighbor.get("score", 0) < min_score:
+                    continue
+                next_nodes.add(neighbor[target_key])
+        current_nodes = next_nodes
+
+        if not current_nodes:
+            break
+
+    return current_nodes
+
+
+def get_neighbors(
+    index: Dict,
+    node_id: str,
+    edge_type: str = None,
+    direction: str = "out",
+    min_score: float = None
+) -> List[Dict]:
+    """
+    获取节点的邻居
+
+    Args:
+        index: 游走索引
+        node_id: 节点ID
+        edge_type: 边类型(可选,不指定则返回所有类型)
+        direction: 方向 "out" / "in"
+        min_score: 最小分数过滤
+
+    Returns:
+        邻居列表 [{"target": "...", "score": 0.5}, ...]
+    """
+    edge_index = index["outEdges"] if direction == "out" else index["inEdges"]
+    node_edges = edge_index.get(node_id, {})
+
+    if edge_type:
+        neighbors = node_edges.get(edge_type, [])
+    else:
+        neighbors = []
+        for edges in node_edges.values():
+            neighbors.extend(edges)
+
+    if min_score is not None:
+        neighbors = [n for n in neighbors if n.get("score", 0) >= min_score]
+
+    return neighbors
+
+
+# ==================== 构建索引 ====================
+
+def build_index(edges: Dict[str, Dict]) -> Dict:
+    """
+    构建游走索引
+
+    Returns:
+        {
+            "outEdges": { nodeId: { edgeType: [{ target, score }] } },
+            "inEdges": { nodeId: { edgeType: [{ source, score }] } }
+        }
+    """
+    out_edges = {}
+    in_edges = {}
+
+    for edge_id, edge_data in edges.items():
+        source = edge_data["source"]
+        target = edge_data["target"]
+        edge_type = edge_data["type"]
+        score = edge_data["score"]
+
+        # outEdges
+        if source not in out_edges:
+            out_edges[source] = {}
+        if edge_type not in out_edges[source]:
+            out_edges[source][edge_type] = []
+        out_edges[source][edge_type].append({
+            "target": target,
+            "score": score
+        })
+
+        # inEdges
+        if target not in in_edges:
+            in_edges[target] = {}
+        if edge_type not in in_edges[target]:
+            in_edges[target][edge_type] = []
+        in_edges[target][edge_type].append({
+            "source": source,
+            "score": score
+        })
+
+    return {
+        "outEdges": out_edges,
+        "inEdges": in_edges
+    }
+
+
+# ==================== 主函数 ====================
+
+def main():
+    config = PathConfig()
+    config.ensure_dirs()
+
+    print(f"账号: {config.account_name}")
+    print(f"输出版本: {config.output_version}")
+    print()
+
+    # 输入文件路径
+    pattern_file = config.pattern_cluster_file
+    associations_file = config.account_dir / "pattern相关文件/optimization/dimension_associations_analysis.json"
+    intra_associations_file = config.account_dir / "pattern相关文件/optimization/intra_dimension_associations_analysis.json"
+    historical_posts_dir = config.historical_posts_dir
+
+    # 输出文件路径
+    output_file = config.intermediate_dir / "人设图谱.json"
+
+    print("输入文件:")
+    print(f"  pattern聚合文件: {pattern_file}")
+    print(f"  跨点关联分析文件: {associations_file}")
+    print(f"  点内关联分析文件: {intra_associations_file}")
+    print(f"  历史帖子目录: {historical_posts_dir}")
+    print(f"\n输出文件: {output_file}")
+    print()
+
+    # ===== 读取数据 =====
+    print("=" * 60)
+    print("读取数据...")
+
+    print("  读取 pattern 聚合结果...")
+    with open(pattern_file, "r", encoding="utf-8") as f:
+        pattern_data = json.load(f)
+
+    print("  读取跨点关联分析结果...")
+    with open(associations_file, "r", encoding="utf-8") as f:
+        associations_data = json.load(f)
+
+    print("  读取点内关联分析结果...")
+    with open(intra_associations_file, "r", encoding="utf-8") as f:
+        intra_associations_data = json.load(f)
+
+    # ===== 提取节点 =====
+    print("\n" + "=" * 60)
+    print("提取节点...")
+
+    all_nodes = {}
+
+    dimension_mapping = {
+        "灵感点列表": "灵感点",
+        "目的点": "目的点",
+        "关键点列表": "关键点"
+    }
+
+    # 分类节点
+    print("\n提取分类节点:")
+    for dim_key, dim_name in dimension_mapping.items():
+        category_nodes = extract_category_nodes_from_pattern(pattern_data, dim_key, dim_name)
+        all_nodes.update(category_nodes)
+        print(f"  {dim_name}: {len(category_nodes)} 个")
+
+    # 标签节点
+    print("\n提取标签节点:")
+    for dim_key, dim_name in dimension_mapping.items():
+        tag_nodes = extract_tag_nodes_from_pattern(pattern_data, dim_key, dim_name)
+        all_nodes.update(tag_nodes)
+        print(f"  {dim_name}: {len(tag_nodes)} 个")
+
+    # 统计
+    category_count = sum(1 for n in all_nodes.values() if n["type"] == "分类")
+    tag_count = sum(1 for n in all_nodes.values() if n["type"] == "标签")
+    print(f"\n节点总计: {len(all_nodes)} (分类: {category_count}, 标签: {tag_count})")
+
+    # ===== 提取边 =====
+    print("\n" + "=" * 60)
+    print("提取边...")
+
+    all_edges = {}
+
+    # 属于/包含边
+    print("\n提取属于/包含边:")
+    for dim_key, dim_name in dimension_mapping.items():
+        belong_contain_edges = extract_belong_contain_edges(pattern_data, dim_key, dim_name, all_nodes)
+        all_edges.update(belong_contain_edges)
+    belong_count = sum(1 for e in all_edges.values() if e["type"] == "属于")
+    contain_count = sum(1 for e in all_edges.values() if e["type"] == "包含")
+    print(f"  属于边: {belong_count}, 包含边: {contain_count}")
+
+    # 分类共现边(跨点)
+    print("\n提取分类共现边(跨点):")
+    category_cooccur_edges = extract_category_cooccur_edges(associations_data)
+    all_edges.update(category_cooccur_edges)
+    print(f"  分类共现边: {len(category_cooccur_edges)}")
+
+    # 分类共现边(点内)
+    print("\n提取分类共现边(点内):")
+    intra_category_edges = extract_intra_category_cooccur_edges(intra_associations_data)
+    all_edges.update(intra_category_edges)
+    print(f"  分类共现_点内边: {len(intra_category_edges)}")
+
+    # 标签共现边
+    print("\n提取标签共现边:")
+    tag_cooccur_edges = extract_tag_cooccur_edges(historical_posts_dir)
+    all_edges.update(tag_cooccur_edges)
+    print(f"  标签共现边: {len(tag_cooccur_edges)}")
+
+    # ===== 添加根节点和维度节点 =====
+    print("\n添加根节点和维度节点:")
+
+    # 根节点
+    root_id = "人设:人设:人设:人设"
+    all_nodes[root_id] = create_node(
+        domain="人设",
+        dimension="人设",
+        node_type="人设",
+        name="人设",
+        detail={}
+    )
+
+    # 维度节点 + 边
+    dimensions = ["灵感点", "目的点", "关键点"]
+    for dim in dimensions:
+        dim_id = f"人设:{dim}:{dim}:{dim}"
+        all_nodes[dim_id] = create_node(
+            domain="人设",
+            dimension=dim,
+            node_type=dim,
+            name=dim,
+            detail={}
+        )
+
+        # 维度 -> 根 的属于边
+        edge_id = build_edge_id(dim_id, "属于", root_id)
+        all_edges[edge_id] = create_edge(
+            source=dim_id,
+            target=root_id,
+            edge_type="属于",
+            score=1.0,
+            detail={}
+        )
+
+        # 根 -> 维度 的包含边
+        edge_id_contain = build_edge_id(root_id, "包含", dim_id)
+        all_edges[edge_id_contain] = create_edge(
+            source=root_id,
+            target=dim_id,
+            edge_type="包含",
+            score=1.0,
+            detail={}
+        )
+
+        # 找该维度下的顶级分类(没有父节点的分类),添加边
+        dim_categories = [
+            (nid, ndata) for nid, ndata in all_nodes.items()
+            if ndata["dimension"] == dim and ndata["type"] == "分类"
+            and not ndata["detail"].get("parentPath")
+        ]
+
+        for cat_id, cat_data in dim_categories:
+            # 顶级分类 -> 维度 的属于边
+            edge_id = build_edge_id(cat_id, "属于", dim_id)
+            all_edges[edge_id] = create_edge(
+                source=cat_id,
+                target=dim_id,
+                edge_type="属于",
+                score=1.0,
+                detail={}
+            )
+
+            # 维度 -> 顶级分类 的包含边
+            edge_id_contain = build_edge_id(dim_id, "包含", cat_id)
+            all_edges[edge_id_contain] = create_edge(
+                source=dim_id,
+                target=cat_id,
+                edge_type="包含",
+                score=1.0,
+                detail={}
+            )
+
+    print(f"  添加节点: 1 根节点 + 3 维度节点 = 4")
+    print(f"  添加边: 根↔维度 6条 + 维度↔顶级分类")
+
+    # 边统计
+    edge_type_counts = {}
+    for edge in all_edges.values():
+        t = edge["type"]
+        edge_type_counts[t] = edge_type_counts.get(t, 0) + 1
+
+    print(f"\n边总计: {len(all_edges)}")
+    for t, count in sorted(edge_type_counts.items(), key=lambda x: -x[1]):
+        print(f"  {t}: {count}")
+
+    # ===== 构建索引 =====
+    print("\n" + "=" * 60)
+    print("构建索引...")
+    index = build_index(all_edges)
+    print(f"  outEdges 节点数: {len(index['outEdges'])}")
+    print(f"  inEdges 节点数: {len(index['inEdges'])}")
+
+    # ===== 构建嵌套树 =====
+    print("\n" + "=" * 60)
+    print("构建嵌套树...")
+    tree = build_nested_tree(all_nodes, all_edges)
+
+    # 统计树节点数
+    def count_tree_nodes(node):
+        count = 1
+        for child in node.get("children", []):
+            count += count_tree_nodes(child)
+        return count
+
+    tree_node_count = count_tree_nodes(tree)
+    print(f"  树节点数: {tree_node_count}")
+
+    # ===== 统计各维度 =====
+    dimension_stats = {}
+    for dim_name in ["灵感点", "目的点", "关键点"]:
+        dim_categories = sum(1 for n in all_nodes.values() if n["type"] == "分类" and n["dimension"] == dim_name)
+        dim_tags = sum(1 for n in all_nodes.values() if n["type"] == "标签" and n["dimension"] == dim_name)
+        dimension_stats[dim_name] = {
+            "categoryCount": dim_categories,
+            "tagCount": dim_tags
+        }
+
+    # ===== 构建输出 =====
+    print("\n" + "=" * 60)
+    print("保存结果...")
+
+    output_data = {
+        "meta": {
+            "description": "人设图谱数据",
+            "account": config.account_name,
+            "createdAt": datetime.now().isoformat(),
+            "stats": {
+                "nodeCount": len(all_nodes),
+                "edgeCount": len(all_edges),
+                "categoryCount": category_count,
+                "tagCount": tag_count,
+                "treeNodeCount": tree_node_count,
+                "dimensions": dimension_stats,
+                "edgeTypes": edge_type_counts
+            }
+        },
+        "nodes": all_nodes,
+        "edges": all_edges,
+        "index": index,
+        "tree": tree
+    }
+
+    with open(output_file, "w", encoding="utf-8") as f:
+        json.dump(output_data, f, ensure_ascii=False, indent=2)
+
+    print(f"\n输出文件: {output_file}")
+    print("\n" + "=" * 60)
+    print("完成!")
+
+
+if __name__ == "__main__":
+    main()

+ 705 - 0
script/data_processing/build_post_graph.py

@@ -0,0 +1,705 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+构建帖子图谱
+
+================================================================================
+输入文件:
+================================================================================
+filtered_results/*_filtered.json - 帖子解构结果(过滤后的how解构)
+
+================================================================================
+输出文件: post_graph/{post_id}_帖子图谱.json(每个帖子一个文件)
+================================================================================
+{
+    "meta": {                    # 元信息
+        "postId": "帖子ID",
+        "postTitle": "帖子标题",
+        "postDetail": {...},
+        "createdAt": "时间戳",
+        "stats": { ... }
+    },
+    "nodes": {                   # 节点字典 (nodeId -> nodeData)
+        "{domain}:{dimension}:{type}:{name}": {
+            "name": "显示名称",
+            "type": "帖子|灵感点|目的点|关键点|点|标签",
+            "domain": "帖子",
+            "dimension": "帖子|灵感点|目的点|关键点",
+            "detail": { ... }
+        }
+    },
+    "edges": {                   # 边字典 (edgeId -> edgeData)
+        "{source}|{type}|{target}": {
+            "source": "源节点ID",
+            "target": "目标节点ID",
+            "type": "属于|包含",
+            "score": 1.0,
+            "detail": { ... }
+        }
+    },
+    "index": {                   # 游走索引
+        "outEdges": { nodeId: { edgeType: [{ target, score }] } },
+        "inEdges": { nodeId: { edgeType: [{ source, score }] } }
+    },
+    "tree": { ... }              # 嵌套树结构
+}
+
+================================================================================
+核心逻辑:
+================================================================================
+1. 从 filtered_results 读取帖子解构结果
+2. 提取点节点和标签节点
+3. 添加根节点(帖子)和维度节点(灵感点/目的点/关键点)
+4. 构建属于/包含边
+5. 构建索引和嵌套树
+
+================================================================================
+层级对应(人设 vs 帖子):
+================================================================================
+| 人设   | 帖子   |
+|--------|--------|
+| 人设   | 帖子   |
+| 维度   | 维度   |
+| 分类   | 点     |
+| 标签   | 标签   |
+
+================================================================================
+节点ID格式: {domain}:{dimension}:{type}:{name}
+================================================================================
+- 根节点:   帖子:帖子:帖子:{post_id}
+- 维度节点: 帖子:灵感点:灵感点:灵感点
+- 点节点:   帖子:灵感点:点:{point_name}
+- 标签节点: 帖子:灵感点:标签:{tag_name}
+
+================================================================================
+边类型:
+================================================================================
+- 属于: 子节点 -> 父节点(层级关系)
+- 包含: 父节点 -> 子节点(层级关系)
+- 匹配: 帖子标签 <-> 人设标签(双向,score为相似度)
+
+================================================================================
+匹配边说明:
+================================================================================
+帖子图谱包含与人设图谱的匹配边,通过节点ID关联:
+- 帖子标签ID: 帖子:灵感点:标签:{tag_name}
+- 人设标签ID: 人设:灵感点:标签:{persona_tag_name}
+
+使用方式:从帖子标签出发,沿"匹配"边游走到人设标签ID,
+再从人设图谱.json中查找该ID的详细信息。
+
+================================================================================
+"""
+
+import json
+from pathlib import Path
+from typing import Dict, List, Set
+from datetime import datetime
+import sys
+
+# 添加项目根目录到路径
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+from script.data_processing.path_config import PathConfig
+
+
+# ==================== 节点和边构建工具 ====================
+
+def build_node_id(domain: str, dimension: str, node_type: str, name: str) -> str:
+    """构建节点ID"""
+    return f"{domain}:{dimension}:{node_type}:{name}"
+
+
+def build_edge_id(source: str, edge_type: str, target: str) -> str:
+    """构建边ID"""
+    return f"{source}|{edge_type}|{target}"
+
+
+def create_node(
+    domain: str,
+    dimension: str,
+    node_type: str,
+    name: str,
+    detail: Dict = None
+) -> Dict:
+    """创建节点"""
+    return {
+        "name": name,
+        "type": node_type,
+        "dimension": dimension,
+        "domain": domain,
+        "detail": detail or {}
+    }
+
+
+def create_edge(
+    source: str,
+    target: str,
+    edge_type: str,
+    score: float = None,
+    detail: Dict = None
+) -> Dict:
+    """创建边"""
+    return {
+        "source": source,
+        "target": target,
+        "type": edge_type,
+        "score": score,
+        "detail": detail or {}
+    }
+
+
+# ==================== 从帖子解构结果提取节点和匹配边 ====================
+
+def extract_points_tags_and_matches(filtered_data: Dict) -> tuple:
+    """
+    从帖子解构结果中提取点节点、标签节点和匹配边
+
+    Returns:
+        (点节点字典, 标签节点字典, 标签到点的映射, 匹配边字典)
+    """
+    point_nodes = {}  # nodeId -> nodeData
+    tag_nodes = {}    # nodeId -> nodeData
+    tag_to_point = {} # tagId -> [pointId, ...]
+    match_edges = {}  # edgeId -> edgeData
+
+    how_result = filtered_data.get("how解构结果", {})
+
+    dimension_mapping = {
+        "灵感点列表": "灵感点",
+        "目的点列表": "目的点",
+        "关键点列表": "关键点"
+    }
+
+    for list_key, dimension in dimension_mapping.items():
+        points = how_result.get(list_key, [])
+
+        for point in points:
+            point_name = point.get("名称", "")
+            point_desc = point.get("描述", "")
+
+            if not point_name:
+                continue
+
+            # 创建点节点
+            point_id = build_node_id("帖子", dimension, "点", point_name)
+            point_nodes[point_id] = create_node(
+                domain="帖子",
+                dimension=dimension,
+                node_type="点",
+                name=point_name,
+                detail={
+                    "description": point_desc
+                }
+            )
+
+            # 遍历how步骤列表,提取标签和匹配
+            how_steps = point.get("how步骤列表", [])
+
+            for step in how_steps:
+                step_name = step.get("步骤名称", "")
+                features = step.get("特征列表", [])
+
+                for feature in features:
+                    tag_name = feature.get("特征名称", "")
+                    weight = feature.get("权重", 1.0)
+
+                    if not tag_name:
+                        continue
+
+                    # 创建标签节点
+                    tag_id = build_node_id("帖子", dimension, "标签", tag_name)
+
+                    if tag_id not in tag_nodes:
+                        tag_nodes[tag_id] = create_node(
+                            domain="帖子",
+                            dimension=dimension,
+                            node_type="标签",
+                            name=tag_name,
+                            detail={
+                                "weight": weight,
+                                "stepName": step_name,
+                                "pointNames": [point_name]
+                            }
+                        )
+                    else:
+                        # 同一标签可能属于多个点
+                        if point_name not in tag_nodes[tag_id]["detail"]["pointNames"]:
+                            tag_nodes[tag_id]["detail"]["pointNames"].append(point_name)
+
+                    # 记录标签到点的映射
+                    if tag_id not in tag_to_point:
+                        tag_to_point[tag_id] = []
+                    if point_id not in tag_to_point[tag_id]:
+                        tag_to_point[tag_id].append(point_id)
+
+                    # 提取匹配边
+                    matches = feature.get("匹配结果", [])
+                    for match in matches:
+                        persona_name = match.get("人设特征名称", "")
+                        persona_dimension = match.get("人设特征层级", "")
+                        persona_type = match.get("特征类型", "标签")
+                        match_detail = match.get("匹配结果", {})
+                        similarity = match_detail.get("相似度", 0)
+
+                        if not persona_name or not persona_dimension:
+                            continue
+
+                        # 构建人设节点ID
+                        persona_id = build_node_id("人设", persona_dimension, persona_type, persona_name)
+
+                        # 创建双向匹配边
+                        # 帖子标签 -> 人设标签
+                        edge_id_1 = build_edge_id(tag_id, "匹配", persona_id)
+                        match_edges[edge_id_1] = create_edge(
+                            source=tag_id,
+                            target=persona_id,
+                            edge_type="匹配",
+                            score=similarity,
+                            detail={}
+                        )
+
+                        # 人设标签 -> 帖子标签
+                        edge_id_2 = build_edge_id(persona_id, "匹配", tag_id)
+                        match_edges[edge_id_2] = create_edge(
+                            source=persona_id,
+                            target=tag_id,
+                            edge_type="匹配",
+                            score=similarity,
+                            detail={}
+                        )
+
+    return point_nodes, tag_nodes, tag_to_point, match_edges
+
+
+# ==================== 构建边 ====================
+
+def build_belong_contain_edges(
+    point_nodes: Dict[str, Dict],
+    tag_nodes: Dict[str, Dict],
+    tag_to_point: Dict[str, List[str]],
+    dimension_node_ids: Dict[str, str]
+) -> Dict[str, Dict]:
+    """
+    构建属于/包含边
+
+    Returns:
+        边字典 { edgeId: edgeData }
+    """
+    edges = {}
+
+    # 1. 点 -> 维度(属于/包含)
+    for point_id, point_data in point_nodes.items():
+        dimension = point_data["dimension"]
+        dim_node_id = dimension_node_ids[dimension]
+
+        # 属于边:点 -> 维度
+        edge_id = build_edge_id(point_id, "属于", dim_node_id)
+        edges[edge_id] = create_edge(
+            source=point_id,
+            target=dim_node_id,
+            edge_type="属于",
+            score=1.0
+        )
+
+        # 包含边:维度 -> 点
+        edge_id_contain = build_edge_id(dim_node_id, "包含", point_id)
+        edges[edge_id_contain] = create_edge(
+            source=dim_node_id,
+            target=point_id,
+            edge_type="包含",
+            score=1.0
+        )
+
+    # 2. 标签 -> 点(属于/包含)
+    for tag_id, point_ids in tag_to_point.items():
+        for point_id in point_ids:
+            # 属于边:标签 -> 点
+            edge_id = build_edge_id(tag_id, "属于", point_id)
+            edges[edge_id] = create_edge(
+                source=tag_id,
+                target=point_id,
+                edge_type="属于",
+                score=1.0
+            )
+
+            # 包含边:点 -> 标签
+            edge_id_contain = build_edge_id(point_id, "包含", tag_id)
+            edges[edge_id_contain] = create_edge(
+                source=point_id,
+                target=tag_id,
+                edge_type="包含",
+                score=1.0
+            )
+
+    return edges
+
+
+# ==================== 构建索引 ====================
+
+def build_index(edges: Dict[str, Dict]) -> Dict:
+    """
+    构建游走索引
+
+    Returns:
+        {
+            "outEdges": { nodeId: { edgeType: [{ target, score }] } },
+            "inEdges": { nodeId: { edgeType: [{ source, score }] } }
+        }
+    """
+    out_edges = {}
+    in_edges = {}
+
+    for edge_data in edges.values():
+        source = edge_data["source"]
+        target = edge_data["target"]
+        edge_type = edge_data["type"]
+        score = edge_data["score"]
+
+        # outEdges
+        if source not in out_edges:
+            out_edges[source] = {}
+        if edge_type not in out_edges[source]:
+            out_edges[source][edge_type] = []
+        out_edges[source][edge_type].append({
+            "target": target,
+            "score": score
+        })
+
+        # inEdges
+        if target not in in_edges:
+            in_edges[target] = {}
+        if edge_type not in in_edges[target]:
+            in_edges[target][edge_type] = []
+        in_edges[target][edge_type].append({
+            "source": source,
+            "score": score
+        })
+
+    return {
+        "outEdges": out_edges,
+        "inEdges": in_edges
+    }
+
+
+# ==================== 构建嵌套树 ====================
+
+def build_nested_tree(nodes: Dict[str, Dict], edges: Dict[str, Dict], root_id: str) -> Dict:
+    """
+    从根节点开始,沿"包含"边递归构建嵌套树结构
+
+    Returns:
+        嵌套的树结构
+    """
+    # 从"包含"边构建 父节点 -> [子节点] 的映射
+    parent_to_children = {}
+
+    for edge_data in edges.values():
+        if edge_data["type"] == "包含":
+            parent_id = edge_data["source"]
+            child_id = edge_data["target"]
+
+            if parent_id not in parent_to_children:
+                parent_to_children[parent_id] = []
+            parent_to_children[parent_id].append(child_id)
+
+    # 递归构建子树
+    def build_subtree(node_id: str) -> Dict:
+        node_data = nodes[node_id]
+
+        subtree = {
+            "id": node_id,
+            "name": node_data["name"],
+            "type": node_data["type"],
+            "domain": node_data["domain"],
+            "dimension": node_data["dimension"],
+            "detail": node_data.get("detail", {}),
+            "children": []
+        }
+
+        # 获取子节点
+        child_ids = parent_to_children.get(node_id, [])
+
+        for child_id in child_ids:
+            if child_id in nodes:
+                subtree["children"].append(build_subtree(child_id))
+
+        return subtree
+
+    return build_subtree(root_id)
+
+
+# ==================== 图游走工具 ====================
+
+def walk_graph(
+    index: Dict,
+    start_node: str,
+    edge_types: List[str],
+    direction: str = "out",
+    min_score: float = None
+) -> Set[str]:
+    """
+    从起始节点出发,按指定边类型序列游走N步
+
+    Args:
+        index: 游走索引 {"outEdges": {...}, "inEdges": {...}}
+        start_node: 起始节点ID
+        edge_types: 边类型序列,如 ["属于", "包含"]
+        direction: 游走方向 "out"(沿出边) / "in"(沿入边)
+        min_score: 最小分数过滤
+
+    Returns:
+        到达的节点ID集合
+    """
+    edge_index = index["outEdges"] if direction == "out" else index["inEdges"]
+    target_key = "target" if direction == "out" else "source"
+
+    current_nodes = {start_node}
+
+    for edge_type in edge_types:
+        next_nodes = set()
+        for node in current_nodes:
+            neighbors = edge_index.get(node, {}).get(edge_type, [])
+            for neighbor in neighbors:
+                if min_score is not None and neighbor.get("score", 0) < min_score:
+                    continue
+                next_nodes.add(neighbor[target_key])
+        current_nodes = next_nodes
+
+        if not current_nodes:
+            break
+
+    return current_nodes
+
+
+def get_neighbors(
+    index: Dict,
+    node_id: str,
+    edge_type: str = None,
+    direction: str = "out",
+    min_score: float = None
+) -> List[Dict]:
+    """
+    获取节点的邻居
+
+    Args:
+        index: 游走索引
+        node_id: 节点ID
+        edge_type: 边类型(可选,不指定则返回所有类型)
+        direction: 方向 "out" / "in"
+        min_score: 最小分数过滤
+
+    Returns:
+        邻居列表 [{"target": "...", "score": 0.5}, ...]
+    """
+    edge_index = index["outEdges"] if direction == "out" else index["inEdges"]
+    node_edges = edge_index.get(node_id, {})
+
+    if edge_type:
+        neighbors = node_edges.get(edge_type, [])
+    else:
+        neighbors = []
+        for edges in node_edges.values():
+            neighbors.extend(edges)
+
+    if min_score is not None:
+        neighbors = [n for n in neighbors if n.get("score", 0) >= min_score]
+
+    return neighbors
+
+
+# ==================== 处理单个帖子 ====================
+
+def process_single_post(filtered_file: Path, output_dir: Path) -> Dict:
+    """
+    处理单个帖子,生成帖子图谱
+
+    Returns:
+        处理结果统计
+    """
+    # 读取数据
+    with open(filtered_file, "r", encoding="utf-8") as f:
+        filtered_data = json.load(f)
+
+    post_id = filtered_data.get("帖子id", "")
+    post_detail = filtered_data.get("帖子详情", {})
+    post_title = post_detail.get("title", "")
+
+    # 初始化节点和边
+    all_nodes = {}
+    all_edges = {}
+
+    # 1. 提取点节点、标签节点和匹配边
+    point_nodes, tag_nodes, tag_to_point, match_edges = extract_points_tags_and_matches(filtered_data)
+
+    # 2. 添加根节点
+    root_id = build_node_id("帖子", "帖子", "帖子", post_id)
+    all_nodes[root_id] = create_node(
+        domain="帖子",
+        dimension="帖子",
+        node_type="帖子",
+        name=post_id,
+        detail={
+            "postTitle": post_title,
+            "postDetail": post_detail
+        }
+    )
+
+    # 3. 添加维度节点
+    dimensions = ["灵感点", "目的点", "关键点"]
+    dimension_node_ids = {}
+
+    for dim in dimensions:
+        dim_id = build_node_id("帖子", dim, dim, dim)
+        dimension_node_ids[dim] = dim_id
+
+        all_nodes[dim_id] = create_node(
+            domain="帖子",
+            dimension=dim,
+            node_type=dim,
+            name=dim,
+            detail={}
+        )
+
+        # 维度 -> 根 的属于边
+        edge_id = build_edge_id(dim_id, "属于", root_id)
+        all_edges[edge_id] = create_edge(
+            source=dim_id,
+            target=root_id,
+            edge_type="属于",
+            score=1.0
+        )
+
+        # 根 -> 维度 的包含边
+        edge_id_contain = build_edge_id(root_id, "包含", dim_id)
+        all_edges[edge_id_contain] = create_edge(
+            source=root_id,
+            target=dim_id,
+            edge_type="包含",
+            score=1.0
+        )
+
+    # 4. 添加点节点和标签节点
+    all_nodes.update(point_nodes)
+    all_nodes.update(tag_nodes)
+
+    # 5. 构建属于/包含边
+    belong_contain_edges = build_belong_contain_edges(
+        point_nodes, tag_nodes, tag_to_point, dimension_node_ids
+    )
+    all_edges.update(belong_contain_edges)
+
+    # 6. 添加匹配边
+    all_edges.update(match_edges)
+
+    # 7. 构建索引
+    index = build_index(all_edges)
+
+    # 8. 构建嵌套树
+    tree = build_nested_tree(all_nodes, all_edges, root_id)
+
+    # 统计
+    point_count = len(point_nodes)
+    tag_count = len(tag_nodes)
+    match_count = len(match_edges) // 2  # 双向边,除以2得到实际匹配数
+
+    dimension_stats = {}
+    for dim in dimensions:
+        dim_points = sum(1 for n in point_nodes.values() if n["dimension"] == dim)
+        dim_tags = sum(1 for n in tag_nodes.values() if n["dimension"] == dim)
+        dimension_stats[dim] = {
+            "pointCount": dim_points,
+            "tagCount": dim_tags
+        }
+
+    # 构建输出
+    output_data = {
+        "meta": {
+            "postId": post_id,
+            "postTitle": post_title,
+            "postDetail": post_detail,
+            "createdAt": datetime.now().isoformat(),
+            "stats": {
+                "nodeCount": len(all_nodes),
+                "edgeCount": len(all_edges),
+                "pointCount": point_count,
+                "tagCount": tag_count,
+                "matchCount": match_count,
+                "dimensions": dimension_stats
+            }
+        },
+        "nodes": all_nodes,
+        "edges": all_edges,
+        "index": index,
+        "tree": tree
+    }
+
+    # 保存
+    output_file = output_dir / f"{post_id}_帖子图谱.json"
+    with open(output_file, "w", encoding="utf-8") as f:
+        json.dump(output_data, f, ensure_ascii=False, indent=2)
+
+    return {
+        "postId": post_id,
+        "postTitle": post_title,
+        "nodeCount": len(all_nodes),
+        "edgeCount": len(all_edges),
+        "pointCount": point_count,
+        "tagCount": tag_count,
+        "matchCount": match_count,
+        "outputFile": str(output_file)
+    }
+
+
+# ==================== 主函数 ====================
+
+def main():
+    config = PathConfig()
+    config.ensure_dirs()
+
+    print(f"账号: {config.account_name}")
+    print(f"输出版本: {config.output_version}")
+    print()
+
+    # 输入目录
+    filtered_results_dir = config.intermediate_dir / "filtered_results"
+
+    # 输出目录
+    output_dir = config.intermediate_dir / "post_graph"
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    print(f"输入目录: {filtered_results_dir}")
+    print(f"输出目录: {output_dir}")
+    print()
+
+    # 获取所有帖子文件
+    filtered_files = list(filtered_results_dir.glob("*_filtered.json"))
+    print(f"找到 {len(filtered_files)} 个帖子文件")
+    print()
+
+    # 处理每个帖子
+    results = []
+    for i, filtered_file in enumerate(filtered_files, 1):
+        print(f"[{i}/{len(filtered_files)}] 处理: {filtered_file.name}")
+        result = process_single_post(filtered_file, output_dir)
+        results.append(result)
+        print(f"  节点: {result['nodeCount']}, 边: {result['edgeCount']}")
+        print(f"  点: {result['pointCount']}, 标签: {result['tagCount']}, 匹配: {result['matchCount']}")
+        print(f"  → {Path(result['outputFile']).name}")
+        print()
+
+    # 汇总统计
+    print("=" * 60)
+    print("处理完成!")
+    print(f"  帖子数: {len(results)}")
+    print(f"  总节点数: {sum(r['nodeCount'] for r in results)}")
+    print(f"  总边数: {sum(r['edgeCount'] for r in results)}")
+    print(f"  总点数: {sum(r['pointCount'] for r in results)}")
+    print(f"  总标签数: {sum(r['tagCount'] for r in results)}")
+    print(f"  总匹配数: {sum(r['matchCount'] for r in results)}")
+    print(f"\n输出目录: {output_dir}")
+
+
+if __name__ == "__main__":
+    main()

+ 64 - 0
script/visualization/build.py

@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+构建人设图谱可视化
+
+步骤:
+1. 通过环境变量传递数据路径给 Vite
+2. npm run build 打包 Vue 项目(数据会被内联)
+3. 复制到输出目录
+"""
+
+import os
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+# 项目路径
+project_root = Path(__file__).parent.parent.parent
+sys.path.insert(0, str(project_root))
+
+from script.data_processing.path_config import PathConfig
+
+
+def main():
+    config = PathConfig()
+    viz_dir = Path(__file__).parent
+
+    print(f"账号: {config.account_name}")
+    print()
+
+    # 1. 确定数据文件路径
+    persona_graph_file = config.intermediate_dir / "人设图谱.json"
+    print(f"数据文件: {persona_graph_file}")
+
+    if not persona_graph_file.exists():
+        print(f"错误: 数据文件不存在!")
+        sys.exit(1)
+
+    # 2. 检查是否需要安装依赖
+    node_modules = viz_dir / "node_modules"
+    if not node_modules.exists():
+        print("\n安装依赖...")
+        subprocess.run(["npm", "install"], cwd=viz_dir, check=True)
+
+    # 3. 构建 Vue 项目(通过环境变量传递数据路径)
+    print("\n构建 Vue 项目...")
+    env = os.environ.copy()
+    env["GRAPH_DATA_PATH"] = str(persona_graph_file.absolute())
+
+    subprocess.run(["npm", "run", "build"], cwd=viz_dir, env=env, check=True)
+
+    # 4. 复制到输出目录
+    dist_html = viz_dir / "dist" / "index.html"
+    output_file = config.intermediate_dir / "人设图谱可视化.html"
+
+    print(f"\n输出: {output_file}")
+    shutil.copy(dist_html, output_file)
+
+    print("\n完成!")
+
+
+if __name__ == "__main__":
+    main()

+ 12 - 0
script/visualization/index.html

@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>人设图谱可视化</title>
+</head>
+<body>
+  <div id="app"></div>
+  <script type="module" src="/src/main.js"></script>
+</body>
+</html>

+ 2646 - 0
script/visualization/package-lock.json

@@ -0,0 +1,2646 @@
+{
+  "name": "persona-graph-visualization",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "persona-graph-visualization",
+      "version": "1.0.0",
+      "dependencies": {
+        "d3": "^7.8.5",
+        "pinia": "^2.1.7",
+        "vue": "^3.4.0"
+      },
+      "devDependencies": {
+        "@vitejs/plugin-vue": "^5.0.0",
+        "autoprefixer": "^10.4.0",
+        "daisyui": "^4.4.0",
+        "postcss": "^8.4.0",
+        "tailwindcss": "^3.4.0",
+        "vite": "^5.0.0",
+        "vite-plugin-singlefile": "^2.0.0"
+      }
+    },
+    "node_modules/@alloc/quick-lru": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+      "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.5.tgz",
+      "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
+      "dependencies": {
+        "@babel/types": "^7.28.5"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.5.tgz",
+      "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz",
+      "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz",
+      "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz",
+      "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz",
+      "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz",
+      "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz",
+      "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz",
+      "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz",
+      "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz",
+      "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz",
+      "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz",
+      "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz",
+      "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz",
+      "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz",
+      "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz",
+      "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz",
+      "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz",
+      "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz",
+      "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz",
+      "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz",
+      "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz",
+      "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz",
+      "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "5.2.4",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+      "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+      "dev": true,
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^5.0.0 || ^6.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.25",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
+      "integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==",
+      "dependencies": {
+        "@babel/parser": "^7.28.5",
+        "@vue/shared": "3.5.25",
+        "entities": "^4.5.0",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.25",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz",
+      "integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.25",
+        "@vue/shared": "3.5.25"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.25",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz",
+      "integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==",
+      "dependencies": {
+        "@babel/parser": "^7.28.5",
+        "@vue/compiler-core": "3.5.25",
+        "@vue/compiler-dom": "3.5.25",
+        "@vue/compiler-ssr": "3.5.25",
+        "@vue/shared": "3.5.25",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.6",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.25",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz",
+      "integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.25",
+        "@vue/shared": "3.5.25"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.25",
+      "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.25.tgz",
+      "integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==",
+      "dependencies": {
+        "@vue/shared": "3.5.25"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.25",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.25.tgz",
+      "integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==",
+      "dependencies": {
+        "@vue/reactivity": "3.5.25",
+        "@vue/shared": "3.5.25"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.25",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz",
+      "integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==",
+      "dependencies": {
+        "@vue/reactivity": "3.5.25",
+        "@vue/runtime-core": "3.5.25",
+        "@vue/shared": "3.5.25",
+        "csstype": "^3.1.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.25",
+      "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.25.tgz",
+      "integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.25",
+        "@vue/shared": "3.5.25"
+      },
+      "peerDependencies": {
+        "vue": "3.5.25"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.25",
+      "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.25.tgz",
+      "integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg=="
+    },
+    "node_modules/any-promise": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz",
+      "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+      "dev": true
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "dev": true,
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/arg": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz",
+      "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+      "dev": true
+    },
+    "node_modules/autoprefixer": {
+      "version": "10.4.22",
+      "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.22.tgz",
+      "integrity": "sha512-ARe0v/t9gO28Bznv6GgqARmVqcWOV3mfgUPn9becPHMiD3o9BwlRgaeccZnwTpZ7Zwqrm+c1sUSsMxIzQzc8Xg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "browserslist": "^4.27.0",
+        "caniuse-lite": "^1.0.30001754",
+        "fraction.js": "^5.3.4",
+        "normalize-range": "^0.1.2",
+        "picocolors": "^1.1.1",
+        "postcss-value-parser": "^4.2.0"
+      },
+      "bin": {
+        "autoprefixer": "bin/autoprefixer"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.0"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.8.31",
+      "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz",
+      "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==",
+      "dev": true,
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.js"
+      }
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz",
+      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.0",
+      "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.0.tgz",
+      "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "baseline-browser-mapping": "^2.8.25",
+        "caniuse-lite": "^1.0.30001754",
+        "electron-to-chromium": "^1.5.249",
+        "node-releases": "^2.0.27",
+        "update-browserslist-db": "^1.1.4"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/camelcase-css": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz",
+      "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001757",
+      "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz",
+      "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ]
+    },
+    "node_modules/chokidar": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
+      "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+      "dev": true,
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/chokidar/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/commander": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmmirror.com/commander/-/commander-7.2.0.tgz",
+      "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/css-selector-tokenizer": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmmirror.com/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz",
+      "integrity": "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==",
+      "dev": true,
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "fastparse": "^1.1.2"
+      }
+    },
+    "node_modules/cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true,
+      "bin": {
+        "cssesc": "bin/cssesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
+    },
+    "node_modules/culori": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmmirror.com/culori/-/culori-3.3.0.tgz",
+      "integrity": "sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==",
+      "dev": true,
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      }
+    },
+    "node_modules/d3": {
+      "version": "7.9.0",
+      "resolved": "https://registry.npmmirror.com/d3/-/d3-7.9.0.tgz",
+      "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==",
+      "dependencies": {
+        "d3-array": "3",
+        "d3-axis": "3",
+        "d3-brush": "3",
+        "d3-chord": "3",
+        "d3-color": "3",
+        "d3-contour": "4",
+        "d3-delaunay": "6",
+        "d3-dispatch": "3",
+        "d3-drag": "3",
+        "d3-dsv": "3",
+        "d3-ease": "3",
+        "d3-fetch": "3",
+        "d3-force": "3",
+        "d3-format": "3",
+        "d3-geo": "3",
+        "d3-hierarchy": "3",
+        "d3-interpolate": "3",
+        "d3-path": "3",
+        "d3-polygon": "3",
+        "d3-quadtree": "3",
+        "d3-random": "3",
+        "d3-scale": "4",
+        "d3-scale-chromatic": "3",
+        "d3-selection": "3",
+        "d3-shape": "3",
+        "d3-time": "3",
+        "d3-time-format": "4",
+        "d3-timer": "3",
+        "d3-transition": "3",
+        "d3-zoom": "3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-array": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmmirror.com/d3-array/-/d3-array-3.2.4.tgz",
+      "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+      "dependencies": {
+        "internmap": "1 - 2"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-axis": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/d3-axis/-/d3-axis-3.0.0.tgz",
+      "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-brush": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/d3-brush/-/d3-brush-3.0.0.tgz",
+      "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-drag": "2 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-selection": "3",
+        "d3-transition": "3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-chord": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-chord/-/d3-chord-3.0.1.tgz",
+      "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==",
+      "dependencies": {
+        "d3-path": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-contour": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmmirror.com/d3-contour/-/d3-contour-4.0.2.tgz",
+      "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==",
+      "dependencies": {
+        "d3-array": "^3.2.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-delaunay": {
+      "version": "6.0.4",
+      "resolved": "https://registry.npmmirror.com/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
+      "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==",
+      "dependencies": {
+        "delaunator": "5"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dispatch": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+      "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-drag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz",
+      "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-selection": "3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dsv": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-dsv/-/d3-dsv-3.0.1.tgz",
+      "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==",
+      "dependencies": {
+        "commander": "7",
+        "iconv-lite": "0.6",
+        "rw": "1"
+      },
+      "bin": {
+        "csv2json": "bin/dsv2json.js",
+        "csv2tsv": "bin/dsv2dsv.js",
+        "dsv2dsv": "bin/dsv2dsv.js",
+        "dsv2json": "bin/dsv2json.js",
+        "json2csv": "bin/json2dsv.js",
+        "json2dsv": "bin/json2dsv.js",
+        "json2tsv": "bin/json2dsv.js",
+        "tsv2csv": "bin/dsv2dsv.js",
+        "tsv2json": "bin/dsv2json.js"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-fetch": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-fetch/-/d3-fetch-3.0.1.tgz",
+      "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==",
+      "dependencies": {
+        "d3-dsv": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-force": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/d3-force/-/d3-force-3.0.0.tgz",
+      "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-quadtree": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-format": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-format/-/d3-format-3.1.0.tgz",
+      "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-geo": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmmirror.com/d3-geo/-/d3-geo-3.1.1.tgz",
+      "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
+      "dependencies": {
+        "d3-array": "2.5.0 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-hierarchy": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
+      "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-path": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-path/-/d3-path-3.1.0.tgz",
+      "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-polygon": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-polygon/-/d3-polygon-3.0.1.tgz",
+      "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-quadtree": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
+      "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-random": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-random/-/d3-random-3.0.1.tgz",
+      "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-scale": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmmirror.com/d3-scale/-/d3-scale-4.0.2.tgz",
+      "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+      "dependencies": {
+        "d3-array": "2.10.0 - 3",
+        "d3-format": "1 - 3",
+        "d3-interpolate": "1.2.0 - 3",
+        "d3-time": "2.1.1 - 3",
+        "d3-time-format": "2 - 4"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-scale-chromatic": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
+      "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
+      "dependencies": {
+        "d3-color": "1 - 3",
+        "d3-interpolate": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-selection": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz",
+      "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-shape": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/d3-shape/-/d3-shape-3.2.0.tgz",
+      "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+      "dependencies": {
+        "d3-path": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-time/-/d3-time-3.1.0.tgz",
+      "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+      "dependencies": {
+        "d3-array": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-time-format": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-time-format/-/d3-time-format-4.1.0.tgz",
+      "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+      "dependencies": {
+        "d3-time": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-transition": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz",
+      "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+      "dependencies": {
+        "d3-color": "1 - 3",
+        "d3-dispatch": "1 - 3",
+        "d3-ease": "1 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "d3-selection": "2 - 3"
+      }
+    },
+    "node_modules/d3-zoom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz",
+      "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-drag": "2 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-selection": "2 - 3",
+        "d3-transition": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/daisyui": {
+      "version": "4.12.24",
+      "resolved": "https://registry.npmmirror.com/daisyui/-/daisyui-4.12.24.tgz",
+      "integrity": "sha512-JYg9fhQHOfXyLadrBrEqCDM6D5dWCSSiM6eTNCRrBRzx/VlOCrLS8eDfIw9RVvs64v2mJdLooKXY8EwQzoszAA==",
+      "dev": true,
+      "dependencies": {
+        "css-selector-tokenizer": "^0.8",
+        "culori": "^3",
+        "picocolors": "^1",
+        "postcss-js": "^4"
+      },
+      "engines": {
+        "node": ">=16.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/daisyui"
+      }
+    },
+    "node_modules/delaunator": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/delaunator/-/delaunator-5.0.1.tgz",
+      "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==",
+      "dependencies": {
+        "robust-predicates": "^3.0.2"
+      }
+    },
+    "node_modules/didyoumean": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz",
+      "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+      "dev": true
+    },
+    "node_modules/dlv": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz",
+      "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+      "dev": true
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.262",
+      "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz",
+      "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==",
+      "dev": true
+    },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
+    },
+    "node_modules/fast-glob": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz",
+      "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.8"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/fast-glob/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fastparse": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/fastparse/-/fastparse-1.1.2.tgz",
+      "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==",
+      "dev": true
+    },
+    "node_modules/fastq": {
+      "version": "1.19.1",
+      "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz",
+      "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==",
+      "dev": true,
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/fraction.js": {
+      "version": "5.3.4",
+      "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz",
+      "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/rawify"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/internmap": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/internmap/-/internmap-2.0.3.tgz",
+      "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.16.1",
+      "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz",
+      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+      "dev": true,
+      "dependencies": {
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/jiti": {
+      "version": "1.21.7",
+      "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz",
+      "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+      "dev": true,
+      "bin": {
+        "jiti": "bin/jiti.js"
+      }
+    },
+    "node_modules/lilconfig": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
+      "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+      "dev": true,
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antonk52"
+      }
+    },
+    "node_modules/lines-and-columns": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+      "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+      "dev": true
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz",
+      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+      "dev": true,
+      "dependencies": {
+        "braces": "^3.0.3",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/mz": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz",
+      "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+      "dev": true,
+      "dependencies": {
+        "any-promise": "^1.0.0",
+        "object-assign": "^4.0.1",
+        "thenify-all": "^1.0.0"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.27",
+      "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz",
+      "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
+      "dev": true
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/normalize-range": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz",
+      "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-hash": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz",
+      "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+      "dev": true,
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "dev": true
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pify": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz",
+      "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/pinia": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz",
+      "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.3",
+        "vue-demi": "^0.14.10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.4.4",
+        "vue": "^2.7.0 || ^3.5.11"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pirates": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz",
+      "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.6",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/postcss-import": {
+      "version": "15.1.0",
+      "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz",
+      "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+      "dev": true,
+      "dependencies": {
+        "postcss-value-parser": "^4.0.0",
+        "read-cache": "^1.0.0",
+        "resolve": "^1.1.7"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "postcss": "^8.0.0"
+      }
+    },
+    "node_modules/postcss-js": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.1.0.tgz",
+      "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "camelcase-css": "^2.0.1"
+      },
+      "engines": {
+        "node": "^12 || ^14 || >= 16"
+      },
+      "peerDependencies": {
+        "postcss": "^8.4.21"
+      }
+    },
+    "node_modules/postcss-load-config": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+      "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "lilconfig": "^3.1.1"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "peerDependencies": {
+        "jiti": ">=1.21.0",
+        "postcss": ">=8.0.9",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "jiti": {
+          "optional": true
+        },
+        "postcss": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/postcss-nested": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz",
+      "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "postcss-selector-parser": "^6.1.1"
+      },
+      "engines": {
+        "node": ">=12.0"
+      },
+      "peerDependencies": {
+        "postcss": "^8.2.14"
+      }
+    },
+    "node_modules/postcss-selector-parser": {
+      "version": "6.1.2",
+      "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+      "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+      "dev": true,
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postcss-value-parser": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+      "dev": true
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/read-cache": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz",
+      "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+      "dev": true,
+      "dependencies": {
+        "pify": "^2.3.0"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "1.22.11",
+      "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz",
+      "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+      "dev": true,
+      "dependencies": {
+        "is-core-module": "^2.16.1",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz",
+      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+      "dev": true,
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/robust-predicates": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmmirror.com/robust-predicates/-/robust-predicates-3.0.2.tgz",
+      "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="
+    },
+    "node_modules/rollup": {
+      "version": "4.53.3",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.53.3.tgz",
+      "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
+      "dev": true,
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.53.3",
+        "@rollup/rollup-android-arm64": "4.53.3",
+        "@rollup/rollup-darwin-arm64": "4.53.3",
+        "@rollup/rollup-darwin-x64": "4.53.3",
+        "@rollup/rollup-freebsd-arm64": "4.53.3",
+        "@rollup/rollup-freebsd-x64": "4.53.3",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.53.3",
+        "@rollup/rollup-linux-arm-musleabihf": "4.53.3",
+        "@rollup/rollup-linux-arm64-gnu": "4.53.3",
+        "@rollup/rollup-linux-arm64-musl": "4.53.3",
+        "@rollup/rollup-linux-loong64-gnu": "4.53.3",
+        "@rollup/rollup-linux-ppc64-gnu": "4.53.3",
+        "@rollup/rollup-linux-riscv64-gnu": "4.53.3",
+        "@rollup/rollup-linux-riscv64-musl": "4.53.3",
+        "@rollup/rollup-linux-s390x-gnu": "4.53.3",
+        "@rollup/rollup-linux-x64-gnu": "4.53.3",
+        "@rollup/rollup-linux-x64-musl": "4.53.3",
+        "@rollup/rollup-openharmony-arm64": "4.53.3",
+        "@rollup/rollup-win32-arm64-msvc": "4.53.3",
+        "@rollup/rollup-win32-ia32-msvc": "4.53.3",
+        "@rollup/rollup-win32-x64-gnu": "4.53.3",
+        "@rollup/rollup-win32-x64-msvc": "4.53.3",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "node_modules/rw": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmmirror.com/rw/-/rw-1.3.3.tgz",
+      "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/sucrase": {
+      "version": "3.35.1",
+      "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz",
+      "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+      "dev": true,
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.2",
+        "commander": "^4.0.0",
+        "lines-and-columns": "^1.1.6",
+        "mz": "^2.7.0",
+        "pirates": "^4.0.1",
+        "tinyglobby": "^0.2.11",
+        "ts-interface-checker": "^0.1.9"
+      },
+      "bin": {
+        "sucrase": "bin/sucrase",
+        "sucrase-node": "bin/sucrase-node"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      }
+    },
+    "node_modules/sucrase/node_modules/commander": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz",
+      "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/tailwindcss": {
+      "version": "3.4.18",
+      "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.18.tgz",
+      "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==",
+      "dev": true,
+      "dependencies": {
+        "@alloc/quick-lru": "^5.2.0",
+        "arg": "^5.0.2",
+        "chokidar": "^3.6.0",
+        "didyoumean": "^1.2.2",
+        "dlv": "^1.1.3",
+        "fast-glob": "^3.3.2",
+        "glob-parent": "^6.0.2",
+        "is-glob": "^4.0.3",
+        "jiti": "^1.21.7",
+        "lilconfig": "^3.1.3",
+        "micromatch": "^4.0.8",
+        "normalize-path": "^3.0.0",
+        "object-hash": "^3.0.0",
+        "picocolors": "^1.1.1",
+        "postcss": "^8.4.47",
+        "postcss-import": "^15.1.0",
+        "postcss-js": "^4.0.1",
+        "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+        "postcss-nested": "^6.2.0",
+        "postcss-selector-parser": "^6.1.2",
+        "resolve": "^1.22.8",
+        "sucrase": "^3.35.0"
+      },
+      "bin": {
+        "tailwind": "lib/cli.js",
+        "tailwindcss": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/thenify": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz",
+      "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+      "dev": true,
+      "dependencies": {
+        "any-promise": "^1.0.0"
+      }
+    },
+    "node_modules/thenify-all": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz",
+      "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+      "dev": true,
+      "dependencies": {
+        "thenify": ">= 3.1.0 < 4"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.15",
+      "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
+      "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+      "dev": true,
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tinyglobby/node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/tinyglobby/node_modules/picomatch": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz",
+      "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/ts-interface-checker": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+      "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+      "dev": true
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
+      "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "dev": true
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vite-plugin-singlefile": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmmirror.com/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.0.tgz",
+      "integrity": "sha512-DAcHzYypM0CasNLSz/WG0VdKOCxGHErfrjOoyIPiNxTPTGmO6rRD/te93n1YL/s+miXq66ipF1brMBikf99c6A==",
+      "dev": true,
+      "dependencies": {
+        "micromatch": "^4.0.8"
+      },
+      "engines": {
+        "node": ">18.0.0"
+      },
+      "peerDependencies": {
+        "rollup": "^4.44.1",
+        "vite": "^5.4.11 || ^6.0.0 || ^7.0.0"
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.25",
+      "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.25.tgz",
+      "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.25",
+        "@vue/compiler-sfc": "3.5.25",
+        "@vue/runtime-dom": "3.5.25",
+        "@vue/server-renderer": "3.5.25",
+        "@vue/shared": "3.5.25"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-demi": {
+      "version": "0.14.10",
+      "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
+      "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+      "hasInstallScript": true,
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    }
+  }
+}

+ 24 - 0
script/visualization/package.json

@@ -0,0 +1,24 @@
+{
+  "name": "persona-graph-visualization",
+  "version": "1.0.0",
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "vue": "^3.4.0",
+    "d3": "^7.8.5",
+    "pinia": "^2.1.7"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.0.0",
+    "vite": "^5.0.0",
+    "vite-plugin-singlefile": "^2.0.0",
+    "tailwindcss": "^3.4.0",
+    "postcss": "^8.4.0",
+    "autoprefixer": "^10.4.0",
+    "daisyui": "^4.4.0"
+  }
+}

+ 6 - 0
script/visualization/postcss.config.js

@@ -0,0 +1,6 @@
+export default {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+}

+ 36 - 0
script/visualization/src/App.vue

@@ -0,0 +1,36 @@
+<template>
+  <div data-theme="dark" class="flex flex-col h-screen">
+    <!-- 顶部栏 -->
+    <header class="navbar bg-base-200 min-h-0 px-4 py-2 shrink-0">
+      <h1 class="text-sm font-medium text-primary">人设图谱</h1>
+      <div class="flex gap-4 text-xs text-base-content/60 ml-6">
+        <div class="flex items-center gap-1">
+          <span class="w-2.5 h-2.5 rounded-full bg-dim-inspiration"></span>灵感点
+        </div>
+        <div class="flex items-center gap-1">
+          <span class="w-2.5 h-2.5 rounded-full bg-dim-purpose"></span>目的点
+        </div>
+        <div class="flex items-center gap-1">
+          <span class="w-2.5 h-2.5 rounded-full bg-dim-key"></span>关键点
+        </div>
+        <div class="flex items-center gap-1">○ 标签</div>
+        <div class="flex items-center gap-1">□ 分类</div>
+      </div>
+    </header>
+
+    <!-- 主内容区 -->
+    <main class="flex flex-1 overflow-hidden">
+      <TreeView class="w-[420px] shrink-0 bg-base-200 border-r border-base-300" />
+      <GraphView class="flex-1" />
+    </main>
+
+    <!-- 详情面板 -->
+    <DetailPanel />
+  </div>
+</template>
+
+<script setup>
+import TreeView from './components/TreeView.vue'
+import GraphView from './components/GraphView.vue'
+import DetailPanel from './components/DetailPanel.vue'
+</script>

+ 45 - 0
script/visualization/src/components/DetailPanel.vue

@@ -0,0 +1,45 @@
+<template>
+  <div
+    v-if="store.selectedNode"
+    class="card bg-base-200/95 fixed bottom-4 right-4 w-72 shadow-xl"
+  >
+    <div class="card-body p-4 text-xs">
+      <h4 class="card-title text-primary text-sm">{{ store.selectedNode.name }}</h4>
+
+      <div class="space-y-2 text-base-content/80">
+        <div class="flex">
+          <span class="text-base-content/50 w-14 shrink-0">类型</span>
+          <span>{{ store.selectedNode.type }}</span>
+        </div>
+        <div class="flex">
+          <span class="text-base-content/50 w-14 shrink-0">维度</span>
+          <span>{{ store.selectedNode.dimension }}</span>
+        </div>
+
+        <template v-if="store.selectedNode.detail">
+          <div v-if="store.selectedNode.detail.postCount !== undefined" class="flex">
+            <span class="text-base-content/50 w-14 shrink-0">帖子数</span>
+            <span>{{ store.selectedNode.detail.postCount }}</span>
+          </div>
+          <div v-if="store.selectedNode.detail.parentPath?.length" class="flex">
+            <span class="text-base-content/50 w-14 shrink-0">路径</span>
+            <span class="break-all">{{ store.selectedNode.detail.parentPath.join(' > ') }}</span>
+          </div>
+        </template>
+      </div>
+
+      <button
+        @click="store.clearSelection"
+        class="btn btn-circle btn-ghost btn-xs absolute top-3 right-3"
+      >
+        ✕
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { useGraphStore } from '../stores/graph'
+
+const store = useGraphStore()
+</script>

+ 406 - 0
script/visualization/src/components/GraphView.vue

@@ -0,0 +1,406 @@
+<template>
+  <div class="flex flex-col h-full">
+    <!-- 头部 -->
+    <div class="flex items-center gap-3 px-4 py-2 bg-base-300 text-xs text-base-content/60">
+      <span>相关图</span>
+      <span class="text-primary font-medium">{{ currentNodeName }}</span>
+      <div class="flex-1"></div>
+      <button @click="showConfig = !showConfig" class="btn btn-ghost btn-xs">
+        {{ showConfig ? '隐藏配置' : '游走配置' }}
+      </button>
+    </div>
+
+    <!-- 游走配置面板 -->
+    <div v-show="showConfig" class="px-4 py-2 bg-base-200 border-b border-base-300 text-xs space-y-3 max-h-64 overflow-y-auto relative z-50">
+      <!-- 步数设置 -->
+      <div class="flex items-center gap-2">
+        <span class="text-base-content/60 w-16">游走步数:</span>
+        <input type="range" :min="1" :max="5" v-model.number="walkSteps" class="range range-xs range-primary flex-1" />
+        <span class="w-6 text-center">{{ walkSteps }}</span>
+      </div>
+
+      <!-- 配置模式切换 -->
+      <div class="flex items-center gap-3">
+        <span class="text-base-content/60 w-16">配置模式:</span>
+        <label class="flex items-center gap-1 cursor-pointer">
+          <input type="radio" v-model="configMode" value="global" class="radio radio-xs radio-primary" />
+          <span>整体设置</span>
+        </label>
+        <label class="flex items-center gap-1 cursor-pointer">
+          <input type="radio" v-model="configMode" value="step" class="radio radio-xs radio-primary" />
+          <span>分步设置</span>
+        </label>
+      </div>
+
+      <!-- 整体设置 -->
+      <div v-if="configMode === 'global'" class="pl-4 space-y-2 border-l-2 border-primary/30">
+        <div class="flex items-center gap-2 flex-wrap">
+          <span class="text-base-content/60 w-14">边类型:</span>
+          <label v-for="et in allEdgeTypes" :key="et" class="flex items-center gap-1 cursor-pointer">
+            <input type="checkbox" v-model="globalEdgeTypes" :value="et" class="checkbox checkbox-xs" />
+            <span :style="{ color: edgeColors[et] }">{{ et }}</span>
+          </label>
+        </div>
+        <div class="flex items-center gap-2">
+          <span class="text-base-content/60 w-14">最小分:</span>
+          <input type="range" :min="0" :max="1" :step="0.1" v-model.number="globalMinScore" class="range range-xs flex-1" />
+          <span class="w-8 text-center">{{ globalMinScore.toFixed(1) }}</span>
+        </div>
+      </div>
+
+      <!-- 分步设置 -->
+      <div v-else class="space-y-2">
+        <div v-for="step in walkSteps" :key="step" class="pl-4 space-y-1 border-l-2 border-secondary/30">
+          <div class="font-medium text-secondary">第 {{ step }} 步</div>
+          <div class="flex items-center gap-2 flex-wrap">
+            <span class="text-base-content/60 w-14">边类型:</span>
+            <label v-for="et in allEdgeTypes" :key="et" class="flex items-center gap-1 cursor-pointer">
+              <input type="checkbox" v-model="stepConfigs[step-1].edgeTypes" :value="et" class="checkbox checkbox-xs" />
+              <span :style="{ color: edgeColors[et] }">{{ et }}</span>
+            </label>
+          </div>
+          <div class="flex items-center gap-2">
+            <span class="text-base-content/60 w-14">最小分:</span>
+            <input type="range" :min="0" :max="1" :step="0.1" v-model.number="stepConfigs[step-1].minScore" class="range range-xs flex-1" />
+            <span class="w-8 text-center">{{ stepConfigs[step-1].minScore.toFixed(1) }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- SVG 容器 -->
+    <div ref="containerRef" class="flex-1 relative overflow-hidden">
+      <svg ref="svgRef" class="w-full h-full"></svg>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, watch, onMounted } from 'vue'
+import * as d3 from 'd3'
+import { useGraphStore } from '../stores/graph'
+
+const store = useGraphStore()
+
+const containerRef = ref(null)
+const svgRef = ref(null)
+
+let simulation = null
+
+// 维度颜色映射
+const dimColors = {
+  '人设': '#e94560',
+  '灵感点': '#f39c12',
+  '目的点': '#3498db',
+  '关键点': '#9b59b6'
+}
+
+// 边类型颜色
+const edgeColors = {
+  '属于': '#9b59b6',
+  '包含': '#3498db',
+  '分类共现': '#2ecc71',
+  '分类共现_点内': '#27ae60',
+  '标签共现': '#f39c12'
+}
+
+// 所有边类型
+const allEdgeTypes = ['属于', '包含', '标签共现', '分类共现', '分类共现_点内']
+
+// 游走配置
+const showConfig = ref(false)
+const walkSteps = ref(2)
+const configMode = ref('global')  // 'global' | 'step'
+
+// 整体设置
+const globalEdgeTypes = ref([...allEdgeTypes])
+const globalMinScore = ref(0)
+
+// 分步设置(最多5步)
+const stepConfigs = reactive([
+  { edgeTypes: [...allEdgeTypes], minScore: 0 },
+  { edgeTypes: [...allEdgeTypes], minScore: 0 },
+  { edgeTypes: [...allEdgeTypes], minScore: 0 },
+  { edgeTypes: [...allEdgeTypes], minScore: 0 },
+  { edgeTypes: [...allEdgeTypes], minScore: 0 }
+])
+
+// 游走时记录的边
+let walkedEdges = []
+
+// 执行游走
+function executeWalk() {
+  if (!store.selectedNodeId) return
+
+  const visited = new Set([store.selectedNodeId])
+  let currentFrontier = new Set([store.selectedNodeId])
+  walkedEdges = []  // 清空之前的边记录
+
+  for (let step = 0; step < walkSteps.value; step++) {
+    const config = configMode.value === 'global'
+      ? { edgeTypes: globalEdgeTypes.value, minScore: globalMinScore.value }
+      : stepConfigs[step]
+
+    const nextFrontier = new Set()
+
+    for (const nodeId of currentFrontier) {
+      const neighbors = getFilteredNeighbors(nodeId, config)
+      for (const n of neighbors) {
+        if (!visited.has(n.nodeId)) {
+          visited.add(n.nodeId)
+          nextFrontier.add(n.nodeId)
+          // 记录走过的边(固定出边方向)
+          walkedEdges.push({
+            source: nodeId,
+            target: n.nodeId,
+            type: n.edgeType,
+            score: n.score
+          })
+        }
+      }
+    }
+
+    currentFrontier = nextFrontier
+    if (currentFrontier.size === 0) break
+  }
+
+  // 更新高亮节点
+  store.highlightedNodeIds = visited
+  renderGraph()
+}
+
+// 根据配置获取过滤后的邻居(固定沿出边游走)
+function getFilteredNeighbors(nodeId, config) {
+  const neighbors = []
+  const index = store.graphData.index
+  const outEdges = index.outEdges?.[nodeId] || {}
+
+  for (const [edgeType, targets] of Object.entries(outEdges)) {
+    if (!config.edgeTypes.includes(edgeType)) continue
+
+    for (const t of targets) {
+      const score = t.score || 0
+      if (score >= config.minScore) {
+        neighbors.push({ nodeId: t.target, edgeType, score })
+      }
+    }
+  }
+
+  return neighbors
+}
+
+const currentNodeName = computed(() => {
+  if (!store.selectedNodeId) return '点击左侧节点查看'
+  const node = store.getNode(store.selectedNodeId)
+  return node ? node.name : store.selectedNodeId
+})
+
+// 获取节点颜色
+function getNodeColor(node) {
+  if (node.type === '人设') return dimColors['人设']
+  return dimColors[node.dimension] || '#888'
+}
+
+// 渲染相关图
+function renderGraph() {
+  const svg = d3.select(svgRef.value)
+  svg.selectAll('*').remove()
+
+  if (!store.selectedNodeId) return
+
+  const container = containerRef.value
+  const width = container.clientWidth
+  const height = container.clientHeight
+
+  svg.attr('viewBox', `0 0 ${width} ${height}`)
+
+  const centerNodeId = store.selectedNodeId
+  const centerNode = store.getNode(centerNodeId)
+
+  if (!centerNode) return
+
+  // 判断是否有游走高亮结果
+  const hasWalkResult = store.highlightedNodeIds.size > 1
+
+  // 准备节点和边数据
+  const nodes = []
+  const links = []
+  const nodeSet = new Set()
+  const linkSet = new Set()
+
+  if (hasWalkResult) {
+    // 游走模式:显示所有高亮节点
+    for (const nodeId of store.highlightedNodeIds) {
+      const nodeData = store.getNode(nodeId)
+      if (nodeData) {
+        nodes.push({
+          id: nodeId,
+          ...nodeData,
+          isCenter: nodeId === centerNodeId,
+          isHighlighted: true
+        })
+        nodeSet.add(nodeId)
+      }
+    }
+
+    // 使用游走时记录的边(只显示实际走过的路径)
+    links.push(...walkedEdges)
+  } else {
+    // 普通模式:显示选中节点的直接邻居
+    nodes.push({
+      id: centerNodeId,
+      ...centerNode,
+      isCenter: true,
+      isHighlighted: false
+    })
+    nodeSet.add(centerNodeId)
+
+    const neighbors = store.getNeighbors(centerNodeId)
+    for (const n of neighbors) {
+      const nodeData = store.getNode(n.nodeId)
+      if (nodeData && !nodeSet.has(n.nodeId)) {
+        nodeSet.add(n.nodeId)
+        nodes.push({
+          id: n.nodeId,
+          ...nodeData,
+          isCenter: false,
+          isHighlighted: false
+        })
+        links.push({
+          source: n.direction === 'out' ? centerNodeId : n.nodeId,
+          target: n.direction === 'out' ? n.nodeId : centerNodeId,
+          type: n.edgeType,
+          score: n.score
+        })
+      }
+    }
+  }
+
+  const g = svg.append('g')
+
+  // 缩放
+  const zoom = d3.zoom()
+    .scaleExtent([0.3, 3])
+    .on('zoom', (e) => g.attr('transform', e.transform))
+  svg.call(zoom)
+
+  // 力导向模拟
+  simulation = d3.forceSimulation(nodes)
+    .force('link', d3.forceLink(links).id(d => d.id).distance(100))
+    .force('charge', d3.forceManyBody().strength(-200))
+    .force('center', d3.forceCenter(width / 2, height / 2))
+    .force('collision', d3.forceCollide().radius(35))
+
+  // 边
+  const link = g.append('g')
+    .selectAll('line')
+    .data(links)
+    .join('line')
+    .attr('class', 'graph-link')
+    .attr('stroke', d => edgeColors[d.type] || '#666')
+    .attr('stroke-width', 1.5)
+
+  // 边标签
+  const linkLabel = g.append('g')
+    .selectAll('text')
+    .data(links)
+    .join('text')
+    .attr('class', 'graph-link-label')
+    .attr('text-anchor', 'middle')
+    .text(d => d.type)
+
+  // 节点组
+  const node = g.append('g')
+    .selectAll('g')
+    .data(nodes)
+    .join('g')
+    .attr('class', d => `graph-node ${d.isCenter ? 'center' : ''}`)
+    .call(d3.drag()
+      .on('start', (e, d) => {
+        if (!e.active) simulation.alphaTarget(0.3).restart()
+        d.fx = d.x
+        d.fy = d.y
+      })
+      .on('drag', (e, d) => {
+        d.fx = e.x
+        d.fy = e.y
+      })
+      .on('end', (e, d) => {
+        if (!e.active) simulation.alphaTarget(0)
+        d.fx = null
+        d.fy = null
+      }))
+    .on('click', (e, d) => {
+      e.stopPropagation()
+      store.selectNode(d.id)
+    })
+
+  // 节点形状
+  node.each(function(d) {
+    const el = d3.select(this)
+    const size = d.isCenter ? 16 : 10
+    const color = getNodeColor(d)
+
+    // 高亮光晕
+    if (d.isHighlighted && !d.isCenter) {
+      el.append('circle')
+        .attr('r', size/2 + 4)
+        .attr('fill', 'none')
+        .attr('stroke', color)
+        .attr('stroke-width', 2)
+        .attr('stroke-opacity', 0.3)
+    }
+
+    if (d.type === '分类') {
+      el.append('rect')
+        .attr('x', -size/2)
+        .attr('y', -size/2)
+        .attr('width', size)
+        .attr('height', size)
+        .attr('rx', 3)
+        .attr('fill', color)
+        .attr('stroke', d.isCenter ? '#fff' : 'none')
+        .attr('stroke-width', d.isCenter ? 2 : 0)
+    } else {
+      el.append('circle')
+        .attr('r', size/2)
+        .attr('fill', color)
+        .attr('stroke', d.isCenter ? '#fff' : 'none')
+        .attr('stroke-width', d.isCenter ? 2 : 0)
+    }
+  })
+
+  // 节点标签
+  node.append('text')
+    .attr('dy', d => (d.isCenter ? 16 : 10) / 2 + 12)
+    .attr('text-anchor', 'middle')
+    .text(d => d.name.length > 8 ? d.name.slice(0, 8) + '...' : d.name)
+
+  // 更新位置
+  simulation.on('tick', () => {
+    link
+      .attr('x1', d => d.source.x)
+      .attr('y1', d => d.source.y)
+      .attr('x2', d => d.target.x)
+      .attr('y2', d => d.target.y)
+
+    linkLabel
+      .attr('x', d => (d.source.x + d.target.x) / 2)
+      .attr('y', d => (d.source.y + d.target.y) / 2)
+
+    node.attr('transform', d => `translate(${d.x},${d.y})`)
+  })
+}
+
+watch(() => store.selectedNodeId, (nodeId) => {
+  if (nodeId) {
+    executeWalk()  // 点击节点自动执行游走
+  } else {
+    renderGraph()
+  }
+})
+
+onMounted(() => {
+  renderGraph()
+})
+</script>

+ 256 - 0
script/visualization/src/components/TreeView.vue

@@ -0,0 +1,256 @@
+<template>
+  <div class="flex flex-col h-full">
+    <!-- 头部 -->
+    <div class="flex items-center justify-between px-4 py-2 bg-base-300 text-xs text-base-content/60">
+      <span>人设树</span>
+      <div class="flex gap-2">
+        <span v-if="store.highlightedNodeIds.size > 0" class="text-primary">
+          已高亮 {{ store.highlightedNodeIds.size }} 个节点
+        </span>
+        <button @click="clearHighlight" class="btn btn-ghost btn-xs">清除高亮</button>
+      </div>
+    </div>
+
+    <!-- SVG 容器 -->
+    <div ref="containerRef" class="flex-1 overflow-auto bg-base-100">
+      <svg ref="svgRef" class="block"></svg>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, watch } from 'vue'
+import * as d3 from 'd3'
+import { useGraphStore } from '../stores/graph'
+
+const store = useGraphStore()
+
+const containerRef = ref(null)
+const svgRef = ref(null)
+
+// 维度颜色映射
+const dimColors = {
+  '人设': '#e94560',
+  '灵感点': '#f39c12',
+  '目的点': '#3498db',
+  '关键点': '#9b59b6'
+}
+
+// 节点元素映射
+let nodeElements = {}
+let currentRoot = null
+
+// 获取节点颜色
+function getNodeColor(d) {
+  if (d.data.type === '人设') return dimColors['人设']
+  return dimColors[d.data.dimension] || '#888'
+}
+
+// 处理节点点击
+function handleNodeClick(event, d) {
+  event.stopPropagation()
+  const nodeId = d.data.id
+  store.selectedNodeId = nodeId
+  // 清除之前的高亮,等待 GraphView 的游走配置
+  store.highlightedNodeIds = new Set([nodeId])
+  updateSelection()
+}
+
+// 渲染树(完整显示,不过滤)
+function renderTree() {
+  const svg = d3.select(svgRef.value)
+  svg.selectAll('*').remove()
+  nodeElements = {}
+
+  const treeData = store.treeData
+  if (!treeData || !treeData.id) return
+
+  // 创建层级数据
+  const root = d3.hierarchy(treeData)
+  currentRoot = root
+
+  // 智能计算树的尺寸
+  const allNodes = root.descendants()
+  const maxDepth = d3.max(allNodes, d => d.depth)
+  const leafCount = allNodes.filter(d => !d.children).length
+
+  // 高度:基于叶子节点数量
+  const treeHeight = Math.max(800, leafCount * 20 + 100)
+  // 宽度:根据深度
+  const treeWidth = Math.max(600, (maxDepth + 1) * 150 + 50)
+
+  svg.attr('width', treeWidth).attr('height', treeHeight + 50)
+
+  // 创建树布局
+  const treeLayout = d3.tree()
+    .size([treeHeight - 50, treeWidth - 100])
+    .separation((a, b) => a.parent === b.parent ? 1 : 1.2)
+
+  treeLayout(root)
+
+  // 创建主组
+  const g = svg.append('g')
+    .attr('transform', 'translate(25, 25)')
+
+  // 绘制边
+  g.append('g')
+    .attr('class', 'tree-edges')
+    .selectAll('.tree-link')
+    .data(root.links())
+    .join('path')
+    .attr('class', 'tree-link')
+    .attr('fill', 'none')
+    .attr('stroke', '#9b59b6')
+    .attr('stroke-opacity', 0.3)
+    .attr('stroke-width', 1)
+    .attr('d', d => {
+      const midX = (d.source.y + d.target.y) / 2
+      return `M${d.source.y},${d.source.x} C${midX},${d.source.x} ${midX},${d.target.x} ${d.target.y},${d.target.x}`
+    })
+
+  // 绘制节点
+  const nodes = g.append('g')
+    .attr('class', 'tree-nodes')
+    .selectAll('.tree-node')
+    .data(root.descendants())
+    .join('g')
+    .attr('class', d => {
+      let cls = 'tree-node'
+      if (store.selectedNodeId === d.data.id) cls += ' selected'
+      if (store.highlightedNodeIds.has(d.data.id)) cls += ' highlighted'
+      return cls
+    })
+    .attr('transform', d => `translate(${d.y},${d.x})`)
+    .style('cursor', 'pointer')
+    .on('click', handleNodeClick)
+
+  // 节点形状
+  nodes.each(function(d) {
+    const el = d3.select(this)
+    const nodeType = d.data.type
+    const nodeColor = getNodeColor(d)
+    const isRoot = d.depth === 0
+    const isDimension = ['灵感点', '目的点', '关键点'].includes(nodeType)
+
+    if (nodeType === '分类') {
+      el.append('rect')
+        .attr('class', 'tree-shape')
+        .attr('x', -4)
+        .attr('y', -4)
+        .attr('width', 8)
+        .attr('height', 8)
+        .attr('rx', 1)
+        .attr('fill', nodeColor)
+        .attr('stroke', 'rgba(255,255,255,0.5)')
+        .attr('stroke-width', 1)
+    } else {
+      const radius = isRoot ? 6 : (isDimension ? 5 : 3)
+      el.append('circle')
+        .attr('class', 'tree-shape')
+        .attr('r', radius)
+        .attr('fill', nodeColor)
+        .attr('stroke', 'rgba(255,255,255,0.5)')
+        .attr('stroke-width', 1)
+    }
+
+    nodeElements[d.data.id] = this
+  })
+
+  // 节点标签
+  nodes.append('text')
+    .attr('dy', '0.31em')
+    .attr('x', d => d.children ? -8 : 8)
+    .attr('text-anchor', d => d.children ? 'end' : 'start')
+    .attr('fill', d => {
+      const isRoot = d.depth === 0
+      const isDimension = ['灵感点', '目的点', '关键点'].includes(d.data.type)
+      return (isRoot || isDimension) ? getNodeColor(d) : '#bbb'
+    })
+    .attr('font-size', d => {
+      const isRoot = d.depth === 0
+      const isDimension = ['灵感点', '目的点', '关键点'].includes(d.data.type)
+      return isRoot ? '11px' : (isDimension ? '10px' : '9px')
+    })
+    .attr('font-weight', d => {
+      const isRoot = d.depth === 0
+      const isDimension = ['灵感点', '目的点', '关键点'].includes(d.data.type)
+      return (isRoot || isDimension) ? 'bold' : 'normal'
+    })
+    .text(d => {
+      const name = d.data.name
+      const maxLen = d.children ? 6 : 8
+      return name.length > maxLen ? name.slice(0, maxLen) + '…' : name
+    })
+}
+
+// 滚动到指定节点
+function scrollToNode(nodeId) {
+  const nodeEl = nodeElements[nodeId]
+  if (!nodeEl) return
+
+  const container = containerRef.value
+  const nodeRect = nodeEl.getBoundingClientRect()
+  const containerRect = container.getBoundingClientRect()
+
+  const scrollTop = container.scrollTop + nodeRect.top - containerRect.top - containerRect.height / 2
+  const scrollLeft = container.scrollLeft + nodeRect.left - containerRect.left - containerRect.width / 2
+
+  container.scrollTo({
+    top: Math.max(0, scrollTop),
+    left: Math.max(0, scrollLeft),
+    behavior: 'smooth'
+  })
+}
+
+// 更新选中/高亮状态
+function updateSelection() {
+  const svg = d3.select(svgRef.value)
+
+  svg.selectAll('.tree-node')
+    .classed('selected', d => store.selectedNodeId === d.data.id)
+    .classed('highlighted', d => store.highlightedNodeIds.has(d.data.id))
+
+  svg.selectAll('.tree-link')
+    .classed('highlighted', d => {
+      // 只有当边的两端都在高亮集合中时才高亮边
+      return store.highlightedNodeIds.has(d.source.data.id) &&
+             store.highlightedNodeIds.has(d.target.data.id)
+    })
+}
+
+// 清除高亮
+function clearHighlight() {
+  store.highlightedNodeIds = new Set()
+  store.selectedNodeId = null
+  updateSelection()
+}
+
+// 滚动到根节点
+function scrollToRoot() {
+  if (!currentRoot) return
+  const container = containerRef.value
+  if (!container) return
+
+  const rootY = currentRoot.x + 25
+  const targetScroll = rootY - container.clientHeight / 2
+  container.scrollTop = Math.max(0, targetScroll)
+}
+
+// 监听选中变化(从外部触发)
+watch(() => store.selectedNodeId, (nodeId, oldNodeId) => {
+  if (nodeId && nodeId !== oldNodeId) {
+    updateSelection()
+    scrollToNode(nodeId)
+  }
+})
+
+// 监听高亮变化(从 GraphView 游走结果同步)
+watch(() => store.highlightedNodeIds, () => {
+  updateSelection()
+}, { deep: true })
+
+onMounted(() => {
+  renderTree()
+  setTimeout(scrollToRoot, 100)
+})
+</script>

+ 8 - 0
script/visualization/src/main.js

@@ -0,0 +1,8 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import App from './App.vue'
+import './style.css'
+
+const app = createApp(App)
+app.use(createPinia())
+app.mount('#app')

+ 103 - 0
script/visualization/src/stores/graph.js

@@ -0,0 +1,103 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+
+// eslint-disable-next-line no-undef
+const graphDataRaw = __GRAPH_DATA__  // 由 vite.config.js 注入
+
+console.log('graphDataRaw loaded:', !!graphDataRaw)
+console.log('graphDataRaw.tree:', graphDataRaw?.tree)
+console.log('nodes count:', Object.keys(graphDataRaw?.nodes || {}).length)
+
+export const useGraphStore = defineStore('graph', () => {
+  // 原始数据
+  const graphData = ref(graphDataRaw || { nodes: {}, edges: {}, index: {}, tree: {} })
+
+  // 当前选中的节点
+  const selectedNodeId = ref(null)
+
+  // 高亮的节点ID集合
+  const highlightedNodeIds = ref(new Set())
+
+  // 获取节点
+  function getNode(nodeId) {
+    return graphData.value.nodes[nodeId]
+  }
+
+  // 获取邻居节点
+  function getNeighbors(nodeId, direction = 'both') {
+    const neighbors = []
+    const seen = new Set()
+    const index = graphData.value.index
+
+    if (direction === 'out' || direction === 'both') {
+      const outEdges = index.outEdges?.[nodeId] || {}
+      for (const [edgeType, targets] of Object.entries(outEdges)) {
+        for (const t of targets) {
+          if (!seen.has(t.target)) {
+            seen.add(t.target)
+            neighbors.push({
+              nodeId: t.target,
+              edgeType,
+              direction: 'out',
+              score: t.score
+            })
+          }
+        }
+      }
+    }
+
+    if (direction === 'in' || direction === 'both') {
+      const inEdges = index.inEdges?.[nodeId] || {}
+      for (const [edgeType, sources] of Object.entries(inEdges)) {
+        for (const s of sources) {
+          if (!seen.has(s.source)) {
+            seen.add(s.source)
+            neighbors.push({
+              nodeId: s.source,
+              edgeType,
+              direction: 'in',
+              score: s.score
+            })
+          }
+        }
+      }
+    }
+
+    return neighbors
+  }
+
+  // 选中节点
+  function selectNode(nodeId) {
+    selectedNodeId.value = nodeId
+
+    // 更新高亮节点(邻居)
+    const neighbors = getNeighbors(nodeId)
+    highlightedNodeIds.value = new Set(neighbors.map(n => n.nodeId))
+  }
+
+  // 清除选中
+  function clearSelection() {
+    selectedNodeId.value = null
+    highlightedNodeIds.value = new Set()
+  }
+
+  // 计算属性:当前选中节点的数据
+  const selectedNode = computed(() => {
+    return selectedNodeId.value ? getNode(selectedNodeId.value) : null
+  })
+
+  // 计算属性:树数据
+  const treeData = computed(() => graphData.value.tree)
+
+  return {
+    graphData,
+    selectedNodeId,
+    highlightedNodeIds,
+    selectedNode,
+    treeData,
+    getNode,
+    getNeighbors,
+    selectNode,
+    clearSelection
+  }
+})

+ 119 - 0
script/visualization/src/style.css

@@ -0,0 +1,119 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+/* 全局基础样式 */
+@layer base {
+  body {
+    @apply bg-base-300 overflow-hidden;
+  }
+
+  /* 自定义滚动条 */
+  ::-webkit-scrollbar {
+    @apply w-1.5 h-1.5;
+  }
+  ::-webkit-scrollbar-track {
+    @apply bg-transparent;
+  }
+  ::-webkit-scrollbar-thumb {
+    @apply bg-base-content/20 rounded;
+  }
+  ::-webkit-scrollbar-thumb:hover {
+    @apply bg-base-content/30;
+  }
+}
+
+/* D3相关样式 */
+@layer components {
+  /* 树节点 */
+  .tree-node {
+    cursor: pointer;
+  }
+
+  .tree-node circle,
+  .tree-node rect {
+    stroke-width: 2;
+    transition: all 0.2s;
+  }
+
+  .tree-node:hover circle,
+  .tree-node:hover rect {
+    filter: brightness(1.2);
+  }
+
+  .tree-node.selected circle,
+  .tree-node.selected rect {
+    stroke: #fff;
+    stroke-width: 3;
+  }
+
+  .tree-node.highlighted circle,
+  .tree-node.highlighted rect {
+    stroke: #2ecc71;
+    stroke-width: 2;
+  }
+
+  .tree-node text {
+    @apply text-xs;
+    fill: oklch(var(--bc));
+    pointer-events: none;
+  }
+
+  /* 树边 */
+  .tree-link {
+    fill: none;
+    stroke: #9b59b6;
+    stroke-opacity: 0.3;
+    stroke-width: 1;
+    transition: stroke-opacity 0.2s;
+  }
+
+  .tree-link.highlighted {
+    stroke-opacity: 0.8;
+    stroke-width: 2;
+  }
+
+  /* 图节点 */
+  .graph-node {
+    cursor: pointer;
+  }
+
+  .graph-node circle,
+  .graph-node rect {
+    stroke: #333;
+    stroke-width: 2;
+    transition: all 0.2s;
+  }
+
+  .graph-node.center circle,
+  .graph-node.center rect {
+    stroke: #fff;
+    stroke-width: 3;
+  }
+
+  .graph-node:hover circle,
+  .graph-node:hover rect {
+    filter: brightness(1.2);
+  }
+
+  .graph-node text {
+    @apply text-xs;
+    fill: oklch(var(--bc));
+    pointer-events: none;
+  }
+
+  /* 图边 */
+  .graph-link {
+    stroke-opacity: 0.6;
+    transition: stroke-opacity 0.2s;
+  }
+
+  .graph-link:hover {
+    stroke-opacity: 1;
+  }
+
+  .graph-link-label {
+    @apply text-xs;
+    fill: oklch(var(--bc) / 0.5);
+  }
+}

+ 22 - 0
script/visualization/tailwind.config.js

@@ -0,0 +1,22 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+  content: [
+    "./index.html",
+    "./src/**/*.{vue,js,ts,jsx,tsx}",
+  ],
+  theme: {
+    extend: {
+      colors: {
+        // 维度颜色
+        'dim-persona': '#e94560',
+        'dim-inspiration': '#f39c12',
+        'dim-purpose': '#3498db',
+        'dim-key': '#9b59b6',
+      }
+    },
+  },
+  plugins: [require("daisyui")],
+  daisyui: {
+    themes: ["dark"],
+  },
+}

+ 37 - 0
script/visualization/vite.config.js

@@ -0,0 +1,37 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { viteSingleFile } from 'vite-plugin-singlefile'
+import fs from 'fs'
+
+// 数据路径由 Python 通过环境变量传入
+const dataPath = process.env.GRAPH_DATA_PATH
+if (!dataPath) {
+  console.error('错误: 请设置 GRAPH_DATA_PATH 环境变量')
+  process.exit(1)
+}
+console.log('数据文件:', dataPath)
+
+// 读取 JSON 数据
+const graphData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
+console.log('节点数:', Object.keys(graphData.nodes || {}).length)
+console.log('边数:', (graphData.edges || []).length)
+
+export default defineConfig({
+  plugins: [vue(), viteSingleFile()],
+  define: {
+    // 将数据注入为全局常量
+    __GRAPH_DATA__: JSON.stringify(graphData)
+  },
+  build: {
+    target: 'esnext',
+    outDir: 'dist',
+    assetsInlineLimit: 100000000,
+    chunkSizeWarningLimit: 100000000,
+    cssCodeSplit: false,
+    rollupOptions: {
+      output: {
+        inlineDynamicImports: true
+      }
+    }
+  }
+})