mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-28 03:20:11 +00:00
Match Proxmox tag color generation (#1348)
This commit is contained in:
parent
e9bbc35bae
commit
ca66581b6e
2 changed files with 127 additions and 166 deletions
|
|
@ -1,71 +1,59 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getTagColorWithSpecial } from '../tagColors';
|
||||
|
||||
describe('tagColors', () => {
|
||||
describe('getTagColorWithSpecial', () => {
|
||||
it('returns consistent colors for special tags in light mode', () => {
|
||||
const production = getTagColorWithSpecial('production', false);
|
||||
expect(production).toEqual({
|
||||
bg: 'rgb(254, 226, 226)',
|
||||
text: 'rgb(153, 27, 27)',
|
||||
border: 'rgb(239, 68, 68)',
|
||||
});
|
||||
describe('getTagColorWithSpecial', () => {
|
||||
it('matches Proxmox fallback colors for tags', () => {
|
||||
expect(getTagColorWithSpecial('production', false)).toEqual({
|
||||
bg: 'rgb(191.3, 176.60000000000002, 238.89999999999998)',
|
||||
text: '#000000',
|
||||
border: 'rgb(191.3, 176.60000000000002, 238.89999999999998)',
|
||||
});
|
||||
|
||||
const staging = getTagColorWithSpecial('STAGING', false); // case insensitive
|
||||
expect(staging).toEqual({
|
||||
bg: 'rgb(254, 243, 199)',
|
||||
text: 'rgb(146, 64, 14)',
|
||||
border: 'rgb(245, 158, 11)',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns consistent colors for special tags in dark mode', () => {
|
||||
const backup = getTagColorWithSpecial('backup', true);
|
||||
expect(backup).toEqual({
|
||||
bg: 'rgb(30, 58, 138)',
|
||||
text: 'rgb(191, 219, 254)',
|
||||
border: 'rgb(59, 130, 246)',
|
||||
});
|
||||
});
|
||||
|
||||
it('generates hash-based colors for non-special tags', () => {
|
||||
const color1 = getTagColorWithSpecial('mytag', false);
|
||||
const color2 = getTagColorWithSpecial('mytag', false);
|
||||
|
||||
// Consistency
|
||||
expect(color1).toEqual(color2);
|
||||
|
||||
// Values (H, S, L format)
|
||||
expect(color1.bg).toMatch(/hsl\(\d+, 65%, 60%\)/);
|
||||
expect(color1.text).toMatch(/hsl\(\d+, 65%, 25%\)/);
|
||||
expect(color1.border).toMatch(/hsl\(\d+, 65%, 50%\)/);
|
||||
});
|
||||
|
||||
it('generates different colors for different tags', () => {
|
||||
const colorA = getTagColorWithSpecial('tagA', false);
|
||||
const colorB = getTagColorWithSpecial('tagB', false);
|
||||
|
||||
expect(colorA.bg).not.toBe(colorB.bg);
|
||||
});
|
||||
|
||||
it('prefers proxmox-supplied colors over fallback palettes', () => {
|
||||
const color = getTagColorWithSpecial('Production', false, {
|
||||
production: '#112233',
|
||||
});
|
||||
|
||||
expect(color).toEqual({
|
||||
bg: '#112233',
|
||||
text: '#ffffff',
|
||||
border: '#112233',
|
||||
});
|
||||
});
|
||||
|
||||
it('generates dark mode hash-based colors', () => {
|
||||
const color = getTagColorWithSpecial('custom', true);
|
||||
|
||||
expect(color.bg).toMatch(/hsl\(\d+, 55%, 35%\)/);
|
||||
expect(color.text).toMatch(/hsl\(\d+, 55%, 85%\)/);
|
||||
expect(color.border).toMatch(/hsl\(\d+, 55%, 45%\)/);
|
||||
});
|
||||
expect(getTagColorWithSpecial('STAGING', false)).toEqual({
|
||||
bg: 'rgb(103.10000000000001, 103.10000000000001, 175.9)',
|
||||
text: '#ffffff',
|
||||
border: 'rgb(103.10000000000001, 103.10000000000001, 175.9)',
|
||||
});
|
||||
});
|
||||
|
||||
it('is deterministic for the same tag', () => {
|
||||
const color1 = getTagColorWithSpecial('mytag', false);
|
||||
const color2 = getTagColorWithSpecial('mytag', true);
|
||||
|
||||
expect(color1).toEqual(color2);
|
||||
expect(color1).toEqual({
|
||||
bg: 'rgb(228.39999999999998, 164.7, 128.3)',
|
||||
text: '#000000',
|
||||
border: 'rgb(228.39999999999998, 164.7, 128.3)',
|
||||
});
|
||||
});
|
||||
|
||||
it('generates different colors for different tags', () => {
|
||||
const colorA = getTagColorWithSpecial('tagA', false);
|
||||
const colorB = getTagColorWithSpecial('tagB', false);
|
||||
|
||||
expect(colorA.bg).not.toBe(colorB.bg);
|
||||
});
|
||||
|
||||
it('prefers proxmox-supplied colors over generated fallback colors', () => {
|
||||
expect(getTagColorWithSpecial('Production', false, {
|
||||
production: '#112233',
|
||||
})).toEqual({
|
||||
bg: 'rgb(17, 34, 51)',
|
||||
text: '#ffffff',
|
||||
border: 'rgb(17, 34, 51)',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to generated colors when proxmox color is invalid', () => {
|
||||
expect(getTagColorWithSpecial('backup', false, {
|
||||
backup: 'not-a-color',
|
||||
})).toEqual({
|
||||
bg: 'rgb(108.00000000000001, 149.3, 143.7)',
|
||||
text: '#ffffff',
|
||||
border: 'rgb(108.00000000000001, 149.3, 143.7)',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,120 +1,93 @@
|
|||
// Generate consistent colors for tags based on their text
|
||||
// This replicates Proxmox's tag color generation logic
|
||||
|
||||
/**
|
||||
* Simple hash function to generate a number from a string
|
||||
* This ensures the same tag always gets the same color
|
||||
*/
|
||||
function hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a color for a tag based on its text
|
||||
* Uses HSL to ensure good visibility and consistent saturation/lightness
|
||||
* (Internal helper - use getTagColorWithSpecial instead)
|
||||
*/
|
||||
function getTagColor(tag: string): { bg: string; text: string; border: string } {
|
||||
// Get a hash of the tag
|
||||
const hash = hashString(tag.toLowerCase());
|
||||
|
||||
// Generate hue from hash (0-360 degrees)
|
||||
const hue = hash % 360;
|
||||
|
||||
// Use moderate saturation for subtle but visible colors
|
||||
// These values are tuned to be noticeable without being distracting
|
||||
const saturation = 65; // Moderate saturation
|
||||
const lightnessBg = 60; // Slightly muted background
|
||||
const lightnessText = 25; // Dark text for contrast
|
||||
const lightnessBorder = 50; // Medium border
|
||||
|
||||
// For dark mode, we'll adjust these in the component
|
||||
return {
|
||||
bg: `hsl(${hue}, ${saturation}%, ${lightnessBg}%)`,
|
||||
text: `hsl(${hue}, ${saturation}%, ${lightnessText}%)`,
|
||||
border: `hsl(${hue}, ${saturation}%, ${lightnessBorder}%)`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tag colors adjusted for dark mode
|
||||
* (Internal helper - use getTagColorWithSpecial instead)
|
||||
*/
|
||||
function getTagColorDark(tag: string): { bg: string; text: string; border: string } {
|
||||
const hash = hashString(tag.toLowerCase());
|
||||
const hue = hash % 360;
|
||||
const saturation = 55; // Moderate saturation in dark mode
|
||||
|
||||
return {
|
||||
bg: `hsl(${hue}, ${saturation}%, 35%)`, // Subtler background
|
||||
text: `hsl(${hue}, ${saturation}%, 85%)`, // Light text
|
||||
border: `hsl(${hue}, ${saturation}%, 45%)`, // Subtle border
|
||||
};
|
||||
}
|
||||
|
||||
interface TagColorStyle {
|
||||
bg: string;
|
||||
text: string;
|
||||
border: string;
|
||||
}
|
||||
|
||||
interface TagColorTheme {
|
||||
light: TagColorStyle;
|
||||
dark: TagColorStyle;
|
||||
function stringToRGB(tag: string): [number, number, number] {
|
||||
let hash = 0;
|
||||
if (!tag) {
|
||||
return [255, 255, 255];
|
||||
}
|
||||
|
||||
const value = `${tag.toLowerCase()}prox`;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = value.charCodeAt(i) + ((hash << 5) - hash);
|
||||
hash &= hash;
|
||||
}
|
||||
|
||||
const alpha = 0.7;
|
||||
const bg = 255;
|
||||
|
||||
return [
|
||||
(hash & 255) * alpha + bg * (1 - alpha),
|
||||
((hash >> 8) & 255) * alpha + bg * (1 - alpha),
|
||||
((hash >> 16) & 255) * alpha + bg * (1 - alpha),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxmox's default tag colors for special tags
|
||||
* These override the hash-based colors for specific tags
|
||||
*/
|
||||
const specialTagColors: Record<string, TagColorTheme> = {
|
||||
production: {
|
||||
light: { bg: 'rgb(254, 226, 226)', text: 'rgb(153, 27, 27)', border: 'rgb(239, 68, 68)' },
|
||||
dark: { bg: 'rgb(127, 29, 29)', text: 'rgb(254, 202, 202)', border: 'rgb(185, 28, 28)' },
|
||||
},
|
||||
staging: {
|
||||
light: { bg: 'rgb(254, 243, 199)', text: 'rgb(146, 64, 14)', border: 'rgb(245, 158, 11)' },
|
||||
dark: { bg: 'rgb(120, 53, 15)', text: 'rgb(253, 230, 138)', border: 'rgb(217, 119, 6)' },
|
||||
},
|
||||
development: {
|
||||
light: { bg: 'rgb(220, 252, 231)', text: 'rgb(22, 101, 52)', border: 'rgb(34, 197, 94)' },
|
||||
dark: { bg: 'rgb(20, 83, 45)', text: 'rgb(187, 247, 208)', border: 'rgb(34, 197, 94)' },
|
||||
},
|
||||
backup: {
|
||||
light: { bg: 'rgb(219, 234, 254)', text: 'rgb(30, 58, 138)', border: 'rgb(59, 130, 246)' },
|
||||
dark: { bg: 'rgb(30, 58, 138)', text: 'rgb(191, 219, 254)', border: 'rgb(59, 130, 246)' },
|
||||
},
|
||||
};
|
||||
function rgbToCss(rgb: [number, number, number]): string {
|
||||
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
||||
}
|
||||
|
||||
function getTextContrastClass(rgb: [number, number, number]): 'light' | 'dark' {
|
||||
const blkThrs = 0.022;
|
||||
const blkClmp = 1.414;
|
||||
|
||||
const r = (rgb[0] / 255) ** 2.4;
|
||||
const g = (rgb[1] / 255) ** 2.4;
|
||||
const b = (rgb[2] / 255) ** 2.4;
|
||||
|
||||
let bg = r * 0.2126729 + g * 0.7151522 + b * 0.072175;
|
||||
bg = bg > blkThrs ? bg : bg + (blkThrs - bg) ** blkClmp;
|
||||
|
||||
const contrastLight = bg ** 0.65 - 1;
|
||||
const contrastDark = bg ** 0.56 - 0.046134502;
|
||||
|
||||
return Math.abs(contrastLight) >= Math.abs(contrastDark) ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
function parseHexColor(hex: string): [number, number, number] | null {
|
||||
const normalized = hex.trim().replace(/^#/, '');
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
parseInt(normalized.slice(0, 2), 16),
|
||||
parseInt(normalized.slice(2, 4), 16),
|
||||
parseInt(normalized.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
|
||||
function buildStyleFromRGB(rgb: [number, number, number]): TagColorStyle {
|
||||
const bg = rgbToCss(rgb);
|
||||
const text = getTextContrastClass(rgb) === 'light' ? '#ffffff' : '#000000';
|
||||
|
||||
return {
|
||||
bg,
|
||||
text,
|
||||
border: bg,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for a tag.
|
||||
* Priority: Proxmox-supplied hex color → special hardcoded colors → hash-based fallback.
|
||||
* @param colorMap Optional map of lowercase tag name → "#rrggbb" from Proxmox datacenter config.
|
||||
* Priority: Proxmox-supplied hex color -> Proxmox fallback hash algorithm.
|
||||
*/
|
||||
export function getTagColorWithSpecial(
|
||||
tag: string,
|
||||
isDarkMode: boolean,
|
||||
_isDarkMode: boolean,
|
||||
colorMap?: Record<string, string>,
|
||||
): { bg: string; text: string; border: string } {
|
||||
): TagColorStyle {
|
||||
const lowerTag = tag.toLowerCase();
|
||||
|
||||
// Use Proxmox-supplied colour when available
|
||||
if (colorMap?.[lowerTag]) {
|
||||
const hex = colorMap[lowerTag];
|
||||
return { bg: hex, text: '#ffffff', border: hex };
|
||||
const proxmoxHex = colorMap?.[lowerTag];
|
||||
if (proxmoxHex) {
|
||||
const rgb = parseHexColor(proxmoxHex);
|
||||
if (rgb) {
|
||||
return buildStyleFromRGB(rgb);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a special tag
|
||||
if (specialTagColors[lowerTag]) {
|
||||
return isDarkMode ? specialTagColors[lowerTag].dark : specialTagColors[lowerTag].light;
|
||||
}
|
||||
|
||||
// Otherwise use hash-based color
|
||||
return isDarkMode ? getTagColorDark(tag) : getTagColor(tag);
|
||||
return buildStyleFromRGB(stringToRGB(tag));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue