优化社交关系图可读性并突出连接人数
This commit is contained in:
@@ -1295,6 +1295,73 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
if not selected_edges:
|
||||
return None
|
||||
|
||||
# 关系图改为“全员 + 主要边”策略:
|
||||
# 1. 用户希望不要限制图上的人数,因此节点仍尽量保留全量;
|
||||
# 2. 真正导致看不清的不是人数,而是边过多、边标签过多;
|
||||
# 3. 这里对展示边做一层稀疏化,只保留每个人的主要关系和全图最强的若干边。
|
||||
full_selected_edges = selected_edges
|
||||
display_edge_cap = max(18, int(len(selected_nodes) * 1.18))
|
||||
display_edge_cap = min(len(full_selected_edges), display_edge_cap)
|
||||
if len(full_selected_edges) > display_edge_cap:
|
||||
core_node_count = max(3, min(6, len(selected_nodes) // 8 + 2))
|
||||
core_nodes = set(selected_nodes[:core_node_count])
|
||||
node_degree_cap: Dict[str, int] = {
|
||||
uid: (6 if uid in core_nodes else 3)
|
||||
for uid in selected_nodes
|
||||
}
|
||||
edge_degree_map: Dict[str, int] = {uid: 0 for uid in selected_nodes}
|
||||
strongest_edge_by_node: Dict[str, Tuple[str, str, float, float, int, int]] = {}
|
||||
sorted_edges = sorted(
|
||||
full_selected_edges,
|
||||
key=lambda item: (
|
||||
-float(item[3]),
|
||||
-float(item[2]),
|
||||
-min(len(partner_map.get(item[0], set())), len(partner_map.get(item[1], set()))),
|
||||
item[0],
|
||||
item[1],
|
||||
),
|
||||
)
|
||||
|
||||
for edge in sorted_edges:
|
||||
a, b = edge[0], edge[1]
|
||||
if a not in strongest_edge_by_node:
|
||||
strongest_edge_by_node[a] = edge
|
||||
if b not in strongest_edge_by_node:
|
||||
strongest_edge_by_node[b] = edge
|
||||
|
||||
kept_edge_keys = set()
|
||||
pruned_edges: List[Tuple[str, str, float, float, int, int]] = []
|
||||
|
||||
def try_append_edge(edge_item: Tuple[str, str, float, float, int, int], ignore_degree_cap: bool = False) -> bool:
|
||||
edge_a, edge_b = edge_item[0], edge_item[1]
|
||||
edge_key = (edge_a, edge_b)
|
||||
if edge_key in kept_edge_keys:
|
||||
return False
|
||||
if not ignore_degree_cap:
|
||||
if edge_degree_map.get(edge_a, 0) >= node_degree_cap.get(edge_a, 3):
|
||||
return False
|
||||
if edge_degree_map.get(edge_b, 0) >= node_degree_cap.get(edge_b, 3):
|
||||
return False
|
||||
kept_edge_keys.add(edge_key)
|
||||
pruned_edges.append(edge_item)
|
||||
edge_degree_map[edge_a] = edge_degree_map.get(edge_a, 0) + 1
|
||||
edge_degree_map[edge_b] = edge_degree_map.get(edge_b, 0) + 1
|
||||
return True
|
||||
|
||||
# 先保证每个人至少能挂上一条自己最强的关系线,避免外围节点变成“孤点”。
|
||||
for uid in selected_nodes:
|
||||
edge = strongest_edge_by_node.get(uid)
|
||||
if edge:
|
||||
try_append_edge(edge, ignore_degree_cap=True)
|
||||
|
||||
# 再按全图强度补满,兼顾整体关系结构,但限制每个节点的展示边数。
|
||||
for edge in sorted_edges:
|
||||
if len(pruned_edges) >= display_edge_cap:
|
||||
break
|
||||
try_append_edge(edge, ignore_degree_cap=False)
|
||||
|
||||
selected_edges = pruned_edges
|
||||
|
||||
html_content = self._build_social_graph_html(group_id, selected_nodes, selected_edges, partner_map, node_score_map)
|
||||
if not html_content:
|
||||
return None
|
||||
@@ -1325,8 +1392,8 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
|
||||
import html
|
||||
|
||||
width = 1460
|
||||
height = 1040
|
||||
width = 1540
|
||||
height = 1120
|
||||
cx, cy = width // 2, height // 2 + 24
|
||||
node_count = len(selected_nodes)
|
||||
|
||||
@@ -1338,9 +1405,9 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
# 1. 核心节点(连接人数更多)放在内圈,外围节点逐层向外扩散;
|
||||
# 2. 每圈容量递增,避免所有人都挤在一个圆上;
|
||||
# 3. 外圈增加轻微抖动和椭圆拉伸,让大图下更分散,不会像等分钟表一样拥挤。
|
||||
inner_margin = 150.0
|
||||
outer_margin_x = 120.0
|
||||
outer_margin_y = 110.0
|
||||
inner_margin = 188.0
|
||||
outer_margin_x = 108.0
|
||||
outer_margin_y = 102.0
|
||||
max_rx = width * 0.5 - outer_margin_x
|
||||
max_ry = height * 0.5 - outer_margin_y
|
||||
ring_capacities: List[int] = []
|
||||
@@ -1348,9 +1415,9 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
ring_index = 0
|
||||
while placed < node_count:
|
||||
if ring_index == 0:
|
||||
capacity = min(4, node_count)
|
||||
capacity = min(3, node_count)
|
||||
elif ring_index == 1:
|
||||
capacity = 8
|
||||
capacity = 7
|
||||
else:
|
||||
capacity = 12 + ring_index * 6
|
||||
ring_capacities.append(capacity)
|
||||
@@ -1393,20 +1460,42 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
|
||||
cm = ContactManager.get_instance()
|
||||
edge_svg_parts: List[str] = []
|
||||
# 双向@标签层:
|
||||
# 1. 每条边只保留双向次数数字,避免标签信息量过大把图面压得太满;
|
||||
# 2. 仍保留白底半透明标签,确保在密集连线里也能看清数值。
|
||||
# 边数字标签只给“最重要的少数边”:
|
||||
# 1. 全边标数字时,图面会被 0/1、1/0 这类噪声淹没;
|
||||
# 2. 这里保留最强的一小部分边标签,其余只看粗细即可;
|
||||
# 3. 这样既能保留方向信息,也不至于牺牲整体结构可读性。
|
||||
edge_label_parts: List[str] = []
|
||||
max_labeled_edges = max(6, min(12, len(selected_nodes) // 4 + 3))
|
||||
labeled_edge_keys = {
|
||||
(edge[0], edge[1])
|
||||
for edge in sorted(
|
||||
selected_edges,
|
||||
key=lambda item: (-float(item[3]), -float(item[2]), item[0], item[1]),
|
||||
)[:max_labeled_edges]
|
||||
}
|
||||
|
||||
def _shorten_graph_nick(raw_name: str, max_len: int = 8) -> str:
|
||||
text = str(raw_name or "").strip()
|
||||
return text if len(text) <= max_len else f"{text[:max_len]}…"
|
||||
|
||||
def _estimate_label_units(raw_text: str) -> float:
|
||||
units = 0.0
|
||||
for ch in str(raw_text or ""):
|
||||
units += 1.0 if ord(ch) < 128 else 1.75
|
||||
return units
|
||||
|
||||
for edge_idx, (a, b, mention_count, score, a_to_b_count, b_to_a_count) in enumerate(selected_edges, start=1):
|
||||
ax, ay = pos_map[a]
|
||||
bx, by = pos_map[b]
|
||||
normalized = max(0.12, min(score / max_edge_score, 1.0))
|
||||
stroke_width = 1.0 + 7.0 * normalized
|
||||
opacity = 0.20 + 0.55 * normalized
|
||||
stroke_width = 1.2 + 6.2 * normalized
|
||||
opacity = 0.14 + 0.42 * normalized
|
||||
edge_svg_parts.append(
|
||||
f'<line x1="{ax:.1f}" y1="{ay:.1f}" x2="{bx:.1f}" y2="{by:.1f}" '
|
||||
f'stroke="rgba(33, 150, 243, {opacity:.3f})" stroke-width="{stroke_width:.2f}" />'
|
||||
)
|
||||
if (a, b) not in labeled_edge_keys:
|
||||
continue
|
||||
# 通过字典序固定方向,确保同一条边每次渲染文案方向一致。
|
||||
# 当前标签只保留双向互动数字,同时继续沿法线方向偏移,避免数字压在线条上。
|
||||
mx, my = (ax + bx) / 2.0, (ay + by) / 2.0
|
||||
@@ -1427,12 +1516,12 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
angle_deg += 180
|
||||
label_text = f"{int(a_to_b_count)}/{int(b_to_a_count)}"
|
||||
safe_label = html.escape(label_text)
|
||||
label_width = max(52.0, min(92.0, 10.0 * len(label_text) + 24.0))
|
||||
label_width = max(50.0, min(88.0, 10.0 * len(label_text) + 20.0))
|
||||
edge_label_parts.append(
|
||||
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'rx="7" ry="7" fill="rgba(255,255,255,0.84)"></rect>'
|
||||
f'<text x="0" y="4" text-anchor="middle" font-size="11" fill="#4E5F7D">{safe_label}</text>'
|
||||
f'</g>'
|
||||
)
|
||||
|
||||
@@ -1450,7 +1539,8 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
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))
|
||||
display_nick = _shorten_graph_nick(str(nick), 8)
|
||||
safe_nick = html.escape(display_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))
|
||||
@@ -1486,30 +1576,57 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
f'{html.escape(str(nick)[:1] or "?")}</text>'
|
||||
)
|
||||
|
||||
# 节点文案继续放在头像外侧,但整体收紧一圈:
|
||||
# 1. 用户反馈昵称和头像分得太开,这里缩短外偏移距离;
|
||||
# 2. 昵称字号调小,减少节点一多时的横向挤压;
|
||||
# 3. 统计行与昵称保持较近距离,让“头像-昵称-数据”看起来是一组。
|
||||
# “连接人数”改成贴在头像上的数字徽标:
|
||||
# 1. 用户反馈原先那行“连接x · 分数y”混在线条里看不清;
|
||||
# 2. 数字徽标直接挂在节点边缘,读图时更像“这个人连了多少人”;
|
||||
# 3. 图里移除分数展示,只保留更关键的连接人数,减少认知负担。
|
||||
badge_text = str(int(partner_count))
|
||||
badge_font_size = 11 if len(badge_text) <= 2 else 10
|
||||
badge_width = 16.0 + len(badge_text) * 7.5
|
||||
badge_height = 20.0
|
||||
badge_x = x + node_radius * 0.58 - badge_width / 2.0
|
||||
badge_y = y + node_radius * 0.55 - badge_height / 2.0
|
||||
node_svg_parts.append(
|
||||
f'<rect x="{badge_x:.1f}" y="{badge_y:.1f}" width="{badge_width:.1f}" height="{badge_height:.1f}" '
|
||||
f'rx="10" ry="10" fill="rgba(255,255,255,0.95)" stroke="{ring_color}" stroke-width="1.9"></rect>'
|
||||
)
|
||||
node_svg_parts.append(
|
||||
f'<text x="{badge_x + badge_width / 2.0:.1f}" y="{badge_y + 13.2:.1f}" text-anchor="middle" '
|
||||
f'font-size="{badge_font_size}" fill="#2F3B52" font-weight="700">{badge_text}</text>'
|
||||
)
|
||||
|
||||
# 节点文案只保留昵称,并加半透明底板:
|
||||
# 1. 去掉第二行数值后,名字可以更干净地贴在节点旁边;
|
||||
# 2. 底板能把名字从复杂连线背景里“托”出来;
|
||||
# 3. 长昵称做截断,避免外围节点之间互相顶住。
|
||||
outward_dx = math.cos(angle)
|
||||
outward_dy = math.sin(angle)
|
||||
label_gap = node_radius + (11.0 if ring_idx == 0 else 17.0 + ring_idx * 2.0)
|
||||
label_gap = node_radius + (14.0 if ring_idx == 0 else 18.0 + ring_idx * 2.0)
|
||||
title_x = x + outward_dx * label_gap
|
||||
title_y = y + outward_dy * label_gap
|
||||
info_x = x + outward_dx * (label_gap + 12.0)
|
||||
info_y = y + outward_dy * (label_gap + 12.0) + 1.5
|
||||
if abs(outward_dx) < 0.20:
|
||||
anchor = "middle"
|
||||
elif outward_dx > 0:
|
||||
anchor = "start"
|
||||
else:
|
||||
anchor = "end"
|
||||
label_units = _estimate_label_units(display_nick)
|
||||
label_width = max(44.0, min(156.0, 10.0 * label_units + 18.0))
|
||||
label_height = 24.0
|
||||
if anchor == "middle":
|
||||
label_box_x = title_x - label_width / 2.0
|
||||
elif anchor == "start":
|
||||
label_box_x = title_x - 8.0
|
||||
else:
|
||||
label_box_x = title_x - label_width + 8.0
|
||||
label_box_y = title_y - 18.0
|
||||
node_svg_parts.append(
|
||||
f'<text x="{title_x:.1f}" y="{title_y:.1f}" text-anchor="{anchor}" '
|
||||
f'font-size="16" fill="#2F3B52" font-weight="700">{safe_nick}</text>'
|
||||
f'<rect x="{label_box_x:.1f}" y="{label_box_y:.1f}" width="{label_width:.1f}" height="{label_height:.1f}" '
|
||||
f'rx="12" ry="12" fill="rgba(255,255,255,0.78)"></rect>'
|
||||
)
|
||||
node_svg_parts.append(
|
||||
f'<text x="{info_x:.1f}" y="{info_y:.1f}" text-anchor="{anchor}" '
|
||||
f'font-size="12" fill="#6C7A96">连接{partner_count} · {score:.1f}</text>'
|
||||
f'<text x="{title_x:.1f}" y="{title_y:.1f}" text-anchor="{anchor}" '
|
||||
f'font-size="14.5" fill="#2F3B52" font-weight="700">{safe_nick}</text>'
|
||||
)
|
||||
|
||||
group_title = html.escape(ContactManager.get_instance().get_nickname(group_id) or group_id)
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="legend">
|
||||
说明:节点越大代表连接群友越多;连线越粗代表互动越强。节点优先显示成员头像,缺失头像时自动回退昵称首字。
|
||||
说明:节点越大代表连接群友越多;头像右下角数字是连接人数;连线越粗代表互动越强。为保证可读性,图中仅保留主要关系线,边上的数字只标最强的一小部分关系。
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user