feat: update knowledge and agent UI
This commit is contained in:
1 parent
bebe4feccf
commit
7efd811e75
27 files changed
+2949
-782
No files matched your search
@@ -20,6 +20,7 @@ import type {
|
||||
import {
|
||||
applyCircularLayout,
|
||||
ForceSimulation,
|
||||
getEdgeId,
|
||||
getEdgeInfo,
|
||||
getEdgeLabel,
|
||||
getLinkDistance,
|
||||
@@ -56,8 +57,20 @@ interface GraphCanvasProps {
|
||||
onSelectNode: (id: string | null) => void;
|
||||
onTooltip: (tooltip: TooltipState | null) => void;
|
||||
onZoomChange: (k: number) => void;
|
||||
/**
|
||||
* Explore-mode rendering context; null/undefined renders the normal full view.
|
||||
* preview = no root picked yet: nodes are shown faded as pickable起点 candidates.
|
||||
*/
|
||||
explore?: {
|
||||
preview: boolean;
|
||||
rootId: string | null;
|
||||
nodeDepthMap: Map<string, number>;
|
||||
expandedNodeIds: Set<string>;
|
||||
} | null;
|
||||
/** Floating pills rendered top-left over the canvas. */
|
||||
toolbar?: ReactNode;
|
||||
/** Small floating hint rendered bottom-center over the canvas. */
|
||||
hint?: ReactNode;
|
||||
/** Centered overlay (empty / loading states). */
|
||||
overlay?: ReactNode;
|
||||
}
|
||||
@@ -153,7 +166,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
if (!source || !target) continue;
|
||||
const label = getEdgeLabel(e);
|
||||
simEdges.push({
|
||||
id: e.id ?? `${e.source}->${e.target}:${label}`,
|
||||
id: getEdgeId(e),
|
||||
source,
|
||||
target,
|
||||
label,
|
||||
@@ -213,7 +226,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
// Redraw on highlight-affecting prop changes.
|
||||
useEffect(() => {
|
||||
requestDraw();
|
||||
}, [props.selectedNodeId, props.searchMatchIds, props.showLabels, requestDraw]);
|
||||
}, [
|
||||
props.selectedNodeId,
|
||||
props.searchMatchIds,
|
||||
props.showLabels,
|
||||
props.explore,
|
||||
requestDraw,
|
||||
]);
|
||||
|
||||
// ------------------------------------------------------------ transform
|
||||
|
||||
@@ -361,6 +380,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const neighbors = activeId ? adjacencyRef.current.get(activeId) : undefined;
|
||||
const search = p.searchMatchIds;
|
||||
const k = t.k || 1;
|
||||
const explore = p.explore;
|
||||
|
||||
// ---- edges
|
||||
for (const e of sim.edges) {
|
||||
@@ -373,6 +393,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
alpha = search.has(e.source.id) || search.has(e.target.id) ? 0.75 : 0.06;
|
||||
else alpha = 0.38;
|
||||
|
||||
if (explore && !explore.preview) {
|
||||
const sourceDepth = explore.nodeDepthMap.get(e.source.id) ?? 0;
|
||||
const targetDepth = explore.nodeDepthMap.get(e.target.id) ?? 0;
|
||||
const edgeDepth = Math.max(sourceDepth, targetDepth);
|
||||
alpha *= Math.max(0.48, 1 - edgeDepth * 0.12);
|
||||
}
|
||||
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = e.info.color;
|
||||
ctx.lineWidth = (touchesActive ? 2.4 : 1.2) / k;
|
||||
@@ -405,33 +432,73 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
const isActive = n.id === activeId;
|
||||
const isSelected = n.id === p.selectedNodeId;
|
||||
const isNeighbor = neighbors?.has(n.id) ?? false;
|
||||
const depth = explore?.nodeDepthMap.get(n.id);
|
||||
const isExploreRoot = explore?.rootId === n.id;
|
||||
const isExpanded = explore?.expandedNodeIds.has(n.id) ?? false;
|
||||
let alpha: number;
|
||||
if (activeId) alpha = isActive || isNeighbor ? 1 : 0.14;
|
||||
else if (search) alpha = search.has(n.id) ? 1 : 0.14;
|
||||
else alpha = 1;
|
||||
|
||||
const r = n.radius * (n.id === hover?.id ? 1.15 : 1);
|
||||
let baseRadius = n.radius;
|
||||
let labelByExplore = false;
|
||||
let strokeWidth = 1.4 / k;
|
||||
let strokeColor = "rgba(2, 6, 23, 0.55)";
|
||||
let fillColor = n.color;
|
||||
if (explore?.preview) {
|
||||
alpha *= 0.42;
|
||||
baseRadius = Math.max(8, n.radius * 0.86);
|
||||
labelByExplore = isActive || isSelected;
|
||||
} else if (explore && depth != null) {
|
||||
baseRadius = depth === 0 ? 34 : Math.max(14, 28 - depth * 4);
|
||||
alpha *= Math.max(0.45, 1 - depth * 0.12);
|
||||
labelByExplore = depth <= 2 || isActive || isSelected;
|
||||
strokeWidth = (depth === 0 ? 4 : isExpanded ? 3 : 1.5) / k;
|
||||
strokeColor = depth === 0 ? "#e0f2fe" : isExpanded ? "#7dd3fc" : "rgba(2, 6, 23, 0.55)";
|
||||
if (isExploreRoot) fillColor = "#38bdf8";
|
||||
}
|
||||
|
||||
const r = baseRadius * (n.id === hover?.id ? 1.15 : 1);
|
||||
ctx.globalAlpha = alpha;
|
||||
|
||||
// Glow for hubs and the active node.
|
||||
if (alpha > 0.5 && (isActive || isSelected || n.degree >= labelThreshold)) {
|
||||
ctx.shadowColor = n.color;
|
||||
ctx.shadowBlur = isActive || isSelected ? 22 : 12;
|
||||
if (
|
||||
alpha > 0.5 &&
|
||||
(isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold)
|
||||
) {
|
||||
ctx.shadowColor = fillColor;
|
||||
ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12;
|
||||
} else {
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = n.color;
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Rim.
|
||||
ctx.lineWidth = 1.4 / k;
|
||||
ctx.strokeStyle = "rgba(2, 6, 23, 0.55)";
|
||||
ctx.lineWidth = strokeWidth;
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.stroke();
|
||||
|
||||
if (isExploreRoot) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x, n.y, r + 8 / k, 0, Math.PI * 2);
|
||||
ctx.lineWidth = 1.4 / k;
|
||||
ctx.strokeStyle = "rgba(125, 211, 252, 0.9)";
|
||||
ctx.stroke();
|
||||
} else if (isExpanded) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x + r * 0.58, n.y - r * 0.58, Math.max(4 / k, 3), 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#7dd3fc";
|
||||
ctx.fill();
|
||||
ctx.lineWidth = 1 / k;
|
||||
ctx.strokeStyle = "rgba(2, 6, 23, 0.8)";
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Selection: double ring.
|
||||
if (isSelected) {
|
||||
ctx.beginPath();
|
||||
@@ -465,6 +532,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
isActive ||
|
||||
isSelected ||
|
||||
isNeighbor ||
|
||||
labelByExplore ||
|
||||
(search?.has(n.id) ?? false) ||
|
||||
(p.showLabels && n.degree >= labelThreshold && k >= 0.45);
|
||||
if (wantsLabel && alpha > 0.5) {
|
||||
@@ -775,6 +843,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
|
||||
{props.toolbar ? (
|
||||
<div className="kg-canvas-toolbar">{props.toolbar}</div>
|
||||
) : null}
|
||||
{props.hint}
|
||||
{minimapVisible ? (
|
||||
<canvas ref={minimapRef} className="kg-minimap" width={180} height={120} />
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { Crosshair, EyeOff, FileText, Loader2, MousePointerClick } from "lucide-react";
|
||||
import {
|
||||
Compass,
|
||||
Crosshair,
|
||||
EyeOff,
|
||||
FileText,
|
||||
Loader2,
|
||||
MousePointerClick,
|
||||
Waypoints,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { KnowledgeNote } from "@/core/knowledge/types";
|
||||
|
||||
@@ -37,10 +46,20 @@ interface GraphDetailPanelProps {
|
||||
/** Note detail (only for note-type nodes; undefined while absent/loading). */
|
||||
note: KnowledgeNote | undefined;
|
||||
noteLoading: boolean;
|
||||
/** Explore-mode context; null when in full mode. */
|
||||
explore: {
|
||||
/** Depth of this node in the explore graph; null = not yet visible. */
|
||||
depth: number | null;
|
||||
expanded: boolean;
|
||||
isRoot: boolean;
|
||||
} | null;
|
||||
onViewSource: (noteId: string) => void;
|
||||
onLocate: (id: string) => void;
|
||||
onHide: (id: string) => void;
|
||||
onPickNeighbor: (id: string) => void;
|
||||
onExpandNode: (id: string) => void;
|
||||
onSetExploreRoot: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatTime(value: string | null | undefined): string {
|
||||
@@ -57,6 +76,14 @@ export function GraphDetailPanel(props: GraphDetailPanelProps) {
|
||||
<aside className="kg-panel kg-detail">
|
||||
<div className="kg-panel-header">
|
||||
<span className="kg-panel-title">节点详情</span>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-panel-close-btn"
|
||||
title="收起详情面板"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
{!node ? (
|
||||
<div className="kg-detail-empty">
|
||||
@@ -104,6 +131,28 @@ function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
|
||||
>
|
||||
度数 {props.degree}
|
||||
</span>
|
||||
{props.explore && props.explore.depth !== null ? (
|
||||
<>
|
||||
<span
|
||||
className="kg-detail-type"
|
||||
style={{ background: "rgba(56, 189, 248, 0.14)", color: "#7dd3fc" }}
|
||||
>
|
||||
{props.explore.isRoot
|
||||
? "探索起点"
|
||||
: `第 ${props.explore.depth} 层`}
|
||||
</span>
|
||||
<span
|
||||
className="kg-detail-type"
|
||||
style={
|
||||
props.explore.expanded
|
||||
? { background: "rgba(52, 211, 153, 0.14)", color: "#6ee7b7" }
|
||||
: { background: "rgba(251, 191, 36, 0.14)", color: "#fcd34d" }
|
||||
}
|
||||
>
|
||||
{props.explore.expanded ? "已展开" : "可展开"}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{props.noteLoading ? (
|
||||
@@ -171,6 +220,31 @@ function DetailBody(props: GraphDetailPanelProps & { node: GraphNode }) {
|
||||
>
|
||||
<EyeOff size={14} /> 隐藏节点
|
||||
</button>
|
||||
{props.explore ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
disabled={props.explore.depth === null || props.explore.expanded}
|
||||
title={
|
||||
props.explore.expanded
|
||||
? "该节点的一跳邻居已全部展开"
|
||||
: "展开该节点的一跳邻居"
|
||||
}
|
||||
onClick={() => props.onExpandNode(node.id)}
|
||||
>
|
||||
<Waypoints size={14} /> 展开一跳邻居
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="kg-icon-button"
|
||||
title="清空当前探索路径,以此节点为新起点"
|
||||
onClick={() => props.onSetExploreRoot(node.id)}
|
||||
>
|
||||
<Compass size={14} /> 以此为新起点
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h3 className="kg-detail-section-title">
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
Download,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
PanelLeft,
|
||||
PanelRight,
|
||||
RefreshCw,
|
||||
Scan,
|
||||
Shuffle,
|
||||
@@ -18,8 +16,6 @@ interface GraphHeaderProps {
|
||||
totalNodes: number;
|
||||
totalEdges: number;
|
||||
refreshing: boolean;
|
||||
showSidebar: boolean;
|
||||
showDetail: boolean;
|
||||
onBack: () => void;
|
||||
onRelayout: () => void;
|
||||
onFitView: () => void;
|
||||
@@ -28,8 +24,6 @@ interface GraphHeaderProps {
|
||||
onExport: () => void;
|
||||
onFullscreen: () => void;
|
||||
onRefresh: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
onToggleDetail: () => void;
|
||||
}
|
||||
|
||||
export function GraphHeader(props: GraphHeaderProps) {
|
||||
@@ -124,23 +118,6 @@ export function GraphHeader(props: GraphHeaderProps) {
|
||||
<RefreshCw size={15} />
|
||||
)}
|
||||
</button>
|
||||
<div className="kg-header-divider" />
|
||||
<button
|
||||
type="button"
|
||||
className={`kg-icon-button${props.showSidebar ? " kg-icon-button-active" : ""}`}
|
||||
title="筛选面板"
|
||||
onClick={props.onToggleSidebar}
|
||||
>
|
||||
<PanelLeft size={15} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`kg-icon-button${props.showDetail ? " kg-icon-button-active" : ""}`}
|
||||
title="详情面板"
|
||||
onClick={props.onToggleDetail}
|
||||
>
|
||||
<PanelRight size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Search, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { GraphNode, GraphStats, NodeKind } from "./graphTypes";
|
||||
import {
|
||||
@@ -24,9 +25,56 @@ interface GraphSidebarProps {
|
||||
onToggleEdgeLabel: (label: string) => void;
|
||||
onRestoreHidden: () => void;
|
||||
onPickNode: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type SectionKey = "nodeTypes" | "edgeTypes" | "graphStats" | "coreNodes";
|
||||
|
||||
interface FilterSectionProps {
|
||||
title: string;
|
||||
extra?: string;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function FilterSection(props: FilterSectionProps) {
|
||||
return (
|
||||
<section className="kg-filter-section">
|
||||
<button
|
||||
type="button"
|
||||
className="kg-filter-section-header"
|
||||
onClick={props.onToggle}
|
||||
aria-expanded={!props.collapsed}
|
||||
>
|
||||
<span>{props.title}</span>
|
||||
<span className="kg-filter-section-extra">
|
||||
{props.extra}
|
||||
{props.collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</span>
|
||||
</button>
|
||||
{!props.collapsed ? (
|
||||
<div className="kg-filter-section-body">{props.children}</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function GraphSidebar(props: GraphSidebarProps) {
|
||||
// 节点类型 / 关系类型默认收起,统计与核心节点默认展开。
|
||||
const [collapsedSections, setCollapsedSections] = useState<
|
||||
Record<SectionKey, boolean>
|
||||
>({
|
||||
nodeTypes: true,
|
||||
edgeTypes: true,
|
||||
graphStats: false,
|
||||
coreNodes: false,
|
||||
});
|
||||
|
||||
const toggleFilterSection = (key: SectionKey) => {
|
||||
setCollapsedSections((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
|
||||
const kinds = NODE_KIND_ORDER.filter((k) => (props.nodeKindCounts.get(k) ?? 0) > 0);
|
||||
const edgeLabels = [...props.edgeLabelCounts.entries()].sort((a, b) => b[1] - a[1]);
|
||||
|
||||
@@ -34,15 +82,25 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
||||
<aside className="kg-panel kg-sidebar">
|
||||
<div className="kg-panel-header">
|
||||
<span className="kg-panel-title">搜索与筛选</span>
|
||||
{props.hiddenCount > 0 ? (
|
||||
<div className="kg-panel-header-actions">
|
||||
{props.hiddenCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="kg-text-button"
|
||||
onClick={props.onRestoreHidden}
|
||||
>
|
||||
恢复 {props.hiddenCount} 个隐藏节点
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="kg-text-button"
|
||||
onClick={props.onRestoreHidden}
|
||||
className="kg-panel-close-btn"
|
||||
title="收起筛选面板"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
恢复 {props.hiddenCount} 个隐藏节点
|
||||
<X size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="kg-panel-content">
|
||||
<div className="kg-search-wrap">
|
||||
@@ -67,8 +125,12 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="kg-filter-section">
|
||||
<div className="kg-filter-title">节点类型</div>
|
||||
<FilterSection
|
||||
title="节点类型"
|
||||
extra={`${kinds.length} 类`}
|
||||
collapsed={collapsedSections.nodeTypes}
|
||||
onToggle={() => toggleFilterSection("nodeTypes")}
|
||||
>
|
||||
{kinds.map((kind) => (
|
||||
<label key={kind} className="kg-check-row">
|
||||
<span className="kg-check-row-main">
|
||||
@@ -86,11 +148,15 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
||||
<span className="kg-count">{props.nodeKindCounts.get(kind)}</span>
|
||||
</label>
|
||||
))}
|
||||
</section>
|
||||
</FilterSection>
|
||||
|
||||
{edgeLabels.length > 0 ? (
|
||||
<section className="kg-filter-section">
|
||||
<div className="kg-filter-title">关系类型</div>
|
||||
<FilterSection
|
||||
title="关系类型"
|
||||
extra={`${edgeLabels.length} 类`}
|
||||
collapsed={collapsedSections.edgeTypes}
|
||||
onToggle={() => toggleFilterSection("edgeTypes")}
|
||||
>
|
||||
{edgeLabels.map(([label, count]) => {
|
||||
const info = getEdgeInfo(label);
|
||||
return (
|
||||
@@ -114,11 +180,14 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</FilterSection>
|
||||
) : null}
|
||||
|
||||
<section className="kg-filter-section">
|
||||
<div className="kg-filter-title">图谱统计</div>
|
||||
<FilterSection
|
||||
title="图谱统计"
|
||||
collapsed={collapsedSections.graphStats}
|
||||
onToggle={() => toggleFilterSection("graphStats")}
|
||||
>
|
||||
<div className="kg-stats-grid">
|
||||
<div className="kg-stat-card">
|
||||
<div className="kg-stat-card-value">{props.stats.nodes}</div>
|
||||
@@ -139,11 +208,15 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
||||
<div className="kg-stat-card-label">最大连通分量</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</FilterSection>
|
||||
|
||||
{props.topNodes.length > 0 ? (
|
||||
<section className="kg-filter-section">
|
||||
<div className="kg-filter-title">核心节点</div>
|
||||
<FilterSection
|
||||
title="核心节点"
|
||||
extra={`${props.topNodes.length} 个`}
|
||||
collapsed={collapsedSections.coreNodes}
|
||||
onToggle={() => toggleFilterSection("coreNodes")}
|
||||
>
|
||||
{props.topNodes.map(({ node, degree }, i) => (
|
||||
<div
|
||||
key={node.id}
|
||||
@@ -155,7 +228,7 @@ export function GraphSidebar(props: GraphSidebarProps) {
|
||||
<span className="kg-rank-degree">{degree} 度</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</FilterSection>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -205,12 +205,39 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kg-panel-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.kg-panel-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--kg-text-main);
|
||||
}
|
||||
|
||||
.kg-panel-close-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(15, 23, 42, 0.65);
|
||||
color: #94a3b8;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kg-panel-close-btn:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(56, 189, 248, 0.14);
|
||||
border-color: rgba(56, 189, 248, 0.35);
|
||||
}
|
||||
|
||||
.kg-panel-content {
|
||||
padding: 14px;
|
||||
overflow-y: auto;
|
||||
@@ -267,15 +294,44 @@
|
||||
}
|
||||
|
||||
.kg-filter-section {
|
||||
margin-top: 18px;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.kg-filter-title {
|
||||
font-size: 12px;
|
||||
.kg-filter-section:first-of-type {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.kg-filter-section-header {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #cbd5e1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--kg-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.kg-filter-section-header:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.kg-filter-section-extra {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.kg-filter-section-body {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.kg-check-row {
|
||||
@@ -421,6 +477,14 @@
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.kg-canvas-shell {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.kg-canvas {
|
||||
@@ -485,6 +549,82 @@
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.kg-explore-button {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: #cbd5e1;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.kg-explore-button:hover {
|
||||
background: rgba(56, 189, 248, 0.14);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.kg-explore-button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.kg-canvas-hint {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 22px;
|
||||
z-index: 11;
|
||||
transform: translateX(-50%);
|
||||
max-width: min(560px, calc(100% - 48px));
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.22);
|
||||
background: rgba(15, 23, 42, 0.78);
|
||||
color: #bae6fd;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
backdrop-filter: blur(12px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.kg-side-rail-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
z-index: 20;
|
||||
width: 32px;
|
||||
min-height: 88px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(56, 189, 248, 0.28);
|
||||
background: rgba(15, 23, 42, 0.86);
|
||||
color: #bae6fd;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
writing-mode: vertical-rl;
|
||||
letter-spacing: 4px;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(12px);
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.kg-side-rail-toggle-left {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.kg-side-rail-toggle-right {
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
.kg-side-rail-toggle:hover {
|
||||
background: rgba(56, 189, 248, 0.16);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.kg-minimap {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
|
||||
@@ -43,6 +43,18 @@ export type NodeKind =
|
||||
|
||||
export type LayoutType = "force" | "circular";
|
||||
|
||||
/** Graph display mode: full graph vs. click-to-expand exploration. */
|
||||
export type GraphMode = "full" | "explore";
|
||||
|
||||
/** One incremental expansion step in explore mode (for undo / rebuild). */
|
||||
export interface ExploreStep {
|
||||
nodeId: string;
|
||||
depth: number;
|
||||
addedNodeIds: string[];
|
||||
addedEdgeIds: string[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/** Style metadata of an edge label. */
|
||||
export interface EdgeInfo {
|
||||
color: string;
|
||||
|
||||
@@ -76,6 +76,10 @@ export function truncateLabel(text: string, max = 14): string {
|
||||
export const getEdgeLabel = (edge: GraphEdge): string =>
|
||||
edge.label || edge.type || edge.relation || "related";
|
||||
|
||||
/** Stable edge identity shared by explore-mode visibility sets and the canvas. */
|
||||
export const getEdgeId = (edge: GraphEdge): string =>
|
||||
edge.id ?? `${edge.source}->${edge.target}:${getEdgeLabel(edge)}`;
|
||||
|
||||
const KNOWN_EDGE_INFO: Record<string, EdgeInfo> = {
|
||||
related: { color: "#94a3b8", zh: "相关", dashed: false },
|
||||
links: { color: "#a78bfa", zh: "引用", dashed: true },
|
||||
|
||||
Reference in new issue
Block a user