diff --git a/plugins/value_rank/config.toml b/plugins/value_rank/config.toml index 58f063f..3eb5502 100644 --- a/plugins/value_rank/config.toml +++ b/plugins/value_rank/config.toml @@ -31,8 +31,12 @@ base_score_scale = 1000 # 排行默认展示数量 default_rank_limit = 10 max_rank_limit = 50 -default_graph_nodes = 12 -max_graph_nodes = 24 +# 社交关系图默认按全量节点绘制: +# 1. 0 表示不截断,尽量展示所有关系节点; +# 2. 用户若显式传人数参数,仍可只看局部; +# 3. max_graph_nodes 保留为兼容字段,0 表示不再设置硬上限。 +default_graph_nodes = 0 +max_graph_nodes = 0 graph_edge_pool_limit = 300 social_graph_template_path = "plugins/value_rank/templates/social_graph.html" text_auto_revoke_seconds = 80 diff --git a/plugins/value_rank/main.py b/plugins/value_rank/main.py index a1ff0a8..bf22cda 100644 --- a/plugins/value_rank/main.py +++ b/plugins/value_rank/main.py @@ -555,8 +555,12 @@ class ValueRankPlugin(MessagePluginInterface): self.default_rank_limit = 10 self.max_rank_limit = 50 - self.default_graph_nodes = 12 - self.max_graph_nodes = 24 + # 社交关系图默认改成“尽量全量展示”: + # 1. 用户希望图上不再限制人数; + # 2. 这里用 0 表示“不截断”,后续解析层统一识别; + # 3. 如果用户手动传了人数参数,仍然允许看局部子图。 + self.default_graph_nodes = 0 + self.max_graph_nodes = 0 self.graph_edge_pool_limit = 300 self.social_graph_template_path = "plugins/value_rank/templates/social_graph.html" self.text_auto_revoke_seconds = 80 @@ -1194,7 +1198,7 @@ class ValueRankPlugin(MessagePluginInterface): 设计说明: 1. 数据层使用近窗口期社交边聚合结果,避免扫全量消息; - 2. 先做无向边合并,再筛选“连接度高”的核心节点,控制图复杂度; + 2. 先做无向边合并,再按“连接度高优先”构建分层图,让核心节点更靠近中心; 3. 通过 markdown2image 的 HTML 截图能力输出图片,统一渲染链路。 """ if not self.db: @@ -1233,7 +1237,10 @@ class ValueRankPlugin(MessagePluginInterface): if not edge_map: return None - # 使用“伙伴去重数 + 互动分”混合排序选核心节点,让图既看覆盖面也看互动强度。 + # 使用“伙伴去重数 + 互动分”混合排序: + # 1. 连接人数越多,说明越像群里的桥梁节点; + # 2. 同连接数下再用互动分排序,避免只看“浅连接”; + # 3. 后续布局时也复用这份排序,让高连接节点自然更靠近中心。 sorted_nodes = sorted( list(partner_map.keys()), key=lambda uid: ( @@ -1242,7 +1249,14 @@ class ValueRankPlugin(MessagePluginInterface): uid, ), ) - selected_nodes = sorted_nodes[:max_nodes] + # 不再对关系图人数做硬截断: + # 1. 用户明确希望“不要限制图上人数”; + # 2. max_nodes 现在只作为“用户请求值”,当传 0 或很大时都允许尽量展示全量; + # 3. 若请求人数小于全量,则仍尊重用户显式指定,方便只看局部图。 + if max_nodes > 0: + selected_nodes = sorted_nodes[:max_nodes] + else: + selected_nodes = sorted_nodes selected_set = set(selected_nodes) if len(selected_nodes) < 2: return None @@ -1311,22 +1325,71 @@ class ValueRankPlugin(MessagePluginInterface): import html - width = 1300 - height = 900 - cx, cy = width // 2, height // 2 + 10 - radius = min(width, height) * 0.33 + width = 1460 + height = 1040 + cx, cy = width // 2, height // 2 + 24 node_count = len(selected_nodes) - # 计算节点坐标:使用圆形布局,稳定且不依赖额外图算法库。 - pos_map: Dict[str, Tuple[float, float]] = {} - for idx, uid in enumerate(selected_nodes): - angle = (2.0 * math.pi * idx) / max(node_count, 1) - x = cx + radius * math.cos(angle) - y = cy + radius * math.sin(angle) - pos_map[uid] = (x, y) - max_edge_score = max([edge[3] for edge in selected_edges] + [1.0]) max_partner_count = max([len(partner_map.get(uid, set())) for uid in selected_nodes] + [1]) + max_node_score = max([float(node_score_map.get(uid, 0.0)) for uid in selected_nodes] + [1.0]) + + # 多环分层布局: + # 1. 核心节点(连接人数更多)放在内圈,外围节点逐层向外扩散; + # 2. 每圈容量递增,避免所有人都挤在一个圆上; + # 3. 外圈增加轻微抖动和椭圆拉伸,让大图下更分散,不会像等分钟表一样拥挤。 + inner_margin = 150.0 + outer_margin_x = 120.0 + outer_margin_y = 110.0 + max_rx = width * 0.5 - outer_margin_x + max_ry = height * 0.5 - outer_margin_y + ring_capacities: List[int] = [] + placed = 0 + ring_index = 0 + while placed < node_count: + if ring_index == 0: + capacity = min(4, node_count) + elif ring_index == 1: + capacity = 8 + else: + capacity = 12 + ring_index * 6 + ring_capacities.append(capacity) + placed += capacity + ring_index += 1 + ring_count = len(ring_capacities) + if ring_count <= 1: + ring_radii = [(inner_margin, inner_margin * 0.82)] + else: + ring_radii = [] + for idx in range(ring_count): + progress = idx / max(ring_count - 1, 1) + rx = inner_margin + (max_rx - inner_margin) * progress + ry = inner_margin * 0.82 + (max_ry - inner_margin * 0.82) * progress + ring_radii.append((rx, ry)) + + pos_map: Dict[str, Tuple[float, float]] = {} + node_meta_map: Dict[str, Dict[str, float]] = {} + cursor = 0 + for ring_idx, capacity in enumerate(ring_capacities): + ring_nodes = selected_nodes[cursor: cursor + capacity] + cursor += len(ring_nodes) + if not ring_nodes: + continue + rx, ry = ring_radii[min(ring_idx, len(ring_radii) - 1)] + angle_offset = (ring_idx % 2) * (math.pi / max(len(ring_nodes), 3)) + for idx, uid in enumerate(ring_nodes): + angle = angle_offset + (2.0 * math.pi * idx) / max(len(ring_nodes), 1) + # 外圈轻微抖动,避免文本和头像在同一环上严格对齐后互相压住。 + wobble = 1.0 + (0.03 * ((idx % 3) - 1) if ring_idx >= 1 else 0.0) + x = cx + rx * wobble * math.cos(angle) + y = cy + ry * wobble * math.sin(angle) + pos_map[uid] = (x, y) + node_meta_map[uid] = { + "ring_idx": float(ring_idx), + "angle": angle, + "rx": rx, + "ry": ry, + } cm = ContactManager.get_instance() edge_svg_parts: List[str] = [] @@ -1352,12 +1415,17 @@ class ValueRankPlugin(MessagePluginInterface): edge_len = max(math.hypot(dx, dy), 1.0) # 线段法线单位向量(逆时针旋转90度)。 nx, ny = (-dy / edge_len), (dx / edge_len) - # 交错方向,减少多条边标签都挤在同一侧导致重叠。 + # 标签改为“沿边平行显示”: + # 1. 旋转到与连线同方向,文本阅读会比水平浮在中点附近更顺; + # 2. 仍沿法线做少量偏移,防止字直接压在线条上; + # 3. 交错放在边两侧,减少多条边平行时的标签重叠。 side_sign = 1.0 if (edge_idx % 2 == 0) else -1.0 - # 偏移距离与线宽相关,至少 22 像素,保证不贴线。 - offset = max(22.0, 14.0 + stroke_width * 2.4) + offset = max(18.0, 10.0 + stroke_width * 2.0) label_x = mx + nx * offset * side_sign label_y = my + ny * offset * side_sign + angle_deg = math.degrees(math.atan2(dy, dx)) + if angle_deg > 90 or angle_deg < -90: + angle_deg += 180 nick_a = cm.get_group_name(group_id, a) or a nick_b = cm.get_group_name(group_id, b) or b @@ -1376,10 +1444,12 @@ class ValueRankPlugin(MessagePluginInterface): label_text = f"{nick_a}→{nick_b} {a_to_b_count}/{b_to_a_count} | {relation_tag}" safe_label = html.escape(label_text) + label_width = max(140.0, min(360.0, 8.0 * len(label_text) + 34.0)) edge_label_parts.append( - f'' - f'' - f'{safe_label}' + f'' + f'' + f'{safe_label}' f'' ) @@ -1392,11 +1462,15 @@ class ValueRankPlugin(MessagePluginInterface): x, y = pos_map[uid] partner_count = len(partner_map.get(uid, set())) score = float(node_score_map.get(uid, 0.0)) + # 节点大小继续体现连接人数,但收一点上限,避免大节点把标签区挤爆。 size_norm = max(0.15, min(partner_count / max_partner_count, 1.0)) - node_radius = 18.0 + 24.0 * size_norm + score_norm = max(0.10, min(score / max(max_node_score, 1.0), 1.0)) + node_radius = 18.0 + 18.0 * size_norm + 6.0 * score_norm nick = cm.get_group_name(group_id, uid) or uid safe_nick = html.escape(str(nick)) avatar_url = str(cm.get_head_image(uid) or "").strip() + ring_idx = int(node_meta_map.get(uid, {}).get("ring_idx", 0)) + angle = float(node_meta_map.get(uid, {}).get("angle", 0.0)) # 使用“连接度越高环线越偏暖”的视觉策略,帮助快速识别核心节点。 ring_color = "rgba(255, 152, 0, 0.95)" if size_norm >= 0.6 else "rgba(79, 123, 201, 0.95)" @@ -1429,12 +1503,29 @@ class ValueRankPlugin(MessagePluginInterface): f'{html.escape(str(nick)[:1] or "?")}' ) + # 节点文案放到节点外侧: + # 1. 外圈节点文本沿“从中心指向外部”的方向偏移,减少互相压住; + # 2. 内圈节点仍保持较近距离,保证中心区域不会炸开; + # 3. 文本锚点根据左右半区自动切换,让阅读方向更自然。 + outward_dx = math.cos(angle) + outward_dy = math.sin(angle) + label_gap = node_radius + (18.0 if ring_idx == 0 else 28.0 + ring_idx * 4.0) + title_x = x + outward_dx * label_gap + title_y = y + outward_dy * label_gap + info_x = x + outward_dx * (label_gap + 22.0) + info_y = y + outward_dy * (label_gap + 22.0) + if abs(outward_dx) < 0.20: + anchor = "middle" + elif outward_dx > 0: + anchor = "start" + else: + anchor = "end" node_svg_parts.append( - f'{safe_nick}' ) node_svg_parts.append( - f'连接{partner_count}人 · 互动{score:.1f}' ) @@ -1762,7 +1853,13 @@ class ValueRankPlugin(MessagePluginInterface): return min(days, self.max_trend_days) def _parse_graph_nodes(self, content: str) -> int: - """解析社交关系图节点数量参数。""" + """解析社交关系图节点数量参数。 + + 规则说明: + 1. 不传人数时,默认返回 0,表示不截断、尽量全量展示; + 2. 传人数时仍允许只看局部,但不再强制套 max_graph_nodes 上限; + 3. 最小保留 6,避免用户传太小导致图失去可读性。 + """ parts = content.split() if len(parts) < 2: return self.default_graph_nodes @@ -1770,8 +1867,12 @@ class ValueRankPlugin(MessagePluginInterface): node_count = int(parts[1]) except Exception: return self.default_graph_nodes + if node_count <= 0: + return 0 node_count = max(6, node_count) - return min(node_count, self.max_graph_nodes) + if self.max_graph_nodes > 0: + return min(node_count, self.max_graph_nodes) + return node_count @staticmethod def _normalize_linear(value: float, ceiling: float) -> float: