feat(*): Add node search with highlight and dim on the diagram

This commit is contained in:
Josh Creek
2026-07-12 20:18:18 +01:00
parent 72fd77b9c9
commit 9cea45945c
2 changed files with 188 additions and 0 deletions
+135
View File
@@ -22,6 +22,7 @@
import loadNetworkData from '$lib/data/loadNetworkData';
import { validateNetworkData, type ValidationIssue } from '$lib/data/networkSchema';
import transformNetworkDataToGraph from '$lib/graph/transformNetworkData';
import { computeSearchMatches } from '$lib/graph/searchHighlight';
import type { GraphNodeData, GraphTransformResult } from '$lib/graph/types';
import { themeMode, type ThemeMode } from '$lib/stores/theme';
import type { NetworkData, Port } from '$lib/types';
@@ -78,6 +79,9 @@
let showEthernetLabels = false;
let diagramViewMode: DiagramViewMode = 'network';
let searchQuery = '';
let searchMatchCount = 0;
let lastTransformResult: GraphTransformResult | null = null;
let machineVmIndex: GraphTransformResult['machineVmIndex'] = {};
let collapsedHosts: Record<string, boolean> = {};
let exportFileName = 'network-diagram';
@@ -514,6 +518,25 @@
'font-size': 8,
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);
lastTransformResult = transformed;
machineVmIndex = transformed.machineVmIndex;
syncCollapsedHosts(machineVmIndex);
const visible = buildVisibleElements(transformed);
@@ -923,9 +947,50 @@
applyPhysicalEdgeDeconfliction(cy);
applyEthernetLabelVisibility(showEthernetLabels);
}
applySearchEmphasis();
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() {
const validation = validateNetworkData(networkData);
validationIssues = validation.errors;
@@ -1612,6 +1677,27 @@
<button type="button" class="controls-toggle" on:click={toggleMapControls}>Show Controls</button>
{:else}
<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={addMachine}>Add Machine</button>
<button type="button" on:click={addDevice}>Add Device</button>
@@ -2540,6 +2626,55 @@
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 {
display: inline-flex;
align-items: center;
+53
View File
@@ -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;
}