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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/sequence-properties-visualizations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@platforma-open/milaboratories.sequence-properties.model": minor
"@platforma-open/milaboratories.sequence-properties.ui": minor
"@platforma-open/milaboratories.sequence-properties": minor
---

Add Scatterplot and Histogram tabs to the Sequence Properties block. Both
panels read the existing `propertiesPf` p-frame and pick modality-aware
defaults: peptide charge / hydrophobicity in peptide mode, chain "A" CDR3
charge / hydrophobicity in antibody/TCR mode. Axis pickers list every
numeric scalar PColumn emitted by the run, excluding the 2-axis AA fraction
column. R21 / R21a reference line at GRAVY = 0 deferred — see
docs/spec-deviations.md SD-009.
90 changes: 90 additions & 0 deletions docs/spec-deviations.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,3 +449,93 @@ chain is present.
- MiXCR chain enum verified via `mcp__pl__query_table` on the bulk QC
pt (`reports/bulk/clonotypesByChain/{IGHeavy,IGLight,TCRAlpha,TCRBeta,TCRGamma,TCRDelta}`).
- Predecessor: SD-003 (receptor on axis domain for single-cell).

---

## SD-009: Defer R21 Reference Line At GRAVY = 0

**Status:** applied
**Date:** 2026-05-05
**Affected file:** `ui/src/pages/ScatterPage.vue` (line not rendered),
`ui/src/pages/HistogramPage.vue` (line not rendered)

### Symptom

Spec R21 calls for `significantLines: [0]` on the scatterplot axis whenever a
hydrophobicity column is plotted, marking the hydrophobic / hydrophilic divide.
Spec R21a calls for the same on the histogram metric axis when the hook
exists. The implementation ships scatter and histogram panels without the
reference line on either chart.

### Root cause

Graph-maker has no path to inject `significantLines` on a data-column axis
today. Verified at `core/visualizations/packages/graph-maker/src/`:

- `composeScatterplotSettings.ts:applyChartInfoFromAnnotations` only reads
`Annotation.Graph.Thresholds` from the **grouping** column's spec
(lines ~82–113), not from the X or Y selected source.
- `getAxesDataFromForms.ts:getAxesDataFromFormsScatterplot` propagates
`axesFormsData.axisX.significantLinesStyle` to the rendered axis but does
not carry a `significantLines: number[]` array — that field does not exist
on `AxesState.axisX/axisY` (`constantsCommon.ts` `AxesState`).
- `composeHistogramSettings.ts` does not consume `significantLines` at all
(the histogram path has no thresholds wiring).

Spec R21 explicitly notes this: "graph-maker today does not read thresholds
from the X/Y data column directly. A platform-side extension to read
thresholds from data columns is tracked separately and is not part of this
block's spec."

### Trigger

Every scatter + histogram render. The reference line never appears regardless
of which column is selected on which axis.

### Impact

Visual cue at hydrophobic / hydrophilic divide is missing. Properties are
computed and plotted correctly; only the divide marker is absent. Users can
still read the value at zero off the axis ticks.

### Options considered

**A. Defer R21 and R21a entirely. [chosen]**
Skip the line on both panels. No graph-maker changes, no data-model
annotations. Land the panels now; pick up the line when graph-maker grows
the affordance.

**B. Extend `composeScatterplotSettings` to read thresholds from X/Y data
columns and annotate hydrophobicity columns with `Annotation.Graph.Thresholds
= [{value: 0}]` in this block's workflow.**
Reusable by other blocks. Spec carves this out as "platform-side extension...
tracked separately, not in this block's spec" — doing it here expands scope
into `core/visualizations` and needs visualizations-team sign-off. Defer.

**C. Add a block-local `axisInjections` prop to `GraphMaker` so blocks can
pass `significantLines` directly without column annotation.**
Matches spec wording ("block-local scope, not a PColumn annotation") most
literally. New graph-maker API surface, design review on prop shape, same
`core/visualizations` touch as B. Defer.

### Decision

**A.** The spec already permits R21a to defer; extending the same posture to
R21 keeps this block's scope contained. Revisit when the platform-side
extension named in the spec lands, or on explicit ask to scope graph-maker
work into this block.

