mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Render world map overview as a screen-space starfield and rename it to 世界地图.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
8444d85005
commit
fdab188cc1
11 changed files with 470 additions and 67 deletions
|
|
@ -1440,8 +1440,15 @@ const en = {
|
|||
newSubRegion: "New sub-region",
|
||||
newSubRegionName: "New sub-region",
|
||||
newSubRegionPrompt: "Sub-region name",
|
||||
subRegionNotOnFlat: "The flat world map has no regions. Create sub-regions on World or a lower-level canvas.",
|
||||
subRegionNotOnFlat: "The world map has no regions. Create sub-regions on World or a lower-level canvas.",
|
||||
worldMapNoDirectNes: "Cannot add NEs on the world map — add them on a sub-region canvas.",
|
||||
worldHud: "World {{total}} · close-up {{cached}} · z{{zoom}} · {{lod}}",
|
||||
worldEmptyCoords: "World map has no coordinates yet",
|
||||
worldEmptyCoordsHint:
|
||||
"Dock has {{dock}} NEs, but Fabric / world coords are empty. Apply without pulling UME.",
|
||||
worldApply: "Apply to world map",
|
||||
worldApplying: "Applying…",
|
||||
worldApplyOk: "Applied to world map",
|
||||
subRegionNeedParent: "Open a root map or region canvas first, then create a sub-region.",
|
||||
regionCreated: "Created",
|
||||
regionDeleted: "Deleted",
|
||||
|
|
|
|||
|
|
@ -1430,8 +1430,14 @@ const zh = {
|
|||
newSubRegion: "新建子区域",
|
||||
newSubRegionName: "新子区域",
|
||||
newSubRegionPrompt: "子区域名称",
|
||||
subRegionNotOnFlat: "完整世界地图不支持子区域,请在 World 或下级区域画布上创建。",
|
||||
subRegionNotOnFlat: "世界地图不支持子区域,请在 World 或下级区域画布上创建。",
|
||||
worldMapNoDirectNes: "世界地图不能直接添加网元,请到某个子区域画布上添加。",
|
||||
worldHud: "世界 {{total}} · 近景 {{cached}} · z{{zoom}} · {{lod}}",
|
||||
worldEmptyCoords: "世界地图还没有坐标",
|
||||
worldEmptyCoordsHint: "码头已有 {{dock}} 个网元,但尚未灌入 Fabric / 世界坐标。可一键应用(不拉 UME)。",
|
||||
worldApply: "应用到世界地图",
|
||||
worldApplying: "正在应用…",
|
||||
worldApplyOk: "已应用到世界地图",
|
||||
subRegionNeedParent: "请先打开根图或区域画布,再新建子区域。",
|
||||
regionCreated: "已创建",
|
||||
regionDeleted: "已删除",
|
||||
|
|
|
|||
|
|
@ -7183,6 +7183,40 @@ pre {
|
|||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.topo-world-hud {
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
top: 10px;
|
||||
left: 12px;
|
||||
max-width: min(520px, calc(100% - 24px));
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(15, 23, 42, 0.72);
|
||||
color: #cbd5e1;
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.topo-world-scatter {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topo-world-scatter-panel.react-flow__panel {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.topo-canvas__overlay--empty.is-interactive {
|
||||
pointer-events: auto;
|
||||
background: rgba(11, 18, 32, 0.35);
|
||||
}
|
||||
|
||||
.topo-node__pixel {
|
||||
display: block;
|
||||
width: 4px;
|
||||
|
|
|
|||
|
|
@ -62,12 +62,14 @@ import {
|
|||
updateLldpCollectPolicy,
|
||||
updateTopologyFolder,
|
||||
updateTopologyMap,
|
||||
applyUmeTopologyToFabric,
|
||||
} from "../services/api";
|
||||
import { queryKeys } from "../constants/queryKeys";
|
||||
import { HelpHint } from "../components/HelpHint";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useToast } from "../hooks/useToast";
|
||||
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||
import { WorldScatterLayer } from "./topology/WorldScatterLayer";
|
||||
import type {
|
||||
FabricNodeSearchHit,
|
||||
TopologyDiscoverJob,
|
||||
|
|
@ -177,7 +179,8 @@ function isWorldDrillFolder(folder: TopologyTreeFolderItem | null | undefined):
|
|||
}
|
||||
|
||||
function isWorldFlatViewName(name: string | undefined | null): boolean {
|
||||
return String(name || "").trim() === "完整世界地图";
|
||||
const n = String(name || "").trim();
|
||||
return n === "世界地图" || n === "完整世界地图";
|
||||
}
|
||||
|
||||
/** Visual LOD for 1:1 world coords (~1e5 span). fitView overview is ~0.01. */
|
||||
|
|
@ -187,6 +190,20 @@ function worldVisualLodFromZoom(zoom: number): "dot" | "pin" | "full" {
|
|||
return "full";
|
||||
}
|
||||
|
||||
function worldDisplayBounds(wt: TopologyWorldTransform | null | undefined): {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
} | null {
|
||||
if (!wt) return null;
|
||||
const scale = Number(wt.scale) || 1;
|
||||
const width = Math.max(1, (Number(wt.full_max_x) - Number(wt.full_min_x)) * scale);
|
||||
const height = Math.max(1, (Number(wt.full_max_y) - Number(wt.full_min_y)) * scale);
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
||||
return { x: 0, y: 0, width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Region row IS the canvas for nested / UME SBN / World drill.
|
||||
* Top-level under Network root (and UME World container) are hex-nav only — same as UME World.
|
||||
|
|
@ -303,6 +320,7 @@ function mergeFlatWorldGraph(
|
|||
nodes,
|
||||
edges,
|
||||
world_transform: next.world_transform ?? prev.world_transform,
|
||||
scatter: next.scatter?.length ? next.scatter : prev.scatter,
|
||||
truncated: Boolean(prev.truncated || next.truncated),
|
||||
truncate_reason: next.truncate_reason || prev.truncate_reason || "",
|
||||
outside_peers: next.outside_peers?.length ? next.outside_peers : prev.outside_peers,
|
||||
|
|
@ -1103,7 +1121,7 @@ function graphFingerprint(graph: TopologyViewGraph): string {
|
|||
.map((e) => `${e.id}:${e.a_node_id}:${e.b_node_id}:${e.a_port || ""}:${e.b_port || ""}:${e.status || ""}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
return `${nodes}#${edges}#${graph.outside_peers?.length || 0}#${graph.world_transform?.lod || ""}`;
|
||||
return `${nodes}#${edges}#${graph.outside_peers?.length || 0}#${graph.world_transform?.lod || ""}#${graph.scatter?.length || 0}#${graph.world_transform?.total || 0}`;
|
||||
}
|
||||
|
||||
function applyViewGraph(
|
||||
|
|
@ -1400,17 +1418,30 @@ export function TopologyPage() {
|
|||
);
|
||||
};
|
||||
try {
|
||||
// Far out: keep accumulated detail tiles — don't wipe with a sparse overview.
|
||||
if (zoom < 0.025) {
|
||||
// Far / mid zoom: starfield scatter only (screen-space canvas). Drop heavy RF tiles.
|
||||
if (zoom < 0.12) {
|
||||
const prev = queryClient.getQueryData<TopologyViewGraph>(queryKeys.topologyGraph(mapId));
|
||||
if (prev && prev.nodes.length > 1600) return;
|
||||
const g = await fetchTopologyGraph(mapId, { lod: "overview" });
|
||||
if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return;
|
||||
const cx =
|
||||
g.nodes.reduce((s, n) => s + Number(n.x || 0), 0) / Math.max(1, g.nodes.length);
|
||||
const cy =
|
||||
g.nodes.reduce((s, n) => s + Number(n.y || 0), 0) / Math.max(1, g.nodes.length);
|
||||
commit(g, cx, cy);
|
||||
if (!prev?.scatter?.length) {
|
||||
const g = await fetchTopologyGraph(mapId, { lod: "overview" });
|
||||
if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return;
|
||||
const bounds = worldDisplayBounds(g.world_transform);
|
||||
const cx = bounds ? bounds.x + bounds.width / 2 : 0;
|
||||
const cy = bounds ? bounds.y + bounds.height / 2 : 0;
|
||||
commit(g, cx, cy);
|
||||
} else if (prev.nodes.length > 0 && zoom < 0.04) {
|
||||
commit(
|
||||
{
|
||||
...prev,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
world_transform: prev.world_transform
|
||||
? { ...prev.world_transform, lod: "overview" }
|
||||
: prev.world_transform,
|
||||
},
|
||||
0,
|
||||
0,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const pane = canvasRef.current?.getBoundingClientRect();
|
||||
|
|
@ -1445,6 +1476,17 @@ export function TopologyPage() {
|
|||
}, 200);
|
||||
}, [isWorldFlatCanvas, refreshFlatViewport]);
|
||||
|
||||
const fitCanvas = useCallback(() => {
|
||||
const inst = rfRef.current;
|
||||
if (!inst) return;
|
||||
const bounds = isWorldFlatCanvas
|
||||
? worldDisplayBounds(worldTransformRef.current || graphQuery.data?.world_transform)
|
||||
: null;
|
||||
if (bounds) inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
|
||||
else inst.fitView({ ...FIT_VIEW_OPTS });
|
||||
setCanvasZoom(inst.getZoom());
|
||||
}, [isWorldFlatCanvas, graphQuery.data?.world_transform]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flatLodTimerRef.current) window.clearTimeout(flatLodTimerRef.current);
|
||||
|
|
@ -1828,13 +1870,19 @@ export function TopologyPage() {
|
|||
bumpHistory();
|
||||
// World / large canvases use absolute coords far outside the default RF
|
||||
// viewport — without an initial fit the canvas looks empty after load.
|
||||
pendingFitRef.current = nextNodes.length > 0;
|
||||
const hasScatter = (graphQuery.data.scatter?.length || 0) > 0;
|
||||
const hasWorld = Boolean(worldDisplayBounds(graphQuery.data.world_transform));
|
||||
pendingFitRef.current = nextNodes.length > 0 || hasScatter || hasWorld;
|
||||
}
|
||||
historyLockRef.current = false;
|
||||
}, [canvasMode, mapId, graphQuery.data, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingFitRef.current || !nodes.length) return;
|
||||
if (!pendingFitRef.current) return;
|
||||
const wt = graphQuery.data?.world_transform;
|
||||
const bounds = isWorldFlatCanvas ? worldDisplayBounds(wt) : null;
|
||||
const canFitNodes = nodes.length > 0;
|
||||
if (!bounds && !canFitNodes) return;
|
||||
let cancelled = false;
|
||||
let tries = 0;
|
||||
const run = () => {
|
||||
|
|
@ -1845,8 +1893,12 @@ export function TopologyPage() {
|
|||
return;
|
||||
}
|
||||
pendingFitRef.current = false;
|
||||
// duration 0 — animated fit with 400+ nodes often never paints the first frame.
|
||||
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
|
||||
if (bounds) {
|
||||
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
|
||||
} else {
|
||||
// duration 0 — animated fit with 400+ nodes often never paints the first frame.
|
||||
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
|
||||
}
|
||||
setCanvasZoom(inst.getZoom());
|
||||
};
|
||||
const t = window.setTimeout(run, 0);
|
||||
|
|
@ -1854,7 +1906,7 @@ export function TopologyPage() {
|
|||
cancelled = true;
|
||||
window.clearTimeout(t);
|
||||
};
|
||||
}, [nodes, mapId]);
|
||||
}, [nodes, mapId, isWorldFlatCanvas, graphQuery.data?.world_transform]);
|
||||
|
||||
useEffect(() => {
|
||||
setEdges((eds) => eds.map((e) => withEdgeVisual(e, edgeDefaults)));
|
||||
|
|
@ -2101,6 +2153,17 @@ export function TopologyPage() {
|
|||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const applyWorldMut = useMutation({
|
||||
mutationFn: () => applyUmeTopologyToFabric(),
|
||||
onSuccess: async () => {
|
||||
showOk(t("topology.worldApplyOk"));
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
||||
pendingFitRef.current = true;
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const promptNewRegion = useCallback(() => {
|
||||
setNewRootDialog({ name: t("topology.newRegionName") });
|
||||
}, [t]);
|
||||
|
|
@ -2145,7 +2208,7 @@ export function TopologyPage() {
|
|||
const drillFolder = isWorldDrillFolder(folder);
|
||||
const umeNav = isUmeWorldNavFolder(folder);
|
||||
// Region === canvas: hide physical/custom view rows; only child regions.
|
||||
// UME World container: only「完整世界地图」as a sibling view.
|
||||
// UME World container: only「世界地图」as a sibling view.
|
||||
const canvasRegion = isRegionCanvasFolder(folder, String(treeRoot?.id || ""));
|
||||
const visibleViews = canvasRegion
|
||||
? []
|
||||
|
|
@ -3881,13 +3944,21 @@ export function TopologyPage() {
|
|||
const outsidePeers = graphQuery.data?.outside_peers || [];
|
||||
const graphTruncated = Boolean(graphQuery.data?.truncated);
|
||||
const truncateReason = String(graphQuery.data?.truncate_reason || "").trim();
|
||||
const worldScatter = graphQuery.data?.scatter || [];
|
||||
const worldTotal = Number(graphQuery.data?.world_transform?.total || 0);
|
||||
const worldDockMe = Number(graphQuery.data?.world_transform?.dock_me_count || 0);
|
||||
const showWorldScatter =
|
||||
isWorldFlatCanvas && worldVisualLod !== "full" && worldScatter.length > 0;
|
||||
const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data);
|
||||
const canvasGraphEmpty =
|
||||
Boolean(mapId) &&
|
||||
graphQuery.isSuccess &&
|
||||
!graphQuery.isFetching &&
|
||||
(graphQuery.data?.nodes?.length ?? 0) === 0 &&
|
||||
nodes.length === 0;
|
||||
worldScatter.length === 0 &&
|
||||
nodes.length === 0 &&
|
||||
worldTotal === 0;
|
||||
const worldNeedsApply = isWorldFlatCanvas && canvasGraphEmpty && worldDockMe > 0;
|
||||
const canvasGraphRefreshing =
|
||||
Boolean(mapId) && liveSync && graphQuery.isFetching && Boolean(graphQuery.data) && !canvasGraphLoading;
|
||||
const truncateBannerText = useMemo(() => {
|
||||
|
|
@ -4312,8 +4383,8 @@ export function TopologyPage() {
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={nodes.length === 0}
|
||||
onClick={() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS })}
|
||||
disabled={!isWorldFlatCanvas && nodes.length === 0}
|
||||
onClick={() => fitCanvas()}
|
||||
>
|
||||
{t("topology.fit")}
|
||||
</button>
|
||||
|
|
@ -5226,7 +5297,7 @@ export function TopologyPage() {
|
|||
type="button"
|
||||
className="topo-fs-toolbar__btn"
|
||||
title={t("topology.fit")}
|
||||
onClick={() => rfRef.current?.fitView({ ...FIT_VIEW_OPTS })}
|
||||
onClick={() => fitCanvas()}
|
||||
>
|
||||
{t("topology.fit")}
|
||||
</button>
|
||||
|
|
@ -5242,8 +5313,25 @@ export function TopologyPage() {
|
|||
) : null}
|
||||
{treeRoot ? (
|
||||
<TopoDisplayContext.Provider value={displayOpts}>
|
||||
{isWorldFlatCanvas ? (
|
||||
<div className="topo-world-hud" role="status">
|
||||
<span>
|
||||
{t("topology.worldHud")
|
||||
.replace("{{total}}", String(worldTotal || worldScatter.length || 0))
|
||||
.replace("{{cached}}", String(nodes.length))
|
||||
.replace("{{zoom}}", canvasZoom.toFixed(3))
|
||||
.replace("{{lod}}", worldVisualLod)}
|
||||
</span>
|
||||
{graphTruncated ? (
|
||||
<span className="muted"> · {truncateReason || "truncated"}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<ReactFlow
|
||||
nodes={nodes.map((n) => {
|
||||
nodes={
|
||||
isWorldFlatCanvas && worldVisualLod !== "full"
|
||||
? []
|
||||
: nodes.map((n) => {
|
||||
const isRegion = n.data.kind === "region";
|
||||
const lod = isWorldFlatCanvas ? worldVisualLod : "full";
|
||||
const sized =
|
||||
|
|
@ -5259,8 +5347,9 @@ export function TopologyPage() {
|
|||
className: "is-search-hit",
|
||||
}
|
||||
: { ...sized, className: undefined };
|
||||
})}
|
||||
edges={displayEdges}
|
||||
})
|
||||
}
|
||||
edges={isWorldFlatCanvas && worldVisualLod !== "full" ? [] : displayEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onlyRenderVisibleElements={
|
||||
|
|
@ -5369,11 +5458,17 @@ export function TopologyPage() {
|
|||
}}
|
||||
onInit={(inst) => {
|
||||
rfRef.current = inst as ReactFlowInstance<Node<NeNodeData>, Edge>;
|
||||
if (pendingFitRef.current && nodesRef.current.length > 0) {
|
||||
if (pendingFitRef.current) {
|
||||
pendingFitRef.current = false;
|
||||
// Defer one frame so RF has committed node internals.
|
||||
window.requestAnimationFrame(() => {
|
||||
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
|
||||
const bounds = isWorldFlatCanvas
|
||||
? worldDisplayBounds(worldTransformRef.current || graphQuery.data?.world_transform)
|
||||
: null;
|
||||
if (bounds) {
|
||||
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
|
||||
} else if (nodesRef.current.length > 0) {
|
||||
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
|
||||
}
|
||||
setCanvasZoom(inst.getZoom());
|
||||
});
|
||||
}
|
||||
|
|
@ -5382,6 +5477,11 @@ export function TopologyPage() {
|
|||
deleteKeyCode={null}
|
||||
edgesFocusable
|
||||
>
|
||||
<WorldScatterLayer
|
||||
points={worldScatter}
|
||||
mode={worldVisualLod === "pin" ? "pin" : "dot"}
|
||||
visible={showWorldScatter}
|
||||
/>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={16}
|
||||
|
|
@ -5453,9 +5553,34 @@ export function TopologyPage() {
|
|||
</div>
|
||||
) : null}
|
||||
{treeRoot && canvasGraphEmpty ? (
|
||||
<div className="topo-canvas__overlay topo-canvas__overlay--empty" role="status">
|
||||
<p className="topo-canvas__overlay-title">{t("topology.canvasEmpty")}</p>
|
||||
<p className="topo-canvas__overlay-hint">{t("topology.canvasEmptyHint")}</p>
|
||||
<div
|
||||
className={`topo-canvas__overlay topo-canvas__overlay--empty${
|
||||
worldNeedsApply ? " is-interactive" : ""
|
||||
}`}
|
||||
role="status"
|
||||
>
|
||||
<p className="topo-canvas__overlay-title">
|
||||
{worldNeedsApply ? t("topology.worldEmptyCoords") : t("topology.canvasEmpty")}
|
||||
</p>
|
||||
<p className="topo-canvas__overlay-hint">
|
||||
{worldNeedsApply
|
||||
? t("topology.worldEmptyCoordsHint").replace("{{dock}}", String(worldDockMe))
|
||||
: t("topology.canvasEmptyHint")}
|
||||
</p>
|
||||
{worldNeedsApply ? (
|
||||
<div className="btn-row" style={{ marginTop: 10, justifyContent: "center" }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
disabled={applyWorldMut.isPending}
|
||||
onClick={() => applyWorldMut.mutate()}
|
||||
>
|
||||
{applyWorldMut.isPending
|
||||
? t("topology.worldApplying")
|
||||
: t("topology.worldApply")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{treeRoot && canvasGraphRefreshing ? (
|
||||
|
|
|
|||
110
web/src/pages/topology/WorldScatterLayer.tsx
Normal file
110
web/src/pages/topology/WorldScatterLayer.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { Panel, useStore, type ReactFlowState } from "@xyflow/react";
|
||||
|
||||
export type WorldScatterPoint = { x: number; y: number };
|
||||
|
||||
type Props = {
|
||||
points: WorldScatterPoint[];
|
||||
/** "dot" = tiny starfield; "pin" = slightly larger. */
|
||||
mode: "dot" | "pin";
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
const selectViewport = (s: ReactFlowState) => s.transform;
|
||||
|
||||
/**
|
||||
* Screen-space starfield for the flat world map.
|
||||
* Mounted via Panel (outside the RF viewport transform) so dots stay ~constant
|
||||
* pixel size when zoomed far out — unlike RF DOM nodes.
|
||||
*/
|
||||
export function WorldScatterLayer({ points, mode, visible }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const transform = useStore(selectViewport);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !visible) return;
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const w = parent.clientWidth || 1;
|
||||
const h = parent.clientHeight || 1;
|
||||
if (canvas.width !== Math.floor(w * dpr) || canvas.height !== Math.floor(h * dpr)) {
|
||||
canvas.width = Math.floor(w * dpr);
|
||||
canvas.height = Math.floor(h * dpr);
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!points.length) return;
|
||||
|
||||
const [tx, ty, zoom] = transform;
|
||||
const z = Math.max(Number(zoom) || 0.001, 0.001);
|
||||
const r = mode === "pin" ? 3.5 : 2;
|
||||
const useDensity = points.length > 6000 || z < 0.02;
|
||||
|
||||
if (useDensity) {
|
||||
const cell = mode === "pin" ? 8 : 6;
|
||||
const cols = Math.ceil(w / cell) + 1;
|
||||
const rows = Math.ceil(h / cell) + 1;
|
||||
const counts = new Uint16Array(cols * rows);
|
||||
let maxC = 1;
|
||||
for (const p of points) {
|
||||
const sx = Number(p.x) * z + tx;
|
||||
const sy = Number(p.y) * z + ty;
|
||||
if (sx < -cell || sy < -cell || sx > w + cell || sy > h + cell) continue;
|
||||
const cx = Math.min(cols - 1, Math.max(0, Math.floor(sx / cell)));
|
||||
const cy = Math.min(rows - 1, Math.max(0, Math.floor(sy / cell)));
|
||||
const i = cy * cols + cx;
|
||||
const n = (counts[i] = (counts[i] + 1) as number);
|
||||
if (n > maxC) maxC = n;
|
||||
}
|
||||
for (let i = 0; i < counts.length; i++) {
|
||||
const c = counts[i];
|
||||
if (!c) continue;
|
||||
const cx = (i % cols) * cell + cell / 2;
|
||||
const cy = Math.floor(i / cols) * cell + cell / 2;
|
||||
const t = Math.min(1, c / Math.max(4, maxC * 0.35));
|
||||
const radius = r * (0.7 + t * 1.4);
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = `rgba(56, 189, 248, ${0.25 + t * 0.7})`;
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.fillStyle = "rgba(56, 189, 248, 0.85)";
|
||||
for (const p of points) {
|
||||
const sx = Number(p.x) * z + tx;
|
||||
const sy = Number(p.y) * z + ty;
|
||||
if (sx < -4 || sy < -4 || sx > w + 4 || sy > h + 4) continue;
|
||||
ctx.beginPath();
|
||||
ctx.arc(sx, sy, r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}, [points, mode, visible, transform]);
|
||||
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<Panel
|
||||
position="top-left"
|
||||
className="topo-world-scatter-panel"
|
||||
style={{
|
||||
margin: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
maxWidth: "none",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasRef} className="topo-world-scatter" aria-hidden="true" />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -565,6 +565,12 @@ export type TopologyWorldTransform = {
|
|||
full_max_y: number;
|
||||
total: number;
|
||||
lod: string;
|
||||
dock_me_count?: number;
|
||||
};
|
||||
|
||||
export type TopologyWorldScatterPoint = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type TopologyViewGraph = {
|
||||
|
|
@ -575,6 +581,7 @@ export type TopologyViewGraph = {
|
|||
truncate_reason?: string;
|
||||
outside_peers?: TopologyOutsidePeer[];
|
||||
world_transform?: TopologyWorldTransform | null;
|
||||
scatter?: TopologyWorldScatterPoint[];
|
||||
};
|
||||
|
||||
export type FabricSummary = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue