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:
waaraawa 2025-10-15 08:30:24 +09:00
parent cbba30bb72
commit 39f3e84fac
4 changed files with 266 additions and 4 deletions

View file

@ -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"/>`;

View file

@ -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'

View file

@ -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();
});
});
});
});

View file

@ -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
});
});
});
});