feat: click nodes and links

This commit is contained in:
Fine 2023-03-16 12:09:29 +08:00
parent d2de2dab66
commit 6798b33029

View File

@ -20,32 +20,74 @@ limitations under the License. -->
element-loading-background="rgba(0, 0, 0, 0)"
:style="`height: ${height}px`"
>
<svg :width="width - 100" :height="height" style="background-color: #fff">
<g v-for="(n, index) in topologyLayout.nodes" :key="index">
<svg :width="width - 100" :height="height" style="background-color: #fff" @click="handleSvg($event)">
<g
v-for="(n, index) in topologyLayout.nodes"
:key="index"
@mouseout="hideTip"
@mouseover="showNodeTip($event, n)"
@click="handleNodeClick($event, n)"
class="topo-node"
>
<circle
class="node"
r="18"
stroke-width="6"
:stroke="n.isReal ? '#72c59f' : 'red'"
:stroke="n.isReal ? '#72c59f' : '#ed374d'"
fill="#fff"
:cx="n.x"
:cy="n.y"
/>
<image
width="18"
height="18"
:x="n.x - 8"
:y="n.y - 10"
:href="!n.type || n.type === `N/A` ? icons.UNDEFINED : icons[n.type.toUpperCase().replace('-', '')]"
/>
<text :x="n.x - (n.name.length * 6) / 2" :y="n.y + n.height + 12" style="pointer-events: none">
{{ n.name.length > 20 ? `${n.name.substring(0, 20)}...` : n.name }}
</text>
</g>
<path
v-for="(l, index) in topologyLayout.calls"
:key="index"
class="link"
:d="`M${l.sourceObj.x} ${l.sourceObj.y}
L${l.targetObj.x} ${l.targetObj.y}`"
stroke="#999"
stroke-width="1"
/>
<g v-for="(l, index) in topologyLayout.calls" :key="index">
<path
class="topo-line"
:d="`M${l.sourceObj.x} ${l.sourceObj.y}
L${l.targetObj.x} ${l.targetObj.y}`"
stroke="#aaa"
stroke-width="1"
marker-end="url(#arrow)"
/>
<circle
class="topo-line-anchor"
:cx="(l.sourceObj.x + l.targetObj.x) / 2"
:cy="(l.sourceObj.y + l.targetObj.y) / 2"
r="4"
fill="#aaa"
@click="handleLinkClick($event, l)"
@mouseover="showLinkTip($event, l)"
@mouseout="hideTip"
/>
</g>
<g class="arrows">
<defs v-for="(_, index) in topologyLayout.calls" :key="index">
<marker
id="arrow"
markerUnits="strokeWidth"
markerWidth="8"
markerHeight="8"
viewBox="0 0 12 12"
refX="10"
refY="6"
orient="auto"
>
<path d="M2,2 L10,6 L2,10 L6,6 L2,2" fill="#999" />
</marker>
</defs>
</g>
</svg>
<!-- <div class="legend">
<div id="tooltip"></div>
<div class="legend">
<div>
<img :src="icons.CUBE" />
<span>
@ -85,7 +127,7 @@ limitations under the License. -->
<span v-for="(item, index) of items" :key="index" @click="item.func(item.dashboard)">
{{ item.title }}
</span>
</div> -->
</div>
</div>
</template>
<script lang="ts" setup>
@ -93,11 +135,8 @@ limitations under the License. -->
import { ref, onMounted, onBeforeUnmount, reactive, watch, computed, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import * as d3 from "d3";
import d3tip from "d3-tip";
import zoom from "../../components/utils/zoom";
import { simulationInit, simulationSkip } from "./utils/simulation";
import nodeElement from "./utils/nodeElement";
import { linkElement, anchorElement, arrowMarker } from "./utils/linkElement";
// import { simulationInit, simulationSkip } from "./utils/simulation";
import type { Node, Call } from "@/types/topology";
import { useSelectorStore } from "@/store/modules/selectors";
import { useTopologyStore } from "@/store/modules/topology";
@ -134,12 +173,10 @@ limitations under the License. -->
const simulation = ref<any>(null);
const svg = ref<Nullable<any>>(null);
const chart = ref<Nullable<HTMLDivElement>>(null);
const tip = ref<Nullable<HTMLDivElement>>(null);
const graph = ref<any>(null);
const node = ref<any>(null);
const link = ref<any>(null);
const anchor = ref<any>(null);
const arrow = ref<any>(null);
const showSetting = ref<boolean>(false);
const settings = ref<any>(props.config);
const operationsPos = reactive<{ x: number; y: number }>({ x: NaN, y: NaN });
@ -147,6 +184,7 @@ limitations under the License. -->
const graphConfig = computed(() => props.config.graph || {});
const depth = ref<number>(graphConfig.value.depth || 2);
const topologyLayout = ref<any>({});
const tooltip = ref<Nullable<any>>(null);
onMounted(async () => {
await nextTick();
@ -176,9 +214,8 @@ limitations under the License. -->
// svg.value = d3.select(chart.value).append("svg").attr("class", "topo-svg");
await initLegendMetrics();
draw();
// await init();
// update();
// setNodeTools(settings.value.nodeDashboard);
tooltip.value = d3.select("#tooltip");
setNodeTools(settings.value.nodeDashboard);
});
function draw() {
const levels = [];
@ -212,26 +249,14 @@ limitations under the License. -->
}
}
topologyLayout.value = layout(levels, topologyStore.calls);
console.log(topologyLayout.value);
}
async function init() {
tip.value = (d3tip as any)().attr("class", "d3-tip").offset([-8, 0]);
graph.value = svg.value.append("g").attr("class", "topo-svg-graph").attr("transform", `translate(-100, -100)`);
graph.value.call(tip.value);
simulation.value = simulationInit(d3, topologyStore.nodes, topologyStore.calls, ticked);
node.value = graph.value.append("g").selectAll(".topo-node");
link.value = graph.value.append("g").selectAll(".topo-line");
anchor.value = graph.value.append("g").selectAll(".topo-line-anchor");
arrow.value = graph.value.append("g").selectAll(".topo-line-arrow");
svg.value.call(zoom(d3, graph.value, [-100, -100]));
svg.value.on("click", (event: PointerEvent) => {
event.stopPropagation();
event.preventDefault();
topologyStore.setNode(null);
topologyStore.setLink(null);
dashboardStore.selectWidget(props.config);
});
function handleSvg(event: MouseEvent) {
event.stopPropagation();
event.preventDefault();
topologyStore.setNode(null);
topologyStore.setLink(null);
dashboardStore.selectWidget(props.config);
}
async function initLegendMetrics() {
@ -245,6 +270,65 @@ limitations under the License. -->
}
}
}
function showNodeTip(event: MouseEvent, data: Node) {
const nodeMetrics: string[] = settings.value.nodeMetrics || [];
const nodeMetricConfig = settings.value.nodeMetricConfig || [];
const html = nodeMetrics.map((m, index) => {
const metric =
topologyStore.nodeMetricValue[m].values.find((val: { id: string; value: unknown }) => val.id === data.id) || {};
const opt: MetricConfigOpt = nodeMetricConfig[index] || {};
const v = aggregation(metric.value, opt);
return ` <div class="mb-5"><span class="grey">${opt.label || m}: </span>${v} ${opt.unit || ""}</div>`;
});
const tipHtml = [` <div class="mb-5"><span class="grey">name: </span>${data.name}</div>`, ...html].join(" ");
tooltip.value
.style("top", event.offsetY + "px")
.style("left", event.offsetX + "px")
.style("visibility", "visible")
.html(tipHtml);
}
function showLinkTip(event: MouseEvent, data: Call) {
const linkClientMetrics: string[] = settings.value.linkClientMetrics || [];
const linkServerMetricConfig: MetricConfigOpt[] = settings.value.linkServerMetricConfig || [];
const linkClientMetricConfig: MetricConfigOpt[] = settings.value.linkClientMetricConfig || [];
const linkServerMetrics: string[] = settings.value.linkServerMetrics || [];
const htmlServer = linkServerMetrics.map((m, index) => {
const metric = topologyStore.linkServerMetrics[m].values.find(
(val: { id: string; value: unknown }) => val.id === data.id,
);
if (metric) {
const opt: MetricConfigOpt = linkServerMetricConfig[index] || {};
const v = aggregation(metric.value, opt);
return ` <div class="mb-5"><span class="grey">${opt.label || m}: </span>${v} ${opt.unit || ""}</div>`;
}
});
const htmlClient = linkClientMetrics.map((m: string, index: number) => {
const opt: MetricConfigOpt = linkClientMetricConfig[index] || {};
const metric = topologyStore.linkClientMetrics[m].values.find(
(val: { id: string; value: unknown }) => val.id === data.id,
);
if (metric) {
const v = aggregation(metric.value, opt);
return ` <div class="mb-5"><span class="grey">${opt.label || m}: </span>${v} ${opt.unit || ""}</div>`;
}
});
const html = [
...htmlServer,
...htmlClient,
`<div><span class="grey">${t("detectPoint")}:</span>${data.detectPoints.join(" | ")}</div>`,
].join(" ");
tooltip.value
.style("top", event.offsetY + "px")
.style("left", event.offsetX + "px")
.style("visibility", "visible")
.html(html);
}
function hideTip() {
tooltip.value.style("visibility", "hidden");
}
function ticked() {
link.value.attr(
"d",
@ -281,7 +365,8 @@ limitations under the License. -->
simulation.value.alphaTarget(0);
}
}
function handleNodeClick(event: PointerEvent, d: Node & { x: number; y: number }) {
function handleNodeClick(event: MouseEvent, d: Node & { x: number; y: number }) {
hideTip();
topologyStore.setNode(d);
topologyStore.setLink(null);
operationsPos.x = event.offsetX;
@ -294,7 +379,7 @@ limitations under the License. -->
{ id: "alarm", title: "Alarm", func: handleGoAlarm },
];
}
function handleLinkClick(event: PointerEvent, d: Call) {
function handleLinkClick(event: MouseEvent, d: Call) {
if (d.source.layer !== dashboardStore.layerId || d.target.layer !== dashboardStore.layerId) {
return;
}
@ -321,114 +406,6 @@ limitations under the License. -->
window.open(routeUrl.href, "_blank");
dashboardStore.setEntity(origin);
}
function update() {
// node element
if (!node.value || !link.value) {
return;
}
node.value = node.value.data(topologyStore.nodes, (d: Node) => d.id);
node.value.exit().remove();
node.value = nodeElement(
d3,
node.value.enter(),
{
dragstart: dragstart,
dragged: dragged,
dragended: dragended,
handleNodeClick: handleNodeClick,
tipHtml: (data: Node) => {
const nodeMetrics: string[] = settings.value.nodeMetrics || [];
const nodeMetricConfig = settings.value.nodeMetricConfig || [];
const html = nodeMetrics.map((m, index) => {
const metric =
topologyStore.nodeMetricValue[m].values.find(
(val: { id: string; value: unknown }) => val.id === data.id,
) || {};
const opt: MetricConfigOpt = nodeMetricConfig[index] || {};
const v = aggregation(metric.value, opt);
return ` <div class="mb-5"><span class="grey">${opt.label || m}: </span>${v} ${opt.unit || ""}</div>`;
});
return [` <div class="mb-5"><span class="grey">name: </span>${data.name}</div>`, ...html].join(" ");
},
},
tip.value,
settings.value.legend,
).merge(node.value);
// line element
link.value = link.value.data(topologyStore.calls, (d: Call) => d.id);
link.value.exit().remove();
link.value = linkElement(link.value.enter()).merge(link.value);
// anchorElement
anchor.value = anchor.value.data(topologyStore.calls, (d: Call) => d.id);
anchor.value.exit().remove();
anchor.value = anchorElement(
anchor.value.enter(),
{
handleLinkClick: handleLinkClick,
tipHtml: (data: Call) => {
const linkClientMetrics: string[] = settings.value.linkClientMetrics || [];
const linkServerMetricConfig: MetricConfigOpt[] = settings.value.linkServerMetricConfig || [];
const linkClientMetricConfig: MetricConfigOpt[] = settings.value.linkClientMetricConfig || [];
const linkServerMetrics: string[] = settings.value.linkServerMetrics || [];
const htmlServer = linkServerMetrics.map((m, index) => {
const metric = topologyStore.linkServerMetrics[m].values.find(
(val: { id: string; value: unknown }) => val.id === data.id,
);
if (metric) {
const opt: MetricConfigOpt = linkServerMetricConfig[index] || {};
const v = aggregation(metric.value, opt);
return ` <div class="mb-5"><span class="grey">${opt.label || m}: </span>${v} ${opt.unit || ""}</div>`;
}
});
const htmlClient = linkClientMetrics.map((m: string, index: number) => {
const opt: MetricConfigOpt = linkClientMetricConfig[index] || {};
const metric = topologyStore.linkClientMetrics[m].values.find(
(val: { id: string; value: unknown }) => val.id === data.id,
);
if (metric) {
const v = aggregation(metric.value, opt);
return ` <div class="mb-5"><span class="grey">${opt.label || m}: </span>${v} ${opt.unit || ""}</div>`;
}
});
const html = [
...htmlServer,
...htmlClient,
`<div><span class="grey">${t("detectPoint")}:</span>${data.detectPoints.join(" | ")}</div>`,
].join(" ");
return html;
},
},
tip.value,
).merge(anchor.value);
// arrow marker
arrow.value = arrow.value.data(topologyStore.calls, (d: Call) => d.id);
arrow.value.exit().remove();
arrow.value = arrowMarker(arrow.value.enter()).merge(arrow.value);
// force element
simulation.value.nodes(topologyStore.nodes);
simulation.value
.force("link")
.links(topologyStore.calls)
.id((d: Call) => d.id);
simulationSkip(d3, simulation.value, ticked);
const loopMap: any = {};
for (let i = 0; i < topologyStore.calls.length; i++) {
const link: any = topologyStore.calls[i];
link.loopFactor = 1;
for (let j = 0; j < topologyStore.calls.length; j++) {
if (i === j || loopMap[i]) {
continue;
}
const otherLink = topologyStore.calls[j];
if (link.source.id === otherLink.target.id && link.target.id === otherLink.source.id) {
link.loopFactor = -1;
loopMap[j] = 1;
break;
}
}
}
}
async function handleInspect() {
svg.value.selectAll(".topo-svg-graph").remove();
const id = topologyStore.node.id;
@ -440,8 +417,6 @@ limitations under the License. -->
if (resp && resp.errors) {
ElMessage.error(resp.errors);
}
await init();
update();
}
function handleGoEndpoint(name: string) {
const path = `/dashboard/${dashboardStore.layerId}/${EntityType[2].value}/${topologyStore.node.id}/${name}`;
@ -479,8 +454,6 @@ limitations under the License. -->
if (resp && resp.errors) {
ElMessage.error(resp.errors);
}
await init();
update();
topologyStore.setNode(null);
topologyStore.setLink(null);
}
@ -547,8 +520,6 @@ limitations under the License. -->
return;
}
svg.value.selectAll(".topo-svg-graph").remove();
await init();
update();
}
async function changeDepth(opt: Option[] | any) {
@ -684,7 +655,8 @@ limitations under the License. -->
animation: topo-dash 0.5s linear infinite;
}
.topo-line-anchor {
.topo-line-anchor,
.topo-node {
cursor: pointer;
}
@ -695,34 +667,6 @@ limitations under the License. -->
opacity: 0.8;
}
}
.d3-tip {
line-height: 1;
padding: 8px;
color: #eee;
border-radius: 4px;
font-size: 12px;
z-index: 9999;
background: #252a2f;
}
.d3-tip:after {
box-sizing: border-box;
display: block;
font-size: 10px;
width: 100%;
line-height: 0.8;
color: #252a2f;
content: "\25BC";
position: absolute;
text-align: center;
}
.d3-tip.n:after {
margin: -2px 0 0 0;
top: 100%;
left: 0;
}
@keyframes topo-dash {
from {
stroke-dashoffset: 20;
@ -736,4 +680,13 @@ limitations under the License. -->
.el-loading-spinner {
top: 30%;
}
#tooltip {
position: absolute;
visibility: hidden;
padding: 5px;
border: 1px solid #000;
border-radius: 3px;
background-color: #fff;
}
</style>