### Implementation

No code injects `significantLines`. Pages call `GraphMaker` with default
options that select hydrophobicity columns when modality dictates; the
chart renders without the reference line.

### References

- Spec sections touched: `README.md` Requirements R21, R21a; Visualizations
§Reference line at GRAVY = 0.
- Graph-maker render path verified in
`core/visualizations/packages/graph-maker/src/utils/createChartSettingsForRender/composeScatterplotSettings.ts`,
`composeHistogramSettings.ts`, and `getAxesDataFromForms.ts`.
- `AxesState` shape: `core/visualizations/packages/graph-maker/src/constantsCommon.ts`.
171 changes: 171 additions & 0 deletions docs/visualizations-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
# Visualizations — implementation plan (PR #118 in docs/text)

Spec source: `docs/text/work/projects/sequence-properties/README.md` §Visualizations + R18–R21a.

## Summary of the spec change

Add two graph-maker panels reading the existing `propertiesPf` p-frame:
- **Scatterplot** — defaults adapt to detected modality (peptide charge vs hydrophobicity, or chain-A CDR3 charge vs hydrophobicity for IG/TCRAB/TCRGD).
- **Histogram** — default metric also modality-adaptive (peptide hydrophobicity / chain-A CDR3 hydrophobicity).
- **Axis pickers** enumerate every numeric scalar PColumn emitted by the run; exclude the 2-axis `pl7.app/aaFraction` column (R7) and discrete axes.
- **Fallback** when defaults absent: first one/two numeric scalars in workflow emission order (peptide → CDR3 → full-chain → Fv).
- **Reference line** at 0 on a hydrophobicity scatter axis: inject `significantLines: [0]` block-locally.
- **Histogram reference line**: defer — `composeHistogramSettings.ts` does not consume `significantLines` today (verified). No fallback display logic; line just doesn't render.
- **Phase 2 (out of scope):** cross-component selection, lasso → table, region shading.

## Workspace patterns to align with

- Reference pages: `blocks/clonotype-enrichment/ui/src/pages/ScatterPage.vue`, `blocks/clonotype-clustering/ui/src/pages/HistogramPage.vue`, `blocks/titeseq-analysis/ui/src/pages/KDDistributionPage.vue`.
- Graph state shape stored in `BlockData`: `GraphMakerState` from `@milaboratories/graph-maker`.
- PFrame for graph-maker: `createPFrameForGraphs(ctx, pCols)` exposed via `outputWithStatus(..., (ctx): PFrameHandle | undefined => …)`.
- Default-options use `PredefinedGraphOption<'scatterplot'|'histogram'>` with `selectedSource: PColumnSpec` looked up by `name`+`domain`.
- `dataColumnPredicate` filters columns shown in axis pickers.
- **No workspace block today renders a graph-maker panel and `PlAgDataTableV2` on the same page** — the convention is one section per panel. Spec says "alongside the properties table on the block's main page"; need to confirm layout (see Q1).

## Plan

### 1. Workflow

**No changes.** All required PColumns already emitted by `process.tpl.tengo` (`pl7.app/charge`, `pl7.app/hydrophobicity`, `pl7.app/chargeShift`, AA composition fractions, `pl7.app/aaFraction`). Workflow emission order in `process.tpl.tengo` is already peptide → CDR3 → full-chain → Fv (lines ~118–354), satisfying R19a/R20a fallback ordering.

### 2. Model (`model/src/`)

#### `types.ts`
Add to `BlockData`:
```ts
import type { GraphMakerState } from '@milaboratories/graph-maker';

graphStateScatter: GraphMakerState;
graphStateHistogram: GraphMakerState;
```

#### `dataModel.ts`
Bump data-model version (e.g. `"Ver_2026_05_05"`) with a migration that adds the two new graph-state fields initialised to a minimal default `GraphMakerState`. Keep `"Ver_2026_04_28"` registered.

#### `index.ts`
Add three new outputs:

