@jbrowse/display-ui 4.3.0 → 5.0.0-beta.10

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.
Files changed (61) hide show
  1. package/README.md +53 -13
  2. package/esm/AxisCaption.d.ts +24 -0
  3. package/esm/AxisCaption.js +57 -0
  4. package/esm/AxisGutter.d.ts +12 -0
  5. package/esm/AxisGutter.js +15 -0
  6. package/esm/CrossHatches.d.ts +12 -0
  7. package/esm/CrossHatches.js +31 -0
  8. package/esm/FloatingLegend.d.ts +4 -6
  9. package/esm/FloatingLegend.js +93 -29
  10. package/esm/FloatingSvgOverlay.d.ts +5 -0
  11. package/esm/FloatingSvgOverlay.js +25 -0
  12. package/esm/FloatingText.d.ts +50 -0
  13. package/esm/FloatingText.js +48 -0
  14. package/esm/PointerLayer.d.ts +12 -18
  15. package/esm/PointerLayer.js +14 -20
  16. package/esm/ScoreDomainCaption.d.ts +15 -0
  17. package/esm/ScoreDomainCaption.js +23 -0
  18. package/esm/ScoreRules.d.ts +21 -0
  19. package/esm/ScoreRules.js +46 -0
  20. package/esm/YScaleBar.d.ts +6 -0
  21. package/esm/YScaleBar.js +47 -0
  22. package/esm/YScaleBarOverlay.d.ts +11 -0
  23. package/esm/YScaleBarOverlay.js +20 -0
  24. package/esm/axisPlacement.d.ts +44 -0
  25. package/esm/axisPlacement.js +70 -0
  26. package/esm/bottomRightCorner.d.ts +3 -4
  27. package/esm/bottomRightCorner.js +3 -4
  28. package/esm/chromeOverlayContext.d.ts +1 -1
  29. package/esm/chromeOverlayContext.js +4 -5
  30. package/esm/chromeOverlays.d.ts +4 -2
  31. package/esm/cullOverlappingLabels.d.ts +14 -0
  32. package/esm/cullOverlappingLabels.js +31 -0
  33. package/esm/embed/index.d.ts +70 -0
  34. package/esm/embed/index.js +72 -0
  35. package/esm/embed/legend.d.ts +10 -0
  36. package/esm/embed/legend.js +40 -0
  37. package/esm/embed/location.d.ts +17 -0
  38. package/esm/embed/location.js +65 -0
  39. package/esm/embed/provider.d.ts +11 -0
  40. package/esm/embed/provider.js +6 -0
  41. package/esm/embed/regions.d.ts +53 -0
  42. package/esm/embed/regions.js +95 -0
  43. package/esm/floatingLegendTop.d.ts +2 -0
  44. package/esm/floatingLegendTop.js +3 -0
  45. package/esm/index.d.ts +19 -0
  46. package/esm/index.js +24 -0
  47. package/esm/plainChromeOverlays.d.ts +1 -1
  48. package/esm/plainChromeOverlays.js +6 -6
  49. package/esm/tooltip/Tooltip.d.ts +3 -3
  50. package/esm/tooltip/Tooltip.js +3 -3
  51. package/esm/trackControl/trackControlContext.d.ts +1 -1
  52. package/esm/trackControl/trackControlContext.js +6 -6
  53. package/esm/trackOverlay/TrackOverlaySlot.d.ts +2 -2
  54. package/esm/trackOverlay/TrackOverlaySlot.js +2 -2
  55. package/esm/valueScale.d.ts +72 -0
  56. package/esm/valueScale.js +1 -0
  57. package/esm/yAxisConstants.d.ts +7 -0
  58. package/esm/yAxisConstants.js +19 -0
  59. package/esm/yScaleTicks.d.ts +49 -0
  60. package/esm/yScaleTicks.js +47 -0
  61. package/package.json +11 -7
package/README.md CHANGED
@@ -36,7 +36,7 @@ above them.
36
36
 
37
37
  | | |
38
38
  | ------------------------------------------- | --------------------------------------------------------------------------------------- |
39
- | `DisplayChromeOverlays` | the five `displayPhase` states, as a component set, with the model shape each is handed |
39
+ | `DisplayChromeOverlays` | the `displayPhase` states, as a five-component set, with the model shape each is handed |
40
40
  | `DisplayChromeOverlayProvider` | redirects those states for JBrowse's own displays |
