From 2e7591d56742cf8152815f0ce408e2c437b28067 Mon Sep 17 00:00:00 2001 From: Yury Moladau Date: Fri, 24 Apr 2026 12:36:26 +0200 Subject: [PATCH] app/vmui: improve series color visibility (#10872) ### Describe Your Changes Improve generated series colors to increase visibility and consistency across light and dark themes. Related issue: https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10869 PR: https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10872 | Before | After | |---|---| | image | image | | image | image | --------- Signed-off-by: Yury Molodov Signed-off-by: Max Kotliar Co-authored-by: Max Kotliar Co-authored-by: Roman Khavronenko --- app/vmui/packages/vmui/src/utils/color.ts | 155 +++++++++++++----- .../packages/vmui/src/utils/uplot/series.ts | 9 +- docs/victoriametrics/changelog/CHANGELOG.md | 1 + 3 files changed, 121 insertions(+), 44 deletions(-) diff --git a/app/vmui/packages/vmui/src/utils/color.ts b/app/vmui/packages/vmui/src/utils/color.ts index 99a760c53e..f9ba536f05 100644 --- a/app/vmui/packages/vmui/src/utils/color.ts +++ b/app/vmui/packages/vmui/src/utils/color.ts @@ -1,47 +1,18 @@ import { ArrayRGB } from "../types"; export const baseContrastColors = [ - "#e54040", - "#32a9dc", - "#2ee329", - "#7126a1", - "#e38f0f", - "#3d811a", - "#ffea00", - "#2d2d2d", - "#da42a6", - "#a44e0c", + "#e6194b", // red + "#4363d8", // blue + "#3cb44b", // green + "#911eb4", // purple + "#f58231", // orange + "#f032e6", // magenta + "#c8a200", // dark yellow + "#a65628", // brown + "#42d4f4", // cyan + "#a9a9a9", // gray ]; -export const hexToRGB = (hex: string): string => { - if (hex.length != 7) return "0, 0, 0"; - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return `${r}, ${g}, ${b}`; -}; - -export const getColorFromString = (text: string): string => { - const SEED = 16777215; - const FACTOR = 49979693; - - let b = 1; - let d = 0; - let f = 1; - - if (text.length > 0) { - for (let i = 0; i < text.length; i++) { - text[i].charCodeAt(0) > d && (d = text[i].charCodeAt(0)); - f = parseInt(String(SEED / d)); - b = (b + text[i].charCodeAt(0) * f * FACTOR) % SEED; - } - } - - let hex = ((b * text.length) % SEED).toString(16); - hex = hex.padEnd(6, hex); - return `#${hex}`; -}; - export const getContrastColor = (value: string) => { let hex = value.replace("#", "").trim(); @@ -70,3 +41,109 @@ export const generateGradient = (start: ArrayRGB, end: ArrayRGB, steps: number) } return gradient.map(c => `rgb(${c})`); }; + +const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n)); + +const hexToRgb = (hex: string) => { + let value = hex.replace("#", "").trim(); + + if (value.length === 3) { + value = value.split("").map((c) => c + c).join(""); + } + + if (!/^[0-9a-fA-F]{6}$/.test(value)) { + throw new Error("Invalid HEX color."); + } + + return { + r: parseInt(value.slice(0, 2), 16), + g: parseInt(value.slice(2, 4), 16), + b: parseInt(value.slice(4, 6), 16), + }; +}; + +const rgbToHex = (r: number, g: number, b: number) => + `#${[r, g, b].map((v) => clamp(Math.round(v), 0, 255).toString(16).padStart(2, "0")).join("")}`; + +const rgbToHsl = (r: number, g: number, b: number) => { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + const d = max - min; + + let h = 0; + let s = 0; + + if (d !== 0) { + s = d / (1 - Math.abs(2 * l - 1)); + + switch (max) { + case r: h = ((g - b) / d) % 6; break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; + } + + h *= 60; + if (h < 0) h += 360; + } + + return { h, s: s * 100, l: l * 100 }; +}; + +const hslToRgb = (h: number, s: number, l: number) => { + s /= 100; + l /= 100; + + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs((h / 60) % 2 - 1)); + const m = l - c / 2; + + let r: number; + let g: number; + let b: number; + + if (h < 60) [r, g, b] = [c, x, 0]; + else if (h < 120) [r, g, b] = [x, c, 0]; + else if (h < 180) [r, g, b] = [0, c, x]; + else if (h < 240) [r, g, b] = [0, x, c]; + else if (h < 300) [r, g, b] = [x, 0, c]; + else [r, g, b] = [c, 0, x]; + + return { + r: (r + m) * 255, + g: (g + m) * 255, + b: (b + m) * 255, + }; +}; + +const varyColor = (hex: string, variant: number) => { + const { r, g, b } = hexToRgb(hex); + const { h, s, l } = rgbToHsl(r, g, b); + + const variants = [ + { ds: 0, dl: 0 }, + { ds: -20, dl: -16 }, + { ds: -16, dl: +16 }, + { ds: +14, dl: -20 }, + ]; + + const v = variants[variant % variants.length]; + + const nextS = clamp(s + v.ds, 35, 85); + const nextL = clamp(l + v.dl, 35, 70); + + const rgb = hslToRgb(h, nextS, nextL); + return rgbToHex(rgb.r, rgb.g, rgb.b); +}; + +export const getSeriesColor = (index: number) => { + const baseCount = baseContrastColors.length; + + const baseIndex = index % baseCount; + const variantIndex = Math.floor(index / baseCount); + + const base = baseContrastColors[(baseIndex + variantIndex) % baseCount]; + + return varyColor(base, variantIndex); +}; diff --git a/app/vmui/packages/vmui/src/utils/uplot/series.ts b/app/vmui/packages/vmui/src/utils/uplot/series.ts index 8ab0efc959..dfb3ce9a86 100644 --- a/app/vmui/packages/vmui/src/utils/uplot/series.ts +++ b/app/vmui/packages/vmui/src/utils/uplot/series.ts @@ -2,7 +2,7 @@ import { MetricBase, MetricResult } from "../../api/types"; import uPlot, { Series as uPlotSeries } from "uplot"; import { getNameForMetric, promValueToNumber } from "../metric"; import { HideSeriesArgs, LegendItemType, SeriesItem } from "../../types"; -import { baseContrastColors, getColorFromString } from "../color"; +import { getSeriesColor } from "../color"; import { getMathStats } from "../math"; import { formatPrettyNumber } from "./helpers"; import { drawPoints } from "./scatter"; @@ -17,11 +17,10 @@ export const extractFields = (metric: MetricBase["metric"]): string => { export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], alias: string[], showPoints?: boolean, isRawQuery?: boolean) => { const colorState: {[key: string]: string} = {}; - const maxColors = Math.min(data.length, baseContrastColors.length); - for (let i = 0; i < maxColors; i++) { + for (let i = 0; i < data.length; i++) { const label = getNameForMetric(data[i], alias[data[i].group - 1]); - colorState[label] = baseContrastColors[i]; + colorState[label] = getSeriesColor(i); } return (d: MetricResult): SeriesItem => { @@ -32,7 +31,7 @@ export const getSeriesItemContext = (data: MetricResult[], hideSeries: string[], label, hasAlias: Boolean(aliasValue), width: 1.4, - stroke: colorState[label] || getColorFromString(label), + stroke: colorState[label], points: getPointsSeries(showPoints, isRawQuery), spanGaps: false, freeFormFields: d.metric, diff --git a/docs/victoriametrics/changelog/CHANGELOG.md b/docs/victoriametrics/changelog/CHANGELOG.md index 7b54a32cbc..bb9e454ca4 100644 --- a/docs/victoriametrics/changelog/CHANGELOG.md +++ b/docs/victoriametrics/changelog/CHANGELOG.md @@ -43,6 +43,7 @@ See also [LTS releases](https://docs.victoriametrics.com/victoriametrics/lts-rel * BUGFIX: [vmrestore](https://docs.victoriametrics.com/victoriametrics/vmrestore/): fix an issue where vmrestore could hang indefinitely when interrupted during backup download. See [#10794](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10794). * BUGFIX: [vmsingle](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/): properly execute graceful shutdown for vmsingle if `-maxIngestionRate` is configured. See [#10795](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10795). * BUGFIX: [vmui](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#vmui): fix time display on Alerting Rules page to use selected timezone. See [#10827](https://github.com/VictoriaMetrics/VictoriaMetrics/pull/10827). +* BUGFIX: [vmui](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/#vmui): use contrasting colors when displaying time series to improve visibility on light and dark themes. See [#10869](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10869). * BUGFIX: [vmalert](https://docs.victoriametrics.com/victoriametrics/vmalert/): delete labels from rule results if they are specified with an empty string value in rule or group labels. See [#10766](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10766). * BUGFIX: [vmsingle](https://docs.victoriametrics.com/victoriametrics/single-server-victoriametrics/) and `vmselect` in [VictoriaMetrics cluster](https://docs.victoriametrics.com/victoriametrics/cluster-victoriametrics/): fix incorrect evaluation of binary operations caused by an ordering bug (e.g. `10 - (3 + 3 + 4)` being evaluated as `10 - 3 + 3 + 4`). The issue was introduced in v1.140.0, v1.136.4, and v1.122.19. See [#10856](https://github.com/VictoriaMetrics/VictoriaMetrics/issues/10856).