# PivoGraph project file format (`.pivograph`) - schema version 2

Status: **Current.** Schema version 2 was introduced in PivoGraph 1.6 and supersedes version 1. Version 1 files keep opening: they are migrated automatically when opened (see "What changed since version 1"). PivoGraph's compatibility policy is to keep published older versions openable through the built-in migration chain (see "Versioning").

This document describes the file that PivoGraph writes with **File -> Save Project...** and reads with **File -> Open Project...** or by drag-and-drop. It also defines how external tools and AI agents can create projects that open for further editing in PivoGraph.

- Machine-readable schema (JSON Schema draft-07): `https://pivograph.com/schemas/project/v2.json`
- Version catalog: `https://pivograph.com/schemas/project/index.json`
- Examples (machine-readable list; one file per table family plus a complete project): `https://pivograph.com/schemas/project/v2/examples/index.json`
- This page as Markdown: `https://pivograph.com/developers/project-format/v2.md`
- Previous version: `https://pivograph.com/developers/project-format/v1/`

The JSON Schema is generated from the same validator the application uses, so it is authoritative for field names, enumerations and limits. This page explains the semantics that a schema cannot express.

## What changed since version 1

Version 2 collects every change to the document structure and to rendering defaults made for PivoGraph 1.6. A version 1 document is still a valid starting point: everything below either adds optional fields or is converted on open.

- **Rich text.** Every text field that is drawn on the graph - graph / X / Y titles, free text, the label of a significance bracket, text bound to a drawing shape, and custom legend labels - is now a *rich text value*: either a plain string or `{ "runs": [...] }` with per-run bold / italic / superscript / subscript. Plain strings are taken **literally**; the `^{...}` / `_{...}` markup of version 1 is no longer interpreted. See "Rich text values".
- **Title rotation** (`axesOverrides.titlesAndFonts.<title>.rotation`) is a number in degrees (clockwise positive) instead of the names `horizontal` / `verticalUp` / `verticalDown`.
- **Line spacing.** Multi-line free text, titles and shape-bound text carry an optional `lineSpacing` (shapes: `textLineSpacing`) in points.
- **Category label weight.** `axesOverrides.xCategorical.labelBold` switches the bold category labels of column-type graphs off.
- **Text distance of annotation lines.** The default `textGap` of shape-bound text is 3 points (version 1 drew it at 1.5).
- **Exploded pie / donut slices.** New optional element `partsExplode` (off by default) in `elementActivation`, keyed per slice by the slice's row index; the offset lives in the `bar` style as `explodeOffsetPt`.
- **Fitted-curve draw order.** The `curve` style carries `drawOrder`: `underneath` (default; the curve is drawn behind the data points) or `onTop`.
- **Stacked-bar error bars.** `grouped-stacked-bars` accepts the optional `errorBar` element (on by default; each segment's error bar is centred on the segment's outer edge, extends to both sides and is drawn on top of all segments).
- **Significance bracket formatting.** `bracket` annotations accept the same line and text formatting as text-bearing shapes: `dashStyle`, `lineColor`, `fontFamily`, base `bold` / `italic`, `textRotation`, `textLineSpacing` and `textGap` (default 3, the same as annotation lines) join the existing `lineWidth`, `fontSize` and `textColor`. Brackets did not store a text distance in version 1 and drew it at 1.5 points; old brackets are *not* migrated to keep that value, so their label sits 1.5 points higher than before.
- **Summary symbols on scatter plots.** New optional element `summarySymbol` (off by default) on the scatter variants of Column, Grouped and Nested graphs (`column-scatter-only`, `grouped-interleaved-scatter`, `grouped-separated-scatter`, `grouped-superimposed-scatter`, `nested-scatter`): a second symbol drawn at each group's mean / median on top of the individual points and error bars, styled by the `summarySymbol` entry of `styleOverrides` (same fields as `symbol`; default a white-filled bordered circle of size 6 whose border follows the data set colour). On the same variants the summary line (`summaryLine`) became an optional element that is on by default, so it can be switched off.
- **More heat map colour scales.** `plot.options.heatMap.colormap` also accepts `viridis`, `magma`, `cividis` (perceptually uniform sequential scales) and `purpleOrange` (diverging purple - white - orange). Unlike `sequential` and `diverging`, these scales do not follow the theme.
- **Heat map colour bar length.** The default `colorScaleBar.lengthRatio` is 1 (the full plot height); version 1 drew it at 0.6, and heat maps from version 1 files keep that value.
- **Edge spacing of bar layouts.** `graphSettingsOverride.firstGapRatio` / `lastGapRatio` are `"auto"` (default) or a number ≥ 0: the clear distance from the plot edge to the outer edge of the first / last bar, as a fraction of the bar slot (the row slot on grouped graphs), so `0` puts the bar flush with the edge while the spacing between bars is kept. `"auto"` leaves half the space between bars, as before. In version 1 the numbers were extra padding added on top of that half space; version 1 values are converted on open (`0` becomes `"auto"`, larger values keep their visible distance).
- **Before-after in grouped rows.** `grouped-rm-across-row` defaults to `plot.options.seriesAlign: "staggered"`, so the data sets of a row sit side by side and each subject's values (same sub-column) are joined across the row. Its connecting lines follow the graph-level `line` style (colour, thickness, dash); the connecting lines of `grouped-rm-in-subcol` now also follow the `line` dash style. In version 1 the across-row default was `"aligned"` and its lines were always solid dark grey. Version 1 across-row graphs get `seriesAlign: "aligned"` when it is not set, and a global `line` colour of `#3f3f46` unless a concrete line colour is already set globally or for the first plotted data set. Line colours and dash styles that were set explicitly in version 1, and that these connecting lines ignored, apply from version 2.
- **Brackets on grouped graphs.** A bracket anchor may carry `rowIndex` to snap to one data set in one row of a grouped graph (see "Annotations and titles").
- **Per-row t-tests.** New analysis method `rowTTests` for grouped tables (see "Analyses").