41
41
  | `TrackControlComponent` | one shape for every ambient bottom-right control, icons named rather than passed |
42
42
  | `TrackControlProvider` | redirects those |
@@ -89,10 +89,9 @@ and the status text disappearing under them.
89
89
  It has never been reachable: the two displays that render the control row
90
90
  (alignments, canvas) are not among the four that report a `ready`-phase status
91
91
  (clustering, on multi-wiggle / multi-row features / maf / the multi-sample
92
- variant pair). That is the _reason_ to make it structural rather than to leave
93
- it — nothing on either side is aware of the constraint, so the first display to
94
- want both would find the bug, and it presents as a status message that silently
95
- never appears.
92
+ variant pair). The layout is structural anyway, because neither side checks for
93
+ the constraint, so the first display to want both would hit the bug as a status
94
+ message that never appears.
96
95
 
97
96
  `BottomRightIndicators` already described itself as "the single anchor point for
98
97
  every bottom-right overlay ... so they lay out as one row instead of each
@@ -208,9 +207,9 @@ question only exists for an MST node.
208
207
 
209
208
  ### plainChromeOverlays
210
209
 
211
- The five `displayPhase` states drawn with no UI toolkit: no theme provider to
212
- mount, no emotion in the host page, and nothing that reads as a stray Material
213
- widget inside someone else's design system.
210
+ The `displayPhase` states drawn with no UI toolkit: no theme provider to mount,
211
+ no emotion in the host page, and nothing that reads as a stray Material widget
212
+ inside someone else's design system.
214
213
 
215
214
  `DisplayUIProvider` installs this by default, so mount that rather than naming
216
215
  this — reach for it directly only to wrap a state or to build a context value by
@@ -288,9 +287,8 @@ toolkit, so the package's no-Material-UI guarantee holds.
288
287
  Takes a single element child and clones it rather than wrapping it: the controls
289
288
  that want a tooltip are absolutely positioned inside a legend or sit in a flex
290
289
  row, where an extra `<span>` moves them. The child keeps its own handlers —
291
- these compose on top of them. This is the same trigger pattern MUI's own
292
- `Tooltip` uses, which is what the rest of JBrowse's chrome is built on; the
293
- toolkit is what differs here, not the shape.
290
+ these compose on top of them. MUI's `Tooltip`, which the rest of JBrowse's
291
+ chrome is built on, uses the same trigger pattern; only the toolkit differs.
294
292
 
