{"_id":"@bravobit/org-chart","_rev":"3-4cf2f1d6e68e1f03a27ae628ac502ce7","name":"@bravobit/org-chart","dist-tags":{"latest":"1.0.2"},"versions":{"1.0.0":{"name":"@bravobit/org-chart","version":"1.0.0","keywords":["org-chart","organization-chart","ownership-structure","ubo","hierarchy","diagram","svg","typescript"],"author":{"name":"Stan van Heumen"},"license":"SEE LICENSE IN LICENSE","_id":"@bravobit/org-chart@1.0.0","maintainers":[{"name":"stanvanheumen","email":"stanvanheumen@gmail.com"}],"homepage":"https://github.com/bravobit/bravobit-org-chart#readme","bugs":{"url":"https://github.com/bravobit/bravobit-org-chart/issues"},"dist":{"shasum":"12ff1d23f2bc1dcfa2d4ddd4a9b3da72eea6fa7c","tarball":"https://registry.npmjs.org/@bravobit/org-chart/-/org-chart-1.0.0.tgz","fileCount":11,"integrity":"sha512-5AUMbPZNWEfOG2b+EeiVKuKzJWtL/fJG9VgW+ZyOEShPQrfykemrCJVT3+E1/Fj9ooN1OmcBqF+hEfKvBc6bSQ==","signatures":[{"sig":"MEQCIFN6eCYetsyF8wwVU3OWQn0bTAsHzLJzk0fHx8H3MloDAiAXgE/YuArT1BbYsj1PKzpCOv8qd6kN2kJNQgKTq9h5IA==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":463879},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=22"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./styles.css":"./dist/org-chart.css","./package.json":"./package.json"},"gitHead":"561730a9d13ee6be5eb3dc7c1b043b79bafb572d","scripts":{"lint":"eslint .","test":"npm run build && npm run typecheck && npm run lint && npm run test:unit","build":"tsup","test:unit":"node --test \"tests/*.test.mjs\"","typecheck":"tsc --noEmit","test:watch":"node --test --watch \"tests/*.test.mjs\"","check:package":"publint && attw --pack . --exclude-entrypoints ./styles.css","test:coverage":"node --enable-source-maps --test --experimental-test-coverage --test-coverage-exclude=\"**/node_modules/**\" --test-coverage-exclude=\"**/tests/**\" --test-coverage-exclude=\"**/demo/**\" --test-coverage-lines=75 --test-coverage-branches=85 --test-coverage-functions=85 \"tests/*.test.mjs\"","prepublishOnly":"npm test && npm run check:package"},"_npmUser":{"name":"stanvanheumen","email":"stanvanheumen@gmail.com"},"repository":{"url":"git+https://github.com/bravobit/bravobit-org-chart.git","type":"git"},"_npmVersion":"11.18.0","description":"Renderer-driven organization/ownership chart library with an injectable DOM adapter. Zero runtime dependencies, fully tree-shakable.","directories":{},"sideEffects":false,"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.0","jsdom":"^29.1.1","eslint":"^10.6.0","globals":"^17.7.0","publint":"^0.3.21","@eslint/js":"^10.0.1","typescript":"^6.0.3","typescript-eslint":"^8.62.1","@arethetypeswrong/cli":"^0.18.4","@stylistic/eslint-plugin":"^5.10.0"},"_npmOperationalInternal":{"tmp":"tmp/org-chart_1.0.0_1783250331725_0.9816599326198199","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@bravobit/org-chart","version":"1.0.1","keywords":["org-chart","organization-chart","ownership-structure","ubo","hierarchy","diagram","svg","typescript"],"author":{"name":"Stan van Heumen"},"license":"SEE LICENSE IN LICENSE","_id":"@bravobit/org-chart@1.0.1","maintainers":[{"name":"stanvanheumen","email":"stanvanheumen@gmail.com"}],"homepage":"https://github.com/bravobit/bravobit-org-chart#readme","bugs":{"url":"https://github.com/bravobit/bravobit-org-chart/issues"},"dist":{"shasum":"0263092d7c8abf3b92c5be551aa30a2f4b98d418","tarball":"https://registry.npmjs.org/@bravobit/org-chart/-/org-chart-1.0.1.tgz","fileCount":11,"integrity":"sha512-azySj9VAu6t9vykqqACpKa3w6n6H8SnSUWpqOUFqSZOeoL8KtDrWhGFhcGAaAT+8oFDTRvg0J/kJ1WHfkKG3hg==","signatures":[{"sig":"MEYCIQCmReQfAhX8sjlJTQucIZs8JYaJa9T+dK9ZZDbu84xrdAIhANssuRp2rtRSRLkDwFYE3Tml4OcS9pBa6arwYKz/fh2C","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":468770},"main":"./dist/index.cjs","type":"module","types":"./dist/index.d.ts","module":"./dist/index.js","engines":{"node":">=22"},"exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./styles.css":"./dist/org-chart.css","./package.json":"./package.json"},"gitHead":"263db482634e7900dc5e1a89afbaba71f5c4677a","scripts":{"lint":"eslint .","test":"npm run build && npm run typecheck && npm run lint && npm run test:unit","build":"tsup","test:unit":"node --test \"tests/*.test.mjs\"","typecheck":"tsc --noEmit","test:watch":"node --test --watch \"tests/*.test.mjs\"","check:package":"publint && attw --pack . --exclude-entrypoints ./styles.css","test:coverage":"node --enable-source-maps --test --experimental-test-coverage --test-coverage-exclude=\"**/node_modules/**\" --test-coverage-exclude=\"**/tests/**\" --test-coverage-exclude=\"**/demo/**\" --test-coverage-lines=75 --test-coverage-branches=85 --test-coverage-functions=85 \"tests/*.test.mjs\"","prepublishOnly":"npm test && npm run check:package"},"_npmUser":{"name":"stanvanheumen","email":"stanvanheumen@gmail.com"},"repository":{"url":"git+https://github.com/bravobit/bravobit-org-chart.git","type":"git"},"_npmVersion":"11.18.0","description":"Renderer-driven organization/ownership chart library with an injectable DOM adapter. Zero runtime dependencies, fully tree-shakable.","directories":{},"sideEffects":false,"_nodeVersion":"24.16.0","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"tsup":"^8.5.0","jsdom":"^29.1.1","eslint":"^10.6.0","globals":"^17.7.0","publint":"^0.3.21","@eslint/js":"^10.0.1","typescript":"^6.0.3","typescript-eslint":"^8.62.1","@arethetypeswrong/cli":"^0.18.4","@stylistic/eslint-plugin":"^5.10.0"},"_npmOperationalInternal":{"tmp":"tmp/org-chart_1.0.1_1783361941114_0.13939365995582165","host":"s3://npm-registry-packages-npm-production"}},"1.0.2":{"name":"@bravobit/org-chart","version":"1.0.2","description":"Renderer-driven organization/ownership chart library with an injectable DOM adapter. Zero runtime dependencies, fully tree-shakable.","author":{"name":"Stan van Heumen"},"license":"SEE LICENSE IN LICENSE","repository":{"type":"git","url":"git+https://github.com/bravobit/bravobit-org-chart.git"},"bugs":{"url":"https://github.com/bravobit/bravobit-org-chart/issues"},"homepage":"https://github.com/bravobit/bravobit-org-chart#readme","publishConfig":{"access":"public"},"type":"module","sideEffects":false,"engines":{"node":">=22"},"main":"./dist/index.cjs","module":"./dist/index.js","types":"./dist/index.d.ts","exports":{".":{"import":{"types":"./dist/index.d.ts","default":"./dist/index.js"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./styles.css":"./dist/org-chart.css","./package.json":"./package.json"},"scripts":{"build":"tsup","typecheck":"tsc --noEmit","lint":"eslint .","test":"npm run build && npm run typecheck && npm run lint && npm run test:unit","test:unit":"node --test \"tests/*.test.mjs\"","test:watch":"node --test --watch \"tests/*.test.mjs\"","test:coverage":"node --enable-source-maps --test --experimental-test-coverage --test-coverage-exclude=\"**/node_modules/**\" --test-coverage-exclude=\"**/tests/**\" --test-coverage-exclude=\"**/demo/**\" --test-coverage-lines=75 --test-coverage-branches=85 --test-coverage-functions=85 \"tests/*.test.mjs\"","check:package":"publint && attw --pack . --exclude-entrypoints ./styles.css","prepublishOnly":"npm test && npm run check:package"},"keywords":["org-chart","organization-chart","ownership-structure","ubo","hierarchy","diagram","svg","typescript"],"devDependencies":{"@arethetypeswrong/cli":"^0.18.4","@eslint/js":"^10.0.1","@stylistic/eslint-plugin":"^5.10.0","eslint":"^10.6.0","globals":"^17.7.0","jsdom":"^29.1.1","publint":"^0.3.21","tsup":"^8.5.0","typescript":"^6.0.3","typescript-eslint":"^8.62.1"},"gitHead":"df9fbd2ed6a8f97789701d2781d675cfe5e43e65","_id":"@bravobit/org-chart@1.0.2","_nodeVersion":"24.18.0","_npmVersion":"11.16.0","dist":{"integrity":"sha512-FPl3ldFH1H/MsAEFDmCPb1AvMnyht7ElkAJ0IBoJ0rn/rs8jb2sGsSamSyKa9mDpSgnKKip6xXSsIh177vtp1A==","shasum":"27b10ac0cd4f1c11bf204fa9bf04dcd4be0dc0b8","tarball":"https://registry.npmjs.org/@bravobit/org-chart/-/org-chart-1.0.2.tgz","fileCount":11,"unpackedSize":472146,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCpeNjjz1yU4XmgxdDAjCFYgs1zZEEIfbuI0+N15I9MYQIhANWd1ai3X3tYVZBMyktTyBS+Uz2xQus32GRed7WLPtWd"}]},"_npmUser":{"name":"stanvanheumen","email":"stanvanheumen@gmail.com"},"directories":{},"maintainers":[{"name":"stanvanheumen","email":"stanvanheumen@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/org-chart_1.0.2_1783598970999_0.5782553782552062"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-05T11:18:51.613Z","modified":"2026-07-09T12:09:31.220Z","1.0.0":"2026-07-05T11:18:51.860Z","1.0.1":"2026-07-06T18:19:01.337Z","1.0.2":"2026-07-09T12:09:31.124Z"},"bugs":{"url":"https://github.com/bravobit/bravobit-org-chart/issues"},"author":{"name":"Stan van Heumen"},"license":"SEE LICENSE IN LICENSE","homepage":"https://github.com/bravobit/bravobit-org-chart#readme","keywords":["org-chart","organization-chart","ownership-structure","ubo","hierarchy","diagram","svg","typescript"],"repository":{"type":"git","url":"git+https://github.com/bravobit/bravobit-org-chart.git"},"description":"Renderer-driven organization/ownership chart library with an injectable DOM adapter. Zero runtime dependencies, fully tree-shakable.","maintainers":[{"name":"stanvanheumen","email":"stanvanheumen@gmail.com"}],"readme":"# @bravobit/org-chart\n\n[![npm version](https://img.shields.io/npm/v/%40bravobit%2Forg-chart)](https://www.npmjs.com/package/@bravobit/org-chart)\n[![types](https://img.shields.io/badge/types-TypeScript-blue)](#typed-node-data)\n[![license](https://img.shields.io/badge/license-commercial-red)](#license)\n\n**Dependency-free TypeScript library for visualizing ownership and corporate structures** — KvK / OpenCorporates / UBO style. Inverted layout (root at the bottom, owners above) or classic top-down, HTML nodes rendered exactly the way you want, and a mathematical guarantee that nodes never overlap and lines never cross them.\n\n- 🪶 **Zero runtime dependencies**, fully tree-shakable (`sideEffects: false`) — the pure layout engine bundles at ~7 KB\n- 🧩 **Bring your own nodes**: every node is your HTML, sized and rendered by your renderer\n- 🎨 **CSS-first styling**: one stylesheet, tuned with CSS variables — nothing is injected at runtime\n- 🖱️ **Interactive**: pan, wheel zoom, pinch zoom, hover highlighting, node & edge events\n- ⌨️ **Keyboard accessible**: tabbable nodes, arrow-key navigation, Enter/Space activation\n- 🖼️ **SVG export**: render any dataset to a standalone SVG string — server-side too\n- 🌐 **Runs anywhere**: all DOM access goes through an injectable adapter (browser, jsdom, headless)\n- 🔒 **Strictly typed**, generic over your node data\n\n> **Commercial license required.** This is proprietary software: using it requires a paid license from Bravobit B.V., and modification is not permitted. See [License](#license).\n\n---\n\n**Contents** — [Installation](#installation) · [Quick start](#quick-start) · [Data model](#data-model) · [Renderers](#renderers) · [Typed node data](#typed-node-data) · [Styling](#styling) · [Viewport & interaction](#viewport--interaction) · [Keyboard & accessibility](#keyboard--accessibility) · [Events](#events) · [Hover highlighting](#hover-highlighting) · [Managing data](#managing-data) · [Options](#options) · [DOM adapter](#dom-adapter) · [Headless layout](#headless-layout) · [SVG export](#svg-export) · [API reference](#api-reference) · [Guarantees](#layout-guarantees) · [License](#license)\n\n## Installation\n\n```bash\nnpm install @bravobit/org-chart\n```\n\n## Quick start\n\n```ts\nimport { OrgChart, browserDomAdapter } from '@bravobit/org-chart';\nimport '@bravobit/org-chart/styles.css';\n\n// 1. A renderer per node.type: you control size and markup.\nconst renderers = {\n  company: {\n    size: () => ({ width: 220, height: 64 }),\n    render: (node) => `<div class=\"card\">${node.data.label}</div>`,\n  },\n  person: {\n    size: () => ({ width: 180, height: 56 }),\n    render: (node) => `<div class=\"card card--person\">${node.data.label}</div>`,\n  },\n};\n\n// 2. Create the chart — renderers and a DOM adapter are always explicit.\nconst chart = new OrgChart(document.getElementById('chart')!, {\n  renderers,\n  dom: browserDomAdapter(),\n});\n\n// 3. Set data and render. Edges point from the owned entity UP to its owner.\nchart\n  .setData({\n    nodes: [\n      { id: 'bv', type: 'company', data: { label: 'Meridiaan B.V.' } },\n      { id: 'holding', type: 'company', data: { label: 'Holding B.V.' } },\n      { id: 'ubo', type: 'person', data: { label: 'A. de Groot' } },\n    ],\n    edges: [\n      { from: 'bv', to: 'holding', direction: 'up', label: '100%', arrow: 'from' },\n      { from: 'holding', to: 'ubo', direction: 'up', label: '100%', arrow: 'from' },\n    ],\n  })\n  .on('nodeClick', (node) => console.log('clicked', node.id))\n  .render({ fit: true });\n```\n\nThat's the whole integration: a container element, a stylesheet, renderers, and data. The root (`bv`) sits at the bottom; ownership builds upward.\n\n## Data model\n\n### Nodes\n\n```ts\ninterface OrgChartNode<TData = Record<string, unknown>> {\n  id: string;        // unique\n  type: string;      // selects the renderer — required, there is no default\n  data?: TData;      // everything your renderer displays\n  floating?: boolean; // render detached from the tree (parked at the side)\n  fx?: number;       // fixed position for a floating node\n  fy?: number;\n}\n```\n\nNodes carry **no dimensions** — sizes come from the renderer (`size()`), so data and presentation never disagree.\n\nFloating nodes are parked beside the chart (or pinned at `fx`/`fy`) — but they are full citizens: edges may claim owners or side branches on them, and that subtree is laid out and parked **with** them as one unit. The park starts level with the root and stacks as a column by default; set `parkDirection: 'horizontal'` to line floating nodes up in a row instead.\n\n### Edges\n\n```ts\ninterface OrgChartEdge {\n  from: string;\n  to: string;\n  direction?: 'up' | 'left' | 'right' | 'down'; // default: 'up'\n  label?: string;                               // e.g. '40%' or 'affiliated'\n  arrow?: 'none' | 'to' | 'from' | 'both';\n  dashed?: boolean;                             // default depends on edge kind\n}\n```\n\nThe `direction` describes where the **target** ends up relative to the source:\n\n| direction | Meaning | Placement |\n|---|---|---|\n| `up` | ownership/hierarchy | target above the source, in the tree |\n| `right` / `left` | affiliated branch | target beside the source, carrying its own full subtree |\n| `down` | subsidiary branch | target below the source |\n\nDeclaration order matters: the **first** structural claim on a node wins. Any additional edge to an already-claimed node becomes a *cross link* — drawn as a dashed curve, without structural influence on the layout.\n\n### Orientation\n\nBy default the hierarchy grows **upward** (root at the bottom — the ownership/UBO reading). Set `orientation: 'down'` for a classic org chart with the root at the top:\n\n```ts\nnew OrgChart(el, { renderers, dom, orientation: 'down' });\nchart.setOptions({ orientation: 'up' }).render({ fit: true }); // switch at runtime\n```\n\nDirection names are **semantic, not visual**: they are defined in the canonical `'up'` space, so `direction: 'up'` always means *toward the owner/parent* — in `'down'` orientation that relation simply renders below. The same dataset therefore works unchanged in both orientations; orientation is pure presentation. Internally `'down'` is an exact y-mirror of the computed geometry, so all layout guarantees carry over by construction.\n\n## Renderers\n\nA renderer answers two questions per `node.type`: *how big is this node?* and *what does it look like?*\n\n```ts\ninterface NodeRenderer<TData> {\n  size(node: OrgChartNode<TData>): { width: number; height: number };\n  render(node: PlacedNode<TData>, dom: DomAdapter): DomElement | string;\n}\n```\n\n`render` may return an HTML **string** or an **element**. Sizes may depend on content:\n\n```ts\nconst company: NodeRenderer = {\n  // wider card for longer names\n  size: (node) => ({ width: 140 + String(node.data?.label ?? '').length * 6, height: 64 }),\n  render: (node) => {\n    const el = document.createElement('div');\n    el.className = 'card card--company';\n    el.textContent = String(node.data.label);\n    el.addEventListener('pointerup', () => console.log('inner interactivity works too'));\n    return el;\n  },\n};\n```\n\nEvery `node.type` in your data **must** have a renderer; a missing one throws a clear error. Nodes are placed as `<foreignObject>` elements, so your HTML/CSS works unmodified inside the SVG.\n\n> **Stay inside the box:** a `foreignObject` clips its content at exactly the size your renderer reports — browsers apply `overflow: hidden` per the SVG spec, and hit-testing outside the bounds is unreliable even where painting works. Design badges, buttons, and focus effects *within* the reported width/height (the library's own focus ring draws inward for the same reason). If something must stick out, make `size()` include that headroom.\n\n> **Security note:** a renderer returning a *string* is inserted as-is (`innerHTML`). When `node.data` contains untrusted input (imported registries, user uploads), build an element and set text via `textContent`/`dom.setText` instead of interpolating into a string.\n\n## Typed node data\n\nAll node-facing APIs are generic over the shape of `node.data`:\n\n```ts\ninterface CompanyData { label: string; kvk?: string }\n\nconst companyRenderer: NodeRenderer<CompanyData> = {\n  size: () => ({ width: 220, height: 64 }),\n  render: (node) => `<div>${node.data.label}</div>`,   // node.data is CompanyData\n};\n\nconst chart = new OrgChart<CompanyData>(el, {\n  renderers: { company: companyRenderer },\n  dom: browserDomAdapter(),\n});\n\nchart.on('nodeClick', (node) => console.log(node.data.kvk)); // typed\n```\n\nUntyped usage keeps working — `TData` defaults to `Record<string, unknown>`.\n\n## Styling\n\nImport the structural stylesheet once (canvas behavior, edge lines, node container — nothing more):\n\n```ts\nimport '@bravobit/org-chart/styles.css';\n```\n\nNode appearance is 100% yours, via your renderers and CSS. Edge and fade styling is tuned with CSS variables on the container:\n\n```css\n.ogc {\n  --ogc-edge: #55637a;          /* edge stroke color */\n  --ogc-edge-label: #1d2c44;    /* label text color */\n  --ogc-edge-label-bg: #ffffff; /* label halo (keeps text readable over lines) */\n  --ogc-fade-opacity: 0.25;     /* opacity of faded elements on hover */\n  --ogc-fade-duration: 150ms;   /* fade transition duration */\n  --ogc-focus: #4c6fff;         /* keyboard focus ring color */\n}\n```\n\nUseful class hooks:\n\n| Class | On |\n|---|---|\n| `.ogc` | the container you passed in |\n| `.ogc-node` | every node group (`.ogc-node--<type>` per type, `.ogc-node--floating` for floating nodes) |\n| `.ogc-node:hover` | hover styling for your cards |\n| `.ogc-edge`, `.ogc-edge--dashed` | edge paths |\n| `.ogc-edge-label` | edge labels |\n| `.ogc-edge-hit` | invisible wide hover/click target over every edge |\n| `.ogc--dim` | elements faded by hover highlighting |\n| `.ogc--panning` | on the svg while dragging |\n\n## Viewport & interaction\n\nOut of the box: **drag to pan**, **wheel to zoom**, **two-pointer pinch zoom** on touch, and a configurable **double-click** action.\n\n```ts\nchart.fit();        // scale & center the whole chart (optionally: fit(padding))\nchart.zoomIn();     // step zoom around the center\nchart.zoomOut();\n\nchart.render();               // re-render, PRESERVES current pan/zoom\nchart.render({ fit: true });  // re-render and fit\n```\n\n`render()` never moves your viewport uninvited — incremental updates (`addNode`, `removeNode`, `setData`) stay exactly where the user left the chart. Fit is always explicit.\n\n```ts\nnew OrgChart(el, { renderers, dom, dblClick: 'zoomIn' }); // 'fit' (default) | 'zoomIn' | 'none'\n```\n\nBy default the wheel always zooms, which captures page scroll above the chart. For charts embedded in a scrolling page, `zoom: { wheel: 'ctrlZoom' }` gives Figma/Maps-style behavior: the page scrolls normally, Ctrl/Cmd+wheel (and trackpad pinch) zooms. `'none'` disables wheel zoom entirely.\n\n## Keyboard & accessibility\n\nThe chart works without a pointer, out of the box:\n\n- The svg carries `role=\"group\"` and an accessible name (`ariaLabel` option).\n- Every node is a **tab stop**; the focus ring is styled via `--ogc-focus` (drawn with `:focus-visible`, so it only appears for keyboard users).\n- **Arrow keys** move focus to the spatially nearest node in that direction.\n- **Enter / Space** activates the focused node — `nodeClick` fires with the node's on-screen center as `clientX`/`clientY`, so positioned popovers keep working.\n- **`+` / `-` / `0`** zoom in, zoom out, and fit.\n- Focusing a node reports through `nodeHover` and applies the hover highlight, so tooltips and fading work for keyboard users too.\n\nArrow-key focus movement uses the optional `DomAdapter.focus` operation; custom adapters without it lose only that feature.\n\n## Events\n\nSubscribe with `on`, unsubscribe with `off`; both chain.\n\n| Event | Signature | Fires |\n|---|---|---|\n| `nodeClick` | `(node, ev)` | pointer press + release on a node without dragging, or Enter/Space on a focused node |\n| `nodeHover` | `(node \\| null, ev)` | hovered (or keyboard-focused) node changed; `null` when leaving all nodes |\n| `edgeClick` | `(edge, ev)` | click on an edge line or label |\n| `edgeHover` | `(edge \\| null, ev)` | hovered edge changed; `null` when leaving all edges |\n\nEdges are thin, so every edge gets an invisible ~14px-wide hit path (`.ogc-edge-hit`); lines and labels are comfortable hover/click targets. Edge handlers receive the `RoutedEdge` (from/to/label/kind), e.g. for percentage tooltips or relation detail panels. `ev` is structurally typed (`ChartPointerEvent`: `clientX`, `clientY`, `target`) — no casting needed for tooltip positioning.\n\n```ts\nconst onHover = (node, ev) => (node ? tooltip.show(node, ev) : tooltip.hide());\nchart.on('nodeHover', onHover);\n// later, e.g. on component unmount:\nchart.off('nodeHover', onHover);\n```\n\n`nodeHover` fires even when the visual hover effect is disabled, so tooltips work independently of the fade.\n\n## Hover highlighting\n\nHovering a node emphasizes the relevant part of the structure: the hovered node and everything \"connected\" stays fully visible, everything else — including edges that don't connect two visible nodes — fades out smoothly.\n\n```ts\n// default: on. Disable the fade (events keep firing):\nnew OrgChart(el, { renderers, dom, hover: { enabled: false } });\n```\n\nWhat counts as \"connected\" is yours to define. Two resolvers ship with the library — `highlightNeighbors` (default: the hovered node plus its direct neighbors) and `highlightSubtree` (the hovered node plus its entire structural subtree) — or you write your own:\n\n```ts\nimport { highlightSubtree } from '@bravobit/org-chart';\n\n// ready-made subtree variant:\nnew OrgChart(el, { renderers, dom: browserDomAdapter(), highlight: highlightSubtree });\n\n// or fully custom, e.g. only UBOs stay visible:\nnew OrgChart(el, {\n  renderers,\n  dom: browserDomAdapter(),\n  highlight: (node, layout) =>\n    [...layout.nodes.values()].filter((n) => n.type === 'person' || n.id === node.id).map((n) => n.id),\n});\n```\n\nThe resolver receives the hovered `PlacedNode` and the current `LayoutResult`, and returns the node ids to keep. The fade itself is pure CSS (`--ogc-fade-opacity`, `--ogc-fade-duration`).\n\n## Managing data\n\n```ts\nchart.setData(data);          // replace everything\nchart.getData();              // current data (as set)\n```\n\n> **Ownership:** the chart takes ownership of the object passed to `setData` — `addNode`/`removeNode` mutate its arrays in place, and `getData()` returns the same live object. Pass a copy (`structuredClone(data)`) if the original must stay untouched, e.g. when it lives in framework state.\n\n```ts\nchart.addNode(node, edge?);   // add a node, optionally with its connecting edge\nchart.removeNode('id');       // remove node + its entire subtree + all their edges\nchart.removeNode('id', { cascade: false }); // remove only the node itself\nchart.getLayout();            // computed positions, routed edges, bounding box\nchart.destroy();              // remove the svg, listeners, and container class\n```\n\nMutations don't render by themselves — call `render()` when you're ready:\n\n```ts\nchart\n  .addNode({ id: 'x1', type: 'person', data: { label: 'New UBO' } },\n           { from: 'holding', to: 'x1', direction: 'up', label: '50%' })\n  .render(); // keeps the current viewport\n```\n\n## Options\n\nAll options are optional and deep-merged over the defaults; `chart.setOptions(patch)` applies the same merge at runtime (call `render()` to apply):\n\n```ts\nnew OrgChart(el, {\n  renderers, dom,\n  gap: { sibling: 40, level: 80, side: 56, forest: 96, park: 120, parkStack: 24 },\n  edge: { arrow: 'none', attachDashed: true, crossDashed: true, elbowRadius: 8 },\n  zoom: { min: 0.1, max: 3, step: 1.2, wheelSpeed: 0.0015, wheel: 'zoom' },\n  fitPadding: 48,\n  fitMaxZoom: 1.25,\n  hover: { enabled: true },\n  dblClick: 'fit',\n  ariaLabel: 'Organization chart',\n});\n```\n\n| Option | Default | Description |\n|---|---|---|\n| `orientation` | `'up'` | which way the hierarchy grows (see [Orientation](#orientation)) |\n| `parkDirection` | `'vertical'` | how floating nodes stack in the park: a column toward the leaves (`'vertical'`) or a row away from the chart (`'horizontal'`); both start level with the root |\n| `gap.sibling` | `40` | horizontal gap between sibling subtrees |\n| `gap.level` | `80` | vertical gap between hierarchy levels |\n| `gap.side` | `56` | gap between a node and its left/right/down branch |\n| `gap.forest` | `96` | gap between separate trees (multiple roots) |\n| `gap.park` / `gap.parkStack` | `120` / `24` | gap between chart and park / between parked floating nodes |\n| `edge.arrow` | `'none'` | default arrowheads (`'to' \\| 'from' \\| 'both'`) |\n| `edge.attachDashed` / `edge.crossDashed` | `true` | dash side branches / cross links |\n| `edge.elbowRadius` | `8` | corner radius of orthogonal edges |\n| `zoom.min` / `zoom.max` / `zoom.step` / `zoom.wheelSpeed` | `0.1 / 3 / 1.2 / 0.0015` | zoom behavior |\n| `zoom.wheel` | `'zoom'` | wheel behavior: `'zoom' \\| 'ctrlZoom' \\| 'none'` (see [Viewport](#viewport--interaction)) |\n| `fitPadding` | `48` | padding used by `fit()` |\n| `fitMaxZoom` | `1.25` | upper bound on the scale `fit()` may choose |\n| `hover.enabled` | `true` | fade unrelated nodes on hover |\n| `dblClick` | `'fit'` | double-click action (`'fit' \\| 'zoomIn' \\| 'none'`) |\n| `ariaLabel` | `'Organization chart'` | accessible name of the chart |\n| `measureLabel` | – | optional pure `(label) => { width, height }` for exact edge-label metrics when you restyle the label font |\n\n## DOM adapter\n\nThe library never touches browser globals. Every DOM operation goes through a `DomAdapter` — a flat set of operations over opaque element handles, inspired by Angular's Renderer2 — which you provide explicitly:\n\n```ts\nimport { OrgChart, browserDomAdapter, type DomAdapter } from '@bravobit/org-chart';\n\n// In the browser:\nnew OrgChart(el, { renderers, dom: browserDomAdapter() });\n\n// Against another document (iframe, jsdom):\nnew OrgChart(host, { renderers, dom: browserDomAdapter(iframe.contentDocument) });\n\n// Or fully custom (tests, instrumentation, other runtimes):\nconst myAdapter: DomAdapter = { createElement, createSvgElement, appendChild, /* … */ };\nnew OrgChart(host, { renderers, dom: myAdapter });\n```\n\nAll operations are required except `focus`, which powers arrow-key focus movement; adapters without it lose only that feature.\n\nBecause the adapter is a required, explicit dependency, the browser implementation never sneaks into bundles that don't use it.\n\n## Headless layout\n\nThe layout engine is pure — no DOM required. Use it server-side or in tests via `computeLayout`:\n\n```ts\nimport { computeLayout, DEFAULTS } from '@bravobit/org-chart';\n\nconst layout = computeLayout(\n  { nodes, edges },\n  (node) => ({ width: 200, height: 60 }), // size resolver\n  DEFAULTS,\n);\n\nlayout.nodes;  // Map<string, PlacedNode> — final x/y/w/h per node\nlayout.edges;  // RoutedEdge[] — ready-made SVG path per edge\nlayout.bbox;   // overall bounding box\nlayout.roots;  // detected root ids\n```\n\nImporting only `computeLayout` tree-shakes the entire render/DOM layer away (~7 KB minified).\n\n## SVG export\n\n`renderToSvgString` renders a dataset to a **standalone SVG string** — same layout engine, same markup as the live chart, but pure: no DOM, no browser. Use it server-side (reports, thumbnails, e-mail) or client-side (download button, printing):\n\n```ts\nimport { renderToSvgString } from '@bravobit/org-chart';\n\nconst svg = renderToSvgString(data, {\n  renderers,\n  padding: 32,                       // whitespace around the chart (default 24)\n  css: '.card { background: #fff }', // embedded so the file is self-contained\n  gap: { side: 80 },                 // all layout options apply\n});\n\n// e.g. offer as a download:\nconst url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));\n```\n\nNotes:\n\n- The structural edge/label styles are embedded with `var(--…, fallback)`, so the file renders standalone **and** picks up your `.ogc` theme variables when inlined in a page.\n- Your node HTML is included as-is; pass its CSS via `css` (or use inline styles in the renderer) to make the file self-contained.\n- Renderers must create elements via the provided `dom` adapter (or return HTML strings) — `document.createElement` throws a clear error here, since there is no document.\n- Output is deterministic: same input → identical string (snapshot-test friendly). Interactive artifacts (hit paths, `tabindex`) are omitted; the root carries `role=\"img\"`.\n\n## API reference\n\n### `new OrgChart<TData>(container, config)`\n\n| `config` | Required | Description |\n|---|---|---|\n| `renderers` | ✅ | `Record<string, NodeRenderer<TData>>` — one per `node.type` |\n| `dom` | ✅ | `DomAdapter` — e.g. `browserDomAdapter()` |\n| `highlight` | – | `HighlightResolver<TData>` — hover keep-set, default `highlightNeighbors` |\n| *…options* | – | everything from [Options](#options) |\n\n### Methods\n\n| Method | Returns | Description |\n|---|---|---|\n| `setData(data)` / `getData()` | `this` / data | replace / read the dataset |\n| `setOptions(patch)` | `this` | deep-merge options at runtime (e.g. `orientation`); apply with `render()` |\n| `addNode(node, edge?)` | `this` | add a node (+ optional connecting edge) |\n| `removeNode(id, {cascade?})` | `this` | remove node; cascade (default) removes its subtree |\n| `render({fit?})` | `this` | (re)render; preserves pan/zoom unless `fit: true` |\n| `fit(padding?)` | `this` | scale & center the chart in the container |\n| `zoomIn()` / `zoomOut()` | `this` | step zoom around the center |\n| `on(event, fn)` / `off(event, fn)` | `this` | subscribe / unsubscribe (`nodeClick`, `nodeHover`, `edgeClick`, `edgeHover`) |\n| `getLayout()` | `LayoutResult<TData> \\| null` | positions, routed edges, bbox |\n| `destroy()` | `void` | remove svg, unbind listeners, clean the container |\n\n### Standalone exports\n\n| Export | Description |\n|---|---|\n| `browserDomAdapter(doc?)` | `DomAdapter` backed by the real DOM |\n| `computeLayout(data, sizeOf, opts)` | pure layout engine (headless) |\n| `renderToSvgString(data, config)` | standalone SVG export (headless) |\n| `highlightNeighbors` / `highlightSubtree` | ready-made hover resolvers |\n| `DEFAULTS` | the default options (e.g. as `opts` for `computeLayout`) |\n\nAll public types are exported: `OrgChartData`, `OrgChartNode`, `OrgChartEdge`, `PlacedNode`, `RoutedEdge`, `LayoutResult`, `NodeRenderer`, `RendererMap`, `DomAdapter`, `DomElement`, `HighlightResolver`, `OrgChartEvents`, `OrgChartOptions`, `ChartPointerEvent`, `SvgExportConfig`, and more.\n\n## Layout guarantees\n\nPlacement uses recursive bounding boxes: **placed nodes mathematically never overlap**. Edges never cross nodes either — child edges run through a \"bus\" strip that is empty by construction, down branches enter via a reserved corridor, and side branches that cannot be reached in a straight line (2nd+ branches on one side, or branches whose root carries sub-branches toward the anchor) are reached via routing lanes below the boxes in the way. Overlapping edge labels in dense areas are automatically nudged apart.\n\nMalformed input degrades gracefully: cyclic claims (e.g. `a→b` as a branch and `b→a` as its owner) are detected across the whole claim graph — up-parents and side claims combined — and the cycle-closing edge is demoted to a dashed cross link, so every component keeps a root and the overlap guarantee holds.\n\nA crossing detector in the test suite verifies all of this — with exact segment/rect geometry, not sampling — across ~900 fuzzed structures (0 overlaps, 0 crossings), including 500 fully random graphs with arbitrary cycles.\n\nKnown limitations:\n\n- Box spacing is conservative in exchange for the overlap guarantee.\n- Cross links may run across nodes (deliberately low priority).\n- No position animation on re-layout.\n\n## Development\n\nRequires Node ≥ 22. Tests use the built-in `node:test` runner (zero extra dependencies) and run against the **built** `dist/` output, so they verify the artifact consumers actually get.\n\n```bash\nnpm install\nnpm run build          # tsup → dist/ (ESM + CJS + d.ts + org-chart.css)\nnpm test               # build + typecheck + lint + all suites\nnpm run lint           # eslint (typescript-eslint, type-checked rules)\nnpm run test:unit      # suites only, against the existing dist/ (fast loop)\nnpm run test:watch     # re-run on change\nnpm run test:coverage  # per-TS-file coverage via source maps, with thresholds\nnpm run check:package  # publint + arethetypeswrong (exports/types health)\n\n# focus on one suite or test by name:\nnode --test --test-name-pattern=\"cycles\" \"tests/*.test.mjs\"\n\n# interactive demo (consumes the built dist/):\nnpm run build && npx serve .   # → open http://localhost:3000/demo/\n```\n\nReleases follow [Keep a Changelog](https://keepachangelog.com/): user-visible changes are added to the **Unreleased** section of [CHANGELOG.md](./CHANGELOG.md) as part of the change itself, and move into a versioned, dated section when publishing.\n\n## License\n\nProprietary — see [LICENSE](./LICENSE). Installing this package does **not** grant a right to use it: usage requires a paid commercial license agreement with Bravobit B.V., and modifying the software or creating derivative works is not permitted. A 14-day non-production evaluation is allowed. For licensing inquiries, open an issue at [bravobit/bravobit-org-chart](https://github.com/bravobit/bravobit-org-chart/issues).\n","readmeFilename":"README.md"}