**Migration of version 1 files.** The migration runs in memory when a version 1 file is opened and makes the explicit choices that keep the old appearance: markup in free text, titles, shape text and legend labels becomes runs (an unclosed `^{` or `_{` stays literal); bracket labels and data set names are never parsed; rotation names become degrees; texts that already contain a line break get `lineSpacing: 0`; shape-bound text without an explicit `textGap` gets `textGap: 1.5`; tables that carry a `linearRegression` / `nonlinearRegression` analysis get `styleOverrides.global.curve.drawOrder: "onTop"`; heat maps get `styleOverrides.global.colorScaleBar.lengthRatio: 0.6`; numeric `firstGapRatio` / `lastGapRatio` are converted to the new clear-distance meaning; `grouped-rm-across-row` graphs get `seriesAlign: "aligned"` and the former dark-grey line colour. The only intended visible difference is the label of significance brackets without an explicit `textGap`, which moves up by 1.5 points. The original file is never rewritten; saving writes a version 2 document.

## 1. File basics

- Plain **UTF-8 JSON**, no byte-order mark (a leading BOM is tolerated when reading). Extension `.pivograph`; MIME type `application/json`. Where a tool needs the JSON language recognised from the file name, `.pivograph.json` is also accepted when opening.
- The whole document is one JSON object. **Unknown keys are rejected** at every level with the JSON path of the offending key. Tool-specific data belongs in `usermeta` (document level or per table).
- Everything in the file is data. There is no executable content, no external references are fetched when a file is opened, and the application never downloads the `$schema` URL.
- **Write complete, read tolerant.** PivoGraph writes resolved document state explicitly so that saved settings do not silently inherit future defaults. Hand-written or generated documents may omit any optional field - and may give any nested object *partially*; the application fills its current defaults. Optional fields are expressed by **omitting the key**; `null` is only valid where the field's type includes it (missing cells, unmapped variable roles, automatic geometry).
- Opening reports two kinds of problems: **errors** (the file is not opened; the current project is untouched) and **warnings** (the file opens, but the listed parts were ignored or replaced by defaults, for example a style override keyed to a data set that does not exist). Both carry JSON paths.

## 2. Envelope

```json
{
  "$schema": "https://pivograph.com/schemas/project/v2.json",
  "format": "pivograph-project",
  "schemaVersion": 2,
  "generator": { "name": "PivoGraph", "version": "1.6.0", "url": "https://pivograph.com" },
  "createdAt": "2026-09-06T10:00:00.000Z",
  "modifiedAt": "2026-09-06T12:00:00.000Z",
  "meta": { "title": "Dose response" },
  "usermeta": {},
  "view": { "activeTableId": "dose", "workspaceView": "graph" },
  "tables": []
}
```