```ts
.outputWithStatus("propertiesPfHandle", (ctx): PFrameHandle | undefined => {
const pCols = ctx.outputs?.resolve("propertiesPf")?.getPColumns();
if (pCols === undefined) return undefined;
return createPFrameForGraphs(ctx, pCols);
})

.output("propertiesPfCols", (ctx) => {
const pCols = ctx.outputs?.resolve("propertiesPf")?.getPColumns();
if (pCols === undefined) return undefined;
return pCols.map(c => ({ columnId: c.id, spec: c.spec }) satisfies PColumnIdAndSpec);
})
```

The UI uses `propertiesPfCols` for default-axis lookup and column-predicate filtering, and `propertiesPfHandle` to feed the chart.

If layout is **separate sections** (Q1), extend `.sections(...)` with two more entries:
```ts
.sections(() => [
{ type: 'link' as const, href: '/' as const, label: 'Properties' },
{ type: 'link' as const, href: '/scatter' as const, label: 'Scatterplot' },
{ type: 'link' as const, href: '/histogram' as const, label: 'Histogram' },
])
```

### 3. UI (`ui/src/`)

#### Numeric-scalar predicate (R18a)
```ts
const NUMERIC = new Set(['Int', 'Long', 'Float', 'Double']);
const dataColumnPredicate = (spec: PColumnSpec) =>
NUMERIC.has(spec.valueType)
&& spec.axesSpec.length === 1 // excludes 2-axis aaFraction (R7)
&& spec.name !== 'pl7.app/aaFraction'; // belt-and-braces
```

#### Default lookup (R19, R20)
Read modality from `app.model.outputs.info` (existing `WorkflowInfo` carries `mode` + `receptor` + `coverageTier`). Map:
- `mode === 'peptide'` → look up by `name === 'pl7.app/charge'/'hydrophobicity'` with `domain['pl7.app/feature'] === 'peptide'`.
- antibody/TCR (any other mode) → look up by `name === 'pl7.app/charge'/'hydrophobicity'` with `domain['pl7.app/feature'] === 'CDR3'` and `domain['pl7.app/vdj/scClonotypeChain'] === 'A'`.

The label naming (`CDR-H3 / CDR-α3 / CDR-γ3`) is already encoded in column annotations by the workflow (R13a) — graph-maker reads label from spec annotations, no UI-side label work required.

#### Fallback (R19a, R20a)
When the modality default is not present in `propertiesPfCols`, take the first one (histogram) or two (scatter) `PColumnIdAndSpec` entries that pass `dataColumnPredicate`, preserving the workflow's emission order.

When fewer than required, render via `statusText.noPframe.title = 'Select X and Y axes to plot'` (scatter) / `'Select a metric to plot'` (histogram).

#### `defaultOptions` shape
```ts
const scatterDefaults = computed((): PredefinedGraphOption<'scatterplot'>[] | null => {
const cols = app.model.outputs.propertiesPfCols;
const info = app.model.outputs.info;
if (!cols || !info) return null;

const xSpec = pickDefaultX(cols, info) ?? cols.filter(c => isScalar(c.spec))[0]?.spec;
const ySpec = pickDefaultY(cols, info) ?? cols.filter(c => isScalar(c.spec))[1]?.spec;
if (!xSpec || !ySpec) return null;

return [
{ inputName: 'x', selectedSource: xSpec },
{ inputName: 'y', selectedSource: ySpec },
];
});
```

#### Significant lines at GRAVY = 0 (R21)
The block writes `axesSettings.axisX.significantLines = [0]` (or axisY) on the persisted `graphStateScatter` whenever the currently-selected source on that axis is a `pl7.app/hydrophobicity` column; clears it otherwise. Implemented as a `watchEffect` over `app.model.data.graphStateScatter.optionsState.components.x/y.selectorStates[0].selectedSource`. This is `data → data`, not `output → data`, so it's outside the canonical hairpin shape (per `harnesses/block-dev/hairpin.md`) — flagged as a deviation candidate; document in `docs/spec-deviations.md` if it stays. Multi-client races converge because the written value is deterministic from the selected source.

#### Histogram reference line (R21a)
Verified at planning time: `composeHistogramSettings.ts` does not consume `significantLines`. **Skip the injection on the histogram path.** No runtime error; no fallback display. Add a comment pointing at `composeHistogramSettings.ts` so the next reviewer understands why the symmetry is broken.