295
293
  ```tsx
296
294
  <Tooltip title="Hide legend">
@@ -366,8 +364,8 @@ under them with nothing to say so.
366
364
  this component: JBrowse's own track container passes 100, which is positioned
367
365
  above `PaddingBlocks` and below `TrackLabel` at 200, and means nothing to a host
368
366
  whose masks sit at 2. A default would be a number that is right in one layout
369
- and silently wrong in every other, and the failure — chrome painted under a mask
370
- — is invisible until someone looks at the right zoom.
367
+ and wrong in every other, and the failure — chrome painted under a mask — goes
368
+ unseen until someone looks at the right zoom.
371
369
 
372
370
  The node takes no pointer events, so it does not eat the canvas's. Chrome that
373
371
  wants them takes them back on its own positioned box; it also carries
@@ -430,4 +428,46 @@ carries position only.
430
428
 
431
429
  [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/display-ui/src/trackControl/useTrackControlMenu.tsx)
432
430
 
431
+ ### ValueScale
432
+
433
+ A value scale a display places its y through, declared so the chrome can derive
434
+ the axis from it — the score axis's counterpart to a colour scale.
435
+
436
+ `domain` is the resolved `[min, max]`; `height` the band it rules and `offset`
437
+ the inset of the plot box inside that band (`axisPlotBox(height, offset)`);
438
+ `minimalTicks` and `symlogConstant` how the ticks are chosen. `ticks` is the
439
+ display's own ladder where the band's arithmetic is its own (the coverage band's
440
+ octaves against the shader's box, the read cloud's decades against the arc
441
+ geometry); `ScoreScaleMixin` derives one through `computeYTicks` otherwise.
442
+
443
+ `bandTops` is the screen y of each band the scale rules — one at 0 unless the
444
+ display stacks the same plot: the multi-wiggle's rows, a grouped alignments
445
+ track's coverage band per group, each projected through the display's own
446
+ scroll; the chrome drops the ones off screen. `[]` is a scale the display maps
447
+ to colour rather than to y (density rows each in their own colour), which gets
448
+ the `[min, max]` caption and no axis, as a scale whose bands are too short for
449
+ one does. `side` and `left` are which of the band's edges the display's own
450
+ panels leave clear for a gutter: `right` where a group label chip takes the
451
+ left, `left: n` where a dendrogram takes the first `n` px. `caption` is what the
452
+ scale measures (`TLEN`), as a colour scale's `field` is; the chrome draws it
453
+ once for the scale, beside the bands on screen, however many it rules. `rules`
454
+ are the reference lines the scale declares and `grid` a line at every tick, both
455
+ drawn across every band.
456
+
457
+ A member describes the scale or the band it rules, never the axis — not an
458
+ orientation, a form, a gutter width or a font. The chrome reads a member to keep
459
+ off what the display put in the band, never to choose between two drawings of it
460
+ (ADR-109).
461
+
462
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/display-ui/src/valueScale.ts)
463
+
464
+ ### ValueScaleRule
465
+
466
+ One reference line a value scale declares, at a value in the scale's own units.
467
+ `label` is free text and carries no meaning the chrome assigns: over a coverage
468
+ plot "2 copies" is a claim only the author can make, since no ploidy can be
469
+ assumed.
470
+
471
+ [Source code](https://github.com/GMOD/jbrowse-components/blob/main/packages/display-ui/src/valueScale.ts)
472
+
433
473
  <!-- API_DOCS_END -->
@@ -0,0 +1,24 @@
1
+ import type { YAxis } from './valueScale.ts';
2
+ /**
3
+ * A scale's caption, drawn once however many bands the scale rules: rotated
4
+ * along the outer edge of the gutter column, the origin at the column's top
5
+ * left, centred on the bands given as nearly as the display's `height` holds
6
+ * it whole, and cut to that height where it is longer. The on-screen overlay
7
+ * and the export both draw through this, so the two cannot place a caption
8
+ * differently.
9
+ */
10
+ export default function AxisCaption({ axis, bandTops, height, }: {
11
+ axis: YAxis;
12
+ bandTops: number[];
13
+ height: number;
14
+ }): import("react").JSX.Element | null;
15
+ /**
16
+ * The on-screen caption of one scale: an `<svg>` over the gutter column, the
17
+ * height of the display, with `AxisCaption` inside.
18
+ */
19
+ export declare function AxisCaptionOverlay({ axis, bandTops, width, height, }: {
20
+ axis: YAxis;
21
+ bandTops: number[];
22
+ width: number;
23
+ height: number;
24
+ }): import("react").JSX.Element;
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { usePalette } from '@jbrowse/core/ui/PaletteContext';
3
+ import { VERTICAL_SCROLLBAR_CLEARANCE } from '@jbrowse/core/ui/VerticalScrollbar';
4
+ import { measureText } from '@jbrowse/core/util/measureText';
5
+ import { axisCaptionY, axisGutterLeft, axisGutterWidth, } from './axisPlacement.js';
6
+ import { AXIS_FONT_PX, CAPTION_INSET_PX } from './yAxisConstants.js';
7
+ // The caption and its length, cut to an ellipsis where it is longer than
8
+ // `room`, as a Vega-Lite axis title is cut at its `titleLimit`. The ellipsis
9
+ // is one em.
10
+ function fitCaption(caption, room) {
11
+ const whole = measureText(caption, AXIS_FONT_PX);
12
+ if (whole <= room) {
13
+ return { text: caption, length: whole };
14
+ }
15
+ for (let end = caption.length - 1; end > 0; end--) {
16
+ const length = measureText(caption.slice(0, end), AXIS_FONT_PX) + AXIS_FONT_PX;
17
+ if (length <= room) {
18
+ return { text: `${caption.slice(0, end)}…`, length };
19
+ }
20
+ }
21
+ return undefined;
22
+ }
23
+ /**
24
+ * A scale's caption, drawn once however many bands the scale rules: rotated
25
+ * along the outer edge of the gutter column, the origin at the column's top
26
+ * left, centred on the bands given as nearly as the display's `height` holds
27
+ * it whole, and cut to that height where it is longer. The on-screen overlay
28
+ * and the export both draw through this, so the two cannot place a caption
29
+ * differently.
30
+ */
31
+ export default function AxisCaption({ axis, bandTops, height, }) {
32
+ const palette = usePalette();
33
+ const x = axis.side === 'right'
34
+ ? axisGutterWidth(axis) - CAPTION_INSET_PX
35
+ : CAPTION_INSET_PX;
36
+ const caption = fitCaption(axis.caption ?? '', height);
37
+ if (!caption) {
38
+ return null;
39
+ }
40
+ const half = caption.length / 2;
41
+ const y = Math.min(Math.max(axisCaptionY(axis, bandTops), half), height - half);
42
+ return (_jsx("text", { x: x, y: y, fontSize: AXIS_FONT_PX, textAnchor: "middle", fill: palette.text.primary, stroke: palette.background.default, strokeWidth: 2.5, paintOrder: "stroke", transform: `rotate(-90, ${x}, ${y})`, children: caption.text }));
43
+ }
44
+ /**
45
+ * The on-screen caption of one scale: an `<svg>` over the gutter column, the
46
+ * height of the display, with `AxisCaption` inside.
47
+ */
48
+ export function AxisCaptionOverlay({ axis, bandTops, width, height, }) {
49
+ return (_jsx("svg", { style: {
50
+ position: 'absolute',
51
+ top: 0,
52
+ left: axisGutterLeft(axis, width, VERTICAL_SCROLLBAR_CLEARANCE),
53
+ pointerEvents: 'none',
54
+ height,
55
+ width: axisGutterWidth(axis),
56
+ }, children: _jsx(AxisCaption, { axis: axis, bandTops: bandTops, height: height }) }));
57
+ }
@@ -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. The
6
+ * on-screen overlay and the export both draw through this, so the two cannot
7
+ * place a spine differently. The scale's caption is `AxisCaption`'s, drawn once
8
+ * for the scale and not per band.
9
+ */
10
+ export default function AxisGutter({ axis }: {
11
+ axis: YAxis;
12
+ }): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import YScaleBar from './YScaleBar.js';
3
+ import { axisGutterWidth } from './axisPlacement.js';
4
+ /**
5
+ * One band's axis drawn inside its gutter, the origin at the gutter's left
6
+ * edge: the spine on the gutter's inner edge — its right for a left-side
7
+ * axis, its left for a right-side one — with the numbers growing outward. The
8
+ * on-screen overlay and the export both draw through this, so the two cannot
9
+ * place a spine differently. The scale's caption is `AxisCaption`'s, drawn once
10
+ * for the scale and not per band.
11
+ */
12
+ export default function AxisGutter({ axis }) {
13
+ const right = axis.side === 'right';
14
+ return (_jsx("g", { transform: `translate(${right ? 0 : axisGutterWidth(axis)} 0)`, children: _jsx(YScaleBar, { ticks: axis.ticks, orientation: right ? 'right' : 'left', bandHeight: axis.height }) }));
15
+ }
@@ -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,16 +1,14 @@
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, }: {
5
- items?: LegendItem[];
6
- sections?: LegendSection[];
2
+ export type { LegendGradient, LegendItem, LegendSection, LegendSpec, LegendSwatch, } from '@jbrowse/core/ui/legendSpec';
3
+ declare const FloatingLegend: ({ sections, title, onDismiss, onDismissSection, onItemClick, maxItems, maxWidth, top, right, }: {
4
+ sections: readonly LegendSection[];
7
5
  title?: string;
8
- children?: ReactNode;
9
6
  onDismiss?: () => void;
10
7
  onDismissSection?: (id: string) => void;
11
8
  onItemClick?: (item: LegendItem, section: LegendSection) => void;
12
9
  maxItems?: number;
13
10
  maxWidth?: number;
14
11
  top?: number;
12
+ right?: number;
15
13
  }) => import("react").JSX.Element | null;
16
14
  export default FloatingLegend;
@@ -8,12 +8,12 @@ import { LegendSwatchGlyph } from '@jbrowse/core/ui/LegendSwatchGlyph';
8
8
  import { legendSwatches, nonEmptyLegendSections, } from '@jbrowse/core/ui/legendSpec';
9
9
  import { cx, makeStyles } from '@jbrowse/core/util/tss-react';
10
10
  import { observer } from 'mobx-react';
11
+ import { FLOATING_LEGEND_RIGHT_PX, FLOATING_LEGEND_TOP_PX, } from './floatingLegendTop.js';
11
12
  import Tooltip from './tooltip/Tooltip.js';
12
13
  import { TrackOverlayPortal } from './trackOverlay/TrackOverlayPortal.js';
13
14
  const useStyles = makeStyles()(theme => ({
14
15
  legend: {
15
16
  position: 'absolute',
16
- right: 10,
17
17
  background: theme.palette.background.paper,
18
18
  // A hairline edge, because `background.paper` on a track whose canvas is
19
19
  // also paper-white is no edge at all: the key read as loose text floating
@@ -25,6 +25,9 @@ const useStyles = makeStyles()(theme => ({
25
25
  border: `1px solid ${theme.palette.divider}`,
26
26
  borderRadius: 3,
27
27
  padding: 3,
28
+ // Two keys on one short display ran past its bottom edge and were cut
29
+ // there; the box scrolls inside the display instead.
30
+ overflowY: 'auto',
28
31
  fontSize: 10,
29
32
  zIndex: 100,
30
33
  // the default; `maxWidth` overrides it per display
@@ -87,6 +90,13 @@ const useStyles = makeStyles()(theme => ({
87
90
  overflow: 'hidden',
88
91
  textOverflow: 'ellipsis',
89
92
  },
93
+ // Wraps where every other line ellipsizes: a note is a sentence, and half of
94
+ // one says nothing.
95
+ sectionNote: {
96
+ color: theme.palette.text.secondary,
97
+ whiteSpace: 'normal',
98
+ marginBottom: 2,
99
+ },
90
100
  sectionClose: {
91
101
  font: 'inherit',
92
102
  lineHeight: 1,
@@ -134,6 +144,28 @@ const useStyles = makeStyles()(theme => ({
134
144
  overflow: 'hidden',
135
145
  textOverflow: 'ellipsis',
136
146
  },
147
+ hiddenItem: {
148
+ opacity: 0.4,
149
+ textDecoration: 'line-through',
150
+ },
151
+ gradientRow: {
152
+ marginBottom: 1,
153
+ },
154
+ gradientBar: {
155
+ width: GRADIENT_BAR_WIDTH,
156
+ height: 10,
157
+ borderRadius: 2,
158
+ border: `1px solid ${theme.palette.divider}`,
159
+ // clip the gradient to the padding box so it doesn't paint under the
160
+ // translucent divider border — otherwise the border composites over the
161
+ // gradient's end and the edge renders a dark sliver
162
+ backgroundClip: 'padding-box',
163
+ },
164
+ gradientLabels: {
165
+ display: 'flex',
166
+ justifyContent: 'space-between',
167
+ width: GRADIENT_BAR_WIDTH,
168
+ },
137
169
  // was `<Link component="button" underline="hover">`, which is the same
138
170
  // element with Material's typography on it — see `closeButton` above for why
139
171
  // that mattered
@@ -146,7 +178,7 @@ const useStyles = makeStyles()(theme => ({
146
178
  border: 0,
147
179
  background: 'none',
148
180
  textAlign: 'left',
149
- color: theme.palette.primary.main,
181
+ color: theme.palette.accent,
150
182
  cursor: 'pointer',
151
183
  '&:hover': {
152
184
  textDecoration: 'underline',
@@ -155,56 +187,88 @@ const useStyles = makeStyles()(theme => ({
155
187
  }));
156
188
  const DEFAULT_MAX_ITEMS = 12;
157
189
  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.
190
+ const GRADIENT_BAR_WIDTH = 100;
191
+ // Side of one swatch box.
160
192
  const SWATCH = 12;
193
+ // A ramp row: the caption, the bar and the domain ends. The bar is a CSS
194
+ // gradient over the same stops the export's <linearGradient> takes, so the
195
+ // two draw one ramp. Stops are flattened over the paper because a translucent
196
+ // first stop (Hi-C's juicebox fade) rasterizes to a dark sliver at the bar's
197
+ // left edge.
198
+ function gradientCss({ stops }, paper) {
199
+ const list = stops.map(({ color, opacity = 1, offset }) => {
200
+ const flat = opacity >= 1
201
+ ? color
202
+ : `color-mix(in srgb, ${color} ${Math.round(opacity * 100)}%, ${paper})`;
203
+ return `${flat} ${offset * 100}%`;
204
+ });
205
+ return `linear-gradient(to right, ${list.join(', ')})`;
206
+ }
207
+ function GradientRow({ label, gradient, }) {
208
+ const { classes, theme } = useStyles();
209
+ return (_jsxs("div", { className: classes.gradientRow, children: [label ? _jsx("div", { className: classes.label, children: label }) : null, _jsx("div", { className: classes.gradientBar,
210
+ // backgroundImage (longhand), not the `background` shorthand: the
211
+ // shorthand resets background-clip back to border-box, undoing the
212
+ // padding-box clip
213
+ style: {
214
+ backgroundImage: gradientCss(gradient, theme.palette.background.paper),
215
+ } }), _jsxs("div", { className: classes.gradientLabels, children: [_jsx("span", { children: gradient.minLabel }), _jsx("span", { children: gradient.maxLabel })] })] }));
216
+ }
161
217
  // One list of swatches with its own independent collapse state, so each section
162
218
  // in a multi-section legend expands/collapses on its own.
163
219
  const LegendItemList = observer(function LegendItemList({ items, maxItems, onItemClick, }) {
164
220
  const { classes } = useStyles();
165
221
  const [expanded, setExpanded] = useState(false);
166
- const collapsible = items.length > maxItems;
222
+ // A toggle revealing one row costs the row it saves.
223
+ const collapsible = items.length > maxItems + 1;
167
224
  const shown = collapsible && !expanded ? items.slice(0, maxItems) : items;
168
225
  const hiddenCount = items.length - maxItems;
169
- // 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
171
- // heading row indents like the swatches it heads, as the fixed-size box it
172
- // replaces used to.
173
- const columns = Math.max(1, ...shown.map(i => legendSwatches(i).length));
226
+ // Every row reserves the widest row's swatch column, collapsed or not, so
227
+ // labels stay in one vertical line and do not shift on expanding.
228
+ const columns = Math.max(1, ...items.map(i => legendSwatches(i).length));
174
229
  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 })] }));
176
- return onItemClick ? (_jsx("button", { type: "button", className: cx(classes.item, classes.itemButton), title: "Show only these rows", onClick: () => {
230
+ if (item.gradient) {
231
+ return (_jsx(GradientRow
232
+ // eslint-disable-next-line @eslint-react/no-array-index-key
233
+ , { label: item.label, gradient: item.gradient }, `${item.label}-${i}`));
234
+ }
235
+ 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.shape}-${swatch.color}`))) }), _jsx("span", { className: classes.label, children: item.label })] }));
236
+ const itemClass = cx(classes.item, item.hidden && classes.hiddenItem);
237
+ return onItemClick ? (_jsx("button", { type: "button", className: cx(itemClass, classes.itemButton), title: "Show only these rows", onClick: () => {
177
238
  onItemClick(item);
178
- }, children: row }, `${item.label}-${i}`)) : (_jsx("div", { className: classes.item, children: row }, `${item.label}-${i}`));
239
+ }, children: row }, `${item.label}-${i}`)) : (_jsx("div", { className: itemClass, children: row }, `${item.label}-${i}`));
179
240
  }), collapsible ? (_jsx("button", { type: "button", className: classes.toggle, onClick: () => {
180
241
  setExpanded(!expanded);
181
242
  }, children: expanded ? 'Show less' : `Show ${hiddenCount} more…` })) : null] }));