- `format` (required): always `"pivograph-project"`. Together with `schemaVersion` this is how a reader recognises the file; `$schema` is informational.
- `schemaVersion` (required): integer. See "Versioning".
- `tables` (required): array of tables in sidebar order (maximum 200). A minimal valid document is exactly `format` + `schemaVersion` + `tables`.
- `generator` (optional): the program that wrote the file (`name`, `version`, optional `url`). Purely informational; PivoGraph always writes it.
- `createdAt` / `modifiedAt` (optional): ISO 8601 timestamps with timezone. PivoGraph keeps `createdAt` from the first save and rewrites `modifiedAt`.
- `meta` (optional): `title` (required inside `meta`, may be empty), optional `description`, `authors[]`, `license`, `source`, `keywords[]`. `license` and `source` are meant for example / template projects.
- `usermeta` (optional): a JSON object for tool-specific data. Its JSON value is preserved across save and reopen, never interpreted, and limited to 64 KB when serialised; whitespace and object formatting are not preserved.
- `view` (optional): which table is active (`activeTableId`, must be an explicit table id) and whether the editor opens in `"table"` or `"graph"` view. This is the only presentation state in the file.

## 3. Tables

Each table is one data table of one of the eight families:

`xy`, `column`, `grouped`, `nested`, `contingency`, `survival`, `partsOfWhole`, `multipleVariables`.

```json
{
  "id": "dose",
  "name": "Dose response",
  "format": "xy",
  "replicates": 3,
  "x": [-9, -8, -7],
  "rowLabels": ["low", "mid", "high"],
  "dataSets": [
    { "name": "Control", "values": [[1.1, 1.2, null], [2.0, 2.1, 1.9], [5.0, 4.8, 5.1]] },
    { "name": "Treated", "values": [[0.9, 1.0, 1.1], [1.5, 1.6, 1.4], [2.9, 3.1, 3.0]] }
  ],
  "graph": { "variantId": "xy-points-error" },
  "analyses": [],
  "annotations": [],
  "titles": {},
  "usermeta": {}
}
```

- `format` (required): the family.
- `dataSets` (required unless `derivedFrom` is present; at least one): one entry per data set, corresponding to the table's data columns. `values[row][replicate]` holds finite numbers, `null` for missing, or text. Rows may be shorter than `replicates` (missing cells are filled with `null`). Data set names must be unique within a table.
  - Text is accepted only in `multipleVariables` tables (text categories and labels). In every other family a text cell is an error, except that numeric-looking strings such as `"1.5"` are read as numbers.
  - `name` is the stable key that styles, analyses and `variableTypes` refer to. `title` (optional) is the label shown on the graph and in panels; it may repeat across data sets. When omitted the label equals `name`; `null` means the data set has no label (the legend shows a placeholder and category axes stay blank). Data set names and titles are plain strings and are always drawn literally.
  - `subColumnTitles` (optional, `nested` tables only): one label per sub-column of this data set (`null` = none), aligned with `replicates`; these appear on the category axis.
- `replicateTitles` (optional, tables whose sub-columns are shared, e.g. `xy` / `grouped`): one label per sub-column (`null` = none), aligned with `replicates`. Spreadsheet headers only; not drawn on the graph.
- `id` (optional): any non-empty string (<= 128 characters), unique among tables; generated when omitted. Tables referenced by `derivedFrom` or `view.activeTableId` need an explicit id. Human-readable slugs are fine.
- `name` (optional): defaults to `Table N`.
- `replicates` (optional): number of sub-columns per data set. Fixed at 1 for `column`, `contingency`, `survival`, `partsOfWhole` and `multipleVariables`; >= 2 for `nested`; free for `xy` and `grouped`. When omitted it is inferred from the widest row (never below the family minimum).
- `x`: only for `xy` and `survival` - the shared X column, one entry per row, aligned with `values`. In survival tables X is time and the cells are event codes (1 = event, 0 = censored).
- `rowLabels`: optional row titles (categories for `grouped` / `contingency` / `partsOfWhole`, sample names for heat maps).
- `variableTypes`: `multipleVariables` only - explicit overrides of the inferred variable type (`continuous`, `categorical`, `label`) keyed by variable (data set) name.
- `columnWidths`: optional spreadsheet column widths; presentation only.
- `usermeta`: optional tool-specific data for this table; same rules as the document-level slot.
- `derivedFrom`: marks a **results table** produced by an analysis on another table: `{ "sourceTableId", "analysisId" }`. Such tables carry no `dataSets`, `x`, `rowLabels` or `variableTypes`; their content is recomputed when the file opens. The referenced analysis must exist on the source table (with an explicit id) and must be one that produces a results table (`frequencyDistribution`, `correlationMatrix`).
- Row count is the longest of `x`, `rowLabels` and any `values`; trailing empty rows are not stored.

