Skip to content

TheAccessibleOrgChart — Multiple-Chart Support Design

Design date: 2026-07-13. No code changes — this is a design/best-practice proposal for how the product should support multiple org charts. Grounded in a review of the current data model (packages/org-chart-shared/src/types.ts), the import/generate pipeline (workers/api/src/routes/org-chart/), and the render-from-managerId templates. Follows on from docs/admin/org-chart-competitive-analysis.md (Phase 3/4 work).

Problem

We want the product to support “multiple charts” from three requested angles:

  1. Parse spreadsheets with multiple tabs — should each tab become its own chart?
  2. Let a user designate any node as a root and create a new chart from it.
  3. Embed charts inside other charts.

Answered naively, each of these produces duplicated, drifting data — the exact weakness of today’s single-owner, one-dataset-per-chart model. This doc recommends a single architectural move that makes all three easy and safe.

Current model (verified in code)

  • OrgChart conflates three concerns: the dataset (people + relationships), the presentation (template, density, colors, branding), and the commercial unit (one Stripe finalization + one 1-year hosting window + one owner).
  • Person.managerId points to another person in the same chart. The tree is derived at render time from managerId — it is not a stored, materialized hierarchy.
  • Versioning, sharing, hosting, and payment are all per-OrgChart.

The render-from-edges fact is the unlock: “a different chart” is usually just “a different starting node and filter over the same edges.”

Core recommendation: separate the org from the chart

Split today’s OrgChart into two layers.

LayerWhat it isCardinality
Org (dataset)Canonical people + reporting/relationship edges. Source of truth.One per real organization
Chart (view){ orgId, rootPersonId?, maxDepth?, filter, templateId, density, colors, branding } — a saved lens onto the org. Cheap, live, derived.Many per org

Guiding invariant: people/edges are stored once, per org. A chart is a view, never a copy. The failure mode to avoid at all costs is duplicating people into N chart rows so one title change must be made in five places.

This is additive to the existing model — OrgChart today already holds the dataset; the change introduces a lightweight “view” record (root + filter + presentation) that references it, and lets one org own many views.

Current import surface (verified in code)

Before designing multi-tab support, note what exists today:

  • Accepted inputs: image (png/jpeg/bmp) and pdf → AI vision extraction; csv → structured import; manual editor; developer REST API (POST /api/v1/charts/generate).
  • Spreadsheets: CSV only. There is no .xlsx/.xls support. The API MIME allowlist (packages/org-chart-shared/src/constants.ts:15) is application/pdf + image/png|jpeg|bmp only; CSV is a separate text path. The upload accept filter is image/*,application/pdf,.csv,text/csv.
  • Structure is a hybrid — a downloadable CSV template with canonical headers (name,title,department,email,phone,location,employeeId,managerName,secondaryManagerNames,photoUrl,linkUrl, editor/page.tsx:1191) plus forgiving fuzzy column matching (workers/api/src/utils/csv-parser.ts: STANDARD_FIELDS + FIELD_MAP + Levenshtein) that maps aliases like fullname→name, jobtitle/position/role→title, dept/division→department, reportsto/supervisor→manager, dottedline/additionalmanagers→secondaryManagers.
  • Hierarchy is by manager name reference (each row names its manager, matched to another row’s name) — not by ID, parent column, or indentation. Only title is strictly required; name is optional (position-only charts).
  • Known bug (Critical #4, competitive analysis): export joins secondary managers with ; but the parser splits on ,, so dotted lines silently vanish and CSVs don’t round-trip.

Format & library decision

No spreadsheet library exists in the monorepo today — this is a greenfield choice, and the library determines whether legacy .xls is nearly free or a real burden.

  • Support .xlsx (+ existing .csv). .xls is not required. .xls (binary BIFF8) has been deprecated since Excel 2007; Excel, Google Sheets, and every modern HRIS (Workday, BambooHR, Rippling) export .xlsx/.csv. XML-based .xlsx (a ZIP of OOXML) is far easier and safer to parse than the proprietary, more CVE-prone binary .xls.
  • Use SheetJS (xlsx). One API reads .xlsx, .xls, and .ods through the same code path — so .xls/.ods long-tail support (relevant for the gov/edu buyer running legacy or LibreOffice systems) is essentially free. Accept .xls passively (parse if it arrives) but market only .xlsx/.csv.
  • Fallback if SheetJS is unacceptable: ExcelJS is XML-only (.xlsx + .csv, cleaner API) but reads no .xls. In that case ship .xlsx-only and show a “Save as .xlsx or .csv and re-upload” message on .xls — do not build a bespoke binary .xls parser.
  • Runtime: parse spreadsheets server-side on the Node API (.4), not at the edge. SheetJS’s community build is heavy CJS; if any import path ever runs in a Cloudflare Worker, verify the bundle there separately.
  • Security (any format): read cell values only; ignore macros (.xlsm/.xls can carry them); apply the same formula-injection sanitization flagged for CSV export; cap file size in the allowlist change.

1. Multi-tab spreadsheets

Hard prerequisite: XLSX ingestion does not exist yet. We accept CSV only, and CSV is single-sheet by definition — it cannot carry tabs. This feature therefore starts with adding real spreadsheet parsing (SheetJS/xlsx — see the Format & library decision above) to both the accept filter and the API MIME allowlist, not merely a parser tweak. Sequence this ahead of any tab-handling logic.

Do not hardcode “one tab = one chart.” Tabs are used inconsistently in the wild — sometimes a tab is a department, sometimes a lookup/legend table, sometimes a prior-year snapshot.

  • Make import a mapping step with a preview (we need this UI regardless — today’s fuzzy matching guesses silently and import errors just say “check console”). The preview should show the detected column→field mapping and let the user correct it. Offer: combine all tabs into one org (tab name → department/group column), create a separate chart per tab, or ignore selected tabs.
  • Heuristic default: tabs with matching column schema → propose “one org, tab = department”; differing schemas → propose “separate charts.” Always confirm; never silently create N charts.
  • When combined, the tab name becomes a filter dimension that feeds per-department views (§2) — one import, many charts, no duplication.
  • Fix the ;/, delimiter bug and consider ID-based hierarchy (employeeId + managerId) as an unambiguous alternative to name-matching while touching the import path — name-matching breaks on duplicate names, which is common at large-org scale.

2. Designate any node as a new root

Yes — as a lightweight view, not a data fork. This is the ChartHop/Workday “focus on this person’s org” pattern; render-from-managerId supports it for free by starting traversal at rootPersonId.

  • Default action — “Create view from here”: stores { orgId, rootPersonId, maxDepth?, template }. Live: when the org changes, every departmental view updates. This is how you get a dozen departmental charts from one university import.
  • Explicit, rare action — “Fork to independent chart”: copies the subtree into a new dataset. Reserve for what-if / scenario / archival snapshot, and label it clearly as a point-in-time copy that will drift.

Enforce the distinction in the UI: re-root is cheap and live; fork is heavy and diverges. The live view is the obvious default.

3. Embed charts inside other charts

“Embed” means three different things — keep them separate:

  • (a) Composition / transclusion (the valuable one). A node in the master org references the root of another org maintained by a different team (central comms owns the university-wide chart; each college maintains its own and “plugs in” under its VP). A reference node transcludes another org’s subtree, resolved at render into one coherent tree. Genuine Institution-tier differentiator: distributed maintenance, one published artifact.
  • (b) Drill-down / lazy-load (a scale technique). Expanding a node loads that department’s subtree on demand instead of rendering 20,000 nodes at once. Worth doing for large orgs regardless.
  • (c) Visual iframe embed. Already exists for external pages. Nesting one iframe inside another is the pattern to avoid.

Hard accessibility guardrail (this is the moat): compose into a single, coherent ARIA tree with one keyboard/focus model. Never nest iframes-within-iframes or stitch multiple independent ARIA trees — it breaks arrow-key nav and screen-reader announcement across the boundary. Transclusion must flatten into one tree at render time. An accessibility product cannot ship a chart that fails its own standard.

Cross-cutting decisions (decide deliberately, not after)

  • Monetization: payment/hosting is per-OrgChart today. If charts become cheap views, choose whether to charge per published view or per org. Lines up with the Phase 3 pricing plan: Individual = 1 org / few charts; Department/Institution = unlimited orgs, views, and composition.
  • Versioning & sharing grain: currently per-chart. With the org/view split, prefer versioning the org (the data) and treating views as disposable configs. Decide before building.
  • Accessibility test coverage: every new render path (re-rooted view, transcluded tree, lazy-loaded subtree) must be added to test:a11y axe fixtures — same discipline as Phase 2.
  1. XLSX ingestion (prerequisite for multi-tab) — add SheetJS (xlsx) parsing on the Node API for .xlsx (+ free .xls/.ods); wire into the accept filter and API MIME allowlist; read values only + sanitize + size-cap. Fix the ;/, secondary-manager delimiter bug and consider ID-based hierarchy in the same pass. Without this, multi-tab is impossible (CSV is single-sheet). See “Format & library decision” in §1.
  2. Import mapping/preview UI + multi-tab handling (also fixes the silent fuzzy-guess + “check console” error UX). Standalone value; no schema change required if tabs collapse into the existing single dataset with a department/group column.
  3. Org/View split — introduce the view record (rootPersonId + filter + presentation) referencing the existing dataset; “Create view from here” as the default multi-chart path. Migration required.
  4. Fork-to-snapshot as an explicit, clearly-labeled action.
  5. Transclusion / composition (reference nodes) — Institution-tier feature; depends on the org/view split and single-tree render guarantee.
  6. Lazy-load subtrees for large-org scale, as needed.

Steps 2–4 pair naturally with Phase 3 (tiering/workspaces) from the competitive-analysis plan — the same migration/entitlement surface.

Bottom line

Don’t build “multiple charts” as multiple datasets. Build one org → many live views (re-root + filter), add a mapping step for multi-tab import that feeds those views, support transclusion for cross-team composition, and keep copy/fork as a rare, clearly-labeled snapshot — all rendering into a single accessible tree. This delivers every requested capability without the data-drift trap and reinforces the compliance moat rather than fracturing it.