feat(core): improve legendColumns with row-major order

- Change legend layout from column-major to row-major order
  - Fields now fill left-to-right, then top-to-bottom
  - More intuitive for users (e.g., 4 fields + 3 cols = 3-1 layout)
  - Fix bottom position width calculation for multi-column legends
  - totalWidth now accounts for legend width (200px per column)
  - Add tests for 3-column layout with 6 fields
This commit is contained in:
waaraawa 2025-10-14 13:16:36 +09:00
parent 4d5cd4e289
commit cbba30bb72
7 changed files with 553 additions and 20 deletions

View file

@ -118,5 +118,3 @@ fields:
**layoutUnit field:**
- Can be omitted (auto-inferred from suffix)
- Can be explicit (use `layoutUnit: bit` or `layoutUnit: byte` if desired)
For more details, see [CLAUDE.md](../CLAUDE.md).

View file

@ -247,6 +247,11 @@ export function parse(source: string): ByteGridConfig {
? (obj.legendPosition as 'right' | 'left' | 'bottom' | 'none')
: undefined;
// Parse legendColumns (optional)
const legendColumns = obj.legendColumns && typeof obj.legendColumns === 'number'
? obj.legendColumns
: undefined;
// Parse showFooter (optional)
const showFooter = obj.showFooter !== undefined && typeof obj.showFooter === 'boolean'
? obj.showFooter
@ -262,6 +267,7 @@ export function parse(source: string): ByteGridConfig {
layoutUnit: finalLayoutUnit,
colorScheme,
legendPosition,
legendColumns,
showFooter,
fields,
};

View file

@ -27,6 +27,7 @@ const DEFAULT_OPTIONS: Required<RenderOptions> = {
showHexDump: false,
showLegend: true,
legendPosition: 'right',
legendColumns: 1,
showFooter: true,
showGrid: true,
cellWidth: 40, // Increased from 30 to make bit numbers readable
@ -78,6 +79,14 @@ export function renderSVG(
? config.showFooter
: opts.showFooter;
// Determine legendColumns
// Priority: options.legendColumns > config.legendColumns > default (1)
const effectiveLegendColumns = options?.legendColumns !== undefined
? Math.max(1, Math.floor(options.legendColumns))
: config.legendColumns !== undefined
? Math.max(1, Math.floor(config.legendColumns))
: opts.legendColumns;
// Detect rows with bitfields
const rowsWithBitfields = new Set<number>();
for (const block of blocks) {
@ -94,7 +103,10 @@ export function renderSVG(
// Calculate dimensions
const rows = Math.ceil(config.size / layout);
const gridWidth = layout * opts.cellWidth;
const legendWidth = effectiveLegendPosition !== 'none' && effectiveLegendPosition !== 'bottom' ? 200 : 0;
const legendColumnWidth = 200; // Width of each legend column
const legendWidth = effectiveLegendPosition !== 'none' && effectiveLegendPosition !== 'bottom'
? legendColumnWidth * effectiveLegendColumns
: 0;
const margin = 20;
// Count unique fields for legend height calculation
@ -104,7 +116,7 @@ export function renderSVG(
// Calculate legend height (for 'bottom' position or side positions)
let legendHeight = 0;
if (effectiveLegendPosition !== 'none' && fieldCount > 0) {
// Calculate actual height needed based on bitfields
// Calculate actual height needed based on bitfields (row-major order)
let totalLegendHeight = 40; // Header space
const uniqueFields = new Map<string, LayoutBlock>();
for (const block of blocks) {
@ -112,13 +124,25 @@ export function renderSVG(
uniqueFields.set(block.fieldName, block);
}
}
// For multi-column row-major, we need to find the tallest entry in each row
const rowHeights: number[] = [];
let fieldIndex = 0;
for (const [, block] of uniqueFields) {
const rowIndex = Math.floor(fieldIndex / effectiveLegendColumns);
let entryHeight = 50;
if (block.bitfields && block.bitfields.length > 0) {
entryHeight = 50 + block.bitfields.length * 12;
}
totalLegendHeight += entryHeight;
rowHeights[rowIndex] = Math.max(rowHeights[rowIndex] || 0, entryHeight);
fieldIndex++;
}
// Sum up all row heights
for (const h of rowHeights) {
totalLegendHeight += h;
}
legendHeight = totalLegendHeight;
}
@ -153,7 +177,9 @@ export function renderSVG(
gridStartY = margin + 60;
} else if (effectiveLegendPosition === 'bottom') {
// Legend at bottom (with extra 30px spacing for title)
totalWidth = margin + gridWidth + margin;
// Need to account for multi-column legend width
const bottomLegendWidth = legendColumnWidth * effectiveLegendColumns;
totalWidth = margin + Math.max(gridWidth, bottomLegendWidth) + margin;
totalHeight = margin + 60 + gridHeight + margin + 30 + legendHeight + footerHeight;
gridStartX = margin;
gridStartY = margin + 60;
@ -306,45 +332,75 @@ export function renderSVG(
}
}
let index = 0;
for (const [fieldName, block] of uniqueFields) {
let entryHeight = 50; // Default height per field
// Calculate number of rows (row-major order: fill columns left-to-right)
const totalFields = uniqueFields.size;
const totalRows = Math.ceil(totalFields / effectiveLegendColumns);
// Calculate height needed if bitfields exist
if (block.bitfields && block.bitfields.length > 0) {
entryHeight = 50 + block.bitfields.length * 12; // Extra 12px per bitfield
// Calculate cumulative Y positions for each row (to handle variable heights)
const rowYPositions: number[] = [legendStartY];
const fieldsArray = Array.from(uniqueFields.entries());
for (let row = 0; row < totalRows; row++) {
let maxHeightInRow = 50; // Default height
// Check all fields in this row across all columns (row-major)
for (let col = 0; col < effectiveLegendColumns; col++) {
const fieldIndex = row * effectiveLegendColumns + col;
if (fieldIndex < totalFields) {
const [, block] = fieldsArray[fieldIndex];
let entryHeight = 50;
if (block.bitfields && block.bitfields.length > 0) {
entryHeight = 50 + block.bitfields.length * 12;
}
maxHeightInRow = Math.max(maxHeightInRow, entryHeight);
}
}
const y = legendStartY + index * entryHeight;
if (row < totalRows - 1) {
rowYPositions.push(rowYPositions[row] + maxHeightInRow);
}
}
// Render fields in row-major order (left-to-right, then top-to-bottom)
let fieldIndex = 0;
for (const [fieldName, block] of uniqueFields) {
// Calculate row and column for this field (row-major)
const row = Math.floor(fieldIndex / effectiveLegendColumns);
const col = fieldIndex % effectiveLegendColumns;
// Calculate position
const x = legendX + col * legendColumnWidth;
const y = rowYPositions[row];
const color = COLORS[block.color] || COLORS.gray;
// Color box
svg += `<rect x="${legendX}" y="${y}" width="20" height="20" fill="${color}" stroke="#333" stroke-width="1"/>`;
svg += `<rect x="${x}" y="${y}" width="20" height="20" fill="${color}" stroke="#333" stroke-width="1"/>`;
// Field name
svg += `<text x="${legendX + 30}" y="${y + 12}" font-size="11" font-weight="bold" fill="#333">${escapeXml(fieldName)}</text>`;
svg += `<text x="${x + 30}" y="${y + 12}" font-size="11" font-weight="bold" fill="#333">${escapeXml(fieldName)}</text>`;
// Type
svg += `<text x="${legendX + 30}" y="${y + 24}" font-size="9" fill="#666">${escapeXml(block.fieldType)}</text>`;
svg += `<text x="${x + 30}" y="${y + 24}" font-size="9" fill="#666">${escapeXml(block.fieldType)}</text>`;
// Offset info
const unit = layoutUnit === 'bit' ? 'bits' : 'bytes';
const size = block.offsetEnd - block.offsetStart + 1;
svg += `<text x="${legendX + 30}" y="${y + 36}" font-size="9" fill="#999">offset: ${block.offsetStart}-${block.offsetEnd} (${size} ${unit})</text>`;
svg += `<text x="${x + 30}" y="${y + 36}" font-size="9" fill="#999">offset: ${block.offsetStart}-${block.offsetEnd} (${size} ${unit})</text>`;
// Bitfields (if any)
if (block.bitfields && block.bitfields.length > 0) {
svg += `<text x="${legendX + 30}" y="${y + 48}" font-size="9" font-weight="bold" fill="#666">Bits:</text>`;
svg += `<text x="${x + 30}" y="${y + 48}" font-size="9" font-weight="bold" fill="#666">Bits:</text>`;
block.bitfields.forEach((bf, bfIndex) => {
const bfY = y + 60 + bfIndex * 12;
svg += `<text x="${legendX + 35}" y="${bfY}" font-size="8" fill="#888">`;
svg += `<text x="${x + 35}" y="${bfY}" font-size="8" fill="#888">`;
svg += `bit ${escapeXml(bf.bits)}: ${escapeXml(bf.name)}`;
svg += `</text>`;
});
}
index++;
fieldIndex++;
}
}

View file

@ -92,6 +92,7 @@ export interface ByteGridConfig {
layoutUnit?: LayoutUnit; // 'byte' (default) or 'bit'
colorScheme?: ColorScheme;
legendPosition?: LegendPosition; // 'right' (default), 'left', 'bottom', or 'none'
legendColumns?: number; // Number of columns in legend (default: 1)
showFooter?: boolean; // Show footer with total size and layout info (default: true)
fields: Field[];
}
@ -131,6 +132,7 @@ export interface RenderOptions {
showHexDump?: boolean;
showLegend?: boolean; // Deprecated: use legendPosition instead
legendPosition?: LegendPosition; // 'right' (default), 'left', 'bottom', or 'none'
legendColumns?: number; // Number of columns in legend (default: 1)
showFooter?: boolean; // Show footer with total size and layout info (default: true)
showGrid?: boolean;
cellWidth?: number;

View file

@ -624,5 +624,55 @@ fields:
expect(result.showFooter).toBeUndefined();
});
});
// legendColumns tests
describe('legendColumns', () => {
it('should parse legendColumns: 1', () => {
const yaml = `
name: Test
size: 16
legendColumns: 1
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.legendColumns).toBe(1);
});
it('should parse legendColumns: 2', () => {
const yaml = `
name: Test
size: 16
legendColumns: 2
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.legendColumns).toBe(2);
});
it('should return undefined when legendColumns is not specified', () => {
const yaml = `
name: Test
size: 16
fields:
- offset: 0-3
name: Field1
type: uint32_t
`;
const result = parse(yaml);
expect(result.legendColumns).toBeUndefined();
});
});
});
});

