Add topology display pickers for label, edge-number, and vendor icon colors.

Persist choices in localStorage, apply via canvas CSS variables, and tint router icons with mask fills so each vendor color is editable.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-05 09:29:02 +08:00
parent 4a0918ab44
commit 65afe8f29f
4 changed files with 288 additions and 51 deletions

View file

@ -1466,7 +1466,25 @@ const en = {
display: "Display",
canvasBg: "Canvas background",
canvasBgColor: "Background color",
canvasBgReset: "Reset light blue",
canvasBgReset: "Reset default",
textColors: "Text colors",
textColorsReset: "Reset default",
labelColor: "NE name",
edgeLabelColor: "Link numbers",
vendorColors: "Vendor icon colors",
vendorColorsReset: "Reset default",
vendorTone: {
cisco: "Cisco",
huawei: "Huawei",
zte: "ZTE",
juniper: "Juniper",
nokia: "Nokia",
ericsson: "Ericsson",
h3c: "H3C",
ruijie: "Ruijie",
mikrotik: "MikroTik",
gray: "Unknown / unmanaged",
},
hideIp: "Hide IP",
hideVendor: "Hide vendor",
hidePorts: "Hide ports",

View file

@ -1460,7 +1460,25 @@ const zh = {
display: "显示",
canvasBg: "画布背景",
canvasBgColor: "背景色",
canvasBgReset: "恢复浅蓝",
canvasBgReset: "恢复默认",
textColors: "文字颜色",
textColorsReset: "恢复默认",
labelColor: "网元名称",
edgeLabelColor: "链路数字",
vendorColors: "厂商图标色",
vendorColorsReset: "恢复默认",
vendorTone: {
cisco: "Cisco",
huawei: "华为",
zte: "中兴",
juniper: "Juniper",
nokia: "Nokia",
ericsson: "Ericsson",
h3c: "H3C",
ruijie: "锐捷",
mikrotik: "MikroTik",
gray: "未知/未托管",
},
hideIp: "隐藏 IP",
hideVendor: "隐藏厂商",
hidePorts: "隐藏端口",

View file

@ -6877,7 +6877,7 @@ pre {
min-height: 0;
background: var(--topo-canvas-bg, #0f172a);
--xy-edge-label-background-color: transparent;
--xy-edge-label-color: #e2e8f0;
--xy-edge-label-color: var(--topo-edge-label-color, #e2e8f0);
}
.topo-canvas:fullscreen,
@ -7023,50 +7023,39 @@ pre {
box-shadow: none;
font-size: 11px;
text-align: center;
/* Base asset /topo/ne-router.png is navy-blue; tints approximate brand hues. */
--topo-icon-filter: none;
--topo-icon-color: var(--topo-vendor-gray, #94a3b8);
}
/* Cisco — cyan / light blue (#049FD9) */
.topo-node--cisco {
--topo-icon-filter: hue-rotate(-18deg) saturate(1.25) brightness(1.08);
--topo-icon-color: var(--topo-vendor-cisco, #049fd9);
}
/* Huawei — red (#CF0A2C); was incorrectly greenish at 155deg */
.topo-node--huawei {
--topo-icon-filter: hue-rotate(128deg) saturate(1.85) brightness(1.02);
--topo-icon-color: var(--topo-vendor-huawei, #cf0a2c);
}
/* ZTE — keep navy-blue base (#0091DA family) */
.topo-node--zte {
--topo-icon-filter: saturate(1.15) brightness(1.02);
--topo-icon-color: var(--topo-vendor-zte, #0091da);
}
/* Juniper — green (#84B135) */
.topo-node--juniper {
--topo-icon-filter: hue-rotate(78deg) saturate(1.35) brightness(1.05);
--topo-icon-color: var(--topo-vendor-juniper, #84b135);
}
/* Nokia / Alcatel — deep blue-purple (#124191) */
.topo-node--nokia {
--topo-icon-filter: hue-rotate(-35deg) saturate(1.2) brightness(0.92);
--topo-icon-color: var(--topo-vendor-nokia, #124191);
}
/* Ericsson — dark navy */
.topo-node--ericsson {
--topo-icon-filter: hue-rotate(-8deg) saturate(1.1) brightness(0.82);
--topo-icon-color: var(--topo-vendor-ericsson, #1e3a5f);
}
/* H3C — green (#7AC143) */
.topo-node--h3c {
--topo-icon-filter: hue-rotate(88deg) saturate(1.45) brightness(1.06);
--topo-icon-color: var(--topo-vendor-h3c, #7ac143);
}
/* Ruijie — purple */
.topo-node--ruijie {
--topo-icon-filter: hue-rotate(45deg) saturate(1.4) brightness(1.0);
--topo-icon-color: var(--topo-vendor-ruijie, #7c3aed);
}
/* MikroTik — red-orange */
.topo-node--mikrotik {
--topo-icon-filter: hue-rotate(145deg) saturate(1.7) brightness(1.05);
--topo-icon-color: var(--topo-vendor-mikrotik, #ea580c);
}
/* Unmanaged / unknown vendor */
.topo-node--gray,
.topo-node--other {
--topo-icon-filter: grayscale(1) brightness(0.92) contrast(0.95);
--topo-icon-color: var(--topo-vendor-gray, #94a3b8);
}
.topo-node__glyph {
@ -7125,16 +7114,15 @@ pre {
.topo-node.is-selected .topo-node__icon {
transform: none;
filter: var(--topo-icon-filter, none);
}
.topo-node.is-selected .topo-node__caption {
color: #f8fafc;
font-weight: 700;
}
.topo-node.is-selected .topo-node__caption-name {
color: #93c5fd;
color: var(--topo-label-color, #f1f5f9);
filter: brightness(1.12);
}
/* Selected link: light highlight only (do not alter geometry / arrows). */
@ -7148,11 +7136,18 @@ pre {
width: 56px;
height: 56px;
margin: 0;
object-fit: contain;
pointer-events: none;
user-select: none;
filter: var(--topo-icon-filter, none);
transition: transform 0.12s ease, filter 0.12s ease;
background-color: var(--topo-icon-color, #3b82f6);
-webkit-mask-image: url(/topo/ne-router.png);
mask-image: url(/topo/ne-router.png);
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
transition: transform 0.12s ease, background-color 0.12s ease;
}
.topo-node__caption {
@ -7161,7 +7156,7 @@ pre {
align-items: center;
gap: 1px;
margin-top: 3px;
color: #e2e8f0;
color: var(--topo-label-color, #f1f5f9);
line-height: 1.2;
max-width: 160px;
pointer-events: none;
@ -7175,7 +7170,7 @@ pre {
max-width: 100%;
font-size: 11px;
font-weight: 700;
color: #f1f5f9;
color: var(--topo-label-color, #f1f5f9);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@ -7185,7 +7180,7 @@ pre {
display: block;
max-width: 100%;
font-size: 10px;
color: #94a3b8;
color: color-mix(in srgb, var(--topo-label-color, #f1f5f9) 55%, #64748b);
font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
white-space: nowrap;
overflow: hidden;
@ -7198,7 +7193,7 @@ pre {
}
.topo-canvas .react-flow__edge-text {
fill: #e2e8f0;
fill: var(--topo-edge-label-color, #e2e8f0);
font-size: 11px;
paint-order: stroke;
stroke: rgba(15, 23, 42, 0.85);
@ -7207,7 +7202,7 @@ pre {
.topo-edge-label {
font-size: 11px;
color: #e2e8f0;
color: var(--topo-edge-label-color, #e2e8f0);
background: transparent !important;
border: 0 !important;
border-radius: 0;
@ -7220,8 +7215,9 @@ pre {
}
.topo-edge-label.is-selected {
color: #93c5fd;
color: var(--topo-edge-label-color, #e2e8f0);
font-weight: 600;
filter: brightness(1.15);
}
.topo-ctx__member-list {

View file

@ -7,6 +7,7 @@
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
import { useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@ -370,17 +371,9 @@ function nodeMatchesQuery(n: Node<NeNodeData>, query: string): boolean {
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
}
/** Asset: /topo/ne-router.png (navy blue base); vendors tint via CSS filters. */
/** Silhouette via CSS mask; fill color comes from --topo-icon-color (vendor palette). */
function RouterIcon() {
return (
<img
className="topo-node__icon"
src="/topo/ne-router.png"
alt=""
draggable={false}
aria-hidden="true"
/>
);
return <span className="topo-node__icon" aria-hidden="true" />;
}
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */
@ -455,6 +448,46 @@ const CANVAS_BG_KEY = "netx.topology.canvasBg";
const DEFAULT_CANVAS_BG = "#0f172a";
/** Previous light default — migrate so existing sessions pick up dark canvas. */
const LEGACY_CANVAS_BG = "#dbeafe";
const LABEL_COLORS_KEY = "netx.topology.labelColors";
const VENDOR_COLORS_KEY = "netx.topology.vendorColors";
const VENDOR_TONE_KEYS = [
"cisco",
"huawei",
"zte",
"juniper",
"nokia",
"ericsson",
"h3c",
"ruijie",
"mikrotik",
"gray",
] as const;
type VendorToneKey = (typeof VENDOR_TONE_KEYS)[number];
type VendorColors = Record<VendorToneKey, string>;
type LabelColors = { name: string; edgeLabel: string };
const DEFAULT_LABEL_COLORS: LabelColors = {
name: "#f1f5f9",
edgeLabel: "#e2e8f0",
};
const DEFAULT_VENDOR_COLORS: VendorColors = {
cisco: "#049fd9",
huawei: "#cf0a2c",
zte: "#0091da",
juniper: "#84b135",
nokia: "#124191",
ericsson: "#1e3a5f",
h3c: "#7ac143",
ruijie: "#7c3aed",
mikrotik: "#ea580c",
gray: "#94a3b8",
};
function isHexColor(value: string): boolean {
return /^#[0-9a-fA-F]{6}$/.test(value);
}
function loadCanvasBg(): string {
try {
@ -463,7 +496,7 @@ function loadCanvasBg(): string {
persistCanvasBg(DEFAULT_CANVAS_BG);
return DEFAULT_CANVAS_BG;
}
if (/^#[0-9a-fA-F]{6}$/i.test(raw)) return raw;
if (isHexColor(raw)) return raw;
} catch {
/* ignore */
}
@ -478,6 +511,54 @@ function persistCanvasBg(value: string) {
}
}
function loadLabelColors(): LabelColors {
try {
const raw = localStorage.getItem(LABEL_COLORS_KEY);
if (!raw) return { ...DEFAULT_LABEL_COLORS };
const parsed = JSON.parse(raw) as Partial<LabelColors>;
return {
name: isHexColor(String(parsed.name || "")) ? String(parsed.name).toLowerCase() : DEFAULT_LABEL_COLORS.name,
edgeLabel: isHexColor(String(parsed.edgeLabel || ""))
? String(parsed.edgeLabel).toLowerCase()
: DEFAULT_LABEL_COLORS.edgeLabel,
};
} catch {
return { ...DEFAULT_LABEL_COLORS };
}
}
function persistLabelColors(value: LabelColors) {
try {
localStorage.setItem(LABEL_COLORS_KEY, JSON.stringify(value));
} catch {
/* ignore */
}
}
function loadVendorColors(): VendorColors {
try {
const raw = localStorage.getItem(VENDOR_COLORS_KEY);
if (!raw) return { ...DEFAULT_VENDOR_COLORS };
const parsed = JSON.parse(raw) as Partial<VendorColors>;
const out = { ...DEFAULT_VENDOR_COLORS };
for (const key of VENDOR_TONE_KEYS) {
const c = String(parsed?.[key] || "").trim().toLowerCase();
if (isHexColor(c)) out[key] = c;
}
return out;
} catch {
return { ...DEFAULT_VENDOR_COLORS };
}
}
function persistVendorColors(value: VendorColors) {
try {
localStorage.setItem(VENDOR_COLORS_KEY, JSON.stringify(value));
} catch {
/* ignore */
}
}
function canvasDotColor(bg: string): string {
const hex = String(bg || "").replace("#", "");
if (hex.length !== 6) return "#93c5fd";
@ -741,6 +822,8 @@ export function TopologyPage() {
const [edgeFlow, setEdgeFlow] = useState(false);
const [edgeDefaults, setEdgeDefaults] = useState<EdgeDefaults>(() => loadEdgeDefaults());
const [canvasBg, setCanvasBg] = useState(loadCanvasBg);
const [labelColors, setLabelColors] = useState<LabelColors>(() => loadLabelColors());
const [vendorColors, setVendorColors] = useState<VendorColors>(() => loadVendorColors());
const [toolMode, setToolMode] = useState<ToolMode>("select");
const [snapToGrid, setSnapToGrid] = useState(true);
const [autoLayoutAfterDiscover, setAutoLayoutAfterDiscover] = useState(loadAutoLayoutAfterDiscover);
@ -2900,16 +2983,122 @@ export function TopologyPage() {
const raw = e.target.value.trim();
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
setCanvasBg(raw);
if (/^#[0-9a-fA-F]{6}$/.test(raw)) persistCanvasBg(raw.toLowerCase());
if (isHexColor(raw)) persistCanvasBg(raw.toLowerCase());
}}
onBlur={() => {
if (!/^#[0-9a-fA-F]{6}$/.test(canvasBg)) {
if (!isHexColor(canvasBg)) {
setCanvasBg(loadCanvasBg());
}
}}
/>
</div>
</div>
<div className="topo-display-defaults topo-display-defaults--canvas-bg">
<div className="topo-display-defaults__head">
<strong>{t("topology.textColors")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
setLabelColors({ ...DEFAULT_LABEL_COLORS });
persistLabelColors({ ...DEFAULT_LABEL_COLORS });
}}
>
{t("topology.textColorsReset")}
</button>
</div>
{(
[
["name", t("topology.labelColor")],
["edgeLabel", t("topology.edgeLabelColor")],
] as const
).map(([key, label]) => (
<div key={key} className="topo-display-defaults__row">
<span className="topo-display-defaults__name">{label}</span>
<input
type="color"
value={labelColors[key]}
title={label}
onChange={(e) => {
const next = { ...labelColors, [key]: e.target.value.toLowerCase() };
setLabelColors(next);
persistLabelColors(next);
}}
/>
<input
className="topo-toolbar__select topo-canvas-bg-hex"
value={labelColors[key]}
spellCheck={false}
aria-label={label}
onChange={(e) => {
const raw = e.target.value.trim();
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
const next = { ...labelColors, [key]: raw };
setLabelColors(next);
if (isHexColor(raw)) persistLabelColors({ ...next, [key]: raw.toLowerCase() });
}}
onBlur={() => {
if (!isHexColor(labelColors[key])) {
setLabelColors(loadLabelColors());
}
}}
/>
</div>
))}
</div>
<div className="topo-display-defaults topo-display-defaults--canvas-bg">
<div className="topo-display-defaults__head">
<strong>{t("topology.vendorColors")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
setVendorColors({ ...DEFAULT_VENDOR_COLORS });
persistVendorColors({ ...DEFAULT_VENDOR_COLORS });
}}
>
{t("topology.vendorColorsReset")}
</button>
</div>
{VENDOR_TONE_KEYS.map((key) => {
const label = t(`topology.vendorTone.${key}`);
return (
<div key={key} className="topo-display-defaults__row">
<span className="topo-display-defaults__name">{label}</span>
<input
type="color"
value={vendorColors[key]}
title={label}
onChange={(e) => {
const next = { ...vendorColors, [key]: e.target.value.toLowerCase() };
setVendorColors(next);
persistVendorColors(next);
}}
/>
<input
className="topo-toolbar__select topo-canvas-bg-hex"
value={vendorColors[key]}
spellCheck={false}
aria-label={label}
onChange={(e) => {
const raw = e.target.value.trim();
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
const next = { ...vendorColors, [key]: raw };
setVendorColors(next);
if (isHexColor(raw)) {
persistVendorColors({ ...next, [key]: raw.toLowerCase() });
}
}}
onBlur={() => {
if (!isHexColor(vendorColors[key])) {
setVendorColors(loadVendorColors());
}
}}
/>
</div>
);
})}
</div>
<div className="topo-display-defaults">
<div className="topo-display-defaults__head">
<strong>{t("topology.edgeDefaults")}</strong>
@ -3327,7 +3516,23 @@ export function TopologyPage() {
<div
className={`topo-canvas${fullscreen ? " is-fullscreen" : ""}${toolMode === "pan" ? " is-pan-mode" : ""}`}
ref={canvasRef}
style={{ ["--topo-canvas-bg" as string]: canvasBg }}
style={
{
"--topo-canvas-bg": canvasBg,
"--topo-label-color": labelColors.name,
"--topo-edge-label-color": labelColors.edgeLabel,
"--topo-vendor-cisco": vendorColors.cisco,
"--topo-vendor-huawei": vendorColors.huawei,
"--topo-vendor-zte": vendorColors.zte,
"--topo-vendor-juniper": vendorColors.juniper,
"--topo-vendor-nokia": vendorColors.nokia,
"--topo-vendor-ericsson": vendorColors.ericsson,
"--topo-vendor-h3c": vendorColors.h3c,
"--topo-vendor-ruijie": vendorColors.ruijie,
"--topo-vendor-mikrotik": vendorColors.mikrotik,
"--topo-vendor-gray": vendorColors.gray,
} as CSSProperties
}
onDragOver={onCanvasDragOver}
onDrop={onCanvasDrop}
>