Skip to main content
Glama
Map.vue54.5 kB
<!-- eslint-disable vue/no-multiple-template-root --> <template> <ExploreMapSkeleton v-if="showSkeleton" /> <section id="map" :class=" clsx( 'grid h-full', showSkeleton && 'hidden', // Since the svgs need a target to be drawn, we need to have this in the DOM ) " > <div v-if="selectedComponent && selectedComponents.size === 1" id="selection" :class=" clsx('absolute top-[110px] w-[435px] right-3', 'flex flex-col gap-xs') " style="max-height: calc(100vh - 115px)" > <div :class=" clsx( 'flex-none p-xs', selectedComponentHasFailedActions ? themeClasses('bg-destructive-100', 'bg-destructive-900') : themeClasses('bg-action-200', 'bg-action-800'), ) " :style="{ border: selectedComponentHasFailedActions ? '0.5px solid rgb(239 68 68)' // destructive-500 : '0.5px solid rgb(59 130 246)', // action-500 }" > <ExploreGridTile ref="selectedGridTileRef" :component="selectedComponent!" hideConnections @click="navigateToSelectedComponent" /> </div> <div class="scrollable grow"> <ConnectionsPanel :component="selectedComponent!" inMap /> </div> </div> <div id="controls" class="absolute left-0 bottom-0 flex flex-col gap-xs m-sm items-start" > <!-- Minimap --> <MiniMap v-show="showMinimap" :layoutData="dataAsGraph" :worldBounds="worldBounds" :viewportCoordinates="viewportCoordinates" :currentScale="transformMatrix[0] ?? 1" @pan="pan" /> <!-- Control buttons --> <div class="flex flex-row gap-xs items-center"> <div v-tooltip="showMinimap ? 'Hide Minimap' : 'Show Minimap'" :class="getButtonClasses(false)" @click="toggleMinimap" > <Icon name="minimap" size="sm" /> </div> <div v-tooltip="'Zoom Out'" :class="getButtonClasses(zoomLevel >= MAX_ZOOM)" @click="zoomOut" > <Icon name="minus" size="sm" /> </div> <div v-tooltip="'Current Zoom'" :class=" clsx( 'border p-2xs rounded select-none', themeClasses( 'text-black border-black bg-white', 'text-white border-white bg-black', ), ) " > {{ Math.round(zoomLevel * 100) }}% </div> <div v-tooltip="'Zoom In'" :class="getButtonClasses(zoomLevel >= MAX_ZOOM)" @click="zoomIn" > <Icon name="plus" size="sm" /> </div> <div v-tooltip="'Reset'" :class="getButtonClasses(false)" @click="reset" > <Icon name="empty-square" size="sm" /> </div> <div v-tooltip="'Help'" :class="getButtonClasses(false)" @click="emit('help')" > <Icon name="question-circle" size="sm" /> </div> </div> </div> <svg :class="mouseDown ? 'cursor-grabbing' : 'cursor-grab'" height="100%" width="100%" preserveAspectRatio="xMidYMid" :viewBox="`0 0 ${viewBox} ${viewBox}`" @wheel="wheel" @mousedown.left.prevent="mousedown" @mouseup.left="mouseup" @mousemove="mousemove" > <defs v-for="logo in logos" :key="logo"> <pattern :id="`${logo}`" width="1" height="1" patternUnits="objectBoundingBox" > <IconNoWrapper :name="logo" :class="themeClasses('text-black', 'text-white')" :fillColor="theme === 'light' ? 'black' : 'white'" :forcedSizeNumbers="getLogoForcedSizeNumbers(logo)" /> </pattern> </defs> <template v-for="icon in icons" :key="icon"> <defs v-for="tone in tones" :key="tone"> <pattern :id="`${icon}-${tone}`" width="30" height="30" patternUnits="objectBoundingBox" > <IconNoWrapper :name="icon" :tone="tone" size="sm" /> </pattern> </defs> </template> <!-- Arrow markers for connection directions --> <defs> <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="8" refY="3.5" orient="auto" > <path d="M 0,0 L 8,3.5 L 0,7" fill="none" :stroke="themeClasses('#6b7280', '#9ca3af')" stroke-width="1.5" stroke-linejoin="miter" /> </marker> <marker id="arrowhead-highlighted" markerWidth="10" markerHeight="7" refX="8" refY="3.5" orient="auto" > <path d="M 0,0 L 8,3.5 L 0,7" fill="none" :stroke="themeClasses('#3b82f6', '#93c5fd')" stroke-width="1.5" stroke-linejoin="miter" /> </marker> <marker id="arrowhead-greyed" markerWidth="10" markerHeight="7" refX="8" refY="3.5" orient="auto" > <path d="M 0,0 L 8,3.5 L 0,7" fill="none" :stroke="themeClasses('#d1d5db', '#4b5563')" stroke-width="1.5" stroke-linejoin="miter" opacity="0.3" /> </marker> </defs> <g id="mapRootGroup" :transform="`matrix(${transformMatrix})`"></g> </svg> <ComponentContextMenu ref="componentContextMenuRef" onGrid hidePin hideBulk @edit="navigateToSelectedComponent" /> </section> </template> <script lang="ts" setup> import { useQuery } from "@tanstack/vue-query"; import { IconNoWrapper, Icon, IconNames, Tones, themeClasses, LOGO_ICONS, useTheme, LOGO_FORCED_SIZE_NUMBERS, } from "@si/vue-lib/design-system"; import { computed, inject, nextTick, onMounted, onUnmounted, reactive, ref, watch, } from "vue"; import ELK from "elkjs/lib/elk.bundled.js"; import * as d3 from "d3"; import clsx from "clsx"; import { tw } from "@si/vue-lib"; import { useRoute, useRouter } from "vue-router"; import * as _ from "lodash-es"; import { ComponentId } from "@/api/sdf/dal/component"; import { IncomingConnections, ComponentInList, EntityKind, } from "@/workers/types/entity_kind_types"; import { bifrostList, useMakeArgs, useMakeKey, } from "@/store/realtime/heimdall"; import ExploreMapSkeleton from "@/newhotness/skeletons/ExploreMapSkeleton.vue"; import { SelectionsInQueryString } from "./Workspace.vue"; import { KeyDetails } from "./logic_composables/emitters"; import { assertIsDefined, Context, ExploreContext } from "./types"; import ExploreGridTile from "./explore_grid/ExploreGridTile.vue"; import ConnectionsPanel from "./ConnectionsPanel.vue"; import { pickBrandIconByString } from "./util"; import ComponentContextMenu from "./ComponentContextMenu.vue"; import { truncateString } from "./logic_composables/string_funcs"; import MiniMap from "./MiniMap.vue"; const MAX_STRING_LENGTH = 18; const router = useRouter(); const { theme } = useTheme(); const props = defineProps<{ active: boolean; components: ComponentInList[]; componentsWithFailedActions: Set<ComponentId>; }>(); const componentsById = computed<Record<ComponentId, ComponentInList>>(() => { return (props.components || []).reduce((obj, component) => { obj[component.id] = component; return obj; }, {} as Record<ComponentId, ComponentInList>); }); const componentContextMenuRef = ref<InstanceType<typeof ComponentContextMenu>>(); const selectedComponents = ref<Set<ComponentInList>>(new Set()); // Store selected component IDs to maintain selection across component updates const selectedComponentIds = computed( () => new Set(Array.from(selectedComponents.value).map((c) => c.id)), ); // Watch for componentsById changes and update selectedComponents with new object references watch(componentsById, (newComponentsById) => { if (selectedComponentIds.value.size > 0) { const newSelectedComponents = new Set<ComponentInList>(); selectedComponentIds.value.forEach((componentId) => { const component = newComponentsById[componentId]; if (component) { newSelectedComponents.add(component); } }); selectedComponents.value = newSelectedComponents; emit("selectedComponents", selectedComponents.value); } }); const showMinimap = ref((props.components?.length ?? 0) > 0); watch( () => props.components?.length ?? 0, (newLength, oldLength) => { const isHideUnconnected = router.currentRoute.value.query.hideSubscriptions === "1"; if (oldLength === 0 && newLength > 0) { // Only show minimap if not in hideSubscriptions mode showMinimap.value = !isHideUnconnected; } else if (oldLength > 0 && newLength === 0) { showMinimap.value = false; } }, ); // Hide minimap when in hideSubscriptions mode watch( () => router.currentRoute.value.query.hideSubscriptions, (hideSubscriptions) => { if (hideSubscriptions === "1") { showMinimap.value = false; } else { // Restore minimap if we have components showMinimap.value = (props.components?.length ?? 0) > 0; } }, { immediate: true }, ); // Get the primary selected component (first one in the set) const selectedComponent = computed<ComponentInList | null>(() => { if (selectedComponents.value.size === 0) { return null; } const firstComponent = selectedComponents.value.values().next().value; return firstComponent || null; }); // Check if selected component has failed actions const selectedComponentHasFailedActions = computed(() => { return ( selectedComponent.value && props.componentsWithFailedActions.has(selectedComponent.value.id) ); }); const ctx = inject<Context>("CONTEXT"); assertIsDefined(ctx); const explore = inject<ExploreContext>("EXPLORE_CONTEXT"); assertIsDefined<ExploreContext>(explore); const showSkeleton = computed(() => explore.showSkeleton.value); // don't change this! // magic number puts the yellow dot in the middle! // driving me a bit insane const viewBox = ref(1500); const origCenter = ref(viewBox.value / 2); const origTransformMatrix = [1, 0, 0, 1, 0, 0]; const transformMatrix = reactive<number[]>([...origTransformMatrix]); const scaleStep = 0.13; const baseZoom = 1; const zoomLevel = ref(baseZoom); const lastPos = ref<xy | null>(null); const MAX_ZOOM = 5; const MIN_ZOOM = 0.2; const applyZoom = () => { zoomLevel.value = Math.fround(zoomLevel.value); zoomLevel.value = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoomLevel.value)); // Zoom implementation // Attempt 1 // "FPS style" - always zoom to the center point of the SVG // move what you want to the center to see it // first and fourth elements of the matrix describe // the X & Y scale (aka zoom) of the coordinate space const scaleX = transformMatrix[0] ?? 1; const scaleY = transformMatrix[3] ?? 1; // the last two are X & Y adjustments to origin 0,0 // 0,0 is top left, positive values move down and right // negative values move up and left // panning the stage changes these values without changing scale const translateX = transformMatrix[4] ?? 0; const translateY = transformMatrix[5] ?? 0; /** find the center of the SVG coordinate space * as we scale the coordinate space we need to adjust * what "center" is e.g. zooming in, space between points * grows which moves the center point down and right * and so the top left must move negatively to keep * the center point _in_ the center of the screen */ const svgCenterX = origCenter.value; const svgCenterY = origCenter.value; /** get the difference between "center" and where * we have moved the origin off 0,0, from both panning and zoom * and divide by the current scale. this normalizes us back to * an "unscaled" coordinate space */ const worldX = (svgCenterX - translateX) / scaleX; const worldY = (svgCenterY - translateY) / scaleY; const newScale = zoomLevel.value; // now translate the new points against the new zoom level // to get the proper X, Y panning adjustments for the grown/shrunk // coordinate space const newTranslateX = svgCenterX - worldX * newScale; const newTranslateY = svgCenterY - worldY * newScale; transformMatrix.splice( 0, 6, newScale, 0, 0, newScale, newTranslateX, newTranslateY, ); }; const pan = (dx: number, dy: number) => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const x = transformMatrix[4]!; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const y = transformMatrix[5]!; transformMatrix.splice(4, 1, x + dx); transformMatrix.splice(5, 1, y + dy); // TODO(Wendy) - this fixes the position of the context menu // but it is not a perfect fix, as it doesn't handle the // selected component going near the edges or offscreen well :( if (selectedComponent.value && componentContextMenuRef.value?.isOpen) { selectComponent(selectedComponent.value); } }; let currentPanAnimationId: number | null = null; let lastUserPanTime = 0; const smoothPan = ( totalDx: number, totalDy: number, duration = 400, isUserInitiated = false, ) => { // Cancel any ongoing animation if (currentPanAnimationId !== null) { cancelAnimationFrame(currentPanAnimationId); currentPanAnimationId = null; } if (isUserInitiated) { lastUserPanTime = performance.now(); } const startTime = performance.now(); const startTranslateX = transformMatrix[4] ?? 0; const startTranslateY = transformMatrix[5] ?? 0; const animate = (currentTime: number) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // Ease-out cubic for smooth deceleration const easeProgress = 1 - (1 - progress) ** 3; // Calculate current target position const currentX = startTranslateX + totalDx * easeProgress; const currentY = startTranslateY + totalDy * easeProgress; // Set the transform matrix directly transformMatrix.splice(4, 1, currentX); transformMatrix.splice(5, 1, currentY); if (progress < 1) { currentPanAnimationId = requestAnimationFrame(animate); } else { currentPanAnimationId = null; } }; currentPanAnimationId = requestAnimationFrame(animate); }; // Viewport preservation functions const preserveViewportCenter = () => { const scale = transformMatrix[0] ?? 1; const translateX = transformMatrix[4] ?? 0; const translateY = transformMatrix[5] ?? 0; return { worldCenterX: (windowDimensions.value.width / 2 - translateX) / scale, worldCenterY: (windowDimensions.value.height / 2 - translateY) / scale, scale, }; }; const restoreViewportCenter = ( snapshot: ReturnType<typeof preserveViewportCenter>, layoutData: GraphData | null, ) => { if (!layoutData?.children) return; const scale = transformMatrix[0] ?? 1; const newTranslateX = windowDimensions.value.width / 2 - snapshot.worldCenterX * scale; const newTranslateY = windowDimensions.value.height / 2 - snapshot.worldCenterY * scale; transformMatrix.splice(4, 1, newTranslateX); transformMatrix.splice(5, 1, newTranslateY); }; // Helper functions for creating node elements and edges // eslint-disable-next-line @typescript-eslint/no-explicit-any const addNodeElements = (group: any, d: layoutNode) => { group .append("rect") .attr("width", d.width) .attr("height", d.height) .attr("class", () => { const classes = [`node`, `id-${d.component.id}`]; // Check if this component is in the current selection const isSelected = Array.from(selectedComponents.value).some( (c) => c.id === d.component.id, ); if (isSelected) { classes.push("selected"); } if (props.componentsWithFailedActions.has(d.component.id)) { classes.push("failed-actions"); } return classes.join(" "); }) .on("click", (e: MouseEvent) => { clickedNode(e, d); }) .on("contextmenu", (e: MouseEvent) => { rightClickedNode(e, d); }) .on("dblclick", (_e: Event) => { componentNavigate(d.component.id); }); group .append("path") .attr("d", () => { const lineGenerator = d3.line(); return lineGenerator([ [0, 0], [0, HEIGHT], ]); }) .attr("stroke", d.component.color ?? "#111111") .attr("stroke-width", 3) .attr("pointer-events", "none"); // logos group .append("path") .attr("d", d3.symbol().size(1000).type(d3.symbolSquare)) .attr("transform", "translate(23, 35)") .attr("pointer-events", "none") .style("fill", () => { const icon = pickBrandIconByString(d.component.schemaCategory); return `url(#${icon})`; }); group .append("text") .text(truncateString(d.component.name, MAX_STRING_LENGTH)) .attr("dx", "45") .attr("dy", "25") .attr("class", "name") .attr("alignment-baseline", "middle") .attr("pointer-events", "none"); group .append("text") .text(truncateString(d.component.schemaVariantName, MAX_STRING_LENGTH)) .attr("dx", "45") .attr("dy", "45") .attr("class", "") .attr("alignment-baseline", "middle") .attr("color", "white") .attr("pointer-events", "none"); // qual & resource icons d.icons.forEach((icon: string | null, idx: number) => { group .append("path") .attr("d", d3.symbol().size(1000).type(d3.symbolSquare)) .attr("transform", `translate(${WIDTH - 13 - 23 * idx}, ${HEIGHT - 9})`) .attr("pointer-events", "none") .style("fill", icon ? `url(#${icon})` : "none"); }); // Socket connections alert icon if (d.component.hasSocketConnections) { group .append("path") .attr("d", d3.symbol().size(1000).type(d3.symbolSquare)) .attr("transform", () => { const iconOffset = d.icons.length * 23; return `translate(${WIDTH - 13 - iconOffset - 23}, ${HEIGHT - 9})`; }) .attr("pointer-events", "none") .style("fill", "url(#alert-triangle-filled-warning)"); } }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const getEdgeStyling = (edgeId: string) => { const [targetId, sourceId] = edgeId.split("-"); const isConnectedToSelected = Array.from(selectedComponents.value).some( (component) => targetId === component.id || sourceId === component.id, ); const isDark = document.body.classList.contains("dark"); const connectedColor = isDark ? "#93c5fd" : "#3b82f6"; const greyedColor = isDark ? "#4b5563" : "#d1d5db"; const defaultColor = isDark ? "#9ca3af" : "#6b7280"; const hasSelection = selectedComponents.value.size > 0; let stroke = defaultColor; let opacity = "1"; let markerEnd = "url(#arrowhead)"; if (hasSelection) { stroke = isConnectedToSelected ? connectedColor : greyedColor; opacity = isConnectedToSelected ? "1" : "0.3"; markerEnd = isConnectedToSelected ? "url(#arrowhead-highlighted)" : "url(#arrowhead-greyed)"; } return { isConnectedToSelected, stroke, strokeWidth: isConnectedToSelected ? "2" : "1", opacity, markerEnd, }; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any const renderEdges = (svg: any, edges: layoutLine[]) => { svg .selectAll(".edge") .data(edges) .enter() .append("path") .attr("class", (d: layoutLine) => { const styling = getEdgeStyling(d.id); return `edge ${styling.isConnectedToSelected ? "connected" : ""}`; }) .attr("data-edge-id", (d: layoutLine) => d.id) .attr("d", (d: layoutLine) => { const lineGenerator = d3.line(); const points: xy[] = []; d.sections.forEach((section) => { points.push({ x: section.startPoint.x, y: section.startPoint.y }); if (section.bendPoints) points.push(...section.bendPoints); points.push({ x: section.endPoint.x, y: section.endPoint.y }); }); const pairs: Array<[number, number]> = points.map((p) => [p.x, p.y]); return lineGenerator(pairs); }) .attr("marker-end", (d: layoutLine) => getEdgeStyling(d.id).markerEnd) .style("fill", "none") .style("stroke", (d: layoutLine) => getEdgeStyling(d.id).stroke) .style("stroke-width", (d: layoutLine) => getEdgeStyling(d.id).strokeWidth) .style("opacity", (d: layoutLine) => getEdgeStyling(d.id).opacity); }; // Update edge styling without redrawing // eslint-disable-next-line @typescript-eslint/no-explicit-any const updateEdgeStyling = (svg: any) => { // eslint-disable-next-line func-names svg.selectAll(".edge").each(function (this: SVGPathElement) { const edgeId = d3.select(this).attr("data-edge-id"); if (edgeId) { const styling = getEdgeStyling(edgeId); d3.select(this) .attr( "class", `edge ${styling.isConnectedToSelected ? "connected" : ""}`, ) .attr("marker-end", styling.markerEnd) .style("stroke", styling.stroke) .style("stroke-width", styling.strokeWidth) .style("opacity", styling.opacity); } }); }; // Function to ensure selection state is properly applied to DOM elements const applySelectionState = () => { // Clear all selection classes first const allNodes = document.querySelectorAll("#map > svg rect.node"); allNodes.forEach((element) => { element.classList.remove("selected"); }); // Apply selection to currently selected components selectedComponents.value.forEach((component) => { const element = document.querySelector( `#map > svg rect.node.id-${component.id}`, ); if (element) { element.classList.add("selected"); } }); // Update failed action classes document.querySelectorAll("#map > svg rect.node").forEach((element) => { const componentId = Array.from(element.classList) .find((cls) => cls.startsWith("id-")) ?.substring(3); if (componentId) { const hasFailedActions = props.componentsWithFailedActions.has(componentId); if (hasFailedActions) { element.classList.add("failed-actions"); } else { element.classList.remove("failed-actions"); } } }); }; const panToComponent = (componentId: string, isUserInitiated = false) => { if (!dataAsGraph.value?.children) { // Layout not ready yet, retry after a short delay setTimeout(() => panToComponent(componentId, isUserInitiated), 100); return; } // Find the node for this component const node = dataAsGraph.value.children.find( (n: layoutNode) => n.component.id === componentId, ); if (!node) { // Node not found in layout, retry after a short delay setTimeout(() => panToComponent(componentId, isUserInitiated), 100); return; } // Calculate the center of the component node const componentCenterX = node.x + node.width / 2; const componentCenterY = node.y + node.height / 2; // Calculate the center of the viewport const viewportCenterX = windowDimensions.value.width / 2; const viewportCenterY = windowDimensions.value.height / 2; // Get current scale and transform const scale = transformMatrix[0] ?? 1; const currentTranslateX = transformMatrix[4] ?? 0; const currentTranslateY = transformMatrix[5] ?? 0; // Calculate where the component center currently appears on screen const currentScreenX = componentCenterX * scale + currentTranslateX; const currentScreenY = componentCenterY * scale + currentTranslateY; // Calculate how much we need to pan to center the component const panDx = viewportCenterX - currentScreenX; const panDy = viewportCenterY - currentScreenY; // Use smooth animated pan smoothPan(panDx, panDy, 400, isUserInitiated); }; const mouseDown = ref(false); // Reactive window dimensions for accurate viewport tracking const windowDimensions = ref({ width: window.innerWidth, height: window.innerHeight, }); // Update window dimensions reactively const updateWindowDimensions = () => { windowDimensions.value = { width: window.innerWidth, height: window.innerHeight, }; }; // Calculate world bounds for minimap coordinate system const worldBounds = computed(() => { if (!dataAsGraph.value?.children || dataAsGraph.value.children.length === 0) { return { minX: -500, minY: -500, maxX: 500, maxY: 500, width: 1000, height: 1000, }; } // Calculate bounding box of all nodes let nodeMinX = Infinity; let nodeMinY = Infinity; let nodeMaxX = -Infinity; let nodeMaxY = -Infinity; dataAsGraph.value.children.forEach((node: layoutNode) => { nodeMinX = Math.min(nodeMinX, node.x); nodeMinY = Math.min(nodeMinY, node.y); nodeMaxX = Math.max(nodeMaxX, node.x + node.width); nodeMaxY = Math.max(nodeMaxY, node.y + node.height); }); // Calculate current viewport area const currentScale = transformMatrix[0] ?? 1; const translateX = transformMatrix[4] ?? 0; const translateY = transformMatrix[5] ?? 0; const actualWidth = windowDimensions.value.width; const actualHeight = windowDimensions.value.height; const viewportWidth = actualWidth / currentScale; const viewportHeight = actualHeight / currentScale; const viewportX = -translateX / currentScale; const viewportY = -translateY / currentScale; // Strategy: minimap should always show all nodes prominently for navigation // The world bounds should be large enough to include both nodes and viewport, // but sized so that nodes remain visible and useful // Calculate how much larger the viewport is compared to the node area const nodeWidth = nodeMaxX - nodeMinX; const nodeHeight = nodeMaxY - nodeMinY; // Always include the viewport area let finalMinX = Math.min(nodeMinX, viewportX); let finalMinY = Math.min(nodeMinY, viewportY); let finalMaxX = Math.max(nodeMaxX, viewportX + viewportWidth); let finalMaxY = Math.max(nodeMaxY, viewportY + viewportHeight); // If the total area is much larger than the node area, constrain it so nodes stay visible const totalWidth = finalMaxX - finalMinX; const totalHeight = finalMaxY - finalMinY; const maxReasonableExpansion = 4; // Don't let the world be more than 4x the node size if (totalWidth > nodeWidth * maxReasonableExpansion) { const maxWidth = nodeWidth * maxReasonableExpansion; const centerX = (finalMinX + finalMaxX) / 2; finalMinX = centerX - maxWidth / 2; finalMaxX = centerX + maxWidth / 2; } if (totalHeight > nodeHeight * maxReasonableExpansion) { const maxHeight = nodeHeight * maxReasonableExpansion; const centerY = (finalMinY + finalMaxY) / 2; finalMinY = centerY - maxHeight / 2; finalMaxY = centerY + maxHeight / 2; } // Add small padding for clean edges const padding = 20; return { minX: finalMinX - padding, minY: finalMinY - padding, maxX: finalMaxX + padding, maxY: finalMaxY + padding, width: finalMaxX - finalMinX + padding * 2, height: finalMaxY - finalMinY + padding * 2, }; }); // Calculate viewport coordinates for minimap const viewportCoordinates = computed(() => { const scale = transformMatrix[0] ?? 1; const translateX = transformMatrix[4] ?? 0; const translateY = transformMatrix[5] ?? 0; // Use actual window dimensions for accurate viewport representation const actualWidth = windowDimensions.value.width; const actualHeight = windowDimensions.value.height; // Calculate the viewport area in the main coordinate space const viewportWidth = actualWidth / scale; const viewportHeight = actualHeight / scale; const viewportX = -translateX / scale; const viewportY = -translateY / scale; return { x: viewportX, y: viewportY, width: viewportWidth, height: viewportHeight, }; }); const zoomIn = () => { zoomLevel.value += scaleStep; applyZoom(); }; const zoomOut = () => { zoomLevel.value -= scaleStep; applyZoom(); }; const wheel = (event: WheelEvent) => { if (event.metaKey || event.ctrlKey) { // Zoom behavior when modifier key is held if (event.deltaY > 0) { zoomOut(); } else { zoomIn(); } } else { // Pan behavior - move map in scroll direction const panSpeed = 40; pan((-event.deltaX * panSpeed) / 100, (-event.deltaY * panSpeed) / 100); } }; const reset = () => { zoomLevel.value = 1; transformMatrix.splice(0, 6, 1, 0, 0, 1, 0, 0); }; const toggleMinimap = () => { showMinimap.value = !showMinimap.value; }; const clickWithNoDrag = ref(false); const mousedown = () => { mouseDown.value = true; clickWithNoDrag.value = true; }; const mouseup = (e: MouseEvent) => { mouseDown.value = false; const target = e.target; if (target instanceof SVGRectElement && target.classList.contains("node")) { // don't deselect if you clicked on a node! return; } else if (clickWithNoDrag.value) { deselect(); emit("deselect"); } }; const active = computed(() => props.active); const KEYSTEP = 10; const onArrowDown = () => { if (!active.value) return; pan(0, -KEYSTEP); }; const onArrowUp = () => { if (!active.value) return; pan(0, KEYSTEP); }; const onArrowLeft = () => { if (!active.value) return; pan(KEYSTEP, 0); }; const onArrowRight = () => { if (!active.value) return; pan(-KEYSTEP, 0); }; const onEscape = () => { if (!active.value) return; // If components are selected, deselect them first if (selectedComponents.value.size > 0) { deselect(); } else { // If no components selected, close map and return to grid const query = { ...router.currentRoute.value?.query }; delete query.map; delete query.c; delete query.hideSubscriptions; // Also clear hideSubscriptions when closing map delete query.showDiff; query.grid = "1"; router.push({ query }); } }; const onE = (_e: KeyDetails["e"]) => { if (selectedComponents.value.size > 0) { componentContextMenuRef.value?.componentsStartErase( Array.from(selectedComponents.value), ); } }; const onD = (e: KeyDetails["d"]) => { if (selectedComponents.value.size > 0 && (e.metaKey || e.ctrlKey)) { const componentIds = Array.from(selectedComponents.value).map( (component) => component.id, ); componentContextMenuRef.value?.duplicateComponentStart(componentIds); } }; const onP = (_e: KeyDetails["p"]) => { // Do nothing! Pinning is unsupported in the map view. }; const onU = (_e: KeyDetails["u"]) => { // if (selectedComponent.value && selectedComponent.value.canBeUpgraded) { // componentContextMenuRef.value?.componentUpgrade([ // selectedComponent.value.id, // ]); // } }; const onBackspace = (_e: KeyDetails["Backspace"]) => { if (selectedComponents.value.size > 0) { const componentsToDelete = Array.from(selectedComponents.value).filter( (component) => !component.toDelete, ); if (componentsToDelete.length > 0) { componentContextMenuRef.value?.componentsStartDelete(componentsToDelete); } } }; const onR = (_e: KeyDetails["r"]) => { if (selectedComponents.value.size > 0) { const componentsToRestore = Array.from(selectedComponents.value).filter( (component) => component.toDelete, ); if (componentsToRestore.length > 0) { const componentIds = componentsToRestore.map((component) => component.id); componentContextMenuRef.value?.componentsRestore(componentIds); } } }; const onM = (_e: KeyDetails["m"]) => { toggleMinimap(); }; onMounted(() => { // if we need to adjust zoom level on load dynamically // change it here applyZoom(); // Set up window resize listener for reactive dimensions window.addEventListener("resize", updateWindowDimensions); }); onUnmounted(() => { window.removeEventListener("resize", updateWindowDimensions); }); const mousemove = (event: MouseEvent) => { clickWithNoDrag.value = false; const current: xy = { x: event.clientX, y: event.clientY }; if (!mouseDown.value || !lastPos.value) { lastPos.value = { ...current }; return; } const diff: xy = { x: current.x - lastPos.value.x, y: current.y - lastPos.value.y, }; lastPos.value = { ...current }; pan(diff.x, diff.y); }; const key = useMakeKey(); const args = useMakeArgs(); const queryKey = key(EntityKind.IncomingConnectionsList); const logos = reactive<IconNames[]>(Object.keys(LOGO_ICONS) as IconNames[]); const getLogoForcedSizeNumbers = (logoName: IconNames) => { return LOGO_FORCED_SIZE_NUMBERS[logoName] ?? undefined; }; const icons = reactive<IconNames[]>([ "check-hex-outline", "check-hex", "x-hex-outline", "alert-triangle-filled", ]); const tones = reactive<Tones[]>(["success", "destructive", "warning"]); const connections = useQuery<IncomingConnections[]>({ queryKey, enabled: () => active.value, // Only run query when map view is active queryFn: async () => { const d = await bifrostList<IncomingConnections[] | null>( args(EntityKind.IncomingConnectionsList), ); return d ?? []; }, }); const mapData = computed(() => { const nodes = new Set<string>(); const edges = new Set<string>(); const components: Record<string, ComponentInList> = {}; if (!connections.data.value || !componentsById.value) { return { nodes, edges, components }; } // Check if we should filter to only show connected components const shouldHideUnconnected = router.currentRoute.value.query.hideSubscriptions === "1"; const showOnlyDiff = router.currentRoute.value.query.showDiff === "1"; const hasSelectedComponents = selectedComponents.value.size > 0; // First pass: collect all components and their connections const allComponents = new Map<string, ComponentInList>(); const allConnections = new Set<string>(); connections.data.value.forEach((c) => { const component = componentsById.value[c.id]; if (!component) return; if (showOnlyDiff && component.diffStatus === "None") return; allComponents.set(c.id, component); c.connections.forEach((e) => { if ( !componentsById.value[e.toComponentId] || !componentsById.value[e.fromComponentId] ) return; const edge = `${e.toComponentId}-${e.fromComponentId}`; allConnections.add(edge); }); }); // If we're in hideSubscriptions mode and have selected components, filter the data if (shouldHideUnconnected && hasSelectedComponents) { // Get the connected component IDs (including selected ones) const connectedIds = new Set<string>(); // Always include selected components selectedComponents.value.forEach((comp) => { connectedIds.add(comp.id); }); // Add directly connected components selectedComponents.value.forEach((selectedComp) => { if (showOnlyDiff && selectedComp.diffStatus === "None") return; const selectedId = selectedComp.id; connections.data.value?.forEach((component) => { component.connections.forEach((connection) => { if (connection.toComponentId === selectedId) { connectedIds.add(connection.fromComponentId); } if (connection.fromComponentId === selectedId) { connectedIds.add(connection.toComponentId); } }); }); }); // Only include connected components in the final data connectedIds.forEach((componentId) => { const component = allComponents.get(componentId); if (component) { nodes.add(componentId); components[componentId] = component; } }); // Only include edges between visible components allConnections.forEach((edgeId) => { const [targetId, sourceId] = edgeId.split("-"); if ( targetId && sourceId && connectedIds.has(targetId) && connectedIds.has(sourceId) ) { edges.add(edgeId); } }); } else { // Normal mode: include all components allComponents.forEach((component, componentId) => { if (!showOnlyDiff || (showOnlyDiff && component.diffStatus !== "None")) { nodes.add(componentId); components[componentId] = component; } }); allConnections.forEach((edge) => { edges.add(edge); }); } return { nodes, edges, components }; }); export type node = { id: string; width: number; height: number; component: ComponentInList; icons: [string | null]; }; export type xy = { x: number; y: number; }; export type h = { $H: number; }; export type edge = { id: string; sources: string[]; targets: string[]; }; export type line = { sections: { id: string; startPoint: xy; endPoint: xy; bendPoints?: xy[]; incomingShape: string; outgoingShape: string; }[]; container: string; }; export type layoutNode = node & xy & h; export type layoutLine = line & edge; // Type for ELK layout result - simplified to match actual usage export type GraphData = { children: layoutNode[]; edges: unknown[]; // ELK transforms edges in complex ways [key: string]: unknown; // Allow other ELK properties }; const dataAsGraph = ref<GraphData | null>(null); const previousLayout = ref<GraphData | null>(null); const WIDTH = 270; const HEIGHT = 75; const selectedGridTileRef = ref<InstanceType<typeof ExploreGridTile>>(); const clickedNode = (e: MouseEvent, n: layoutNode) => { e.preventDefault(); e.stopPropagation(); if (e.shiftKey) { // Multi-select mode with shift-click const newSelectedComponents = new Set(selectedComponents.value); if (newSelectedComponents.has(n.component)) { // Remove from selection if already selected newSelectedComponents.delete(n.component); } else { // Add to selection newSelectedComponents.add(n.component); } selectedComponents.value = newSelectedComponents; // Update selectedComponentIds to maintain stable selection // Close context menu during multi-select if (componentContextMenuRef.value?.isOpen) { componentContextMenuRef.value?.close(); } } else { // Single select mode if (selectedComponent.value?.id === n.component.id) { navigateToSelectedComponent(); } else { // Close context menu if it's open before selecting new component if (componentContextMenuRef.value?.isOpen) { componentContextMenuRef.value?.close(); } // Clear multi-select and set single selection selectedComponents.value = new Set([n.component]); // Update edge styling immediately without redrawing // This prevents jitter from edges being removed/redrawn during pan const svg = d3.select("#map > svg g"); updateEdgeStyling(svg); // Pan to center the selected component (mark as user-initiated) panToComponent(n.component.id, true); } } emit("selectedComponents", selectedComponents.value); }; const rightClickedNode = (e: MouseEvent, n: layoutNode) => { e.preventDefault(); e.stopPropagation(); // If the right-clicked component is not in the current selection, add it to selection if (!selectedComponents.value.has(n.component)) { const newSelectedComponents = new Set(selectedComponents.value); newSelectedComponents.add(n.component); selectedComponents.value = newSelectedComponents; emit("selectedComponents", selectedComponents.value); } // Show context menu for all selected components // wait for the map to animate setTimeout(() => { showContextMenuForSelection(n.component, e.target as Element); }, 500); }; const showContextMenuForSelection = ( anchorComponent: ComponentInList, componentEl?: Element, ) => { nextTick(() => { let element = componentEl; if (!element) { element = document.getElementsByClassName(`id-${anchorComponent.id}`)[0]; if (!element) return; } const rect = element.getBoundingClientRect(); const anchor = { $el: element, getBoundingClientRect: () => ({ ...rect, left: rect.right, x: rect.right, width: 0, }), }; // Pass all selected components to the context menu const componentsForMenu = Array.from(selectedComponents.value); componentContextMenuRef.value?.open(anchor, componentsForMenu); }); }; const selectComponent = (component: ComponentInList, componentEl?: Element) => { selectedComponents.value = new Set([component]); showContextMenuForSelection(component, componentEl); emit("selectedComponents", selectedComponents.value); }; const deselect = () => { selectedComponents.value = new Set(); componentContextMenuRef.value?.close(); emit("selectedComponents", selectedComponents.value); }; // Note: Connection filtering is now handled in the mapData computed, // so we no longer need separate DOM manipulation for hiding/showing components const fillDefault = ref<string[]>(); onMounted(() => { const query: SelectionsInQueryString = { ...router.currentRoute.value?.query, }; if (query.c) { // Parse comma-separated component IDs fillDefault.value = query.c.split(",").filter((id) => id.trim()); } }); // Clean up hideSubscriptions parameter when leaving the map onUnmounted(() => { const currentQuery = router.currentRoute.value.query; if (currentQuery.hideSubscriptions === "1") { const newQuery = { ...currentQuery }; delete newQuery.hideSubscriptions; router.replace({ query: newQuery }); } }); // Watch for when components become available and retry selection if needed watch( [componentsById, fillDefault], () => { if ( fillDefault.value && fillDefault.value.length > 0 && Object.keys(componentsById.value).length > 0 ) { nextTick(() => { const selectedComps: ComponentInList[] = []; fillDefault.value?.forEach((componentId) => { const component = componentsById.value[componentId]; if (component) { selectedComps.push(component); } }); if (selectedComps.length > 0) { selectedComponents.value = new Set(selectedComps); emit("selectedComponents", selectedComponents.value); // Set up pending pan for when layout becomes available if (selectedComps[0]) { pendingPanComponent.value = selectedComps[0].id; } fillDefault.value = undefined; } }); } }, { immediate: true }, ); // Watch for when layout data becomes available and pan to selected component from URL const pendingPanComponent = ref<string | null>(null); watch( [dataAsGraph, selectedComponents], () => { // If we have a pending pan request and the layout is now available if ( pendingPanComponent.value && dataAsGraph.value?.children && selectedComponents.value.size > 0 ) { const componentId = pendingPanComponent.value; const node = dataAsGraph.value.children.find( (n: layoutNode) => n.component.id === componentId, ); if (node) { // Use nextTick to ensure DOM is updated nextTick(() => { panToComponent(componentId); pendingPanComponent.value = null; }); } } }, { immediate: true }, ); // when the change set changes, we dont want to animate, we want to re-draw entirely. watch(ctx.changeSetId, () => { previousLayout.value = null; }); // debouncing since the fzf and svg is actually a bit of a grind for every key press // PSA: selecting a component re-fires this watcher. Why? Entirely unsure. watch( mapData, _.debounce( async () => { // if we filtered away our selection remove it if ( selectedComponent.value?.id && !mapData.value.components[selectedComponent.value.id] ) deselect(); const children: node[] = [...mapData.value.nodes].map((nId) => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const component = mapData.value.components[nId]!; const icons: [string | null] = [ component.hasResource ? "check-hex-success" : null, ]; if (component.qualificationTotals.failed > 0) icons.push("x-hex-outline-destructive"); else icons.push("check-hex-outline-success"); return { id: nId, width: WIDTH, height: HEIGHT, component, icons }; }); const validNodeIds = new Set(mapData.value.nodes); const edges: edge[] = [...mapData.value.edges] .map((eId) => { const [target, source] = eId.split("-"); return { id: eId, sources: [source ?? ""], targets: [target ?? ""] }; }) .filter((edge) => { // Only include edges where both source and target exist in the nodes const source = edge.sources[0]; const target = edge.targets[0]; const sourceExists = source && validNodeIds.has(source); const targetExists = target && validNodeIds.has(target); return sourceExists && targetExists; }); if (!children || !edges) return null; const graph = { id: "root", layoutOptions: { "elk.algorithm": "layered", direction: "DOWN", spacing: "80", "layered.spacing.nodeNodeBetweenLayers": "150", "spacing.nodeNode": "80", padding: "50", }, children, edges, }; // Preserve current viewport center before layout change const viewportSnapshot = preserveViewportCenter(); const elk = new ELK(); const layoutedData = await elk.layout(graph); // typescript gods help me dataAsGraph.value = layoutedData as unknown as GraphData; // Restore viewport to maintain visual continuity if (previousLayout.value && dataAsGraph.value) { restoreViewportCenter(viewportSnapshot, dataAsGraph.value); } nextTick(() => { const svg = d3.select("#map > svg #mapRootGroup"); // the viewbox should be based on "how much of the coordinate space is in use" // the types generated aren't exactly matching the actual data! // this is what I see const children = layoutedData.children as layoutNode[]; const edges = layoutedData.edges as layoutLine[]; // Check if we should animate or do immediate update const shouldAnimate = previousLayout.value && previousLayout.value.children; if (shouldAnimate && previousLayout.value) { // Animate existing nodes to new positions const oldNodes = previousLayout.value.children as layoutNode[]; const newNodeIds = new Set(children.map((n) => n.id)); // Remove deleted nodes with fade out oldNodes.forEach((oldNode) => { if (!newNodeIds.has(oldNode.id)) { svg .select(`g:has(rect.node.id-${oldNode.id})`) .transition() .duration(300) .style("opacity", 0) .remove(); } }); // Animate existing nodes to new positions /** * PSA for this FN and interaction with the filter box * (or anything that will repeatedly change `componentsById`) * * Once a component gets added (in the `else`) it will be animating * The next time through this fn it will run the `if` because it will exist. * * All animation "end states" in the adding (`else`) need to be defined in * the "update" (`if`) too. * * For example, if a node gets added, and its opacity has not finished animating * when it runs through the update, the new transition will be applied leaving its * opacity "halfway through" animating. You need to have (opacity, 1) in the transition * definition (even if its redundant for everything already at opacity = 1) * */ children.forEach(async (newNode) => { const existingGroup = svg.select( `g:has(rect.node.id-${newNode.id})`, ); if (!existingGroup.empty()) { // Animate to new position existingGroup .transition() .duration(600) .ease(d3.easeCubicOut) .style("opacity", 1) .attr("transform", `translate(${newNode.x}, ${newNode.y})`); // Update selection state on existing nodes const rect = existingGroup.select("rect.node"); const isSelected = selectedComponentIds.value.has(newNode.id); const hasFailedActions = props.componentsWithFailedActions.has( newNode.id, ); rect.attr("class", () => { const classes = [`node`, `id-${newNode.id}`]; if (isSelected) classes.push("selected"); if (hasFailedActions) classes.push("failed-actions"); return classes.join(" "); }); } else { // Add new node with fade in const group = svg .append("g") .attr("transform", `translate(${newNode.x}, ${newNode.y})`) .style("opacity", 0); addNodeElements(group, newNode); group.transition().duration(400).style("opacity", 1); } }); svg.selectAll(".edge").remove(); renderEdges(svg, edges); // Pan to selected component, but skip if user just initiated a pan // (prevents interrupting user-initiated pan animations) if (selectedComponent.value) { const timeSinceUserPan = performance.now() - lastUserPanTime; if (timeSinceUserPan >= 500) { panToComponent(selectedComponent.value.id); } } // Selection state is already handled during animation above } else { // First render - clear and redraw immediately svg.selectAll("*").remove(); children.forEach((node) => { const group = svg .append("g") .attr("transform", `translate(${node.x}, ${node.y})`); addNodeElements(group, node); }); renderEdges(svg, edges); // Ensure selection state is applied after first render nextTick(() => { applySelectionState(); }); } // Store current layout for next animation previousLayout.value = layoutedData as unknown as GraphData; }); // Don't show context menu when component is selected via URL parameter // Context menu should only show on user interaction (right-click) }, 100, { trailing: true }, ), { immediate: true }, ); function getButtonClasses(isDisabled: boolean) { return clsx( tw`rounded-full p-1 border`, themeClasses( "bg-neutral-600 text-white active:bg-neutral-200 hover:bg-neutral-400 active:text-black active:border-black", "bg-neutral-200 text-black active:bg-neutral-700 hover:bg-neutral-400 active:text-white active:border-white", ), isDisabled ? tw`cursor-not-allowed opacity-50` : tw`cursor-pointer`, ); } const route = useRoute(); const componentNavigate = (componentId: ComponentId) => { const params = { ...route.params }; params.componentId = componentId; router.push({ name: "new-hotness-component", params, query: {}, }); }; const navigateToSelectedComponent = () => { if (selectedComponent.value?.id) { componentNavigate(selectedComponent.value.id); } }; // TODO there's a noticeable time before drawing svgs that we should take into account for setting this flag const isLoading = computed(() => connections.isLoading.value); const emit = defineEmits<{ (e: "deselect"): void; (e: "selectedComponents", components: Set<ComponentInList>): void; (e: "help"): void; }>(); defineExpose({ selectedComponents, deselect, navigateToSelectedComponent, onArrowUp, onArrowDown, onArrowLeft, onArrowRight, onEscape, onE, onD, onP, onU, onBackspace, onR, onM, isLoading, }); </script> <style lang="less"> #map > svg { rect.node { fill: @colors-neutral-100; stroke: @colors-neutral-500; stroke-width: 2; body.dark & { fill: @colors-neutral-800; } &.selected { stroke: @colors-action-500; stroke-width: 3; body.dark & { stroke: @colors-action-300; } } &.failed-actions { stroke: @colors-destructive-600; stroke-width: 2; body.dark & { stroke: @colors-destructive-400; } } &.selected.failed-actions { stroke: @colors-destructive-600; stroke-width: 3; body.dark & { stroke: @colors-destructive-400; } } &:hover { fill: @colors-neutral-200; body.dark & { fill: @colors-neutral-700; } cursor: pointer; } &.selected:hover { fill: @colors-neutral-200; body.dark & { fill: @colors-neutral-700; } } &.greyed-out { fill: @colors-neutral-200; stroke: @colors-neutral-300; opacity: 0.3; body.dark & { fill: @colors-neutral-700; stroke: @colors-neutral-600; } } } text { fill: black; body.dark & { fill: white; } font-size: 0.75rem; &.name { font-weight: bold; font-size: 0.875rem; } &.greyed-out { fill: @colors-neutral-400; opacity: 0.6; body.dark & { fill: @colors-neutral-500; } } } path.edge { stroke: @colors-neutral-500; stroke-width: 1; body.dark & { stroke: @colors-neutral-400; } &.connected { stroke: @colors-action-500; stroke-width: 2; body.dark & { stroke: @colors-action-300; } } } } </style>

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/systeminit/si'

If you have feedback or need assistance with the MCP directory API, please join our Discord server