182
243
  });
183
- // Floating color-key overlay. Pass a flat `items` list for a single scheme, or
184
- // `sections` to show several titled, individually-closable panels in one box
185
- // (e.g. genotype colors vs. sample-grouping colors on the multi-sample variant
186
- // display). Section titles + per-section close buttons only appear when there
187
- // is more than one section, so a single-scheme legend looks unchanged. `title`
188
- // is a heading for the whole box, shown regardless of section count.
244
+ // Floating color-key overlay: one panel per section (e.g. genotype colors vs.
245
+ // sample-grouping colors on the multi-sample variant display). Section titles
246
+ // and per-section close buttons only appear when there is more than one
247
+ // section, so a single-scheme legend is just its rows. `title` is a heading
248
+ // for the whole box, shown regardless of section count.
189
249
  //
190
- // `onItemClick` makes the rows act — for a key whose entries name groups of
191
- // rows, clicking one focuses those rows. It is handed the section too, so a
192
- // display with several vocabularies can act on the one that names rows and
193
- // 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, }) {
250
+ // `onItemClick` makes the rows of a section declaring `focusesRows` act —
251
+ // clicking an entry focuses the rows it names. A section coloring features or
252
+ // cells stays inert text beside it.
253
+ const FloatingLegend = observer(function FloatingLegend({ sections, title, onDismiss, onDismissSection, onItemClick, maxItems = DEFAULT_MAX_ITEMS, maxWidth = DEFAULT_MAX_WIDTH, top = FLOATING_LEGEND_TOP_PX, right = FLOATING_LEGEND_RIGHT_PX, }) {
195
254
  const { classes } = useStyles();
196
- const nonEmpty = nonEmptyLegendSections({ items, sections });
197
- if (nonEmpty.length === 0 && !children) {
255
+ const nonEmpty = nonEmptyLegendSections(sections);
256
+ if (nonEmpty.length === 0) {
198
257
  return null;
199
258
  }
200
259
  const multiSection = nonEmpty.length > 1;
201
260
  // portal above the inter-region padding masks so the key isn't buried at
202
261
  // whole-genome / multi-region scale (see TrackOverlayPortal)
203
- 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: () => {
262
+ return (_jsx(TrackOverlayPortal, { children: _jsxs("div", { className: cx(classes.legend, onDismiss && classes.withClose), style: {
263
+ top,
264
+ right,
265
+ maxWidth,
266
+ maxHeight: `calc(100% - ${top + 4}px)`,
267
+ }, "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
268
  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: () => {
269
+ }, 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
270
  onDismissSection(section.id);
207
- }, children: "\u00D7" }) })) : null] })) : null, _jsx(LegendItemList, { items: section.items, maxItems: maxItems, onItemClick: onItemClick
271
+ }, children: "\u00D7" }) })) : null] })) : null, section.note ? (_jsx("div", { className: classes.sectionNote, children: section.note })) : null, _jsx(LegendItemList, { items: section.items, maxItems: maxItems, onItemClick: onItemClick && section.focusesRows
208
272
  ? item => {
209
273
  onItemClick(item, section);
210
274
  }
@@ -0,0 +1,5 @@
1
+ export declare const FloatingSvgOverlay: ({ width, height, children, }: {
2
+ width: number;
3
+ height: number;
4
+ children: React.ReactNode;
5
+ }) => import("react").JSX.Element;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { observer } from 'mobx-react';
3
+ import { TrackOverlayPortal } from './trackOverlay/TrackOverlayPortal.js';
4
+ // Lifts an SVG legend / chrome above the inter-region padding masks: a
5
+ // full-track, pointer-events:none <svg> portaled into the TrackContainer overlay
6
+ // node (see TrackOverlayPortal). The SVG counterpart to the HTML overlays
7
+ // (FloatingLegend, HicOverlayPanel) — used where the same legend
8
+ // markup is also drawn by an SVG-export path, so it must stay <svg>.
9
+ //
10
+ // Children draw in <svg> user space; the wrapping <svg> stays pointer-events:none
11
+ // so it doesn't eat canvas events, so any interactive sub-content (a dismiss
12
+ // "×", clickable swatches) must set pointer-events:auto on its own element.
13
+ // fallbackInline={false}: outside a TrackContainer it renders nothing, matching
14
+ // callers whose export path draws its own inline legend.
15
+ export const FloatingSvgOverlay = observer(function FloatingSvgOverlay({ width, height, children, }) {
16
+ return (_jsx(TrackOverlayPortal, { fallbackInline: false, children: _jsx("svg", { style: {
17
+ position: 'absolute',
18
+ top: 0,
19
+ left: 0,
20
+ width,
21
+ height,
22
+ pointerEvents: 'none',
23
+ overflow: 'hidden',
24
+ }, children: children }) }));
25
+ });
@@ -0,0 +1,50 @@
1
+ import type { CSSProperties, ComponentPropsWithoutRef, ReactNode } from 'react';
2
+ /**
3
+ * Four offset shadows in the surface colour: a halo under a line of DOM text
4
+ * that needs no measured stroke and reads the same over a mark and over the
5
+ * background.
6
+ */
7
+ export declare function textHalo(color: string): string;
8
+ /**
9
+ * Where a line box's glyph baseline sits below its top, as a fraction of the
10
+ * font size. A DOM label is positioned by its top and an exported `<text>` by
11
+ * its baseline, and this is what puts the two on one line.
12
+ */
13
+ export declare const TEXT_BASELINE_RATIO = 0.84;
14
+ /**
15
+ * One line of text floated over a display, at the px `x` and `y` of its line
16
+ * box's top-left corner: absolutely positioned, one line high, never wrapped.
17
+ * The canvas feature labels and the mark display's text marks emit through
18
+ * this, so the typography of a floated label is one declaration; what places
19
+ * each label is its display's own rule. `halo` draws {@link textHalo} under
20
+ * the glyphs in that colour. Everything else a caller passes reaches the div:
21
+ * a class for the pointer and the cursor, the data attributes a delegated
22
+ * handler reads, a `title`.
23
+ */
24
+ export declare function FloatingText({ x, y, color, fontSize, halo, style, children, ...rest }: {
25
+ x: number;
26
+ y: number;
27
+ color: string;
28
+ fontSize: number;
29
+ halo?: string;
30
+ style?: CSSProperties;
31
+ children: ReactNode;
32
+ } & Omit<ComponentPropsWithoutRef<'div'>, 'style' | 'color' | 'children'>): import("react").JSX.Element;
33
+ /**
34
+ * The same line in an SVG export: two `<text>`s at the baseline `y`, a stroke
35
+ * in the surface colour under the glyphs, since SVG paints stroke over fill
36
+ * and a thick stroke beneath a second copy is how a halo is drawn there. Both
37
+ * carry the size and family explicitly, because a `<text>` with neither takes
38
+ * SVG's 16px default in a saved file.
39
+ */
40
+ export declare function SvgHaloText({ x, y, fill, halo, haloWidth, fontSize, fontFamily, anchor, children, }: {
41
+ x: number;
42
+ y: number;
43
+ fill: string;
44
+ halo: string;
45
+ haloWidth: number | string;
46
+ fontSize: number;
47
+ fontFamily: string;
48
+ anchor?: 'start' | 'middle' | 'end';
49
+ children: ReactNode;
50
+ }): import("react").JSX.Element;
@@ -0,0 +1,48 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Four offset shadows in the surface colour: a halo under a line of DOM text
4
+ * that needs no measured stroke and reads the same over a mark and over the
5
+ * background.
6
+ */
7
+ export function textHalo(color) {
8
+ return `-1px -1px 0 ${color}, 1px -1px 0 ${color}, -1px 1px 0 ${color}, 1px 1px 0 ${color}`;
9
+ }
10
+ /**
11
+ * Where a line box's glyph baseline sits below its top, as a fraction of the
12
+ * font size. A DOM label is positioned by its top and an exported `<text>` by
13
+ * its baseline, and this is what puts the two on one line.
14
+ */
15
+ export const TEXT_BASELINE_RATIO = 0.84;
16
+ /**
17
+ * One line of text floated over a display, at the px `x` and `y` of its line
18
+ * box's top-left corner: absolutely positioned, one line high, never wrapped.
19
+ * The canvas feature labels and the mark display's text marks emit through
20
+ * this, so the typography of a floated label is one declaration; what places
21
+ * each label is its display's own rule. `halo` draws {@link textHalo} under
22
+ * the glyphs in that colour. Everything else a caller passes reaches the div:
23
+ * a class for the pointer and the cursor, the data attributes a delegated
24
+ * handler reads, a `title`.
25
+ */
26
+ export function FloatingText({ x, y, color, fontSize, halo, style, children, ...rest }) {
27
+ return (_jsx("div", { ...rest, style: {
28
+ position: 'absolute',
29
+ lineHeight: 1,
30
+ whiteSpace: 'nowrap',
31
+ color,
32
+ fontSize,
33
+ transform: `translate(${x}px, ${y}px)`,
34
+ ...(halo ? { textShadow: textHalo(halo) } : {}),
35
+ ...style,
36
+ }, children: children }));
37
+ }
38
+ /**
39
+ * The same line in an SVG export: two `<text>`s at the baseline `y`, a stroke
40
+ * in the surface colour under the glyphs, since SVG paints stroke over fill
41
+ * and a thick stroke beneath a second copy is how a halo is drawn there. Both
42
+ * carry the size and family explicitly, because a `<text>` with neither takes
43
+ * SVG's 16px default in a saved file.
44
+ */
45
+ export function SvgHaloText({ x, y, fill, halo, haloWidth, fontSize, fontFamily, anchor = 'start', children, }) {
46
+ const attrs = { x, y, fontSize, fontFamily, textAnchor: anchor };
47
+ return (_jsxs(_Fragment, { children: [_jsx("text", { ...attrs, stroke: halo, strokeWidth: haloWidth, strokeLinejoin: "round", children: children }), _jsx("text", { ...attrs, fill: fill, children: children })] }));
48
+ }