优化身价功能社交关系图布局与展示策略
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'<g transform="translate({label_x:.1f},{label_y:.1f})">'
|
||||
f'<rect x="-170" y="-12" width="340" height="24" rx="8" ry="8" fill="rgba(255,255,255,0.82)"></rect>'
|
||||
f'<text x="0" y="5" text-anchor="middle" font-size="12" fill="#4E5F7D">{safe_label}</text>'
|
||||
f'<g transform="translate({label_x:.1f},{label_y:.1f}) rotate({angle_deg:.1f})">'
|
||||
f'<rect x="-{label_width / 2:.1f}" y="-11" width="{label_width:.1f}" height="22" '
|
||||
f'rx="7" ry="7" fill="rgba(255,255,255,0.78)"></rect>'
|
||||
f'<text x="0" y="4" text-anchor="middle" font-size="11.5" fill="#4E5F7D">{safe_label}</text>'
|
||||
f'</g>'
|
||||
)
|
||||
|
||||
@@ -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 "?")}</text>'
|
||||
)
|
||||
|
||||
# 节点文案放到节点外侧:
|
||||
# 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'<text x="{x:.1f}" y="{y + node_radius + 22:.1f}" text-anchor="middle" '
|
||||
f'<text x="{title_x:.1f}" y="{title_y:.1f}" text-anchor="{anchor}" '
|
||||
f'font-size="19" fill="#2F3B52" font-weight="700">{safe_nick}</text>'
|
||||
)
|
||||
node_svg_parts.append(
|
||||
f'<text x="{x:.1f}" y="{y + node_radius + 44:.1f}" text-anchor="middle" '
|
||||
f'<text x="{info_x:.1f}" y="{info_y:.1f}" text-anchor="{anchor}" '
|
||||
f'font-size="14" fill="#6C7A96">连接{partner_count}人 · 互动{score:.1f}</text>'
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user