@jbrowse/display-ui 5.0.0-beta.4 → 5.0.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -430,4 +430,34 @@ carries position only.
430
430
 
431
431
  [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/display-ui/src/trackControl/useTrackControlMenu.tsx)
432
432
 
433
+ ### ValueScale
434
+
435
+ A value scale a display places its y through, declared so the chrome can derive
436
+ the axis from it — the score axis's counterpart to a colour scale.
437
+
438
+ `domain` is the resolved `[min, max]`; `height` the band it rules and `offset`
439
+ the inset of the plot box inside that band (`axisPlotBox(height, offset)`);
440
+ `minimalTicks` and `symlogConstant` how the ticks are chosen. `ticks` is the
441
+ display's own ladder where the band's arithmetic is its own (the coverage band's
442
+ octaves against the shader's box, the read cloud's decades against the arc
443
+ geometry); `ScoreScaleMixin` derives one through `computeYTicks` otherwise.
444
+
445
+ `bandTops` is the screen y of each band the scale rules — one at 0 unless the
446
+ display stacks the same plot: the multi-wiggle's rows, a grouped alignments
447
+ track's coverage band per group, each projected through the display's own
448
+ scroll; the chrome drops the ones off screen. `[]` is a scale the display maps
449
+ to colour rather than to y (density rows each in their own colour), which gets
450
+ the `[min, max]` caption and no axis, as a scale whose bands are too short for
451
+ one does. `side` and `left` are which of the band's edges the display's own
452
+ panels leave clear for a gutter: `right` where a group label chip takes the
453
+ left, `left: n` where a dendrogram takes the first `n` px. `caption` is what the
454
+ scale measures (`TLEN`), as a colour scale's `field` is.
455
+
456
+ A member describes the scale or the band it rules, never the axis — not an
457
+ orientation, a form, a gutter width or a font. The chrome reads a member to keep
458
+ off what the display put in the band, never to choose between two drawings of it
459
+ (ADR-109).
460
+
461
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/display-ui/src/valueScale.ts)
462
+
433
463
  <!-- API_DOCS_END -->
@@ -0,0 +1,12 @@
1
+ import type { YAxis } from './valueScale.ts';
2
+ /**
3
+ * One band's axis drawn inside its gutter, the origin at the gutter's left
4
+ * edge: the spine on the gutter's inner edge — its right for a left-side
5
+ * axis, its left for a right-side one — with the numbers growing outward, and
6
+ * the caption, where the scale has one, rotated along the outer edge. The
7
+ * on-screen overlay and the export both draw through this, so the two cannot
8
+ * place a spine differently.
9
+ */
10
+ export default function AxisGutter({ axis }: {
11
+ axis: YAxis;
12
+ }): import("react").JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { usePalette } from '@jbrowse/core/ui/PaletteContext';
3
+ import YScaleBar from './YScaleBar.js';
4
+ import { AXIS_GUTTER_WIDTH_PX } from './yScaleTicks.js';
5
+ // Half the rotated caption's own 10px height plus a px of margin: how far in
6
+ // from the gutter's outer edge it sits with its glyphs still inside.
7
+ const CAPTION_INSET_PX = 8;
8
+ /**
9
+ * One band's axis drawn inside its gutter, the origin at the gutter's left
10
+ * edge: the spine on the gutter's inner edge — its right for a left-side
11
+ * axis, its left for a right-side one — with the numbers growing outward, and
12
+ * the caption, where the scale has one, rotated along the outer edge. The
13
+ * on-screen overlay and the export both draw through this, so the two cannot
14
+ * place a spine differently.
15
+ */
16
+ export default function AxisGutter({ axis }) {
17
+ const palette = usePalette();
18
+ const right = axis.side === 'right';
19
+ const { ticks, caption, height } = axis;
20
+ const captionX = right
21
+ ? AXIS_GUTTER_WIDTH_PX - CAPTION_INSET_PX
22
+ : CAPTION_INSET_PX;
23
+ const midY = (ticks.yTop + ticks.yBottom) / 2;
24
+ return (_jsxs(_Fragment, { children: [_jsx("g", { transform: `translate(${right ? 0 : AXIS_GUTTER_WIDTH_PX} 0)`, children: _jsx(YScaleBar, { ticks: ticks, orientation: right ? 'right' : 'left', bandHeight: height }) }), caption ? (_jsx("text", { x: captionX, y: midY, fontSize: 10, textAnchor: "middle", fill: palette.text.primary, stroke: palette.background.default, strokeWidth: 2.5, paintOrder: "stroke", transform: `rotate(-90, ${captionX}, ${midY})`, children: caption })) : null] }));
25
+ }
@@ -0,0 +1,12 @@
1
+ import type { YScaleTicks } from './yScaleTicks.ts';
2
+ export declare function CrossHatchLines({ ticks, width, offsetY, }: {
3
+ ticks: YScaleTicks;
4
+ width: number;
5
+ offsetY?: number;
6
+ }): import("react").JSX.Element;
7
+ export default function CrossHatches({ ticks, width, height, bandTops, }: {
8
+ ticks: YScaleTicks;
9
+ width: number;
10
+ height: number;
11
+ bandTops: number[];
12
+ }): import("react").JSX.Element;
@@ -0,0 +1,31 @@
1
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
2
+ import { clampStrokeInsideAxis } from './yScaleTicks.js';
3
+ // Bare <line> set for the Y-scale tick guide lines, no wrapping <svg>. Shared by
4
+ // the absolutely-positioned on-screen CrossHatches overlay and the flat SVG
5
+ // export, so the two can't drift. `offsetY` is the band's top.
6
+ export function CrossHatchLines({ ticks, width, offsetY = 0, }) {
7
+ return (_jsx(_Fragment, { children: ticks.items.map(({ value, y }) => {
8
+ // Same clamp the axis ticks take, so a guide line and the tick it
9
+ // belongs to can't disagree at the bottom edge — where, in multi-wiggle,
10
+ // "one pixel lower" is the next sample's row. Only that edge moves; the
11
+ // guide lines don't crispen to a half pixel the way the axis does.
12
+ const lineY = offsetY + clampStrokeInsideAxis(y, ticks.yBottom);
13
+ return (_jsx("line", { x1: 0, x2: width, y1: lineY, y2: lineY, stroke: "rgb(200,200,200)",
14
+ // a separate stroke-opacity attribute (not baked into an rgba()
15
+ // string) survives the SVG export, whose renderToStaticMarkup strips
16
+ // rgba() alpha
17
+ strokeOpacity: 0.8, strokeWidth: 1 }, `${value}-${y}`));
18
+ }) }));
19
+ }
20
+ // Horizontal guide lines at each Y-scale tick, once per band the scale rules.
21
+ // Pointer-events disabled so the underlying canvas still receives mouse events.
22
+ export default function CrossHatches({ ticks, width, height, bandTops, }) {
23
+ return (_jsx("svg", { style: {
24
+ position: 'absolute',
25
+ top: 0,
26
+ left: 0,
27
+ pointerEvents: 'none',
28
+ height,
29
+ width,
30
+ }, children: bandTops.map(top => (_jsx(CrossHatchLines, { ticks: ticks, width: width, offsetY: top }, top))) }));
31
+ }
@@ -1,11 +1,10 @@
1
1
  import type { LegendItem, LegendSection } from '@jbrowse/core/ui/legendSpec';
