@jbrowse/wiggle-core 4.3.0

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 (63) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +292 -0
  3. package/esm/CrossHatches.d.ts +11 -0
  4. package/esm/CrossHatches.js +32 -0
  5. package/esm/ResolutionStepper.d.ts +17 -0
  6. package/esm/ResolutionStepper.js +56 -0
  7. package/esm/ScoreRules.d.ts +11 -0
  8. package/esm/ScoreRules.js +37 -0
  9. package/esm/ScoreScaleMixin.d.ts +105 -0
  10. package/esm/ScoreScaleMixin.js +136 -0
  11. package/esm/SetMinMaxDialog.d.ts +11 -0
  12. package/esm/SetMinMaxDialog.js +18 -0
  13. package/esm/YScaleBar.d.ts +6 -0
  14. package/esm/YScaleBar.js +46 -0
  15. package/esm/YScaleBarOverlay.d.ts +6 -0
  16. package/esm/YScaleBarOverlay.js +24 -0
  17. package/esm/autoscale.d.ts +55 -0
  18. package/esm/autoscale.js +252 -0
  19. package/esm/computeYTicks.d.ts +15 -0
  20. package/esm/computeYTicks.js +44 -0
  21. package/esm/constants.d.ts +2 -0
  22. package/esm/constants.js +10 -0
  23. package/esm/dataTypes.d.ts +26 -0
  24. package/esm/dataTypes.js +1 -0
  25. package/esm/displayModel.d.ts +24 -0
  26. package/esm/displayModel.js +1 -0
  27. package/esm/formatNumber.d.ts +1 -0
  28. package/esm/formatNumber.js +6 -0
  29. package/esm/gapBreak.d.ts +59 -0
  30. package/esm/gapBreak.js +58 -0
  31. package/esm/index.d.ts +36 -0
  32. package/esm/index.js +26 -0
  33. package/esm/normalize.d.ts +40 -0
  34. package/esm/normalize.js +106 -0
  35. package/esm/pointMarker.d.ts +3 -0
  36. package/esm/pointMarker.js +27 -0
  37. package/esm/pointSizeMenu.d.ts +13 -0
  38. package/esm/pointSizeMenu.js +21 -0
  39. package/esm/renderingBackendTypes.d.ts +39 -0
  40. package/esm/renderingBackendTypes.js +29 -0
  41. package/esm/resolveRenderState.d.ts +1 -0
  42. package/esm/resolveRenderState.js +20 -0
  43. package/esm/scale.d.ts +60 -0
  44. package/esm/scale.js +161 -0
  45. package/esm/scoreAxisConfigSchemaFields.d.ts +44 -0
  46. package/esm/scoreAxisConfigSchemaFields.js +54 -0
  47. package/esm/scoreMenuItems.d.ts +37 -0
  48. package/esm/scoreMenuItems.js +104 -0
  49. package/esm/scoreRuleMarks.d.ts +73 -0
  50. package/esm/scoreRuleMarks.js +98 -0
  51. package/esm/symlogTicks.d.ts +17 -0
  52. package/esm/symlogTicks.js +92 -0
  53. package/esm/transferables.d.ts +2 -0
  54. package/esm/transferables.js +32 -0
  55. package/esm/vendor/d3-scale.d.ts +33 -0
  56. package/esm/vendor/d3-scale.js +249 -0
  57. package/esm/visibleStatsDomain.d.ts +59 -0
  58. package/esm/visibleStatsDomain.js +33 -0
  59. package/esm/wiggleRenderModes.generated.d.ts +9 -0
  60. package/esm/wiggleRenderModes.generated.js +11 -0
  61. package/esm/yScaleTicks.d.ts +59 -0
  62. package/esm/yScaleTicks.js +59 -0
  63. package/package.json +54 -0