#### Pages
Two new files: `ui/src/pages/ScatterPage.vue`, `ui/src/pages/HistogramPage.vue`. Both follow the pattern in `clonotype-enrichment/ScatterPage.vue` / `clonotype-clustering/HistogramPage.vue`.

`ui/src/app.ts` adds the routes:
```ts
routes: {
'/': () => MainPage,
'/scatter': () => ScatterPage,
'/histogram': () => HistogramPage,
}
```

`MainPage.vue` is unchanged in this layout.

### 4. Tests

Workflow tests do not change (no new outputs that need backend testing). Optional UI smoke test deferred — the block's existing `test/src/wf.test.ts` covers PColumn shape, which is what these panels rely on.

### 5. Changeset

`.changeset/<name>.md`:
```
---
'@platforma-open/milaboratories.sequence-properties.model': minor
'@platforma-open/milaboratories.sequence-properties.ui': minor
'@platforma-open/milaboratories.sequence-properties': minor
---

Add scatterplot and histogram graph-maker panels with modality-aware defaults.
```

(Root package included because the change is `minor`, not `patch`.)

### 6. Build / verify

`pnpm run build:dev` → reload via pl MCP server (`update_block` on the existing dev project) → verify scatter + histogram render with peptide and antibody fixtures, axis dropdowns enumerate scalars, AA fraction column absent from menu, hydrophobicity = 0 line shows on scatter when selected.

## Decisions (operator confirmed 2026-05-05)

1. **Layout — separate sections.** Three section links: `Properties` (existing main page with the table), `Scatterplot`, `Histogram`. Two new pages under `ui/src/pages/`.
2. **R18a numeric-scalar filter — `valueType ∈ {Int, Long, Float, Double} && axesSpec.length === 1 && name !== 'pl7.app/aaFraction'`.** The 2-axis check excludes the AA fraction column; the explicit name check is belt-and-braces. Sufficient for the current PColumn set.
3. **R21 reference line — deferred.** Graph-maker has no path to inject `significantLines` on a data-column axis today (verified in `composeScatterplotSettings.ts`, `getAxesDataFromForms.ts`, `composeHistogramSettings.ts`). Logged as `SD-009` in `docs/spec-deviations.md`. Block ships the two panels with no reference line on either chart. Pick up R21/R21a when the platform-side threshold extension named in the spec lands.
4. **Data-model migration version — `Ver_2026_05_05`.** Chained migration via `DataModelBuilder.add("Ver_2026_05_05", prev => ({...prev, graphStateScatter: <default>, graphStateHistogram: <default>}))`. Existing `Ver_2026_04_28` stays loadable.

## Out of plan (until operator unblocks)

- R21 / R21a reference line implementation (covered by SD-009).
- Cross-component selection (table ↔ scatter / histogram) — Phase 2 per spec.
- Lasso → table selection — Phase 2 per spec.
- Region/quadrant shading — Phase 2 per spec.

I'll wait for the green light before starting code changes.
1 change: 1 addition & 0 deletions model/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"check": "ts-builder check --target block-model"
},
"dependencies": {
"@milaboratories/graph-maker": "catalog:",
"@milaboratories/helpers": "^1.14.1",
"@platforma-sdk/model": "catalog:"
},
Expand Down
33 changes: 29 additions & 4 deletions model/src/dataModel.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
import type { GraphMakerState } from "@milaboratories/graph-maker";
import { createPlDataTableStateV2, DataModelBuilder } from "@platforma-sdk/model";
import type { BlockData } from "./types";
import type { BlockData, BlockDataV1 } from "./types";

export const blockDataModel = new DataModelBuilder().from<BlockData>("Ver_2026_04_28").init(() => ({
tableState: createPlDataTableStateV2(),
}));
const DEFAULT_SCATTER_STATE: GraphMakerState = {
title: "",
template: "dots",
currentTab: null,
};

const DEFAULT_HISTOGRAM_STATE: GraphMakerState = {
title: "",
template: "bins",
currentTab: null,
layersSettings: {
bins: { fillColor: "#99e099" },
},
};
Comment thread
PaulNewling marked this conversation as resolved.