2
- import type { ReactNode } from 'react';
3
- export type { LegendItem, LegendMark, LegendSection, LegendSpec, LegendSwatch, } from '@jbrowse/core/ui/legendSpec';
4
- declare const FloatingLegend: ({ items, sections, title, onDismiss, onDismissSection, onItemClick, maxItems, maxWidth, top, children, }: {
2
+ export declare const FLOATING_LEGEND_TOP_PX = 10;
3
+ export type { LegendGradient, LegendItem, LegendSection, LegendSpec, LegendSwatch, } from '@jbrowse/core/ui/legendSpec';
4
+ declare const FloatingLegend: ({ items, sections, title, onDismiss, onDismissSection, onItemClick, maxItems, maxWidth, top, }: {
5
5
  items?: LegendItem[];
6
6
  sections?: LegendSection[];
7
7
  title?: string;
8
- children?: ReactNode;
9
8
  onDismiss?: () => void;
10
9
  onDismissSection?: (id: string) => void;
11
10
  onItemClick?: (item: LegendItem, section: LegendSection) => void;
@@ -10,6 +10,8 @@ import { cx, makeStyles } from '@jbrowse/core/util/tss-react';
10
10
  import { observer } from 'mobx-react';
11
11
  import Tooltip from './tooltip/Tooltip.js';
12
12
  import { TrackOverlayPortal } from './trackOverlay/TrackOverlayPortal.js';
13
+ // The key's own inset from the display's top-right corner.
14
+ export const FLOATING_LEGEND_TOP_PX = 10;
13
15
  const useStyles = makeStyles()(theme => ({
14
16
  legend: {
15
17
  position: 'absolute',
@@ -134,6 +136,24 @@ const useStyles = makeStyles()(theme => ({
134
136
  overflow: 'hidden',
135
137
  textOverflow: 'ellipsis',
136
138
  },
139
+ gradientRow: {
140
+ marginBottom: 1,
141
+ },
142
+ gradientBar: {
143
+ width: GRADIENT_BAR_WIDTH,
144
+ height: 10,
145
+ borderRadius: 2,
146
+ border: `1px solid ${theme.palette.divider}`,
147
+ // clip the gradient to the padding box so it doesn't paint under the
148
+ // translucent divider border — otherwise the border composites over the
149
+ // gradient's end and the edge renders a dark sliver
150
+ backgroundClip: 'padding-box',
151
+ },
152
+ gradientLabels: {
153
+ display: 'flex',
154
+ justifyContent: 'space-between',
155
+ width: GRADIENT_BAR_WIDTH,
156
+ },
137
157
  // was `<Link component="button" underline="hover">`, which is the same
138
158
  // element with Material's typography on it — see `closeButton` above for why
139
159
  // that mattered
@@ -155,9 +175,33 @@ const useStyles = makeStyles()(theme => ({
155
175
  }));
156
176
  const DEFAULT_MAX_ITEMS = 12;
157
177
  const DEFAULT_MAX_WIDTH = 200;
158
- // Side of one mark. The old fixed color box's size, kept so a legend of plain
159
- // fills is pixel-identical to what shipped before marks existed.
178
+ const GRADIENT_BAR_WIDTH = 100;
179
+ // Side of one swatch box.
160
180
  const SWATCH = 12;
181
+ // A ramp row: the caption, the bar and the domain ends. The bar is a CSS
182
+ // gradient over the same stops the export's <linearGradient> takes, so the
183
+ // two draw one ramp. Stops are flattened over the paper because a translucent
184
+ // first stop (Hi-C's juicebox fade) rasterizes to a dark sliver at the bar's
185
+ // left edge.
186
+ function gradientCss({ stops }, paper) {
187
+ const list = stops.map(({ color, opacity = 1, offset }) => {
188
+ const flat = opacity >= 1
189
+ ? color
190
+ : `color-mix(in srgb, ${color} ${Math.round(opacity * 100)}%, ${paper})`;
191
+ return `${flat} ${offset * 100}%`;
192
+ });
193
+ return `linear-gradient(to right, ${list.join(', ')})`;
194
+ }
195
+ function GradientRow({ label, gradient, }) {
196
+ const { classes, theme } = useStyles();
197
+ return (_jsxs("div", { className: classes.gradientRow, children: [label ? _jsx("div", { className: classes.label, children: label }) : null, _jsx("div", { className: classes.gradientBar,
198
+ // backgroundImage (longhand), not the `background` shorthand: the
199
+ // shorthand resets background-clip back to border-box, undoing the
200
+ // padding-box clip
201
+ style: {
202
+ backgroundImage: gradientCss(gradient, theme.palette.background.paper),
203
+ } }), _jsxs("div", { className: classes.gradientLabels, children: [_jsx("span", { children: gradient.minLabel }), _jsx("span", { children: gradient.maxLabel })] })] }));
204
+ }
161
205
  // One list of swatches with its own independent collapse state, so each section
162
206
  // in a multi-section legend expands/collapses on its own.
163
207
  const LegendItemList = observer(function LegendItemList({ items, maxItems, onItemClick, }) {
@@ -167,12 +211,17 @@ const LegendItemList = observer(function LegendItemList({ items, maxItems, onIte
167
211
  const shown = collapsible && !expanded ? items.slice(0, maxItems) : items;
168
212
  const hiddenCount = items.length - maxItems;
169
213
  // Every row reserves the widest row's swatch column, so labels stay in one
170
- // vertical line whether a row draws one mark or two — and so a color-less
214
+ // vertical line whether a row draws one box or two — and so a color-less
171
215
  // heading row indents like the swatches it heads, as the fixed-size box it
172
216
  // replaces used to.
173
217
  const columns = Math.max(1, ...shown.map(i => legendSwatches(i).length));
174
218
  return (_jsxs(_Fragment, { children: [shown.map((item, i) => {
175
- const row = (_jsxs(_Fragment, { children: [_jsx("div", { className: classes.swatches, style: { minWidth: columns * SWATCH + (columns - 1) * 2 }, children: legendSwatches(item).map(swatch => (_jsx("svg", { width: SWATCH, height: SWATCH, children: _jsx(LegendSwatchGlyph, { swatch: swatch, size: SWATCH }) }, `${swatch.color}-${swatch.mark ?? 'fill'}`))) }), _jsx("span", { className: classes.label, children: item.label })] }));
219
+ if (item.gradient) {
220
+ return (_jsx(GradientRow
221
+ // eslint-disable-next-line @eslint-react/no-array-index-key
222
+ , { label: item.label, gradient: item.gradient }, `${item.label}-${i}`));
223
+ }
224
+ const row = (_jsxs(_Fragment, { children: [_jsx("div", { className: classes.swatches, style: { minWidth: columns * SWATCH + (columns - 1) * 2 }, children: legendSwatches(item).map(swatch => (_jsx("svg", { width: SWATCH, height: SWATCH, children: _jsx(LegendSwatchGlyph, { swatch: swatch, size: SWATCH }) }, swatch.color))) }), _jsx("span", { className: classes.label, children: item.label })] }));
176
225
  return onItemClick ? (_jsx("button", { type: "button", className: cx(classes.item, classes.itemButton), title: "Show only these rows", onClick: () => {
177
226
  onItemClick(item);
178
227
  }, children: row }, `${item.label}-${i}`)) : (_jsx("div", { className: classes.item, children: row }, `${item.label}-${i}`));
@@ -191,10 +240,10 @@ const LegendItemList = observer(function LegendItemList({ items, maxItems, onIte
191
240
  // rows, clicking one focuses those rows. It is handed the section too, so a
192
241
  // display with several vocabularies can act on the one that names rows and
193
242
  // leave the others inert.
194
- const FloatingLegend = observer(function FloatingLegend({ items, sections, title, onDismiss, onDismissSection, onItemClick, maxItems = DEFAULT_MAX_ITEMS, maxWidth = DEFAULT_MAX_WIDTH, top = 10, children, }) {
243
+ const FloatingLegend = observer(function FloatingLegend({ items, sections, title, onDismiss, onDismissSection, onItemClick, maxItems = DEFAULT_MAX_ITEMS, maxWidth = DEFAULT_MAX_WIDTH, top = FLOATING_LEGEND_TOP_PX, }) {
195
244
  const { classes } = useStyles();
196
245
  const nonEmpty = nonEmptyLegendSections({ items, sections });
197
- if (nonEmpty.length === 0 && !children) {
246
+ if (nonEmpty.length === 0) {
198
247
  return null;
199
248
  }
200
249
  const multiSection = nonEmpty.length > 1;
@@ -202,7 +251,7 @@ const FloatingLegend = observer(function FloatingLegend({ items, sections, title
202
251
  // whole-genome / multi-region scale (see TrackOverlayPortal)
203
252
  return (_jsx(TrackOverlayPortal, { children: _jsxs("div", { className: cx(classes.legend, onDismiss && classes.withClose), style: { top, maxWidth }, "data-testid": "floating-legend", "data-gesture-owner": "true", children: [onDismiss ? (_jsx(Tooltip, { title: "Hide legend", children: _jsx("button", { type: "button", className: classes.closeButton, "aria-label": "Hide legend", onClick: () => {
204
253
  onDismiss();
205
- }, children: "\u00D7" }) })) : null, title ? _jsx("div", { className: classes.topTitle, children: title }) : null, children, nonEmpty.map(section => (_jsxs("div", { className: classes.section, children: [multiSection && section.title ? (_jsxs("div", { className: classes.sectionHeader, children: [_jsx("span", { className: classes.sectionTitle, children: section.title }), onDismissSection ? (_jsx(Tooltip, { title: `Hide ${section.title}`, children: _jsx("button", { type: "button", className: classes.sectionClose, "aria-label": `Hide ${section.title}`, onClick: () => {
254
+ }, children: "\u00D7" }) })) : null, title ? _jsx("div", { className: classes.topTitle, children: title }) : null, nonEmpty.map(section => (_jsxs("div", { className: classes.section, children: [multiSection && section.title ? (_jsxs("div", { className: classes.sectionHeader, children: [_jsx("span", { className: classes.sectionTitle, children: section.title }), onDismissSection ? (_jsx(Tooltip, { title: `Hide ${section.title}`, children: _jsx("button", { type: "button", className: classes.sectionClose, "aria-label": `Hide ${section.title}`, onClick: () => {
206
255
  onDismissSection(section.id);
207
256
  }, children: "\u00D7" }) })) : null] })) : null, _jsx(LegendItemList, { items: section.items, maxItems: maxItems, onItemClick: onItemClick
208
257
  ? item => {
@@ -0,0 +1,14 @@
1
+ export declare const SCORE_CAPTION_HEIGHT = 16;
2
+ /**
3
+ * The one-line `[min, max]` caption that stands in for a y axis where none can
4
+ * be drawn — a scale whose bands are too short for tick labels, and a density
5
+ * track whose rows each carry their own colour, so no single ramp describes
6
+ * them. An axis stand-in rather than a key, which is why it is not a colour
7
+ * scale and draws whether or not the legend does. Right-aligned to
8
+ * `canvasWidth` at the top of the display.
9
+ */
10
+ export default function ScoreDomainCaption({ domain, scaleType, canvasWidth, }: {
11
+ domain: [number, number];
12
+ scaleType: string;
13
+ canvasWidth: number;
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { usePalette } from '@jbrowse/core/ui/PaletteContext';
3
+ import { measureLegendText } from '@jbrowse/core/ui/measureLegendText';
4
+ import { stripAlpha } from '@jbrowse/core/util/svgColorProps';
5
+ import { formatScore } from './formatScore.js';
6
+ export const SCORE_CAPTION_HEIGHT = 16;
7
+ /**
8
+ * The one-line `[min, max]` caption that stands in for a y axis where none can
9
+ * be drawn — a scale whose bands are too short for tick labels, and a density
10
+ * track whose rows each carry their own colour, so no single ramp describes
11
+ * them. An axis stand-in rather than a key, which is why it is not a colour
12
+ * scale and draws whether or not the legend does. Right-aligned to
13
+ * `canvasWidth` at the top of the display.
14
+ */
15
+ export default function ScoreDomainCaption({ domain, scaleType, canvasWidth, }) {
16
+ const palette = usePalette();
17
+ const suffix = scaleType === 'log' ? ' (log)' : scaleType === 'symlog' ? ' (symlog)' : '';
18
+ const text = `[${formatScore(domain[0])}, ${formatScore(domain[1])}]${suffix}`;
19
+ const len = measureLegendText(text, 12);
20
+ const xpos = Math.max(0, canvasWidth - len - 10);
21
+ return (_jsxs("g", { children: [_jsx("rect", { x: xpos - 3, y: 0, width: len + 6, height: SCORE_CAPTION_HEIGHT, fill: stripAlpha(palette.background.paper), fillOpacity: 0.8 }), _jsx("text", { y: 12, x: xpos, fontSize: 12, fill: palette.text.primary, children: text })] }));
22
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * One horizontal rule across a score plot at a chosen value, placed in the
3
+ * plot's own pixel space; `label` is free text the reader chose.
4
+ */
5
+ export interface ScoreRuleMark {
6
+ value: number;
7
+ y: number;
8
+ label?: string;
9
+ color?: string;
10
+ }
11
+ export declare function ScoreRuleLines({ marks, width, offsetY, }: {
12
+ marks: ScoreRuleMark[];
13
+ width: number;
14
+ offsetY?: number;
15
+ }): import("react").JSX.Element;
16
+ export default function ScoreRules({ marks, width, height, bandTops, }: {
17
+ marks: ScoreRuleMark[];
18
+ width: number;
19
+ height: number;
20
+ bandTops: number[];
21
+ }): import("react").JSX.Element;
@@ -0,0 +1,37 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { usePalette } from '@jbrowse/core/ui/PaletteContext';
3
+ const DEFAULT_RULE_COLOR = 'rgb(120,120,120)';
4
+ // Bare marks, no wrapping <svg>, so the on-screen overlay and the SVG export
5
+ // draw the same elements from the same y. Same split as CrossHatchLines.
6
+ export function ScoreRuleLines({ marks, width, offsetY = 0, }) {
7
+ // A caption sits over the plot, not beside it, so it needs the halo the tick
8
+ // labels next to it already draw (YScaleBar) — without one a rule crossing
9
+ // filled bars captions itself in grey on the fill and cannot be read. From
10
+ // the palette rather than a hardcoded white, which inverts into a glare in
11
+ // dark mode.
12
+ const haloColor = usePalette().background.default;
13
+ return (_jsx(_Fragment, { children: marks.map(({ value, label, color, y }) => {
14
+ const lineY = offsetY + y;
15
+ const stroke = color ?? DEFAULT_RULE_COLOR;
16
+ return (_jsxs("g", { children: [_jsx("line", { x1: 0, x2: width, y1: lineY, y2: lineY, stroke: stroke,
17
+ // separate attribute rather than an rgba() string: the SVG
18
+ // export's renderToStaticMarkup strips rgba() alpha
19
+ strokeOpacity: 0.9, strokeWidth: 1, strokeDasharray: "4 3" }), label ? (_jsx("text", { x: width - 4, y: lineY - 2, textAnchor: "end", fontSize: 10,
20
+ // full alpha, unlike the line: 0.9 is there to let the rule
21
+ // recede into the plot, and a caption that has to be read wants
22
+ // the opposite
23
+ fill: stroke, stroke: haloColor, strokeWidth: 2.5, paintOrder: "stroke", children: label })) : null] }, `${value}-${y}`));
24
+ }) }));
25
+ }
26
+ // Once per band the scale rules, like CrossHatches; pointer-events disabled so
27
+ // the canvas underneath still gets mouse events.
28
+ export default function ScoreRules({ marks, width, height, bandTops, }) {
29
+ return (_jsx("svg", { style: {
30
+ position: 'absolute',
31
+ top: 0,
32
+ left: 0,
33
+ pointerEvents: 'none',
34
+ height,
35
+ width,
36
+ }, children: bandTops.map(top => (_jsx(ScoreRuleLines, { marks: marks, width: width, offsetY: top }, top))) }));
37
+ }
@@ -0,0 +1,6 @@
1
+ import type { YScaleTicks } from './yScaleTicks.ts';
2
+ export default function YScaleBar({ ticks, orientation, bandHeight, }: {
3
+ ticks: YScaleTicks | undefined;
4
+ orientation?: 'left' | 'right';
5
+ bandHeight?: number;
6
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { usePalette } from '@jbrowse/core/ui/PaletteContext';
3
+ import { clampStrokeInsideAxis } from './yScaleTicks.js';
4
+ // Not an `observer`, and deliberately: there is nothing here to observe. Every
5
+ // caller already reads `model.ticks` inside its own observer and hands the
6
+ // resolved plain object down, and `usePalette` is a React context, not MobX.
7
+ // Wrapping it anyway allocated a Reaction per instance — multi-wiggle renders
8
+ // one per sample row, on screen as well as in export — and, per CLAUDE.md, put
9
+ // the component out of `babel-plugin-react-compiler`'s reach, so it got neither
10
+ // the MobX tracking it doesn't need nor the memoization it does. Its sibling
11
+ // `CrossHatchLines`, consuming the same ticks, is already plain.
12
+ // Half the vertical space a 10px label needs, halo included: digits reach about
13
+ // 3.6px above the centered baseline and the 2.5px-wide background stroke another
14
+ // 1.25px past that.
15
+ const LABEL_HALF_HEIGHT_PX = 5;
16
+ export default function YScaleBar({ ticks, orientation, bandHeight, }) {
17
+ const palette = usePalette();
18
+ if (!ticks) {
19
+ return null;
20
+ }
21
+ const { items, yTop, yBottom } = ticks;
22
+ const bg = palette.background.default;
23
+ const fg = palette.text.primary;
24
+ const isLeft = orientation !== 'right';
25
+ const k = isLeft ? -1 : 1;
26
+ const tickLength = 6;
27
+ // Crispen to `y + 0.5` so each 1px stroke fills one pixel instead of
28
+ // straddling two, then clamp, which only bites at the bottom edge: there the
29
+ // stroke goes on the last pixel inside the box rather than the first one
30
+ // below it.
31
+ const strokeY = (y) => clampStrokeInsideAxis(y + 0.5, yBottom);
32
+ // The tick keeps the group transform; only the text moves off it, and only
33
+ // under `bandHeight` within half a label of an end. A tick is where the data
34
+ // is, so it never moves — the label is then drawn against the tick rather than
35
+ // across it, which is what any axis does at the edge of its own frame.
36
+ // `undefined` rather than 0 for the labels that don't move, so the markup this
37
+ // emits is unchanged wherever the inset doesn't apply.
38
+ const labelDy = (sy) => {
39
+ if (bandHeight === undefined) {
40
+ return undefined;
41
+ }
42
+ const inset = Math.min(Math.max(sy, LABEL_HALF_HEIGHT_PX), bandHeight - LABEL_HALF_HEIGHT_PX);
43
+ return inset === sy ? undefined : inset - sy;
44
+ };
45
+ return (_jsxs("g", { fontSize: 10, textAnchor: isLeft ? 'end' : 'start', stroke: fg, strokeWidth: 1, children: [_jsx("path", { fill: "none", d: `M${k * tickLength} ${strokeY(yTop)}H0.5V${strokeY(yBottom)}H${k * tickLength}` }), items.map(({ value, y, label }) => (_jsxs("g", { transform: `translate(0,${strokeY(y)})`, children: [_jsx("line", { x2: k * tickLength }), _jsx("text", { stroke: bg, strokeWidth: 2.5, paintOrder: "stroke", fill: fg, y: labelDy(strokeY(y)), dy: "0.32em", x: k * 9, children: label ?? value })] }, `${value}-${y}`)))] }));
46
+ }
@@ -0,0 +1,11 @@
1
+ import type { YAxis } from './valueScale.ts';
2
+ /**
3
+ * The on-screen axis of one band: an `<svg>` the width of the gutter,
4
+ * positioned absolutely over the band at `top`, with `AxisGutter` inside. A
5
+ * right-side gutter clears the vertical scrollbar a display may mount.
6
+ */
7
+ export default function YScaleBarOverlay({ axis, top, width, }: {
8
+ axis: YAxis;
9
+ top: number;
10
+ width: number;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { VERTICAL_SCROLLBAR_CLEARANCE } from '@jbrowse/core/ui/VerticalScrollbar';
3
+ import AxisGutter from './AxisGutter.js';
4
+ import { axisGutterLeft } from './axisPlacement.js';
5
+ import { AXIS_GUTTER_WIDTH_PX } from './yScaleTicks.js';
6
+ /**
7
+ * The on-screen axis of one band: an `<svg>` the width of the gutter,
8
+ * positioned absolutely over the band at `top`, with `AxisGutter` inside. A
9
+ * right-side gutter clears the vertical scrollbar a display may mount.
10
+ */
11
+ export default function YScaleBarOverlay({ axis, top, width, }) {
12
+ return (_jsx("svg", { style: {
13
+ position: 'absolute',
14
+ top,
15
+ left: axisGutterLeft(axis, width, VERTICAL_SCROLLBAR_CLEARANCE),
16
+ pointerEvents: 'none',
17
+ height: axis.height,
18
+ width: AXIS_GUTTER_WIDTH_PX,
19
+ overflow: 'visible',
20
+ }, children: _jsx(AxisGutter, { axis: axis }) }));
21
+ }
@@ -0,0 +1,25 @@
1
+ import type { ValueScale } from './valueScale.ts';
2
+ /**
3
+ * Below this band height a full `YScaleBar`'s tick labels overlap each other,
4
+ * so the chrome draws one `ScoreDomainCaption` for the scale instead of an
5
+ * axis per band. Its own module because `YScaleBar`'s label geometry is what
6
+ * makes 30 the number.
7
+ */
8
+ export declare const COMPACT_AXIS_HEIGHT = 30;
9
+ /**
10
+ * Whether a scale gets an axis at all: it rules at least one band, and the
11
+ * band has room for tick labels. A scale that fails either is captioned.
12
+ */
13
+ export declare function axisDrawn(scale: Pick<ValueScale, 'height' | 'bandTops'>): boolean;
14
+ /** How far in from the plot's right edge a right-side gutter ends in an export. */
15
+ export declare const AXIS_RIGHT_INSET_PX = 4;
16
+ /**
17
+ * Left edge of the gutter an axis is drawn in. A right-side axis takes the
18
+ * gutter inside the plot's right edge, less `rightInset` (the vertical
19
+ * scrollbar a display may mount on screen; a margin in an export). A left-side
20
+ * axis takes the gutter at the display's left edge, past whatever panel the
21
+ * scale's `left` reserves — except in an export, whose margin is the gutter,
22
+ * so an axis nothing pushes right sits in the margin with its spine on the
23
+ * content edge and its numbers outside the plot.
24
+ */
25
+ export declare function axisGutterLeft(scale: Pick<ValueScale, 'side' | 'left'>, width: number, rightInset: number, exportContentLeft?: number): number;
@@ -0,0 +1,35 @@
1
+ import { AXIS_GUTTER_WIDTH_PX } from './yScaleTicks.js';
2
+ /**
3
+ * Below this band height a full `YScaleBar`'s tick labels overlap each other,
4
+ * so the chrome draws one `ScoreDomainCaption` for the scale instead of an
5
+ * axis per band. Its own module because `YScaleBar`'s label geometry is what
6
+ * makes 30 the number.
7
+ */
8
+ export const COMPACT_AXIS_HEIGHT = 30;
9
+ /**
10
+ * Whether a scale gets an axis at all: it rules at least one band, and the
11
+ * band has room for tick labels. A scale that fails either is captioned.
12
+ */
13
+ export function axisDrawn(scale) {
14
+ return ((scale.bandTops?.length ?? 1) > 0 && scale.height >= COMPACT_AXIS_HEIGHT);
15
+ }
16
+ /** How far in from the plot's right edge a right-side gutter ends in an export. */
17
+ export const AXIS_RIGHT_INSET_PX = 4;
18
+ /**
19
+ * Left edge of the gutter an axis is drawn in. A right-side axis takes the
20
+ * gutter inside the plot's right edge, less `rightInset` (the vertical
21
+ * scrollbar a display may mount on screen; a margin in an export). A left-side
22
+ * axis takes the gutter at the display's left edge, past whatever panel the
23
+ * scale's `left` reserves — except in an export, whose margin is the gutter,
24
+ * so an axis nothing pushes right sits in the margin with its spine on the
25
+ * content edge and its numbers outside the plot.
26
+ */
27
+ export function axisGutterLeft(scale, width, rightInset, exportContentLeft) {
28
+ if (scale.side === 'right') {
29
+ return width - rightInset - AXIS_GUTTER_WIDTH_PX;
30
+ }
31
+ const left = scale.left ?? 0;
32
+ return exportContentLeft !== undefined && left === 0
33
+ ? exportContentLeft - AXIS_GUTTER_WIDTH_PX
34
+ : left;
35
+ }
@@ -0,0 +1 @@
1
+ export declare function formatScore(n: number): string;
@@ -0,0 +1,14 @@
1
+ // Only the fractional padding toPrecision adds is noise; guard on the decimal
2
+ // point because toPrecision can round UP out of the fractional form entirely —
3
+ // 99.95 comes back as "100", where an unguarded trailing-zero strip eats two
4
+ // significant digits and reports the score as 1.
5
+ export function formatScore(n) {
6
+ if (n === 0) {
7
+ return '0';
8
+ }
9
+ if (Math.abs(n) >= 100) {
10
+ return n.toFixed(0);
11
+ }
12
+ const s = n.toPrecision(3);
13
+ return s.includes('.') ? s.replace(/\.?0+$/, '') : s;
14
+ }
package/esm/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export { default as plainTrackControl } from './trackControl/plainTrackControl.t
7
7
  export { useTrackControlMenu } from './trackControl/useTrackControlMenu.tsx';
8
8
  export type { TrackControlMenu } from './trackControl/useTrackControlMenu.tsx';
9
9
  export type { TrackControlComponent, TrackControlIcon, TrackControlOption, TrackControlProps, } from './trackControl/types.ts';
10
- export { default as FloatingLegend } from './FloatingLegend.tsx';
10
+ export { default as FloatingLegend, FLOATING_LEGEND_TOP_PX, } from './FloatingLegend.tsx';
11
11
  export type { LegendItem, LegendSection } from './FloatingLegend.tsx';
12
12
  export { FloatingSvgOverlay } from './FloatingSvgOverlay.tsx';
13
13
  export { default as Tooltip } from './tooltip/Tooltip.tsx';
@@ -21,3 +21,16 @@ export { TrackOverlaySlot } from './trackOverlay/TrackOverlaySlot.tsx';
21
21
  export { tooLargeBannerText } from './tooLargeBannerText.ts';
22
22
  export { isLiveModel } from './isLiveModel.ts';
23
23
  export { BOTTOM_RIGHT_CONTROLS_ORDER, BottomRightCornerContext, } from './bottomRightCorner.ts';
24
+ export type { AxisSide, ValueScale, YAxis } from './valueScale.ts';
25
+ export { AXIS_GUTTER_WIDTH_PX, axisPlotBox, clampStrokeInsideAxis, scoreToAxisY, } from './yScaleTicks.ts';
26
+ export type { YScaleTicks } from './yScaleTicks.ts';
27
+ export { YSCALEBAR_LABEL_OFFSET } from './yAxisConstants.ts';
28
+ export { AXIS_RIGHT_INSET_PX, COMPACT_AXIS_HEIGHT, axisDrawn, axisGutterLeft, } from './axisPlacement.ts';
29
+ export { default as AxisGutter } from './AxisGutter.tsx';
30
+ export { default as YScaleBar } from './YScaleBar.tsx';
31
+ export { default as YScaleBarOverlay } from './YScaleBarOverlay.tsx';
32
+ export { default as ScoreDomainCaption, SCORE_CAPTION_HEIGHT, } from './ScoreDomainCaption.tsx';
33
+ export { formatScore } from './formatScore.ts';
34
+ export { CrossHatchLines, default as CrossHatches } from './CrossHatches.tsx';
35
+ export { ScoreRuleLines, default as ScoreRules } from './ScoreRules.tsx';
36
+ export type { ScoreRuleMark } from './ScoreRules.tsx';
package/esm/index.js CHANGED
@@ -22,7 +22,7 @@ export { useTrackControlMenu } from './trackControl/useTrackControlMenu.js';
22
22
  // no UI toolkit, and it is chrome by the definition this package uses — a thing
23
23
  // a display draws that is not its data. `@jbrowse/plugin-linear-genome-view`
24
24
  // re-exports it, so its published name is unchanged.
25
- export { default as FloatingLegend } from './FloatingLegend.js';
25
+ export { default as FloatingLegend, FLOATING_LEGEND_TOP_PX, } from './FloatingLegend.js';
26
26
  // The <svg> counterpart of that legend, for a display whose key is drawn by an
27
27
  // SVG-export path as well as on screen. Beside it for the same reasons — it
28
28
  // reaches no UI toolkit, and portaling chrome above the region masks is what
@@ -55,3 +55,13 @@ export { isLiveModel } from './isLiveModel.js';
55
55
  // contract the prop types cannot carry, so it belongs with them rather than
56
56
  // with the chrome that happens to mount it.
57
57
  export { BOTTOM_RIGHT_CONTROLS_ORDER, BottomRightCornerContext, } from './bottomRightCorner.js';
58
+ export { AXIS_GUTTER_WIDTH_PX, axisPlotBox, clampStrokeInsideAxis, scoreToAxisY, } from './yScaleTicks.js';
59
+ export { YSCALEBAR_LABEL_OFFSET } from './yAxisConstants.js';
60
+ export { AXIS_RIGHT_INSET_PX, COMPACT_AXIS_HEIGHT, axisDrawn, axisGutterLeft, } from './axisPlacement.js';
61
+ export { default as AxisGutter } from './AxisGutter.js';
62
+ export { default as YScaleBar } from './YScaleBar.js';
63
+ export { default as YScaleBarOverlay } from './YScaleBarOverlay.js';
64
+ export { default as ScoreDomainCaption, SCORE_CAPTION_HEIGHT, } from './ScoreDomainCaption.js';
65
+ export { formatScore } from './formatScore.js';
66
+ export { CrossHatchLines, default as CrossHatches } from './CrossHatches.js';
67
+ export { ScoreRuleLines, default as ScoreRules } from './ScoreRules.js';
@@ -0,0 +1,50 @@
1
+ import type { YScaleTicks } from './yScaleTicks.ts';
2
+ export type AxisSide = 'left' | 'right';
3
+ /**
4
+ * #api
5
+ * A value scale a display places its y through, declared so the chrome can
6
+ * derive the axis from it — the score axis's counterpart to a colour scale.
7
+ *
8
+ * `domain` is the resolved `[min, max]`; `height` the band it rules and
9
+ * `offset` the inset of the plot box inside that band (`axisPlotBox(height,
10
+ * offset)`); `minimalTicks` and `symlogConstant` how the ticks are chosen.
11
+ * `ticks` is the display's own ladder where the band's arithmetic is its own
12
+ * (the coverage band's octaves against the shader's box, the read cloud's
13
+ * decades against the arc geometry); `ScoreScaleMixin` derives one through
14
+ * `computeYTicks` otherwise.
15
+ *
16
+ * `bandTops` is the screen y of each band the scale rules — one at 0 unless
17
+ * the display stacks the same plot: the multi-wiggle's rows, a grouped
18
+ * alignments track's coverage band per group, each projected through the
19
+ * display's own scroll; the chrome drops the ones off screen. `[]` is a scale
20
+ * the display maps to colour rather than to y (density rows each in their own
21
+ * colour), which gets the `[min, max]` caption and no axis, as a scale whose
22
+ * bands are too short for one does. `side` and `left` are which of the band's
23
+ * edges the display's own panels leave clear for a gutter: `right` where a
24
+ * group label chip takes the left, `left: n` where a dendrogram takes the
25
+ * first `n` px. `caption` is what the scale measures (`TLEN`), as a colour
26
+ * scale's `field` is.
27
+ *
28
+ * A member describes the scale or the band it rules, never the axis — not an
29
+ * orientation, a form, a gutter width or a font. The chrome reads a member to
30
+ * keep off what the display put in the band, never to choose between two
31
+ * drawings of it (ADR-109).
32
+ */
33
+ export interface ValueScale {
34
+ domain: [number, number] | undefined;
35
+ scaleType: string;
36
+ height: number;
37
+ offset?: number;
38
+ minimalTicks?: boolean;
39
+ symlogConstant?: number;
40
+ ticks?: YScaleTicks;
41
+ bandTops?: number[];
42
+ side?: AxisSide;
43
+ left?: number;
44
+ caption?: string;
45
+ }
46
+ /** A value scale whose ticks resolved: what the chrome draws. */
47
+ export interface YAxis extends ValueScale {
48
+ domain: [number, number];
49
+ ticks: YScaleTicks;
50
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare const YSCALEBAR_LABEL_OFFSET = 5;
@@ -0,0 +1,5 @@
1
+ // Vertical inset a plot box reserves above and below itself so the top and
2
+ // bottom y-axis labels aren't clipped. Its own module because both index.ts
3
+ // (which re-exports computeYTicks) and computeYTicks.ts need it — importing it
4
+ // from index.ts would close a cycle.
5
+ export const YSCALEBAR_LABEL_OFFSET = 5;
@@ -0,0 +1,48 @@
1
+ export interface YScaleTicks {
2
+ items: {
3
+ value: number;
4
+ y: number;
5
+ label?: string;
6
+ }[];
7
+ yTop: number;
8
+ yBottom: number;
9
+ }
10
+ /**
11
+ * The plot box a wiggle-family display insets inside its own height, for both
12
+ * sides of the contract above: the renderer positions its canvas at `yTop` and
13
+ * draws `plotHeight` tall, and the axis puts domain-max at `yTop` and
14
+ * domain-min at `yBottom`.
15
+ *
16
+ * One function because the two have to agree exactly, and `height - 2 * offset`
17
+ * spelled out at each end is how they stopped: `computeYTicks` derived its own
18
+ * bottom as `height - offset - 1`, and every tick above it inherited a fraction
19
+ * of that pixel.
20
+ */
21
+ export declare function axisPlotBox(height: number, offset?: number): {
22
+ yTop: number;
23
+ yBottom: number;
24
+ plotHeight: number;
25
+ };
26
+ /**
27
+ * Screen y for an already-normalized ([0,1], 0 at domain-min) score. The
28
+ * composition of {@link axisPlotBox} with a `makeScoreNormalizer` result, which
29
+ * is what a tick at that score must land on.
30
+ */
31
+ export declare function scoreToAxisY(normalized: number, box: ReturnType<typeof axisPlotBox>): number;
32
+ /**
33
+ * Width of the gutter an axis is drawn in, on screen and in the export
34
+ * margin alike. `YScaleBar` grows its ticks and numbers away from the spine,
35
+ * and `x = ±9`, `fontSize 10` put a label of ordinary width inside this many
36
+ * px; the alignments coverage axis and the MAF band axes had each arrived at
37
+ * the same number independently, so a change to the labels moved neither.
38
+ */
39
+ export declare const AXIS_GUTTER_WIDTH_PX = 50;
40
+ /**
41
+ * Keep a 1px stroke's center inside the axis box. At `yBottom` — the box's own
42
+ * bottom edge — the stroke has to cover the pixel *above* the boundary, since
43
+ * the one below belongs to whatever is drawn next; in multi-wiggle, where rows
44
+ * stack edge to edge, that is literally the next sample's first row of pixels.
45
+ * Callers pass whatever stroke center their own convention gives (the axis
46
+ * crispens to `y + 0.5`, the guide lines don't); only the bottom edge moves.
47
+ */
48
+ export declare function clampStrokeInsideAxis(strokeY: number, yBottom: number): number;
@@ -0,0 +1,46 @@
1
+ import { YSCALEBAR_LABEL_OFFSET } from './yAxisConstants.js';
2
+ /**
3
+ * The plot box a wiggle-family display insets inside its own height, for both
4
+ * sides of the contract above: the renderer positions its canvas at `yTop` and
5
+ * draws `plotHeight` tall, and the axis puts domain-max at `yTop` and
6
+ * domain-min at `yBottom`.
7
+ *
8
+ * One function because the two have to agree exactly, and `height - 2 * offset`
9
+ * spelled out at each end is how they stopped: `computeYTicks` derived its own
10
+ * bottom as `height - offset - 1`, and every tick above it inherited a fraction
11
+ * of that pixel.
12
+ */
13
+ export function axisPlotBox(height, offset = YSCALEBAR_LABEL_OFFSET) {
14
+ return {
15
+ yTop: offset,
16
+ yBottom: height - offset,
17
+ plotHeight: height - 2 * offset,
18
+ };
19
+ }
20
+ /**
21
+ * Screen y for an already-normalized ([0,1], 0 at domain-min) score. The
22
+ * composition of {@link axisPlotBox} with a `makeScoreNormalizer` result, which
23
+ * is what a tick at that score must land on.
24
+ */
25
+ export function scoreToAxisY(normalized, box) {
26
+ return box.yTop + (1 - normalized) * box.plotHeight;
27
+ }
28
+ /**
29
+ * Width of the gutter an axis is drawn in, on screen and in the export
30
+ * margin alike. `YScaleBar` grows its ticks and numbers away from the spine,
31
+ * and `x = ±9`, `fontSize 10` put a label of ordinary width inside this many
32
+ * px; the alignments coverage axis and the MAF band axes had each arrived at
33
+ * the same number independently, so a change to the labels moved neither.
34
+ */
35
+ export const AXIS_GUTTER_WIDTH_PX = 50;
36
+ /**
37
+ * Keep a 1px stroke's center inside the axis box. At `yBottom` — the box's own
38
+ * bottom edge — the stroke has to cover the pixel *above* the boundary, since
39
+ * the one below belongs to whatever is drawn next; in multi-wiggle, where rows
40
+ * stack edge to edge, that is literally the next sample's first row of pixels.
41
+ * Callers pass whatever stroke center their own convention gives (the axis
42
+ * crispens to `y + 0.5`, the guide lines don't); only the bottom edge moves.
43
+ */
44
+ export function clampStrokeInsideAxis(strokeY, yBottom) {
45
+ return Math.min(strokeY, yBottom - 0.5);
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbrowse/display-ui",
3
- "version": "5.0.0-beta.4",
3
+ "version": "5.0.0-beta.7",
4
4
  "type": "module",
5
5
  "description": "The UI a display draws that is not data: the swappable chrome contract, its toolkit-free implementations, and the track overlay layer",
6
6
  "keywords": [
@@ -18,13 +18,17 @@
18
18
  },
19
19
  "author": "JBrowse Team",
20
20
  "main": "esm/index.js",
21
+ "exports": {
22
+ ".": "./esm/index.js",
23
+ "./yAxisConstants": "./esm/yAxisConstants.js"
24
+ },
21
25
  "files": [
22
26
  "esm"
23
27
  ],
24
28
  "dependencies": {
25
29
  "@jbrowse/mobx-state-tree": "^6.5.1",
26
30
  "mobx-react": "^10.0.2",
27
- "@jbrowse/core": "^5.0.0-beta.4"
31
+ "@jbrowse/core": "^5.0.0-beta.7"
28
32
  },
29
33
  "peerDependencies": {
30
34
  "react": ">=19.0.0",
@@ -41,8 +45,5 @@
41
45
  "clean": "rimraf esm *.tsbuildinfo",
42
46
  "prebuild": "pnpm clean"
43
47
  },
44
- "types": "esm/index.d.ts",
45
- "exports": {
46
- ".": "./esm/index.js"
47
- }
48
+ "types": "esm/index.d.ts"
48
49
  }