first
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
buildGraphRendererAdapterData,
|
||||
buildGraphRendererBehaviorContract,
|
||||
type GraphRendererBehaviorContract
|
||||
} from "../src/render";
|
||||
import type {
|
||||
GraphAggregationMarker,
|
||||
GraphData,
|
||||
GraphFocusInput,
|
||||
GraphPinHint,
|
||||
GraphTypeFilters,
|
||||
NodeId,
|
||||
PinMap,
|
||||
SelectionInput
|
||||
} from "../src/types";
|
||||
|
||||
export interface SigmaTrialOptions {
|
||||
pins?: PinMap;
|
||||
selection?: SelectionInput | null;
|
||||
searchResultIds?: NodeId[];
|
||||
aggregationMarkers?: GraphAggregationMarker[];
|
||||
focus?: GraphFocusInput;
|
||||
typeFilters?: GraphTypeFilters;
|
||||
}
|
||||
|
||||
export interface SigmaTrialDrawerItem {
|
||||
id: string;
|
||||
label: string;
|
||||
meta: string;
|
||||
}
|
||||
|
||||
export interface SigmaTrialDrawerPayload {
|
||||
kind: "node" | "community" | "global";
|
||||
title: string;
|
||||
kicker: string;
|
||||
// Representative workbench-like drawer facts (counts the React drawer shows).
|
||||
facts: { label: string; value: string }[];
|
||||
// Representative list rows (core nodes / pinned nodes / search hits), capped.
|
||||
items: SigmaTrialDrawerItem[];
|
||||
}
|
||||
|
||||
export interface SigmaTrialModel {
|
||||
nodes: SigmaTrialNode[];
|
||||
edges: SigmaTrialEdge[];
|
||||
communities: SigmaTrialCommunity[];
|
||||
aggregations: SigmaTrialAggregation[];
|
||||
// Pre-computed drawer payloads so the trial HTML renders a workbench-weight
|
||||
// summary card instead of a single text node.
|
||||
drawer: {
|
||||
global: SigmaTrialDrawerPayload;
|
||||
nodes: Record<string, SigmaTrialDrawerPayload>;
|
||||
communities: Record<string, SigmaTrialDrawerPayload>;
|
||||
};
|
||||
behavior: GraphRendererBehaviorContract;
|
||||
}
|
||||
|
||||
export interface SigmaTrialNode {
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
color: string;
|
||||
communityId: string | null;
|
||||
sourcePath: string;
|
||||
selected: boolean;
|
||||
searchHit: boolean;
|
||||
pinned: boolean;
|
||||
pinHint: GraphPinHint;
|
||||
aggregationIds: string[];
|
||||
}
|
||||
|
||||
export interface SigmaTrialEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
color: string;
|
||||
size: number;
|
||||
relationType: string | null;
|
||||
}
|
||||
|
||||
export interface SigmaTrialCommunity {
|
||||
id: string;
|
||||
label: string;
|
||||
nodeIds: string[];
|
||||
selected: boolean;
|
||||
searchResultIds: string[];
|
||||
pinnedNodeIds: string[];
|
||||
}
|
||||
|
||||
export interface SigmaTrialAggregation {
|
||||
id: string;
|
||||
communityId: string | null;
|
||||
nodeIds: string[];
|
||||
selectedNodeIds: string[];
|
||||
searchResultIds: string[];
|
||||
pinnedNodeIds: string[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
const COMMUNITY_COLORS = [
|
||||
"#2563eb",
|
||||
"#059669",
|
||||
"#d97706",
|
||||
"#7c3aed",
|
||||
"#dc2626",
|
||||
"#0891b2",
|
||||
"#4f46e5",
|
||||
"#65a30d"
|
||||
];
|
||||
|
||||
export function buildSigmaGraphologyTrialModel(data: GraphData, options: SigmaTrialOptions = {}): SigmaTrialModel {
|
||||
// The trial model must consume adapter-controlled render data only. Node and
|
||||
// edge budgets (which objects exist, their positions, sizes, colors, label
|
||||
// visibility, selection/search/pin/aggregation state) all come from the
|
||||
// adapter output; the raw GraphData is no longer traversed to decide what to
|
||||
// draw. 'data' is still threaded to the adapter for graph semantics.
|
||||
const adapter = buildGraphRendererAdapterData(data, options);
|
||||
|
||||
const nodes = adapter.nodes.map((node): SigmaTrialNode => {
|
||||
const communityId = node.communityId == null ? null : String(node.communityId);
|
||||
return {
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
x: finiteNumber(node.point.x, 0),
|
||||
y: finiteNumber(node.point.y, 0),
|
||||
size: trialNodeSize(node),
|
||||
color: trialNodeColor(node, communityId),
|
||||
communityId,
|
||||
sourcePath: node.sourcePath,
|
||||
selected: node.selected,
|
||||
searchHit: node.searchHit,
|
||||
pinned: node.pinHint.pinned,
|
||||
pinHint: node.pinHint,
|
||||
aggregationIds: node.aggregationIds
|
||||
};
|
||||
});
|
||||
|
||||
const edges = adapter.edges.map((edge): SigmaTrialEdge => ({
|
||||
id: edge.id,
|
||||
source: edge.sourceNodeId,
|
||||
target: edge.targetNodeId,
|
||||
color: "#9ca3af",
|
||||
size: finiteNumber(edge.render.strokeWidth, 1),
|
||||
relationType: edge.relationType == null ? null : String(edge.relationType)
|
||||
}));
|
||||
|
||||
const communities = adapter.communities.map((community): SigmaTrialCommunity => ({
|
||||
id: community.id,
|
||||
label: community.label,
|
||||
nodeIds: community.nodeIds,
|
||||
selected: community.selected,
|
||||
searchResultIds: community.searchResultIds,
|
||||
pinnedNodeIds: community.pinHints.map((hint) => hint.nodeId)
|
||||
}));
|
||||
|
||||
const aggregations = adapter.aggregations.map((aggregation): SigmaTrialAggregation => ({
|
||||
id: aggregation.id,
|
||||
communityId: aggregation.communityId == null ? null : String(aggregation.communityId),
|
||||
nodeIds: aggregation.nodeIds,
|
||||
selectedNodeIds: aggregation.selectedNodeIds,
|
||||
searchResultIds: aggregation.searchResultIds,
|
||||
pinnedNodeIds: aggregation.pinnedNodeIds,
|
||||
totalCount: aggregation.totalCount
|
||||
}));
|
||||
|
||||
const drawer = buildTrialDrawer(adapter);
|
||||
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
communities,
|
||||
aggregations,
|
||||
drawer,
|
||||
behavior: buildGraphRendererBehaviorContract(adapter, "candidate-global")
|
||||
};
|
||||
}
|
||||
|
||||
// Representative workbench-weight drawer payloads derived only from adapter
|
||||
// render data. The trial HTML renders these as a real summary card + list so the
|
||||
// drawer/overlay DOM cost is comparable to the production GraphSummaryDrawer.
|
||||
function buildTrialDrawer(adapter: ReturnType<typeof buildGraphRendererAdapterData>): SigmaTrialModel["drawer"] {
|
||||
const adjacency = new Map<string, number>();
|
||||
for (const edge of adapter.edges) {
|
||||
adjacency.set(edge.sourceNodeId, (adjacency.get(edge.sourceNodeId) ?? 0) + 1);
|
||||
adjacency.set(edge.targetNodeId, (adjacency.get(edge.targetNodeId) ?? 0) + 1);
|
||||
}
|
||||
const trim = (values: string[]): string[] => values.slice(0, 8);
|
||||
|
||||
const nodePayloads: Record<string, SigmaTrialDrawerPayload> = {};
|
||||
for (const node of adapter.nodes) {
|
||||
const meta = node.type ? String(node.type) : "node";
|
||||
nodePayloads[node.id] = {
|
||||
kind: "node",
|
||||
kicker: "节点",
|
||||
title: node.label || node.id,
|
||||
facts: [
|
||||
{ label: "类型", value: meta },
|
||||
{ label: "连接", value: String(adjacency.get(node.id) ?? 0) },
|
||||
{ label: "社区", value: node.communityId == null ? "—" : String(node.communityId) }
|
||||
],
|
||||
items: trim(node.aggregationIds).map((id) => ({ id, label: id, meta: "aggregation" }))
|
||||
};
|
||||
}
|
||||
|
||||
const communityPayloads: Record<string, SigmaTrialDrawerPayload> = {};
|
||||
for (const community of adapter.communities) {
|
||||
const labelById = new Map(adapter.nodes.map((node) => [node.id, node.label || node.id]));
|
||||
const coreNodes = community.nodeIds
|
||||
.map((id) => ({ id, label: labelById.get(id) ?? id, degree: adjacency.get(id) ?? 0 }))
|
||||
.sort((a, b) => b.degree - a.degree);
|
||||
communityPayloads[community.id] = {
|
||||
kind: "community",
|
||||
kicker: "社区",
|
||||
title: community.label || community.id,
|
||||
facts: [
|
||||
{ label: "节点数", value: String(community.nodeCount) },
|
||||
{ label: "命中", value: String(community.searchResultIds.length) },
|
||||
{ label: "置顶", value: String(community.pinHints.length) }
|
||||
],
|
||||
items: trim(coreNodes.map((node) => ({ id: node.id, label: node.label, meta: "core" })))
|
||||
};
|
||||
}
|
||||
|
||||
const globalPayload: SigmaTrialDrawerPayload = {
|
||||
kind: "global",
|
||||
kicker: "全局",
|
||||
title: "全局图谱概览",
|
||||
facts: [
|
||||
{ label: "节点", value: String(adapter.counts.nodes) },
|
||||
{ label: "边", value: String(adapter.counts.edges) },
|
||||
{ label: "社区", value: String(adapter.communities.length) }
|
||||
],
|
||||
items: trim(adapter.communities.map((community) => ({ id: community.id, label: community.label || community.id, meta: "community" })))
|
||||
};
|
||||
|
||||
return { global: globalPayload, nodes: nodePayloads, communities: communityPayloads };
|
||||
}
|
||||
|
||||
// Visual budget for a trial node, derived only from adapter render state.
|
||||
function trialNodeSize(node: { pinHint: GraphPinHint; selected: boolean; searchHit: boolean; render: { priority: number } }): number {
|
||||
if (node.pinHint.pinned) return 5;
|
||||
if (node.selected) return 5;
|
||||
if (node.searchHit) return 4;
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Color budget for a trial node, derived only from adapter render state.
|
||||
function trialNodeColor(node: { selected: boolean; searchHit: boolean }, communityId: string | null): string {
|
||||
if (node.selected) return "#ef4444";
|
||||
if (node.searchHit) return "#f59e0b";
|
||||
return colorForCommunity(communityId);
|
||||
}
|
||||
|
||||
function colorForCommunity(communityId: string | null): string {
|
||||
if (!communityId) return "#64748b";
|
||||
const index = Math.abs(hashString(communityId)) % COMMUNITY_COLORS.length;
|
||||
return COMMUNITY_COLORS[index];
|
||||
}
|
||||
|
||||
function hashString(value: string): number {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = ((hash << 5) - hash + value.charCodeAt(index)) | 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
Reference in New Issue
Block a user