## 4. Graph

`graph` is the graph document of the table (one graph per table). Only `variantId` is required; every other field, and every nested object inside them, may be given partially.

- `variantId`: a graph variant from the PivoGraph registry, for example `bar`, `column-box-whiskers`, `xy-points-line-error`, `grouped-heat-map`, `survival-staircase-ticks`, `parts-donut`, `mv-volcano`. The full list is the `GraphVariantId` definition in the JSON Schema. The variant must belong to the table's family and be available for the table's replicate count (for example `xy-points-error` needs `replicates > 1`).
- `plot`: statistical / topology options of the family (`{ "family": "...", "options": {...} }`); `family` must equal the table format. Options are merged over the defaults of the variant; a `mode` that the variant does not offer is replaced by the variant default with a warning. For `multipleVariables` tables `plot` is **required** because it carries the variable mapping: `plot.options.kind` must match the variant (`bubble` -> `mv-bubble`, `scatter` -> `mv-scatter`, `volcano` -> `mv-volcano`, `forest` -> `mv-forest`) and `plot.options.mapping` gives data set indexes per role (`x` required; `y`, `size`, `color`, `shape`, `label`, `lower`, `upper` as the plot kind requires; omitted roles are unmapped).
- `activePresetId`: built-in theme preset (`modern`, `classic`, `floral`, `starry`). `applyPresetNonColor`: whether the preset's non-colour constants are applied.
- `styleOverrides`: user overrides on top of the theme, in three layers - `global`, `perDataSet` (keyed by data set **name**, or a semantic key such as `volcano:up`) and `perPoint` (data set name -> point index). Each layer is a partial patch per element kind (`bar`, `symbol`, `errorBar`, `line`, ...). Colours are either `"theme"` (follow the preset) or `{ "hex": "#rrggbb", "alpha": 0.8 }`; hex colours have 3 or 6 digits and transparency always goes in `alpha`. Two fields worth knowing: `bar.explodeOffsetPt` (points, default 2) is the distance an exploded pie / donut slice moves away from the centre (pie slices reuse the `bar` style; `perPoint` is keyed by the slice's row index), and `curve.drawOrder` (`underneath` default / `onTop`) places a fitted curve and its confidence band behind or in front of the data points. `summarySymbol` carries the same fields as `symbol` and styles the optional mean / median symbol of scatter variants independently of the individual points.
- `graphSettingsOverride`: partial graph settings (bar width, gaps, baseline, line of identity, ...).
- `axesOverrides`: six partial buckets (`frameAndOrigin`, `xCategorical`, `xContinuous`, `leftY`, `rightY`, `titlesAndFonts`); `"auto"` means automatic where a number is otherwise expected. Nested objects (grid lines, tick appearance, number format, title font) may be partial. Title *text* is not part of the axes - see `titles`. Additional ticks / reference lines (`additionalTicks`) may carry `lineColor` and `lineThickness`; when omitted the line follows the axis colour and the major grid width.
  - `titlesAndFonts.graphTitle` / `xAxisTitle` / `yAxisTitle`: `font` (family, size, `bold`, `italic` - the *base* style of the title, see "Rich text values") and `rotation` in **degrees**, clockwise positive, normally a multiple of 45 (defaults: 0 for the graph and X titles, -90 for the Y title).
  - `xCategorical.labelBold` (boolean, default `true`): whether the category labels of column-type graphs are bold.
- `plotGeometry`: plot-area size in points (`null` or omitted = automatic).
- `dataSetVisibility`: data set **index** (as a string key) -> `false` to hide; missing = visible.
- `elementActivation`: optional-element switches in three scopes (`global`, `perDataSet` by index, `graph`). Which elements a variant offers is fixed by the registry (for example `errorBar` on `grouped-stacked-bars` - on by default - `summarySymbol` and `summaryLine` on the scatter variants - off / on by default - and `partsExplode` on `parts-pie` / `parts-donut` - off by default). For pie and donut graphs `perDataSet` is keyed by the **slice's row index**, so a single slice can be exploded. Switches for elements the variant does not offer are accepted and have no effect.
- `legend`: legend configuration including `itemOverrides` keyed by data set index or semantic id. `bold` / `italic` are the base style of the whole legend; an item's `label` is a rich text value (see below) that replaces the data set label.
- `partsOfWholeBorders`: outer / inner border of pie, donut and slice graphs.

Keys that refer to data sets by name and keys that refer to them by index coexist in version 2 (they mirror the application's model). Keys that match no data set are ignored with a warning. A future schema version may unify them; documents will be migrated automatically.

A graph that uses several of the version 2 fields - a Column scatter plot with a white mean symbol drawn over semi-transparent points, the summary line switched off, the first group flush with the left edge of the plot, regular-weight category labels and a Y title rotated to read upwards:

```json
{
  "variantId": "column-scatter-only",
  "plot": { "family": "column", "options": { "mode": "meanWithSd" } },
  "styleOverrides": {
    "global": {
      "symbol": { "color": { "hex": "#1f77b4", "alpha": 0.35 } },
      "summarySymbol": { "size": 7, "borderColor": { "hex": "#000000" } },
      "errorBar": { "color": { "hex": "#000000" } }
    }
  },
  "elementActivation": { "global": { "summarySymbol": true, "summaryLine": false } },
  "graphSettingsOverride": { "firstGapRatio": 0, "lastGapRatio": "auto" },
  "axesOverrides": {
    "xCategorical": { "labelBold": false },
    "titlesAndFonts": { "yAxisTitle": { "rotation": -90 } }
  }
}
```

## 5. Analyses

`analyses` is a list of analysis instances attached to the table: `{ "id", "method", "params" }`. `id` is optional (generated when omitted; required when a results table refers to it) and `params` may be partial (method defaults fill the rest). Results are not stored; they are recomputed from the data and the parameters.

- `ttest` (column, xy): `{ "groupA": "name" | null, "groupB": "name" | null, "design": "unpaired" | "paired", "variance": "welch" | "equal" }`. Group names refer to data set names; `null` uses the first two populated data sets.
- `rowTTests` (grouped, needs at least two sub-columns): the same parameters as `ttest`; `groupA` / `groupB` name the two data sets that are compared in every row. Each row is tested on its own, and P values are not adjusted for multiple comparisons. With `"design": "paired"` the values in the same sub-column of a row belong to one subject; sub-columns without a value on both sides are skipped. Significance brackets for the rows can be added to the graph (see the `rowIndex` anchor in "Annotations and titles"). Example: `{ "method": "rowTTests", "params": { "groupA": "Before", "groupB": "After", "design": "paired" } }`.
- `linearRegression` (xy): `{ "confidenceLevel": 90 | 95 | 99 }`.
- `nonlinearRegression` (xy, four-parameter logistic): `{ "model": "logX" | "linearX" }`.
- `frequencyDistribution` (column): `{ "binWidth": number | null, "binStart": number | null, "output": "count" | "fraction" | "percent", "cumulative": boolean }`. Produces a results table (`grouped`).
- `correlationMatrix` (multipleVariables): `{ "method": "pearson" | "spearman" }`. Produces a results table (`grouped`) rendered as a heat map.
- `survivalComparison` (survival): `{ "showPValue": boolean, "showHazardRatio": boolean, "showMedians": boolean }`. Log-rank (Mantel-Cox) and Gehan-Breslow-Wilcoxon tests across all curves, hazard ratio for two curves and median survival per data set; the switches only choose the lines of the text that can be placed on the graph.

A method must be available for the table's family. Explicit analysis ids must be unique within their table.

## 6. Rich text values

Text that is drawn on the graph is a **rich text value** - one of two forms:

```json
"Cell number"
```

```json
{
  "runs": [
    { "text": "Cell number (×10" },
    { "text": "6", "baseline": "super" },
    { "text": ")" },
    { "text": "\nper well", "italic": true }
  ]
}
```

- A **plain string** is drawn literally - there is no markup. `\n` starts a new line.
- The **runs form** concatenates its runs in order. Each run has `text` (non-empty; may contain `\n`) and optional `bold`, `italic` (booleans) and `baseline` (`"super"` or `"sub"`). Omitting `bold` / `italic` means *inherit the base style of the object* (the title font, the free text's or shape's `bold` / `italic`, or the legend's `bold` / `italic`); giving `false` explicitly overrides an inherited bold / italic. Omitting `baseline` means normal position; super- and subscripts are drawn at reduced size and shifted. Formatting cannot nest beyond these three properties.
- Limits: at most 500 runs per value; the concatenated text obeys the general string limit. Adjacent runs with identical formatting and empty runs are accepted but PivoGraph normalises them when saving, and writes the plain-string form whenever no run carries formatting.

Rich text values appear in: `titles.graph|x|y.text`, `annotations[].text` (all three annotation types), and `graph.legend.itemOverrides.<key>.label`. Data set names and titles, row labels and axis numbers are plain strings and are never parsed.

## 7. Annotations and titles

`annotations` holds drawing objects placed on the graph, discriminated by `type` (`id` optional, generated when omitted):

- `bracket`: significance bracket. `left` / `right` anchors are `{ "kind": "group", "groupIndex": n }` (snapped to the n-th plotted data set), `{ "kind": "group", "groupIndex": n, "rowIndex": r }` on grouped graphs (the n-th plotted data set in row r; available when the data sets of a row sit side by side - interleaved or separated layouts, or staggered series), or `{ "kind": "free", "x": pt }`; `y` is the bar position, `leftFootLen` / `rightFootLen` the foot lengths, `text` the label (`"*"`, `"ns"`, ... - a rich text value). Line formatting: `lineWidth`, `dashStyle`, `lineColor`. Text formatting: `fontSize`, `fontFamily`, `textColor`, base `bold` / `italic`, `textRotation` in degrees (multiples of 45, about the label centre), `textLineSpacing` in points and `textGap` - distance between label and bar in points, default 3. Labels that consist only of asterisks are drawn enlarged and aligned to the text baseline.
- `text`: free text at `x`, `y` (points): `text` (rich text value), `fontSize`, `fontFamily`, `color`, base `bold` / `italic`, `rotation` in degrees (multiples of 45, about the centre of the text block), and `lineSpacing` - extra space in points between lines when the text has more than one line (default 2).
- `shape`: lines, arrows, rectangles, ellipses, brackets, ... (`shape` enum), with two or three control points and optional bound text (`text` rich text value, `textRotation` in degrees, `textLineSpacing` in points, `textGap` - distance between text and line in points, default 3 - and base `bold` / `italic`). A two-point line *with* text is always drawn horizontal or vertical (the end point is projected onto the nearer axis); a two-point line *without* text keeps the exact angle its end points describe. Closed shapes (circle, ellipse, square, rectangle, rounded rectangle) may carry `fillColor`; omitted means no fill.

A significance bracket between the two data sets of the first row of a grouped graph, with a dashed line and an italic two-line label:

```json
{
  "type": "bracket",
  "left": { "kind": "group", "groupIndex": 0, "rowIndex": 0 },
  "right": { "kind": "group", "groupIndex": 1, "rowIndex": 0 },
  "y": 24,
  "text": { "runs": [{ "text": "P = 0.012\n", "italic": true }, { "text": "n = 8" }] },
  "dashStyle": "dashed",
  "textGap": 3,
  "textLineSpacing": 1
}
```

`titles` overrides the graph, X and Y titles (`graph`, `x`, `y`): `text` (rich text value), drag offset (`dx`, `dy`), `fontSize`, `fontFamily`, `color` and `lineSpacing`. This is the only place where title text lives; the title's base bold / italic and its rotation live in `axesOverrides.titlesAndFonts`.

## 8. Versioning and compatibility

- `schemaVersion` is an integer. Every change to the document structure or to rendering-relevant defaults increases it by one and ships with a migration step. Migrations run once, at open time, before validation.
- A reader opens every version from 1 up to its own. Older files are migrated in memory; the original file is never rewritten.
- A file with a **newer** `schemaVersion` than the reader supports is refused with an explicit message (in the web application: reload the page to get the latest version). Nothing is guessed or silently dropped.
- Registry identifiers (variants, presets, methods, symbols, colormaps) are part of the format contract. Deprecated identifiers are handled by migrations, not by aliases.
- Every version has exactly one JSON Schema (`/schemas/project/v<N>.json`, immutable), one specification page (`/developers/project-format/v<N>/`, plus the Markdown copy `v<N>.md`) and one set of examples, listed in `/schemas/project/v<N>/examples/index.json`. `/schemas/project/index.json` lists all versions and which one is current. There is deliberately no "latest" schema URL: a file's `$schema` must stay stable.
- Fixtures of every published version are kept in the repository and tested on every build.

## 9. Limits

Files larger than 50 MB, more than 200 tables, more than 2,000,000 cells in total, more than 200,000 rows or 500 data sets per table, more than 2,000 annotations or 200 analyses per table, more than 500 runs in one rich text value, non-finite numbers, over-long strings, or record keys named `__proto__`, `constructor` or `prototype` are rejected. Exact values are in the JSON Schema.

## 10. Validation

Opening a file goes through: size limit -> strict UTF-8 -> JSON parse -> envelope (`format`, `schemaVersion`) -> version gate -> forbidden-key scan -> migration -> strict structural validation (JSON Schema equivalent) -> semantic validation -> import. Semantic errors include: duplicate ids, dangling `derivedFrom` or `view.activeTableId` references, replicate counts invalid for the family, variant / plot family not matching the table, variant unavailable for the replicate count, incomplete variable mapping, analysis methods unavailable for the family, text cells outside `multipleVariables`, rows wider than `replicates`, total cell count. Semantic warnings include: style / visibility / legend keys that match no data set, and plot modes the variant does not offer.

All problems are reported together with JSON paths (for example `tables[0].graph.variantId`). If any error occurs, the current project in the editor is left untouched.

## 11. Minimal example

```json
{
  "format": "pivograph-project",
  "schemaVersion": 2,
  "tables": [
    {
      "name": "Cell viability",
      "format": "column",
      "dataSets": [
        { "name": "Vehicle", "values": [[98], [102], [95]] },
        { "name": "Compound", "values": [[71], [68], [74]] }
      ],
      "graph": { "variantId": "scatter-bar", "plot": { "family": "column", "options": { "mode": "meanWithSd" } } }
    }
  ]
}
```

More examples, one per table family, are listed in `https://pivograph.com/schemas/project/v2/examples/index.json` (each entry gives the file URL and the table families, graph variants and analyses it uses); `representative.pivograph` is a complete document written by PivoGraph itself.

## 12. For AI assistants

PivoGraph's Help menu offers **Copy Prompt for AI Assistants...**: a prompt generated from the running version that lists every family, variant id, analysis method, the main format rules, and a minimal example. Give that prompt to an AI assistant, describe the data and intended figure, save the returned JSON as `.pivograph`, and open it in PivoGraph. Treat generated projects as drafts: verify the source values, analysis choices, and graph before using the result. Agents can also use this page, its Markdown copy, `llms.txt` at the site root, the JSON Schema, and the examples.

## Changelog

- **v2 (PivoGraph 1.6)** - rich text values (plain string or `runs` with bold / italic / super- / subscript; no markup), title `rotation` in degrees, `lineSpacing` / `textLineSpacing`, `xCategorical.labelBold`, shape `textGap` default 3, `partsExplode` element + `bar.explodeOffsetPt`, `curve.drawOrder`, optional `errorBar` on stacked bars (on by default), line / text formatting fields on `bracket` annotations, `summarySymbol` element + style on scatter variants (with `summaryLine` now optional), heat map colormaps `viridis` / `magma` / `cividis` / `purpleOrange`, colour bar length default 1, edge spacing `firstGapRatio` / `lastGapRatio` as clear distance with `"auto"` default, `grouped-rm-across-row` staggered by default with connecting lines that follow the `line` style, `rowIndex` on bracket anchors, `rowTTests` analysis. Version 1 files are migrated automatically.
- **v1** - first public version (PivoGraph 1.3).