@@ -0,0 +1,105 @@
1
+ import type { ScoreAxisConfigModel } from './scoreAxisConfigSchemaFields.ts';
2
+ /**
3
+ * The whole of what `ScoreScaleMixin` needs a composing display to be. Exported
4
+ * because it is the mixin's contract and `ScoreScaleMixin.test.ts` pins it:
5
+ * widen it and the `@ts-expect-error`s there go unused.
6
+ */
7
+ export interface ScoreScaleHost {
8
+ configuration: ScoreAxisConfigModel;
9
+ }
10
+ /**
11
+ * #stateModel ScoreScaleMixin
12
+ * #category display
13
+ * #crossCuttingMixin Score axis. Nothing — the config slots. Brings `scaleType` / `autoscaleType` / `minScore` / `maxScore` / `*Bound` / `numStdDev` and their setters, i.e. the whole `ScoreScaleModel` interface the shared score menu and `SetMinMaxDialog` consume
14
+ *
15
+ * The score axis every quantitative display shares: which scale, how to
16
+ * autoscale it, and the manual min/max bounds. This is the runtime half of
17
+ * {@link ScoreScaleModel} in `scoreMenuItems.ts` — that interface is what the
18
+ * shared Score menu, the autoscale/scale submenus and `SetMinMaxDialog` consume,
19
+ * and it was already the canonical contract while two displays hand-wrote
20
+ * identical implementations of it (`WiggleScoreConfigMixin`, and the alignments
21
+ * coverage band). Composing this is now how a display satisfies it, so a new
22
+ * score display cannot satisfy it *partially*.
23
+ *
24
+ * Deliberately just the axis. Colors, `resolution`, cross-hatches and the
25
+ * autoscale *computation* stay in `WiggleScoreConfigMixin` / `WiggleCommonMixin`
26
+ * — the alignments coverage band shares this axis but none of the rest.
27
+ *
28
+ * `minScore`/`maxScore` are the **raw** slot values with their
29
+ * `Number.MIN_VALUE`/`Number.MAX_VALUE` "unset" sentinels intact, because that
30
+ * is what the dialog round-trips; `minScoreBound`/`maxScoreBound` are the
31
+ * resolved bounds, where `undefined` means "autoscale this end". Every consumer
32
+ * that computes a domain reads the `*Bound` pair.
33
+ */
34
+ export declare function ScoreScaleMixin(): import("@jbrowse/mobx-state-tree").IModelType<{}, {
35
+ /**
36
+ * #getter
37
+ */
38
+ readonly scaleType: string;
39
+ /**
40
+ * #getter
41
+ */
42
+ readonly autoscaleType: string;
43
+ /**
44
+ * #getter
45
+ */
46
+ readonly numStdDev: number;
47
+ /**
48
+ * #getter
49
+ * Raw slot value, sentinel intact — see the class comment.
50
+ */
51
+ readonly minScore: number;
52
+ /**
53
+ * #getter
54
+ * Raw slot value, sentinel intact — see the class comment.
55
+ */
56
+ readonly maxScore: number;
57
+ /**
58
+ * #getter
59
+ * Overridable hook: what each end of the domain falls back to where the
60
+ * config leaves its bound unset. `[undefined, undefined]` — the default —
61
+ * means autoscale both ends, which is right for a track whose scores have
62
+ * no absolute meaning (a bigwig's units are its own).
63
+ *
64
+ * A display whose scores are bounded *by construction* overrides it, so
65
+ * the axis stops being a function of what happens to be on screen: GC
66
+ * content is a fraction, so 0 and 1 are its real limits and mean the same
67
+ * thing at every locus. Autoscaled, the same GC value drew at different
68
+ * heights depending on where the user had panned, and the track could not
69
+ * be read across loci.
70
+ *
71
+ * A hook rather than a config default because the answer can depend on
72
+ * display state — GC's does, on `gcMode` — and rather than each display
73
+ * re-resolving the sentinels below, which is the one thing that must not
74
+ * be duplicated: config bounds still win, precisely because they are
75
+ * checked before this is consulted.
76
+ */
77
+ readonly defaultScoreDomain: [number | undefined, number | undefined];
78
+ /**
79
+ * #getter
80
+ * Resolved lower bound; `undefined` means autoscale this end.
81
+ */
82
+ readonly minScoreBound: number | undefined;
83
+ /**
84
+ * #getter
85
+ * Resolved upper bound; `undefined` means autoscale this end.
86
+ */
87
+ readonly maxScoreBound: number | undefined;
88
+ } & {
89
+ /**
90
+ * #action
91
+ */
92
+ setScaleType(scaleType: string): void;
93
+ /**
94
+ * #action
95
+ */
96
+ setAutoscale(val?: string): void;
97
+ /**
98
+ * #action
99
+ */
100
+ setMinScore(val?: number): void;
101
+ /**
102
+ * #action
103
+ */
104
+ setMaxScore(val?: number): void;
105
+ }, import("@jbrowse/mobx-state-tree")._NotCustomized, import("@jbrowse/mobx-state-tree")._NotCustomized>;
@@ -0,0 +1,136 @@
1
+ import { getConf, setConf } from '@jbrowse/core/configuration';
2
+ import { types } from '@jbrowse/mobx-state-tree';
3
+ // The mixin composes onto a display that declares this, not the other way
4
+ // round, so its own `self` isn't typed with it. Cast once, narrowed to the
5
+ // field table beside it rather than `AnyConfigurationModel`, which is what keeps
6
+ // the slot names below checked.
7
+ const confNode = (self) => self;
8
+ /**
9
+ * #stateModel ScoreScaleMixin
10
+ * #category display
11
+ * #crossCuttingMixin Score axis. Nothing — the config slots. Brings `scaleType` / `autoscaleType` / `minScore` / `maxScore` / `*Bound` / `numStdDev` and their setters, i.e. the whole `ScoreScaleModel` interface the shared score menu and `SetMinMaxDialog` consume
12
+ *
13
+ * The score axis every quantitative display shares: which scale, how to
14
+ * autoscale it, and the manual min/max bounds. This is the runtime half of
15
+ * {@link ScoreScaleModel} in `scoreMenuItems.ts` — that interface is what the
16
+ * shared Score menu, the autoscale/scale submenus and `SetMinMaxDialog` consume,
17
+ * and it was already the canonical contract while two displays hand-wrote
18
+ * identical implementations of it (`WiggleScoreConfigMixin`, and the alignments
19
+ * coverage band). Composing this is now how a display satisfies it, so a new
20
+ * score display cannot satisfy it *partially*.
21
+ *
22
+ * Deliberately just the axis. Colors, `resolution`, cross-hatches and the
23
+ * autoscale *computation* stay in `WiggleScoreConfigMixin` / `WiggleCommonMixin`
24
+ * — the alignments coverage band shares this axis but none of the rest.
25
+ *
26
+ * `minScore`/`maxScore` are the **raw** slot values with their
27
+ * `Number.MIN_VALUE`/`Number.MAX_VALUE` "unset" sentinels intact, because that
28
+ * is what the dialog round-trips; `minScoreBound`/`maxScoreBound` are the
29
+ * resolved bounds, where `undefined` means "autoscale this end". Every consumer
30
+ * that computes a domain reads the `*Bound` pair.
31
+ */
32
+ export function ScoreScaleMixin() {
33
+ return types
34
+ .model('ScoreScaleMixin', {})
35
+ .views(self => ({
36
+ /**
37
+ * #getter
38
+ */
39
+ get scaleType() {
40
+ return getConf(confNode(self), 'scaleType');
41
+ },
42
+ /**
43
+ * #getter
44
+ */
45
+ get autoscaleType() {
46
+ return getConf(confNode(self), 'autoscale');
47
+ },
48
+ /**
49
+ * #getter
50
+ */
51
+ get numStdDev() {
52
+ return getConf(confNode(self), 'numStdDev');
53
+ },
54
+ /**
55
+ * #getter
56
+ * Raw slot value, sentinel intact — see the class comment.
57
+ */
58
+ get minScore() {
59
+ return getConf(confNode(self), 'minScore');
60
+ },
61
+ /**
62
+ * #getter
63
+ * Raw slot value, sentinel intact — see the class comment.
64
+ */
65
+ get maxScore() {
66
+ return getConf(confNode(self), 'maxScore');
67
+ },
68
+ /**
69
+ * #getter
70
+ * Overridable hook: what each end of the domain falls back to where the
71
+ * config leaves its bound unset. `[undefined, undefined]` — the default —
72
+ * means autoscale both ends, which is right for a track whose scores have
73
+ * no absolute meaning (a bigwig's units are its own).
74
+ *
75
+ * A display whose scores are bounded *by construction* overrides it, so
76
+ * the axis stops being a function of what happens to be on screen: GC
77
+ * content is a fraction, so 0 and 1 are its real limits and mean the same
78
+ * thing at every locus. Autoscaled, the same GC value drew at different
79
+ * heights depending on where the user had panned, and the track could not
80
+ * be read across loci.
81
+ *
82
+ * A hook rather than a config default because the answer can depend on
83
+ * display state — GC's does, on `gcMode` — and rather than each display
84
+ * re-resolving the sentinels below, which is the one thing that must not
85
+ * be duplicated: config bounds still win, precisely because they are
86
+ * checked before this is consulted.
87
+ */
88
+ get defaultScoreDomain() {
89
+ return [undefined, undefined];
90
+ },
91
+ /**
92
+ * #getter
93
+ * Resolved lower bound; `undefined` means autoscale this end.
94
+ */
95
+ get minScoreBound() {
96
+ return this.minScore === Number.MIN_VALUE
97
+ ? this.defaultScoreDomain[0]
98
+ : this.minScore;
99
+ },
100
+ /**
101
+ * #getter
102
+ * Resolved upper bound; `undefined` means autoscale this end.
103
+ */
104
+ get maxScoreBound() {
105
+ return this.maxScore === Number.MAX_VALUE
106
+ ? this.defaultScoreDomain[1]
107
+ : this.maxScore;
108
+ },
109
+ }))
110
+ .actions(self => ({
111
+ /**
112
+ * #action
113
+ */
114
+ setScaleType(scaleType) {
115
+ setConf(confNode(self), 'scaleType', scaleType);
116
+ },
117
+ /**
118
+ * #action
119
+ */
120
+ setAutoscale(val) {
121
+ setConf(confNode(self), 'autoscale', val);
122
+ },
123
+ /**
124
+ * #action
125
+ */
126
+ setMinScore(val) {
127
+ setConf(confNode(self), 'minScore', val);
128
+ },
129
+ /**
130
+ * #action
131
+ */
132
+ setMaxScore(val) {
133
+ setConf(confNode(self), 'maxScore', val);
134
+ },
135
+ }));
136
+ }
@@ -0,0 +1,11 @@
1
+ declare const _default: (props: {
2
+ model: {
3
+ minScore: number;
4
+ maxScore: number;
5
+ scaleType: string;
6
+ setMinScore: (arg?: number) => void;
7
+ setMaxScore: (arg?: number) => void;
8
+ };
9
+ handleClose: () => void;
10
+ }) => import("react").JSX.Element;
11
+ export default _default;
@@ -0,0 +1,18 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { NumberTextField, SubmitDialog } from '@jbrowse/core/ui';
4
+ import { Typography } from '@mui/material';
5
+ import { observer } from 'mobx-react';
6
+ export default observer(function SetMinMaxDialog(props) {
7
+ const { model, handleClose } = props;
8
+ const { minScore, maxScore, scaleType } = model;
9
+ const [min, setMin] = useState(minScore === Number.MIN_VALUE ? undefined : minScore);
10
+ const [max, setMax] = useState(maxScore === Number.MAX_VALUE ? undefined : maxScore);
11
+ const rangeOk = min === undefined || max === undefined || max > min;
12
+ const logOk = !(scaleType === 'log' && min !== undefined && min <= 0);
13
+ return (_jsxs(SubmitDialog, { open: true, title: "Set min/max score for track", submitDisabled: !rangeOk || !logOk, onCancel: handleClose, onSubmit: () => {
14
+ model.setMinScore(min);
15
+ model.setMaxScore(max);
16
+ handleClose();
17
+ }, children: [_jsx(Typography, { children: "Enter min/max score: " }), !rangeOk ? (_jsx(Typography, { color: "error", children: "Max must be greater than min" })) : null, !logOk ? (_jsx(Typography, { color: "error", children: "Min score should be greater than 0 for log scale" })) : null, _jsx(NumberTextField, { defaultValue: min, onValueChange: setMin, placeholder: "Enter min score" }), _jsx(NumberTextField, { defaultValue: max, onValueChange: setMax, placeholder: "Enter max score" })] }));
18
+ });
@@ -0,0 +1,6 @@
1
+ import type { YScaleTicks } from './yScaleTicks.ts';
2
+ export default function YScaleBar({ ticks, orientation, insetLabels, }: {
3
+ ticks: YScaleTicks | undefined;
4
+ orientation?: 'left' | 'right';
5
+ insetLabels?: boolean;
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, insetLabels, }) {
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 `insetLabels` 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 (!insetLabels) {
40
+ return undefined;
41
+ }
42
+ const inset = Math.min(Math.max(sy, yTop + LABEL_HALF_HEIGHT_PX), yBottom - 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,6 @@
1
+ import type { YScaleTicks } from './yScaleTicks.ts';
2
+ export default function YScaleBarOverlay({ ticks, height, width, }: {
3
+ ticks: YScaleTicks;
4
+ height: number;
5
+ width: number;
6
+ }): import("react").JSX.Element;
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import YScaleBar from './YScaleBar.js';
3
+ import { ONSCREEN_AXIS_LEFT_PX } from './constants.js';
4
+ // Y-axis tick labels positioned absolutely, indented from the track's left edge
5
+ // so a right-oriented axis reads inside the plot. The SVG export instead anchors
6
+ // a left-oriented axis at the content edge, putting the labels in the export
7
+ // margin — see WiggleFamilySvgFrame.
8
+ //
9
+ // `width` is the track's, not a fixed strip: an <svg> clips to its own box, and
10
+ // the hardcoded 70px this used to carry silently truncated any label past ~11
11
+ // characters. Not hypothetical on a log axis — a domain under 1 nices to
12
+ // negative powers of two, so `0.0009765625` is an ordinary tick on a
13
+ // mappability track and the labels grow exactly where the numbers get small.
14
+ // The track's own right edge is the real boundary.
15
+ export default function YScaleBarOverlay({ ticks, height, width, }) {
16
+ return (_jsx("svg", { style: {
17
+ position: 'absolute',
18
+ top: 0,
19
+ left: ONSCREEN_AXIS_LEFT_PX,
20
+ pointerEvents: 'none',
21
+ height,
22
+ width: Math.max(0, width - ONSCREEN_AXIS_LEFT_PX),
23
+ }, children: _jsx(YScaleBar, { ticks: ticks, orientation: "right" }) }));
24
+ }
@@ -0,0 +1,55 @@
1
+ export declare const DEFAULT_AUTOSCALE_OPTIONS: [string, string][];
2
+ export interface FeatureArrays {
3
+ featurePositions: Uint32Array;
4
+ featureScores: Float32Array;
5
+ featureMinScores: Float32Array;
6
+ featureMaxScores: Float32Array;
7
+ numFeatures: number;
8
+ hasSummaryScores: boolean;
9
+ }
10
+ export interface Dataset {
11
+ data: FeatureArrays;
12
+ visStart?: number;
13
+ visEnd?: number;
14
+ }
15
+ export interface ScoreStats {
16
+ scoreMin: number;
17
+ scoreMax: number;
18
+ scoreMean: number;
19
+ scoreStdDev: number;
20
+ }
21
+ /**
22
+ * #api
23
+ * Per-feature scalar score array for a summary mode: the min/max summary array
24
+ * for `'min'`/`'max'`, otherwise the average score.
25
+ */
26
+ export declare function getEffectiveScores(data: {
27
+ featureScores: Float32Array;
28
+ featureMinScores: Float32Array;
29
+ featureMaxScores: Float32Array;
30
+ }, summaryScoreMode: string): Float32Array<ArrayBufferLike>;
31
+ export declare function computeScoreStats(summaryScoreMode: string, datasets: Dataset[]): ScoreStats | undefined;
32
+ /**
33
+ * #api
34
+ * Converts score stats into a `[min, max]` domain, applying std-dev
35
+ * expansion for the `localsd` autoscale type.
36
+ */
37
+ export declare function domainFromStats(stats: ScoreStats, autoscaleType: string, numStdDev: number): [number, number];
38
+ export declare function autoscaleDomainFromStats({ stats, autoscaleType, summaryScoreMode, numStdDev, numQuantile, visibleEntries, }: {
39
+ stats: ScoreStats;
40
+ autoscaleType: string;
41
+ summaryScoreMode: string;
42
+ numStdDev: number;
43
+ numQuantile?: number;
44
+ visibleEntries: Dataset[];
45
+ }): [number, number];
46
+ /**
47
+ * #api
48
+ * Computes a score domain from the visible feature arrays for the `local` /
49
+ * `localsd` / `localpercentile` autoscale types.
50
+ */
51
+ export declare function computeAutoscaleDomain(autoscaleType: string, summaryScoreMode: string, numStdDev: number, visibleEntries: {
52
+ data: FeatureArrays;
53
+ visStart: number;
54
+ visEnd: number;
55
+ }[], numQuantile?: number): [number, number] | undefined;
@@ -0,0 +1,252 @@
1
+ // Default autoscale modes shared by wiggle / multi-wiggle. The alignments
2
+ // coverage band exposes only a subset and a dynamic σ value, so it passes its
3
+ // own option list.
4
+ //
5
+ // Here rather than beside the menu that renders it because a `[value, label]`
6
+ // table is the one thing a menu holds that something outside the app needs to
7
+ // read: the website's figure recipes name these labels in a click path, and a
8
+ // module importing React or a lazy .tsx cannot be loaded by the node script
9
+ // that builds them. A leaf module makes the recipe import the label instead of
10
+ // retyping it, which is the difference between a copy that drifts and one that
11
+ // cannot.
12
+ export const DEFAULT_AUTOSCALE_OPTIONS = [
13
+ ['local', 'Local'],
14
+ ['localpercentile', 'Local (99th percentile)'],
15
+ ['localsd', 'Local ± 3σ'],
16
+ ];
17
+ /**
18
+ * #api
19
+ * Per-feature scalar score array for a summary mode: the min/max summary array
20
+ * for `'min'`/`'max'`, otherwise the average score.
21
+ */
22
+ export function getEffectiveScores(data, summaryScoreMode) {
23
+ return summaryScoreMode === 'min'
24
+ ? data.featureMinScores
25
+ : summaryScoreMode === 'max'
26
+ ? data.featureMaxScores
27
+ : data.featureScores;
28
+ }
29
+ // Half-open overlap test between a feature span and the visible window.
30
+ function overlaps(fStart, fEnd, visStart, visEnd) {
31
+ return fEnd > visStart && fStart < visEnd;
32
+ }
33
+ // Which per-feature array each end of the domain comes from. Whiskers spreads
34
+ // the two ends across the min/max summary arrays; every other mode draws both
35
+ // from a single scalar. One table because `computeScoreStats` and the
36
+ // percentile histogram have to agree — a domain whose extent and whose clipped
37
+ // bound were measured off different arrays is a domain that clips its own data.
38
+ function boundArrays(summaryScoreMode) {
39
+ const useWhiskers = summaryScoreMode === 'whiskers';
40
+ return {
41
+ low: (data) => useWhiskers
42
+ ? data.featureMinScores
43
+ : getEffectiveScores(data, summaryScoreMode),
44
+ high: (data) => useWhiskers
45
+ ? data.featureMaxScores
46
+ : getEffectiveScores(data, summaryScoreMode),
47
+ };
48
+ }
49
+ // Bin count for the approximate percentile histogram. 1024 bins gives ~0.1%
50
+ // resolution on the domain, which is finer than any autoscale needs and keeps
51
+ // the second pass O(n) with a fixed, trivial allocation.
52
+ const NUM_HISTOGRAM_BINS = 1024;
53
+ // First index whose feature STARTS at or after `bp`. `featurePositions` is
54
+ // sorted by start — the same property `findFeatureAtBp` binary-searches on.
55
+ function lowerBoundByStart(featurePositions, numFeatures, bp) {
56
+ let lo = 0;
57
+ let hi = numFeatures;
58
+ while (lo < hi) {
59
+ const mid = (lo + hi) >> 1;
60
+ if (featurePositions[mid * 2] >= bp) {
61
+ hi = mid;
62
+ }
63
+ else {
64
+ lo = mid + 1;
65
+ }
66
+ }
67
+ return lo;
68
+ }
69
+ /**
70
+ * The half-open index range that can overlap `[visStart, visEnd)`.
71
+ *
72
+ * A fetch covers `bufferedVisibleRegions` — the viewport plus half a screen on
73
+ * each side — so roughly half of what these passes walk is off-screen, and
74
+ * `localpercentile` (the default) walks it two or three times over. Both bounds
75
+ * come from a binary search on the sorted starts instead.
76
+ *
77
+ * The upper bound needs nothing but that sortedness: a feature starting at or
78
+ * after `visEnd` cannot reach back into the window. The lower bound also leans
79
+ * on wiggle features being non-overlapping bins — bigWig summary levels and
80
+ * bedGraph both are — walking back over any run that does reach in. The callers
81
+ * still test `overlaps` per feature inside the range, so a dataset that broke
82
+ * that assumption could only lose a long early feature, never gain one.
83
+ */
84
+ function visibleIndexRange(featurePositions, numFeatures, visStart, visEnd) {
85
+ if (visStart === undefined || visEnd === undefined) {
86
+ return { from: 0, to: numFeatures };
87
+ }
88
+ const to = lowerBoundByStart(featurePositions, numFeatures, visEnd);
89
+ let from = lowerBoundByStart(featurePositions, numFeatures, visStart);
90
+ while (from > 0 && featurePositions[(from - 1) * 2 + 1] > visStart) {
91
+ from--;
92
+ }
93
+ return { from, to };
94
+ }
95
+ // Min/max/mean/stddev of the visible features for a summary mode, in one pass.
96
+ // Exported (not #api — internal plumbing shared with the wiggle displays) so a
97
+ // caller needing both a domain and the raw extent computes the stats once and
98
+ // feeds them to autoscaleDomainFromStats instead of walking the arrays twice.
99
+ export function computeScoreStats(summaryScoreMode, datasets) {
100
+ const { low, high } = boundArrays(summaryScoreMode);
101
+ let min = Infinity;
102
+ let max = -Infinity;
103
+ let sum = 0;
104
+ let sumSq = 0;
105
+ let count = 0;
106
+ for (const { data, visStart, visEnd } of datasets) {
107
+ const { featureScores, featurePositions, numFeatures } = data;
108
+ // Selecting the arrays once per dataset keeps the mode check out of the
109
+ // per-feature loop.
110
+ const minScores = low(data);
111
+ const maxScores = high(data);
112
+ const { from, to } = visibleIndexRange(featurePositions, numFeatures, visStart, visEnd);
113
+ for (let i = from; i < to; i++) {
114
+ if (visStart !== undefined &&
115
+ visEnd !== undefined &&
116
+ !overlaps(featurePositions[i * 2], featurePositions[i * 2 + 1], visStart, visEnd)) {
117
+ continue;
118
+ }
119
+ min = Math.min(min, minScores[i]);
120
+ max = Math.max(max, maxScores[i]);
121
+ // Mean/stddev always use featureScores (the average) regardless of
122
+ // summaryScoreMode; min/max for the domain bounds come from the mode-
123
+ // selected arrays above. Intentional: sd-based autoscale centers on the
124
+ // average-value distribution even in whiskers/min/max summary modes.
125
+ const avg = featureScores[i];
126
+ sum += avg;
127
+ sumSq += avg * avg;
128
+ count++;
129
+ }
130
+ }
131
+ if (count === 0 || !Number.isFinite(min) || !Number.isFinite(max)) {
132
+ return undefined;
133
+ }
134
+ const mean = sum / count;
135
+ const stdDev = Math.sqrt(Math.max(0, sumSq / count - mean * mean));
136
+ return { scoreMin: min, scoreMax: max, scoreMean: mean, scoreStdDev: stdDev };
137
+ }
138
+ /**
139
+ * #api
140
+ * Converts score stats into a `[min, max]` domain, applying std-dev
141
+ * expansion for the `localsd` autoscale type.
142
+ */
143
+ export function domainFromStats(stats, autoscaleType, numStdDev) {
144
+ if (autoscaleType === 'localsd') {
145
+ const { scoreMean, scoreStdDev, scoreMin } = stats;
146
+ return [
147
+ scoreMin >= 0 ? 0 : scoreMean - numStdDev * scoreStdDev,
148
+ scoreMean + numStdDev * scoreStdDev,
149
+ ];
150
+ }
151
+ return [stats.scoreMin, stats.scoreMax];
152
+ }
153
+ // The `quantile`-th percentile magnitude of one signed side of the score
154
+ // distribution: features are filtered to a single sign (`positiveSide`), their
155
+ // magnitudes binned over `[0, maxMag]`, and the magnitude below which `quantile`
156
+ // of that side's mass falls is returned — clipping the outermost `1 - quantile`
157
+ // as outliers. Returns 0 when the side is empty. A fixed histogram keeps this an
158
+ // O(n) pass with no sort, approximate to ~1/NUM_HISTOGRAM_BINS of maxMag, far
159
+ // finer than the display needs.
160
+ function sideMagnitudePercentile(datasets, scoresFor, positiveSide, maxMag, quantile) {
161
+ if (maxMag <= 0) {
162
+ return 0;
163
+ }
164
+ const bins = new Int32Array(NUM_HISTOGRAM_BINS);
165
+ const scale = NUM_HISTOGRAM_BINS / maxMag;
166
+ let count = 0;
167
+ for (const { data, visStart, visEnd } of datasets) {
168
+ const { featurePositions, numFeatures } = data;
169
+ const scores = scoresFor(data);
170
+ const { from, to } = visibleIndexRange(featurePositions, numFeatures, visStart, visEnd);
171
+ for (let i = from; i < to; i++) {
172
+ if (visStart !== undefined &&
173
+ visEnd !== undefined &&
174
+ !overlaps(featurePositions[i * 2], featurePositions[i * 2 + 1], visStart, visEnd)) {
175
+ continue;
176
+ }
177
+ const mag = positiveSide ? scores[i] : -scores[i];
178
+ if (mag > 0) {
179
+ const bin = Math.min(NUM_HISTOGRAM_BINS - 1, Math.floor(mag * scale));
180
+ bins[bin]++;
181
+ count++;
182
+ }
183
+ }
184
+ }
185
+ if (count === 0) {
186
+ return 0;
187
+ }
188
+ const target = quantile * count;
189
+ let cumulative = 0;
190
+ for (let bin = 0; bin < NUM_HISTOGRAM_BINS; bin++) {
191
+ cumulative += bins[bin];
192
+ // +1 so the target bin is included rather than clipped.
193
+ if (cumulative >= target) {
194
+ return ((bin + 1) / NUM_HISTOGRAM_BINS) * maxMag;
195
+ }
196
+ }
197
+ return maxMag;
198
+ }
199
+ // Builds a `[low, high]` domain by clipping each side of the score distribution
200
+ // to its central `quantile` fraction (e.g. 0.99 → clip the outermost 1% of each
201
+ // sign). Unlike localsd it makes no normality assumption, so it stays robust on
202
+ // the heavily skewed score distributions typical of coverage/wiggle data.
203
+ //
204
+ // The two signs are clipped INDEPENDENTLY, anchored at 0. A single combined
205
+ // percentile spends its whole budget on the dominant side, so on strongly
206
+ // one-sided signed data (e.g. phyloP: mostly-positive conservation with a
207
+ // sparse, small negative tail) the minority tail's 1st percentile lands at or
208
+ // above 0 and the negative extent collapses to a flat band. Measuring each
209
+ // side's percentile from 0 outward keeps a small-but-real opposite tail visible.
210
+ function percentileDomainFromHistogram(stats, summaryScoreMode, quantile, datasets) {
211
+ const { scoreMin, scoreMax } = stats;
212
+ if (scoreMax - scoreMin <= 0) {
213
+ return [scoreMin, scoreMax];
214
+ }
215
+ const arrays = boundArrays(summaryScoreMode);
216
+ const high = scoreMax > 0
217
+ ? sideMagnitudePercentile(datasets, arrays.high, true, scoreMax, quantile)
218
+ : 0;
219
+ const negExtent = scoreMin < 0
220
+ ? sideMagnitudePercentile(datasets, arrays.low, false, -scoreMin, quantile)
221
+ : 0;
222
+ // Anchor the low bound at 0 for all-positive data (matching localsd's origin
223
+ // convention); otherwise extend it to the negative side's clipped extent.
224
+ return [scoreMin < 0 ? -negExtent : 0, high];
225
+ }
226
+ // Turns already-computed stats into the displayed domain for the `local` /
227
+ // `localsd` / `localpercentile` autoscale types. `localpercentile` re-walks the
228
+ // entries to build its histogram; the other types read the stats alone.
229
+ // computeAutoscaleDomain (#api) is the one-shot form.
230
+ export function autoscaleDomainFromStats({ stats, autoscaleType, summaryScoreMode, numStdDev, numQuantile = 0.99, visibleEntries, }) {
231
+ return autoscaleType === 'localpercentile'
232
+ ? percentileDomainFromHistogram(stats, summaryScoreMode, numQuantile, visibleEntries)
233
+ : domainFromStats(stats, autoscaleType, numStdDev);
234
+ }
235
+ /**
236
+ * #api
237
+ * Computes a score domain from the visible feature arrays for the `local` /
238
+ * `localsd` / `localpercentile` autoscale types.
239
+ */
240
+ export function computeAutoscaleDomain(autoscaleType, summaryScoreMode, numStdDev, visibleEntries, numQuantile = 0.99) {
241
+ const stats = computeScoreStats(summaryScoreMode, visibleEntries);
242
+ return stats
243
+ ? autoscaleDomainFromStats({
244
+ stats,
245
+ autoscaleType,
246
+ summaryScoreMode,
247
+ numStdDev,
248
+ numQuantile,
249
+ visibleEntries,
250
+ })
251
+ : undefined;
252
+ }