mirror of
https://github.com/jcreek/OpenNetworkDiagram.git
synced 2026-07-14 03:23:44 +00:00
feat(*): Add node search with highlight and dim on the diagram
This commit is contained in:
@@ -22,6 +22,7 @@
|
|||||||
import loadNetworkData from '$lib/data/loadNetworkData';
|
import loadNetworkData from '$lib/data/loadNetworkData';
|
||||||
import { validateNetworkData, type ValidationIssue } from '$lib/data/networkSchema';
|
import { validateNetworkData, type ValidationIssue } from '$lib/data/networkSchema';
|
||||||
import transformNetworkDataToGraph from '$lib/graph/transformNetworkData';
|
import transformNetworkDataToGraph from '$lib/graph/transformNetworkData';
|
||||||
|
import { computeSearchMatches } from '$lib/graph/searchHighlight';
|
||||||
import type { GraphNodeData, GraphTransformResult } from '$lib/graph/types';
|
import type { GraphNodeData, GraphTransformResult } from '$lib/graph/types';
|
||||||
import { themeMode, type ThemeMode } from '$lib/stores/theme';
|
import { themeMode, type ThemeMode } from '$lib/stores/theme';
|
||||||
import type { NetworkData, Port } from '$lib/types';
|
import type { NetworkData, Port } from '$lib/types';
|
||||||
@@ -78,6 +79,9 @@
|
|||||||
|
|
||||||
let showEthernetLabels = false;
|
let showEthernetLabels = false;
|
||||||
let diagramViewMode: DiagramViewMode = 'network';
|
let diagramViewMode: DiagramViewMode = 'network';
|
||||||
|
let searchQuery = '';
|
||||||
|
let searchMatchCount = 0;
|
||||||
|
let lastTransformResult: GraphTransformResult | null = null;
|
||||||
let machineVmIndex: GraphTransformResult['machineVmIndex'] = {};
|
let machineVmIndex: GraphTransformResult['machineVmIndex'] = {};
|
||||||
let collapsedHosts: Record<string, boolean> = {};
|
let collapsedHosts: Record<string, boolean> = {};
|
||||||
let exportFileName = 'network-diagram';
|
let exportFileName = 'network-diagram';
|
||||||
@@ -514,6 +518,25 @@
|
|||||||
'font-size': 8,
|
'font-size': 8,
|
||||||
color: theme === 'dark' ? '#94a3b8' : '#64748b'
|
color: theme === 'dark' ? '#94a3b8' : '#64748b'
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'node.search-match',
|
||||||
|
style: {
|
||||||
|
'border-width': 4,
|
||||||
|
'border-color': '#f59e0b'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'node.dimmed',
|
||||||
|
style: {
|
||||||
|
opacity: 0.15
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'edge.dimmed',
|
||||||
|
style: {
|
||||||
|
opacity: 0.1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -910,6 +933,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const transformed = transformNetworkDataToGraph(networkData);
|
const transformed = transformNetworkDataToGraph(networkData);
|
||||||
|
lastTransformResult = transformed;
|
||||||
machineVmIndex = transformed.machineVmIndex;
|
machineVmIndex = transformed.machineVmIndex;
|
||||||
syncCollapsedHosts(machineVmIndex);
|
syncCollapsedHosts(machineVmIndex);
|
||||||
const visible = buildVisibleElements(transformed);
|
const visible = buildVisibleElements(transformed);
|
||||||
@@ -923,9 +947,50 @@
|
|||||||
applyPhysicalEdgeDeconfliction(cy);
|
applyPhysicalEdgeDeconfliction(cy);
|
||||||
applyEthernetLabelVisibility(showEthernetLabels);
|
applyEthernetLabelVisibility(showEthernetLabels);
|
||||||
}
|
}
|
||||||
|
applySearchEmphasis();
|
||||||
tooltip.visible = false;
|
tooltip.visible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applySearchEmphasis() {
|
||||||
|
if (!cy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
cy.batch(() => {
|
||||||
|
cy?.elements().removeClass('dimmed search-match');
|
||||||
|
});
|
||||||
|
searchMatchCount = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = lastTransformResult
|
||||||
|
? computeSearchMatches(lastTransformResult, searchQuery)
|
||||||
|
: new Set<string>();
|
||||||
|
let matchCount = 0;
|
||||||
|
cy.batch(() => {
|
||||||
|
for (const node of cy?.nodes().toArray() ?? []) {
|
||||||
|
const isMatch = matches.has(node.id());
|
||||||
|
node.toggleClass('search-match', isMatch);
|
||||||
|
node.toggleClass('dimmed', !isMatch);
|
||||||
|
if (isMatch) {
|
||||||
|
matchCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const edge of cy?.edges().toArray() ?? []) {
|
||||||
|
const touchesMatch =
|
||||||
|
matches.has(String(edge.data('source'))) || matches.has(String(edge.data('target')));
|
||||||
|
edge.toggleClass('dimmed', !touchesMatch);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
searchMatchCount = matchCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
searchQuery = '';
|
||||||
|
applySearchEmphasis();
|
||||||
|
}
|
||||||
|
|
||||||
function revalidateDraft() {
|
function revalidateDraft() {
|
||||||
const validation = validateNetworkData(networkData);
|
const validation = validateNetworkData(networkData);
|
||||||
validationIssues = validation.errors;
|
validationIssues = validation.errors;
|
||||||
@@ -1612,6 +1677,27 @@
|
|||||||
<button type="button" class="controls-toggle" on:click={toggleMapControls}>Show Controls</button>
|
<button type="button" class="controls-toggle" on:click={toggleMapControls}>Show Controls</button>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="map-controls">
|
<div class="map-controls">
|
||||||
|
<div class="search-control">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Find name, IP, role…"
|
||||||
|
aria-label="Search diagram nodes"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
on:input={applySearchEmphasis}
|
||||||
|
/>
|
||||||
|
{#if searchQuery.trim()}
|
||||||
|
<span class="search-count">{searchMatchCount}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-clear"
|
||||||
|
aria-label="Clear search"
|
||||||
|
title="Clear search"
|
||||||
|
on:click={clearSearch}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<button type="button" on:click={loadData} disabled={isLoadingData}>Reload JSON</button>
|
<button type="button" on:click={loadData} disabled={isLoadingData}>Reload JSON</button>
|
||||||
<button type="button" on:click={addMachine}>Add Machine</button>
|
<button type="button" on:click={addMachine}>Add Machine</button>
|
||||||
<button type="button" on:click={addDevice}>Add Device</button>
|
<button type="button" on:click={addDevice}>Add Device</button>
|
||||||
@@ -2540,6 +2626,55 @@
|
|||||||
min-width: 13.5rem;
|
min-width: 13.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-control {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
background: var(--panel-bg);
|
||||||
|
color: var(--panel-contrast);
|
||||||
|
padding: 0.2rem 0.35rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-control input {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--panel-contrast);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
width: 10.5rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-control input::placeholder {
|
||||||
|
color: color-mix(in oklab, var(--panel-contrast) 55%, transparent 45%);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-control:focus-within {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-count {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f59e0b;
|
||||||
|
min-width: 1.1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--panel-contrast);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 0.15rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.select-control {
|
.select-control {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { GraphNodeDetails, GraphNodeElement, GraphTransformResult } from './types';
|
||||||
|
|
||||||
|
function collectSearchableText(node: GraphNodeElement): string[] {
|
||||||
|
const texts: string[] = [node.data.rawName ?? node.data.label];
|
||||||
|
const details = node.data.details as GraphNodeDetails | undefined;
|
||||||
|
if (!details) {
|
||||||
|
return texts;
|
||||||
|
}
|
||||||
|
|
||||||
|
texts.push(details.ip);
|
||||||
|
if (details.type === 'machine') {
|
||||||
|
texts.push(details.role, details.os);
|
||||||
|
for (const vm of details.vms) {
|
||||||
|
texts.push(vm.name, vm.ip, vm.role);
|
||||||
|
}
|
||||||
|
} else if (details.type === 'device') {
|
||||||
|
texts.push(details.deviceType);
|
||||||
|
} else {
|
||||||
|
texts.push(details.role);
|
||||||
|
}
|
||||||
|
return texts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the IDs of nodes matching the query (case-insensitive substring on
|
||||||
|
* name, IP, role, OS and device type). When a VM matches, its host machine ID
|
||||||
|
* is included too so hits remain visible while the host's VMs are collapsed.
|
||||||
|
*/
|
||||||
|
export function computeSearchMatches(
|
||||||
|
transformed: Pick<GraphTransformResult, 'nodes'>,
|
||||||
|
query: string
|
||||||
|
): Set<string> {
|
||||||
|
const matches = new Set<string>();
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of transformed.nodes) {
|
||||||
|
const isMatch = collectSearchableText(node).some((text) =>
|
||||||
|
text.toLowerCase().includes(normalized)
|
||||||
|
);
|
||||||
|
if (!isMatch) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
matches.add(node.data.id);
|
||||||
|
if (node.data.kind === 'vm' && node.data.hostMachineId) {
|
||||||
|
matches.add(node.data.hostMachineId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user