mirror of
https://github.com/waaraawa/ByteGrid.git
synced 2026-07-22 06:41:47 +00:00
feat(core): add color scheme support (default/dark/light)
- Add colorScheme option to RenderOptions and ByteGridConfig
- Implement color transformation with RGB ↔ HSL conversion
- Default: original pastel colors (no transformation)
- Dark: reduce saturation (-20%) and lightness (-10%)
- Light: increase lightness (+20%)
- Apply color transformation to both grid blocks and legend
- Add 10 new tests (4 parser, 6 renderer)
This commit is contained in:
parent
cbba30bb72
commit
39f3e84fac
4 changed files with 266 additions and 4 deletions
|
|
@ -3,10 +3,10 @@
|
|||
* Renders LayoutBlocks to SVG string
|
||||
*/
|
||||
|
||||
import { ByteGridConfig, LayoutBlock, RenderOptions, ColorName, LegendPosition } from './types';
|
||||
import { ByteGridConfig, LayoutBlock, RenderOptions, ColorName, LegendPosition, ColorScheme } from './types';
|
||||
|
||||
/**
|
||||
* Color palette mapping
|
||||
* Color palette mapping (default scheme)
|
||||
*/
|
||||
const COLORS: Record<ColorName, string> = {
|
||||
blue: '#93c5fd',
|
||||
|
|
@ -20,10 +20,126 @@ const COLORS: Record<ColorName, string> = {
|
|||
gray: '#d1d5db',
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert hex color to RGB
|
||||
*/
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB to HSL
|
||||
*/
|
||||
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
case b:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { h, s, l };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert HSL to RGB
|
||||
*/
|
||||
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
||||
let r, g, b;
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
return {
|
||||
r: Math.round(r * 255),
|
||||
g: Math.round(g * 255),
|
||||
b: Math.round(b * 255),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert RGB to hex
|
||||
*/
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return '#' + [r, g, b].map(x => {
|
||||
const hex = x.toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform color based on color scheme
|
||||
*/
|
||||
function transformColor(hexColor: string, scheme: ColorScheme): string {
|
||||
if (scheme === 'default') {
|
||||
return hexColor;
|
||||
}
|
||||
|
||||
const rgb = hexToRgb(hexColor);
|
||||
const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b);
|
||||
|
||||
if (scheme === 'dark') {
|
||||
// Dark scheme: reduce saturation and lightness
|
||||
hsl.s = Math.max(0, hsl.s - 0.2);
|
||||
hsl.l = Math.max(0, hsl.l - 0.1);
|
||||
} else if (scheme === 'light') {
|
||||
// Light scheme: increase lightness
|
||||
hsl.l = Math.min(1, hsl.l + 0.2);
|
||||
}
|
||||
|
||||
const newRgb = hslToRgb(hsl.h, hsl.s, hsl.l);
|
||||
return rgbToHex(newRgb.r, newRgb.g, newRgb.b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default render options
|
||||
*/
|
||||
const DEFAULT_OPTIONS: Required<RenderOptions> = {
|
||||
colorScheme: 'default',
|
||||
showHexDump: false,
|
||||
showLegend: true,
|
||||
legendPosition: 'right',
|
||||
|
|
@ -87,6 +203,14 @@ export function renderSVG(
|
|||
? Math.max(1, Math.floor(config.legendColumns))
|
||||
: opts.legendColumns;
|
||||
|
||||
// Determine colorScheme
|
||||
// Priority: options.colorScheme > config.colorScheme > default ('default')
|
||||
const effectiveColorScheme = options?.colorScheme !== undefined
|
||||
? options.colorScheme
|
||||
: config.colorScheme !== undefined
|
||||
? config.colorScheme
|
||||
: opts.colorScheme;
|
||||
|
||||
// Detect rows with bitfields
|
||||
const rowsWithBitfields = new Set<number>();
|
||||
for (const block of blocks) {
|
||||
|
|
@ -249,7 +373,8 @@ export function renderSVG(
|
|||
const y = rowYPositions.get(block.row) || gridStartY;
|
||||
const width = block.span * opts.cellWidth;
|
||||
const cellHeight = getCellHeight(block);
|
||||
const color = COLORS[block.color] || COLORS.gray;
|
||||
const baseColor = COLORS[block.color] || COLORS.gray;
|
||||
const color = transformColor(baseColor, effectiveColorScheme);
|
||||
|
||||
// Draw block rectangle
|
||||
svg += `<rect x="${x}" y="${y}" width="${width}" height="${cellHeight}" fill="${color}" stroke="#333" stroke-width="1"/>`;
|
||||
|
|
@ -372,7 +497,8 @@ export function renderSVG(
|
|||
const x = legendX + col * legendColumnWidth;
|
||||
const y = rowYPositions[row];
|
||||
|
||||
const color = COLORS[block.color] || COLORS.gray;
|
||||
const baseColor = COLORS[block.color] || COLORS.gray;
|
||||
const color = transformColor(baseColor, effectiveColorScheme);
|
||||
|
||||
// Color box
|
||||
svg += `<rect x="${x}" y="${y}" width="20" height="20" fill="${color}" stroke="#333" stroke-width="1"/>`;
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ export interface LayoutBlock {
|
|||
* Rendering options
|
||||
*/
|
||||
export interface RenderOptions {
|
||||
colorScheme?: ColorScheme; // 'default', 'dark', or 'light'
|
||||
showHexDump?: boolean;
|
||||
showLegend?: boolean; // Deprecated: use legendPosition instead
|
||||
legendPosition?: LegendPosition; // 'right' (default), 'left', 'bottom', or 'none'
|
||||
|
|
|
|||
|
|
@ -674,5 +674,71 @@ fields:
|
|||
expect(result.legendColumns).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// colorScheme tests
|
||||
describe('colorScheme', () => {
|
||||
it('should parse colorScheme: default', () => {
|
||||
const yaml = `
|
||||
name: Test
|
||||
size: 16
|
||||
colorScheme: default
|
||||
fields:
|
||||
- offset: 0-3
|
||||
name: Field1
|
||||
type: uint32_t
|
||||
`;
|
||||
|
||||
const result = parse(yaml);
|
||||
|
||||
expect(result.colorScheme).toBe('default');
|
||||
});
|
||||
|
||||
it('should parse colorScheme: dark', () => {
|
||||
const yaml = `
|
||||
name: Test
|
||||
size: 16
|
||||
colorScheme: dark
|
||||
fields:
|
||||
- offset: 0-3
|
||||
name: Field1
|
||||
type: uint32_t
|
||||
`;
|
||||
|
||||
const result = parse(yaml);
|
||||
|
||||
expect(result.colorScheme).toBe('dark');
|
||||
});
|
||||
|
||||
it('should parse colorScheme: light', () => {
|
||||
const yaml = `
|
||||
name: Test
|
||||
size: 16
|
||||
colorScheme: light
|
||||
fields:
|
||||
- offset: 0-3
|
||||
name: Field1
|
||||
type: uint32_t
|
||||
`;
|
||||
|
||||
const result = parse(yaml);
|
||||
|
||||
expect(result.colorScheme).toBe('light');
|
||||
});
|
||||
|
||||
it('should return undefined when colorScheme is not specified', () => {
|
||||
const yaml = `
|
||||
name: Test
|
||||
size: 16
|
||||
fields:
|
||||
- offset: 0-3
|
||||
name: Field1
|
||||
type: uint32_t
|
||||
`;
|
||||
|
||||
const result = parse(yaml);
|
||||
|
||||
expect(result.colorScheme).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -616,5 +616,74 @@ describe('SVGRenderer', () => {
|
|||
expect(svg).toMatch(/width="\d+"/);
|
||||
});
|
||||
});
|
||||
|
||||
// colorScheme tests
|
||||
describe('colorScheme option', () => {
|
||||
it('should use default colors when colorScheme is "default"', () => {
|
||||
const svg = renderSVG(sampleConfig, sampleBlocks, {
|
||||
colorScheme: 'default',
|
||||
});
|
||||
|
||||
// Default blue color for Field1 is #93c5fd
|
||||
expect(svg).toContain('#93c5fd');
|
||||
});
|
||||
|
||||
it('should transform colors when colorScheme is "dark"', () => {
|
||||
const svg = renderSVG(sampleConfig, sampleBlocks, {
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
|
||||
// Dark scheme should have different colors than default
|
||||
expect(svg).not.toContain('#93c5fd'); // Should not have default blue
|
||||
expect(svg).toContain('fill="#'); // Should have some transformed colors
|
||||
});
|
||||
|
||||
it('should transform colors when colorScheme is "light"', () => {
|
||||
const svg = renderSVG(sampleConfig, sampleBlocks, {
|
||||
colorScheme: 'light',
|
||||
});
|
||||
|
||||
// Light scheme should have different colors than default
|
||||
expect(svg).not.toContain('#93c5fd'); // Should not have default blue
|
||||
expect(svg).toContain('fill="#'); // Should have some transformed colors
|
||||
});
|
||||
|
||||
it('should use config.colorScheme when options.colorScheme is not provided', () => {
|
||||
const configWithScheme: ByteGridConfig = {
|
||||
...sampleConfig,
|
||||
colorScheme: 'dark',
|
||||
};
|
||||
|
||||
const svg = renderSVG(configWithScheme, sampleBlocks);
|
||||
|
||||
// Should use dark scheme from config
|
||||
expect(svg).not.toContain('#93c5fd');
|
||||
});
|
||||
|
||||
it('should prioritize options.colorScheme over config.colorScheme', () => {
|
||||
const configWithScheme: ByteGridConfig = {
|
||||
...sampleConfig,
|
||||
colorScheme: 'dark',
|
||||
};
|
||||
|
||||
const svg = renderSVG(configWithScheme, sampleBlocks, {
|
||||
colorScheme: 'light',
|
||||
});
|
||||
|
||||
// Should use light scheme from options, not dark from config
|
||||
expect(svg).not.toContain('#93c5fd');
|
||||
});
|
||||
|
||||
it('should apply color transformation to both grid blocks and legend', () => {
|
||||
const svg = renderSVG(sampleConfig, sampleBlocks, {
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
|
||||
// Both grid blocks and legend should use transformed colors
|
||||
expect(svg).toContain('fill="#'); // Fill attribute exists
|
||||
expect(svg).toContain('Field1'); // Legend exists
|
||||
expect(svg).toContain('Field2'); // Legend exists
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue