Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ out
.nuxt
dist
dist-tests
dist-benchmarks
temp

# Gatsby files
Expand Down
41 changes: 37 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.0.0] - 2026-01-22

### Changed
- Promoted 1.0.0-rc.1 to stable release
- Updated CDN references to 1.0.0

### Documentation
- Added `labelFit` and `labelPadding` options to README Labels section
- Added `LabelOptions` type definition to API docs

## [1.0.0-rc.1] - 2026-01-21

### Added
- **Multi-parent nodes stable**: Removed EXPERIMENTAL status from multi-parent nodes feature. Added comprehensive test suite (23 tests) covering detection, normalization, validation, layout, and integration. Documented known limitations (key highlighting, navigation ambiguity). Stable since 1.0.
- **Layer-specific rootLabelStyle**: Added `rootLabelStyle` option to `LayerConfig` for per-layer control of root label rendering (`'curved'` or `'straight'`). Layer setting takes priority over global `LabelOptions.rootLabelStyle`.
- **Configurable fontSizeScale**: Added `fontSizeScale` option to `LabelOptions` to control font size calculation. Default is `0.5`. Use smaller values (e.g., `0.25`) for large charts where fonts would otherwise always hit max size.
- **Accessibility improvements** (#47): Added keyboard navigation and ARIA support for screen readers:
- Arc elements are now focusable (`tabindex="0"`) and have `role="button"` with descriptive `aria-label`
- Keyboard navigation: Tab to focus arcs, Enter/Space to drill down
- Focus shows tooltip and breadcrumb, blur hides them
- SVG container has `role="graphics-document"` and `aria-label`
- Tooltip element has `role="tooltip"`, breadcrumb has `role="status"`
- **Performance benchmarks** (#49): Added benchmark suite and performance documentation:
- Benchmark scripts for layout, render, and navigation (`npm run bench`)
- Performance guide with node limits and optimization tips (`docs/guides/performance.md`)
- **Configurable label padding** (#55): Added `labelPadding` option to `LabelOptions` to control spacing between label text and arc boundaries (default: 8px).
- **Label fit mode**: Added `labelFit` option to `LabelOptions` with values `'both'` (default), `'height'`, or `'width'` to control which dimension is checked for label visibility. When set to `'width'`, font size uses the configured max instead of scaling with arc thickness.
- **Browser compatibility documentation** (#48): Added browser support guide with compatibility matrix, minimum versions, and required browser features (`docs/guides/browser-support.md`).
- **Migration guide** (#50): Added migration guide for 1.0 documenting breaking changes, new features, and version compatibility (`docs/guides/migration-guide.md`).

### Fixed
- **Straight label centering**: Fixed straight labels on full-circle root nodes (360° arcs) to render at the true center instead of on the arc midpoint. Only applies to innermost rings (`y0 ≈ 0`); outer full-circle layers keep labels on the ring's mid-radius to avoid overlapping other layers.

## [0.4.0] - 2026-01-20

### Added
Expand Down Expand Up @@ -36,7 +69,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **colorAssignment.ts**: Use `codePointAt()` instead of `charCodeAt()`.
- **multi-parent-test.html**: Improve text contrast ratio.

## [0.3.6] - 2025-01-27
## [0.3.6] - 2025-11-27

### Enhanced
- **Multi-parent nodes nested support**: Multi-parent nodes can now be placed at any depth in the tree hierarchy, not just at root level. Removed restriction that limited `parents` property to root-level nodes only.
Expand All @@ -47,12 +80,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **Multi-parent radial positioning**: Fixed radial depth calculation for multi-parent children by properly converting y1 pixel values back to units. This prevents multi-parent children from overlapping with their unified parent arcs.

## [0.3.5] - 2025-01-27
## [0.3.5] - 2025-11-27

### Added
- **Multi-parent nodes (EXPERIMENTAL)**: Added `parents` property to `TreeNodeInput` to create unified parent arcs spanning multiple nodes. When a node specifies `parents: ['key1', 'key2']`, the parent nodes with those keys are treated as ONE combined arc, and the node becomes a child of that unified parent. Multiple nodes can share the same parent set, creating many-to-many relationships. Multi-parent nodes can be placed at any depth in the tree hierarchy (root level or nested). This feature is marked as experimental and includes a console warning on first use due to potential issues with animations, value calculations, and navigation. Parent nodes in a multi-parent group should not have their own individual children.

## [0.3.4] - 2025-01-26
## [0.3.4] - 2025-11-26

### Added
- **Border customization**: Added `borderColor` and `borderWidth` options to customize arc borders (strokes). Supports both global settings via `RenderSvgOptions` and per-layer overrides via `LayerConfig`. Layer-specific settings take priority over global options. Accepts any valid CSS color string for `borderColor` (hex, rgb, rgba, named colors) and numeric pixel values for `borderWidth`.
Expand Down Expand Up @@ -165,7 +198,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Recycled keyed SVG paths so update cycles no longer churn event listeners or DOM nodes.
- Expanded render handle tests and documentation to cover the update workflow and responsive sizing defaults.

## [0.2.0] - 2025-09-20
## [0.2.0] - 2025-09-22

### Added
- Highlight-by-key runtime with optional pinning, plus demo integration showing related arc highlighting.
Expand Down
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ For detailed guides, API reference, and examples, visit the [full documentation]
- [API Reference](#api-reference)
- [Build & Development](#build--development)
- [CDN Usage](#cdn-usage)
- [Browser Support](#browser-support)
- [License](#license)

---
Expand Down Expand Up @@ -536,6 +537,22 @@ renderSVG({
});
```

#### Label Options

```javascript
labels: {
labelPadding: 8, // Spacing around text in pixels (default: 8)
labelFit: 'both', // 'both' | 'height' | 'width'
fontSize: { min: 8, max: 16 },
autoLabelColor: true // Contrast-aware text color
}
```

**`labelFit`** controls which dimensions are checked when fitting labels:
- `'both'` (default): Label must fit the arc's radial thickness and arc length
- `'height'`: Only check radial thickness, use max font size based on ring height
- `'width'`: Only check arc length, labels always fit along the arc path

#### Custom Label Formatting

```javascript
Expand Down Expand Up @@ -671,7 +688,7 @@ For quick prototyping or non-bundled environments:
```html
<svg id="chart"></svg>

<script src="https://unpkg.com/@akitain/sandjs@0.4.0/dist/sandjs.iife.min.js"></script>
<script src="https://unpkg.com/@akitain/sandjs@1.0.0/dist/sandjs.iife.min.js"></script>
<script>
const { renderSVG } = window.SandJS;

Expand All @@ -698,6 +715,25 @@ For quick prototyping or non-bundled environments:

---

## Browser Support

Sand.js targets ESNext and supports modern browsers:

| Browser | Minimum Version |
|---------|-----------------|
| Chrome | 80+ |
| Firefox | 74+ |
| Safari | 13.1+ |
| Edge | 80+ |
| iOS Safari | 13.4+ |
| Chrome Android | 80+ |

**Not supported:** Internet Explorer

> For older browsers, transpile the bundle with Babel. See the [Browser Support Guide](./docs/guides/browser-support.md) for details.

---

## License

MIT © Aqu1tain
163 changes: 163 additions & 0 deletions benchmarks/generators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import type { TreeNodeInput, SunburstConfig } from '../src/index.js';

export type TreeShape = 'balanced' | 'deep' | 'wide' | 'realistic';

export interface GeneratorOptions {
nodeCount: number;
shape?: TreeShape;
maxDepth?: number;
branchingFactor?: number;
}

export function generateTree(options: GeneratorOptions): TreeNodeInput {
const { nodeCount, shape = 'balanced' } = options;

switch (shape) {
case 'deep':
return generateDeepTree(nodeCount, options.maxDepth ?? 8);
case 'wide':
return generateWideTree(nodeCount);
case 'realistic':
return generateRealisticTree(nodeCount);
default:
return generateBalancedTree(nodeCount, options.branchingFactor ?? 5);
}
}

function generateBalancedTree(nodeCount: number, branchingFactor: number): TreeNodeInput {
let created = 0;
const maxDepth = Math.min(Math.ceil(Math.log(nodeCount) / Math.log(branchingFactor)), 6);

function createNode(depth: number): TreeNodeInput {
created++;
const remaining = nodeCount - created;
const node: TreeNodeInput = {
name: `Node-${created}`,
value: Math.floor(Math.random() * 100) + 1,
};

if (remaining <= 0 || depth >= maxDepth) return node;

const childCount = Math.min(branchingFactor, remaining);
if (childCount > 0) {
node.children = [];
for (let i = 0; i < childCount && created < nodeCount; i++) {
node.children.push(createNode(depth + 1));
}
}

return node;
}

return createNode(0);
}

function generateDeepTree(nodeCount: number, maxDepth: number): TreeNodeInput {
const depthToUse = Math.min(nodeCount - 1, maxDepth);
let remaining = nodeCount;

function createNode(depth: number): TreeNodeInput {
remaining--;
const node: TreeNodeInput = {
name: `Deep-${nodeCount - remaining}`,
value: Math.floor(Math.random() * 100) + 1,
};

if (remaining <= 0 || depth >= depthToUse) return node;

const siblingCount = Math.min(Math.ceil(remaining / Math.max(1, depthToUse - depth)), 5);
node.children = [];

for (let i = 0; i < siblingCount && remaining > 0; i++) {
node.children.push(createNode(depth + 1));
}

return node;
}

return createNode(0);
}

function generateWideTree(nodeCount: number): TreeNodeInput {
const root: TreeNodeInput = {
name: 'Root',
value: 100,
children: [],
};

for (let i = 1; i < nodeCount; i++) {
root.children!.push({
name: `Wide-${i}`,
value: Math.floor(Math.random() * 100) + 1,
});
}

return root;
}

function generateRealisticTree(nodeCount: number): TreeNodeInput {
let created = 0;
const maxDepth = 5;
const baseChildren = Math.ceil(Math.pow(nodeCount, 1 / maxDepth));

function createNode(depth: number): TreeNodeInput {
created++;
const node: TreeNodeInput = {
name: `Item-${created}`,
value: Math.floor(Math.random() * 1000) + 1,
};

const remaining = nodeCount - created;
if (remaining <= 0 || depth >= maxDepth) return node;

const levelsLeft = maxDepth - depth;
const targetChildren = Math.min(
Math.ceil(Math.pow(remaining, 1 / levelsLeft)),
baseChildren,
remaining
);

if (targetChildren > 0) {
node.children = [];
for (let i = 0; i < targetChildren && created < nodeCount; i++) {
node.children.push(createNode(depth + 1));
}
}

return node;
}

return createNode(0);
}

export function getTreeDepth(node: TreeNodeInput, depth = 0): number {
if (!node.children || node.children.length === 0) return depth;
return Math.max(...node.children.map(c => getTreeDepth(c, depth + 1)));
}

export function createConfig(tree: TreeNodeInput, expandLevels?: number): SunburstConfig {
const depth = getTreeDepth(tree);
const levels = expandLevels ?? depth + 2;

return {
size: { radius: 300 },
layers: [
{
id: 'main',
radialUnits: [0, levels],
angleMode: 'free',
tree,
},
],
};
}

export function countNodes(node: TreeNodeInput): number {
let count = 1;
if (node.children) {
for (const child of node.children) {
count += countNodes(child);
}
}
return count;
}
39 changes: 39 additions & 0 deletions benchmarks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));

const benchmarks = ['layout.bench.js', 'render.bench.js', 'navigation.bench.js'];

async function runBenchmark(file: string): Promise<void> {
return new Promise((resolve, reject) => {
const proc = spawn('node', [join(__dirname, file)], { stdio: 'inherit' });
proc.on('close', (code) => {
if (code === 0) resolve();
else reject(new Error(`Benchmark ${file} exited with code ${code}`));
});
proc.on('error', reject);
});
}

async function main() {
console.log('='.repeat(70));
console.log(' SAND.JS PERFORMANCE BENCHMARKS');
console.log('='.repeat(70));
console.log('');

for (const bench of benchmarks) {
await runBenchmark(bench);
console.log('\n');
}

console.log('='.repeat(70));
console.log(' ALL BENCHMARKS COMPLETE');
console.log('='.repeat(70));
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
Loading