View file

@ -483,5 +483,138 @@ describe('SVGRenderer', () => {
expect(svg).not.toContain('Total size:');
});
});
// legendColumns tests
describe('legendColumns option', () => {
const multiFieldConfig: ByteGridConfig = {
name: 'Multi Field Test',
size: 16,
layout: 16,
fields: [
{ offset: '0-3', name: 'Field1', type: 'uint32_t', color: 'blue' },
{ offset: '4-7', name: 'Field2', type: 'uint32_t', color: 'cyan' },
{ offset: '8-11', name: 'Field3', type: 'uint32_t', color: 'yellow' },
{ offset: '12-15', name: 'Field4', type: 'uint32_t', color: 'green' },
],
};
const multiFieldBlocks: LayoutBlock[] = [
{
row: 0, col: 0, span: 4,
fieldName: 'Field1', fieldType: 'uint32_t', color: 'blue',
offsetStart: 0, offsetEnd: 3, isPadding: false,
},
{
row: 0, col: 4, span: 4,
fieldName: 'Field2', fieldType: 'uint32_t', color: 'cyan',
offsetStart: 4, offsetEnd: 7, isPadding: false,
},
{
row: 0, col: 8, span: 4,
fieldName: 'Field3', fieldType: 'uint32_t', color: 'yellow',
offsetStart: 8, offsetEnd: 11, isPadding: false,
},
{
row: 0, col: 12, span: 4,
fieldName: 'Field4', fieldType: 'uint32_t', color: 'green',
offsetStart: 12, offsetEnd: 15, isPadding: false,
},
];
it('should render legend in 1 column by default', () => {
const svg = renderSVG(multiFieldConfig, multiFieldBlocks);
expect(svg).toContain('Field1');
expect(svg).toContain('Field2');
expect(svg).toContain('Field3');
expect(svg).toContain('Field4');
});
it('should render legend in 2 columns when legendColumns is 2', () => {
const svg = renderSVG(multiFieldConfig, multiFieldBlocks, {
legendColumns: 2,
});
expect(svg).toContain('Field1');
expect(svg).toContain('Field2');
expect(svg).toContain('Field3');
expect(svg).toContain('Field4');
});
it('should use config.legendColumns when options.legendColumns is not provided', () => {
const configWithColumns: ByteGridConfig = {
...multiFieldConfig,
legendColumns: 2,
};
const svg = renderSVG(configWithColumns, multiFieldBlocks);
expect(svg).toContain('Field1');
expect(svg).toContain('Field2');
});
it('should prioritize options.legendColumns over config.legendColumns', () => {
const configWithColumns: ByteGridConfig = {
...multiFieldConfig,
legendColumns: 1,
};
const svg = renderSVG(configWithColumns, multiFieldBlocks, {
legendColumns: 2,
});
expect(svg).toContain('Field1');
});
it('should handle legendColumns with minimum value of 1', () => {
const svg = renderSVG(multiFieldConfig, multiFieldBlocks, {
legendColumns: 0, // Should be treated as 1
});
expect(svg).toContain('Field1');
});
it('should render legend in 3 columns when legendColumns is 3', () => {
// 6 fields for testing 3 columns
const sixFieldConfig: ByteGridConfig = {
name: 'Six Field Test',
size: 24,
layout: 16,
fields: [
{ offset: '0-3', name: 'Field1', type: 'uint32_t', color: 'blue' },
{ offset: '4-7', name: 'Field2', type: 'uint32_t', color: 'cyan' },
{ offset: '8-11', name: 'Field3', type: 'uint32_t', color: 'yellow' },
{ offset: '12-15', name: 'Field4', type: 'uint32_t', color: 'green' },
{ offset: '16-19', name: 'Field5', type: 'uint32_t', color: 'orange' },
{ offset: '20-23', name: 'Field6', type: 'uint32_t', color: 'purple' },
],
};
const sixFieldBlocks: LayoutBlock[] = [
{ row: 0, col: 0, span: 4, fieldName: 'Field1', fieldType: 'uint32_t', color: 'blue', offsetStart: 0, offsetEnd: 3, isPadding: false },
{ row: 0, col: 4, span: 4, fieldName: 'Field2', fieldType: 'uint32_t', color: 'cyan', offsetStart: 4, offsetEnd: 7, isPadding: false },
{ row: 0, col: 8, span: 4, fieldName: 'Field3', fieldType: 'uint32_t', color: 'yellow', offsetStart: 8, offsetEnd: 11, isPadding: false },
{ row: 0, col: 12, span: 4, fieldName: 'Field4', fieldType: 'uint32_t', color: 'green', offsetStart: 12, offsetEnd: 15, isPadding: false },
{ row: 1, col: 0, span: 4, fieldName: 'Field5', fieldType: 'uint32_t', color: 'orange', offsetStart: 16, offsetEnd: 19, isPadding: false },
{ row: 1, col: 4, span: 4, fieldName: 'Field6', fieldType: 'uint32_t', color: 'purple', offsetStart: 20, offsetEnd: 23, isPadding: false },
];
const svg = renderSVG(sixFieldConfig, sixFieldBlocks, {
legendColumns: 3,
});
// All 6 fields should be present
expect(svg).toContain('Field1');
expect(svg).toContain('Field2');
expect(svg).toContain('Field3');
expect(svg).toContain('Field4');
expect(svg).toContain('Field5');
expect(svg).toContain('Field6');
// Check for proper width (should be wider for 3 columns)
// Each column is 200px, so 3 columns = 600px
expect(svg).toMatch(/width="\d+"/);
});
});
});
});

