From 39f3e84fac5f3414fc27bfb1d9410100d7f7fc4c Mon Sep 17 00:00:00 2001 From: waaraawa Date: Wed, 15 Oct 2025 08:30:24 +0900 Subject: [PATCH] feat(core): add color scheme support (default/dark/light) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- packages/core/src/svgRenderer.ts | 134 +++++++++++++++++++++++- packages/core/src/types.ts | 1 + packages/core/tests/parser.test.ts | 66 ++++++++++++ packages/core/tests/svgRenderer.test.ts | 69 ++++++++++++ 4 files changed, 266 insertions(+), 4 deletions(-) diff --git a/packages/core/src/svgRenderer.ts b/packages/core/src/svgRenderer.ts index 76bc2b2..3a56a12 100644 --- a/packages/core/src/svgRenderer.ts +++ b/packages/core/src/svgRenderer.ts @@ -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 = { blue: '#93c5fd', @@ -20,10 +20,126 @@ const COLORS: Record = { 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 = { + 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(); 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 += ``; @@ -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 += ``; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b236200..4d87c5c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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' diff --git a/packages/core/tests/parser.test.ts b/packages/core/tests/parser.test.ts index 800f272..b4aa6e6 100644 --- a/packages/core/tests/parser.test.ts +++ b/packages/core/tests/parser.test.ts @@ -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(); + }); + }); }); }); diff --git a/packages/core/tests/svgRenderer.test.ts b/packages/core/tests/svgRenderer.test.ts index 9319afd..71450b4 100644 --- a/packages/core/tests/svgRenderer.test.ts +++ b/packages/core/tests/svgRenderer.test.ts @@ -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 + }); + }); }); });