export const blockDataModel = new DataModelBuilder()
.from<BlockDataV1>("Ver_2026_04_28")
.migrate<BlockData>("Ver_2026_05_05", (v1) => ({
...v1,
graphStateScatter: { ...DEFAULT_SCATTER_STATE },
graphStateHistogram: { ...DEFAULT_HISTOGRAM_STATE },
Comment on lines +24 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the factory functions to initialize the graph states to avoid shared references to nested objects.

Suggested change
graphStateScatter: { ...DEFAULT_SCATTER_STATE },
graphStateHistogram: { ...DEFAULT_HISTOGRAM_STATE },
graphStateScatter: createDefaultScatterState(),
graphStateHistogram: createDefaultHistogramState(),

}))
.init(() => ({
tableState: createPlDataTableStateV2(),
graphStateScatter: { ...DEFAULT_SCATTER_STATE },
graphStateHistogram: { ...DEFAULT_HISTOGRAM_STATE },
Comment thread
PaulNewling marked this conversation as resolved.
}));
56 changes: 54 additions & 2 deletions model/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { ColumnSource, InferOutputsType } from "@platforma-sdk/model";
import type {
ColumnSource,
InferOutputsType,
PColumnIdAndSpec,
PFrameHandle,
} from "@platforma-sdk/model";
import {
Annotation,
ArrayColumnProvider,
Expand Down Expand Up @@ -138,9 +143,56 @@ export const platforma = BlockModelV3.create(blockDataModel)
},
});
})
.outputWithStatus("propertiesPfHandle", (ctx): PFrameHandle | undefined => {
const allPCols = ctx.outputs?.resolve("propertiesPf")?.getPColumns();
if (allPCols === undefined) return undefined;
// Drop the AA fraction column from the pframe entirely. Two-axis
// (variantKey × aminoAcid), at 50k peptides ~1M cells — enough to trip
// graph-maker's cell-count guard on its own. The picker already excludes
// it via `isNumericScalar` (axesSpec.length === 1), so the data was
// pure overhead.
const pCols = allPCols.filter((c) => c.spec.name !== "pl7.app/aaFraction");
// Use `ctx.createPFrame` instead of `createPFrameForGraphs`. The latter
// walks the result pool and pulls in this block's `exports.properties`
// — a `trace.inject`-stamped re-emission of every column already in
// `propertiesPf`, published for Lead Selection — so axis dropdowns
// show e.g. "Net Charge (pH7) / IG" twice. Same workaround chosen by
// cdr3-spectratype, batch-correction, cell-type-annotation, and
// dimensionality-reduction.
//
// Pull single-axis metadata anchored to the input dataset's two axes
// (idx 0 = sample, idx 1 = entity key) so sample groups / patient IDs /
// peptide abundance and similar cols remain available for grouping and
// filtering. Drop self-trace to keep our own exports out.
const inputAnchor = ctx.data.inputAnchor;
const upstreamMeta =
inputAnchor !== undefined
? (
ctx.resultPool.getAnchoredPColumns({ main: inputAnchor }, [
{ axes: [{ anchor: "main", idx: 0 }] },
{ axes: [{ anchor: "main", idx: 1 }] },
]) ?? []
).filter(
(c) =>
!c.spec.annotations?.[Annotation.Trace]?.includes(
"milaboratories.sequence-properties",
),
)
: [];
return ctx.createPFrame([...pCols, ...upstreamMeta]);
})
.output("propertiesPfCols", (ctx): PColumnIdAndSpec[] | undefined => {
const pCols = ctx.outputs?.resolve("propertiesPf")?.getPColumns();
if (pCols === undefined) return undefined;
return pCols.map((c) => ({ columnId: c.id, spec: c.spec }) satisfies PColumnIdAndSpec);
})
.title(() => "Sequence Properties")
.subtitle((ctx) => ctx.data.defaultBlockLabel ?? "")
.sections(() => [{ type: "link" as const, href: "/" as const, label: "Main" }])
.sections(() => [
{ type: "link" as const, href: "/" as const, label: "Main" },
{ type: "link" as const, href: "/scatter" as const, label: "Scatterplot" },
{ type: "link" as const, href: "/histogram" as const, label: "Histogram" },
])
.done();

export type BlockOutputs = InferOutputsType<typeof platforma>;
Loading
Loading