From 1ef7f86639a2becab301f150eac1d780a25ba56a Mon Sep 17 00:00:00 2001 From: Corentin R Date: Wed, 21 Jan 2026 09:51:27 +0100 Subject: [PATCH 01/13] feat: add layer-specific rootLabelStyle option (#52) * feat: add layer-specific rootLabelStyle option * fix: center straight label on full-circle root nodes * feat: add configurable fontSizeScale option --- src/render/svg/label-system.ts | 23 ++++++++++++++++++++--- src/render/types.ts | 1 + src/types/index.ts | 1 + 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/render/svg/label-system.ts b/src/render/svg/label-system.ts index d006b95..5912548 100644 --- a/src/render/svg/label-system.ts +++ b/src/render/svg/label-system.ts @@ -99,11 +99,15 @@ export function updateArcLabel(managed: ManagedPath, arc: LayoutArc, options: Up const labelColor = resolveLabelColor(arc, layer, renderOptions, arcColor); const useStraightStyle = shouldUseStraightLabel(arc, renderOptions); - showLabel(managed, text, evaluation, arc, labelColor, { useStraightStyle }); + showLabel(managed, text, evaluation, arc, labelColor, { useStraightStyle, cx, cy }); } function shouldUseStraightLabel(arc: LayoutArc, renderOptions: ResolvedRenderOptions): boolean { if (arc.depth !== 0) return false; + + const layer = renderOptions.config.layers.find(l => l.id === arc.layerId); + if (layer?.rootLabelStyle) return layer.rootLabelStyle === 'straight'; + const labelOptions = renderOptions.labels; if (typeof labelOptions !== 'object') return false; return labelOptions?.rootLabelStyle === 'straight'; @@ -143,6 +147,13 @@ function resolveMinRadialThickness(labelOptions: ResolvedRenderOptions['labels'] return labelOptions?.minRadialThickness ?? LABEL_MIN_RADIAL_THICKNESS; } +const DEFAULT_FONT_SIZE_SCALE = 0.5; + +function resolveFontSizeScale(labelOptions: ResolvedRenderOptions['labels']): number { + if (typeof labelOptions !== 'object') return DEFAULT_FONT_SIZE_SCALE; + return labelOptions?.fontSizeScale ?? DEFAULT_FONT_SIZE_SCALE; +} + /** * Evaluates whether a label can be shown for an arc */ @@ -165,8 +176,9 @@ function evaluateLabelVisibility( } const fontConfig = resolveFontSizeConfig(renderOptions.labels); + const fontSizeScale = resolveFontSizeScale(renderOptions.labels); const midRadius = arc.y0 + radialThickness * 0.5; - const fontSize = Math.min(Math.max(radialThickness * 0.5, fontConfig.min), fontConfig.max); + const fontSize = Math.min(Math.max(radialThickness * fontSizeScale, fontConfig.min), fontConfig.max); const estimatedWidth = text.length * fontSize * LABEL_CHAR_WIDTH_FACTOR + LABEL_PADDING; const arcLength = span * midRadius; @@ -268,6 +280,8 @@ function createLabelArcPath(params: { type ShowLabelOptions = { useStraightStyle: boolean; + cx: number; + cy: number; }; /** @@ -292,7 +306,10 @@ function showLabel( labelElement.dataset.depth = String(arc.depth); if (options.useStraightStyle) { - showStraightLabel(managed, text, evaluation); + const isFullCircle = (arc.x1 - arc.x0) >= TAU - ZERO_TOLERANCE; + const x = isFullCircle ? options.cx : evaluation.x; + const y = isFullCircle ? options.cy : evaluation.y; + showStraightLabel(managed, text, { ...evaluation, x, y }); } else { showCurvedLabel(managed, text, evaluation); } diff --git a/src/render/types.ts b/src/render/types.ts index 544e343..8a97c22 100644 --- a/src/render/types.ts +++ b/src/render/types.ts @@ -72,6 +72,7 @@ export interface LabelOptions { labelColor?: string; autoLabelColor?: boolean; fontSize?: number | { min: number; max: number }; + fontSizeScale?: number; minRadialThickness?: number; rootLabelStyle?: 'curved' | 'straight'; } diff --git a/src/types/index.ts b/src/types/index.ts index 9867772..2036843 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -55,6 +55,7 @@ export interface LayerConfig { borderWidth?: number; labelColor?: string; showLabels?: boolean; + rootLabelStyle?: 'curved' | 'straight'; } /** From f916b59119f2de14c0cc362b400075effd5d735a Mon Sep 17 00:00:00 2001 From: Corentin R Date: Wed, 21 Jan 2026 09:54:24 +0100 Subject: [PATCH 02/13] feat: add fontSizeScale option and update CHANGELOG (#53) * feat: add layer-specific rootLabelStyle option * fix: center straight label on full-circle root nodes * feat: add configurable fontSizeScale option * docs: update CHANGELOG with unreleased changes --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ecf4ad..114297b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + +### 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. + ## [0.4.0] - 2026-01-20 ### Added From 7b05aeffbc72144c71900769c2ae06d2c15613a5 Mon Sep 17 00:00:00 2001 From: Corentin R Date: Wed, 21 Jan 2026 09:59:31 +0100 Subject: [PATCH 03/13] feat: stabilize multi-parent nodes for 1.0 (#51) * feat: stabilize multi-parent nodes for 1.0 - Add comprehensive test suite (tests/multiParent.spec.ts) with 23 tests covering detection, normalization, validation, layout, and integration - Remove EXPERIMENTAL warning from normalization.ts - Update documentation to mark feature as stable since 1.0 - Document known limitations (key highlighting, navigation ambiguity) * chore: remove EXPERIMENTAL comment from types * chore: remove experimental banner from demo --- demo/multi-parent-test.html | 10 +- docs/api/configuration.md | 18 +- docs/api/tree-node-input.md | 10 +- src/layout/normalization.ts | 18 +- src/types/index.ts | 2 +- tests/multiParent.spec.ts | 921 ++++++++++++++++++++++++++++++++++++ tsconfig.test.json | 3 +- 7 files changed, 942 insertions(+), 40 deletions(-) create mode 100644 tests/multiParent.spec.ts diff --git a/demo/multi-parent-test.html b/demo/multi-parent-test.html index 8814764..68c7491 100644 --- a/demo/multi-parent-test.html +++ b/demo/multi-parent-test.html @@ -77,20 +77,14 @@ -

🚧 Multi-Parent Nodes Test

- -
- ⚠️ EXPERIMENTAL FEATURE - Check console for warnings -
+

Multi-Parent Nodes Test

- This demo shows NESTED multi-parent nodes! Now you can use the parents property at any depth. + This demo shows NESTED multi-parent nodes. Use the parents property at any depth.

Root-level: Engineering + Design → Frontend Team (outer ring)
Nested: Backend + DevOps → Shared Infrastructure (inside Engineering) -

- Open your browser console to see the experimental feature warning.
diff --git a/docs/api/configuration.md b/docs/api/configuration.md index f13b1af..87e24d8 100644 --- a/docs/api/configuration.md +++ b/docs/api/configuration.md @@ -716,10 +716,10 @@ Completely hide this node from layout. #### parents -**⚠️ EXPERIMENTAL FEATURE - Use at your own risk** - Array of parent keys that creates a unified parent arc spanning multiple nodes. When multiple parent keys are specified, those parent nodes are treated as ONE combined arc, and this node becomes a child of that unified parent. +**Stable since:** 1.0 + ```javascript { name: 'Frontend Team', @@ -734,7 +734,6 @@ Array of parent keys that creates a unified parent arc spanning multiple nodes. - The combined arc spans from the start of the first parent to the end of the last parent - Multiple nodes can share the same `parents` array and will all be children of the unified parent - Parent nodes referenced in `parents` arrays should not have their own individual children -- A console warning will appear the first time this feature is used **How it works:** 1. Nodes with a `parents` property are extracted during normalization @@ -742,12 +741,9 @@ Array of parent keys that creates a unified parent arc spanning multiple nodes. 3. A combined angular span is calculated (from min start to max end of all parent arcs) 4. Multi-parent children are laid out within that combined span -**⚠️ Warning:** -This feature may cause unexpected behavior with: -- Animations and transitions -- Value calculations (parents lose their individual values) -- Key-based highlighting -- Navigation and drill-down +**Known limitations:** +- Key-based highlighting does not automatically highlight multi-parent arcs when hovering parents +- Navigation/drill-down has path ambiguity with multi-parent nodes **Constraints:** - Must be an array of at least 2 strings @@ -847,8 +843,8 @@ This feature may cause unexpected behavior with: - Cross-functional teams spanning multiple divisions - Many-to-many relationships -**Recommended approach:** -This feature is experimental. For simpler cases, consider using multiple layers with alignment instead. +**Alternative approach:** +For simpler cases, consider using multiple layers with alignment instead. --- diff --git a/docs/api/tree-node-input.md b/docs/api/tree-node-input.md index e949913..cb374fa 100644 --- a/docs/api/tree-node-input.md +++ b/docs/api/tree-node-input.md @@ -20,7 +20,7 @@ interface TreeNodeInput { labelColor?: string; padAngle?: number; children?: TreeNodeInput[]; - parents?: string[]; // EXPERIMENTAL + parents?: string[]; tooltip?: string; collapsed?: boolean; hidden?: boolean; @@ -207,12 +207,12 @@ children: [ ### parents -**⚠️ EXPERIMENTAL FEATURE** - Array of parent keys that creates a unified parent arc spanning multiple nodes. **Type:** `string[] | undefined` +**Stable since:** 1.0 + **Example:** ```javascript { @@ -234,6 +234,10 @@ Array of parent keys that creates a unified parent arc spanning multiple nodes. - Parent nodes referenced should not have their own `children` - Can be used at any depth (root level or nested) +**Known limitations:** +- Key-based highlighting does not automatically highlight multi-parent arcs when hovering parents +- Navigation/drill-down has path ambiguity with multi-parent nodes + **Use cases:** - Shared resources across departments - Matrix organizational structures diff --git a/src/layout/normalization.ts b/src/layout/normalization.ts index d7333a9..48ad773 100644 --- a/src/layout/normalization.ts +++ b/src/layout/normalization.ts @@ -32,16 +32,6 @@ function isMultiParentNode(node: TreeNodeInput): boolean { return Boolean(node.parents && Array.isArray(node.parents) && node.parents.length > 1); } -function warnMultiParentFeature(warnOnce: { warned: boolean }): void { - if (warnOnce.warned) return; - console.warn( - '[Sand.js] ⚠️ EXPERIMENTAL FEATURE: Multi-parent nodes detected. ' + - 'Parent nodes with matching keys will be unified into a single combined arc. ' + - 'Use at your own risk.' - ); - warnOnce.warned = true; -} - function addToMultiParentGroup( node: TreeNodeInput, index: number, @@ -87,9 +77,7 @@ export function normalizeTree( parentPath: TreeNodeInput[] = [], parentIndices: number[] = [], multiParentGroups: Map = new Map(), - warnOnce?: { warned: boolean }, ): NormalizationResult { - const warnState = warnOnce ?? { warned: false }; const nodes = Array.isArray(tree) ? tree : [tree]; const isRoot = parentPath.length === 0; const normalized: NormalizedNode[] = []; @@ -98,12 +86,11 @@ export function normalizeTree( if (!node || node.hidden) return; if (isMultiParentNode(node)) { - warnMultiParentFeature(warnState); addToMultiParentGroup(node, index, parentPath, parentIndices, multiParentGroups); return; } - normalized.push(normalizeNode(node, index, parentPath, parentIndices, multiParentGroups, warnState)); + normalized.push(normalizeNode(node, index, parentPath, parentIndices, multiParentGroups)); }); if (isRoot) { @@ -118,12 +105,11 @@ function normalizeNode( parentPath: TreeNodeInput[], parentIndices: number[], multiParentGroups: Map, - warnOnce: { warned: boolean }, ): NormalizedNode { const children = Array.isArray(node.children) ? node.children : []; const path = parentPath.concat(node); const pathIndices = parentIndices.concat(index); - const childResult = normalizeTree(children, path, pathIndices, multiParentGroups, warnOnce); + const childResult = normalizeTree(children, path, pathIndices, multiParentGroups); const normalizedChildren = childResult.nodes; const collapsed = Boolean(node.collapsed); diff --git a/src/types/index.ts b/src/types/index.ts index 2036843..3a29561 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -30,7 +30,7 @@ export interface TreeNodeInput { labelColor?: string; padAngle?: number; children?: TreeNodeInput[]; - parents?: string[]; // EXPERIMENTAL: Array of parent keys for multi-parent nodes + parents?: string[]; tooltip?: string; hidden?: boolean; collapsed?: boolean; diff --git a/tests/multiParent.spec.ts b/tests/multiParent.spec.ts new file mode 100644 index 0000000..45733e6 --- /dev/null +++ b/tests/multiParent.spec.ts @@ -0,0 +1,921 @@ +import { test, describe, mock } from 'node:test'; +import * as assert from 'node:assert/strict'; +import { layout, renderSVG } from '../src/index.js'; +import type { SunburstConfig, TreeNodeInput } from '../src/index.js'; +import { normalizeTree } from '../src/layout/normalization.js'; + +const TOLERANCE = 1e-6; + +function roughlyEqual(a: number, b: number, tolerance = TOLERANCE) { + assert.ok(Math.abs(a - b) <= tolerance, `Expected ${a} to be within ${tolerance} of ${b}`); +} + +// Stub elements for renderSVG tests +class StubElement { + public attributes = new Map(); + public children: StubElement[] = []; + public parentNode: StubElement | null = null; + public textContent = ''; + public firstChild: StubElement | null = null; + public style: Record = {}; + public classList = { + add: () => {}, + remove: () => {}, + toggle: () => {}, + }; + public dataset: Record; + public listeners: Record void>> = {}; + private _innerHTML = ''; + + constructor(public tagName: string) { + const self = this; + this.dataset = new Proxy({} as Record, { + set(target, prop: string, value: string) { + target[prop] = value; + self.attributes.set(`data-${prop}`, value); + return true; + }, + deleteProperty(target, prop: string) { + delete target[prop]; + self.attributes.delete(`data-${prop}`); + return true; + }, + }); + } + + setAttribute(name: string, value: string) { + this.attributes.set(name, value); + if (name.startsWith('data-')) { + this.dataset[name.slice(5)] = value; + } + } + + setAttributeNS(_namespace: string | null, name: string, value: string) { + this.setAttribute(name, value); + } + + set innerHTML(value: string) { + this._innerHTML = value; + this.children = []; + this.firstChild = null; + this.textContent = value; + } + + get innerHTML(): string { + return this._innerHTML; + } + + removeAttribute(name: string) { + this.attributes.delete(name); + if (name.startsWith('data-')) { + delete this.dataset[name.slice(5)]; + } + } + + appendChild(child: T): T { + child.parentNode = this; + this.children.push(child); + this.firstChild = this.children[0] ?? null; + return child; + } + + removeChild(child: StubElement): StubElement { + const index = this.children.indexOf(child); + if (index !== -1) { + this.children.splice(index, 1); + child.parentNode = null; + } + this.firstChild = this.children[0] ?? null; + return child; + } + + remove(): void { + if (!this.parentNode) return; + this.parentNode.removeChild(this); + } + + addEventListener(type: string, handler: (event: any) => void): void { + if (!this.listeners[type]) { + this.listeners[type] = []; + } + this.listeners[type].push(handler); + } + + removeEventListener(type: string, handler: (event: any) => void): void { + const list = this.listeners[type]; + if (!list) return; + const index = list.indexOf(handler); + if (index !== -1) { + list.splice(index, 1); + } + } + + querySelector(selector: string): StubElement | null { + const matchAttr = selector.startsWith('[') && selector.endsWith(']') + ? selector.slice(1, -1) + : null; + if (!matchAttr) return null; + const [attr] = matchAttr.split('='); + return ( + this.children.find((child) => child.attributes.has(attr)) ?? + this.children.reduce((found, child) => found ?? child.querySelector(selector), null) + ); + } + + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + get childNodes(): StubElement[] { + return this.children; + } + + insertBefore(newChild: T, refChild: StubElement | null): T { + if (!refChild || !this.children.includes(refChild)) { + return this.appendChild(newChild); + } + const index = this.children.indexOf(refChild); + this.children.splice(index, 0, newChild); + newChild.parentNode = this; + this.firstChild = this.children[0] ?? null; + return newChild; + } +} + +class StubSVGElement extends StubElement {} + +class StubSVGDefsElement extends StubSVGElement { + constructor() { + super('defs'); + } +} + +class StubDocument { + public body = new StubElement('body'); + + createElementNS(_ns: string, tag: string) { + if (tag === 'defs') { + return new StubSVGDefsElement(); + } + return new StubSVGElement(tag); + } + + createElement(tag: string) { + return new StubElement(tag); + } + + querySelector(): null { + return null; + } +} + +// Ensure global SVG classes exist for instanceof checks. +(globalThis as any).SVGElement = StubSVGElement; +(globalThis as any).SVGDefsElement = StubSVGDefsElement; + +// ============================================================================= +// Detection Tests +// ============================================================================= + +describe('Multi-parent detection', () => { + test('isMultiParentNode identifies nodes with 2+ parents', () => { + const tree: TreeNodeInput[] = [ + { name: 'Parent A', key: 'a', value: 30 }, + { name: 'Parent B', key: 'b', value: 30 }, + { name: 'Multi-parent child', value: 20, parents: ['a', 'b'] }, + ]; + + const result = normalizeTree(tree); + assert.equal(result.nodes.length, 2, 'Regular nodes should be in nodes array'); + assert.equal(result.multiParentGroups.length, 1, 'Multi-parent nodes should be extracted'); + assert.deepEqual(result.multiParentGroups[0].parentKeys, ['a', 'b']); + }); + + test('returns false for nodes without parents property', () => { + const tree: TreeNodeInput[] = [ + { name: 'Normal node A', value: 50 }, + { name: 'Normal node B', value: 50 }, + ]; + + const result = normalizeTree(tree); + assert.equal(result.nodes.length, 2); + assert.equal(result.multiParentGroups.length, 0); + }); + + test('returns false for nodes with single parent or empty array', () => { + const tree: TreeNodeInput[] = [ + { name: 'Single parent', value: 30, parents: ['a'] }, + { name: 'Empty parents', value: 30, parents: [] }, + { name: 'Normal', value: 40 }, + ]; + + const result = normalizeTree(tree); + // Single parent and empty parents are NOT multi-parent nodes + // They should be treated as normal nodes + assert.equal(result.nodes.length, 3, 'All nodes should be in nodes array'); + assert.equal(result.multiParentGroups.length, 0, 'No multi-parent groups'); + }); +}); + +// ============================================================================= +// Normalization Tests +// ============================================================================= + +describe('Multi-parent normalization', () => { + test('multi-parent nodes are extracted into separate groups', () => { + const tree: TreeNodeInput[] = [ + { name: 'Eng', key: 'eng', value: 40 }, + { name: 'Design', key: 'design', value: 30 }, + { name: 'Product', key: 'product', value: 30 }, + { name: 'Frontend Team', value: 25, parents: ['eng', 'design'] }, + ]; + + const result = normalizeTree(tree); + assert.equal(result.nodes.length, 3, 'Regular nodes extracted'); + assert.equal(result.multiParentGroups.length, 1, 'Multi-parent group created'); + assert.equal(result.multiParentGroups[0].children.length, 1); + assert.equal(result.multiParentGroups[0].children[0].input.name, 'Frontend Team'); + }); + + test('nodes with same parent keys are grouped together', () => { + const tree: TreeNodeInput[] = [ + { name: 'Eng', key: 'eng', value: 40 }, + { name: 'Design', key: 'design', value: 30 }, + { name: 'Frontend Team', value: 25, parents: ['eng', 'design'] }, + { name: 'Shared Tools', value: 20, parents: ['eng', 'design'] }, + ]; + + const result = normalizeTree(tree); + assert.equal(result.multiParentGroups.length, 1, 'Same parents = same group'); + assert.equal(result.multiParentGroups[0].children.length, 2); + }); + + test('nodes with different parent combinations are in separate groups', () => { + const tree: TreeNodeInput[] = [ + { name: 'Eng', key: 'eng', value: 30 }, + { name: 'Design', key: 'design', value: 30 }, + { name: 'Product', key: 'product', value: 30 }, + { name: 'Frontend', value: 15, parents: ['eng', 'design'] }, + { name: 'Growth', value: 15, parents: ['product', 'design'] }, + ]; + + const result = normalizeTree(tree); + assert.equal(result.multiParentGroups.length, 2, 'Different parents = different groups'); + }); + + test('non-multi-parent siblings remain in normal tree', () => { + const tree: TreeNodeInput[] = [ + { name: 'Eng', key: 'eng', value: 40, children: [{ name: 'Eng-child', value: 10 }] }, + { name: 'Design', key: 'design', value: 30 }, + { name: 'Multi', value: 20, parents: ['eng', 'design'] }, + ]; + + const result = normalizeTree(tree); + const engNode = result.nodes.find(n => n.input.name === 'Eng'); + assert.ok(engNode, 'Eng should be in normal tree'); + assert.equal(engNode!.children.length, 1, 'Eng children preserved'); + assert.equal(engNode!.children[0].input.name, 'Eng-child'); + }); + + test('nested multi-parent nodes work within subtrees', () => { + const tree: TreeNodeInput = { + name: 'Root', + children: [ + { name: 'Team1', key: 'team1', value: 30 }, + { name: 'Team2', key: 'team2', value: 20 }, + { name: 'Shared', value: 15, parents: ['team1', 'team2'] }, + ], + }; + + const result = normalizeTree(tree); + // Root should be processed, and nested multi-parent should be extracted + assert.equal(result.nodes.length, 1, 'Root is in nodes'); + assert.equal(result.multiParentGroups.length, 1, 'Nested multi-parent group extracted'); + }); +}); + +// ============================================================================= +// Validation Tests +// ============================================================================= + +describe('Multi-parent validation', () => { + test('warns when parent key does not exist', () => { + const originalWarn = console.warn; + const warnings: string[] = []; + console.warn = (...args: any[]) => warnings.push(args.join(' ')); + + try { + const config: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 2], + angleMode: 'free', + tree: [ + { name: 'Existing', key: 'existing', value: 50 }, + { name: 'Multi', value: 30, parents: ['existing', 'nonexistent'] }, + ], + }, + ], + }; + + layout(config); + + const relevantWarning = warnings.find(w => w.includes('missing parent arcs')); + assert.ok(relevantWarning, 'Should warn about missing parent keys'); + assert.ok(relevantWarning!.includes('nonexistent'), 'Warning should mention missing key'); + } finally { + console.warn = originalWarn; + } + }); + + test('warns when all parent keys are missing', () => { + const originalWarn = console.warn; + const warnings: string[] = []; + console.warn = (...args: any[]) => warnings.push(args.join(' ')); + + try { + const config: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 2], + angleMode: 'free', + tree: [ + { name: 'Node', value: 50 }, + { name: 'Multi', value: 30, parents: ['a', 'b'] }, + ], + }, + ], + }; + + layout(config); + + const relevantWarning = warnings.find(w => w.includes('non-existent parent keys')); + assert.ok(relevantWarning, 'Should warn about non-existent parent keys'); + } finally { + console.warn = originalWarn; + } + }); + + test('skips group when parent node has children (critical constraint)', () => { + const originalError = console.error; + const originalWarn = console.warn; + const errors: string[] = []; + console.error = (...args: any[]) => errors.push(args.join(' ')); + console.warn = () => {}; // Suppress other warnings + + try { + const config: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 3], + angleMode: 'free', + tree: [ + { + name: 'Parent With Children', + key: 'parent-a', + children: [{ name: 'Child', value: 20 }], + }, + { name: 'Parent B', key: 'parent-b', value: 30 }, + { name: 'Multi', value: 25, parents: ['parent-a', 'parent-b'] }, + ], + }, + ], + }; + + const arcs = layout(config); + + // Should have an error logged + const validationError = errors.find(e => e.includes('Multi-parent validation failed')); + assert.ok(validationError, 'Should log validation error'); + assert.ok(validationError!.includes('parent-a'), 'Error should mention the offending key'); + + // Multi-parent child should NOT be rendered + const multiArc = arcs.find(arc => arc.data.name === 'Multi'); + assert.equal(multiArc, undefined, 'Multi-parent child should be skipped'); + + // Normal arcs should still render + const parentArc = arcs.find(arc => arc.data.name === 'Parent With Children'); + assert.ok(parentArc, 'Normal parent should render'); + } finally { + console.error = originalError; + console.warn = originalWarn; + } + }); +}); + +// ============================================================================= +// Layout Tests +// ============================================================================= + +describe('Multi-parent layout', () => { + test('multi-parent child spans angular range of all parents (x0 to x1)', () => { + const originalWarn = console.warn; + console.warn = () => {}; // Suppress experimental warning + + try { + const config: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 2], + angleMode: 'free', + tree: [ + { name: 'A', key: 'a', value: 50 }, + { name: 'B', key: 'b', value: 50 }, + { name: 'Multi', value: 30, parents: ['a', 'b'] }, + ], + }, + ], + }; + + const arcs = layout(config); + const arcA = arcs.find(arc => arc.data.name === 'A'); + const arcB = arcs.find(arc => arc.data.name === 'B'); + const multiArc = arcs.find(arc => arc.data.name === 'Multi'); + + assert.ok(arcA && arcB && multiArc, 'All arcs should be present'); + + // Multi-parent arc should span from min(parents.x0) to max(parents.x1) + const expectedStart = Math.min(arcA!.x0, arcB!.x0); + const expectedEnd = Math.max(arcA!.x1, arcB!.x1); + + roughlyEqual(multiArc!.x0, expectedStart); + roughlyEqual(multiArc!.x1, expectedEnd); + } finally { + console.warn = originalWarn; + } + }); + + test('child positioned at correct radial depth (y0, y1)', () => { + const originalWarn = console.warn; + console.warn = () => {}; + + try { + const config: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 2], + angleMode: 'free', + tree: [ + { name: 'A', key: 'a', value: 50 }, + { name: 'B', key: 'b', value: 50 }, + { name: 'Multi', value: 30, parents: ['a', 'b'] }, + ], + }, + ], + }; + + const arcs = layout(config); + const arcA = arcs.find(arc => arc.data.name === 'A'); + const multiArc = arcs.find(arc => arc.data.name === 'Multi'); + + assert.ok(arcA && multiArc, 'Arcs should be present'); + + // Multi-parent arc should start at parent's outer radius + roughlyEqual(multiArc!.y0, arcA!.y1); + // Multi-parent arc should have proper thickness + assert.ok(multiArc!.y1 > multiArc!.y0, 'Multi-parent arc should have positive thickness'); + } finally { + console.warn = originalWarn; + } + }); + + test('multiple children sharing same parents divide the span', () => { + const originalWarn = console.warn; + console.warn = () => {}; + + try { + const config: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 2], + angleMode: 'free', + tree: [ + { name: 'A', key: 'a', value: 50 }, + { name: 'B', key: 'b', value: 50 }, + { name: 'Multi1', value: 30, parents: ['a', 'b'] }, + { name: 'Multi2', value: 30, parents: ['a', 'b'] }, + ], + }, + ], + }; + + const arcs = layout(config); + const multi1 = arcs.find(arc => arc.data.name === 'Multi1'); + const multi2 = arcs.find(arc => arc.data.name === 'Multi2'); + + assert.ok(multi1 && multi2, 'Both multi-parent arcs should be present'); + + // They should divide the parent span equally (same value = 50% each) + roughlyEqual(multi1!.percentage, 0.5); + roughlyEqual(multi2!.percentage, 0.5); + + // multi1 should end where multi2 starts + roughlyEqual(multi1!.x1, multi2!.x0); + } finally { + console.warn = originalWarn; + } + }); + + test('padAngle applied between multi-parent siblings', () => { + const originalWarn = console.warn; + console.warn = () => {}; + + try { + const padAngle = 0.1; + const config: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 2], + angleMode: 'free', + padAngle, + tree: [ + { name: 'A', key: 'a', value: 50 }, + { name: 'B', key: 'b', value: 50 }, + { name: 'Multi1', value: 30, parents: ['a', 'b'] }, + { name: 'Multi2', value: 30, parents: ['a', 'b'] }, + ], + }, + ], + }; + + const arcs = layout(config); + const multi1 = arcs.find(arc => arc.data.name === 'Multi1'); + const multi2 = arcs.find(arc => arc.data.name === 'Multi2'); + + assert.ok(multi1 && multi2, 'Both arcs should be present'); + + // There should be a gap between multi1's end and multi2's start + const gap = multi2!.x0 - multi1!.x1; + roughlyEqual(gap, padAngle); + } finally { + console.warn = originalWarn; + } + }); + + test('multi-parent arc depth is one more than parent depth', () => { + const originalWarn = console.warn; + console.warn = () => {}; + + try { + const config: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 2], + angleMode: 'free', + tree: [ + { name: 'A', key: 'a', value: 50 }, + { name: 'B', key: 'b', value: 50 }, + { name: 'Multi', value: 30, parents: ['a', 'b'] }, + ], + }, + ], + }; + + const arcs = layout(config); + const arcA = arcs.find(arc => arc.data.name === 'A'); + const multiArc = arcs.find(arc => arc.data.name === 'Multi'); + + assert.ok(arcA && multiArc, 'Arcs should be present'); + assert.equal(multiArc!.depth, arcA!.depth + 1, 'Multi-parent depth should be parent depth + 1'); + } finally { + console.warn = originalWarn; + } + }); +}); + +// ============================================================================= +// Integration Test +// ============================================================================= + +describe('Multi-parent integration', () => { + test('full render with renderSVG() produces correct DOM output', () => { + const document = new StubDocument(); + const hostStub = new StubSVGElement('svg'); + const originalWarn = console.warn; + console.warn = () => {}; // Suppress experimental warning + + try { + const config: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 2], + angleMode: 'free', + tree: [ + { name: 'Engineering', key: 'eng', value: 40 }, + { name: 'Design', key: 'design', value: 30 }, + { name: 'Product', key: 'product', value: 30 }, + { name: 'Frontend Team', value: 25, parents: ['eng', 'design'] }, + { name: 'Shared Tools', value: 20, parents: ['eng', 'design'] }, + ], + }, + ], + }; + + const chart = renderSVG({ + el: hostStub as unknown as SVGElement, + config, + document: document as unknown as Document, + }); + + // Should render 3 root nodes + 2 multi-parent children = 5 arcs + assert.equal(chart.length, 5, 'Should render all arcs'); + + const paths = hostStub.children.filter(c => c.tagName === 'path'); + assert.equal(paths.length, 5, 'Should create path elements for all arcs'); + + // Verify arc names + const arcNames = chart.map(arc => arc.data.name); + assert.ok(arcNames.includes('Engineering')); + assert.ok(arcNames.includes('Design')); + assert.ok(arcNames.includes('Product')); + assert.ok(arcNames.includes('Frontend Team')); + assert.ok(arcNames.includes('Shared Tools')); + + // Multi-parent arcs should have depth 1 + const frontendArc = chart.find(arc => arc.data.name === 'Frontend Team'); + const sharedArc = chart.find(arc => arc.data.name === 'Shared Tools'); + assert.equal(frontendArc!.depth, 1); + assert.equal(sharedArc!.depth, 1); + + chart.destroy(); + } finally { + console.warn = originalWarn; + } + }); + + test('multi-parent with nested structure renders correctly', () => { + const document = new StubDocument(); + const hostStub = new StubSVGElement('svg'); + const originalWarn = console.warn; + console.warn = () => {}; + + try { + const config: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 3], + angleMode: 'free', + tree: { + name: 'Company', + children: [ + { name: 'Team1', key: 'team1', value: 30 }, + { name: 'Team2', key: 'team2', value: 20 }, + { name: 'Shared Resource', value: 15, parents: ['team1', 'team2'] }, + ], + }, + }, + ], + }; + + const chart = renderSVG({ + el: hostStub as unknown as SVGElement, + config, + document: document as unknown as Document, + }); + + // Should render: Company, Team1, Team2, Shared Resource = 4 arcs + assert.equal(chart.length, 4, 'Should render nested structure with multi-parent'); + + const sharedArc = chart.find(arc => arc.data.name === 'Shared Resource'); + assert.ok(sharedArc, 'Shared resource should be rendered'); + + // Shared resource should be at depth 2 (Company -> Teams -> Shared) + assert.equal(sharedArc!.depth, 2); + + chart.destroy(); + } finally { + console.warn = originalWarn; + } + }); + + test('update() works with multi-parent configurations', () => { + const document = new StubDocument(); + const hostStub = new StubSVGElement('svg'); + const originalWarn = console.warn; + console.warn = () => {}; + + try { + const initialConfig: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 2], + angleMode: 'free', + tree: [ + { name: 'A', key: 'a', value: 50 }, + { name: 'B', key: 'b', value: 50 }, + ], + }, + ], + }; + + const chart = renderSVG({ + el: hostStub as unknown as SVGElement, + config: initialConfig, + document: document as unknown as Document, + }); + + assert.equal(chart.length, 2, 'Initial render should have 2 arcs'); + + // Update to include multi-parent node + const updatedConfig: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 2], + angleMode: 'free', + tree: [ + { name: 'A', key: 'a', value: 50 }, + { name: 'B', key: 'b', value: 50 }, + { name: 'Multi', value: 30, parents: ['a', 'b'] }, + ], + }, + ], + }; + + chart.update(updatedConfig); + assert.equal(chart.length, 3, 'After update should have 3 arcs including multi-parent'); + + const multiArc = chart.find(arc => arc.data.name === 'Multi'); + assert.ok(multiArc, 'Multi-parent arc should be present after update'); + + chart.destroy(); + } finally { + console.warn = originalWarn; + } + }); +}); + +// ============================================================================= +// Edge Cases +// ============================================================================= + +describe('Multi-parent edge cases', () => { + test('handles multiple separate multi-parent groups', () => { + const originalWarn = console.warn; + console.warn = () => {}; + + try { + const config: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 2], + angleMode: 'free', + tree: [ + { name: 'A', key: 'a', value: 25 }, + { name: 'B', key: 'b', value: 25 }, + { name: 'C', key: 'c', value: 25 }, + { name: 'D', key: 'd', value: 25 }, + { name: 'Multi-AB', value: 20, parents: ['a', 'b'] }, + { name: 'Multi-CD', value: 20, parents: ['c', 'd'] }, + ], + }, + ], + }; + + const arcs = layout(config); + + // Should have 4 parents + 2 multi-parent = 6 arcs + assert.equal(arcs.length, 6); + + const multiAB = arcs.find(arc => arc.data.name === 'Multi-AB'); + const multiCD = arcs.find(arc => arc.data.name === 'Multi-CD'); + + assert.ok(multiAB && multiCD, 'Both multi-parent groups should render'); + + // Multi-AB should span A and B + const arcA = arcs.find(arc => arc.data.name === 'A'); + const arcB = arcs.find(arc => arc.data.name === 'B'); + roughlyEqual(multiAB!.x0, Math.min(arcA!.x0, arcB!.x0)); + roughlyEqual(multiAB!.x1, Math.max(arcA!.x1, arcB!.x1)); + + // Multi-CD should span C and D + const arcC = arcs.find(arc => arc.data.name === 'C'); + const arcD = arcs.find(arc => arc.data.name === 'D'); + roughlyEqual(multiCD!.x0, Math.min(arcC!.x0, arcD!.x0)); + roughlyEqual(multiCD!.x1, Math.max(arcC!.x1, arcD!.x1)); + } finally { + console.warn = originalWarn; + } + }); + + test('non-adjacent parents work correctly', () => { + const originalWarn = console.warn; + console.warn = () => {}; + + try { + const config: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 2], + angleMode: 'free', + tree: [ + { name: 'A', key: 'a', value: 33 }, + { name: 'B', key: 'b', value: 34 }, + { name: 'C', key: 'c', value: 33 }, + // Parents are A and C, which are NOT adjacent (B is in between) + { name: 'Multi-AC', value: 20, parents: ['a', 'c'] }, + ], + }, + ], + }; + + const arcs = layout(config); + const multiArc = arcs.find(arc => arc.data.name === 'Multi-AC'); + const arcA = arcs.find(arc => arc.data.name === 'A'); + const arcC = arcs.find(arc => arc.data.name === 'C'); + + assert.ok(multiArc && arcA && arcC, 'Arcs should be present'); + + // Multi-parent should span from A's start to C's end (including B's span) + roughlyEqual(multiArc!.x0, arcA!.x0); + roughlyEqual(multiArc!.x1, arcC!.x1); + } finally { + console.warn = originalWarn; + } + }); + + test('parent key order does not affect grouping', () => { + const tree1: TreeNodeInput[] = [ + { name: 'A', key: 'a', value: 50 }, + { name: 'B', key: 'b', value: 50 }, + { name: 'Multi', value: 30, parents: ['a', 'b'] }, + ]; + + const tree2: TreeNodeInput[] = [ + { name: 'A', key: 'a', value: 50 }, + { name: 'B', key: 'b', value: 50 }, + { name: 'Multi', value: 30, parents: ['b', 'a'] }, // Reversed order + ]; + + const result1 = normalizeTree(tree1); + const result2 = normalizeTree(tree2); + + // Both should produce the same group (keys are sorted) + assert.deepEqual( + result1.multiParentGroups[0].parentKeys, + result2.multiParentGroups[0].parentKeys, + 'Parent key order should not affect grouping' + ); + }); + + test('three or more parents work correctly', () => { + const originalWarn = console.warn; + console.warn = () => {}; + + try { + const config: SunburstConfig = { + size: { radius: 100 }, + layers: [ + { + id: 'main', + radialUnits: [0, 2], + angleMode: 'free', + tree: [ + { name: 'A', key: 'a', value: 33 }, + { name: 'B', key: 'b', value: 34 }, + { name: 'C', key: 'c', value: 33 }, + { name: 'Multi', value: 30, parents: ['a', 'b', 'c'] }, + ], + }, + ], + }; + + const arcs = layout(config); + const multiArc = arcs.find(arc => arc.data.name === 'Multi'); + + assert.ok(multiArc, 'Multi-parent arc should be present'); + // Should span the entire circle (all three parents) + roughlyEqual(multiArc!.x0, 0); + roughlyEqual(multiArc!.x1, Math.PI * 2); + } finally { + console.warn = originalWarn; + } + }); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index f0d8f1a..b5329a6 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -18,6 +18,7 @@ "tests/errorHandling.spec.ts", "tests/runtimes.spec.ts", "tests/navigation.spec.ts", - "tests/labels.spec.ts" + "tests/labels.spec.ts", + "tests/multiParent.spec.ts" ] } From 24b4640647ee839a492353374b5f0bfde0770ca4 Mon Sep 17 00:00:00 2001 From: Corentin R Date: Wed, 21 Jan 2026 10:07:55 +0100 Subject: [PATCH 04/13] fix: only center full-circle labels for innermost rings (#54) * fix: only center full-circle labels for innermost rings * fix: correct release dates in CHANGELOG --- CHANGELOG.md | 10 +++++----- src/render/svg/label-system.ts | 6 ++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 114297b..6e496b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. ### 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. +- **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 @@ -44,7 +44,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. @@ -55,12 +55,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`. @@ -173,7 +173,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. diff --git a/src/render/svg/label-system.ts b/src/render/svg/label-system.ts index 5912548..d53dbe2 100644 --- a/src/render/svg/label-system.ts +++ b/src/render/svg/label-system.ts @@ -307,8 +307,10 @@ function showLabel( if (options.useStraightStyle) { const isFullCircle = (arc.x1 - arc.x0) >= TAU - ZERO_TOLERANCE; - const x = isFullCircle ? options.cx : evaluation.x; - const y = isFullCircle ? options.cy : evaluation.y; + const includesCenter = arc.y0 <= ZERO_TOLERANCE; + const useCenter = isFullCircle && includesCenter; + const x = useCenter ? options.cx : evaluation.x; + const y = useCenter ? options.cy : evaluation.y; showStraightLabel(managed, text, { ...evaluation, x, y }); } else { showCurvedLabel(managed, text, evaluation); From 1bb6114405aa834371233de86702eba7c02db068 Mon Sep 17 00:00:00 2001 From: Corentin R Date: Wed, 21 Jan 2026 11:09:42 +0100 Subject: [PATCH 05/13] feat(a11y): add keyboard navigation and ARIA support (#56) * feat(a11y): add keyboard navigation and ARIA support - Add tabindex, role="button", aria-label to arc elements - Add keyboard handlers (Enter/Space to drill down) - Add focus/blur events to show/hide tooltip and breadcrumb - Add role="graphics-document" and aria-label to SVG container - Add showAt method to tooltip for keyboard focus positioning Closes #47 * fix(a11y): improve focus visibility and z-order - Remove default square outline, use stroke for focus indication - Bring focused element to front (SVG stacking order) - Move label to front with focused arc - Add is-focused class for custom styling - Restore original border on blur * fix(a11y): use drop-shadow filter for focus visibility Drop-shadow glow effect works on all arc colors regardless of borders * fix(a11y): restore element position after blur Save nextSibling before moving to front, restore on blur * fix(a11y): simplify focus handling, remove DOM reordering DOM reordering was interfering with blur events * fix(a11y): use blue stroke for focus indicator, bring to front - Blue stroke (#005fcc, 2px) follows arc shape - Element moved to front on focus for visibility - Position restored on blur - Saves/restores original stroke values * fix(a11y): simplify focus - remove unreliable DOM restoration Sibling-based restoration fails when multiple elements focus/blur Focus ring and stroke styling still works correctly * fix(a11y): support keyboard activation for highlight pinning * fix(a11y): prevent blur during focus reordering * fix(a11y): move label with arc during focus reordering * fix(a11y): preserve tab order by not reordering DOM on focus * fix(a11y): use overlay focus ring to keep indicator on top * refactor(a11y): extract focus ring constants and helper --- CHANGELOG.md | 221 ++++++++++++++++++++++++++++++ src/render/runtime/highlight.ts | 2 +- src/render/runtime/tooltip.ts | 8 ++ src/render/svg/constants.ts | 6 + src/render/svg/orchestration.ts | 4 + src/render/svg/path-management.ts | 58 +++++++- src/render/types.ts | 4 +- 7 files changed, 299 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e496b4..f434837 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,227 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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"` + +### 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 +- **Simple API**: New simplified way to create sunbursts without layer configuration. Use `data` and `radius` props directly instead of wrapping everything in `config.layers`. The library auto-computes layer depth from tree structure. Example: `renderSVG({ el: '#chart', radius: 400, data: [{ name: 'A', children: [...] }] })`. Full `config` API remains available for advanced use cases (multi-layer, aligned layers, etc.). +- **Label font size control** (#40): Added `fontSize` option to `LabelOptions` - accepts a number for fixed size or `{ min, max }` object for range. Added `minRadialThickness` option to control visibility threshold. +- **Root label style** (#26): Added `rootLabelStyle` option to `LabelOptions` with values `'curved'` (default) or `'straight'` to display root node labels as centered text instead of following the arc path. +- **CI workflow**: Added GitHub Actions workflow for automated testing on PRs to main/dev branches. + +### Changed +- **Instant node removal** (#39): Disappearing nodes during navigation now remove instantly without fade/collapse animation. Only nodes that stay and expand are animated. + +### Refactoring +- **path-management.ts**: Extracted `attachEventHandlers`, `applyBorderStyles`, `applyDataAttributes`, `buildClassList` helpers. Simplified opacity conditionals with single guard clause. +- **orchestration.ts**: Split `renderSVG` (222→59 lines) into `createRenderLoop`, `executeRender`, `processArcs`, `applySvgDimensions`, `appendNewElement`, `scheduleRemovals`. Replaced `Object.defineProperties` with direct assignment. +- **label-system.ts**: Extracted `resolveLabelColor` helper. Replaced if-else chain in `logHiddenLabel` with `LABEL_HIDDEN_REASONS` map. Added `LABEL_TANGENT_*` constants for magic numbers. +- **navigation.ts**: Consolidated 8 mutable variables into `NavigationState` type. Renamed WeakMaps to clearer names (`nodeToBase`, `baseToPath`, `derivedToPath`). Extracted `registerSingleArc` and `setFocus` helpers. Flattened nested conditionals with guard clauses. +- **aligned.ts**: Reduced `layoutAlignedLayer` cognitive complexity from 27 to ~5 by extracting `getSourceLayer`, `buildRootSourceMap`, `getAlignedSlot`, `computeTrimmedBounds`, `layoutAlignedNode`, `fallbackToFreeLayout`. +- **breadcrumbs.ts**: Use `.dataset` instead of `setAttribute` for data attributes. +- **orchestration.ts**: Refactor `scheduleRemovals` to use options object (8→1 parameters). +- **normalization.ts**: Reduced `normalizeTree` cognitive complexity from 17 to ~6 by extracting `isMultiParentNode`, `warnMultiParentFeature`, `addToMultiParentGroup`, `normalizeValue`, `normalizeNode`. +- **orchestration.ts**: Replace boolean `supportsFragment` parameter with `BatchTargets` strategy pattern. +- **removal.ts, path-management.ts**: Use `element.remove()` instead of `parentNode.removeChild(element)`. +- **normalization.ts**: Avoid object literal as default parameter for `warnOnce`. +- **navigation.ts**: Extract nested ternary into `resolveNavigationOptions` helper. +- **index.ts**: Use `Set` with `has()` instead of array with `includes()` for `foundKeys`. +- **document.ts, animation.ts**: Compare with `undefined` directly instead of using `typeof`. +- **colorAssignment.ts**: Use `codePointAt()` instead of `charCodeAt()`. +- **multi-parent-test.html**: Improve text contrast ratio. + +## [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. + +### Added +- **Multi-parent validation**: Added validation to prevent parent nodes referenced in `parents` arrays from having their own children. When a parent node has children, the multi-parent group is skipped with a clear error message explaining the constraint violation. + +### 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-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-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`. + +- **Label visibility and color control**: Added comprehensive label customization with three levels of control: + - **Global control** via `labels` option: `true`/`false` for simple enable/disable, or `LabelOptions` object with `showLabels`, `labelColor`, and `autoLabelColor` properties + - **Layer-level control** via `LayerConfig.showLabels` and `LayerConfig.labelColor` to override global settings per layer + - **Node-level control** via `TreeNodeInput.labelColor` for individual arc label colors (highest priority) + - **Auto-contrast mode**: When `autoLabelColor: true`, automatically chooses black or white label color based on arc background using WCAG relative luminance calculation for optimal readability + - Priority cascade: Node labelColor → Layer labelColor → Global labelColor → Auto-contrast → Default (#000000) + +## [0.3.3] - 2025-11-21 + +### Fixed +- **Label positioning and orientation**: Fixed two critical label rendering bugs: + - Labels are now properly centered on arcs instead of appearing offset to the bottom-left. Added `text-anchor: middle` attribute to `` elements for correct SVG text alignment. + - Label inversion now uses tangent direction instead of midpoint angle, ensuring labels are always readable regardless of arc configuration. Labels at 0° and 180° now have correct orientation in all cases. + +### Changed +- **Console logging is now opt-in via `debug` option**: Label visibility warnings (e.g., "Hiding label because arc span is too narrow") no longer appear by default. Pass `debug: true` to `renderSVG()` to enable diagnostic logging for debugging layout issues. + +## [0.3.2] - 2025-10-23 + +### Refactoring +- **Split layout/index.ts into modular files** (#10): Extracted layout logic into separate, focused modules for improved maintainability and code organization. Created `normalization.ts` for tree normalization and utility functions, `shared.ts` for common types and arc creation, `free.ts` for free layout mode (value-based angular distribution), and `aligned.ts` for aligned layout mode (key-based alignment). Reduced index.ts from 467 lines to 98 lines. + +- **Split navigation.ts into modular files** (#9): Extracted navigation logic into separate modules for better organization. Created `navigation/types.ts` for core types (FocusTarget, NavigationTransitionContext), `navigation/tree-utils.ts` for tree traversal utilities (findNodeByKey, getNodeAtPath, collectNodesAlongPath, indexBaseConfig), `navigation/config-derivation.ts` for config derivation logic, and `navigation/focus-helpers.ts` for focus management helpers. Reduced navigation.ts from 534 lines to 282 lines. + +- **Extract constants and utilities from svg.ts** (#8): Extracted commonly used constants, types, and utility functions from svg.ts into separate modules. Created `svg/constants.ts` for SVG namespaces, label thresholds, and collapsed arc constants, `svg/types.ts` for RuntimeSet, AnimationHandle, AnimationDrivers, and ManagedPath types, `svg/runtime-creation.ts` for runtime set creation and disposal, and `svg/utils.ts` for utility functions (isSunburstConfig, ensureLabelDefs, extractConfigFromUpdate). Reduced svg.ts from 1,298 lines to 1,186 lines (112 lines saved). + +### Performance +- **Batch DOM manipulations** (#16): Added DocumentFragment batching for DOM operations to improve rendering performance by reducing reflows and repaints. Includes fallback for test environments that don't support createDocumentFragment. + +### Enhancement +- **Encapsulate render state** (#17): Created RenderState class to centralize all mutable render state (currentOptions, baseConfig, pathRegistry, runtimes, getArcColor, isRendering, pendingRender) for better lifecycle management and easier testing. + +### Testing +- **Add comprehensive test coverage** (#18): Created 25 new tests across 2 new test files. Added `colorAssignment.spec.ts` with 11 tests covering key-based, value-based, depth-based, and index-based assignment, custom palettes, and node color overrides. Added `geometry.spec.ts` with 14 tests covering arc path generation, polar to cartesian conversion, full circles, wedges, and edge cases. Increased total test coverage from 15 to 38 tests (153% increase). All tests pass successfully. + +## [0.3.1] - 2025-10-23 + +### Performance +- **Optimized color assignment from O(n²) to O(n)** (#12): Pre-compute min/max values once during color assigner creation instead of recalculating for every arc, significantly improving performance for large datasets. + +### Refactoring +- **Consolidated duplicate key resolution logic** (#13): Created shared `resolveKeyFromSource()` helper function in `keys.ts` and removed duplicate implementations from `highlight.ts` and `navigation.ts`, following DRY principles. + +### Enhancement +- **Migrated to AbortController for event listener cleanup** (#14): Replaced manual `removeEventListener` calls with modern AbortController pattern for more robust cleanup and automatic listener removal when elements are removed from DOM. + +### Documentation +- **Added explanatory comments to magic numbers** (#15): Documented geometry calculations (SVG arc flags), easing functions (cubic ease-in-out with reference link), tooltip positioning (cursor offset rationale), and all label/collapsed arc rendering thresholds throughout the codebase for better maintainability. + +### Fixed +- **Removed non-null assertion in highlight.ts** (#11): Replaced unsafe non-null assertion operator with proper null checking to prevent potential runtime errors. + +## [0.3.0] - 2025-10-20 + +### Added +- **Navigation/Drilldown System**: Interactive drill-down navigation with click-to-focus on any arc, smooth morphing transitions, breadcrumb trail support, and programmatic reset capability via `resetNavigation()`. +- **Color Theme System**: Comprehensive automatic coloring with 14 built-in palettes across three theme types: + - **Qualitative palettes** (6): `default`, `pastel`, `vibrant`, `earth`, `ocean`, `sunset` for categorical data + - **Sequential palettes** (4): `blues`, `greens`, `purples`, `oranges` for ordered data + - **Diverging palettes** (3): `redBlue`, `orangePurple`, `greenRed` for data with meaningful midpoints +- **Color Assignment Strategies**: Four ways to map colors to arcs - by key (consistent), depth (hierarchical), index (sequential), or value (magnitude-based). +- **New Exports**: `QUALITATIVE_PALETTES`, `SEQUENTIAL_PALETTES`, `DIVERGING_PALETTES` constants and `ColorThemeOptions`, `NavigationOptions`, `TransitionOptions` types. +- **Navigation Options**: Configure drilldown behavior with `layers` (which layers support navigation), `rootLabel` (breadcrumb root text), `onFocusChange` callback, and `focusTransition` (animation settings). +- **Breadcrumb Interactivity**: Optional `interactive` flag for breadcrumbs to enable navigation via trail clicks. + +### Changed +- Refactored color assignment logic to maintain consistent colors during navigation - each key now maps to the same color regardless of zoom level or drilldown state. +- Improved label positioning with increased safety margins (15%), better width estimation (0.7 factor), and enhanced padding (8px) to prevent text cutoff on partial arcs. +- Fixed text inversion boundaries so labels at vertical positions (90°/270°) render with correct orientation for readability. +- Normalized angle calculations for text rotation to handle full-circle and partial arc cases correctly. +- Extracted magic numbers into dedicated constant files (`tooltipConstants.ts`, `breadcrumbConstants.ts`) for easier customization. +- Consolidated duplicate utility functions (`clamp01`, `ZERO_TOLERANCE`) into shared modules for maintainability. +- Simplified demo to showcase color theme system with interactive controls for theme type, palette selection, and assignment strategy. + +### Fixed +- Fixed `labelPendingLogReason` self-assignment bug that prevented proper label state clearing during arc animations. +- Fixed collapsed children disappearing unexpectedly during navigation transitions. +- Fixed text labels appearing upside-down due to incorrect angle normalization. +- Fixed color assignments shifting during drilldown navigation by caching the color assigner based on full configuration. +- Fixed demo radial space allocation error by adjusting layer configuration to properly accommodate node hierarchy. + +## [0.2.3] - 2025-09-23 + +### Added +- Introduced `npm run dev`, a Rollup watch + local web server workflow that auto-rebuilds bundles and serves the demo for rapid iteration. + +### Changed +- Reworked the demo into the "Sand.js Studio" single-screen app with live JSON editing, collapsible branch toggles, and a leaner visual design. +- Simplified the demo styling to better showcase built-in arc metadata and reduce custom decoration. +- Updated the README CDN snippet to reference `@akitain/sandjs@0.2.3`. +- Skipped redundant asset copies during dev watch runs to quiet Rollup while keeping publish builds intact. + +## [0.2.2] - 2025-09-23 + +### Added +- Enriched default SVG nodes with `data-depth`/`data-collapsed` attributes and root/collapsed class tokens so integrators can style arcs without custom hooks. + +### Changed +- Normalized arc class merging to dedupe tokens when combining defaults with `classForArc` overrides. +- Corrected Changelog 0.2.1 release date (2025-09-21 when it was 2025-09-22) + +## [0.2.1] - 2025-09-22 + +### Changed +- Broke out render runtimes (tooltip, highlight, breadcrumbs) into reusable modules and persist them across updates to avoid re-instantiation costs. +- 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-22 + +### Added +- Highlight-by-key runtime with optional pinning, plus demo integration showing related arc highlighting. +- Collapsed node support in layout, including demo toggles to expand/collapse branches while preserving radial depth. +- Breadcrumb helpers and tooltip enhancements exposed via `formatArcBreadcrumb`. +- `renderSVG` update/destroy handle for redrawing in place without re-binding listeners. +- API docs generation workflow and GitHub Pages docs landing page. + +### Changed +- Demo data refreshed with Pulp Fiction network theme and interactive controls. +- README examples and configuration essentials updated to cover new interactions. +- Build tooling configured to generate API reference via API Extractor/Documenter. + +## [0.1.2] - 2025-09-16 + +### Added +- Added `.npmignore` to prepare npm publish + +### Changed +- Changed `publishConfig` into `package.json` in order to make the package public + +## [0.1.1] - 2025-09-16 + +### Changed +- Updated `README.md` and `package.json` because sandjs was already taken + +## [0.1.0] - 2025-09-16 + +### Added +- Core layout engine with `free` and `align` layers, offsets, and padding controls. +- SVG renderer with tooltips, hover/click callbacks, and full-circle arc support. +- TypeScript definitions and Rollup builds (ESM + minified IIFE) with demo data showcase. +- Node test suite covering layout behaviours and offset edge cases. +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### 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"` ### 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. diff --git a/src/render/runtime/highlight.ts b/src/render/runtime/highlight.ts index c33f881..a0ae6a8 100644 --- a/src/render/runtime/highlight.ts +++ b/src/render/runtime/highlight.ts @@ -7,7 +7,7 @@ export type HighlightRuntime = { pointerEnter: (arc: LayoutArc, path: SVGPathElement) => void; pointerMove: (arc: LayoutArc, path: SVGPathElement) => void; pointerLeave: (arc: LayoutArc, path: SVGPathElement) => void; - handleClick?: (arc: LayoutArc, path: SVGPathElement, event: MouseEvent) => void; + handleClick?: (arc: LayoutArc, path: SVGPathElement, event: MouseEvent | KeyboardEvent) => void; handlesClick: boolean; dispose: () => void; }; diff --git a/src/render/runtime/tooltip.ts b/src/render/runtime/tooltip.ts index 74ad2ec..5736218 100644 --- a/src/render/runtime/tooltip.ts +++ b/src/render/runtime/tooltip.ts @@ -19,6 +19,7 @@ const TOOLTIP_ATTRIBUTE = 'data-sandjs-tooltip'; export type TooltipRuntime = { show: (event: PointerEvent, arc: LayoutArc) => void; + showAt: (rect: DOMRect, arc: LayoutArc) => void; move: (event: PointerEvent) => void; hide: () => void; dispose: () => void; @@ -66,6 +67,13 @@ export function createTooltipRuntime( element.style.visibility = 'visible'; element.style.opacity = '1'; }, + showAt(rect, arc) { + element.innerHTML = formatter(arc); + element.style.left = `${rect.right + TOOLTIP_OFFSET_X}px`; + element.style.top = `${rect.top}px`; + element.style.visibility = 'visible'; + element.style.opacity = '1'; + }, move(event) { if (element.style.visibility !== 'visible') { return; diff --git a/src/render/svg/constants.ts b/src/render/svg/constants.ts index 129769e..4054588 100644 --- a/src/render/svg/constants.ts +++ b/src/render/svg/constants.ts @@ -24,3 +24,9 @@ export const COLLAPSED_ARC_SPAN_SHRINK_FACTOR = 0.1; // Shrink span to 10% of or export const COLLAPSED_ARC_MIN_SPAN = 0.01; // Minimum span in radians for collapsed arcs export const COLLAPSED_ARC_THICKNESS_SHRINK_FACTOR = 0.1; // Shrink thickness to 10% of original export const COLLAPSED_ARC_MIN_THICKNESS = 0.5; // Minimum thickness in pixels for collapsed arcs + +/** + * Focus ring styling + */ +export const FOCUS_RING_COLOR = '#005fcc'; +export const FOCUS_RING_WIDTH = 3; diff --git a/src/render/svg/orchestration.ts b/src/render/svg/orchestration.ts index e11c350..326f60b 100644 --- a/src/render/svg/orchestration.ts +++ b/src/render/svg/orchestration.ts @@ -208,6 +208,10 @@ function applySvgDimensions(host: SVGElement, radius: number): void { host.setAttribute('viewBox', `0 0 ${diameter} ${diameter}`); host.setAttribute('width', `${diameter}`); host.setAttribute('height', `${diameter}`); + host.setAttribute('role', 'graphics-document'); + if (!host.getAttribute('aria-label')) { + host.setAttribute('aria-label', 'Interactive sunburst chart'); + } } function processArcs(params: { diff --git a/src/render/svg/path-management.ts b/src/render/svg/path-management.ts index b86d5db..9e0cd55 100644 --- a/src/render/svg/path-management.ts +++ b/src/render/svg/path-management.ts @@ -1,6 +1,6 @@ import { describeArcPath } from '../geometry.js'; import { interpolateArc } from '../transition.js'; -import { SVG_NS, XLINK_NS } from './constants.js'; +import { SVG_NS, XLINK_NS, FOCUS_RING_COLOR, FOCUS_RING_WIDTH } from './constants.js'; import type { RuntimeSet, ManagedPath, AnimationDrivers } from './types.js'; import type { LayoutArc } from '../../types/index.js'; import type { ResolvedRenderOptions } from '../types.js'; @@ -31,6 +31,7 @@ export function createManagedPath(params: { }): ManagedPath { const { key, arc, options, runtime, doc, labelDefs } = params; const element = doc.createElementNS(SVG_NS, 'path'); + element.style.outline = 'none'; const labelPathElement = doc.createElementNS(SVG_NS, 'path'); const labelElement = doc.createElementNS(SVG_NS, 'text'); @@ -138,11 +139,46 @@ function attachEventHandlers(managed: ManagedPath, signal: AbortSignal): void { options.onArcClick?.({ arc, path: element, event }); }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + const { arc, runtime, options } = managed; + runtime.highlight?.handleClick?.(arc, element, event); + runtime.navigation?.handleArcClick(arc); + options.onArcClick?.({ arc, path: element, event }); + }; + + let focusRing: SVGPathElement | null = null; + + const handleFocus = () => { + const { arc, runtime } = managed; + element.classList.add('is-focused'); + focusRing = createFocusRing(element); + runtime.tooltip?.showAt(element.getBoundingClientRect(), arc); + if (!runtime.navigation?.handlesBreadcrumbs()) { + runtime.breadcrumbs?.show(arc); + } + }; + + const handleBlur = () => { + const { runtime } = managed; + element.classList.remove('is-focused'); + focusRing?.remove(); + focusRing = null; + runtime.tooltip?.hide(); + if (!runtime.navigation?.handlesBreadcrumbs()) { + runtime.breadcrumbs?.clear(); + } + }; + element.addEventListener('pointerenter', handleEnter, { signal }); element.addEventListener('pointermove', handleMove, { signal }); element.addEventListener('pointerleave', handleLeave, { signal }); element.addEventListener('pointercancel', handleLeave, { signal }); element.addEventListener('click', handleClick, { signal }); + element.addEventListener('keydown', handleKeyDown, { signal }); + element.addEventListener('focus', handleFocus, { signal }); + element.addEventListener('blur', handleBlur, { signal }); } function applyBorderStyles(element: SVGPathElement, arc: LayoutArc, options: ResolvedRenderOptions): void { @@ -162,6 +198,11 @@ function applyDataAttributes(element: SVGPathElement, arc: LayoutArc): void { setOrRemoveAttribute(element, 'data-collapsed', arc.data.collapsed ? 'true' : null); setOrRemoveAttribute(element, 'data-key', arc.key ?? null); setOrRemoveAttribute(element, 'data-tooltip', typeof arc.data.tooltip === 'string' ? arc.data.tooltip : null); + + element.setAttribute('tabindex', '0'); + element.setAttribute('role', 'button'); + const percentage = arc.percentage > 0 ? ` (${(arc.percentage * 100).toFixed(1)}%)` : ''; + element.setAttribute('aria-label', `${arc.data.name}${percentage}`); } function setOrRemoveAttribute(element: Element, name: string, value: string | null): void { @@ -172,6 +213,21 @@ function setOrRemoveAttribute(element: Element, name: string, value: string | nu } } +function createFocusRing(element: SVGPathElement): SVGPathElement | null { + const pathData = element.getAttribute('d'); + if (!pathData || !element.parentNode || !element.ownerDocument) return null; + + const ring = element.ownerDocument.createElementNS(SVG_NS, 'path'); + ring.setAttribute('d', pathData); + ring.setAttribute('fill', 'none'); + ring.setAttribute('stroke', FOCUS_RING_COLOR); + ring.setAttribute('stroke-width', String(FOCUS_RING_WIDTH)); + ring.setAttribute('pointer-events', 'none'); + ring.setAttribute('class', 'sand-focus-ring'); + element.parentNode.appendChild(ring); + return ring; +} + function buildClassList(arc: LayoutArc, options: ResolvedRenderOptions): string { const tokens: string[] = ['sand-arc']; const seen = new Set(tokens); diff --git a/src/render/types.ts b/src/render/types.ts index 8a97c22..22125f1 100644 --- a/src/render/types.ts +++ b/src/render/types.ts @@ -127,7 +127,7 @@ export interface HighlightByKeyOptions { deriveKey?: (arc: LayoutArc) => string | null; pinOnClick?: boolean; pinClassName?: string; - onPinChange?: (payload: { arc: LayoutArc; path: SVGPathElement; pinned: boolean; event: MouseEvent }) => void; + onPinChange?: (payload: { arc: LayoutArc; path: SVGPathElement; pinned: boolean; event: MouseEvent | KeyboardEvent }) => void; } /** @@ -149,7 +149,7 @@ export interface ArcPointerEventPayload { export interface ArcClickEventPayload { arc: LayoutArc; path: SVGPathElement; - event: MouseEvent; + event: MouseEvent | KeyboardEvent; } /** From b28bcc06bff868d7f664f4d7cf40f3a1019cee3a Mon Sep 17 00:00:00 2001 From: Corentin R Date: Wed, 21 Jan 2026 14:26:07 +0100 Subject: [PATCH 06/13] feat: add performance benchmarks and documentation (#57) * feat: add performance benchmarks and documentation (#49) - Add benchmark suite for layout, render, and navigation performance - Create tree generators for various dataset shapes and sizes - Add npm scripts: bench, bench:layout, bench:render, bench:navigation - Document performance recommendations in docs/guides/performance.md * docs: add performance benchmarks entry to CHANGELOG --- .gitignore | 1 + CHANGELOG.md | 218 +--------------------- benchmarks/generators.ts | 163 ++++++++++++++++ benchmarks/index.ts | 39 ++++ benchmarks/layout.bench.ts | 103 ++++++++++ benchmarks/navigation.bench.ts | 331 +++++++++++++++++++++++++++++++++ benchmarks/render.bench.ts | 312 +++++++++++++++++++++++++++++++ benchmarks/runner.ts | 108 +++++++++++ docs/guides/performance.md | 165 ++++++++++++++++ package.json | 8 +- tsconfig.bench.json | 13 ++ 11 files changed, 1245 insertions(+), 216 deletions(-) create mode 100644 benchmarks/generators.ts create mode 100644 benchmarks/index.ts create mode 100644 benchmarks/layout.bench.ts create mode 100644 benchmarks/navigation.bench.ts create mode 100644 benchmarks/render.bench.ts create mode 100644 benchmarks/runner.ts create mode 100644 docs/guides/performance.md create mode 100644 tsconfig.bench.json diff --git a/.gitignore b/.gitignore index ea2d3ea..84ee710 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,7 @@ out .nuxt dist dist-tests +dist-benchmarks temp # Gatsby files diff --git a/CHANGELOG.md b/CHANGELOG.md index f434837..8175bb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,221 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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"` - -### 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 -- **Simple API**: New simplified way to create sunbursts without layer configuration. Use `data` and `radius` props directly instead of wrapping everything in `config.layers`. The library auto-computes layer depth from tree structure. Example: `renderSVG({ el: '#chart', radius: 400, data: [{ name: 'A', children: [...] }] })`. Full `config` API remains available for advanced use cases (multi-layer, aligned layers, etc.). -- **Label font size control** (#40): Added `fontSize` option to `LabelOptions` - accepts a number for fixed size or `{ min, max }` object for range. Added `minRadialThickness` option to control visibility threshold. -- **Root label style** (#26): Added `rootLabelStyle` option to `LabelOptions` with values `'curved'` (default) or `'straight'` to display root node labels as centered text instead of following the arc path. -- **CI workflow**: Added GitHub Actions workflow for automated testing on PRs to main/dev branches. - -### Changed -- **Instant node removal** (#39): Disappearing nodes during navigation now remove instantly without fade/collapse animation. Only nodes that stay and expand are animated. - -### Refactoring -- **path-management.ts**: Extracted `attachEventHandlers`, `applyBorderStyles`, `applyDataAttributes`, `buildClassList` helpers. Simplified opacity conditionals with single guard clause. -- **orchestration.ts**: Split `renderSVG` (222→59 lines) into `createRenderLoop`, `executeRender`, `processArcs`, `applySvgDimensions`, `appendNewElement`, `scheduleRemovals`. Replaced `Object.defineProperties` with direct assignment. -- **label-system.ts**: Extracted `resolveLabelColor` helper. Replaced if-else chain in `logHiddenLabel` with `LABEL_HIDDEN_REASONS` map. Added `LABEL_TANGENT_*` constants for magic numbers. -- **navigation.ts**: Consolidated 8 mutable variables into `NavigationState` type. Renamed WeakMaps to clearer names (`nodeToBase`, `baseToPath`, `derivedToPath`). Extracted `registerSingleArc` and `setFocus` helpers. Flattened nested conditionals with guard clauses. -- **aligned.ts**: Reduced `layoutAlignedLayer` cognitive complexity from 27 to ~5 by extracting `getSourceLayer`, `buildRootSourceMap`, `getAlignedSlot`, `computeTrimmedBounds`, `layoutAlignedNode`, `fallbackToFreeLayout`. -- **breadcrumbs.ts**: Use `.dataset` instead of `setAttribute` for data attributes. -- **orchestration.ts**: Refactor `scheduleRemovals` to use options object (8→1 parameters). -- **normalization.ts**: Reduced `normalizeTree` cognitive complexity from 17 to ~6 by extracting `isMultiParentNode`, `warnMultiParentFeature`, `addToMultiParentGroup`, `normalizeValue`, `normalizeNode`. -- **orchestration.ts**: Replace boolean `supportsFragment` parameter with `BatchTargets` strategy pattern. -- **removal.ts, path-management.ts**: Use `element.remove()` instead of `parentNode.removeChild(element)`. -- **normalization.ts**: Avoid object literal as default parameter for `warnOnce`. -- **navigation.ts**: Extract nested ternary into `resolveNavigationOptions` helper. -- **index.ts**: Use `Set` with `has()` instead of array with `includes()` for `foundKeys`. -- **document.ts, animation.ts**: Compare with `undefined` directly instead of using `typeof`. -- **colorAssignment.ts**: Use `codePointAt()` instead of `charCodeAt()`. -- **multi-parent-test.html**: Improve text contrast ratio. - -## [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. - -### Added -- **Multi-parent validation**: Added validation to prevent parent nodes referenced in `parents` arrays from having their own children. When a parent node has children, the multi-parent group is skipped with a clear error message explaining the constraint violation. - -### 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-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-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`. - -- **Label visibility and color control**: Added comprehensive label customization with three levels of control: - - **Global control** via `labels` option: `true`/`false` for simple enable/disable, or `LabelOptions` object with `showLabels`, `labelColor`, and `autoLabelColor` properties - - **Layer-level control** via `LayerConfig.showLabels` and `LayerConfig.labelColor` to override global settings per layer - - **Node-level control** via `TreeNodeInput.labelColor` for individual arc label colors (highest priority) - - **Auto-contrast mode**: When `autoLabelColor: true`, automatically chooses black or white label color based on arc background using WCAG relative luminance calculation for optimal readability - - Priority cascade: Node labelColor → Layer labelColor → Global labelColor → Auto-contrast → Default (#000000) - -## [0.3.3] - 2025-11-21 - -### Fixed -- **Label positioning and orientation**: Fixed two critical label rendering bugs: - - Labels are now properly centered on arcs instead of appearing offset to the bottom-left. Added `text-anchor: middle` attribute to `` elements for correct SVG text alignment. - - Label inversion now uses tangent direction instead of midpoint angle, ensuring labels are always readable regardless of arc configuration. Labels at 0° and 180° now have correct orientation in all cases. - -### Changed -- **Console logging is now opt-in via `debug` option**: Label visibility warnings (e.g., "Hiding label because arc span is too narrow") no longer appear by default. Pass `debug: true` to `renderSVG()` to enable diagnostic logging for debugging layout issues. - -## [0.3.2] - 2025-10-23 - -### Refactoring -- **Split layout/index.ts into modular files** (#10): Extracted layout logic into separate, focused modules for improved maintainability and code organization. Created `normalization.ts` for tree normalization and utility functions, `shared.ts` for common types and arc creation, `free.ts` for free layout mode (value-based angular distribution), and `aligned.ts` for aligned layout mode (key-based alignment). Reduced index.ts from 467 lines to 98 lines. - -- **Split navigation.ts into modular files** (#9): Extracted navigation logic into separate modules for better organization. Created `navigation/types.ts` for core types (FocusTarget, NavigationTransitionContext), `navigation/tree-utils.ts` for tree traversal utilities (findNodeByKey, getNodeAtPath, collectNodesAlongPath, indexBaseConfig), `navigation/config-derivation.ts` for config derivation logic, and `navigation/focus-helpers.ts` for focus management helpers. Reduced navigation.ts from 534 lines to 282 lines. - -- **Extract constants and utilities from svg.ts** (#8): Extracted commonly used constants, types, and utility functions from svg.ts into separate modules. Created `svg/constants.ts` for SVG namespaces, label thresholds, and collapsed arc constants, `svg/types.ts` for RuntimeSet, AnimationHandle, AnimationDrivers, and ManagedPath types, `svg/runtime-creation.ts` for runtime set creation and disposal, and `svg/utils.ts` for utility functions (isSunburstConfig, ensureLabelDefs, extractConfigFromUpdate). Reduced svg.ts from 1,298 lines to 1,186 lines (112 lines saved). - -### Performance -- **Batch DOM manipulations** (#16): Added DocumentFragment batching for DOM operations to improve rendering performance by reducing reflows and repaints. Includes fallback for test environments that don't support createDocumentFragment. - -### Enhancement -- **Encapsulate render state** (#17): Created RenderState class to centralize all mutable render state (currentOptions, baseConfig, pathRegistry, runtimes, getArcColor, isRendering, pendingRender) for better lifecycle management and easier testing. - -### Testing -- **Add comprehensive test coverage** (#18): Created 25 new tests across 2 new test files. Added `colorAssignment.spec.ts` with 11 tests covering key-based, value-based, depth-based, and index-based assignment, custom palettes, and node color overrides. Added `geometry.spec.ts` with 14 tests covering arc path generation, polar to cartesian conversion, full circles, wedges, and edge cases. Increased total test coverage from 15 to 38 tests (153% increase). All tests pass successfully. - -## [0.3.1] - 2025-10-23 - -### Performance -- **Optimized color assignment from O(n²) to O(n)** (#12): Pre-compute min/max values once during color assigner creation instead of recalculating for every arc, significantly improving performance for large datasets. - -### Refactoring -- **Consolidated duplicate key resolution logic** (#13): Created shared `resolveKeyFromSource()` helper function in `keys.ts` and removed duplicate implementations from `highlight.ts` and `navigation.ts`, following DRY principles. - -### Enhancement -- **Migrated to AbortController for event listener cleanup** (#14): Replaced manual `removeEventListener` calls with modern AbortController pattern for more robust cleanup and automatic listener removal when elements are removed from DOM. - -### Documentation -- **Added explanatory comments to magic numbers** (#15): Documented geometry calculations (SVG arc flags), easing functions (cubic ease-in-out with reference link), tooltip positioning (cursor offset rationale), and all label/collapsed arc rendering thresholds throughout the codebase for better maintainability. - -### Fixed -- **Removed non-null assertion in highlight.ts** (#11): Replaced unsafe non-null assertion operator with proper null checking to prevent potential runtime errors. - -## [0.3.0] - 2025-10-20 - -### Added -- **Navigation/Drilldown System**: Interactive drill-down navigation with click-to-focus on any arc, smooth morphing transitions, breadcrumb trail support, and programmatic reset capability via `resetNavigation()`. -- **Color Theme System**: Comprehensive automatic coloring with 14 built-in palettes across three theme types: - - **Qualitative palettes** (6): `default`, `pastel`, `vibrant`, `earth`, `ocean`, `sunset` for categorical data - - **Sequential palettes** (4): `blues`, `greens`, `purples`, `oranges` for ordered data - - **Diverging palettes** (3): `redBlue`, `orangePurple`, `greenRed` for data with meaningful midpoints -- **Color Assignment Strategies**: Four ways to map colors to arcs - by key (consistent), depth (hierarchical), index (sequential), or value (magnitude-based). -- **New Exports**: `QUALITATIVE_PALETTES`, `SEQUENTIAL_PALETTES`, `DIVERGING_PALETTES` constants and `ColorThemeOptions`, `NavigationOptions`, `TransitionOptions` types. -- **Navigation Options**: Configure drilldown behavior with `layers` (which layers support navigation), `rootLabel` (breadcrumb root text), `onFocusChange` callback, and `focusTransition` (animation settings). -- **Breadcrumb Interactivity**: Optional `interactive` flag for breadcrumbs to enable navigation via trail clicks. - -### Changed -- Refactored color assignment logic to maintain consistent colors during navigation - each key now maps to the same color regardless of zoom level or drilldown state. -- Improved label positioning with increased safety margins (15%), better width estimation (0.7 factor), and enhanced padding (8px) to prevent text cutoff on partial arcs. -- Fixed text inversion boundaries so labels at vertical positions (90°/270°) render with correct orientation for readability. -- Normalized angle calculations for text rotation to handle full-circle and partial arc cases correctly. -- Extracted magic numbers into dedicated constant files (`tooltipConstants.ts`, `breadcrumbConstants.ts`) for easier customization. -- Consolidated duplicate utility functions (`clamp01`, `ZERO_TOLERANCE`) into shared modules for maintainability. -- Simplified demo to showcase color theme system with interactive controls for theme type, palette selection, and assignment strategy. - -### Fixed -- Fixed `labelPendingLogReason` self-assignment bug that prevented proper label state clearing during arc animations. -- Fixed collapsed children disappearing unexpectedly during navigation transitions. -- Fixed text labels appearing upside-down due to incorrect angle normalization. -- Fixed color assignments shifting during drilldown navigation by caching the color assigner based on full configuration. -- Fixed demo radial space allocation error by adjusting layer configuration to properly accommodate node hierarchy. - -## [0.2.3] - 2025-09-23 - -### Added -- Introduced `npm run dev`, a Rollup watch + local web server workflow that auto-rebuilds bundles and serves the demo for rapid iteration. - -### Changed -- Reworked the demo into the "Sand.js Studio" single-screen app with live JSON editing, collapsible branch toggles, and a leaner visual design. -- Simplified the demo styling to better showcase built-in arc metadata and reduce custom decoration. -- Updated the README CDN snippet to reference `@akitain/sandjs@0.2.3`. -- Skipped redundant asset copies during dev watch runs to quiet Rollup while keeping publish builds intact. - -## [0.2.2] - 2025-09-23 - -### Added -- Enriched default SVG nodes with `data-depth`/`data-collapsed` attributes and root/collapsed class tokens so integrators can style arcs without custom hooks. - -### Changed -- Normalized arc class merging to dedupe tokens when combining defaults with `classForArc` overrides. -- Corrected Changelog 0.2.1 release date (2025-09-21 when it was 2025-09-22) - -## [0.2.1] - 2025-09-22 - -### Changed -- Broke out render runtimes (tooltip, highlight, breadcrumbs) into reusable modules and persist them across updates to avoid re-instantiation costs. -- 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-22 - -### Added -- Highlight-by-key runtime with optional pinning, plus demo integration showing related arc highlighting. -- Collapsed node support in layout, including demo toggles to expand/collapse branches while preserving radial depth. -- Breadcrumb helpers and tooltip enhancements exposed via `formatArcBreadcrumb`. -- `renderSVG` update/destroy handle for redrawing in place without re-binding listeners. -- API docs generation workflow and GitHub Pages docs landing page. - -### Changed -- Demo data refreshed with Pulp Fiction network theme and interactive controls. -- README examples and configuration essentials updated to cover new interactions. -- Build tooling configured to generate API reference via API Extractor/Documenter. - -## [0.1.2] - 2025-09-16 - -### Added -- Added `.npmignore` to prepare npm publish - -### Changed -- Changed `publishConfig` into `package.json` in order to make the package public - -## [0.1.1] - 2025-09-16 - -### Changed -- Updated `README.md` and `package.json` because sandjs was already taken - -## [0.1.0] - 2025-09-16 - -### Added -- Core layout engine with `free` and `align` layers, offsets, and padding controls. -- SVG renderer with tooltips, hover/click callbacks, and full-circle arc support. -- TypeScript definitions and Rollup builds (ESM + minified IIFE) with demo data showcase. -- Node test suite covering layout behaviours and offset edge cases. -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### 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`) ### 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. diff --git a/benchmarks/generators.ts b/benchmarks/generators.ts new file mode 100644 index 0000000..b9b6340 --- /dev/null +++ b/benchmarks/generators.ts @@ -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; +} diff --git a/benchmarks/index.ts b/benchmarks/index.ts new file mode 100644 index 0000000..2913898 --- /dev/null +++ b/benchmarks/index.ts @@ -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 { + 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); +}); diff --git a/benchmarks/layout.bench.ts b/benchmarks/layout.bench.ts new file mode 100644 index 0000000..153839a --- /dev/null +++ b/benchmarks/layout.bench.ts @@ -0,0 +1,103 @@ +import { layout } from '../src/index.js'; +import { generateTree, createConfig, countNodes, type TreeShape } from './generators.js'; +import { benchmark, formatResult, formatTable, type BenchmarkResult } from './runner.js'; + +const NODE_COUNTS = [100, 500, 1000, 5000, 10000]; +const SHAPES: TreeShape[] = ['balanced', 'deep', 'wide', 'realistic']; + +function runLayoutBenchmarks(): BenchmarkResult[] { + const results: BenchmarkResult[] = []; + + console.log('Running layout benchmarks...\n'); + + for (const targetCount of NODE_COUNTS) { + for (const shape of SHAPES) { + const tree = generateTree({ nodeCount: targetCount, shape }); + const actualCount = countNodes(tree); + const config = createConfig(tree); + + const result = benchmark( + `Layout ${shape}`, + actualCount, + () => layout(config), + { warmupRuns: 3, measureRuns: 10 } + ); + + results.push(result); + console.log(formatResult(result)); + console.log(''); + } + } + + return results; +} + +function runScalingTest(): BenchmarkResult[] { + const results: BenchmarkResult[] = []; + const counts = [100, 250, 500, 1000, 2500, 5000, 7500, 10000]; + + console.log('\nRunning scaling test...\n'); + + for (const targetCount of counts) { + const tree = generateTree({ nodeCount: targetCount, shape: 'wide' }); + const actualCount = countNodes(tree); + const config = createConfig(tree); + + const result = benchmark( + 'Layout scaling', + actualCount, + () => layout(config), + { warmupRuns: 2, measureRuns: 5 } + ); + + results.push(result); + const nodesPerMs = actualCount / result.mean; + console.log(`${actualCount.toLocaleString().padStart(6)} nodes: ${result.mean.toFixed(2).padStart(8)}ms (${nodesPerMs.toFixed(0)} nodes/ms)`); + } + + return results; +} + +function main() { + console.log('='.repeat(60)); + console.log('Sand.js Layout Performance Benchmarks'); + console.log('='.repeat(60)); + console.log(''); + + const shapeResults = runLayoutBenchmarks(); + + console.log('\n' + '='.repeat(60)); + console.log('Shape Comparison Table'); + console.log('='.repeat(60)); + console.log(''); + console.log(formatTable(shapeResults)); + + const scalingResults = runScalingTest(); + + console.log('\n' + '='.repeat(60)); + console.log('Scaling Analysis'); + console.log('='.repeat(60)); + console.log(''); + + const first = scalingResults[0]; + const last = scalingResults[scalingResults.length - 1]; + const scaleRatio = last.nodeCount / first.nodeCount; + const timeRatio = last.mean / first.mean; + + console.log(`Node increase: ${scaleRatio.toFixed(0)}x`); + console.log(`Time increase: ${timeRatio.toFixed(1)}x`); + console.log(`Complexity: ~O(n^${(Math.log(timeRatio) / Math.log(scaleRatio)).toFixed(2)})`); + + console.log('\n' + '='.repeat(60)); + console.log('Recommendations'); + console.log('='.repeat(60)); + + const targetFps60 = 16.67; + const safeCount = scalingResults.find(r => r.mean > targetFps60)?.nodeCount ?? 20000; + + console.log(''); + console.log(`For 60fps interactions: <${safeCount.toLocaleString()} nodes recommended`); + console.log(`Layout is efficient for typical use cases (100-5000 nodes)`); +} + +main(); diff --git a/benchmarks/navigation.bench.ts b/benchmarks/navigation.bench.ts new file mode 100644 index 0000000..d3bcad5 --- /dev/null +++ b/benchmarks/navigation.bench.ts @@ -0,0 +1,331 @@ +import { renderSVG } from '../src/index.js'; +import { generateTree, createConfig, countNodes } from './generators.js'; +import { benchmark, formatResult, formatTable, type BenchmarkResult } from './runner.js'; + +class StubElement { + public attributes = new Map(); + public children: StubElement[] = []; + public parentNode: StubElement | null = null; + public textContent = ''; + public firstChild: StubElement | null = null; + public style: Record = {}; + public classList = { add: () => {}, remove: () => {}, toggle: () => {} }; + public dataset: Record; + public listeners: Record void>> = {}; + private _innerHTML = ''; + + constructor(public tagName: string) { + const self = this; + this.dataset = new Proxy({} as Record, { + set(target, prop: string, value: string) { + target[prop] = value; + self.attributes.set(`data-${prop}`, value); + return true; + }, + deleteProperty(target, prop: string) { + delete target[prop]; + self.attributes.delete(`data-${prop}`); + return true; + }, + }); + } + + setAttribute(name: string, value: string) { + this.attributes.set(name, value); + if (name.startsWith('data-')) this.dataset[name.slice(5)] = value; + } + + setAttributeNS(_ns: string | null, name: string, value: string) { + this.setAttribute(name, value); + } + + set innerHTML(value: string) { + this._innerHTML = value; + this.children = []; + this.firstChild = null; + this.textContent = value; + } + + get innerHTML(): string { + return this._innerHTML; + } + + removeAttribute(name: string) { + this.attributes.delete(name); + if (name.startsWith('data-')) delete this.dataset[name.slice(5)]; + } + + appendChild(child: T): T { + child.parentNode = this; + this.children.push(child); + this.firstChild = this.children[0] ?? null; + return child; + } + + removeChild(child: StubElement): StubElement { + const index = this.children.indexOf(child); + if (index !== -1) { + this.children.splice(index, 1); + child.parentNode = null; + } + this.firstChild = this.children[0] ?? null; + return child; + } + + remove(): void { + if (this.parentNode) this.parentNode.removeChild(this); + } + + addEventListener(type: string, handler: (event: unknown) => void): void { + if (!this.listeners[type]) this.listeners[type] = []; + this.listeners[type].push(handler); + } + + removeEventListener(type: string, handler: (event: unknown) => void): void { + const list = this.listeners[type]; + if (!list) return; + const index = list.indexOf(handler); + if (index !== -1) list.splice(index, 1); + } + + querySelector(selector: string): StubElement | null { + const matchAttr = selector.startsWith('[') && selector.endsWith(']') ? selector.slice(1, -1) : null; + if (!matchAttr) return null; + const [attr] = matchAttr.split('='); + return this.children.find((c) => c.attributes.has(attr)) ?? null; + } + + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + getBoundingClientRect() { + return { top: 0, left: 0, right: 100, bottom: 100, width: 100, height: 100, x: 0, y: 0 }; + } + + get childNodes(): StubElement[] { + return this.children; + } + + insertBefore(newChild: T, refChild: StubElement | null): T { + if (!refChild || !this.children.includes(refChild)) return this.appendChild(newChild); + const index = this.children.indexOf(refChild); + this.children.splice(index, 0, newChild); + newChild.parentNode = this; + this.firstChild = this.children[0] ?? null; + return newChild; + } +} + +class StubSVGElement extends StubElement {} +class StubSVGDefsElement extends StubSVGElement { + constructor() { + super('defs'); + } +} + +class StubDocument { + public body = new StubElement('body'); + + createElementNS(_ns: string, tag: string) { + if (tag === 'defs') return new StubSVGDefsElement(); + return new StubSVGElement(tag); + } + + createElement(tag: string) { + return new StubElement(tag); + } + + querySelector(): null { + return null; + } +} + +(globalThis as unknown as Record).SVGElement = StubSVGElement; +(globalThis as unknown as Record).SVGDefsElement = StubSVGDefsElement; + +const NODE_COUNTS = [100, 500, 1000, 5000]; + +function runDrillDownBenchmarks(): BenchmarkResult[] { + const results: BenchmarkResult[] = []; + + console.log('Running drill-down benchmarks...\n'); + + for (const targetCount of NODE_COUNTS) { + const tree = generateTree({ nodeCount: targetCount, shape: 'realistic' }); + const actualCount = countNodes(tree); + const config = createConfig(tree); + + const doc = new StubDocument(); + const host = new StubSVGElement('svg'); + const chart = renderSVG({ + el: host as unknown as SVGElement, + config, + document: doc as unknown as Document, + navigation: true, + transition: false, + }); + + const paths = host.children.filter(c => c.tagName === 'path'); + const firstPath = paths[0]; + + if (!firstPath || !firstPath.listeners.click?.[0]) { + console.log(`Skipping ${actualCount} nodes - no clickable path`); + continue; + } + + const mockEvent = { preventDefault: () => {} }; + + const result = benchmark( + 'Drill-down', + actualCount, + () => { + firstPath.listeners.click![0](mockEvent); + chart.resetNavigation?.(); + }, + { warmupRuns: 2, measureRuns: 5 } + ); + + chart.destroy(); + results.push(result); + console.log(formatResult(result)); + console.log(''); + } + + return results; +} + +function runResetBenchmarks(): BenchmarkResult[] { + const results: BenchmarkResult[] = []; + + console.log('\nRunning reset navigation benchmarks...\n'); + + for (const targetCount of NODE_COUNTS) { + const tree = generateTree({ nodeCount: targetCount, shape: 'realistic' }); + const actualCount = countNodes(tree); + const config = createConfig(tree); + + const doc = new StubDocument(); + const host = new StubSVGElement('svg'); + const chart = renderSVG({ + el: host as unknown as SVGElement, + config, + document: doc as unknown as Document, + navigation: true, + transition: false, + }); + + const paths = host.children.filter(c => c.tagName === 'path'); + const firstPath = paths[0]; + const mockEvent = { preventDefault: () => {} }; + + if (firstPath?.listeners.click?.[0]) { + firstPath.listeners.click[0](mockEvent); + } + + const result = benchmark( + 'Reset navigation', + actualCount, + () => { + chart.resetNavigation?.(); + if (firstPath?.listeners.click?.[0]) { + firstPath.listeners.click[0](mockEvent); + } + }, + { warmupRuns: 2, measureRuns: 5 } + ); + + chart.destroy(); + results.push(result); + console.log(formatResult(result)); + console.log(''); + } + + return results; +} + +function runDeepNavigationBenchmarks(): BenchmarkResult[] { + const results: BenchmarkResult[] = []; + + console.log('\nRunning deep navigation benchmarks...\n'); + + const targetCount = 1000; + const tree = generateTree({ nodeCount: targetCount, shape: 'deep', maxDepth: 8 }); + const actualCount = countNodes(tree); + const config = createConfig(tree); + + const doc = new StubDocument(); + const host = new StubSVGElement('svg'); + const chart = renderSVG({ + el: host as unknown as SVGElement, + config, + document: doc as unknown as Document, + navigation: true, + transition: false, + }); + + const mockEvent = { preventDefault: () => {} }; + + const result = benchmark( + 'Deep navigation (8 levels)', + actualCount, + () => { + for (let i = 0; i < 5; i++) { + const paths = host.children.filter(c => c.tagName === 'path'); + if (paths[0]?.listeners.click?.[0]) { + paths[0].listeners.click[0](mockEvent); + } + } + chart.resetNavigation?.(); + }, + { warmupRuns: 2, measureRuns: 5 } + ); + + chart.destroy(); + results.push(result); + console.log(formatResult(result)); + + return results; +} + +function main() { + console.log('='.repeat(60)); + console.log('Sand.js Navigation Performance Benchmarks'); + console.log('='.repeat(60)); + console.log(''); + + const drillDownResults = runDrillDownBenchmarks(); + const resetResults = runResetBenchmarks(); + const deepResults = runDeepNavigationBenchmarks(); + + console.log('\n' + '='.repeat(60)); + console.log('Drill-Down Results'); + console.log('='.repeat(60)); + console.log(''); + console.log(formatTable(drillDownResults)); + + console.log('\n' + '='.repeat(60)); + console.log('Reset Navigation Results'); + console.log('='.repeat(60)); + console.log(''); + console.log(formatTable(resetResults)); + + console.log('\n' + '='.repeat(60)); + console.log('Deep Navigation Results'); + console.log('='.repeat(60)); + console.log(''); + console.log(formatTable(deepResults)); + + console.log('\n' + '='.repeat(60)); + console.log('Recommendations'); + console.log('='.repeat(60)); + console.log(''); + + const targetFps60 = 16.67; + const safeCount = drillDownResults.find(r => r.mean > targetFps60)?.nodeCount ?? 5000; + + console.log(`For 60fps navigation: <${safeCount.toLocaleString()} nodes`); + console.log('Deep hierarchies add minimal overhead to navigation'); +} + +main(); diff --git a/benchmarks/render.bench.ts b/benchmarks/render.bench.ts new file mode 100644 index 0000000..ee458f8 --- /dev/null +++ b/benchmarks/render.bench.ts @@ -0,0 +1,312 @@ +import { renderSVG } from '../src/index.js'; +import { generateTree, createConfig, countNodes, type TreeShape } from './generators.js'; +import { benchmark, formatResult, formatTable, type BenchmarkResult } from './runner.js'; + +class StubElement { + public attributes = new Map(); + public children: StubElement[] = []; + public parentNode: StubElement | null = null; + public textContent = ''; + public firstChild: StubElement | null = null; + public style: Record = {}; + public classList = { add: () => {}, remove: () => {}, toggle: () => {} }; + public dataset: Record; + public listeners: Record void>> = {}; + private _innerHTML = ''; + + constructor(public tagName: string) { + const self = this; + this.dataset = new Proxy({} as Record, { + set(target, prop: string, value: string) { + target[prop] = value; + self.attributes.set(`data-${prop}`, value); + return true; + }, + deleteProperty(target, prop: string) { + delete target[prop]; + self.attributes.delete(`data-${prop}`); + return true; + }, + }); + } + + setAttribute(name: string, value: string) { + this.attributes.set(name, value); + if (name.startsWith('data-')) this.dataset[name.slice(5)] = value; + } + + setAttributeNS(_ns: string | null, name: string, value: string) { + this.setAttribute(name, value); + } + + set innerHTML(value: string) { + this._innerHTML = value; + this.children = []; + this.firstChild = null; + this.textContent = value; + } + + get innerHTML(): string { + return this._innerHTML; + } + + removeAttribute(name: string) { + this.attributes.delete(name); + if (name.startsWith('data-')) delete this.dataset[name.slice(5)]; + } + + appendChild(child: T): T { + child.parentNode = this; + this.children.push(child); + this.firstChild = this.children[0] ?? null; + return child; + } + + removeChild(child: StubElement): StubElement { + const index = this.children.indexOf(child); + if (index !== -1) { + this.children.splice(index, 1); + child.parentNode = null; + } + this.firstChild = this.children[0] ?? null; + return child; + } + + remove(): void { + if (this.parentNode) this.parentNode.removeChild(this); + } + + addEventListener(type: string, handler: (event: unknown) => void): void { + if (!this.listeners[type]) this.listeners[type] = []; + this.listeners[type].push(handler); + } + + removeEventListener(type: string, handler: (event: unknown) => void): void { + const list = this.listeners[type]; + if (!list) return; + const index = list.indexOf(handler); + if (index !== -1) list.splice(index, 1); + } + + querySelector(selector: string): StubElement | null { + const matchAttr = selector.startsWith('[') && selector.endsWith(']') ? selector.slice(1, -1) : null; + if (!matchAttr) return null; + const [attr] = matchAttr.split('='); + return this.children.find((c) => c.attributes.has(attr)) ?? null; + } + + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + getBoundingClientRect() { + return { top: 0, left: 0, right: 100, bottom: 100, width: 100, height: 100, x: 0, y: 0 }; + } + + get childNodes(): StubElement[] { + return this.children; + } + + insertBefore(newChild: T, refChild: StubElement | null): T { + if (!refChild || !this.children.includes(refChild)) return this.appendChild(newChild); + const index = this.children.indexOf(refChild); + this.children.splice(index, 0, newChild); + newChild.parentNode = this; + this.firstChild = this.children[0] ?? null; + return newChild; + } +} + +class StubSVGElement extends StubElement {} +class StubSVGDefsElement extends StubSVGElement { + constructor() { + super('defs'); + } +} + +class StubDocument { + public body = new StubElement('body'); + + createElementNS(_ns: string, tag: string) { + if (tag === 'defs') return new StubSVGDefsElement(); + return new StubSVGElement(tag); + } + + createElement(tag: string) { + return new StubElement(tag); + } + + querySelector(): null { + return null; + } +} + +(globalThis as unknown as Record).SVGElement = StubSVGElement; +(globalThis as unknown as Record).SVGDefsElement = StubSVGDefsElement; + +const NODE_COUNTS = [100, 500, 1000, 5000, 10000]; + +function runInitialRenderBenchmarks(): BenchmarkResult[] { + const results: BenchmarkResult[] = []; + + console.log('Running initial render benchmarks...\n'); + + for (const targetCount of NODE_COUNTS) { + const tree = generateTree({ nodeCount: targetCount, shape: 'realistic' }); + const actualCount = countNodes(tree); + const config = createConfig(tree); + + const result = benchmark( + 'Initial render', + actualCount, + () => { + const doc = new StubDocument(); + const host = new StubSVGElement('svg'); + const chart = renderSVG({ + el: host as unknown as SVGElement, + config, + document: doc as unknown as Document, + }); + chart.destroy(); + }, + { warmupRuns: 2, measureRuns: 5 } + ); + + results.push(result); + console.log(formatResult(result)); + console.log(''); + } + + return results; +} + +function runUpdateBenchmarks(): BenchmarkResult[] { + const results: BenchmarkResult[] = []; + + console.log('\nRunning update benchmarks...\n'); + + for (const targetCount of NODE_COUNTS) { + const tree1 = generateTree({ nodeCount: targetCount, shape: 'realistic' }); + const tree2 = generateTree({ nodeCount: targetCount, shape: 'realistic' }); + const actualCount = countNodes(tree1); + const config1 = createConfig(tree1); + const config2 = createConfig(tree2); + + const doc = new StubDocument(); + const host = new StubSVGElement('svg'); + const chart = renderSVG({ + el: host as unknown as SVGElement, + config: config1, + document: doc as unknown as Document, + }); + + const result = benchmark( + 'Update render', + actualCount, + () => { + chart.update(config2); + chart.update(config1); + }, + { warmupRuns: 2, measureRuns: 5 } + ); + + chart.destroy(); + results.push(result); + console.log(formatResult(result)); + console.log(''); + } + + return results; +} + +function runWithFeaturesBenchmarks(): BenchmarkResult[] { + const results: BenchmarkResult[] = []; + + console.log('\nRunning render with features benchmarks...\n'); + + const featureSets = [ + { name: 'Bare', options: {} }, + { name: 'Tooltip', options: { tooltip: true } }, + { name: 'Navigation', options: { navigation: true } }, + { name: 'Highlight', options: { highlightByKey: true } }, + { name: 'All features', options: { tooltip: true, navigation: true, highlightByKey: true, breadcrumbs: true } }, + ]; + + const targetCount = 1000; + const tree = generateTree({ nodeCount: targetCount, shape: 'realistic' }); + const actualCount = countNodes(tree); + const config = createConfig(tree); + + for (const { name, options } of featureSets) { + const result = benchmark( + name, + actualCount, + () => { + const doc = new StubDocument(); + const host = new StubSVGElement('svg'); + const chart = renderSVG({ + el: host as unknown as SVGElement, + config, + document: doc as unknown as Document, + ...options, + } as Parameters[0]); + chart.destroy(); + }, + { warmupRuns: 2, measureRuns: 5 } + ); + + results.push(result); + console.log(formatResult(result)); + console.log(''); + } + + return results; +} + +function main() { + console.log('='.repeat(60)); + console.log('Sand.js Render Performance Benchmarks'); + console.log('='.repeat(60)); + console.log(''); + + const initialResults = runInitialRenderBenchmarks(); + const updateResults = runUpdateBenchmarks(); + const featureResults = runWithFeaturesBenchmarks(); + + console.log('\n' + '='.repeat(60)); + console.log('Initial Render Results'); + console.log('='.repeat(60)); + console.log(''); + console.log(formatTable(initialResults)); + + console.log('\n' + '='.repeat(60)); + console.log('Update Render Results'); + console.log('='.repeat(60)); + console.log(''); + console.log(formatTable(updateResults)); + + console.log('\n' + '='.repeat(60)); + console.log('Feature Overhead (1000 nodes)'); + console.log('='.repeat(60)); + console.log(''); + console.log(formatTable(featureResults)); + + console.log('\n' + '='.repeat(60)); + console.log('Performance Recommendations'); + console.log('='.repeat(60)); + console.log(''); + + const targetFps60 = 16.67; + const safeForInitial = initialResults.find(r => r.mean > targetFps60 * 2)?.nodeCount ?? 10000; + const safeForUpdate = updateResults.find(r => r.mean > targetFps60)?.nodeCount ?? 10000; + + console.log(`For smooth initial render: <${safeForInitial.toLocaleString()} nodes`); + console.log(`For 60fps updates: <${safeForUpdate.toLocaleString()} nodes`); + + const baseTime = featureResults[0].mean; + const allFeaturesTime = featureResults[featureResults.length - 1].mean; + const overhead = ((allFeaturesTime - baseTime) / baseTime * 100).toFixed(1); + console.log(`Feature overhead (all enabled): +${overhead}%`); +} + +main(); diff --git a/benchmarks/runner.ts b/benchmarks/runner.ts new file mode 100644 index 0000000..9d19de7 --- /dev/null +++ b/benchmarks/runner.ts @@ -0,0 +1,108 @@ +export interface BenchmarkResult { + name: string; + nodeCount: number; + runs: number; + mean: number; + median: number; + min: number; + max: number; + stdDev: number; + opsPerSecond: number; +} + +export interface BenchmarkOptions { + warmupRuns?: number; + measureRuns?: number; +} + +export function benchmark( + name: string, + nodeCount: number, + fn: () => void, + options: BenchmarkOptions = {} +): BenchmarkResult { + const { warmupRuns = 3, measureRuns = 10 } = options; + + // Warmup + for (let i = 0; i < warmupRuns; i++) { + fn(); + } + + // Measure + const times: number[] = []; + for (let i = 0; i < measureRuns; i++) { + const start = performance.now(); + fn(); + const end = performance.now(); + times.push(end - start); + } + + const sorted = [...times].sort((a, b) => a - b); + const mean = times.reduce((a, b) => a + b, 0) / times.length; + const median = sorted[Math.floor(sorted.length / 2)]; + const min = sorted[0]; + const max = sorted[sorted.length - 1]; + + const variance = times.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) / times.length; + const stdDev = Math.sqrt(variance); + + return { + name, + nodeCount, + runs: measureRuns, + mean, + median, + min, + max, + stdDev, + opsPerSecond: 1000 / mean, + }; +} + +export function formatResult(result: BenchmarkResult): string { + const lines = [ + `${result.name} (${result.nodeCount.toLocaleString()} nodes)`, + ` Mean: ${result.mean.toFixed(2)}ms`, + ` Median: ${result.median.toFixed(2)}ms`, + ` Min: ${result.min.toFixed(2)}ms`, + ` Max: ${result.max.toFixed(2)}ms`, + ` StdDev: ${result.stdDev.toFixed(2)}ms`, + ` Ops/s: ${result.opsPerSecond.toFixed(2)}`, + ]; + return lines.join('\n'); +} + +export function formatTable(results: BenchmarkResult[]): string { + const header = '| Benchmark | Nodes | Mean (ms) | Median (ms) | Min (ms) | Max (ms) | Ops/s |'; + const separator = '|-----------|-------|-----------|-------------|----------|----------|-------|'; + + const rows = results.map(r => + `| ${r.name} | ${r.nodeCount.toLocaleString()} | ${r.mean.toFixed(2)} | ${r.median.toFixed(2)} | ${r.min.toFixed(2)} | ${r.max.toFixed(2)} | ${r.opsPerSecond.toFixed(1)} |` + ); + + return [header, separator, ...rows].join('\n'); +} + +export function formatSummary(results: BenchmarkResult[]): string { + const lines: string[] = ['## Performance Summary\n']; + + const byName = new Map(); + for (const r of results) { + const base = r.name.split(' ')[0]; + if (!byName.has(base)) byName.set(base, []); + byName.get(base)!.push(r); + } + + for (const [name, group] of byName) { + lines.push(`### ${name}\n`); + const sorted = group.sort((a, b) => a.nodeCount - b.nodeCount); + + for (const r of sorted) { + const nodesPerMs = r.nodeCount / r.mean; + lines.push(`- **${r.nodeCount.toLocaleString()} nodes**: ${r.mean.toFixed(2)}ms (${nodesPerMs.toFixed(0)} nodes/ms)`); + } + lines.push(''); + } + + return lines.join('\n'); +} diff --git a/docs/guides/performance.md b/docs/guides/performance.md new file mode 100644 index 0000000..6682f51 --- /dev/null +++ b/docs/guides/performance.md @@ -0,0 +1,165 @@ +# Performance Guide + +Sand.js is designed to handle typical sunburst visualization use cases efficiently. This guide covers performance characteristics and optimization recommendations. + +## Performance Summary + +| Operation | 100 nodes | 1,000 nodes | 5,000 nodes | 10,000 nodes | +|-----------|-----------|-------------|-------------|--------------| +| Layout computation | <0.1ms | ~0.3ms | ~1.5ms | ~3ms | +| Initial render | ~1ms | ~8ms | ~40ms | ~80ms | +| Update render | ~2ms | ~7ms | ~48ms | ~100ms | +| Navigation (drill-down) | ~1ms | ~8ms | ~50ms | - | + +*Benchmarks run on Node.js with DOM stubs. Real browser performance may be better due to native SVG optimizations.* + +## Recommended Node Limits + +### For 60fps Interactive Charts +- **Recommended**: Up to 1,000 nodes for smooth animations +- **Acceptable**: Up to 5,000 nodes (may have frame drops during transitions) +- **Large displays**: Up to 10,000 nodes for static or rarely updated charts + +### For Static or Rarely Updated Charts +- Up to 50,000 nodes can be rendered, but initial load time increases significantly + +## Optimization Tips + +### 1. Use Collapsed Nodes for Large Datasets + +If your dataset has deeply nested hierarchies, use `collapsed: true` to hide descendants until the user drills down: + +```javascript +const tree = { + name: 'Root', + children: [ + { + name: 'Large Branch', + collapsed: true, // Children hidden until clicked + children: [/* thousands of nodes */] + } + ] +}; +``` + +### 2. Limit Expand Levels + +Control how many levels are rendered at once using `expandLevels`: + +```javascript +const config = { + size: { radius: 300 }, + layers: [{ + id: 'main', + radialUnits: [0, 5], + angleMode: 'free', + tree: { + name: 'Root', + expandLevels: 3, // Only show 3 levels deep + children: [/* deep hierarchy */] + } + }] +}; +``` + +### 3. Disable Transitions for Large Datasets + +Animations add overhead. For large datasets, disable transitions: + +```javascript +renderSVG({ + el: container, + config, + transition: false // No animations +}); +``` + +### 4. Use Navigation for Drill-Down + +Instead of rendering the entire dataset, use the navigation feature to let users drill down: + +```javascript +renderSVG({ + el: container, + config, + navigation: true, + breadcrumbs: true +}); +``` + +### 5. Consider Data Aggregation + +For very large datasets, pre-aggregate data on the server: + +```javascript +// Instead of 100,000 individual items +const rawData = { /* 100k nodes */ }; + +// Aggregate to ~1,000 meaningful groups +const aggregatedData = aggregateByCategory(rawData); +``` + +## Layout Complexity + +The layout algorithm has approximately **O(n)** complexity where n is the number of visible nodes. Performance scales linearly with node count. + +### Tree Shape Impact + +| Shape | Performance | Use Case | +|-------|-------------|----------| +| Wide (many siblings) | Fastest | Flat hierarchies | +| Balanced | Typical | Most use cases | +| Deep (many levels) | Slightly slower | Deep nesting | + +## Feature Overhead + +Enabling optional features adds minimal overhead: + +| Feature | Overhead | +|---------|----------| +| Tooltip | ~2% | +| Navigation | ~5% | +| Highlight by key | ~2% | +| All features | ~10% | + +## Memory Considerations + +Each arc requires: +- SVG path element +- Label elements (text + textPath) +- Event listeners +- Internal tracking data + +Approximate memory per arc: ~1-2KB + +For 10,000 nodes: ~10-20MB of DOM and JavaScript memory + +## Running Benchmarks + +Run the benchmark suite to test performance on your system: + +```bash +# Run all benchmarks +npm run bench + +# Run specific benchmarks +npm run bench:layout +npm run bench:render +npm run bench:navigation +``` + +## Browser Considerations + +- **Chrome/Edge**: Best SVG rendering performance +- **Firefox**: Good performance, slightly slower for large SVG updates +- **Safari**: Good performance, hardware-accelerated +- **Mobile**: Reduce node count by ~50% for smooth interactions + +## Summary + +For most use cases with 100-5,000 nodes, Sand.js performs well without optimization. For larger datasets: + +1. Use collapsed nodes and navigation +2. Limit expand levels +3. Consider disabling transitions +4. Aggregate data when possible diff --git a/package.json b/package.json index 7e7bd7f..90d4d60 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,13 @@ "dev": "node scripts/dev-server.mjs", "clean:docs": "rimraf temp docs/api", "docs:extract": "api-extractor run --local", - "docs:build": "npm run build && npm run clean:docs && npm run docs:extract && api-documenter markdown --input-folder temp --output-folder docs/api && node scripts/postprocess-docs.mjs" + "docs:build": "npm run build && npm run clean:docs && npm run docs:extract && api-documenter markdown --input-folder temp --output-folder docs/api && node scripts/postprocess-docs.mjs", + "clean:bench": "rimraf dist-benchmarks", + "bench:build": "npm run clean:bench && tsc --project tsconfig.bench.json", + "bench": "npm run bench:build && node dist-benchmarks/benchmarks/index.js", + "bench:layout": "npm run bench:build && node dist-benchmarks/benchmarks/layout.bench.js", + "bench:render": "npm run bench:build && node dist-benchmarks/benchmarks/render.bench.js", + "bench:navigation": "npm run bench:build && node dist-benchmarks/benchmarks/navigation.bench.js" }, "repository": { "type": "git", diff --git a/tsconfig.bench.json b/tsconfig.bench.json new file mode 100644 index 0000000..7900a4d --- /dev/null +++ b/tsconfig.bench.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "node", + "rootDir": ".", + "outDir": "dist-benchmarks", + "declaration": false, + "strict": true, + "skipLibCheck": true + }, + "include": ["benchmarks/**/*.ts", "src/**/*.ts"] +} From 2bfa8b773ec88dbaddef7d59255cffcb05d97c4d Mon Sep 17 00:00:00 2001 From: Corentin R Date: Wed, 21 Jan 2026 14:39:20 +0100 Subject: [PATCH 07/13] feat(labels): add labelPadding and labelFit options (#55) (#58) --- CHANGELOG.md | 2 ++ src/render/svg/label-system.ts | 25 +++++++++++++++++++++---- src/render/types.ts | 2 ++ 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8175bb5..c94fd9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. ### 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. diff --git a/src/render/svg/label-system.ts b/src/render/svg/label-system.ts index d53dbe2..a84205c 100644 --- a/src/render/svg/label-system.ts +++ b/src/render/svg/label-system.ts @@ -154,6 +154,18 @@ function resolveFontSizeScale(labelOptions: ResolvedRenderOptions['labels']): nu return labelOptions?.fontSizeScale ?? DEFAULT_FONT_SIZE_SCALE; } +function resolveLabelPadding(labelOptions: ResolvedRenderOptions['labels']): number { + if (typeof labelOptions !== 'object') return LABEL_PADDING; + return labelOptions?.labelPadding ?? LABEL_PADDING; +} + +type LabelFit = 'both' | 'height' | 'width'; + +function resolveLabelFit(labelOptions: ResolvedRenderOptions['labels']): LabelFit { + if (typeof labelOptions !== 'object') return 'both'; + return labelOptions?.labelFit ?? 'both'; +} + /** * Evaluates whether a label can be shown for an arc */ @@ -169,22 +181,27 @@ function evaluateLabelVisibility( return { visible: false, reason: 'no-span' }; } + const labelFit = resolveLabelFit(renderOptions.labels); + const checkHeight = labelFit === 'both' || labelFit === 'height'; + const checkWidth = labelFit === 'both' || labelFit === 'width'; + const minThickness = resolveMinRadialThickness(renderOptions.labels); const radialThickness = Math.max(0, arc.y1 - arc.y0); - if (radialThickness < minThickness) { + if (checkHeight && radialThickness < minThickness) { return { visible: false, reason: 'thin-radius' }; } const fontConfig = resolveFontSizeConfig(renderOptions.labels); const fontSizeScale = resolveFontSizeScale(renderOptions.labels); const midRadius = arc.y0 + radialThickness * 0.5; - const fontSize = Math.min(Math.max(radialThickness * fontSizeScale, fontConfig.min), fontConfig.max); - const estimatedWidth = text.length * fontSize * LABEL_CHAR_WIDTH_FACTOR + LABEL_PADDING; +const fontSize = Math.min(Math.max(radialThickness * fontSizeScale, fontConfig.min), fontConfig.max); + const labelPadding = resolveLabelPadding(renderOptions.labels); + const estimatedWidth = text.length * fontSize * LABEL_CHAR_WIDTH_FACTOR + labelPadding; const arcLength = span * midRadius; // Apply safety margin for centered text to prevent cut-off at boundaries const requiredLength = estimatedWidth * LABEL_SAFETY_MARGIN; - if (arcLength < requiredLength) { + if (checkWidth && arcLength < requiredLength) { return { visible: false, reason: 'narrow-arc' }; } diff --git a/src/render/types.ts b/src/render/types.ts index 22125f1..68dfaed 100644 --- a/src/render/types.ts +++ b/src/render/types.ts @@ -75,6 +75,8 @@ export interface LabelOptions { fontSizeScale?: number; minRadialThickness?: number; rootLabelStyle?: 'curved' | 'straight'; + labelPadding?: number; + labelFit?: 'both' | 'height' | 'width'; } /** From 3ef79f09039708b1b7ddf9c22450452ce917e47c Mon Sep 17 00:00:00 2001 From: Corentin R Date: Wed, 21 Jan 2026 14:50:58 +0100 Subject: [PATCH 08/13] fix(labels): use max font size when labelFit ignores height (#59) --- CHANGELOG.md | 2 +- src/render/svg/label-system.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c94fd9a..6affdc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- **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. ### 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. diff --git a/src/render/svg/label-system.ts b/src/render/svg/label-system.ts index a84205c..6046d5d 100644 --- a/src/render/svg/label-system.ts +++ b/src/render/svg/label-system.ts @@ -194,7 +194,9 @@ function evaluateLabelVisibility( const fontConfig = resolveFontSizeConfig(renderOptions.labels); const fontSizeScale = resolveFontSizeScale(renderOptions.labels); const midRadius = arc.y0 + radialThickness * 0.5; -const fontSize = Math.min(Math.max(radialThickness * fontSizeScale, fontConfig.min), fontConfig.max); + const fontSize = checkHeight + ? Math.min(Math.max(radialThickness * fontSizeScale, fontConfig.min), fontConfig.max) + : fontConfig.max; const labelPadding = resolveLabelPadding(renderOptions.labels); const estimatedWidth = text.length * fontSize * LABEL_CHAR_WIDTH_FACTOR + labelPadding; const arcLength = span * midRadius; From 735fc3d4a730e4daf1f353072ea88fb60c6739fa Mon Sep 17 00:00:00 2001 From: Corentin R Date: Wed, 21 Jan 2026 14:55:46 +0100 Subject: [PATCH 09/13] docs: add browser compatibility matrix (#60) * docs: add browser compatibility matrix (#48) * fix: correct browser versions for ESNext/ES2020 syntax --- CHANGELOG.md | 1 + README.md | 20 ++++++ docs/guides/browser-support.md | 121 +++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 docs/guides/browser-support.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6affdc4..65490f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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`). ### 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. diff --git a/README.md b/README.md index 21616c5..4720462 100644 --- a/README.md +++ b/README.md @@ -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) --- @@ -698,6 +699,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 diff --git a/docs/guides/browser-support.md b/docs/guides/browser-support.md new file mode 100644 index 0000000..7f83d0b --- /dev/null +++ b/docs/guides/browser-support.md @@ -0,0 +1,121 @@ +# Browser Support + +Sand.js is designed for modern browsers. This guide documents officially supported browsers and any known limitations. + +## Compatibility Matrix + +| Browser | Minimum Version | Status | +|---------|-----------------|--------| +| Chrome | 80+ | Supported | +| Firefox | 74+ | Supported | +| Safari | 13.1+ | Supported | +| Edge | 80+ | Supported | +| Opera | 67+ | Supported | +| iOS Safari | 13.4+ | Supported | +| Chrome Android | 80+ | Supported | +| Samsung Internet | 13.0+ | Supported | +| Internet Explorer | - | Not Supported | + +> **Note:** The distributed bundle targets ESNext and includes ES2020 syntax (optional chaining, nullish coalescing). If you need to support older browsers, you must transpile the bundle with Babel or similar tools. + +## Required Browser Features + +Sand.js relies on the following browser APIs: + +### Core Requirements + +| Feature | Used For | +|---------|----------| +| SVG 1.1 | Chart rendering | +| ES6 (ES2015) | Language features | +| WeakMap | Navigation state tracking | +| AbortController | Event listener cleanup | +| Pointer Events | Mouse/touch interactions | +| requestAnimationFrame | Smooth animations | + +### ES2020+ Features Used + +- Optional chaining (`?.`) +- Nullish coalescing (`??`) +- Arrow functions +- `const` / `let` +- Template literals +- Destructuring +- Spread operator +- `Map` / `Set` / `WeakMap` +- Classes + +### Fallbacks Provided + +| Feature | Fallback | +|---------|----------| +| `requestAnimationFrame` | `setTimeout` (16ms) | +| `performance.now()` | `Date.now()` | + +## Why IE 11 Is Not Supported + +Internet Explorer 11 lacks several critical features: + +- **WeakMap**: Required for navigation system memory management +- **Pointer Events**: Required for unified mouse/touch handling +- **AbortController**: Required for proper event listener cleanup +- **ES6 syntax**: Would require full transpilation and polyfills + +Supporting IE 11 would require significant polyfills and increase bundle size substantially. Given IE 11's end-of-life status (June 2022), we recommend using a modern browser. + +## Mobile Considerations + +Sand.js works on mobile browsers with these notes: + +- **Touch**: Pointer Events provide unified touch/mouse support +- **Performance**: Reduce node count by ~50% for smooth interactions on mobile +- **Viewport**: Charts are responsive; use appropriate `radius` values for mobile screens + +## Testing Your Browser + +You can verify compatibility by checking if your browser supports these APIs: + +```javascript +const isSupported = + typeof WeakMap !== 'undefined' && + typeof AbortController !== 'undefined' && + typeof PointerEvent !== 'undefined' && + typeof requestAnimationFrame !== 'undefined'; + +console.log('Sand.js supported:', isSupported); +``` + +## Known Browser-Specific Issues + +### Safari + +- No known issues + +### Firefox + +- No known issues + +### Edge (Legacy, pre-Chromium) + +- Edge 16-18 may work but is not officially tested +- Recommend upgrading to Edge 79+ (Chromium-based) + +## Transpilation + +The distributed bundle (`dist/sandjs.mjs`) targets **ESNext** and includes ES2020 syntax. If you need to support older browsers: + +1. **Transpile with Babel**: Add `@babel/preset-env` with appropriate targets +2. **Use source files**: Import from source and include in your build pipeline +3. **Add polyfills**: `WeakMap`, `AbortController`, and `PointerEvent` polyfills may be needed + +Example Babel configuration for broader support: + +```json +{ + "presets": [ + ["@babel/preset-env", { + "targets": "> 0.5%, last 2 versions, not dead" + }] + ] +} +``` From 0b6481242795308984e5c92e2ffae76fd7f3cab5 Mon Sep 17 00:00:00 2001 From: Corentin R Date: Wed, 21 Jan 2026 15:24:25 +0100 Subject: [PATCH 10/13] docs: add migration guide for 1.0 (#61) * docs: add migration guide for 1.0 (#50) * fix: replace Object.hasOwn with ES2020 compatible check --- CHANGELOG.md | 1 + docs/guides/migration-guide.md | 172 ++++++++++++++++++++++++++++++++ src/render/runtime/highlight.ts | 2 +- 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 docs/guides/migration-guide.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 65490f4..e431d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **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. diff --git a/docs/guides/migration-guide.md b/docs/guides/migration-guide.md new file mode 100644 index 0000000..d1690be --- /dev/null +++ b/docs/guides/migration-guide.md @@ -0,0 +1,172 @@ +# Migration Guide + +This guide covers breaking changes and migration steps when upgrading Sand.js. + +## Migrating to 1.0 + +Sand.js 1.0 is largely backward compatible with 0.x versions. Most changes are additive new features. However, there are a few behavioral changes to be aware of. + +### Breaking Changes Summary + +| Version | Change | Impact | +|---------|--------|--------| +| 0.4.0 | Instant node removal | Visual change during navigation | +| 0.3.3 | Debug logging opt-in | No console warnings by default | + +### Debug Logging Is Now Opt-In (v0.3.3) + +**Before:** Label visibility warnings (e.g., "Hiding label because arc span is too narrow") appeared in the console by default. + +**After:** Console logging is disabled by default. Pass `debug: true` to enable. + +```javascript +// Before (0.3.2 and earlier) +renderSVG({ + el: '#chart', + config + // Warnings appeared automatically +}); + +// After (0.3.3+) +renderSVG({ + el: '#chart', + config, + debug: true // Enable diagnostic logging +}); +``` + +### Instant Node Removal (v0.4.0) + +**Before:** Disappearing nodes during navigation faded out with animation. + +**After:** Disappearing nodes remove instantly. Only nodes that stay and expand are animated. + +This change improves perceived performance during drill-down navigation. If you relied on the fade-out animation for custom styling, you may need to adjust your CSS transitions. + +### Multi-Parent Nodes Stable (1.0) + +The multi-parent nodes feature introduced in v0.3.5 is now stable. The console warning on first use has been removed. + +**Before (0.3.5 - 0.4.x):** +```javascript +// Console warning: "Multi-parent nodes is an experimental feature..." +const tree = { + name: 'Child', + parents: ['Parent1', 'Parent2'] +}; +``` + +**After (1.0):** +```javascript +// No warning, feature is stable +const tree = { + name: 'Child', + parents: ['Parent1', 'Parent2'] +}; +``` + +**Known limitations** (documented, not bugs): +- Key highlighting may not work as expected with multi-parent nodes +- Navigation can be ambiguous when a node has multiple parents + +--- + +## New Features in 1.0 + +These are additive and don't require migration, but you may want to adopt them. + +### Simple API (v0.4.0) + +New shorthand for simple charts without layer configuration: + +```javascript +// Before: Full config required +renderSVG({ + el: '#chart', + config: { + size: { radius: 300 }, + layers: [{ + id: 'main', + radialUnits: [0, 5], + angleMode: 'free', + tree: { name: 'Root', children: [...] } + }] + } +}); + +// After: Simple API +renderSVG({ + el: '#chart', + radius: 300, + data: { name: 'Root', children: [...] } +}); +``` + +### Label Options (v0.3.4 - 1.0) + +New label customization options: + +```javascript +renderSVG({ + el: '#chart', + config, + labels: { + showLabels: true, + labelColor: '#ffffff', // Fixed color + autoLabelColor: true, // Auto contrast (black/white) + fontSize: { min: 10, max: 16 }, + fontSizeScale: 0.5, // Scale factor (v0.4.0+) + minRadialThickness: 20, // Hide labels on thin arcs + rootLabelStyle: 'straight', // 'curved' or 'straight' + labelPadding: 8, // Padding around text (1.0) + labelFit: 'both' // 'both', 'height', or 'width' (1.0) + } +}); +``` + +### Border Customization (v0.3.4) + +```javascript +renderSVG({ + el: '#chart', + config, + borderColor: '#1a1f2e', + borderWidth: 2 +}); +``` + +### Keyboard Accessibility (1.0) + +Arcs are now keyboard-accessible by default: +- `Tab` to focus arcs +- `Enter` or `Space` to drill down +- Focus shows tooltip and breadcrumb + +No code changes required - this is automatic. + +--- + +## Version Compatibility + +| Sand.js | Node.js | Browsers | +|---------|---------|----------| +| 1.0 | 18+ | Chrome 80+, Firefox 74+, Safari 13.1+, Edge 80+ | +| 0.4.x | 18+ | Chrome 80+, Firefox 74+, Safari 13.1+, Edge 80+ | +| 0.3.x | 16+ | Chrome 66+, Firefox 57+, Safari 13+, Edge 79+ | + +> **Note:** Starting from v0.3.0, the bundle uses ES2020 syntax (optional chaining, nullish coalescing). For older browsers, transpile with Babel. + +--- + +## Deprecations + +There are currently no deprecated APIs. All existing APIs remain supported. + +--- + +## Getting Help + +If you encounter issues during migration: + +1. Check the [CHANGELOG](../../CHANGELOG.md) for detailed release notes +2. Open an issue on [GitHub](https://github.com/Aqu1tain/sandjs/issues) diff --git a/src/render/runtime/highlight.ts b/src/render/runtime/highlight.ts index a0ae6a8..b805c75 100644 --- a/src/render/runtime/highlight.ts +++ b/src/render/runtime/highlight.ts @@ -78,7 +78,7 @@ export function createHighlightRuntime(input: RenderSvgOptions['highlightByKey'] groups.set(key, group); } group.add(path); - if (!Object.hasOwn(path.dataset, 'key')) { + if (path.dataset.key === undefined) { path.dataset.key = key; } }, From fad5f693f74e4e3da5c64bc46675842cb731aa9d Mon Sep 17 00:00:00 2001 From: Corentin R Date: Wed, 21 Jan 2026 16:24:40 +0100 Subject: [PATCH 11/13] chore: bump version to 1.0.0-rc.1 (#62) * chore: bump version to 1.0.0-rc.1 * fix(labels): calculate font size based on both width and height constraints --- CHANGELOG.md | 2 ++ package.json | 2 +- src/render/svg/label-system.ts | 17 +++++++++-------- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e431d2c..418cf43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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`. diff --git a/package.json b/package.json index 90d4d60..43dbdac 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "0.4.0", + "version": "1.0.0-rc.1", "description": "Sunburst Advanced Node Data", "main": "dist/sandjs.mjs", "scripts": { diff --git a/src/render/svg/label-system.ts b/src/render/svg/label-system.ts index 6046d5d..ac3e678 100644 --- a/src/render/svg/label-system.ts +++ b/src/render/svg/label-system.ts @@ -193,17 +193,18 @@ function evaluateLabelVisibility( const fontConfig = resolveFontSizeConfig(renderOptions.labels); const fontSizeScale = resolveFontSizeScale(renderOptions.labels); - const midRadius = arc.y0 + radialThickness * 0.5; - const fontSize = checkHeight - ? Math.min(Math.max(radialThickness * fontSizeScale, fontConfig.min), fontConfig.max) - : fontConfig.max; const labelPadding = resolveLabelPadding(renderOptions.labels); - const estimatedWidth = text.length * fontSize * LABEL_CHAR_WIDTH_FACTOR + labelPadding; + const midRadius = arc.y0 + radialThickness * 0.5; const arcLength = span * midRadius; - // Apply safety margin for centered text to prevent cut-off at boundaries - const requiredLength = estimatedWidth * LABEL_SAFETY_MARGIN; - if (checkWidth && arcLength < requiredLength) { + const heightBasedSize = radialThickness * fontSizeScale; + const widthBasedSize = (arcLength / LABEL_SAFETY_MARGIN - labelPadding) / (text.length * LABEL_CHAR_WIDTH_FACTOR); + + const rawFontSize = checkWidth ? Math.min(widthBasedSize, checkHeight ? heightBasedSize : Infinity) : heightBasedSize; + const fontSize = Math.min(Math.max(rawFontSize, fontConfig.min), fontConfig.max); + + const estimatedWidth = text.length * fontSize * LABEL_CHAR_WIDTH_FACTOR + labelPadding; + if (checkWidth && arcLength < estimatedWidth * LABEL_SAFETY_MARGIN) { return { visible: false, reason: 'narrow-arc' }; } From a83e05a9b1e6550b31bce0b78efa8207ff26add7 Mon Sep 17 00:00:00 2001 From: Corentin R Date: Thu, 22 Jan 2026 09:45:54 +0100 Subject: [PATCH 12/13] docs: add labelFit and labelPadding to README (#63) * docs: add labelFit and labelPadding to README Labels section * docs: add LabelOptions type and labelFit example to api docs --- README.md | 16 ++++++++++++++++ docs/api/render-svg.md | 13 ++++++++++++- docs/api/types.md | 21 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4720462..8257edd 100644 --- a/README.md +++ b/README.md @@ -537,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 diff --git a/docs/api/render-svg.md b/docs/api/render-svg.md index bf520cd..6f0dab2 100644 --- a/docs/api/render-svg.md +++ b/docs/api/render-svg.md @@ -322,8 +322,19 @@ const chart2 = renderSVG({ } }); -// Hide labels +// Control label fitting behavior const chart3 = renderSVG({ + el: '#chart', + config, + labels: { + labelPadding: 8, // Spacing around text in pixels (default: 8) + labelFit: 'height', // Only check radial thickness, ignore arc width + fontSize: { min: 10, max: 18 } + } +}); + +// Hide labels +const chart4 = renderSVG({ el: '#chart', config, labels: false // or { showLabels: false } diff --git a/docs/api/types.md b/docs/api/types.md index 24cb958..5d7ad6d 100644 --- a/docs/api/types.md +++ b/docs/api/types.md @@ -235,6 +235,27 @@ interface TransitionOptions { } ``` +### LabelOptions + +```typescript +interface LabelOptions { + showLabels?: boolean; + labelColor?: string; + autoLabelColor?: boolean; + fontSize?: number | { min: number; max: number }; + fontSizeScale?: number; + minRadialThickness?: number; + rootLabelStyle?: 'curved' | 'straight'; + labelPadding?: number; // Spacing around text in pixels (default: 8) + labelFit?: 'both' | 'height' | 'width'; +} +``` + +**`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 + ## Color Theme Types ### ColorThemeOptions From 4e9b4290f83ff3b7fe487f2ef02fc824850fb84f Mon Sep 17 00:00:00 2001 From: Corentin R Date: Thu, 22 Jan 2026 10:05:14 +0100 Subject: [PATCH 13/13] chore: bump version to 1.0.0 (#64) --- CHANGELOG.md | 10 ++++++++++ README.md | 2 +- docs/README.md | 2 +- docs/guides/first-chart.md | 6 +++--- docs/guides/getting-started.md | 6 +++--- package-lock.json | 4 ++-- package.json | 2 +- 7 files changed, 21 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 418cf43..a44b81c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ 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 diff --git a/README.md b/README.md index 8257edd..87d41a1 100644 --- a/README.md +++ b/README.md @@ -688,7 +688,7 @@ For quick prototyping or non-bundled environments: ```html - + + + + + ``` The library will be available as `window.SandJS`. @@ -75,7 +75,7 @@ const { renderSVG } = require('@akitain/sandjs'); ### CDN / IIFE ```html - + @@ -190,7 +190,7 @@ Here's the full working example: