This commit is contained in:
董宏凯 committed 2026-06-12 20:29:03 +08:00
commit cca954d01c
4 files changed
+207 -91

No files matched your search

@@ -245,9 +245,10 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
// first paint is already roughly laid out instead of an exploding
// cloud that drifts out of the viewport.
if (props.layoutType === "force") {
const deadline = performance.now() + 120;
const budget = simNodes.length > 350 ? 280 : 120;
const deadline = performance.now() + budget;
let guard = 0;
while (sim.alpha > 0.25 && guard < 300 && performance.now() < deadline) {
while (sim.alpha > 0.25 && guard < 600 && performance.now() < deadline) {
sim.tick();
guard += 1;
}
@@ -295,9 +296,15 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k));
// Notify React of zoom changes only when k actually moved — a state
// update per animation frame re-renders the whole page while settling.
const lastNotifiedZoomRef = useRef(0);
const setTransform = useCallback((t: Transform) => {
transformRef.current = t;
propsRef.current.onZoomChange(t.k);
if (Math.abs(t.k - lastNotifiedZoomRef.current) > 0.004) {
lastNotifiedZoomRef.current = t.k;
propsRef.current.onZoomChange(t.k);
}
needsDrawRef.current = true;
}, []);
@@ -326,32 +333,39 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
[setTransform],
);
const computeFitTransform = useCallback((): Transform | null => {
const sim = simRef.current;
const { w, h } = sizeRef.current;
if (!sim || !sim.nodes.length || !w || !h) return null;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of sim.nodes) {
minX = Math.min(minX, n.x - n.radius);
minY = Math.min(minY, n.y - n.radius);
maxX = Math.max(maxX, n.x + n.radius);
maxY = Math.max(maxY, n.y + n.radius);
}
// A single bad coordinate must not poison the viewport transform.
if (!Number.isFinite(minX + minY + maxX + maxY)) return null;
const pad = 70;
const bw = Math.max(maxX - minX, 1);
const bh = Math.max(maxY - minY, 1);
const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2));
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
return { x: w / 2 - cx * k, y: h / 2 - cy * k, k };
}, []);
const fitView = useCallback(
(animated = true) => {
const sim = simRef.current;
const { w, h } = sizeRef.current;
if (!sim || !sim.nodes.length || !w || !h) return;
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const n of sim.nodes) {
minX = Math.min(minX, n.x - n.radius);
minY = Math.min(minY, n.y - n.radius);
maxX = Math.max(maxX, n.x + n.radius);
maxY = Math.max(maxY, n.y + n.radius);
}
const pad = 70;
const bw = Math.max(maxX - minX, 1);
const bh = Math.max(maxY - minY, 1);
const k = clampZoom(Math.min((w - pad * 2) / bw, (h - pad * 2) / bh, 2.2));
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const target = { x: w / 2 - cx * k, y: h / 2 - cy * k, k };
const target = computeFitTransform();
if (!target) return;
if (animated) animateTo(target);
else setTransform(target);
},
[animateTo, setTransform],
[animateTo, computeFitTransform, setTransform],
);
const focusNode = useCallback(
@@ -527,11 +541,13 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
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 || isExploreRoot || isExpanded || n.degree >= labelThreshold)
) {
// Glow for hubs and the active node. Canvas shadows are expensive,
// so on large graphs only the focused nodes keep theirs.
const glowEligible =
sim.nodes.length <= 450
? isActive || isSelected || isExploreRoot || isExpanded || n.degree >= labelThreshold
: isActive || isSelected || isExploreRoot;
if (alpha > 0.5 && glowEligible) {
ctx.shadowColor = fillColor;
ctx.shadowBlur = isActive || isSelected || isExploreRoot ? 24 : 12;
} else {
@@ -692,6 +708,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
};
if (progress >= 1) {
animRef.current = null;
lastNotifiedZoomRef.current = transformRef.current.k;
propsRef.current.onZoomChange(transformRef.current.k);
}
active = true;
@@ -699,10 +716,19 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
if (sim && layoutRef.current === "force" && sim.tick()) {
active = true;
// Until the user pans/zooms/drags, keep the settling layout fully in
// view every frame so nodes never drift out of the viewport.
// Until the user pans/zooms/drags, gently track the settling layout
// so it stays in view without the viewport snapping every frame.
if (!interactedRef.current && sizeRef.current.w) {
fitView(false);
const target = computeFitTransform();
if (target) {
const t = transformRef.current;
const ease = t.k === 0 ? 1 : 0.16;
setTransform({
x: t.x + (target.x - t.x) * ease,
y: t.y + (target.y - t.y) * ease,
k: t.k + (target.k - t.k) * ease,
});
}
autoFitRef.current = true;
} else if (!autoFitRef.current && sim.alpha < 0.3) {
autoFitRef.current = true;
@@ -721,7 +747,7 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
};
rafRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(rafRef.current);
}, [draw, fitView]);
}, [computeFitTransform, draw, fitView, setTransform]);
// --------------------------------------------------------------- resize
@@ -801,7 +827,6 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
if (e.button !== 0) return;
interactedRef.current = true;
const { sx, sy } = localPoint(e);
const { x: wx, y: wy } = screenToWorld(sx, sy);
const node = hitTest(wx, wy);
@@ -828,7 +853,12 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
if (drag && drag.pointerId === e.pointerId) {
const dx = sx - drag.startX;
const dy = sy - drag.startY;
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
if (Math.abs(dx) + Math.abs(dy) > 3) {
drag.moved = true;
// Only a real pan/drag takes over the camera; a plain click keeps
// the auto-follow so the settling graph can't drift out of view.
interactedRef.current = true;
}
if (drag.node) {
const world = screenToWorld(sx, sy);
drag.node.fx = world.x;
@@ -239,15 +239,41 @@ export function applyCircularLayout(nodes: SimNode[]): void {
export class ForceSimulation {
alpha = 1;
readonly alphaMin = 0.004;
private readonly alphaDecay = 0.0228;
private readonly alphaDecay: number;
private readonly velocityDecay = 0.38;
private readonly chargeStrength = -420;
private readonly chargeCutoff2 = 600 * 600;
private readonly chargeStrength: number;
private readonly chargeCutoff: number;
private readonly chargeCutoff2: number;
/** Hard cap on per-tick movement so dense graphs can't oscillate or fly off. */
private readonly maxSpeed = 30;
constructor(
public nodes: SimNode[],
public edges: SimEdge[],
) {}
) {
// Scale forces down and cool faster as the graph grows — with the
// small-graph constants a few hundred nodes keep shoving each other
// around for minutes and the layout never settles.
const n = nodes.length;
if (n > 600) {
this.chargeStrength = -160;
this.chargeCutoff = 260;
this.alphaDecay = 0.05;
} else if (n > 300) {
this.chargeStrength = -240;
this.chargeCutoff = 340;
this.alphaDecay = 0.038;
} else if (n > 150) {
this.chargeStrength = -320;
this.chargeCutoff = 440;
this.alphaDecay = 0.03;
} else {
this.chargeStrength = -420;
this.chargeCutoff = 600;
this.alphaDecay = 0.0228;
}
this.chargeCutoff2 = this.chargeCutoff * this.chargeCutoff;
}
reheat(alpha = 0.9): void {
this.alpha = Math.max(this.alpha, alpha);
@@ -276,31 +302,8 @@ export class ForceSimulation {
s.vy += dy * 0.5;
}
// Many-body repulsion with a distance cutoff.
for (let i = 0; i < nodes.length; i++) {
const ni = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const nj = nodes[j];
let dx = nj.x - ni.x;
let dy = nj.y - ni.y;
let d2 = dx * dx + dy * dy;
if (d2 > this.chargeCutoff2) continue;
if (d2 < 1) {
// Deterministic nudge for coincident nodes.
dx = ((i % 3) - 1) * 0.5 || 0.5;
dy = ((j % 3) - 1) * 0.5 || -0.5;
d2 = dx * dx + dy * dy;
}
const d = Math.sqrt(d2);
const f = (this.chargeStrength * a) / d2;
const fx = (dx / d) * f;
const fy = (dy / d) * f;
ni.vx += fx;
ni.vy += fy;
nj.vx -= fx;
nj.vy -= fy;
}
}
// Repulsion + collision over nearby pairs only (uniform grid).
this.applyPairForces(a);
// Gentle pull toward the origin keeps disconnected clusters on screen.
for (const n of nodes) {
@@ -308,33 +311,9 @@ export class ForceSimulation {
n.vy += (0 - n.y) * 0.05 * a;
}
// Positional collision resolution (prevents node overlap).
for (let i = 0; i < nodes.length; i++) {
const ni = nodes[i];
for (let j = i + 1; j < nodes.length; j++) {
const nj = nodes[j];
const minDist = ni.radius + nj.radius + 8;
let dx = nj.x - ni.x;
let dy = nj.y - ni.y;
const d2 = dx * dx + dy * dy;
if (d2 >= minDist * minDist || d2 === 0) continue;
const d = Math.sqrt(d2);
const push = ((minDist - d) / d) * 0.5;
dx *= push;
dy *= push;
if (ni.fx == null) {
ni.x -= dx;
ni.y -= dy;
}
if (nj.fx == null) {
nj.x += dx;
nj.y += dy;
}
}
}
// Integrate velocities.
for (const n of nodes) {
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
if (n.fx != null && n.fy != null) {
n.x = n.fx;
n.y = n.fy;
@@ -343,10 +322,113 @@ export class ForceSimulation {
} else {
n.vx *= 1 - this.velocityDecay;
n.vy *= 1 - this.velocityDecay;
const speed = Math.sqrt(n.vx * n.vx + n.vy * n.vy);
if (speed > this.maxSpeed) {
const s = this.maxSpeed / speed;
n.vx *= s;
n.vy *= s;
}
n.x += n.vx;
n.y += n.vy;
}
if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) {
// A NaN/Infinity coordinate would blank the whole canvas (the
// bounding box and fit transform become NaN); park the node instead.
const r = 40 + 18 * Math.sqrt(i + 1);
n.x = r * Math.cos(i * GOLDEN_ANGLE);
n.y = r * Math.sin(i * GOLDEN_ANGLE);
n.vx = 0;
n.vy = 0;
}
}
return true;
}
/**
* Many-body repulsion + collision resolution, restricted to pairs within
* one grid cell (cell size = charge cutoff). Two nodes farther apart than
* the cutoff never interact, so binning by cell keeps the scan close to
* O(n) for spread-out layouts instead of O(n²) over all pairs.
*/
private applyPairForces(a: number): void {
const cellSize = this.chargeCutoff;
const grid = new Map<string, { cx: number; cy: number; items: SimNode[] }>();
for (const n of this.nodes) {
const cx = Math.floor(n.x / cellSize);
const cy = Math.floor(n.y / cellSize);
const key = `${cx},${cy}`;
let bucket = grid.get(key);
if (!bucket) grid.set(key, (bucket = { cx, cy, items: [] }));
bucket.items.push(n);
}
// Half of the 8-neighborhood so every cell pair is visited exactly once.
const offsets = [
[0, 0],
[1, 0],
[0, 1],
[1, 1],
[1, -1],
] as const;
for (const bucket of grid.values()) {
for (const [ox, oy] of offsets) {
const other =
ox === 0 && oy === 0
? bucket
: grid.get(`${bucket.cx + ox},${bucket.cy + oy}`);
if (!other) continue;
const left = bucket.items;
const right = other.items;
if (other === bucket) {
for (let i = 0; i < left.length; i++) {
for (let j = i + 1; j < left.length; j++) {
this.pairForce(left[i], left[j], i, j, a);
}
}
} else {
for (let i = 0; i < left.length; i++) {
for (let j = 0; j < right.length; j++) {
this.pairForce(left[i], right[j], i, j, a);
}
}
}
}
}
}
private pairForce(ni: SimNode, nj: SimNode, i: number, j: number, a: number): void {
let dx = nj.x - ni.x;
let dy = nj.y - ni.y;
let d2 = dx * dx + dy * dy;
if (d2 > this.chargeCutoff2) return;
if (d2 < 1) {
// Deterministic nudge for coincident nodes.
dx = ((i % 3) - 1) * 0.5 || 0.5;
dy = ((j % 3) - 1) * 0.5 || -0.5;
d2 = dx * dx + dy * dy;
}
const d = Math.sqrt(d2);
const f = (this.chargeStrength * a) / d2;
const fx = (dx / d) * f;
const fy = (dy / d) * f;
ni.vx += fx;
ni.vy += fy;
nj.vx -= fx;
nj.vy -= fy;
// Positional collision resolution (prevents node overlap).
const minDist = ni.radius + nj.radius + 8;
if (d < minDist) {
const push = ((minDist - d) / d) * 0.5;
const px = dx * push;
const py = dy * push;
if (ni.fx == null) {
ni.x -= px;
ni.y -= py;
}
if (nj.fx == null) {
nj.x += px;
nj.y += py;
}
}
}
}
@@ -112,7 +112,7 @@ export function AgentGallery() {
search,
tagIds,
sort,
page: pages.private,
page: pages.private ?? 1,
pageSize: PAGE_SIZE,
enabled: activeTab === "private",
owner: publisher?.id,
@@ -40,6 +40,7 @@ from app.gateway.routers import (
html_page_favorites,
intent,
knowledge,
knowledge_ingest,
light_apps,
llm_metrics,
mcp,
@@ -1359,6 +1360,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Product knowledge base API is mounted at /api/knowledge
app.include_router(knowledge.router)
# External knowledge ingest (publish md artifacts) at /api/knowledge-ingest
app.include_router(knowledge_ingest.router)
# System settings API is mounted at /api/system-settings
app.include_router(system_settings.router)