288
roadmap.md Normal file
View file

@ -0,0 +1,288 @@
# ByteGrid Development Roadmap
**Last Updated:** 2025-10-14
**Current Phase:** Phase 2 (Core Features)
---
## Phase 1: MVP ✅ COMPLETED
**Goal:** Basic structure visualization with manual field definition
### Completed Features
- [x] Project structure setup (monorepo with core + plugin)
- [x] Basic type definitions (ByteGridConfig, Field, LayoutBlock)
- [x] YAML parser with js-yaml
- [x] Field validation (overlap detection, range checking)
- [x] Simple layout engine (single row)
- [x] Basic SVG renderer
- [x] Obsidian plugin integration
- [x] Test infrastructure (Jest)
**Status:** ✅ All core functionality implemented
---
## Phase 2: Core Features 🚧 IN PROGRESS
**Goal:** Robust visualization with advanced layout options
### Completed Features ✅
- [x] Multi-row layout support
- [x] Grid lines rendering
- [x] Color system (9 predefined colors)
- [x] Bitfield visualization (sub-byte fields)
- [x] Bit-unit layout mode (`layoutUnit: bit`)
- [x] Suffix notation (`32b`, `4B`)
- [x] Legend position control (`legendPosition: right/left/bottom/none`)
- [x] Footer visibility control (`showFooter: true/false`)
- [x] Comprehensive test coverage (114 tests)
### In Progress 🔄
- [ ] Color schemes (dark, light modes)
- [ ] Row/column labels customization
- [ ] Cell padding/spacing options
### Planned Features 📋
- [ ] Hex dump display (`showHexDump: true`)
- [ ] Field description tooltips
- [ ] Value display formatting (hex, decimal, binary)
- [ ] Endianness indicators
**Target Completion:** Q4 2025
---
## Phase 3: Interactive Features 📅 PLANNED
**Goal:** Rich user interaction and real-time visualization
### Features
- [ ] Interactive tooltips on hover
- Field name, type, offset
- Description and value
- Bit range for bitfields
- [ ] Field highlighting
- Click to highlight field
- Show all related blocks
- Copy field info to clipboard
- [ ] Zoom controls
- Zoom in/out
- Pan/scroll for large structures
- [ ] Search/filter fields
- Search by name or type
- Filter by color or category
- [ ] Export options
- Export as SVG file
- Export as PNG image
- Export as C struct definition
- Copy as Markdown table
**Target Start:** Q1 2026
---
## Phase 4: Advanced Features 📅 FUTURE
**Goal:** Binary file integration and advanced analysis
### Binary File Support
- [ ] Binary file parser (`binaryParser.ts`)
- Read binary files
- Auto-populate field values
- Endianness handling
- Generate hex dump
- [ ] File format templates
- WAV_HEADER (audio files)
- ELF_HEADER (executables)
- TCP_HEADER (network packets)
- PNG_HEADER (image files)
- Custom template system
### Structure Analysis
- [ ] Structure comparison (`comparison.ts`)
- Side-by-side view
- Overlay view
- Diff highlighting
- [ ] Padding analysis
- Auto-detect padding bytes
- Show memory waste
- Suggest optimizations
- [ ] Alignment visualization
- Show alignment boundaries
- Detect misaligned fields
- Platform-specific rules
**Target Start:** Q2 2026
---
## Phase 5: Performance & Scale 📅 FUTURE
**Goal:** Handle large structures and optimize rendering
### Performance Optimization
- [ ] Rendering cache (`cache.ts`)
- Hash-based cache keys
- TTL-based expiration
- localStorage persistence
- [ ] Virtual scrolling
- Lazy render large structures
- Only render visible rows
- Smooth scrolling
- [ ] Pagination
- Break large structures (>1000 bytes)
- Page navigation controls
- Jump to offset
### Large Structure Support
- [ ] Performance benchmarks
- 1KB: < 100ms
- 10KB: < 500ms
- 100KB: < 2s (with pagination)
- [ ] Memory optimization
- Efficient data structures
- DOM node recycling
- Progressive rendering
**Target Start:** Q3 2026
---
## Phase 6: Extensibility 📅 FUTURE
**Goal:** Plugin system and customization
### Custom Extensions
- [ ] Type system extensions
- Custom type definitions
- Type aliases
- Nested structures
- Union types
- [ ] Custom color schemes
- User-defined palettes
- Theme import/export
- Dark/light mode auto-switch
- [ ] Render hooks
- Pre-render processing
- Post-render modifications
- Custom SVG elements
- [ ] Plugin API
- Register custom parsers
- Add custom validators
- Extend layout engine
### Developer Tools
- [ ] Debug mode
- Show internal state
- Performance metrics
- Error details
- [ ] Documentation generator
- Auto-generate type docs
- Example templates
- Interactive playground
**Target Start:** Q4 2026
---
## Current Sprint (October 2024)
### Completed This Sprint ✅
- [x] Bit-unit layout implementation
- [x] Legend position options (right, left, bottom, none)
- [x] Footer visibility control
- [x] Fix legend overlapping issues
- [x] Add comprehensive tests for new features
- [x] Documentation updates
### Next Sprint Goals (November 2024)
1. **Color Schemes** (Priority: High)
- Implement dark and light color schemes
- Add `colorScheme` option to config
- Update color constants
- Add tests
2. **Hex Dump Display** (Priority: Medium)
- Add `showHexDump` option
- Render hex values below grid
- Format with proper spacing
- Handle large structures
3. **Documentation** (Priority: High)
- Update CLAUDE.md with new options
- Create user guide in docs/
- Add more examples
- API reference cleanup
---
## Known Limitations & Future Improvements
### Current Limitations
1. **YAML Line Numbers**
- Error messages show field index, not line number
- **Improvement:** Custom YAML parser with position tracking
2. **Cache Persistence**
- Cache cleared on session end
- **Improvement:** localStorage persistence (Phase 5)
3. **Type System**
- No custom type definitions
- No nested structures
- **Improvement:** Type alias system (Phase 6)
4. **Bit Ordering**
- Assumes MSB-first
- **Improvement:** Add `bitOrder: msb/lsb` option
5. **Layout Constraints**
- Fixed cell sizes per visualization
- **Improvement:** Responsive cell sizing
---
## Contributing
See [CONTRIBUTING.md](../CONTRIBUTING.md) for guidelines on:
- Proposing new features
- Submitting pull requests
- Testing requirements
- Code style guidelines
---
## Versioning Strategy
Following **Semantic Versioning** (MAJOR.MINOR.PATCH):
- **MAJOR:** Breaking changes to public API
- **MINOR:** New features, backward compatible
- **PATCH:** Bug fixes, performance improvements
### Planned Releases
- **v0.2.0** - Phase 2 completion (Color schemes, hex dump)
- **v0.3.0** - Phase 3 start (Interactive features)
- **v1.0.0** - Production ready (All core features stable)
- **v2.0.0** - Phase 4-5 (Binary files, performance)
---
## Feedback & Priorities
Feature priorities may change based on:
- User feedback and requests
- Community contributions
- Technical dependencies
- Performance considerations
To suggest features or vote on priorities:
- Open an issue on GitHub
- Join discussions in the community
- Contribute code or documentation
---
**Document Version:** 1.0
**Next Review:** December 2024