@czi-sds/data-viz 2.1.0 → 2.2.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.
package/dist/index.esm.js CHANGED
@@ -1,17 +1,20 @@
1
1
  "use client";
2
2
  import { init } from "echarts";
3
- import { Fragment, cloneElement, forwardRef, memo, useCallback, useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import { Fragment, cloneElement, createContext, forwardRef, memo, useCallback, useContext, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
4
4
  import { throttle } from "lodash";
5
5
  import styled from "@emotion/styled";
6
6
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
7
7
  import { Button, Legend, Theme, Tooltip, TooltipTable, defaultTheme, fontBodyXs, fontBodyXxxs, fontCodeXs, fontHeaderM, fontHeaderS, fontTabularSemiboldXxxxs, generateDiscreteColors, getCorners, getMode, getSemanticColors, getSpaces } from "@czi-sds/components";
8
8
  import { ThemeProvider, useTheme } from "@mui/material/styles";
9
- import { ArrowCounterClockwiseIcon, CheckIcon, CopyIcon, QuestionIcon } from "@phosphor-icons/react";
9
+ import { ApertureIcon, ArrowCounterClockwiseIcon, CheckIcon, CopyIcon, EyeIcon, EyeSlashIcon, QuestionIcon } from "@phosphor-icons/react";
10
+ import { Color } from "molstar/lib/mol-util/color";
11
+ import { OrderedSet } from "molstar/lib/mol-data/int";
10
12
  import { Structure, StructureElement, StructureProperties, Unit } from "molstar/lib/mol-model/structure";
11
13
  import { createPluginUI } from "molstar/lib/mol-plugin-ui";
12
14
  import { renderReact18 } from "molstar/lib/mol-plugin-ui/react18";
13
15
  import { DefaultPluginUISpec } from "molstar/lib/mol-plugin-ui/spec";
14
16
  import { PluginBehaviors } from "molstar/lib/mol-plugin/behavior";
17
+ import { setSubtreeVisibility } from "molstar/lib/mol-plugin/behavior/static/state";
15
18
  import { PluginConfig } from "molstar/lib/mol-plugin/config";
16
19
  import { Representation } from "molstar/lib/mol-repr/representation";
17
20
  import { BehaviorSubject, Subject } from "rxjs";
@@ -19,7 +22,6 @@ import { EveryLoci, Loci } from "molstar/lib/mol-model/loci";
19
22
  import { PluginReactContext } from "molstar/lib/mol-plugin-ui/base";
20
23
  import { ButtonsType, getButton, getButtons, getModifiers } from "molstar/lib/mol-util/input/input-observer";
21
24
  import { throttleTime } from "rxjs/operators";
22
- import { OrderedSet } from "molstar/lib/mol-data/int";
23
25
  import { useTheme as useTheme$1 } from "@mui/material";
24
26
  import { PluginStateObject } from "molstar/lib/mol-plugin-state/objects";
25
27
  import { getChainOptions, getModelEntityOptions, getOperatorOptions, getSequenceWrapper, getStructureOptions } from "molstar/lib/mol-plugin-ui/sequence";
@@ -30,8 +32,11 @@ import { Toasts } from "molstar/lib/mol-plugin-ui/toast";
30
32
  import { Viewport } from "molstar/lib/mol-plugin-ui/viewport";
31
33
  import { PluginCommands } from "molstar/lib/mol-plugin/commands";
32
34
  import { Vec3 } from "molstar/lib/mol-math/linear-algebra";
35
+ import { BondType } from "molstar/lib/mol-model/structure/model/types";
36
+ import { elementLabel } from "molstar/lib/mol-theme/label";
37
+ import { MolScriptBuilder } from "molstar/lib/mol-script/language/builder";
33
38
  import { CustomElementProperty } from "molstar/lib/mol-model-props/common/custom-element-property";
34
- import { Color } from "molstar/lib/mol-util/color";
39
+ import { StructureFocusRepresentation } from "molstar/lib/mol-plugin/behavior/dynamic/selection/structure-focus-representation";
35
40
  //#region src/common/colorScales.ts
36
41
  /**
37
42
  * Perceptually uniform sequential scale (matplotlib's plasma), the default for
@@ -549,6 +554,191 @@ var HeatmapChart_default = memo(forwardRef((props, ref) => {
549
554
  else ref.current = element;
550
555
  }
551
556
  }));
557
+ //#endregion
558
+ //#region src/core/ProteinStructureViewer/components/ChainLegend/style.ts
559
+ /** Opacity applied to a hidden chain's row, so it reads as off but reachable. */
560
+ const HIDDEN_OPACITY = .4;
561
+ /** Side of the color swatch. */
562
+ const SWATCH_SIZE = 8;
563
+ /**
564
+ * The rows, stacked and right-aligned against the color key beside them.
565
+ *
566
+ * Pointer events are switched back on here: the legend overlay turns them off
567
+ * so drags meant for the structure are not intercepted, and this is one of the
568
+ * two places in it that has to be clickable.
569
+ */
570
+ const ChainRowList = styled("div")`
571
+ display: flex;
572
+ flex-direction: column;
573
+ align-items: flex-end;
574
+ pointer-events: auto;
575
+ max-height: 80px;
576
+ overflow-y: auto;
577
+ `;
578
+ const ChainRow = styled("div")`
579
+ display: flex;
580
+ align-items: center;
581
+
582
+ ${(props) => {
583
+ const spaces = getSpaces(props);
584
+ return `
585
+ gap: ${spaces === null || spaces === void 0 ? void 0 : spaces.xs}px;
586
+ `;
587
+ }}
588
+ `;
589
+ const ChainSwatch = styled("div")`
590
+ flex: none;
591
+ width: ${SWATCH_SIZE}px;
592
+ height: ${SWATCH_SIZE}px;
593
+ border-radius: 1px;
594
+
595
+ ${(props) => `
596
+ background-color: ${props.swatchColor};
597
+ opacity: ${props.isHidden ? HIDDEN_OPACITY : 1};
598
+ `}
599
+ `;
600
+ /**
601
+ * A swatch quartered into a grid, for a coloring no single color stands for.
602
+ *
603
+ * Occupies the same box as the solid swatch so the rows stay aligned whichever
604
+ * one a chain gets, with a hairline gap between the quadrants to read as four
605
+ * colors rather than one blurred square.
606
+ */
607
+ const ChainSwatchGrid = styled("div")`
608
+ flex: none;
609
+ display: grid;
610
+ grid-template-columns: repeat(2, 1fr);
611
+ grid-template-rows: repeat(2, 1fr);
612
+ gap: 0px;
613
+ width: ${SWATCH_SIZE}px;
614
+ height: ${SWATCH_SIZE}px;
615
+ border-radius: 1px;
616
+ overflow: hidden;
617
+
618
+ ${(props) => `
619
+ opacity: ${props.isHidden ? HIDDEN_OPACITY : 1};
620
+ `}
621
+ `;
622
+ const ChainSwatchQuadrant = styled("div")`
623
+ ${(props) => `
624
+ background-color: ${props.swatchColor};
625
+ `}
626
+ `;
627
+ /**
628
+ * Selects the whole chain, or clears it when it is already the selection. A
629
+ * button so it is reachable from the keyboard.
630
+ */
631
+ const ChainLabel$1 = styled("button")`
632
+ ${fontBodyXxxs}
633
+
634
+ padding: 0;
635
+ border: none;
636
+ background: none;
637
+
638
+ ${(props) => {
639
+ var _semanticColors$base, _semanticColors$base2, _semanticColors$base3;
640
+ const semanticColors = getSemanticColors(props);
641
+ return `
642
+ color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base = semanticColors.base) === null || _semanticColors$base === void 0 ? void 0 : _semanticColors$base.textSecondary};
643
+ cursor: ${props.isHidden ? "default" : "pointer"};
644
+ font-weight: ${props.isSelected ? 600 : "inherit"};
645
+ opacity: ${props.isHidden ? HIDDEN_OPACITY : 1};
646
+
647
+ &:hover {
648
+ color: ${props.isHidden ? semanticColors === null || semanticColors === void 0 || (_semanticColors$base2 = semanticColors.base) === null || _semanticColors$base2 === void 0 ? void 0 : _semanticColors$base2.textSecondary : semanticColors === null || semanticColors === void 0 || (_semanticColors$base3 = semanticColors.base) === null || _semanticColors$base3 === void 0 ? void 0 : _semanticColors$base3.textPrimary};
649
+ }
650
+ `;
651
+ }}
652
+ `;
653
+ const VisibilityToggle = styled("button")`
654
+ display: flex;
655
+ align-items: center;
656
+ padding: 0;
657
+ border: none;
658
+ background: none;
659
+ cursor: pointer;
660
+ margin-top: 1px;
661
+
662
+ ${(props) => {
663
+ var _semanticColors$base4, _semanticColors$base5;
664
+ const semanticColors = getSemanticColors(props);
665
+ return `
666
+ color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base4 = semanticColors.base) === null || _semanticColors$base4 === void 0 ? void 0 : _semanticColors$base4.textTertiaryOnDark};
667
+
668
+ &:hover {
669
+ color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base5 = semanticColors.base) === null || _semanticColors$base5 === void 0 ? void 0 : _semanticColors$base5.textPrimary};
670
+ }
671
+
672
+ svg {
673
+ display: block;
674
+ }
675
+ `;
676
+ }}
677
+ `;
678
+ //#endregion
679
+ //#region src/core/ProteinStructureViewer/components/ChainLegend/index.tsx
680
+ /**
681
+ * Lists the structure's chains, each with its color and a visibility toggle.
682
+ *
683
+ * Only drawn for a complex: one chain needs no key to tell it from anything,
684
+ * and hiding it would leave an empty canvas with no way back. Its home is the
685
+ * legend rather than the sequence panel so that it survives
686
+ * `showSequenceViewer={false}`, where the chains would otherwise have nowhere
687
+ * to be listed.
688
+ */
689
+ function ChainLegend({ bandColors, chainColors, chains, hiddenChains, onChainHover, onChainSelect, onChainToggle, selectedChains }) {
690
+ if (chains.length < 2) return null;
691
+ return /* @__PURE__ */ jsx(ChainRowList, { children: chains.map((chain) => {
692
+ var _selectedChains$has;
693
+ const hidden = hiddenChains.has(chain.chainId);
694
+ const selected = (_selectedChains$has = selectedChains === null || selectedChains === void 0 ? void 0 : selectedChains.has(chain.chainId)) !== null && _selectedChains$has !== void 0 ? _selectedChains$has : false;
695
+ const color = chainColors === null || chainColors === void 0 ? void 0 : chainColors.get(chain.chainId);
696
+ return /* @__PURE__ */ jsxs(ChainRow, {
697
+ onBlur: () => onChainHover === null || onChainHover === void 0 ? void 0 : onChainHover(null),
698
+ onFocus: () => onChainHover === null || onChainHover === void 0 ? void 0 : onChainHover(chain.chainId),
699
+ onMouseEnter: () => onChainHover === null || onChainHover === void 0 ? void 0 : onChainHover(chain.chainId),
700
+ onMouseLeave: () => onChainHover === null || onChainHover === void 0 ? void 0 : onChainHover(null),
701
+ children: [
702
+ /* @__PURE__ */ jsx(Tooltip, {
703
+ arrow: true,
704
+ placement: "top",
705
+ disableInteractive: true,
706
+ title: hidden ? `Chain ${chain.label} is hidden` : `Click to ${selected ? "deselect" : "select"} Chain ${chain.label}`,
707
+ children: /* @__PURE__ */ jsxs(ChainLabel$1, {
708
+ "aria-pressed": selected,
709
+ disabled: hidden,
710
+ isHidden: hidden,
711
+ isSelected: selected,
712
+ onClick: () => onChainSelect(chain.chainId),
713
+ type: "button",
714
+ children: ["Chain ", chain.label]
715
+ })
716
+ }),
717
+ color !== void 0 && /* @__PURE__ */ jsx(ChainSwatch, {
718
+ isHidden: hidden,
719
+ swatchColor: color
720
+ }),
721
+ color === void 0 && bandColors !== void 0 && /* @__PURE__ */ jsx(ChainSwatchGrid, {
722
+ isHidden: hidden,
723
+ children: bandColors.map((band) => /* @__PURE__ */ jsx(ChainSwatchQuadrant, { swatchColor: band }, band))
724
+ }),
725
+ /* @__PURE__ */ jsx(Tooltip, {
726
+ arrow: true,
727
+ placement: "top",
728
+ disableInteractive: true,
729
+ title: hidden ? `Show Chain ${chain.label}` : `Hide Chain ${chain.label}`,
730
+ children: /* @__PURE__ */ jsx(VisibilityToggle, {
731
+ "aria-label": `${hidden ? "Show" : "Hide"} chain ${chain.label}`,
732
+ "aria-pressed": hidden,
733
+ onClick: () => onChainToggle(chain.chainId),
734
+ type: "button",
735
+ children: hidden ? /* @__PURE__ */ jsx(EyeSlashIcon, { size: 12 }) : /* @__PURE__ */ jsx(EyeIcon, { size: 12 })
736
+ })
737
+ })
738
+ ]
739
+ }, chain.chainId);
740
+ }) });
741
+ }
552
742
  /** Height of the color bar itself. */
553
743
  const BAR_HEIGHT = 8;
554
744
  /**
@@ -673,14 +863,24 @@ const ViewerRoot = styled("div")`
673
863
  height: 100% !important;
674
864
  }
675
865
 
676
- /* Mol* chrome that the custom viewport and sequence panel replace. */
677
- .msp-plugin .msp-viewport-controls-buttons,
678
- .msp-plugin .msp-layout-expanded,
679
- .msp-plugin .msp-log,
680
- .msp-plugin .msp-left-panel-controls,
681
- .msp-plugin .msp-layout-region.msp-layout-left,
682
- .msp-plugin .msp-layout-region.msp-layout-right,
683
- .msp-plugin .msp-layout-region.msp-layout-bottom {
866
+ /*
867
+ * The expanded layout is kept hidden because it is the one piece of Mol*
868
+ * chrome that would escape this container: it takes the plugin out of the
869
+ * box it was given and over the page.
870
+ *
871
+ * Its neighbours used to be here too - the viewport's icon column, and the
872
+ * left, right and bottom regions. They are not any more, because hiding them
873
+ * from here made them unreachable: Mol* already gates each of them behind a
874
+ * PluginConfig.Viewport flag and behind layout.initial.regionState, both of
875
+ * which a consumer can set through molstarSpec, and a display:none on top of
876
+ * that overrode the answer. None of them shows by default - the viewer's own
877
+ * flags and region state are what keep them away - so this only has to stay
878
+ * out of their way.
879
+ *
880
+ * No backticks in here: this is inside a template literal, and one would end
881
+ * it.
882
+ */
883
+ .msp-plugin .msp-layout-expanded {
684
884
  display: none !important;
685
885
  }
686
886
 
@@ -831,6 +1031,18 @@ const StatsGrid = styled("div")`
831
1031
  `;
832
1032
  }}
833
1033
  `;
1034
+ const ChainScaleColumn = styled("div")`
1035
+ display: flex;
1036
+ flex-direction: column;
1037
+ align-items: flex-end;
1038
+
1039
+ ${(props) => {
1040
+ const spaces = getSpaces(props);
1041
+ return `
1042
+ gap: ${spaces === null || spaces === void 0 ? void 0 : spaces.l}px;
1043
+ `;
1044
+ }}
1045
+ `;
834
1046
  const StatColumnWrapper = styled("div")`
835
1047
  display: flex;
836
1048
  flex-direction: column;
@@ -868,18 +1080,22 @@ const ScaleColumn = styled("div")`
868
1080
  `;
869
1081
  }}
870
1082
  `;
1083
+ /**
1084
+ * A wrapping text line, not a flex row: the help icon has to sit at the end of
1085
+ * the last line of the caption rather than beside the whole wrapped block.
1086
+ */
871
1087
  const ScaleCaption = styled("span")`
872
1088
  ${fontBodyXxxs}
873
1089
 
1090
+ display: block;
874
1091
  text-align: left;
875
- display: flex;
876
- align-items: center;
877
1092
 
878
1093
  ${(props) => {
879
1094
  var _semanticColors$base3;
880
1095
  const semanticColors = getSemanticColors(props);
881
1096
  return `
882
1097
  color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base3 = semanticColors.base) === null || _semanticColors$base3 === void 0 ? void 0 : _semanticColors$base3.textTertiaryOnDark};
1098
+ max-width: 142px;
883
1099
  `;
884
1100
  }}
885
1101
  `;
@@ -892,12 +1108,18 @@ const TooltipAnchor = styled("span")`
892
1108
  const spaces = getSpaces(props);
893
1109
  const semanticColors = getSemanticColors(props);
894
1110
  return `
1111
+ display: inline-block;
895
1112
  padding-left: ${spaces === null || spaces === void 0 ? void 0 : spaces.xxxs}px;
896
- margin-bottom: -${spaces === null || spaces === void 0 ? void 0 : spaces.xxxs}px;
1113
+ margin-bottom: 1px;
1114
+ vertical-align: text-bottom;
897
1115
 
898
1116
  &:hover {
899
1117
  color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base4 = semanticColors.base) === null || _semanticColors$base4 === void 0 ? void 0 : _semanticColors$base4.ornamentSecondary};
900
1118
  }
1119
+
1120
+ svg {
1121
+ display: block;
1122
+ }
901
1123
  `;
902
1124
  }}
903
1125
  `;
@@ -913,6 +1135,19 @@ function StatColumn({ label, value }) {
913
1135
  return /* @__PURE__ */ jsxs(StatColumnWrapper, { children: [/* @__PURE__ */ jsx(StatValue, { children: value }), /* @__PURE__ */ jsx(StatLabel, { children: label })] });
914
1136
  }
915
1137
  /**
1138
+ * Builds one readout slot.
1139
+ *
1140
+ * A readout covering several residues reports a mean, and the label says so:
1141
+ * calling it "pLDDT" while it averages ninety of them would read as a single
1142
+ * residue's score.
1143
+ */
1144
+ function readoutSlot(label, value, residueCount) {
1145
+ return {
1146
+ label: residueCount > 1 ? `Mean ${label}` : label,
1147
+ value: value !== null ? value.toFixed(VALUE_PRECISION) : MISSING_VALUE
1148
+ };
1149
+ }
1150
+ /**
916
1151
  * Stats and color key overlaid on the structure viewer.
917
1152
  *
918
1153
  * When a residue is selected or hovered, the whole-structure stats are replaced
@@ -922,24 +1157,19 @@ function StatColumn({ label, value }) {
922
1157
  * residue takes priority so the user can inspect others without losing their
923
1158
  * selection.
924
1159
  */
925
- function StructureLegend({ hoveredResidue = null, scale, scaleLabel, scaleMax = null, scaleMin = 0, scaleTooltip, selectedResidue = null, showSequenceViewer, stats, valueLabel }) {
926
- var _stats$, _stats$2, _stats$3;
1160
+ function StructureLegend({ chainBandColors, chainColors, chains = [], hiddenChains, hoveredResidue = null, onChainHover, onChainSelect, onChainToggle, scale, scaleLabel, scaleMax = null, scaleMin = 0, scaleTooltip, scaleTooltipProps, selectedChains, selectedResidue = null, showSequenceViewer, stats, valueLabel }) {
1161
+ var _activeResidue$residu, _stats$, _stats$2, _stats$3;
927
1162
  const activeResidue = hoveredResidue !== null && hoveredResidue !== void 0 ? hoveredResidue : selectedResidue;
928
1163
  const showResidue = activeResidue !== null;
929
1164
  const showValue = showResidue && valueLabel !== void 0;
1165
+ const residueCount = (_activeResidue$residu = activeResidue === null || activeResidue === void 0 ? void 0 : activeResidue.residueCount) !== null && _activeResidue$residu !== void 0 ? _activeResidue$residu : 1;
930
1166
  const slots = [
931
1167
  showResidue ? {
932
- label: "Residue",
1168
+ label: residueCount > 1 ? "Selection" : "Residue",
933
1169
  value: activeResidue.label
934
1170
  } : (_stats$ = stats[0]) !== null && _stats$ !== void 0 ? _stats$ : null,
935
- showValue ? {
936
- label: valueLabel,
937
- value: activeResidue.value !== null ? activeResidue.value.toFixed(VALUE_PRECISION) : MISSING_VALUE
938
- } : (_stats$2 = stats[1]) !== null && _stats$2 !== void 0 ? _stats$2 : null,
939
- showResidue ? {
940
- label: "pLDDT",
941
- value: activeResidue.plddt !== null ? activeResidue.plddt.toFixed(VALUE_PRECISION) : MISSING_VALUE
942
- } : (_stats$3 = stats[2]) !== null && _stats$3 !== void 0 ? _stats$3 : null
1171
+ showValue ? readoutSlot(valueLabel, activeResidue.value, residueCount) : (_stats$2 = stats[1]) !== null && _stats$2 !== void 0 ? _stats$2 : null,
1172
+ showResidue ? readoutSlot("pLDDT", activeResidue.plddt, residueCount) : (_stats$3 = stats[2]) !== null && _stats$3 !== void 0 ? _stats$3 : null
943
1173
  ];
944
1174
  return /* @__PURE__ */ jsxs(LegendOverlay, {
945
1175
  showSequenceViewer,
@@ -950,20 +1180,369 @@ function StructureLegend({ hoveredResidue = null, scale, scaleLabel, scaleMax =
950
1180
  label: slot.label,
951
1181
  value: slot.value
952
1182
  }, i) : /* @__PURE__ */ jsx("div", {}, i);
953
- }) }), scale && /* @__PURE__ */ jsxs(ScaleColumn, { children: [/* @__PURE__ */ jsx(ColorScaleLegend, {
1183
+ }) }), /* @__PURE__ */ jsxs(ChainScaleColumn, { children: [onChainToggle && onChainSelect && /* @__PURE__ */ jsx(ChainLegend, {
1184
+ bandColors: chainBandColors,
1185
+ chainColors,
1186
+ chains,
1187
+ hiddenChains: hiddenChains !== null && hiddenChains !== void 0 ? hiddenChains : /* @__PURE__ */ new Set(),
1188
+ onChainHover,
1189
+ onChainSelect,
1190
+ onChainToggle,
1191
+ selectedChains
1192
+ }), scale && /* @__PURE__ */ jsxs(ScaleColumn, { children: [/* @__PURE__ */ jsx(ColorScaleLegend, {
954
1193
  max: scaleMax,
955
1194
  min: scaleMin,
956
1195
  scale
957
- }), /* @__PURE__ */ jsxs(ScaleCaption, { children: [scaleLabel, scaleTooltip !== void 0 && /* @__PURE__ */ jsx(Tooltip, {
1196
+ }), /* @__PURE__ */ jsxs(ScaleCaption, { children: [scaleLabel, (scaleTooltip !== void 0 || scaleTooltipProps !== void 0) && /* @__PURE__ */ jsx(Tooltip, _objectSpread2(_objectSpread2({
958
1197
  arrow: true,
959
- placement: "bottom",
960
- title: scaleTooltip,
1198
+ placement: "bottom"
1199
+ }, scaleTooltipProps), {}, {
1200
+ title: scaleTooltip !== null && scaleTooltip !== void 0 ? scaleTooltip : scaleTooltipProps === null || scaleTooltipProps === void 0 ? void 0 : scaleTooltipProps.title,
961
1201
  children: /* @__PURE__ */ jsx(TooltipAnchor, { children: /* @__PURE__ */ jsx(QuestionIcon, {
962
1202
  size: 10,
963
1203
  weight: "bold"
964
1204
  }) })
965
- })] })] })]
1205
+ }))] })] })] })]
1206
+ });
1207
+ }
1208
+ //#endregion
1209
+ //#region src/core/ProteinStructureViewer/utils/selection.ts
1210
+ /**
1211
+ * A stable string for a selection, for comparing one against another.
1212
+ *
1213
+ * A consumer holding the selection in state hands back a new object on every
1214
+ * render, so identity says nothing about whether the selection changed. Order
1215
+ * is normalized too: `{ residues: [2, 1] }` and `{ residues: [1, 2] }` select
1216
+ * the same residues and must not read as a change.
1217
+ */
1218
+ function selectionKey(selection) {
1219
+ var _selection$residues, _selection$chains;
1220
+ const residues = [...new Set((_selection$residues = selection.residues) !== null && _selection$residues !== void 0 ? _selection$residues : [])].sort((a, b) => a - b);
1221
+ return `${[...new Set((_selection$chains = selection.chains) !== null && _selection$chains !== void 0 ? _selection$chains : [])].sort().join(",")}|${residues.join(",")}`;
1222
+ }
1223
+ /** True when the selection names nothing at all. */
1224
+ function isEmptySelection(selection) {
1225
+ var _selection$residues2, _selection$chains2;
1226
+ if (!selection) return true;
1227
+ return !((_selection$residues2 = selection.residues) === null || _selection$residues2 === void 0 ? void 0 : _selection$residues2.length) && !((_selection$chains2 = selection.chains) === null || _selection$chains2 === void 0 ? void 0 : _selection$chains2.length);
1228
+ }
1229
+ /**
1230
+ * The residues a selection covers, ascending and without repeats.
1231
+ *
1232
+ * Chains are expanded through `residuesByChain`, which was read off the loaded
1233
+ * structure, so a chain contributes exactly the residues it holds rather than
1234
+ * the span between its ends. A residue named directly and also carried by a
1235
+ * selected chain is counted once.
1236
+ */
1237
+ function selectionResidues(selection, residuesByChain) {
1238
+ var _selection$residues3, _selection$chains3;
1239
+ const residues = new Set((_selection$residues3 = selection.residues) !== null && _selection$residues3 !== void 0 ? _selection$residues3 : []);
1240
+ for (const chainId of (_selection$chains3 = selection.chains) !== null && _selection$chains3 !== void 0 ? _selection$chains3 : []) {
1241
+ var _residuesByChain$get;
1242
+ for (const index of (_residuesByChain$get = residuesByChain.get(chainId)) !== null && _residuesByChain$get !== void 0 ? _residuesByChain$get : []) residues.add(index);
1243
+ }
1244
+ return [...residues].sort((a, b) => a - b);
1245
+ }
1246
+ /**
1247
+ * Names a selection for the legend's readout.
1248
+ *
1249
+ * A single chain is named as one, since "Chain A" says more than the number of
1250
+ * residues it happens to hold. Anything else is reported as a count, which is
1251
+ * the only thing true of every shape a selection can take. A lone residue is
1252
+ * named by the caller instead, which reads it back off the structure to get its
1253
+ * three-letter code and the number the file gives it.
1254
+ */
1255
+ function selectionLabel(selection, residueCount) {
1256
+ var _selection$chains4, _selection$residues4;
1257
+ const chains = (_selection$chains4 = selection.chains) !== null && _selection$chains4 !== void 0 ? _selection$chains4 : [];
1258
+ if (chains.length === 1 && !((_selection$residues4 = selection.residues) === null || _selection$residues4 === void 0 ? void 0 : _selection$residues4.length)) return `Chain ${chains[0]}`;
1259
+ return `${residueCount} residues`;
1260
+ }
1261
+ /** The mean of the values `read` returns, ignoring residues without one. */
1262
+ function meanOverResidues(residues, read) {
1263
+ let sum = 0;
1264
+ let count = 0;
1265
+ for (const index of residues) {
1266
+ const value = read(index);
1267
+ if (value === null || value === void 0) continue;
1268
+ sum += value;
1269
+ count++;
1270
+ }
1271
+ return count === 0 ? null : sum / count;
1272
+ }
1273
+ //#endregion
1274
+ //#region src/core/ProteinStructureViewer/utils/residueLoci.ts
1275
+ /**
1276
+ * Every atom the predicate accepts, as geometry Mol* can focus, frame and mark,
1277
+ * or undefined when it accepts none.
1278
+ *
1279
+ * One pass over the structure rather than a pass per residue: a whole-chain
1280
+ * selection names hundreds of residues, and resolving them one at a time would
1281
+ * walk the structure once for each.
1282
+ *
1283
+ * Atoms are bucketed by the unit they belong to because element indices are
1284
+ * unit-local. That bucketing is also what lets a result span a chain break: it
1285
+ * carries an entry per unit, which is what a range across two chains needs and
1286
+ * what a single unit-local entry cannot express.
1287
+ *
1288
+ * Whole residues are collected rather than one atom each, since the caller
1289
+ * frames the camera on the loci's bounding sphere and a lone atom would zoom
1290
+ * far closer than the residue warrants.
1291
+ */
1292
+ function lociMatching(structure, matches) {
1293
+ const location = StructureElement.Location.create(structure);
1294
+ const elements = [];
1295
+ for (const unit of structure.units) {
1296
+ location.unit = unit;
1297
+ const indices = [];
1298
+ for (let i = 0; i < unit.elements.length; i++) {
1299
+ location.element = unit.elements[i];
1300
+ if (matches(location)) indices.push(i);
1301
+ }
1302
+ if (indices.length > 0) elements.push({
1303
+ indices,
1304
+ unit
1305
+ });
1306
+ }
1307
+ if (elements.length === 0) return void 0;
1308
+ return StructureElement.Loci(structure, elements.map(({ indices, unit }) => ({
1309
+ indices: OrderedSet.ofSortedArray(indices),
1310
+ unit
1311
+ })));
1312
+ }
1313
+ /**
1314
+ * Every atom of the residues a selection covers, within one structure.
1315
+ *
1316
+ * `hiddenChains` is subtracted from the result. A hidden chain is not drawn, so
1317
+ * geometry built from it would have nothing to sit on: Mol*'s focus
1318
+ * representation draws its own ball-and-stick from whatever is focused, in a
1319
+ * part of the state tree the chain's own visibility does not reach, and a
1320
+ * hidden chain left in the loci would keep showing up there after its cartoon
1321
+ * had gone.
1322
+ */
1323
+ function lociForSelectionInStructure(structure, selection, hiddenChains) {
1324
+ var _selection$residues, _selection$chains;
1325
+ if (isEmptySelection(selection)) return void 0;
1326
+ const residues = new Set((_selection$residues = selection.residues) !== null && _selection$residues !== void 0 ? _selection$residues : []);
1327
+ const chains = new Set((_selection$chains = selection.chains) !== null && _selection$chains !== void 0 ? _selection$chains : []);
1328
+ return lociMatching(structure, (location) => {
1329
+ const chainId = StructureProperties.chain.auth_asym_id(location);
1330
+ if (hiddenChains === null || hiddenChains === void 0 ? void 0 : hiddenChains.has(chainId)) return false;
1331
+ return residues.has(StructureProperties.residue.key(location)) || chains.has(chainId);
1332
+ });
1333
+ }
1334
+ /**
1335
+ * Every atom of one chain's polymer, in the first structure that has one.
1336
+ *
1337
+ * The polymer alone, so pointing at a chain's name lights up the cartoon the
1338
+ * name is captioning. The ligands sitting on the chain belong to it but are
1339
+ * not what it is named for, and they are drawn by a representation of their
1340
+ * own.
1341
+ */
1342
+ function lociForChainPolymer(plugin, chainId) {
1343
+ for (const entry of plugin.managers.structure.hierarchy.current.structures) {
1344
+ var _entry$cell$obj;
1345
+ const structure = (_entry$cell$obj = entry.cell.obj) === null || _entry$cell$obj === void 0 ? void 0 : _entry$cell$obj.data;
1346
+ if (!structure) continue;
1347
+ const loci = lociMatching(structure, (location) => StructureProperties.chain.auth_asym_id(location) === chainId && StructureProperties.entity.type(location) === "polymer");
1348
+ if (loci) return loci;
1349
+ }
1350
+ }
1351
+ /**
1352
+ * Every atom of the residues between two 0-based indices, inclusive.
1353
+ *
1354
+ * A contiguous span is tested by comparison rather than by expanding it into a
1355
+ * set of indices: this runs on every residue a drag crosses, and a drag can
1356
+ * span the whole structure.
1357
+ */
1358
+ function lociForResidueRange(structure, min, max) {
1359
+ return lociMatching(structure, (location) => {
1360
+ const index = StructureProperties.residue.key(location);
1361
+ return index >= min && index <= max;
1362
+ });
1363
+ }
1364
+ /**
1365
+ * The same, across every structure loaded into a plugin. Results are merged
1366
+ * into the first structure that matched: a loci belongs to one structure, and
1367
+ * the residue index space already spans them as though they were one.
1368
+ */
1369
+ function lociForSelection(plugin, selection, hiddenChains) {
1370
+ for (const entry of plugin.managers.structure.hierarchy.current.structures) {
1371
+ var _entry$cell$obj2;
1372
+ const structure = (_entry$cell$obj2 = entry.cell.obj) === null || _entry$cell$obj2 === void 0 ? void 0 : _entry$cell$obj2.data;
1373
+ if (!structure) continue;
1374
+ const loci = lociForSelectionInStructure(structure, selection, hiddenChains);
1375
+ if (loci) return loci;
1376
+ }
1377
+ }
1378
+ //#endregion
1379
+ //#region src/core/ProteinStructureViewer/hooks/useChainHighlight.ts
1380
+ /**
1381
+ * What a chain lights up in while its name is under the pointer.
1382
+ *
1383
+ * Its own color rather than the theme's hover tint, because it answers a
1384
+ * different question. Hovering a residue asks "what is this?" and a tint is
1385
+ * enough; pointing at a chain's name asks "where is this chain?" of a
1386
+ * structure where the answer may be half the screen, and has to carry across
1387
+ * a cartoon already painted in the chain palette, in pLDDT bands, or in an
1388
+ * overlay. A saturated pink is in none of those, so it cannot be mistaken for
1389
+ * the structure's own coloring.
1390
+ */
1391
+ const CHAIN_HIGHLIGHT_COLOR = Color.fromRgb(200, 45, 149);
1392
+ /**
1393
+ * Stronger than the residue hover's tint, for the same reason. A whole chain
1394
+ * washed in 20% pink reads as a rendering artifact; at this strength it reads
1395
+ * as deliberate.
1396
+ */
1397
+ const CHAIN_HIGHLIGHT_STRENGTH = .7;
1398
+ /**
1399
+ * Lights up a whole chain in the 3D view while its name is pointed at, in the
1400
+ * sequence panel's captions or the chain legend's rows.
1401
+ *
1402
+ * Mol*'s marking system is what draws it, so the chain is tinted and outlined
1403
+ * exactly as a hovered residue is, with no geometry added to the state tree
1404
+ * and nothing to tear down. Marking colors are a property of the renderer
1405
+ * rather than of the loci, though, so the pink is swapped in for the length of
1406
+ * the hover and the theme's own colors handed back on the way out.
1407
+ *
1408
+ * Those colors are read off the canvas at that moment rather than passed in.
1409
+ * They are already derived from the theme in two places, and a third copy kept
1410
+ * in step by hand would be the one to drift.
1411
+ */
1412
+ function useChainHighlight({ disabled, pluginRef }) {
1413
+ const borrowedRef = useRef(null);
1414
+ const highlightChain = useCallback((name) => {
1415
+ var _borrowedRef$current;
1416
+ const plugin = pluginRef.current;
1417
+ const canvas3d = plugin === null || plugin === void 0 ? void 0 : plugin.canvas3d;
1418
+ if (!plugin || !canvas3d) return;
1419
+ const chainId = disabled ? null : name;
1420
+ if (chainId === null) {
1421
+ plugin.managers.interactivity.lociHighlights.clearHighlights();
1422
+ const borrowed = borrowedRef.current;
1423
+ if (borrowed) {
1424
+ canvas3d.setProps({
1425
+ marking: { highlightEdgeColor: borrowed.highlightEdgeColor },
1426
+ renderer: {
1427
+ highlightColor: borrowed.highlightColor,
1428
+ highlightStrength: borrowed.highlightStrength
1429
+ }
1430
+ });
1431
+ borrowedRef.current = null;
1432
+ }
1433
+ return;
1434
+ }
1435
+ const loci = lociForChainPolymer(plugin, chainId);
1436
+ if (!loci) return;
1437
+ (_borrowedRef$current = borrowedRef.current) !== null && _borrowedRef$current !== void 0 || (borrowedRef.current = {
1438
+ highlightColor: canvas3d.props.renderer.highlightColor,
1439
+ highlightEdgeColor: canvas3d.props.marking.highlightEdgeColor,
1440
+ highlightStrength: canvas3d.props.renderer.highlightStrength
1441
+ });
1442
+ canvas3d.setProps({
1443
+ marking: { highlightEdgeColor: CHAIN_HIGHLIGHT_COLOR },
1444
+ renderer: {
1445
+ highlightColor: CHAIN_HIGHLIGHT_COLOR,
1446
+ highlightStrength: CHAIN_HIGHLIGHT_STRENGTH
1447
+ }
1448
+ });
1449
+ plugin.managers.interactivity.lociHighlights.highlightOnly({ loci });
1450
+ }, [disabled, pluginRef]);
1451
+ useEffect(() => () => void (borrowedRef.current = null), []);
1452
+ return highlightChain;
1453
+ }
1454
+ //#endregion
1455
+ //#region src/common/chainColors.ts
1456
+ /**
1457
+ * Default colors for telling chains apart, in assignment order.
1458
+ *
1459
+ * The Okabe-Ito qualitative palette, which is built to stay distinguishable
1460
+ * under every common form of color blindness - the reason to prefer it over a
1461
+ * hand-picked set, since a complex is read by which chain is which and a
1462
+ * viewer that collapses two of them into one color stops answering that.
1463
+ *
1464
+ * Its black is left out: the canvas inverts with the theme, so a chain painted
1465
+ * black would vanish against the dark background. The rest carry enough
1466
+ * lightness to hold up against both.
1467
+ *
1468
+ * Chains past the end of the palette wrap around to the start.
1469
+ */
1470
+ const CHAIN_COLOR_PALETTE = [
1471
+ "#009E73",
1472
+ "#0072B2",
1473
+ "#E69F00",
1474
+ "#CC79A7",
1475
+ "#56B4E9",
1476
+ "#D55E00",
1477
+ "#F0E442"
1478
+ ];
1479
+ /**
1480
+ * Assigns each chain a color, in the order given, with `overrides` taking
1481
+ * precedence by `chainId`.
1482
+ *
1483
+ * Palette positions are spent on every chain whether or not it is overridden,
1484
+ * so overriding one chain does not shift the colors of the others.
1485
+ */
1486
+ function chainColorMap(chainIds, overrides) {
1487
+ const colors = /* @__PURE__ */ new Map();
1488
+ chainIds.forEach((chainId, i) => {
1489
+ var _overrides$chainId;
1490
+ const fallback = CHAIN_COLOR_PALETTE[i % CHAIN_COLOR_PALETTE.length];
1491
+ colors.set(chainId, (_overrides$chainId = overrides === null || overrides === void 0 ? void 0 : overrides[chainId]) !== null && _overrides$chainId !== void 0 ? _overrides$chainId : fallback);
966
1492
  });
1493
+ return colors;
1494
+ }
1495
+ //#endregion
1496
+ //#region src/core/ProteinStructureViewer/hooks/useChains.ts
1497
+ /**
1498
+ * Reconciles the chains found in the structure with the visibility and coloring
1499
+ * the consumer asked for.
1500
+ *
1501
+ * Visibility is controlled when `hiddenChains` is passed and owned here when it
1502
+ * is not, so the legend's toggles work out of the box without taking the
1503
+ * decision away from a consumer that wants it. `onChainVisibilityChange` fires
1504
+ * either way: a consumer that only wants to follow along should not have to
1505
+ * take ownership to hear about it.
1506
+ */
1507
+ function useChains({ chainColors, chains, hiddenChains, onChainVisibilityChange }) {
1508
+ const isControlled = hiddenChains !== void 0;
1509
+ const [ownHidden, setOwnHidden] = useState([]);
1510
+ const onChainVisibilityChangeRef = useRef(onChainVisibilityChange);
1511
+ onChainVisibilityChangeRef.current = onChainVisibilityChange;
1512
+ const chainIds = useMemo(() => chains.map((c) => c.chainId), [chains]);
1513
+ /**
1514
+ * Chains the structure no longer has cannot be unhidden through the legend,
1515
+ * since it has no row for them, so a stale id would hide a chain that shares
1516
+ * its name in the next structure. Only the uncontrolled side is pruned; a
1517
+ * controlled consumer owns its own list.
1518
+ */
1519
+ useEffect(() => {
1520
+ if (isControlled) return;
1521
+ setOwnHidden((prev) => {
1522
+ const kept = prev.filter((id) => chainIds.includes(id));
1523
+ return kept.length === prev.length ? prev : kept;
1524
+ });
1525
+ }, [chainIds, isControlled]);
1526
+ const hiddenKey = [...isControlled ? hiddenChains : ownHidden].sort().join("\0");
1527
+ const hidden = useMemo(() => new Set(hiddenKey === "" ? [] : hiddenKey.split("\0")), [hiddenKey]);
1528
+ return {
1529
+ colors: useMemo(() => chainColorMap(chainIds, chainColors), [chainIds.map((id) => {
1530
+ var _chainColors$id;
1531
+ return `${id}:${(_chainColors$id = chainColors === null || chainColors === void 0 ? void 0 : chainColors[id]) !== null && _chainColors$id !== void 0 ? _chainColors$id : ""}`;
1532
+ }).join("\0")]),
1533
+ hidden,
1534
+ toggleChain: useCallback((chainId) => {
1535
+ var _onChainVisibilityCha;
1536
+ const next = hidden.has(chainId) ? [...hidden].filter((id) => id !== chainId) : [...hidden, chainId];
1537
+ const ordered = chainIds.filter((id) => next.includes(id));
1538
+ if (!isControlled) setOwnHidden(ordered);
1539
+ (_onChainVisibilityCha = onChainVisibilityChangeRef.current) === null || _onChainVisibilityCha === void 0 || _onChainVisibilityCha.call(onChainVisibilityChangeRef, ordered);
1540
+ }, [
1541
+ chainIds,
1542
+ hidden,
1543
+ isControlled
1544
+ ])
1545
+ };
967
1546
  }
968
1547
  //#endregion
969
1548
  //#region src/core/ProteinStructureViewer/utils/theme.ts
@@ -1025,7 +1604,7 @@ function useSubscribe(observable, handler) {
1025
1604
  */
1026
1605
  function useViewSetting(settings, select) {
1027
1606
  const [value, setValue] = useState(() => select(settings.value));
1028
- useSubscribe(settings, (next) => setValue(select(next)));
1607
+ useSubscribe(settings, (next) => setValue(() => select(next)));
1029
1608
  return value;
1030
1609
  }
1031
1610
  //#endregion
@@ -1054,57 +1633,412 @@ const HOVER_THROTTLE_MS = 3 * 16.666;
1054
1633
  /** How long the copy button shows its confirmation state. */
1055
1634
  const COPIED_FEEDBACK_MS = 2e3;
1056
1635
  //#endregion
1057
- //#region src/core/ProteinStructureViewer/hooks/usePlugin.ts
1058
- /**
1059
- * The plugin owning the React root this component renders in.
1060
- *
1061
- * Mol* renders its view components inside its own root and provides the plugin
1062
- * through this context, which is what `PluginUIComponent` reads to populate
1063
- * `this.plugin`.
1064
- */
1065
- function usePlugin() {
1066
- return useContext(PluginReactContext);
1067
- }
1068
- //#endregion
1069
- //#region src/core/ProteinStructureViewer/components/SequenceView/utils/residue.ts
1070
- /** Reads the sequence index off a residue span, or undefined if it is not one. */
1071
- function readSeqIdx(el) {
1072
- if (!el || !el.getAttribute) return void 0;
1073
- return el.hasAttribute("data-seqid") ? +el.getAttribute(SEQ_ID_ATTR) : void 0;
1074
- }
1075
- /**
1076
- * Nearest ancestor that scrolls vertically, or null if none. Used to scope the
1077
- * residue tooltip's hide-on-scroll listener to the sequence's own overflow
1078
- * container instead of a global window listener.
1079
- */
1080
- function getScrollParent(node) {
1081
- var _node$parentElement;
1082
- let el = (_node$parentElement = node === null || node === void 0 ? void 0 : node.parentElement) !== null && _node$parentElement !== void 0 ? _node$parentElement : null;
1083
- while (el) {
1084
- const overflowY = getComputedStyle(el).overflowY;
1085
- if (overflowY === "auto" || overflowY === "scroll") return el;
1086
- el = el.parentElement;
1087
- }
1088
- return null;
1089
- }
1090
- function getResidueClass(sequenceWrapper, seqIdx) {
1091
- return sequenceWrapper.isHighlighted(seqIdx) || sequenceWrapper.isSelected(seqIdx) || sequenceWrapper.isFocused(seqIdx) ? "msp-sequence-present msp-sequence-residue-active" : "msp-sequence-present msp-sequence-residue";
1092
- }
1093
- /**
1094
- * The label shown above a residue: its author sequence id for atomic units,
1095
- * or a 1-based position for coarse ones.
1096
- *
1097
- * `location` is a scratch object the caller reuses across residues, since this
1098
- * runs once per numbered residue on every render.
1099
- */
1100
- function getSequenceNumber(sequenceWrapper, seqIdx, location) {
1101
- let seqNum = "";
1102
- const loci = sequenceWrapper.getLoci(seqIdx);
1103
- const l = StructureElement.Loci.getFirstLocation(loci, location);
1104
- if (l) {
1105
- if (Unit.isAtomic(l.unit)) {
1106
- const seqId = StructureProperties.residue.auth_seq_id(l);
1107
- const insCode = StructureProperties.residue.pdbx_PDB_ins_code(l);
1636
+ //#region src/core/ProteinStructureViewer/components/SequenceView/style.ts
1637
+ /** Height of the fade that masks residues scrolling under the panel header. */
1638
+ const SCROLL_FADE_HEIGHT = 8;
1639
+ const SequencePanel = styled("div")`
1640
+ display: flex;
1641
+ flex-direction: column;
1642
+ height: 100%;
1643
+
1644
+ ${(props) => {
1645
+ var _semanticColors$base, _props$backgroundColo, _semanticColors$base2;
1646
+ const spaces = getSpaces(props);
1647
+ const semanticColors = getSemanticColors(props);
1648
+ return `
1649
+ padding: ${spaces === null || spaces === void 0 ? void 0 : spaces.m}px ${spaces === null || spaces === void 0 ? void 0 : spaces.m}px 0;
1650
+ border-top: 1px solid ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base = semanticColors.base) === null || _semanticColors$base === void 0 ? void 0 : _semanticColors$base.divider};
1651
+ background-color: ${(_props$backgroundColo = props.backgroundColor) !== null && _props$backgroundColo !== void 0 ? _props$backgroundColo : semanticColors === null || semanticColors === void 0 || (_semanticColors$base2 = semanticColors.base) === null || _semanticColors$base2 === void 0 ? void 0 : _semanticColors$base2.surfacePrimary};
1652
+ `;
1653
+ }}
1654
+ `;
1655
+ const EmptyState = styled("div")`
1656
+ ${fontBodyXs}
1657
+
1658
+ display: flex;
1659
+ align-items: center;
1660
+ justify-content: center;
1661
+ height: 100%;
1662
+
1663
+ ${(props) => {
1664
+ var _props$backgroundColo2, _semanticColors$base3, _semanticColors$base4;
1665
+ const spaces = getSpaces(props);
1666
+ const semanticColors = getSemanticColors(props);
1667
+ return `
1668
+ padding: ${spaces === null || spaces === void 0 ? void 0 : spaces.m}px;
1669
+ background-color: ${(_props$backgroundColo2 = props.backgroundColor) !== null && _props$backgroundColo2 !== void 0 ? _props$backgroundColo2 : semanticColors === null || semanticColors === void 0 || (_semanticColors$base3 = semanticColors.base) === null || _semanticColors$base3 === void 0 ? void 0 : _semanticColors$base3.surfaceSecondary};
1670
+ color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base4 = semanticColors.base) === null || _semanticColors$base4 === void 0 ? void 0 : _semanticColors$base4.textTertiaryOnDark};
1671
+ `;
1672
+ }}
1673
+ `;
1674
+ const PanelHeader = styled("div")`
1675
+ display: flex;
1676
+ align-items: center;
1677
+
1678
+ ${(props) => {
1679
+ const spaces = getSpaces(props);
1680
+ return `
1681
+ gap: ${spaces === null || spaces === void 0 ? void 0 : spaces.xs}px;
1682
+ `;
1683
+ }}
1684
+ `;
1685
+ const PanelTitle = styled("span")`
1686
+ ${fontHeaderM}
1687
+
1688
+ ${(props) => {
1689
+ var _semanticColors$base5;
1690
+ const semanticColors = getSemanticColors(props);
1691
+ return `
1692
+ color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base5 = semanticColors.base) === null || _semanticColors$base5 === void 0 ? void 0 : _semanticColors$base5.textPrimary};
1693
+ `;
1694
+ }}
1695
+ `;
1696
+ const CopyButton = styled("button")`
1697
+ display: flex;
1698
+ align-items: center;
1699
+ padding: 0;
1700
+ border: none;
1701
+ background: none;
1702
+ cursor: default;
1703
+
1704
+ ${(props) => {
1705
+ var _semanticColors$base6, _semanticColors$base7;
1706
+ const semanticColors = getSemanticColors(props);
1707
+ return `
1708
+ color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base6 = semanticColors.base) === null || _semanticColors$base6 === void 0 ? void 0 : _semanticColors$base6.ornamentSecondary};
1709
+
1710
+ &:hover {
1711
+ color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base7 = semanticColors.base) === null || _semanticColors$base7 === void 0 ? void 0 : _semanticColors$base7.textPrimary};
1712
+ }
1713
+ `;
1714
+ }}
1715
+ `;
1716
+ const ResidueCount = styled("div")`
1717
+ display: flex;
1718
+ align-items: center;
1719
+ margin-left: auto;
1720
+
1721
+ ${(props) => {
1722
+ const spaces = getSpaces(props);
1723
+ return `
1724
+ gap: ${spaces === null || spaces === void 0 ? void 0 : spaces.xxs}px;
1725
+ `;
1726
+ }}
1727
+ `;
1728
+ const ResidueCountLabel = styled("span")`
1729
+ ${fontBodyXs}
1730
+
1731
+ ${(props) => {
1732
+ var _semanticColors$base8;
1733
+ const semanticColors = getSemanticColors(props);
1734
+ return `
1735
+ color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base8 = semanticColors.base) === null || _semanticColors$base8 === void 0 ? void 0 : _semanticColors$base8.textTertiary};
1736
+ `;
1737
+ }}
1738
+ `;
1739
+ const ResidueCountValue = styled("span")`
1740
+ ${fontBodyXs}
1741
+
1742
+ ${(props) => {
1743
+ var _semanticColors$base9;
1744
+ const semanticColors = getSemanticColors(props);
1745
+ return `
1746
+ color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base9 = semanticColors.base) === null || _semanticColors$base9 === void 0 ? void 0 : _semanticColors$base9.textPrimary};
1747
+ `;
1748
+ }}
1749
+ `;
1750
+ /**
1751
+ * Scroll region for the residue grid. The `::before` fade masks residues as
1752
+ * they scroll up under the header, so it has to start from whatever the panel
1753
+ * is painted with or it leaves a mismatched strip.
1754
+ */
1755
+ const SequenceScroller = styled("div")`
1756
+ position: relative;
1757
+ flex: 1;
1758
+ min-height: 0;
1759
+
1760
+ ${(props) => {
1761
+ var _props$backgroundColo3, _semanticColors$base10;
1762
+ const semanticColors = getSemanticColors(props);
1763
+ return `
1764
+ &::before {
1765
+ content: "";
1766
+ position: absolute;
1767
+ inset-inline: 0;
1768
+ top: 0;
1769
+ z-index: 10;
1770
+ height: ${SCROLL_FADE_HEIGHT}px;
1771
+ pointer-events: none;
1772
+ background: linear-gradient(to bottom, ${(_props$backgroundColo3 = props.backgroundColor) !== null && _props$backgroundColo3 !== void 0 ? _props$backgroundColo3 : semanticColors === null || semanticColors === void 0 || (_semanticColors$base10 = semanticColors.base) === null || _semanticColors$base10 === void 0 ? void 0 : _semanticColors$base10.surfacePrimary}, transparent);
1773
+ }
1774
+ `;
1775
+ }}
1776
+ `;
1777
+ /**
1778
+ * Residue spans are written to directly by `useResidueMarkers` rather than
1779
+ * re-rendered, so their hover and selection colors are set inline. Everything
1780
+ * here is the static styling underneath that: the monospace type, the pointer
1781
+ * affordance, and the superscript sequence numbers (which need their own color
1782
+ * rule, since they are nested inside residue spans and would otherwise inherit
1783
+ * the inline color).
1784
+ */
1785
+ const SequenceScrollArea = styled("div")`
1786
+ height: 100%;
1787
+ overflow-y: auto;
1788
+
1789
+ .msp-sequence-wrapper {
1790
+ ${fontCodeXs};
1791
+ font-size: 11px;
1792
+ line-height: 1.2;
1793
+ }
1794
+
1795
+ .msp-sequence-present {
1796
+ cursor: pointer;
1797
+ }
1798
+
1799
+ ${(props) => {
1800
+ var _semanticColors$base11;
1801
+ const semanticColors = getSemanticColors(props);
1802
+ return `
1803
+ .msp-sequence-number {
1804
+ font-size: 8px;
1805
+ color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base11 = semanticColors.base) === null || _semanticColors$base11 === void 0 ? void 0 : _semanticColors$base11.textTertiaryOnDark};
1806
+ }
1807
+ `;
1808
+ }}
1809
+ `;
1810
+ /**
1811
+ * Layout for one chain's residues.
1812
+ *
1813
+ * Residues are grouped into fixed-size sections, each one grid cell. The
1814
+ * equal-width auto-fill tracks and uniform gap keep the spacing between
1815
+ * sections identical on every row - including a partial last row, which keeps
1816
+ * the same column width (rather than stretching) and stacks from the leading
1817
+ * edge.
1818
+ */
1819
+ const ResidueGrid = styled("div")`
1820
+ user-select: none;
1821
+ display: grid;
1822
+ grid-template-columns: repeat(
1823
+ auto-fill,
1824
+ minmax(${10}ch, 1fr)
1825
+ );
1826
+ gap: 8px;
1827
+ justify-items: start;
1828
+ padding-top: 6px;
1829
+
1830
+ /* Section wrappers, so a section's letters never split across lines. */
1831
+ & > span {
1832
+ white-space: nowrap;
1833
+ }
1834
+
1835
+ .msp-sequence-present {
1836
+ position: relative;
1837
+ display: inline-block;
1838
+ width: 1ch;
1839
+ /*
1840
+ * Reserves room above the letter for its superscript sequence number.
1841
+ * content-box clipping keeps the active fill off that gutter, so a
1842
+ * highlighted residue does not tint the number.
1843
+ */
1844
+ padding-top: 8px;
1845
+ background-clip: content-box;
1846
+ text-align: center;
1847
+ }
1848
+
1849
+ .msp-sequence-number {
1850
+ position: absolute;
1851
+ top: 0;
1852
+ line-height: 1.2;
1853
+ white-space: nowrap;
1854
+ pointer-events: none;
1855
+ }
1856
+ `;
1857
+ /**
1858
+ * The row above each chain's grid: a visibility toggle and the chain's name.
1859
+ *
1860
+ * A structure with more than one chain renders one grid per chain, and without
1861
+ * a caption between them the grids read as a single continuous protein. The
1862
+ * controls sit here rather than only in the legend because this is where a
1863
+ * reader is already looking at one chain at a time.
1864
+ */
1865
+ const ChainHeaderRow = styled("div")`
1866
+ display: flex;
1867
+ align-items: center;
1868
+
1869
+ ${(props) => {
1870
+ const spaces = getSpaces(props);
1871
+ return `
1872
+ gap: ${spaces === null || spaces === void 0 ? void 0 : spaces.xxs}px;
1873
+ margin-top: ${spaces === null || spaces === void 0 ? void 0 : spaces.xs}px;
1874
+ margin-bottom: ${spaces === null || spaces === void 0 ? void 0 : spaces.xxxs}px;
1875
+ `;
1876
+ }}
1877
+ `;
1878
+ /**
1879
+ * Shows or hides the chain's cartoon in the 3D view. Full strength even while
1880
+ * the chain is hidden, since it is the way back.
1881
+ */
1882
+ const ChainVisibilityToggle = styled("button")`
1883
+ display: flex;
1884
+ align-items: center;
1885
+ padding: 0;
1886
+ border: none;
1887
+ background: none;
1888
+ cursor: pointer;
1889
+
1890
+ ${(props) => {
1891
+ var _semanticColors$base12, _semanticColors$base13;
1892
+ const semanticColors = getSemanticColors(props);
1893
+ return `
1894
+ color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base12 = semanticColors.base) === null || _semanticColors$base12 === void 0 ? void 0 : _semanticColors$base12.textTertiaryOnDark};
1895
+
1896
+ &:hover {
1897
+ color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base13 = semanticColors.base) === null || _semanticColors$base13 === void 0 ? void 0 : _semanticColors$base13.textPrimary};
1898
+ }
1899
+
1900
+ svg {
1901
+ display: block;
1902
+ }
1903
+ `;
1904
+ }}
1905
+ `;
1906
+ /**
1907
+ * The chain's name, and a button because it also selects the chain: the caption
1908
+ * is already the one thing on screen that stands for a whole chain, so clicking
1909
+ * it is where a reader looks to select one. Clicking it again clears it.
1910
+ */
1911
+ const ChainLabel = styled("button")`
1912
+ ${fontBodyXs}
1913
+
1914
+ padding: 0;
1915
+ border: none;
1916
+ background: none;
1917
+ text-align: left;
1918
+
1919
+ ${(props) => {
1920
+ var _semanticColors$base14, _semanticColors$base15, _semanticColors$base16;
1921
+ const semanticColors = getSemanticColors(props);
1922
+ const resting = props.isSelected ? semanticColors === null || semanticColors === void 0 || (_semanticColors$base14 = semanticColors.base) === null || _semanticColors$base14 === void 0 ? void 0 : _semanticColors$base14.textPrimary : semanticColors === null || semanticColors === void 0 || (_semanticColors$base15 = semanticColors.base) === null || _semanticColors$base15 === void 0 ? void 0 : _semanticColors$base15.textSecondary;
1923
+ return `
1924
+ color: ${resting};
1925
+ cursor: ${props.isHidden ? "default" : "pointer"};
1926
+ font-weight: ${props.isSelected ? 600 : 500};
1927
+ opacity: ${props.isHidden ? .4 : 1};
1928
+
1929
+ &:hover {
1930
+ color: ${props.isHidden ? resting : semanticColors === null || semanticColors === void 0 || (_semanticColors$base16 = semanticColors.base) === null || _semanticColors$base16 === void 0 ? void 0 : _semanticColors$base16.textPrimary};
1931
+ }
1932
+ `;
1933
+ }}
1934
+ `;
1935
+ /**
1936
+ * Wraps a chain whose 3D cartoon is hidden. The grid stays in place and stays
1937
+ * interactive - the sequence is still the sequence - but is dimmed to match the
1938
+ * chain being absent from the structure beside it. Dimmed rather than removed
1939
+ * so the panel does not reflow every time a chain is toggled.
1940
+ */
1941
+ const HiddenChainGroup = styled("div")`
1942
+ opacity: 0.4;
1943
+ `;
1944
+ //#endregion
1945
+ //#region src/core/ProteinStructureViewer/components/SequenceView/components/ChainHeader/index.tsx
1946
+ /**
1947
+ * The row above one chain's residue grid: a visibility toggle and the chain's
1948
+ * name, which selects it.
1949
+ *
1950
+ * The name is a toggle rather than a one-way switch, so the tooltip says which
1951
+ * way the next click goes - offering to select a chain that is already selected
1952
+ * would be telling the reader the wrong thing. A hidden chain cannot be
1953
+ * selected at all, so the name stops being a button then: there is no geometry
1954
+ * to focus, and the toggle beside it is what the reader wants instead.
1955
+ */
1956
+ function ChainHeader({ chainId, isHidden, isSelected, label, onHover, onSelect, onToggle }) {
1957
+ return /* @__PURE__ */ jsxs(ChainHeaderRow, {
1958
+ onBlur: () => onHover === null || onHover === void 0 ? void 0 : onHover(null),
1959
+ onFocus: () => onHover === null || onHover === void 0 ? void 0 : onHover(chainId),
1960
+ onMouseEnter: () => onHover === null || onHover === void 0 ? void 0 : onHover(chainId),
1961
+ onMouseLeave: () => onHover === null || onHover === void 0 ? void 0 : onHover(null),
1962
+ children: [/* @__PURE__ */ jsx(Tooltip, {
1963
+ arrow: true,
1964
+ placement: "top",
1965
+ title: isHidden ? `Chain ${label} is hidden` : `Click to ${isSelected ? "deselect" : "select"} Chain ${label}`,
1966
+ textAlign: "left",
1967
+ children: /* @__PURE__ */ jsxs(ChainLabel, {
1968
+ "aria-pressed": isSelected,
1969
+ disabled: isHidden,
1970
+ isHidden,
1971
+ isSelected,
1972
+ onClick: () => onSelect === null || onSelect === void 0 ? void 0 : onSelect(chainId),
1973
+ type: "button",
1974
+ children: ["Chain ", label]
1975
+ })
1976
+ }), onToggle && /* @__PURE__ */ jsx(Tooltip, {
1977
+ arrow: true,
1978
+ placement: "top",
1979
+ title: `${isHidden ? "Show" : "Hide"} Chain ${label}`,
1980
+ children: /* @__PURE__ */ jsx(ChainVisibilityToggle, {
1981
+ "aria-label": `${isHidden ? "Show" : "Hide"} chain ${label}`,
1982
+ "aria-pressed": isHidden,
1983
+ onClick: () => onToggle(chainId),
1984
+ type: "button",
1985
+ children: isHidden ? /* @__PURE__ */ jsx(EyeSlashIcon, { size: 12 }) : /* @__PURE__ */ jsx(EyeIcon, { size: 12 })
1986
+ })
1987
+ })]
1988
+ });
1989
+ }
1990
+ //#endregion
1991
+ //#region src/core/ProteinStructureViewer/hooks/usePlugin.ts
1992
+ /**
1993
+ * The plugin owning the React root this component renders in.
1994
+ *
1995
+ * Mol* renders its view components inside its own root and provides the plugin
1996
+ * through this context, which is what `PluginUIComponent` reads to populate
1997
+ * `this.plugin`.
1998
+ */
1999
+ function usePlugin() {
2000
+ return useContext(PluginReactContext);
2001
+ }
2002
+ //#endregion
2003
+ //#region src/core/ProteinStructureViewer/components/SequenceView/utils/residue.ts
2004
+ /** Reads the sequence index off a residue span, or undefined if it is not one. */
2005
+ function readSeqIdx(el) {
2006
+ if (!el || !el.getAttribute) return void 0;
2007
+ return el.hasAttribute("data-seqid") ? +el.getAttribute(SEQ_ID_ATTR) : void 0;
2008
+ }
2009
+ /**
2010
+ * Nearest ancestor that scrolls vertically, or null if none. Used to scope the
2011
+ * residue tooltip's hide-on-scroll listener to the sequence's own overflow
2012
+ * container instead of a global window listener.
2013
+ */
2014
+ function getScrollParent(node) {
2015
+ var _node$parentElement;
2016
+ let el = (_node$parentElement = node === null || node === void 0 ? void 0 : node.parentElement) !== null && _node$parentElement !== void 0 ? _node$parentElement : null;
2017
+ while (el) {
2018
+ const overflowY = getComputedStyle(el).overflowY;
2019
+ if (overflowY === "auto" || overflowY === "scroll") return el;
2020
+ el = el.parentElement;
2021
+ }
2022
+ return null;
2023
+ }
2024
+ function getResidueClass(sequenceWrapper, seqIdx) {
2025
+ return sequenceWrapper.isHighlighted(seqIdx) || sequenceWrapper.isSelected(seqIdx) || sequenceWrapper.isFocused(seqIdx) ? "msp-sequence-present msp-sequence-residue-active" : "msp-sequence-present msp-sequence-residue";
2026
+ }
2027
+ /**
2028
+ * The label shown above a residue: its author sequence id for atomic units,
2029
+ * or a 1-based position for coarse ones.
2030
+ *
2031
+ * `location` is a scratch object the caller reuses across residues, since this
2032
+ * runs once per numbered residue on every render.
2033
+ */
2034
+ function getSequenceNumber(sequenceWrapper, seqIdx, location) {
2035
+ let seqNum = "";
2036
+ const loci = sequenceWrapper.getLoci(seqIdx);
2037
+ const l = StructureElement.Loci.getFirstLocation(loci, location);
2038
+ if (l) {
2039
+ if (Unit.isAtomic(l.unit)) {
2040
+ const seqId = StructureProperties.residue.auth_seq_id(l);
2041
+ const insCode = StructureProperties.residue.pdbx_PDB_ins_code(l);
1108
2042
  seqNum = `${seqId}${insCode ? insCode : ""}`;
1109
2043
  } else if (Unit.isCoarse(l.unit)) seqNum = `${seqIdx + 1}`;
1110
2044
  }
@@ -1215,6 +2149,63 @@ function useResidueMarkers({ containerRef, residueColors, sequenceWrapper }) {
1215
2149
  });
1216
2150
  }
1217
2151
  //#endregion
2152
+ //#region src/core/ProteinStructureViewer/utils/residueRef.ts
2153
+ /**
2154
+ * Reads the residue a loci points at, or null when it points at nothing the
2155
+ * viewer can address.
2156
+ *
2157
+ * `index` comes from Mol*'s residue key, which counts residues in file order
2158
+ * from zero and is the same counter `injectPlddtIntoPdb` walks. Deriving it
2159
+ * from the residue's *number* instead would tie it to how the file happens to
2160
+ * be numbered: a crop beginning at residue 200 would index `plddt` at 199, and
2161
+ * a file numbering each chain from 1 would give two residues the same index.
2162
+ */
2163
+ function residueRefFromLoci(loci) {
2164
+ var _StructureProperties$;
2165
+ const location = StructureElement.Location.create(void 0);
2166
+ const firstLoc = StructureElement.Loci.getFirstLocation(loci, location);
2167
+ if (!firstLoc) return null;
2168
+ return {
2169
+ chainId: StructureProperties.chain.auth_asym_id(firstLoc),
2170
+ compId: StructureProperties.atom.label_comp_id(firstLoc),
2171
+ insCode: (_StructureProperties$ = StructureProperties.residue.pdbx_PDB_ins_code(firstLoc)) !== null && _StructureProperties$ !== void 0 ? _StructureProperties$ : "",
2172
+ index: StructureProperties.residue.key(firstLoc),
2173
+ seqId: StructureProperties.residue.auth_seq_id(firstLoc)
2174
+ };
2175
+ }
2176
+ /**
2177
+ * Every residue a loci covers, as a selection the consumer can echo back.
2178
+ *
2179
+ * The counterpart to `residueRefFromLoci`, which names only the first: a drag
2180
+ * across the sequence hands Mol* a range, and reporting its first residue
2181
+ * alone would turn a range the user drew into a single-residue selection.
2182
+ *
2183
+ * Reported as residues rather than as chains even when a whole chain is
2184
+ * covered. What the user drew is a span of residues, and calling it a chain
2185
+ * because it happens to end where one does would change the selection's meaning
2186
+ * on a structure where the two coincide.
2187
+ */
2188
+ function selectionFromLoci(loci) {
2189
+ const location = StructureElement.Location.create(loci.structure);
2190
+ const residues = /* @__PURE__ */ new Set();
2191
+ for (const element of loci.elements) {
2192
+ location.unit = element.unit;
2193
+ OrderedSet.forEach(element.indices, (i) => {
2194
+ location.element = element.unit.elements[i];
2195
+ residues.add(StructureProperties.residue.key(location));
2196
+ });
2197
+ }
2198
+ return { residues: [...residues].sort((a, b) => a - b) };
2199
+ }
2200
+ /**
2201
+ * How a residue is named in the readout, e.g. `"LYS 111"`. Takes the number and
2202
+ * the insertion code from the file so that the readout and the sequence panel
2203
+ * beneath it agree on what to call a residue.
2204
+ */
2205
+ function residueLabel(residue) {
2206
+ return `${residue.compId} ${residue.seqId}${residue.insCode}`;
2207
+ }
2208
+ //#endregion
1218
2209
  //#region src/core/ProteinStructureViewer/components/SequenceView/utils/loci.ts
1219
2210
  /** The loci for a residue, or undefined when there is no residue there. */
1220
2211
  function getLoci(sequenceWrapper, seqIdx) {
@@ -1227,25 +2218,28 @@ function getLoci(sequenceWrapper, seqIdx) {
1227
2218
  * Extends a loci to the range between the mouse-down anchor and the current
1228
2219
  * residue, so dragging across the sequence selects a contiguous span.
1229
2220
  *
1230
- * Element indices are only comparable within a single unit, and the range below
1231
- * is built on the anchor's unit alone. So a drag is only extended when both
1232
- * endpoints are a single element of the same unit; anything else -- different
1233
- * units, or an endpoint spanning several because the chain has more than one
1234
- * symmetry operator -- is left unextended rather than collapsed onto one unit
1235
- * and silently reinterpreted as a different range.
2221
+ * The span is measured in the viewer's own residue index - position in file
2222
+ * order across the whole structure - rather than in element indices, which are
2223
+ * unit-local and so cannot describe a range reaching from one chain into the
2224
+ * next. Measuring it the way every other residue-keyed API measures it is what
2225
+ * lets a drag cross a chain break, and it means a range covers exactly the
2226
+ * residues whose indices lie between its ends.
2227
+ *
2228
+ * An endpoint that names no residue leaves the drag unextended rather than
2229
+ * guessing at where it was. An endpoint spanning several units - a chain with
2230
+ * more than one symmetry operator - is fine: each copy of a residue carries the
2231
+ * same index, so the span is the same either way and resolves back across all
2232
+ * of them.
1236
2233
  */
1237
2234
  function extendToRange(loci, anchor) {
1238
- if (anchor.elements.length !== 1 || loci.elements.length !== 1) return loci;
1239
- const ref = anchor.elements[0];
1240
- const ext = loci.elements[0];
1241
- if (!ref || !ext) return loci;
1242
- if (ref.unit !== ext.unit) return loci;
1243
- const min = Math.min(OrderedSet.min(ref.indices), OrderedSet.min(ext.indices));
1244
- const max = Math.max(OrderedSet.max(ref.indices), OrderedSet.max(ext.indices));
1245
- return StructureElement.Loci(loci.structure, [{
1246
- indices: OrderedSet.ofRange(min, max),
1247
- unit: ref.unit
1248
- }]);
2235
+ var _lociForResidueRange;
2236
+ const from = residueRefFromLoci(anchor);
2237
+ const to = residueRefFromLoci(loci);
2238
+ if (!from || !to) return loci;
2239
+ if (from.index === to.index) return loci;
2240
+ const min = Math.min(from.index, to.index);
2241
+ const max = Math.max(from.index, to.index);
2242
+ return (_lociForResidueRange = lociForResidueRange(loci.structure, min, max)) !== null && _lociForResidueRange !== void 0 ? _lociForResidueRange : loci;
1249
2243
  }
1250
2244
  //#endregion
1251
2245
  //#region src/core/ProteinStructureViewer/components/SequenceView/hooks/useHoveredResidue.ts
@@ -1255,430 +2249,236 @@ function extendToRange(loci, anchor) {
1255
2249
  * The tracked index also deduplicates pointer moves: the grid sees a move for
1256
2250
  * every pixel, but only the ones that cross into a different residue matter.
1257
2251
  */
1258
- function useHoveredResidue({ containerRef, sequenceWrapper }) {
1259
- const tooltipRef = useRef(null);
1260
- /** Index of the tracked residue, or -1 when the pointer is off the grid. */
1261
- const seqIdxRef = useRef(-1);
1262
- const hide = useCallback(() => {
1263
- var _tooltipRef$current;
1264
- return (_tooltipRef$current = tooltipRef.current) === null || _tooltipRef$current === void 0 ? void 0 : _tooltipRef$current.hide();
1265
- }, []);
1266
- const clear = useCallback(() => {
1267
- var _tooltipRef$current2;
1268
- if (seqIdxRef.current === -1) return false;
1269
- seqIdxRef.current = -1;
1270
- (_tooltipRef$current2 = tooltipRef.current) === null || _tooltipRef$current2 === void 0 || _tooltipRef$current2.hide();
1271
- return true;
1272
- }, []);
1273
- const moveTo = useCallback((seqIdx, el) => {
1274
- var _tooltipRef$current3, _tooltipRef$current4;
1275
- if (seqIdxRef.current === seqIdx) return false;
1276
- seqIdxRef.current = seqIdx;
1277
- if (seqIdx < 0) (_tooltipRef$current3 = tooltipRef.current) === null || _tooltipRef$current3 === void 0 || _tooltipRef$current3.hide();
1278
- else (_tooltipRef$current4 = tooltipRef.current) === null || _tooltipRef$current4 === void 0 || _tooltipRef$current4.show(el, sequenceWrapper.residueLabel(seqIdx));
1279
- return true;
1280
- }, [sequenceWrapper]);
1281
- useEffect(() => {
1282
- const scrollTarget = getScrollParent(containerRef.current);
1283
- if (!scrollTarget) return;
1284
- const handleScroll = () => clear();
1285
- scrollTarget.addEventListener("scroll", handleScroll, { passive: true });
1286
- return () => scrollTarget.removeEventListener("scroll", handleScroll);
1287
- }, [clear, containerRef]);
1288
- return useMemo(() => ({
1289
- clear,
1290
- hide,
1291
- moveTo,
1292
- tooltipRef
1293
- }), [
1294
- clear,
1295
- hide,
1296
- moveTo
1297
- ]);
1298
- }
1299
- //#endregion
1300
- //#region src/core/ProteinStructureViewer/components/SequenceView/hooks/useLociDispatch.ts
1301
- /**
1302
- * Wraps a residue loci and the mouse state into a Mol* interaction event. An
1303
- * `anchor` extends the loci to the range between it and `loci`, which is how a
1304
- * drag across the sequence comes out as a contiguous span.
1305
- */
1306
- function buildEvent(loci, buttons, button, modifiers, anchor) {
1307
- const ev = {
1308
- button,
1309
- buttons,
1310
- current: Representation.Loci.Empty,
1311
- modifiers
1312
- };
1313
- if (loci === void 0 || StructureElement.Loci.isEmpty(loci)) return ev;
1314
- ev.current = { loci: anchor ? extendToRange(loci, anchor) : loci };
1315
- return ev;
1316
- }
1317
- /**
1318
- * Reports the grid's hover and click to Mol*, on the same behaviors the 3D
1319
- * view publishes to, so an interaction in either surface reaches the other.
1320
- */
1321
- function useLociDispatch() {
1322
- const plugin = usePlugin();
1323
- const hover = useCallback((loci, buttons, button, modifiers, anchor) => {
1324
- plugin.behaviors.interaction.hover.next(buildEvent(loci, buttons, button, modifiers, anchor));
1325
- }, [plugin]);
1326
- const click = useCallback((loci, buttons, button, modifiers, anchor) => {
1327
- plugin.behaviors.interaction.click.next(buildEvent(loci, buttons, button, modifiers, anchor));
1328
- }, [plugin]);
1329
- return useMemo(() => ({
1330
- click,
1331
- hover
1332
- }), [click, hover]);
1333
- }
1334
- //#endregion
1335
- //#region src/core/ProteinStructureViewer/components/SequenceView/hooks/useResidueSelection.ts
1336
- /**
1337
- * Turns a press and release on the grid into a selection: one residue when
1338
- * both land on the same one, otherwise the contiguous range dragged across.
1339
- */
1340
- function useResidueSelection({ sequenceWrapper }) {
1341
- const anchorRef = useRef(void 0);
1342
- const { click } = useLociDispatch();
1343
- const onMouseDown = (e) => {
1344
- e.stopPropagation();
1345
- anchorRef.current = getLoci(sequenceWrapper, readSeqIdx(e.target));
1346
- };
1347
- const onMouseUp = (e) => {
1348
- e.stopPropagation();
1349
- const anchor = anchorRef.current;
1350
- if (anchor === void 0) return;
1351
- const loci = getLoci(sequenceWrapper, readSeqIdx(e.target));
1352
- if (loci) {
1353
- const dragged = !StructureElement.Loci.areEqual(anchor, loci);
1354
- click(loci, getButtons(e.nativeEvent), getButton(e.nativeEvent), getModifiers(e.nativeEvent), dragged ? anchor : void 0);
1355
- }
1356
- anchorRef.current = void 0;
1357
- };
1358
- return {
1359
- clearAnchor: () => {
1360
- anchorRef.current = void 0;
1361
- },
1362
- getAnchor: () => anchorRef.current,
1363
- onMouseDown,
1364
- onMouseUp
1365
- };
1366
- }
1367
- //#endregion
1368
- //#region src/core/ProteinStructureViewer/components/SequenceView/hooks/useResiduePointer.ts
1369
- /**
1370
- * Everything the grid does with a pointer: hover on move, and the drag-to-range
1371
- * selection delegated to `useResidueSelection`.
1372
- *
1373
- * Hover reaches the 3D view through a throttled queue, so that sweeping the
1374
- * pointer across the grid does not ask Mol* to re-mark on every residue
1375
- * crossed. A drag is exempt: its hover carries the growing range, so it goes
1376
- * out immediately to keep the preview under the pointer.
1377
- */
1378
- function useResiduePointer({ containerRef, sequenceWrapper }) {
1379
- const { hover } = useLociDispatch();
1380
- const { clear, hide, moveTo, tooltipRef } = useHoveredResidue({
1381
- containerRef,
1382
- sequenceWrapper
1383
- });
1384
- const { clearAnchor, getAnchor, onMouseDown, onMouseUp } = useResidueSelection({ sequenceWrapper });
1385
- const queueRef = useRef(null);
1386
- if (queueRef.current === null) queueRef.current = new Subject();
1387
- const queue = queueRef.current;
1388
- useSubscribe(useMemo(() => queue.pipe(throttleTime(HOVER_THROTTLE_MS, void 0, {
1389
- leading: true,
1390
- trailing: true
1391
- })), [queue]), (e) => {
1392
- hover(getLoci(sequenceWrapper, e.seqIdx < 0 ? void 0 : e.seqIdx), e.buttons, e.button, e.modifiers, getAnchor());
1393
- });
1394
- const queueHover = (e, seqIdx) => queue.next({
1395
- button: getButton(e.nativeEvent),
1396
- buttons: getButtons(e.nativeEvent),
1397
- modifiers: getModifiers(e.nativeEvent),
1398
- seqIdx
1399
- });
1400
- /** Asks the 3D view to drop its hover, once, if the grid still holds one. */
1401
- const releaseHover = (e) => {
1402
- if (clear()) queueHover(e, -1);
1403
- };
1404
- const onContextMenu = (e) => {
1405
- e.preventDefault();
1406
- };
1407
- const onMouseMove = (e) => {
1408
- var _readSeqIdx;
1409
- e.stopPropagation();
1410
- const el = e.target;
1411
- if (!el || !el.getAttribute) {
1412
- releaseHover(e);
1413
- return;
1414
- }
1415
- const seqIdx = (_readSeqIdx = readSeqIdx(el)) !== null && _readSeqIdx !== void 0 ? _readSeqIdx : -1;
1416
- if (!moveTo(seqIdx, el)) return;
1417
- const anchor = getAnchor();
1418
- if (anchor === void 0) {
1419
- queueHover(e, seqIdx);
1420
- return;
1421
- }
1422
- hover(getLoci(sequenceWrapper, seqIdx), ButtonsType.Flag.None, ButtonsType.Flag.None, getModifiers(e.nativeEvent), anchor);
1423
- };
1424
- const onMouseLeave = (e) => {
1425
- e.stopPropagation();
1426
- clearAnchor();
1427
- hide();
1428
- releaseHover(e);
1429
- };
1430
- return {
1431
- handlers: {
1432
- onContextMenu,
1433
- onMouseDown,
1434
- onMouseLeave,
1435
- onMouseMove,
1436
- onMouseUp
1437
- },
2252
+ function useHoveredResidue({ containerRef, sequenceWrapper }) {
2253
+ const tooltipRef = useRef(null);
2254
+ /** Index of the tracked residue, or -1 when the pointer is off the grid. */
2255
+ const seqIdxRef = useRef(-1);
2256
+ const hide = useCallback(() => {
2257
+ var _tooltipRef$current;
2258
+ return (_tooltipRef$current = tooltipRef.current) === null || _tooltipRef$current === void 0 ? void 0 : _tooltipRef$current.hide();
2259
+ }, []);
2260
+ const clear = useCallback(() => {
2261
+ var _tooltipRef$current2;
2262
+ if (seqIdxRef.current === -1) return false;
2263
+ seqIdxRef.current = -1;
2264
+ (_tooltipRef$current2 = tooltipRef.current) === null || _tooltipRef$current2 === void 0 || _tooltipRef$current2.hide();
2265
+ return true;
2266
+ }, []);
2267
+ const moveTo = useCallback((seqIdx, el) => {
2268
+ var _tooltipRef$current3, _tooltipRef$current4;
2269
+ if (seqIdxRef.current === seqIdx) return false;
2270
+ seqIdxRef.current = seqIdx;
2271
+ if (seqIdx < 0) (_tooltipRef$current3 = tooltipRef.current) === null || _tooltipRef$current3 === void 0 || _tooltipRef$current3.hide();
2272
+ else (_tooltipRef$current4 = tooltipRef.current) === null || _tooltipRef$current4 === void 0 || _tooltipRef$current4.show(el, sequenceWrapper.residueLabel(seqIdx));
2273
+ return true;
2274
+ }, [sequenceWrapper]);
2275
+ useEffect(() => {
2276
+ const scrollTarget = getScrollParent(containerRef.current);
2277
+ if (!scrollTarget) return;
2278
+ const handleScroll = () => clear();
2279
+ scrollTarget.addEventListener("scroll", handleScroll, { passive: true });
2280
+ return () => scrollTarget.removeEventListener("scroll", handleScroll);
2281
+ }, [clear, containerRef]);
2282
+ return useMemo(() => ({
2283
+ clear,
2284
+ hide,
2285
+ moveTo,
1438
2286
  tooltipRef
1439
- };
2287
+ }), [
2288
+ clear,
2289
+ hide,
2290
+ moveTo
2291
+ ]);
1440
2292
  }
1441
2293
  //#endregion
1442
- //#region src/core/ProteinStructureViewer/components/SequenceView/style.ts
1443
- /** Height of the fade that masks residues scrolling under the panel header. */
1444
- const SCROLL_FADE_HEIGHT = 8;
1445
- const SequencePanel = styled("div")`
1446
- display: flex;
1447
- flex-direction: column;
1448
- height: 100%;
1449
-
1450
- ${(props) => {
1451
- var _semanticColors$base, _props$backgroundColo, _semanticColors$base2;
1452
- const spaces = getSpaces(props);
1453
- const semanticColors = getSemanticColors(props);
1454
- return `
1455
- padding: ${spaces === null || spaces === void 0 ? void 0 : spaces.m}px ${spaces === null || spaces === void 0 ? void 0 : spaces.m}px 0;
1456
- border-top: 1px solid ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base = semanticColors.base) === null || _semanticColors$base === void 0 ? void 0 : _semanticColors$base.divider};
1457
- background-color: ${(_props$backgroundColo = props.backgroundColor) !== null && _props$backgroundColo !== void 0 ? _props$backgroundColo : semanticColors === null || semanticColors === void 0 || (_semanticColors$base2 = semanticColors.base) === null || _semanticColors$base2 === void 0 ? void 0 : _semanticColors$base2.surfacePrimary};
1458
- `;
1459
- }}
1460
- `;
1461
- const EmptyState = styled("div")`
1462
- ${fontBodyXs}
1463
-
1464
- display: flex;
1465
- align-items: center;
1466
- justify-content: center;
1467
- height: 100%;
1468
-
1469
- ${(props) => {
1470
- var _props$backgroundColo2, _semanticColors$base3, _semanticColors$base4;
1471
- const spaces = getSpaces(props);
1472
- const semanticColors = getSemanticColors(props);
1473
- return `
1474
- padding: ${spaces === null || spaces === void 0 ? void 0 : spaces.m}px;
1475
- background-color: ${(_props$backgroundColo2 = props.backgroundColor) !== null && _props$backgroundColo2 !== void 0 ? _props$backgroundColo2 : semanticColors === null || semanticColors === void 0 || (_semanticColors$base3 = semanticColors.base) === null || _semanticColors$base3 === void 0 ? void 0 : _semanticColors$base3.surfaceSecondary};
1476
- color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base4 = semanticColors.base) === null || _semanticColors$base4 === void 0 ? void 0 : _semanticColors$base4.textTertiaryOnDark};
1477
- `;
1478
- }}
1479
- `;
1480
- const PanelHeader = styled("div")`
1481
- display: flex;
1482
- align-items: center;
1483
-
1484
- ${(props) => {
1485
- const spaces = getSpaces(props);
1486
- return `
1487
- gap: ${spaces === null || spaces === void 0 ? void 0 : spaces.xs}px;
1488
- `;
1489
- }}
1490
- `;
1491
- const PanelTitle = styled("span")`
1492
- ${fontHeaderM}
1493
-
1494
- ${(props) => {
1495
- var _semanticColors$base5;
1496
- const semanticColors = getSemanticColors(props);
1497
- return `
1498
- color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base5 = semanticColors.base) === null || _semanticColors$base5 === void 0 ? void 0 : _semanticColors$base5.textPrimary};
1499
- `;
1500
- }}
1501
- `;
1502
- const CopyButton = styled("button")`
1503
- display: flex;
1504
- align-items: center;
1505
- padding: 0;
1506
- border: none;
1507
- background: none;
1508
- cursor: default;
1509
-
1510
- ${(props) => {
1511
- var _semanticColors$base6, _semanticColors$base7;
1512
- const semanticColors = getSemanticColors(props);
1513
- return `
1514
- color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base6 = semanticColors.base) === null || _semanticColors$base6 === void 0 ? void 0 : _semanticColors$base6.ornamentSecondary};
1515
-
1516
- &:hover {
1517
- color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base7 = semanticColors.base) === null || _semanticColors$base7 === void 0 ? void 0 : _semanticColors$base7.textPrimary};
1518
- }
1519
- `;
1520
- }}
1521
- `;
1522
- const ResidueCount = styled("div")`
1523
- display: flex;
1524
- align-items: center;
1525
- margin-left: auto;
1526
-
1527
- ${(props) => {
1528
- const spaces = getSpaces(props);
1529
- return `
1530
- gap: ${spaces === null || spaces === void 0 ? void 0 : spaces.xxs}px;
1531
- `;
1532
- }}
1533
- `;
1534
- const ResidueCountLabel = styled("span")`
1535
- ${fontBodyXs}
1536
-
1537
- ${(props) => {
1538
- var _semanticColors$base8;
1539
- const semanticColors = getSemanticColors(props);
1540
- return `
1541
- color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base8 = semanticColors.base) === null || _semanticColors$base8 === void 0 ? void 0 : _semanticColors$base8.textTertiary};
1542
- `;
1543
- }}
1544
- `;
1545
- const ResidueCountValue = styled("span")`
1546
- ${fontBodyXs}
1547
-
1548
- ${(props) => {
1549
- var _semanticColors$base9;
1550
- const semanticColors = getSemanticColors(props);
1551
- return `
1552
- color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base9 = semanticColors.base) === null || _semanticColors$base9 === void 0 ? void 0 : _semanticColors$base9.textPrimary};
1553
- `;
1554
- }}
1555
- `;
2294
+ //#region src/core/ProteinStructureViewer/components/SequenceView/hooks/useLociDispatch.ts
1556
2295
  /**
1557
- * Scroll region for the residue grid. The `::before` fade masks residues as
1558
- * they scroll up under the header, so it has to start from whatever the panel
1559
- * is painted with or it leaves a mismatched strip.
2296
+ * Wraps a residue loci and the mouse state into a Mol* interaction event. An
2297
+ * `anchor` extends the loci to the range between it and `loci`, which is how a
2298
+ * drag across the sequence comes out as a contiguous span.
1560
2299
  */
1561
- const SequenceScroller = styled("div")`
1562
- position: relative;
1563
- flex: 1;
1564
- min-height: 0;
1565
-
1566
- ${(props) => {
1567
- var _props$backgroundColo3, _semanticColors$base10;
1568
- const semanticColors = getSemanticColors(props);
1569
- return `
1570
- &::before {
1571
- content: "";
1572
- position: absolute;
1573
- inset-inline: 0;
1574
- top: 0;
1575
- z-index: 10;
1576
- height: ${SCROLL_FADE_HEIGHT}px;
1577
- pointer-events: none;
1578
- background: linear-gradient(to bottom, ${(_props$backgroundColo3 = props.backgroundColor) !== null && _props$backgroundColo3 !== void 0 ? _props$backgroundColo3 : semanticColors === null || semanticColors === void 0 || (_semanticColors$base10 = semanticColors.base) === null || _semanticColors$base10 === void 0 ? void 0 : _semanticColors$base10.surfacePrimary}, transparent);
1579
- }
1580
- `;
1581
- }}
1582
- `;
2300
+ function buildEvent(loci, buttons, button, modifiers, anchor) {
2301
+ const ev = {
2302
+ button,
2303
+ buttons,
2304
+ current: Representation.Loci.Empty,
2305
+ modifiers
2306
+ };
2307
+ if (loci === void 0 || StructureElement.Loci.isEmpty(loci)) return ev;
2308
+ ev.current = { loci: anchor ? extendToRange(loci, anchor) : loci };
2309
+ return ev;
2310
+ }
1583
2311
  /**
1584
- * Residue spans are written to directly by `useResidueMarkers` rather than
1585
- * re-rendered, so their hover and selection colors are set inline. Everything
1586
- * here is the static styling underneath that: the monospace type, the pointer
1587
- * affordance, and the superscript sequence numbers (which need their own color
1588
- * rule, since they are nested inside residue spans and would otherwise inherit
1589
- * the inline color).
2312
+ * Reports the grid's hover and click to Mol*, on the same behaviors the 3D
2313
+ * view publishes to, so an interaction in either surface reaches the other.
1590
2314
  */
1591
- const SequenceScrollArea = styled("div")`
1592
- height: 100%;
1593
- overflow-y: auto;
1594
-
1595
- .msp-sequence-wrapper {
1596
- ${fontCodeXs};
1597
- font-size: 11px;
1598
- line-height: 1.2;
1599
- }
1600
-
1601
- .msp-sequence-present {
1602
- cursor: pointer;
1603
- }
1604
-
1605
- ${(props) => {
1606
- var _semanticColors$base11;
1607
- const semanticColors = getSemanticColors(props);
1608
- return `
1609
- .msp-sequence-number {
1610
- font-size: 8px;
1611
- color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base11 = semanticColors.base) === null || _semanticColors$base11 === void 0 ? void 0 : _semanticColors$base11.textTertiaryOnDark};
1612
- }
1613
- `;
1614
- }}
1615
- `;
2315
+ function useLociDispatch() {
2316
+ const plugin = usePlugin();
2317
+ const hover = useCallback((loci, buttons, button, modifiers, anchor) => {
2318
+ plugin.behaviors.interaction.hover.next(buildEvent(loci, buttons, button, modifiers, anchor));
2319
+ }, [plugin]);
2320
+ const click = useCallback((loci, buttons, button, modifiers, anchor) => {
2321
+ plugin.behaviors.interaction.click.next(buildEvent(loci, buttons, button, modifiers, anchor));
2322
+ }, [plugin]);
2323
+ return useMemo(() => ({
2324
+ click,
2325
+ hover
2326
+ }), [click, hover]);
2327
+ }
2328
+ //#endregion
2329
+ //#region src/core/ProteinStructureViewer/components/SequenceView/hooks/useSequenceDrag.ts
2330
+ const SequenceDragContext = createContext({
2331
+ clearAnchor: () => void 0,
2332
+ getAnchor: () => void 0,
2333
+ setAnchor: () => void 0
2334
+ });
2335
+ const SequenceDragProvider = SequenceDragContext.Provider;
1616
2336
  /**
1617
- * Layout for one chain's residues.
2337
+ * The anchor a drag across the sequence started from, shared by every chain's
2338
+ * grid.
1618
2339
  *
1619
- * Residues are grouped into fixed-size sections, each one grid cell. The
1620
- * equal-width auto-fill tracks and uniform gap keep the spacing between
1621
- * sections identical on every row - including a partial last row, which keeps
1622
- * the same column width (rather than stretching) and stacks from the leading
1623
- * edge.
2340
+ * It has to live above the grids rather than inside one. A complex renders one
2341
+ * grid per chain, so a drag that begins on the target and ends on the binder
2342
+ * starts in one component and finishes in another; an anchor held by the grid
2343
+ * it started in is not there to be read by the grid it ends in, and the drag
2344
+ * would be reported as a plain click on whatever it landed on.
2345
+ *
2346
+ * Held in a ref rather than state: it changes on mouse-down and mouse-up, and
2347
+ * nothing renders from it, so re-rendering every residue in the panel twice per
2348
+ * drag would buy nothing.
2349
+ */
2350
+ function useCreateSequenceDrag() {
2351
+ const anchorRef = useRef(void 0);
2352
+ const getAnchor = useCallback(() => anchorRef.current, []);
2353
+ const setAnchor = useCallback((anchor) => {
2354
+ anchorRef.current = anchor;
2355
+ }, []);
2356
+ const clearAnchor = useCallback(() => {
2357
+ anchorRef.current = void 0;
2358
+ }, []);
2359
+ return useMemo(() => ({
2360
+ clearAnchor,
2361
+ getAnchor,
2362
+ setAnchor
2363
+ }), [
2364
+ clearAnchor,
2365
+ getAnchor,
2366
+ setAnchor
2367
+ ]);
2368
+ }
2369
+ function useSequenceDrag() {
2370
+ return useContext(SequenceDragContext);
2371
+ }
2372
+ //#endregion
2373
+ //#region src/core/ProteinStructureViewer/components/SequenceView/hooks/useResidueSelection.ts
2374
+ /**
2375
+ * Turns a press and release on the grid into a selection: one residue when
2376
+ * both land on the same one, otherwise the contiguous range dragged across -
2377
+ * which the panel-level anchor lets reach into another chain's grid.
1624
2378
  */
1625
- const ResidueGrid = styled("div")`
1626
- user-select: none;
1627
- display: grid;
1628
- grid-template-columns: repeat(
1629
- auto-fill,
1630
- minmax(${10}ch, 1fr)
1631
- );
1632
- gap: 8px;
1633
- justify-items: start;
1634
- padding-top: 6px;
1635
-
1636
- /* Section wrappers, so a section's letters never split across lines. */
1637
- & > span {
1638
- white-space: nowrap;
1639
- }
1640
-
1641
- .msp-sequence-present {
1642
- position: relative;
1643
- display: inline-block;
1644
- width: 1ch;
1645
- /*
1646
- * Reserves room above the letter for its superscript sequence number.
1647
- * content-box clipping keeps the active fill off that gutter, so a
1648
- * highlighted residue does not tint the number.
1649
- */
1650
- padding-top: 8px;
1651
- background-clip: content-box;
1652
- text-align: center;
1653
- }
1654
-
1655
- .msp-sequence-number {
1656
- position: absolute;
1657
- top: 0;
1658
- line-height: 1.2;
1659
- white-space: nowrap;
1660
- pointer-events: none;
1661
- }
1662
- `;
2379
+ function useResidueSelection({ sequenceWrapper }) {
2380
+ const { clearAnchor, getAnchor, setAnchor } = useSequenceDrag();
2381
+ const { click } = useLociDispatch();
2382
+ const onMouseDown = (e) => {
2383
+ e.stopPropagation();
2384
+ setAnchor(getLoci(sequenceWrapper, readSeqIdx(e.target)));
2385
+ };
2386
+ const onMouseUp = (e) => {
2387
+ e.stopPropagation();
2388
+ const anchor = getAnchor();
2389
+ if (anchor === void 0) return;
2390
+ const loci = getLoci(sequenceWrapper, readSeqIdx(e.target));
2391
+ if (loci) {
2392
+ const dragged = !StructureElement.Loci.areEqual(anchor, loci);
2393
+ click(loci, getButtons(e.nativeEvent), getButton(e.nativeEvent), getModifiers(e.nativeEvent), dragged ? anchor : void 0);
2394
+ }
2395
+ clearAnchor();
2396
+ };
2397
+ return {
2398
+ onMouseDown,
2399
+ onMouseUp
2400
+ };
2401
+ }
2402
+ //#endregion
2403
+ //#region src/core/ProteinStructureViewer/components/SequenceView/hooks/useResiduePointer.ts
1663
2404
  /**
1664
- * Caption naming the chain a sequence grid belongs to. A structure with more
1665
- * than one chain renders one grid per chain, and without a caption between them
1666
- * the grids read as a single continuous protein.
2405
+ * Everything the grid does with a pointer: hover on move, and the drag-to-range
2406
+ * selection delegated to `useResidueSelection`.
2407
+ *
2408
+ * Hover reaches the 3D view through a throttled queue, so that sweeping the
2409
+ * pointer across the grid does not ask Mol* to re-mark on every residue
2410
+ * crossed. A drag is exempt: its hover carries the growing range, so it goes
2411
+ * out immediately to keep the preview under the pointer.
1667
2412
  */
1668
- const ChainLabel = styled("div")`
1669
- ${fontBodyXs}
1670
-
1671
- ${(props) => {
1672
- var _semanticColors$base12;
1673
- const spaces = getSpaces(props);
1674
- const semanticColors = getSemanticColors(props);
1675
- return `
1676
- margin-top: ${spaces === null || spaces === void 0 ? void 0 : spaces.xs}px;
1677
- margin-bottom: ${spaces === null || spaces === void 0 ? void 0 : spaces.xxxs}px;
1678
- color: ${semanticColors === null || semanticColors === void 0 || (_semanticColors$base12 = semanticColors.base) === null || _semanticColors$base12 === void 0 ? void 0 : _semanticColors$base12.textSecondary};
1679
- `;
1680
- }}
1681
- `;
2413
+ function useResiduePointer({ containerRef, sequenceWrapper }) {
2414
+ const { hover } = useLociDispatch();
2415
+ const { clear, hide, moveTo, tooltipRef } = useHoveredResidue({
2416
+ containerRef,
2417
+ sequenceWrapper
2418
+ });
2419
+ const { getAnchor } = useSequenceDrag();
2420
+ const { onMouseDown, onMouseUp } = useResidueSelection({ sequenceWrapper });
2421
+ const queueRef = useRef(null);
2422
+ if (queueRef.current === null) queueRef.current = new Subject();
2423
+ const queue = queueRef.current;
2424
+ useSubscribe(useMemo(() => queue.pipe(throttleTime(HOVER_THROTTLE_MS, void 0, {
2425
+ leading: true,
2426
+ trailing: true
2427
+ })), [queue]), (e) => {
2428
+ hover(getLoci(sequenceWrapper, e.seqIdx < 0 ? void 0 : e.seqIdx), e.buttons, e.button, e.modifiers, getAnchor());
2429
+ });
2430
+ const queueHover = (e, seqIdx) => queue.next({
2431
+ button: getButton(e.nativeEvent),
2432
+ buttons: getButtons(e.nativeEvent),
2433
+ modifiers: getModifiers(e.nativeEvent),
2434
+ seqIdx
2435
+ });
2436
+ /** Asks the 3D view to drop its hover, once, if the grid still holds one. */
2437
+ const releaseHover = (e) => {
2438
+ if (clear()) queueHover(e, -1);
2439
+ };
2440
+ const onContextMenu = (e) => {
2441
+ e.preventDefault();
2442
+ };
2443
+ const onMouseMove = (e) => {
2444
+ var _readSeqIdx;
2445
+ e.stopPropagation();
2446
+ const el = e.target;
2447
+ if (!el || !el.getAttribute) {
2448
+ releaseHover(e);
2449
+ return;
2450
+ }
2451
+ const seqIdx = (_readSeqIdx = readSeqIdx(el)) !== null && _readSeqIdx !== void 0 ? _readSeqIdx : -1;
2452
+ if (!moveTo(seqIdx, el)) return;
2453
+ const anchor = getAnchor();
2454
+ if (anchor === void 0) {
2455
+ queueHover(e, seqIdx);
2456
+ return;
2457
+ }
2458
+ hover(getLoci(sequenceWrapper, seqIdx), ButtonsType.Flag.None, ButtonsType.Flag.None, getModifiers(e.nativeEvent), anchor);
2459
+ };
2460
+ /**
2461
+ * Leaving one grid is not the end of a drag: on a complex the next chain's
2462
+ * grid is a separate element, so a drag from the target into the binder
2463
+ * leaves this one on its way. The anchor is therefore left alone here and
2464
+ * dropped by the release, or by the pointer leaving the panel altogether.
2465
+ */
2466
+ const onMouseLeave = (e) => {
2467
+ e.stopPropagation();
2468
+ hide();
2469
+ releaseHover(e);
2470
+ };
2471
+ return {
2472
+ handlers: {
2473
+ onContextMenu,
2474
+ onMouseDown,
2475
+ onMouseLeave,
2476
+ onMouseMove,
2477
+ onMouseUp
2478
+ },
2479
+ tooltipRef
2480
+ };
2481
+ }
1682
2482
  //#endregion
1683
2483
  //#region src/core/ProteinStructureViewer/components/SequenceView/components/ResidueTooltip/index.tsx
1684
2484
  var _getSpaces$m, _getSpaces;
@@ -1781,6 +2581,48 @@ const Sequence = ({ chainLabel, hideSequenceNumbers, residueColors, sequenceNumb
1781
2581
  }, handlers), {}, { children: groups })), /* @__PURE__ */ jsx(ResidueTooltip, { ref: tooltipRef })] });
1782
2582
  };
1783
2583
  //#endregion
2584
+ //#region src/core/ProteinStructureViewer/components/SequenceView/utils/copyText.ts
2585
+ /**
2586
+ * Writes `text` to the clipboard.
2587
+ *
2588
+ * The async Clipboard API is preferred, but nested srcdoc sandboxes — MCP
2589
+ * inspectors, Claude Desktop, and similar hosts — often omit `clipboard-write`
2590
+ * from their Permissions Policy, so `navigator.clipboard.writeText` rejects
2591
+ * even on a user click. `document.execCommand("copy")` only needs that click, so
2592
+ * it covers those hosts without the consumer having to patch the bundle.
2593
+ */
2594
+ function copyText(text) {
2595
+ var _navigator$clipboard;
2596
+ if (!((_navigator$clipboard = navigator.clipboard) === null || _navigator$clipboard === void 0 ? void 0 : _navigator$clipboard.writeText)) return copyWithSelection(text);
2597
+ return navigator.clipboard.writeText(text).catch(() => copyWithSelection(text));
2598
+ }
2599
+ /**
2600
+ * Copies by selecting a transient textarea. Must run under user activation,
2601
+ * which a click still has after the Clipboard API has been refused.
2602
+ */
2603
+ function copyWithSelection(text) {
2604
+ return new Promise((resolve, reject) => {
2605
+ const area = document.createElement("textarea");
2606
+ area.value = text;
2607
+ area.setAttribute("readonly", "");
2608
+ area.setAttribute("aria-hidden", "true");
2609
+ area.style.position = "fixed";
2610
+ area.style.top = "-1000px";
2611
+ document.body.appendChild(area);
2612
+ area.select();
2613
+ area.setSelectionRange(0, text.length);
2614
+ let copied = false;
2615
+ try {
2616
+ copied = document.execCommand("copy");
2617
+ } catch (_unused) {
2618
+ copied = false;
2619
+ }
2620
+ area.remove();
2621
+ if (copied) resolve();
2622
+ else reject(/* @__PURE__ */ new Error("The host blocked clipboard access."));
2623
+ });
2624
+ }
2625
+ //#endregion
1784
2626
  //#region src/core/ProteinStructureViewer/components/SequenceView/utils/sequenceText.ts
1785
2627
  /**
1786
2628
  * The structure's residues as text, one section per chain separated by
@@ -1809,12 +2651,11 @@ function useCopySequence(entries) {
1809
2651
  return {
1810
2652
  copied,
1811
2653
  copySequence: useCallback(() => {
1812
- const sequence = sequenceTextFromEntries(entries);
1813
- navigator.clipboard.writeText(sequence).then(() => {
2654
+ copyText(sequenceTextFromEntries(entries)).then(() => {
1814
2655
  setCopied(true);
1815
2656
  if (timeoutRef.current) clearTimeout(timeoutRef.current);
1816
2657
  timeoutRef.current = setTimeout(() => setCopied(false), COPIED_FEEDBACK_MS);
1817
- });
2658
+ }).catch(() => {});
1818
2659
  }, [entries])
1819
2660
  };
1820
2661
  }
@@ -1860,10 +2701,37 @@ function buildParams(state) {
1860
2701
  structure: ParamDefinition.Select(structureOptions.options[0][0], structureOptions.options, { shortLabel: true })
1861
2702
  };
1862
2703
  }
1863
- /** One entry per chain, in Mol*'s entity / chain / operator order. */
2704
+ /**
2705
+ * The file's own name for a chain group, or `""` when the structure holds no
2706
+ * unit for it. Mol* groups its sequence panel by `chainGroupId`, but every
2707
+ * chain-level prop on the viewer is keyed by `auth_asym_id`, so the two have
2708
+ * to be bridged through the structure itself.
2709
+ */
2710
+ function authAsymIdFor(structure, chainGroupId) {
2711
+ const location = StructureElement.Location.create(structure);
2712
+ for (const unit of structure.units) {
2713
+ if (unit.chainGroupId !== chainGroupId) continue;
2714
+ const element = unit.elements[0];
2715
+ if (element === void 0) continue;
2716
+ StructureElement.Location.set(location, structure, unit, element);
2717
+ return StructureProperties.chain.auth_asym_id(location);
2718
+ }
2719
+ return "";
2720
+ }
2721
+ /**
2722
+ * One entry per polymer chain, in Mol*'s entity / chain / operator order.
2723
+ *
2724
+ * Polymer entities only. The heteroatoms on a chain are separate entities, and
2725
+ * Mol* would hand back a caption and a grid for each - a myoglobin listing
2726
+ * "Chain A" and then two more headers, both reading "Chain A_1 [auth A]", one
2727
+ * holding the heme and one the hydroxide. They are drawn in the 3D view and
2728
+ * they are not sequence, and a caption per ligand offers a chain toggle for
2729
+ * something that is not a chain.
2730
+ */
1864
2731
  function buildEntries(structure, selection, _params) {
1865
2732
  const entries = [];
1866
- for (const [modelEntityId] of getModelEntityOptions(structure)) for (const [chainGroupId, cLabel] of getChainOptions(structure, modelEntityId)) for (const [operatorKey] of getOperatorOptions(structure, modelEntityId, chainGroupId)) entries.push({
2733
+ for (const [modelEntityId] of getModelEntityOptions(structure, true)) for (const [chainGroupId, cLabel] of getChainOptions(structure, modelEntityId)) for (const [operatorKey] of getOperatorOptions(structure, modelEntityId, chainGroupId)) entries.push({
2734
+ chainId: authAsymIdFor(structure, chainGroupId),
1867
2735
  id: `${modelEntityId}:${chainGroupId}:${operatorKey}`,
1868
2736
  label: cLabel,
1869
2737
  wrapper: getSequenceWrapper({
@@ -1928,74 +2796,147 @@ function useSequenceWrappers() {
1928
2796
  * for changes rather than read once. The theme is re-supplied through a local
1929
2797
  * `ThemeProvider` for the Emotion styles and the SDS `Tooltip` below.
1930
2798
  */
1931
- function createSequenceView(viewSettings) {
1932
- return function SequenceView() {
1933
- const { mode, theme } = useMolstarTheme(viewSettings);
1934
- const backgroundColor = useViewSetting(viewSettings, (s) => s.sequenceViewerBackgroundColor);
1935
- const { entries, isEmpty, residueCount } = useSequenceWrappers();
1936
- const { copied, copySequence } = useCopySequence(entries);
1937
- if (isEmpty) return /* @__PURE__ */ jsx(ThemeProvider, {
1938
- theme,
1939
- children: /* @__PURE__ */ jsx(EmptyState, {
1940
- backgroundColor,
1941
- className: "msp-sequence",
1942
- children: "No structure available"
1943
- })
1944
- });
1945
- const residueColors = residueColorsForMode(mode);
1946
- const showChainLabels = entries.length > 1;
1947
- return /* @__PURE__ */ jsx(ThemeProvider, {
1948
- theme,
1949
- children: /* @__PURE__ */ jsxs(SequencePanel, {
1950
- backgroundColor,
1951
- children: [/* @__PURE__ */ jsxs(PanelHeader, { children: [
1952
- /* @__PURE__ */ jsx(PanelTitle, { children: "Sequence" }),
1953
- /* @__PURE__ */ jsx(Tooltip, {
1954
- arrow: true,
1955
- placement: "top",
1956
- title: copied ? "Copied!" : "Copy",
1957
- children: /* @__PURE__ */ jsx(CopyButton, {
1958
- "aria-label": "Copy sequence to clipboard",
1959
- onClick: copySequence,
1960
- type: "button",
1961
- children: copied ? /* @__PURE__ */ jsx(CheckIcon, { size: 16 }) : /* @__PURE__ */ jsx(CopyIcon, { size: 16 })
1962
- })
1963
- }),
1964
- /* @__PURE__ */ jsxs(ResidueCount, { children: [/* @__PURE__ */ jsx(ResidueCountLabel, { children: "Residues" }), /* @__PURE__ */ jsx(ResidueCountValue, { children: residueCount })] })
1965
- ] }), /* @__PURE__ */ jsx(SequenceScroller, {
1966
- backgroundColor,
1967
- children: /* @__PURE__ */ jsx(SequenceScrollArea, {
1968
- className: "msp-sequence msp-sequence-wrapper-non-empty",
1969
- children: entries.map((s) => /* @__PURE__ */ jsxs(Fragment, { children: [showChainLabels && /* @__PURE__ */ jsx(ChainLabel, { children: s.label }), typeof s.wrapper === "string" ? /* @__PURE__ */ jsx("div", {
1970
- className: "msp-sequence-wrapper",
1971
- children: s.wrapper
1972
- }) : /* @__PURE__ */ jsx(Sequence, {
1973
- chainLabel: showChainLabels ? s.label : void 0,
1974
- residueColors,
1975
- sequenceWrapper: s.wrapper
1976
- })] }, s.id))
1977
- })
1978
- })]
1979
- })
1980
- });
1981
- };
2799
+ function createSequenceView(viewSettings) {
2800
+ return function SequenceView() {
2801
+ const { mode, theme } = useMolstarTheme(viewSettings);
2802
+ const backgroundColor = useViewSetting(viewSettings, (s) => s.sequenceViewerBackgroundColor);
2803
+ const hiddenChains = useViewSetting(viewSettings, (s) => s.hiddenChains);
2804
+ const onChainHover = useViewSetting(viewSettings, (s) => s.onChainHover);
2805
+ const onChainSelect = useViewSetting(viewSettings, (s) => s.onChainSelect);
2806
+ const onChainToggle = useViewSetting(viewSettings, (s) => s.onChainToggle);
2807
+ const selectedChains = useViewSetting(viewSettings, (s) => s.selectedChains);
2808
+ const { entries, isEmpty, residueCount } = useSequenceWrappers();
2809
+ const { copied, copySequence } = useCopySequence(entries);
2810
+ const drag = useCreateSequenceDrag();
2811
+ if (isEmpty) return /* @__PURE__ */ jsx(ThemeProvider, {
2812
+ theme,
2813
+ children: /* @__PURE__ */ jsx(EmptyState, {
2814
+ backgroundColor,
2815
+ className: "msp-sequence",
2816
+ children: "No structure available"
2817
+ })
2818
+ });
2819
+ const residueColors = residueColorsForMode(mode);
2820
+ const showChainLabels = entries.length > 1;
2821
+ return /* @__PURE__ */ jsx(ThemeProvider, {
2822
+ theme,
2823
+ children: /* @__PURE__ */ jsxs(SequencePanel, {
2824
+ backgroundColor,
2825
+ children: [/* @__PURE__ */ jsxs(PanelHeader, { children: [
2826
+ /* @__PURE__ */ jsx(PanelTitle, { children: "Sequence" }),
2827
+ /* @__PURE__ */ jsx(Tooltip, {
2828
+ arrow: true,
2829
+ placement: "top",
2830
+ title: copied ? "Copied!" : "Copy",
2831
+ children: /* @__PURE__ */ jsx(CopyButton, {
2832
+ "aria-label": "Copy sequence to clipboard",
2833
+ onClick: copySequence,
2834
+ type: "button",
2835
+ children: copied ? /* @__PURE__ */ jsx(CheckIcon, { size: 16 }) : /* @__PURE__ */ jsx(CopyIcon, { size: 16 })
2836
+ })
2837
+ }),
2838
+ /* @__PURE__ */ jsxs(ResidueCount, { children: [/* @__PURE__ */ jsx(ResidueCountLabel, { children: "Residues" }), /* @__PURE__ */ jsx(ResidueCountValue, { children: residueCount })] })
2839
+ ] }), /* @__PURE__ */ jsx(SequenceScroller, {
2840
+ backgroundColor,
2841
+ children: /* @__PURE__ */ jsx(SequenceDragProvider, {
2842
+ value: drag,
2843
+ children: /* @__PURE__ */ jsx(SequenceScrollArea, {
2844
+ className: "msp-sequence msp-sequence-wrapper-non-empty",
2845
+ onMouseLeave: drag.clearAnchor,
2846
+ children: entries.map((s) => {
2847
+ var _hiddenChains$has, _selectedChains$has;
2848
+ const hidden = (_hiddenChains$has = hiddenChains === null || hiddenChains === void 0 ? void 0 : hiddenChains.has(s.chainId)) !== null && _hiddenChains$has !== void 0 ? _hiddenChains$has : false;
2849
+ const grid = typeof s.wrapper === "string" ? /* @__PURE__ */ jsx("div", {
2850
+ className: "msp-sequence-wrapper",
2851
+ children: s.wrapper
2852
+ }) : /* @__PURE__ */ jsx(Sequence, {
2853
+ chainLabel: showChainLabels ? s.label : void 0,
2854
+ residueColors,
2855
+ sequenceWrapper: s.wrapper
2856
+ });
2857
+ return /* @__PURE__ */ jsxs(Fragment, { children: [showChainLabels && /* @__PURE__ */ jsx(ChainHeader, {
2858
+ chainId: s.chainId,
2859
+ isHidden: hidden,
2860
+ isSelected: (_selectedChains$has = selectedChains === null || selectedChains === void 0 ? void 0 : selectedChains.has(s.chainId)) !== null && _selectedChains$has !== void 0 ? _selectedChains$has : false,
2861
+ label: s.label,
2862
+ onHover: onChainHover,
2863
+ onSelect: onChainSelect,
2864
+ onToggle: onChainToggle
2865
+ }), hidden ? /* @__PURE__ */ jsx(HiddenChainGroup, { children: grid }) : grid] }, s.id);
2866
+ })
2867
+ })
2868
+ })
2869
+ })]
2870
+ })
2871
+ });
2872
+ };
2873
+ }
2874
+ //#endregion
2875
+ //#region src/core/ProteinStructureViewer/utils/axes.ts
2876
+ /**
2877
+ * The orientation axes widget, as Mol* parameterizes it.
2878
+ *
2879
+ * Shared rather than declared where it is used, because two different parts of
2880
+ * Mol* take the same shape: the live canvas takes it as
2881
+ * `camera.helper.axes`, and the screenshot helper takes it as its own `axes`,
2882
+ * so an image can carry the widget or leave it out independently of the view
2883
+ * it was captured from.
2884
+ */
2885
+ const AXES_ON = {
2886
+ name: "on",
2887
+ params: {
2888
+ alpha: .51,
2889
+ colorX: 16711680,
2890
+ colorY: 32768,
2891
+ colorZ: 255,
2892
+ labelColorX: 8421504,
2893
+ labelColorY: 8421504,
2894
+ labelColorZ: 8421504,
2895
+ labelOpacity: 1,
2896
+ labelScale: .25,
2897
+ labelX: "X",
2898
+ labelY: "Y",
2899
+ labelZ: "Z",
2900
+ location: "bottom-left",
2901
+ locationOffsetX: 0,
2902
+ locationOffsetY: 0,
2903
+ originColor: 8421504,
2904
+ planeColorXY: 8421504,
2905
+ planeColorXZ: 8421504,
2906
+ planeColorYZ: 8421504,
2907
+ radiusScale: .075,
2908
+ scale: .15,
2909
+ showLabels: false,
2910
+ showPlanes: true
2911
+ }
2912
+ };
2913
+ const AXES_OFF = {
2914
+ name: "off",
2915
+ params: {}
2916
+ };
2917
+ //#endregion
2918
+ //#region src/core/ProteinStructureViewer/utils/color.ts
2919
+ /** Parses `#RRGGBB` (or `RRGGBB`) into a Mol* `Color`. */
2920
+ function parseHexColor(hex) {
2921
+ const h = hex.replace("#", "");
2922
+ return Color.fromRgb(parseInt(h.substring(0, 2), 16), parseInt(h.substring(2, 4), 16), parseInt(h.substring(4, 6), 16));
2923
+ }
2924
+ /**
2925
+ * Parses a theme color into a Mol* `Color`, falling back when the token is
2926
+ * missing or is a format Mol* cannot consume (theme values are hex in practice,
2927
+ * but the palette types allow any CSS color string).
2928
+ */
2929
+ function themeColor(value, fallback) {
2930
+ const candidate = value === null || value === void 0 ? void 0 : value.trim();
2931
+ return parseHexColor(candidate !== void 0 && /^#?[0-9a-f]{6}$/i.test(candidate) ? candidate : fallback);
1982
2932
  }
1983
- //#endregion
1984
- //#region src/core/ProteinStructureViewer/components/Viewport/constants.ts
1985
2933
  /**
1986
- * Fraction of the bounding sphere radius within which the camera still counts
1987
- * as sitting at the default view, absorbing easing and float error.
2934
+ * Neutral fill for residues with no overlay value. Sits between the light and
2935
+ * dark backgrounds so unhighlighted geometry stays legible in both modes.
1988
2936
  */
1989
- const DEFAULT_VIEW_TOLERANCE = .02;
1990
- //#endregion
1991
- //#region src/core/ProteinStructureViewer/components/Viewport/utils/camera.ts
1992
- /** True when the camera's zoom/center differs from the default fit. */
1993
- function isZoomedOffDefault(canvas3d) {
1994
- const { center, radius } = canvas3d.boundingSphereVisible;
1995
- if (radius <= 0) return false;
1996
- const { state } = canvas3d.camera;
1997
- const tolerance = radius * DEFAULT_VIEW_TOLERANCE;
1998
- return Math.abs(state.radius - radius) > tolerance || Vec3.distance(state.target, center) > tolerance;
2937
+ function neutralResidueColor(mode) {
2938
+ const g = mode === "dark" ? 100 : 180;
2939
+ return Color.fromRgb(g, g, g);
1999
2940
  }
2000
2941
  //#endregion
2001
2942
  //#region \0@oxc-project+runtime@0.134.0/helpers/esm/asyncToGenerator.js
@@ -2024,6 +2965,88 @@ function _asyncToGenerator(n) {
2024
2965
  };
2025
2966
  }
2026
2967
  //#endregion
2968
+ //#region src/core/ProteinStructureViewer/utils/screenshot.ts
2969
+ /**
2970
+ * Each resolution as the Mol* preset that produces it: 1280x720, 1920x1080,
2971
+ * 3840x2160, 7680x4320. Named here rather than given as dimensions so the
2972
+ * aspect stays Mol*'s to choose.
2973
+ *
2974
+ * Mol*'s own `viewport` preset - the canvas at its current size - is left out
2975
+ * deliberately: it makes the image depend on how big the viewer happens to be
2976
+ * on screen, which is not something a caller asking for "high" is asking for.
2977
+ */
2978
+ const RESOLUTION_PRESETS = {
2979
+ high: "ultra-hd",
2980
+ low: "hd",
2981
+ maximum: "8k-ultra-hd",
2982
+ medium: "full-hd"
2983
+ };
2984
+ /** Painted behind the structure when a background is asked for but unreadable. */
2985
+ const FALLBACK_BACKGROUND = "#ffffff";
2986
+ /**
2987
+ * Downloads the structure as a PNG.
2988
+ *
2989
+ * Mol*'s screenshot helper renders its own pass rather than reading the canvas
2990
+ * back, so the image is drawn fresh at the requested size and carries its own
2991
+ * transparency and axes settings - none of which disturb the view on screen.
2992
+ *
2993
+ * A named background is the one exception. The helper only chooses between
2994
+ * transparent and whatever the renderer is already painting, so a color has to
2995
+ * be put on the canvas for the length of the capture and taken off afterwards.
2996
+ * `finally` is what guarantees the second half: leaving a figure's background
2997
+ * behind on the viewer would be a worse bug than a failed download.
2998
+ */
2999
+ function downloadStructureImage(_x) {
3000
+ return _downloadStructureImage.apply(this, arguments);
3001
+ }
3002
+ function _downloadStructureImage() {
3003
+ _downloadStructureImage = _asyncToGenerator(function* (plugin, options = {}) {
3004
+ const helper = plugin.helpers.viewportScreenshot;
3005
+ if (!helper) return;
3006
+ const { backgroundColor, filename, resolution = "medium", showAxes } = options;
3007
+ const transparent = backgroundColor === void 0;
3008
+ helper.behaviors.values.next(_objectSpread2(_objectSpread2({}, helper.behaviors.values.value), {}, {
3009
+ axes: showAxes ? AXES_ON : AXES_OFF,
3010
+ format: {
3011
+ name: "png",
3012
+ params: {}
3013
+ },
3014
+ resolution: {
3015
+ name: RESOLUTION_PRESETS[resolution],
3016
+ params: {}
3017
+ },
3018
+ transparent
3019
+ }));
3020
+ const canvas3d = plugin.canvas3d;
3021
+ const painted = canvas3d === null || canvas3d === void 0 ? void 0 : canvas3d.props.renderer.backgroundColor;
3022
+ const overrideBackground = !transparent && canvas3d !== void 0;
3023
+ if (overrideBackground) canvas3d === null || canvas3d === void 0 || canvas3d.setProps({ renderer: { backgroundColor: themeColor(backgroundColor, FALLBACK_BACKGROUND) } });
3024
+ try {
3025
+ yield helper.download(filename);
3026
+ } finally {
3027
+ if (overrideBackground && painted !== void 0) canvas3d === null || canvas3d === void 0 || canvas3d.setProps({ renderer: { backgroundColor: painted } });
3028
+ }
3029
+ });
3030
+ return _downloadStructureImage.apply(this, arguments);
3031
+ }
3032
+ //#endregion
3033
+ //#region src/core/ProteinStructureViewer/components/Viewport/constants.ts
3034
+ /**
3035
+ * Fraction of the bounding sphere radius within which the camera still counts
3036
+ * as sitting at the default view, absorbing easing and float error.
3037
+ */
3038
+ const DEFAULT_VIEW_TOLERANCE = .02;
3039
+ //#endregion
3040
+ //#region src/core/ProteinStructureViewer/components/Viewport/utils/camera.ts
3041
+ /** True when the camera's zoom/center differs from the default fit. */
3042
+ function isZoomedOffDefault(canvas3d) {
3043
+ const { center, radius } = canvas3d.boundingSphereVisible;
3044
+ if (radius <= 0) return false;
3045
+ const { state } = canvas3d.camera;
3046
+ const tolerance = radius * DEFAULT_VIEW_TOLERANCE;
3047
+ return Math.abs(state.radius - radius) > tolerance || Vec3.distance(state.target, center) > tolerance;
3048
+ }
3049
+ //#endregion
2027
3050
  //#region src/core/ProteinStructureViewer/components/Viewport/hooks/useCameraReset.ts
2028
3051
  /**
2029
3052
  * Tracks whether the camera has moved off the default view, and puts it back.
@@ -2124,16 +3147,24 @@ function useCameraReset() {
2124
3147
  /**
2125
3148
  * Pinned to the lower-left of the viewport, clear of the sequence panel below
2126
3149
  * and the axes widget in the corner.
3150
+ *
3151
+ * Anchored by its bottom edge, so the buttons stack upwards: the last one
3152
+ * stays put where a reader last saw it, and an earlier one appearing or going
3153
+ * does not move the rest. Reset comes first in the markup and so sits above
3154
+ * capture.
2127
3155
  */
2128
- const ResetCameraSlot = styled("div")`
3156
+ const ViewportButtonStack = styled("div")`
2129
3157
  position: absolute;
2130
3158
  bottom: 48px;
2131
3159
  z-index: 10;
3160
+ display: flex;
3161
+ flex-direction: column;
2132
3162
 
2133
3163
  ${(props) => {
2134
3164
  const spaces = getSpaces(props);
2135
3165
  return `
2136
3166
  left: ${spaces === null || spaces === void 0 ? void 0 : spaces.m}px;
3167
+ gap: ${spaces === null || spaces === void 0 ? void 0 : spaces.xxs}px;
2137
3168
  `;
2138
3169
  }}
2139
3170
  `;
@@ -2151,8 +3182,30 @@ const ResetCameraSlot = styled("div")`
2151
3182
  */
2152
3183
  function createViewportView(viewSettings) {
2153
3184
  return function ViewportWithReset() {
3185
+ const plugin = usePlugin();
2154
3186
  const { theme } = useMolstarTheme(viewSettings);
2155
3187
  const { cameraChanged, resetCamera } = useCameraReset();
3188
+ const showAxes = useViewSetting(viewSettings, (s) => {
3189
+ var _s$showAxes;
3190
+ return (_s$showAxes = s.showAxes) !== null && _s$showAxes !== void 0 ? _s$showAxes : true;
3191
+ });
3192
+ const download = useViewSetting(viewSettings, (s) => s.download);
3193
+ /**
3194
+ * Disabled while a capture is in flight. A high resolution takes long
3195
+ * enough to click twice through, and each click renders its own pass.
3196
+ */
3197
+ const [downloading, setDownloading] = useState(false);
3198
+ const captureImage = useCallback(_asyncToGenerator(function* () {
3199
+ if (!download) return;
3200
+ setDownloading(true);
3201
+ try {
3202
+ yield downloadStructureImage(plugin, download);
3203
+ } catch (error) {
3204
+ console.error("Failed to download the structure image:", error);
3205
+ } finally {
3206
+ setDownloading(false);
3207
+ }
3208
+ }), [download, plugin]);
2156
3209
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [
2157
3210
  /* @__PURE__ */ jsx(Viewport, {}),
2158
3211
  /* @__PURE__ */ jsx("div", {
@@ -2161,18 +3214,37 @@ function createViewportView(viewSettings) {
2161
3214
  }),
2162
3215
  /* @__PURE__ */ jsx(ThemeProvider, {
2163
3216
  theme,
2164
- children: /* @__PURE__ */ jsx(ResetCameraSlot, {
2165
- hidden: !cameraChanged,
2166
- children: /* @__PURE__ */ jsx(Button, {
2167
- "aria-label": "Reset camera",
2168
- onClick: resetCamera,
3217
+ children: /* @__PURE__ */ jsxs(ViewportButtonStack, { children: [/* @__PURE__ */ jsx("div", {
3218
+ hidden: !showAxes || !cameraChanged,
3219
+ children: /* @__PURE__ */ jsx(Tooltip, {
3220
+ arrow: true,
3221
+ placement: "right",
2169
3222
  title: "Reset camera",
3223
+ textAlign: "left",
3224
+ children: /* @__PURE__ */ jsx(Button, {
3225
+ "aria-label": "Reset camera",
3226
+ onClick: resetCamera,
3227
+ sdsStyle: "minimal",
3228
+ sdsType: "secondary",
3229
+ size: "medium",
3230
+ children: /* @__PURE__ */ jsx(ArrowCounterClockwiseIcon, {})
3231
+ })
3232
+ })
3233
+ }), download && /* @__PURE__ */ jsx(Tooltip, {
3234
+ arrow: true,
3235
+ placement: "right",
3236
+ title: "Download image",
3237
+ textAlign: "left",
3238
+ children: /* @__PURE__ */ jsx(Button, {
3239
+ "aria-label": "Download image of the structure",
3240
+ disabled: downloading,
3241
+ onClick: captureImage,
2170
3242
  sdsStyle: "minimal",
2171
3243
  sdsType: "secondary",
2172
3244
  size: "medium",
2173
- children: /* @__PURE__ */ jsx(ArrowCounterClockwiseIcon, {})
3245
+ children: /* @__PURE__ */ jsx(ApertureIcon, {})
2174
3246
  })
2175
- })
3247
+ })] })
2176
3248
  })
2177
3249
  ] });
2178
3250
  };
@@ -2252,37 +3324,274 @@ function syncClipToZoom(plugin, getClipRatio) {
2252
3324
  }
2253
3325
  }
2254
3326
  //#endregion
2255
- //#region src/core/ProteinStructureViewer/utils/residueRef.ts
3327
+ //#region src/core/ProteinStructureViewer/utils/chains.ts
2256
3328
  /**
2257
- * Reads the residue a loci points at, or null when it points at nothing the
2258
- * viewer can address.
3329
+ * The polymer of one chain, as named in the file. What each chain's cartoon is
3330
+ * built from, and so what makes a chain a thing Mol* can hide on its own.
2259
3331
  *
2260
- * `index` comes from Mol*'s residue key, which counts residues in file order
2261
- * from zero and is the same counter `injectPlddtIntoPdb` walks. Deriving it
2262
- * from the residue's *number* instead would tie it to how the file happens to
2263
- * be numbered: a crop beginning at residue 200 would index `plddt` at 199, and
2264
- * a file numbering each chain from 1 would give two residues the same index.
3332
+ * Paired with `chainLigandExpression`: between them they cover everything on a
3333
+ * chain the viewer draws, split by what it draws it as. The polymer
3334
+ * restriction is the one Mol*'s `polymer-cartoon` preset applied, and is what
3335
+ * keeps a heme from being handed to a representation that can only trace a
3336
+ * backbone.
2265
3337
  */
2266
- function residueRefFromLoci(loci) {
2267
- var _StructureProperties$;
2268
- const location = StructureElement.Location.create(void 0);
2269
- const firstLoc = StructureElement.Loci.getFirstLocation(loci, location);
2270
- if (!firstLoc) return null;
3338
+ function chainPolymerExpression(chainId) {
3339
+ return MolScriptBuilder.struct.generator.atomGroups({
3340
+ "chain-test": MolScriptBuilder.core.rel.eq([MolScriptBuilder.ammp("auth_asym_id"), chainId]),
3341
+ "entity-test": MolScriptBuilder.core.rel.eq([MolScriptBuilder.ammp("entityType"), "polymer"])
3342
+ });
3343
+ }
3344
+ /**
3345
+ * Bonds worth following out of a ligand: the ones holding it to the protein.
3346
+ *
3347
+ * Mol*'s own pair, and metallic coordination is the half that matters most -
3348
+ * a heme's iron is held to its histidine by a coordination bond and not a
3349
+ * covalent one, so covalent alone would leave every metalloprotein's cofactor
3350
+ * looking dropped in rather than bound.
3351
+ */
3352
+ const CONNECTING_BOND = MolScriptBuilder.core.flags.hasAny([MolScriptBuilder.struct.bondProperty.flags(), MolScriptBuilder.core.type.bitflags([BondType.fromNames(["covalent", "metal-coordination"])])]);
3353
+ /**
3354
+ * The heteroatoms of one chain - ligands, ions, glycans, lipids - along with
3355
+ * the residues holding them, which is what its ball-and-stick is built from.
3356
+ *
3357
+ * `non-polymer` is the one entity type a PDB's HETATM records land under,
3358
+ * ligands and ions alike, so there is nothing finer to test here: the
3359
+ * distinction Mol*'s presets draw between their `ligand`, `ion` and `lipid`
3360
+ * components is a residue-name lookup made to give each a slightly different
3361
+ * style, and all three are sticks. `branched` is the separate type glycans
3362
+ * arrive as.
3363
+ *
3364
+ * Water is the third type and is left out of the seed. Every solvent molecule
3365
+ * drawn as sticks buries the structure they surround, which is why Mol* draws
3366
+ * them at 60% alpha rather than plain, and none of them is what "show the
3367
+ * ligands" asks for.
3368
+ *
3369
+ * One layer of bonds out from there, taken as whole residues, is what brings
3370
+ * the binding residue along - myoglobin's His93, say. Without it the heme
3371
+ * floats unattached in the middle of a cartoon it is visibly bonded to, and
3372
+ * the bond drawn to its iron ends in mid-air. One layer and no more: the
3373
+ * residue that holds the ligand, not that residue's own neighbours.
3374
+ *
3375
+ * Intersected back with the chain, so everything a chain is drawn with belongs
3376
+ * to that chain. A ligand bonded across an interface would otherwise leave the
3377
+ * partner chain's residue in this chain's component, somewhere hiding the
3378
+ * partner chain could not reach.
3379
+ */
3380
+ function chainLigandExpression(chainId) {
3381
+ const onThisChain = MolScriptBuilder.core.rel.eq([MolScriptBuilder.ammp("auth_asym_id"), chainId]);
3382
+ return MolScriptBuilder.struct.modifier.union([MolScriptBuilder.struct.modifier.intersectBy({
3383
+ 0: MolScriptBuilder.struct.modifier.includeConnected({
3384
+ 0: MolScriptBuilder.struct.generator.atomGroups({
3385
+ "chain-test": onThisChain,
3386
+ "entity-test": MolScriptBuilder.core.set.has([MolScriptBuilder.set("non-polymer", "branched"), MolScriptBuilder.ammp("entityType")])
3387
+ }),
3388
+ "as-whole-residues": true,
3389
+ "bond-test": CONNECTING_BOND,
3390
+ "layer-count": 1
3391
+ }),
3392
+ by: MolScriptBuilder.struct.generator.atomGroups({ "chain-test": onThisChain })
3393
+ })]);
3394
+ }
3395
+ /**
3396
+ * Matches how the sequence panel captions its chains, which reaches the same
3397
+ * label through Mol*'s own `getChainOptions`. Deriving it the same way is what
3398
+ * keeps a chain from going by one name in the legend and another in the panel.
3399
+ */
3400
+ const CHAIN_LABEL_OPTIONS = {
3401
+ granularity: "chain",
3402
+ hidePrefix: true,
3403
+ htmlStyling: false
3404
+ };
3405
+ /**
3406
+ * Reads the chains of a structure and the polymer residues on each.
3407
+ *
3408
+ * Residue indices are Mol*'s residue keys, counting residues in file order
3409
+ * across the whole structure, so what is reported here addresses the same
3410
+ * residues as `plddt`, `residueOverlay` and `selection`.
3411
+ *
3412
+ * Heteroatoms are drawn (see `chainLigandExpression`) but are not counted
3413
+ * here. A heme is a residue to the file and not to a reader: it is no part of
3414
+ * the sequence the panel lists, and averaging a confidence score over it would
3415
+ * divide by a residue that has none.
3416
+ *
3417
+ * Residues are gathered into a set rather than inferred from the ends: a chain
3418
+ * carrying several symmetry operators repeats each of its residues once per
3419
+ * operator, and a structure whose chains interleave leaves a chain's indices
3420
+ * non-contiguous. Neither is what a predicted complex looks like, but a range
3421
+ * would misread both.
3422
+ */
3423
+ function scanChains(structure) {
3424
+ const accumulators = /* @__PURE__ */ new Map();
3425
+ const location = StructureElement.Location.create(structure);
3426
+ for (const unit of structure.units) {
3427
+ location.unit = unit;
3428
+ for (let i = 0; i < unit.elements.length; i++) {
3429
+ location.element = unit.elements[i];
3430
+ const chainId = StructureProperties.chain.auth_asym_id(location);
3431
+ let chain = accumulators.get(chainId);
3432
+ if (!chain) {
3433
+ chain = {
3434
+ chainId,
3435
+ label: elementLabel(location, CHAIN_LABEL_OPTIONS),
3436
+ residues: /* @__PURE__ */ new Set()
3437
+ };
3438
+ accumulators.set(chainId, chain);
3439
+ }
3440
+ if (StructureProperties.entity.type(location) !== "polymer") continue;
3441
+ chain.residues.add(StructureProperties.residue.key(location));
3442
+ }
3443
+ }
3444
+ const ordered = [...accumulators.values()].filter((chain) => chain.residues.size > 0).map((chain) => _objectSpread2(_objectSpread2({}, chain), {}, { residues: [...chain.residues].sort((a, b) => a - b) })).sort((a, b) => {
3445
+ var _a$residues$, _b$residues$;
3446
+ return ((_a$residues$ = a.residues[0]) !== null && _a$residues$ !== void 0 ? _a$residues$ : 0) - ((_b$residues$ = b.residues[0]) !== null && _b$residues$ !== void 0 ? _b$residues$ : 0);
3447
+ });
2271
3448
  return {
2272
- chainId: StructureProperties.chain.auth_asym_id(firstLoc),
2273
- compId: StructureProperties.atom.label_comp_id(firstLoc),
2274
- insCode: (_StructureProperties$ = StructureProperties.residue.pdbx_PDB_ins_code(firstLoc)) !== null && _StructureProperties$ !== void 0 ? _StructureProperties$ : "",
2275
- index: StructureProperties.residue.key(firstLoc),
2276
- seqId: StructureProperties.residue.auth_seq_id(firstLoc)
3449
+ chainLabels: new Map([...accumulators.values()].map(({ chainId, label }) => [chainId, label])),
3450
+ chains: ordered.map(({ chainId, label, residues }) => {
3451
+ var _residues, _residues$;
3452
+ return {
3453
+ chainId,
3454
+ endIndex: (_residues = residues[residues.length - 1]) !== null && _residues !== void 0 ? _residues : 0,
3455
+ label,
3456
+ residueCount: residues.length,
3457
+ startIndex: (_residues$ = residues[0]) !== null && _residues$ !== void 0 ? _residues$ : 0
3458
+ };
3459
+ }),
3460
+ residuesByChain: new Map(ordered.map(({ chainId, residues }) => [chainId, residues]))
2277
3461
  };
2278
3462
  }
3463
+ /** True when the two lists name the same chains, in the same order. */
3464
+ function chainsEqual(a, b) {
3465
+ return a.length === b.length && a.every((chain, i) => {
3466
+ const other = b[i];
3467
+ return other !== void 0 && chain.chainId === other.chainId && chain.startIndex === other.startIndex && chain.endIndex === other.endIndex && chain.residueCount === other.residueCount;
3468
+ });
3469
+ }
3470
+ //#endregion
3471
+ //#region src/core/ProteinStructureViewer/utils/molstarSpec.ts
2279
3472
  /**
2280
- * How a residue is named in the readout, e.g. `"LYS 111"`. Takes the number and
2281
- * the insertion code from the file so that the readout and the sequence panel
2282
- * beneath it agree on what to call a residue.
3473
+ * The spec's list-valued keys. These are appended rather than merged: Mol*
3474
+ * reads them as collections, so a consumer's entries join the viewer's instead
3475
+ * of replacing them.
3476
+ *
3477
+ * `config` is the one where order carries meaning - Mol* folds the list into a
3478
+ * Map in order, so a later entry overwrites an earlier one, and appending is
3479
+ * what lets a consumer override a setting the viewer chose.
2283
3480
  */
2284
- function residueLabel(residue) {
2285
- return `${residue.compId} ${residue.seqId}${residue.insCode}`;
3481
+ const LIST_KEYS = [
3482
+ "actions",
3483
+ "animations",
3484
+ "behaviors",
3485
+ "config",
3486
+ "customFormats",
3487
+ "customParamEditors"
3488
+ ];
3489
+ /**
3490
+ * Whether a value should be merged into rather than replaced.
3491
+ *
3492
+ * Only object literals qualify. Arrays are collections, and the spec is full
3493
+ * of React components and Mol* class instances, all of which are values in
3494
+ * their own right - merging into one would take it apart.
3495
+ */
3496
+ function isPlainObject(value) {
3497
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
3498
+ const proto = Object.getPrototypeOf(value);
3499
+ return proto === Object.prototype || proto === null;
3500
+ }
3501
+ function deepMerge(base, override) {
3502
+ if (!isPlainObject(base) || !isPlainObject(override)) return override;
3503
+ const merged = _objectSpread2({}, base);
3504
+ for (const [key, value] of Object.entries(override)) merged[key] = key in base ? deepMerge(base[key], value) : value;
3505
+ return merged;
3506
+ }
3507
+ function withoutLists(spec) {
3508
+ const rest = _objectSpread2({}, spec);
3509
+ for (const key of LIST_KEYS) delete rest[key];
3510
+ return rest;
3511
+ }
3512
+ /**
3513
+ * Lays a consumer's Mol* spec over the viewer's own.
3514
+ *
3515
+ * The viewer builds a spec of its own - the sequence panel and viewport it
3516
+ * renders, the controls it hides, the behaviors it drops - and this is how a
3517
+ * consumer reaches the rest of Mol* without the viewer having to grow a prop
3518
+ * per setting. Anything the consumer names wins, so `canvas3d.renderer` or a
3519
+ * `PluginConfig` item can be set to something other than the default the
3520
+ * viewer picked.
3521
+ *
3522
+ * The one thing it cannot do is take something away: `behaviors` is appended
3523
+ * to, so a behavior the viewer removed - Mol*'s click-to-zoom camera - stays
3524
+ * removed, and one the viewer kept cannot be dropped.
3525
+ */
3526
+ function mergeMolstarSpec(base, override) {
3527
+ if (!override) return base;
3528
+ const merged = deepMerge(withoutLists(base), withoutLists(override));
3529
+ for (const key of LIST_KEYS) {
3530
+ var _base, _override;
3531
+ const ours = (_base = base[key]) !== null && _base !== void 0 ? _base : [];
3532
+ const theirs = (_override = override[key]) !== null && _override !== void 0 ? _override : [];
3533
+ if (ours.length > 0 || theirs.length > 0) merged[key] = [...ours, ...theirs];
3534
+ }
3535
+ return merged;
3536
+ }
3537
+ //#endregion
3538
+ //#region src/core/ProteinStructureViewer/utils/chainColorTheme.ts
3539
+ /** Mol* color theme name under which per-chain coloring is registered. */
3540
+ const CHAIN_COLOR_THEME_NAME = "chain-color";
3541
+ /** Painted for a chain with no color assigned, or one assigned a bad value. */
3542
+ const DEFAULT_CHAIN_COLOR_HEX = "#808080";
3543
+ const DEFAULT_CHAIN_COLOR = Color.fromRgb(128, 128, 128);
3544
+ /**
3545
+ * Builds a Mol* color theme that paints each chain its own color.
3546
+ *
3547
+ * This stands in for Mol*'s built-in `chain-id` theme, which picks its own
3548
+ * colors from a palette we cannot read back. Owning the assignment is what
3549
+ * lets the chain legend show a swatch that matches the structure, rather than
3550
+ * a key for colors it has to guess at.
3551
+ *
3552
+ * Created per plugin instance, and its state read live inside `color()`, for
3553
+ * the same reasons as the residue value theme beside it: several viewers can
3554
+ * color chains differently on one page, and recoloring does not rebuild the
3555
+ * structure.
3556
+ */
3557
+ function createChainColorTheme() {
3558
+ const state = {
3559
+ colors: /* @__PURE__ */ new Map(),
3560
+ version: 0
3561
+ };
3562
+ const provider = {
3563
+ category: "chain property",
3564
+ defaultValues: {},
3565
+ factory() {
3566
+ const { colors } = state;
3567
+ const parsed = /* @__PURE__ */ new Map();
3568
+ for (const [chainId, hex] of colors) parsed.set(chainId, themeColor(hex, DEFAULT_CHAIN_COLOR_HEX));
3569
+ return {
3570
+ color(location) {
3571
+ var _parsed$get;
3572
+ if (!StructureElement.Location.is(location)) return DEFAULT_CHAIN_COLOR;
3573
+ const chainId = StructureProperties.chain.auth_asym_id(location);
3574
+ return (_parsed$get = parsed.get(chainId)) !== null && _parsed$get !== void 0 ? _parsed$get : DEFAULT_CHAIN_COLOR;
3575
+ },
3576
+ contextHash: state.version,
3577
+ description: "Color residues by the chain they sit on",
3578
+ factory: provider.factory,
3579
+ granularity: "group",
3580
+ props: {}
3581
+ };
3582
+ },
3583
+ getParams: () => ({}),
3584
+ isApplicable: () => true,
3585
+ label: "Chain Color",
3586
+ name: CHAIN_COLOR_THEME_NAME
3587
+ };
3588
+ return {
3589
+ provider,
3590
+ setState(colors) {
3591
+ state.colors = colors;
3592
+ state.version++;
3593
+ }
3594
+ };
2286
3595
  }
2287
3596
  //#endregion
2288
3597
  //#region src/core/ProteinStructureViewer/utils/plddt.ts
@@ -2318,6 +3627,14 @@ const PLDDT_COLOR_SCALE = {
2318
3627
  ]
2319
3628
  };
2320
3629
  const PLDDT_BANDS = PLDDT_COLOR_SCALE.kind === "stepped" ? PLDDT_COLOR_SCALE.stops : [];
3630
+ /**
3631
+ * The band colors alone, ascending from least to most confident.
3632
+ *
3633
+ * What the chain legend shows in place of a single color while pLDDT is
3634
+ * painting the structure: no one color stands for a chain then, so the swatch
3635
+ * carries the whole key instead.
3636
+ */
3637
+ const PLDDT_BAND_COLORS = PLDDT_BANDS.map((stop) => stop.color);
2321
3638
  const DEFAULT_PLDDT_COLOR = Color.fromRgb(128, 128, 128);
2322
3639
  /**
2323
3640
  * Maps a pLDDT confidence score on the 0-100 B-factor scale to its band color.
@@ -2358,48 +3675,24 @@ function injectPlddtIntoPdb(pdbData, plddtValues) {
2358
3675
  }
2359
3676
  /**
2360
3677
  * Mol* color theme that reads pLDDT scores out of the B-factor column, paired
2361
- * with `injectPlddtIntoPdb`. Registered per plugin instance.
2362
- */
2363
- const PlddtColoring = CustomElementProperty.create({
2364
- coloring: {
2365
- defaultColor: DEFAULT_PLDDT_COLOR,
2366
- getColor(e) {
2367
- return plddtToColor(e);
2368
- }
2369
- },
2370
- getData(model) {
2371
- const map = /* @__PURE__ */ new Map();
2372
- const atomCount = model.atomicHierarchy.atoms._rowCount;
2373
- for (let i = 0; i < atomCount; i++) map.set(i, model.atomicConformation.B_iso_or_equiv.value(i));
2374
- return { value: map };
2375
- },
2376
- label: "pLDDT",
2377
- name: PLDDT_THEME_NAME
2378
- });
2379
- //#endregion
2380
- //#region src/core/ProteinStructureViewer/utils/color.ts
2381
- /** Parses `#RRGGBB` (or `RRGGBB`) into a Mol* `Color`. */
2382
- function parseHexColor(hex) {
2383
- const h = hex.replace("#", "");
2384
- return Color.fromRgb(parseInt(h.substring(0, 2), 16), parseInt(h.substring(2, 4), 16), parseInt(h.substring(4, 6), 16));
2385
- }
2386
- /**
2387
- * Parses a theme color into a Mol* `Color`, falling back when the token is
2388
- * missing or is a format Mol* cannot consume (theme values are hex in practice,
2389
- * but the palette types allow any CSS color string).
2390
- */
2391
- function themeColor(value, fallback) {
2392
- const candidate = value === null || value === void 0 ? void 0 : value.trim();
2393
- return parseHexColor(candidate !== void 0 && /^#?[0-9a-f]{6}$/i.test(candidate) ? candidate : fallback);
2394
- }
2395
- /**
2396
- * Neutral fill for residues with no overlay value. Sits between the light and
2397
- * dark backgrounds so unhighlighted geometry stays legible in both modes.
2398
- */
2399
- function neutralResidueColor(mode) {
2400
- const g = mode === "dark" ? 100 : 180;
2401
- return Color.fromRgb(g, g, g);
2402
- }
3678
+ * with `injectPlddtIntoPdb`. Registered per plugin instance.
3679
+ */
3680
+ const PlddtColoring = CustomElementProperty.create({
3681
+ coloring: {
3682
+ defaultColor: DEFAULT_PLDDT_COLOR,
3683
+ getColor(e) {
3684
+ return plddtToColor(e);
3685
+ }
3686
+ },
3687
+ getData(model) {
3688
+ const map = /* @__PURE__ */ new Map();
3689
+ const atomCount = model.atomicHierarchy.atoms._rowCount;
3690
+ for (let i = 0; i < atomCount; i++) map.set(i, model.atomicConformation.B_iso_or_equiv.value(i));
3691
+ return { value: map };
3692
+ },
3693
+ label: "pLDDT",
3694
+ name: PLDDT_THEME_NAME
3695
+ });
2403
3696
  //#endregion
2404
3697
  //#region src/core/ProteinStructureViewer/utils/residueValueTheme.ts
2405
3698
  /** Mol* color theme name under which residue value overlays are registered. */
@@ -2458,10 +3751,13 @@ function createResidueValueTheme(mode) {
2458
3751
  }
2459
3752
  //#endregion
2460
3753
  //#region src/core/ProteinStructureViewer/hooks/useMolstarPlugin.ts
2461
- /** Structure representation Mol* builds for the loaded model. */
2462
- const REPRESENTATION_PRESET = "polymer-cartoon";
2463
- /** Theme used when no pLDDT scores are supplied. */
2464
- const FALLBACK_THEME_NAME = "chain-id";
3754
+ /**
3755
+ * Theme used when neither pLDDT scores nor an overlay are supplied. Our own
3756
+ * per-chain theme rather than Mol*'s built-in `chain-id`, so the chain legend
3757
+ * can show swatches that match the structure instead of guessing at the colors
3758
+ * Mol* picked.
3759
+ */
3760
+ const FALLBACK_THEME_NAME = CHAIN_COLOR_THEME_NAME;
2465
3761
  /** Delay before retrying initialization while the container has no size. */
2466
3762
  const LAYOUT_RETRY_MS = 100;
2467
3763
  /** How long to wait for a frame before starting without one. */
@@ -2498,38 +3794,6 @@ function nextFrame() {
2498
3794
  */
2499
3795
  const HIGHLIGHT_STRENGTH = .2;
2500
3796
  const SELECT_STRENGTH = 0;
2501
- const AXES_ON = {
2502
- name: "on",
2503
- params: {
2504
- alpha: .51,
2505
- colorX: 16711680,
2506
- colorY: 32768,
2507
- colorZ: 255,
2508
- labelColorX: 8421504,
2509
- labelColorY: 8421504,
2510
- labelColorZ: 8421504,
2511
- labelOpacity: 1,
2512
- labelScale: .25,
2513
- labelX: "X",
2514
- labelY: "Y",
2515
- labelZ: "Z",
2516
- location: "bottom-left",
2517
- locationOffsetX: 0,
2518
- locationOffsetY: 0,
2519
- originColor: 8421504,
2520
- planeColorXY: 8421504,
2521
- planeColorXZ: 8421504,
2522
- planeColorYZ: 8421504,
2523
- radiusScale: .075,
2524
- scale: .15,
2525
- showLabels: false,
2526
- showPlanes: true
2527
- }
2528
- };
2529
- const AXES_OFF = {
2530
- name: "off",
2531
- params: {}
2532
- };
2533
3797
  /**
2534
3798
  * Turns the screenshot controls off, on the versions of Mol* that have them.
2535
3799
  *
@@ -2555,13 +3819,13 @@ function createViewer(_x) {
2555
3819
  return _createViewer.apply(this, arguments);
2556
3820
  }
2557
3821
  function _createViewer() {
2558
- _createViewer = _asyncToGenerator(function* ({ backgroundColor, edgeColor, highlightColor, residueValueTheme, root, showAxes, showSequenceViewer, viewSettings }) {
3822
+ _createViewer = _asyncToGenerator(function* ({ backgroundColor, chainColorTheme, edgeColor, highlightColor, molstarSpec, residueValueTheme, root, showAxes, showSequenceViewer, viewSettings }) {
2559
3823
  var _spec$config;
2560
3824
  const spec = DefaultPluginUISpec();
2561
3825
  const behaviors = spec.behaviors.filter((b) => b.transformer !== PluginBehaviors.Camera.FocusLoci);
2562
3826
  const plugin = yield createPluginUI({
2563
3827
  render: renderReact18,
2564
- spec: _objectSpread2(_objectSpread2({}, spec), {}, {
3828
+ spec: mergeMolstarSpec(_objectSpread2(_objectSpread2({}, spec), {}, {
2565
3829
  behaviors,
2566
3830
  canvas3d: {
2567
3831
  camera: { helper: { axes: showAxes ? AXES_ON : AXES_OFF } },
@@ -2577,10 +3841,16 @@ function _createViewer() {
2577
3841
  selectStrength: SELECT_STRENGTH
2578
3842
  }
2579
3843
  },
2580
- components: _objectSpread2({
3844
+ components: {
3845
+ controls: {
3846
+ bottom: "none",
3847
+ left: "none",
3848
+ right: "none"
3849
+ },
2581
3850
  remoteState: "none",
2582
- sequenceViewer: { view: createSequenceView(viewSettings) }
2583
- }, showAxes && { viewport: { view: createViewportView(viewSettings) } }),
3851
+ sequenceViewer: { view: createSequenceView(viewSettings) },
3852
+ viewport: { view: createViewportView(viewSettings) }
3853
+ },
2584
3854
  config: [
2585
3855
  ...(_spec$config = spec.config) !== null && _spec$config !== void 0 ? _spec$config : [],
2586
3856
  [PluginConfig.Viewport.ShowExpand, false],
@@ -2601,12 +3871,13 @@ function _createViewer() {
2601
3871
  top: showSequenceViewer ? "full" : "hidden"
2602
3872
  }
2603
3873
  } }
2604
- }),
3874
+ }), molstarSpec),
2605
3875
  target: root
2606
3876
  });
2607
3877
  const registry = plugin.representation.structure.themes.colorThemeRegistry;
2608
3878
  if (PlddtColoring.colorThemeProvider) registry.add(PlddtColoring.colorThemeProvider);
2609
3879
  registry.add(residueValueTheme.provider);
3880
+ registry.add(chainColorTheme.provider);
2610
3881
  for (const p of [...plugin.managers.lociLabels.providers]) plugin.managers.lociLabels.removeProvider(p);
2611
3882
  plugin.managers.structure.focus.behaviors.current.subscribe((entry) => {
2612
3883
  const select = plugin.managers.interactivity.lociSelects;
@@ -2617,7 +3888,112 @@ function _createViewer() {
2617
3888
  });
2618
3889
  return _createViewer.apply(this, arguments);
2619
3890
  }
2620
- function loadStructure(_x2, _x3, _x4, _x5) {
3891
+ /**
3892
+ * The representation parameters Mol*'s own presets compute, read from the same
3893
+ * manager state they read. Ported rather than hardcoded so a chain drawn here
3894
+ * looks exactly like the one the preset used to draw. Mol* hands the same set
3895
+ * to its cartoons and its ball-and-sticks, and so does this.
3896
+ */
3897
+ function representationTypeParams(plugin) {
3898
+ const { hydrogens, ignoreLight, visualQuality } = plugin.managers.structure.component.state.options;
3899
+ return {
3900
+ ignoreHydrogens: hydrogens !== "all",
3901
+ ignoreHydrogensVariant: hydrogens === "only-polar" ? "non-polar" : "all",
3902
+ ignoreLight,
3903
+ quality: visualQuality
3904
+ };
3905
+ }
3906
+ /**
3907
+ * The two halves of a chain the viewer draws. Each names the component its
3908
+ * half is built under and tags the representation drawn over it, and it is
3909
+ * that tag `applyColorTheme` reads back to tell the two apart.
3910
+ */
3911
+ const POLYMER_PART = "polymer";
3912
+ const LIGAND_PART = "ligand";
3913
+ /**
3914
+ * True for the representation drawing a chain's heteroatoms.
3915
+ *
3916
+ * Read off the tag put on the representation when it was built, rather than
3917
+ * off the component's key. Mol* does not store the key as it was given: it
3918
+ * files components under `structure-component-<key>`, so a test against the
3919
+ * key as written here silently matches nothing, and every ligand quietly takes
3920
+ * the structure-wide theme instead. The tag survives untouched.
3921
+ */
3922
+ function isLigandRepresentation(representation) {
3923
+ var _representation$cell$;
3924
+ return ((_representation$cell$ = representation.cell.transform.tags) === null || _representation$cell$ === void 0 ? void 0 : _representation$cell$.includes(LIGAND_PART)) === true;
3925
+ }
3926
+ /**
3927
+ * How heteroatoms are colored: the theme Mol* puts on a ball-and-stick when
3928
+ * left to itself, with the parameters it chooses too.
3929
+ *
3930
+ * Named rather than left blank only so that it survives `applyColorTheme`,
3931
+ * which would otherwise repaint the ligands along with everything else. The
3932
+ * structure-wide theme is the wrong answer for them twice over: a heme reads
3933
+ * as a heme because its iron is orange and its nitrogens are blue, which one
3934
+ * flat color throws away, and pLDDT and residue overlays have no value for a
3935
+ * HETATM to be colored by at all.
3936
+ */
3937
+ const ELEMENT_THEME = "element-symbol";
3938
+ /**
3939
+ * Draws one chain: its polymer as a cartoon, its ligands and ions as
3940
+ * ball-and-stick.
3941
+ *
3942
+ * Two components rather than one, because a component is what a representation
3943
+ * attaches to and neither representation suits both halves - a cartoon has no
3944
+ * backbone to trace through a heme, and a ball-and-stick over the whole chain
3945
+ * would draw every protein atom as a sphere.
3946
+ *
3947
+ * Hands back the refs of whichever components the chain turned out to have,
3948
+ * which is what `setSubtreeVisibility` is later pointed at. Grouping them
3949
+ * under the chain is what makes hiding a chain take its ligands with it.
3950
+ */
3951
+ function buildChainComponents(_x5, _x6, _x7, _x8) {
3952
+ return _buildChainComponents.apply(this, arguments);
3953
+ }
3954
+ function _buildChainComponents() {
3955
+ _buildChainComponents = _asyncToGenerator(function* (plugin, structure, chain, typeParams) {
3956
+ const builders = plugin.builders.structure;
3957
+ const refs = [];
3958
+ const addPart = function() {
3959
+ var _ref = _asyncToGenerator(function* (part, expression, props) {
3960
+ const component = yield builders.tryCreateComponentFromExpression(structure, expression, `${part}-${chain.chainId}`, { label: chain.label });
3961
+ if (!component) return;
3962
+ yield builders.representation.addRepresentation(component, props, { tag: part });
3963
+ refs.push(component.ref);
3964
+ });
3965
+ return function addPart(_x2, _x3, _x4) {
3966
+ return _ref.apply(this, arguments);
3967
+ };
3968
+ }();
3969
+ yield addPart(POLYMER_PART, chainPolymerExpression(chain.chainId), {
3970
+ type: "cartoon",
3971
+ typeParams
3972
+ });
3973
+ yield addPart(LIGAND_PART, chainLigandExpression(chain.chainId), {
3974
+ color: ELEMENT_THEME,
3975
+ type: "ball-and-stick",
3976
+ typeParams
3977
+ });
3978
+ return refs;
3979
+ });
3980
+ return _buildChainComponents.apply(this, arguments);
3981
+ }
3982
+ const NOTHING_LOADED = {
3983
+ chains: [],
3984
+ componentRefs: /* @__PURE__ */ new Map(),
3985
+ residuesByChain: /* @__PURE__ */ new Map()
3986
+ };
3987
+ /**
3988
+ * Parses the PDB and draws each chain: a cartoon over its polymer, and
3989
+ * ball-and-stick over its ligands and ions.
3990
+ *
3991
+ * Mol*'s `default` hierarchy preset would be shorter, but it groups every
3992
+ * polymer chain into a single component, and a component is the unit Mol* can
3993
+ * hide. Building them per chain is what makes a chain individually hideable;
3994
+ * the refs handed back are what `setSubtreeVisibility` is later pointed at.
3995
+ */
3996
+ function loadStructure(_x9, _x10, _x11, _x12) {
2621
3997
  return _loadStructure.apply(this, arguments);
2622
3998
  }
2623
3999
  function _loadStructure() {
@@ -2630,31 +4006,69 @@ function _loadStructure() {
2630
4006
  label: "Structure"
2631
4007
  });
2632
4008
  const trajectory = yield plugin.builders.structure.parseTrajectory(data, "pdb");
2633
- yield plugin.builders.structure.hierarchy.applyPreset(trajectory, "default", {
2634
- representationPreset: REPRESENTATION_PRESET,
2635
- showUnitcell: false,
2636
- structure: {
2637
- name: "model",
2638
- params: {}
2639
- }
4009
+ const model = yield plugin.builders.structure.createModel(trajectory);
4010
+ const structure = yield plugin.builders.structure.createStructure(model, {
4011
+ name: "model",
4012
+ params: {}
2640
4013
  });
4014
+ const data3d = structure.data;
4015
+ if (!data3d) return NOTHING_LOADED;
4016
+ const { chainLabels, chains, residuesByChain } = scanChains(data3d);
4017
+ const typeParams = representationTypeParams(plugin);
4018
+ const componentRefs = /* @__PURE__ */ new Map();
4019
+ for (const [chainId, label] of chainLabels) {
4020
+ const refs = yield buildChainComponents(plugin, structure, {
4021
+ chainId,
4022
+ label
4023
+ }, typeParams);
4024
+ if (refs.length > 0) componentRefs.set(chainId, refs);
4025
+ }
2641
4026
  if (usePlddtColoring) yield applyColorTheme(plugin, PLDDT_THEME_NAME);
2642
4027
  (_plugin$canvas3d = plugin.canvas3d) === null || _plugin$canvas3d === void 0 || _plugin$canvas3d.requestCameraReset();
2643
4028
  setAxes(plugin, showAxes);
4029
+ return {
4030
+ chains,
4031
+ componentRefs,
4032
+ residuesByChain
4033
+ };
2644
4034
  } catch (error) {
2645
4035
  console.error("Failed to load structure:", error);
4036
+ return NOTHING_LOADED;
2646
4037
  }
2647
4038
  });
2648
4039
  return _loadStructure.apply(this, arguments);
2649
4040
  }
2650
- /** Recolors every loaded structure's representations with the named theme. */
2651
- function applyColorTheme(_x6, _x7) {
4041
+ /**
4042
+ * Shows or hides everything each chain is drawn with, cartoon and sticks
4043
+ * alike. Every component under the chain, so hiding it takes its ligands with
4044
+ * it rather than leaving a heme floating where its protein used to be.
4045
+ *
4046
+ * The whole subtree of each, not just the component cell: visibility is
4047
+ * per-cell, and it is the representation underneath that actually draws.
4048
+ *
4049
+ * Set outright rather than through the component manager's `toggleVisibility`,
4050
+ * which flips whatever it finds: the hidden set is derived from props on every
4051
+ * pass, so it has to be applied as a statement of what should be true and not
4052
+ * as a change to what already is.
4053
+ */
4054
+ function applyChainVisibility(plugin, componentRefs, hidden) {
4055
+ for (const [chainId, refs] of componentRefs) for (const ref of refs) setSubtreeVisibility(plugin.state.data, ref, hidden.has(chainId));
4056
+ }
4057
+ /**
4058
+ * Recolors every loaded structure's representations with the named theme,
4059
+ * leaving the heteroatoms on `ELEMENT_THEME`.
4060
+ *
4061
+ * The per-representation form of `updateRepresentationsTheme`, so the two can
4062
+ * be told apart in one pass; handing it a filtered list instead would leave
4063
+ * the ligands on whichever theme happened to be current when they were built.
4064
+ */
4065
+ function applyColorTheme(_x13, _x14) {
2652
4066
  return _applyColorTheme.apply(this, arguments);
2653
4067
  }
2654
4068
  function _applyColorTheme() {
2655
4069
  _applyColorTheme = _asyncToGenerator(function* (plugin, colorTheme) {
2656
4070
  yield plugin.dataTransaction(_asyncToGenerator(function* () {
2657
- for (const s of plugin.managers.structure.hierarchy.current.structures) yield plugin.managers.structure.component.updateRepresentationsTheme(s.components, { color: colorTheme });
4071
+ for (const s of plugin.managers.structure.hierarchy.current.structures) yield plugin.managers.structure.component.updateRepresentationsTheme(s.components, (_component, representation) => ({ color: isLigandRepresentation(representation) ? ELEMENT_THEME : colorTheme }));
2658
4072
  }));
2659
4073
  });
2660
4074
  return _applyColorTheme.apply(this, arguments);
@@ -2668,11 +4082,35 @@ function _applyColorTheme() {
2668
4082
  * axes, coloring, new PDB) are pushed in through the effects below and in the
2669
4083
  * sibling hooks rather than by rebuilding.
2670
4084
  */
2671
- function useMolstarPlugin({ backgroundColor, containerRef, edgeColor, hasPlddt, highlightColor, mode, onResidueClick, onResidueHover, onSelectionClear, pdb, sequenceViewerBackgroundColor, showAxes, showSequenceViewer }) {
4085
+ function useMolstarPlugin({ backgroundColor, chainColors, containerRef, download, edgeColor, hasPlddt, hiddenChains, highlightColor, mode, molstarSpec, onChainHover, onChainSelect, onChainToggle, onResidueClick, onResidueHover, onSelectionChange, onSelectionClear, pdb, selectedChains, sequenceViewerBackgroundColor, showAxes, showSequenceViewer }) {
2672
4086
  const pluginRef = useRef(null);
2673
4087
  const residueValueThemeRef = useRef(null);
4088
+ const chainColorThemeRef = useRef(null);
2674
4089
  const currentPdbRef = useRef(null);
2675
4090
  const [isReady, setIsReady] = useState(false);
4091
+ const [chains, setChains] = useState([]);
4092
+ /**
4093
+ * Where each chain's components live in the state tree, which is what the
4094
+ * visibility effect points `setSubtreeVisibility` at. Rewritten by every
4095
+ * load, since the refs do not survive `plugin.clear()`.
4096
+ */
4097
+ const componentRefsRef = useRef(/* @__PURE__ */ new Map());
4098
+ /**
4099
+ * Which residues sit on each chain, for the readout to average a score over
4100
+ * a whole-chain selection. A ref rather than state: nothing renders from it
4101
+ * directly, and it is rewritten in step with `chains`, which does.
4102
+ */
4103
+ const residuesByChainRef = useRef(/* @__PURE__ */ new Map());
4104
+ /**
4105
+ * The visibility and coloring to apply as soon as a structure exists. Read
4106
+ * through refs because both arrive as fresh objects on every render, and a
4107
+ * load has to be able to consult them without the load effect depending on
4108
+ * them - a new Set each render would reload the structure on every pass.
4109
+ */
4110
+ const hiddenChainsRef = useRef(hiddenChains);
4111
+ hiddenChainsRef.current = hiddenChains;
4112
+ const chainColorsRef = useRef(chainColors);
4113
+ chainColorsRef.current = chainColors;
2676
4114
  /**
2677
4115
  * Carries the theme-dependent props into the views Mol* renders in its own
2678
4116
  * React root. They cannot read them from context, and rebuilding the plugin
@@ -2681,7 +4119,8 @@ function useMolstarPlugin({ backgroundColor, containerRef, edgeColor, hasPlddt,
2681
4119
  const viewSettingsRef = useRef(null);
2682
4120
  if (viewSettingsRef.current === null) viewSettingsRef.current = new BehaviorSubject({
2683
4121
  mode,
2684
- sequenceViewerBackgroundColor
4122
+ sequenceViewerBackgroundColor,
4123
+ showAxes
2685
4124
  });
2686
4125
  const viewSettings = viewSettingsRef.current;
2687
4126
  /**
@@ -2692,10 +4131,27 @@ function useMolstarPlugin({ backgroundColor, containerRef, edgeColor, hasPlddt,
2692
4131
  const setClipRatio = useCallback((ratio) => {
2693
4132
  clipRatioRef.current = ratio;
2694
4133
  }, []);
4134
+ /**
4135
+ * Takes up what a load produced: the component refs the chain-keyed props
4136
+ * address, the visibility they were already asking for, and the chain list
4137
+ * the consumer is told about.
4138
+ *
4139
+ * Chains are published only when they actually differ, so reloading the same
4140
+ * structure - a theme switch that happens to reload, a parent handing back an
4141
+ * equal PDB string - does not hand every consumer a new array to react to.
4142
+ */
4143
+ const adoptLoadedStructure = useCallback((plugin, loaded) => {
4144
+ componentRefsRef.current = loaded.componentRefs;
4145
+ residuesByChainRef.current = loaded.residuesByChain;
4146
+ applyChainVisibility(plugin, loaded.componentRefs, hiddenChainsRef.current);
4147
+ setChains((prev) => chainsEqual(prev, loaded.chains) ? prev : loaded.chains);
4148
+ }, []);
2695
4149
  const onResidueClickRef = useRef(onResidueClick);
2696
4150
  onResidueClickRef.current = onResidueClick;
2697
4151
  const onSelectionClearRef = useRef(onSelectionClear);
2698
4152
  onSelectionClearRef.current = onSelectionClear;
4153
+ const onSelectionChangeRef = useRef(onSelectionChange);
4154
+ onSelectionChangeRef.current = onSelectionChange;
2699
4155
  const onResidueHoverRef = useRef(onResidueHover);
2700
4156
  onResidueHoverRef.current = onResidueHover;
2701
4157
  /** Last residue reported to `onResidueHover`, to suppress repeats. */
@@ -2706,6 +4162,7 @@ function useMolstarPlugin({ backgroundColor, containerRef, edgeColor, hasPlddt,
2706
4162
  hasPlddt,
2707
4163
  highlightColor,
2708
4164
  mode,
4165
+ molstarSpec,
2709
4166
  pdb,
2710
4167
  showAxes,
2711
4168
  showSequenceViewer
@@ -2716,17 +4173,32 @@ function useMolstarPlugin({ backgroundColor, containerRef, edgeColor, hasPlddt,
2716
4173
  hasPlddt,
2717
4174
  highlightColor,
2718
4175
  mode,
4176
+ molstarSpec,
2719
4177
  pdb,
2720
4178
  showAxes,
2721
4179
  showSequenceViewer
2722
4180
  };
4181
+ /**
4182
+ * The consumer's `canvas3d` overrides, keyed by content. Unlike the rest of
4183
+ * the spec these are re-applied when they change, and they commonly arrive
4184
+ * as an object written inline - so the effect below turns on what they say
4185
+ * rather than on the identity of the object saying it.
4186
+ */
4187
+ const canvas3dOverrideKey = (() => {
4188
+ try {
4189
+ var _molstarSpec$canvas3d;
4190
+ return JSON.stringify((_molstarSpec$canvas3d = molstarSpec === null || molstarSpec === void 0 ? void 0 : molstarSpec.canvas3d) !== null && _molstarSpec$canvas3d !== void 0 ? _molstarSpec$canvas3d : null);
4191
+ } catch (_unused) {
4192
+ return String(Date.now());
4193
+ }
4194
+ })();
2723
4195
  useEffect(() => {
2724
4196
  const container = containerRef.current;
2725
4197
  if (!container) return;
2726
4198
  let cancelled = false;
2727
4199
  let clipSubscription;
2728
4200
  const init = function() {
2729
- var _ref = _asyncToGenerator(function* () {
4201
+ var _ref2 = _asyncToGenerator(function* () {
2730
4202
  yield nextFrame();
2731
4203
  if (cancelled) return;
2732
4204
  const rect = container.getBoundingClientRect();
@@ -2738,10 +4210,13 @@ function useMolstarPlugin({ backgroundColor, containerRef, edgeColor, hasPlddt,
2738
4210
  try {
2739
4211
  var _plugin$canvas3d2;
2740
4212
  const residueValueTheme = createResidueValueTheme(initial.mode);
4213
+ const chainColorTheme = createChainColorTheme();
2741
4214
  const plugin = yield createViewer({
2742
4215
  backgroundColor: initial.backgroundColor,
4216
+ chainColorTheme,
2743
4217
  edgeColor: initial.edgeColor,
2744
4218
  highlightColor: initial.highlightColor,
4219
+ molstarSpec: initial.molstarSpec,
2745
4220
  residueValueTheme,
2746
4221
  root: container,
2747
4222
  showAxes: initial.showAxes,
@@ -2754,11 +4229,28 @@ function useMolstarPlugin({ backgroundColor, containerRef, edgeColor, hasPlddt,
2754
4229
  }
2755
4230
  pluginRef.current = plugin;
2756
4231
  residueValueThemeRef.current = residueValueTheme;
4232
+ chainColorThemeRef.current = chainColorTheme;
2757
4233
  const latest = initialPropsRef.current;
2758
- yield loadStructure(plugin, latest.pdb, latest.hasPlddt, latest.showAxes);
4234
+ const loaded = yield loadStructure(plugin, latest.pdb, latest.hasPlddt, latest.showAxes);
2759
4235
  currentPdbRef.current = latest.pdb;
4236
+ adoptLoadedStructure(plugin, loaded);
4237
+ /**
4238
+ * Mol*'s click behavior is a BehaviorSubject, so subscribing replays
4239
+ * whatever it is currently holding - which, before anything has been
4240
+ * clicked, is an empty click. Acting on that would read as the user
4241
+ * clicking empty space and clear a selection the consumer had set
4242
+ * before the viewer was even interactive.
4243
+ *
4244
+ * Only the replay is skipped, not every empty click: clicking away
4245
+ * from the structure is how a selection is meant to be cleared.
4246
+ */
4247
+ let clickReplayConsumed = false;
2760
4248
  plugin.behaviors.interaction.click.subscribe((e) => {
2761
- var _onResidueClickRef$cu;
4249
+ var _onResidueClickRef$cu, _onSelectionChangeRef;
4250
+ if (!clickReplayConsumed) {
4251
+ clickReplayConsumed = true;
4252
+ if (Representation.Loci.isEmpty(e.current)) return;
4253
+ }
2762
4254
  if (Representation.Loci.isEmpty(e.current)) {
2763
4255
  var _onSelectionClearRef$;
2764
4256
  (_onSelectionClearRef$ = onSelectionClearRef.current) === null || _onSelectionClearRef$ === void 0 || _onSelectionClearRef$.call(onSelectionClearRef);
@@ -2769,6 +4261,7 @@ function useMolstarPlugin({ backgroundColor, containerRef, edgeColor, hasPlddt,
2769
4261
  const residue = residueRefFromLoci(loci);
2770
4262
  if (!residue) return;
2771
4263
  (_onResidueClickRef$cu = onResidueClickRef.current) === null || _onResidueClickRef$cu === void 0 || _onResidueClickRef$cu.call(onResidueClickRef, residue);
4264
+ (_onSelectionChangeRef = onSelectionChangeRef.current) === null || _onSelectionChangeRef === void 0 || _onSelectionChangeRef.call(onSelectionChangeRef, selectionFromLoci(loci));
2772
4265
  });
2773
4266
  plugin.behaviors.interaction.hover.subscribe((e) => {
2774
4267
  var _lastHoverRef$current, _onResidueHoverRef$cu;
@@ -2787,7 +4280,7 @@ function useMolstarPlugin({ backgroundColor, containerRef, edgeColor, hasPlddt,
2787
4280
  }
2788
4281
  });
2789
4282
  return function init() {
2790
- return _ref.apply(this, arguments);
4283
+ return _ref2.apply(this, arguments);
2791
4284
  };
2792
4285
  }();
2793
4286
  init();
@@ -2798,13 +4291,16 @@ function useMolstarPlugin({ backgroundColor, containerRef, edgeColor, hasPlddt,
2798
4291
  (_pluginRef$current = pluginRef.current) === null || _pluginRef$current === void 0 || _pluginRef$current.dispose();
2799
4292
  pluginRef.current = null;
2800
4293
  residueValueThemeRef.current = null;
4294
+ chainColorThemeRef.current = null;
4295
+ componentRefsRef.current = /* @__PURE__ */ new Map();
4296
+ residuesByChainRef.current = /* @__PURE__ */ new Map();
2801
4297
  currentPdbRef.current = null;
2802
4298
  clipRatioRef.current = null;
2803
4299
  setIsReady(false);
2804
4300
  };
2805
4301
  }, []);
2806
4302
  useEffect(() => {
2807
- var _pluginRef$current2;
4303
+ var _pluginRef$current2, _initialPropsRef$curr;
2808
4304
  const canvas3d = (_pluginRef$current2 = pluginRef.current) === null || _pluginRef$current2 === void 0 ? void 0 : _pluginRef$current2.canvas3d;
2809
4305
  if (!canvas3d || !isReady) return;
2810
4306
  canvas3d.setProps({
@@ -2817,20 +4313,37 @@ function useMolstarPlugin({ backgroundColor, containerRef, edgeColor, hasPlddt,
2817
4313
  highlightColor
2818
4314
  }
2819
4315
  });
4316
+ const override = (_initialPropsRef$curr = initialPropsRef.current.molstarSpec) === null || _initialPropsRef$curr === void 0 ? void 0 : _initialPropsRef$curr.canvas3d;
4317
+ if (override) canvas3d.setProps(override);
2820
4318
  }, [
2821
4319
  backgroundColor,
4320
+ canvas3dOverrideKey,
2822
4321
  edgeColor,
2823
4322
  highlightColor,
2824
4323
  isReady
2825
4324
  ]);
2826
4325
  useEffect(() => {
2827
4326
  viewSettings.next({
4327
+ download,
4328
+ hiddenChains,
2828
4329
  mode,
2829
- sequenceViewerBackgroundColor
4330
+ onChainHover,
4331
+ onChainSelect,
4332
+ onChainToggle,
4333
+ selectedChains,
4334
+ sequenceViewerBackgroundColor,
4335
+ showAxes
2830
4336
  });
2831
4337
  }, [
4338
+ download,
4339
+ hiddenChains,
2832
4340
  mode,
4341
+ onChainHover,
4342
+ onChainSelect,
4343
+ onChainToggle,
4344
+ selectedChains,
2833
4345
  sequenceViewerBackgroundColor,
4346
+ showAxes,
2834
4347
  viewSettings
2835
4348
  ]);
2836
4349
  useEffect(() => {
@@ -2844,120 +4357,312 @@ function useMolstarPlugin({ backgroundColor, containerRef, edgeColor, hasPlddt,
2844
4357
  currentPdbRef.current = pdb;
2845
4358
  clipRatioRef.current = null;
2846
4359
  lastHoverRef.current = null;
2847
- loadStructure(plugin, pdb, hasPlddt, showAxes);
4360
+ loadStructure(plugin, pdb, hasPlddt, showAxes).then((loaded) => {
4361
+ if (pluginRef.current !== plugin) return;
4362
+ adoptLoadedStructure(plugin, loaded);
4363
+ });
2848
4364
  }, [
2849
4365
  pdb,
2850
4366
  hasPlddt,
2851
- isReady
4367
+ isReady,
4368
+ adoptLoadedStructure
2852
4369
  ]);
4370
+ useEffect(() => {
4371
+ const plugin = pluginRef.current;
4372
+ if (!plugin || !isReady) return;
4373
+ applyChainVisibility(plugin, componentRefsRef.current, hiddenChains);
4374
+ }, [hiddenChains, isReady]);
2853
4375
  return {
4376
+ chainColorThemeRef,
4377
+ chains,
2854
4378
  isReady,
2855
4379
  pluginRef,
4380
+ residuesByChainRef,
2856
4381
  residueValueThemeRef,
2857
4382
  setClipRatio
2858
4383
  };
2859
4384
  }
2860
4385
  //#endregion
2861
- //#region src/core/ProteinStructureViewer/utils/residueLoci.ts
4386
+ //#region src/core/ProteinStructureViewer/utils/focusRepresentation.ts
4387
+ /**
4388
+ * What Mol* draws around a focused selection, as ball-and-stick over the
4389
+ * cartoon: the selection itself, the residues near it, and the non-covalent
4390
+ * contacts between the two. All three stay on; what changes is how far "near"
4391
+ * reaches.
4392
+ */
4393
+ const FOCUS_COMPONENTS = [
4394
+ "target",
4395
+ "surroundings",
4396
+ "interactions"
4397
+ ];
4398
+ /** Mol*'s own default, in angstroms. */
4399
+ const NEIGHBOUR_RADIUS = 5;
4400
+ /**
4401
+ * Mol* builds the surroundings as `includeSurroundings(target, radius)` with
4402
+ * whole residues, so a radius of zero resolves to the residues of the target
4403
+ * and nothing else.
4404
+ */
4405
+ const OWN_RESIDUES_ONLY = 0;
2862
4406
  /**
2863
- * Every atom of the residue at a 0-based index, as geometry Mol* can focus and
2864
- * frame, or undefined when the structure has no such residue.
4407
+ * Chooses how far the focus shell reaches around a selection.
2865
4408
  *
2866
- * The inverse of `residueRefFromLoci`, and keyed on the same residue ordinal,
2867
- * so a `selectedResidue` echoed back from a click resolves to the residue that
2868
- * was clicked - on a complex as much as on a single chain.
4409
+ * A residue, or a short range, is a site: the 5A shell around it is the point
4410
+ * of looking at it, and Mol*'s default earns its keep. A whole chain is a
4411
+ * structural unit instead, and its neighbours are the chain bound to it - so
4412
+ * on a complex the shell reaches straight across the interface and draws the
4413
+ * partner's contact face in atoms, which reads as the partner being selected
4414
+ * too.
2869
4415
  *
2870
- * The whole residue is collected rather than one atom, since the caller frames
2871
- * the camera on the loci's bounding sphere and a single atom would zoom far
2872
- * closer than the residue warrants.
4416
+ * Taking the radius to zero confines the shell to the selection rather than
4417
+ * turning it off: the surroundings and the interactions are still drawn, over
4418
+ * the selected chain's own residues, so a chain shows its own atoms and
4419
+ * contacts and no others'.
2873
4420
  */
2874
- function lociForResidueIndex(plugin, residueIndex) {
2875
- for (const entry of plugin.managers.structure.hierarchy.current.structures) {
2876
- var _entry$cell$obj;
2877
- const structure = (_entry$cell$obj = entry.cell.obj) === null || _entry$cell$obj === void 0 ? void 0 : _entry$cell$obj.data;
2878
- if (!structure) continue;
2879
- const location = StructureElement.Location.create(structure);
2880
- for (const unit of structure.units) {
2881
- location.unit = unit;
2882
- const indices = [];
2883
- for (let i = 0; i < unit.elements.length; i++) {
2884
- location.element = unit.elements[i];
2885
- if (StructureProperties.residue.key(location) === residueIndex) indices.push(i);
2886
- }
2887
- if (indices.length > 0) return StructureElement.Loci(structure, [{
2888
- indices: OrderedSet.ofSortedArray(indices),
2889
- unit
2890
- }]);
2891
- }
2892
- }
4421
+ function setFocusNeighbourhood(_x, _x2) {
4422
+ return _setFocusNeighbourhood.apply(this, arguments);
4423
+ }
4424
+ function _setFocusNeighbourhood() {
4425
+ _setFocusNeighbourhood = _asyncToGenerator(function* (plugin, includeNeighbours) {
4426
+ const expandRadius = includeNeighbours ? NEIGHBOUR_RADIUS : OWN_RESIDUES_ONLY;
4427
+ yield plugin.state.updateBehavior(StructureFocusRepresentation, (params) => {
4428
+ params.components = FOCUS_COMPONENTS;
4429
+ params.expandRadius = expandRadius;
4430
+ });
4431
+ });
4432
+ return _setFocusNeighbourhood.apply(this, arguments);
2893
4433
  }
2894
4434
  //#endregion
2895
- //#region src/core/ProteinStructureViewer/hooks/useResidueFocus.ts
4435
+ //#region src/core/ProteinStructureViewer/hooks/useSelectionFocus.ts
4436
+ /**
4437
+ * Points the camera at a loci and marks it.
4438
+ *
4439
+ * A click has already focused what the consumer echoes back by the time it
4440
+ * arrives, so the focus is only set when it is not already what is marked;
4441
+ * setting it again would restate the selection and repaint the sequence panel
4442
+ * for nothing.
4443
+ */
4444
+ function focusLoci(plugin, loci, setClipRatio) {
4445
+ const focused = plugin.managers.structure.focus.behaviors.current.value;
4446
+ if (!focused || !StructureElement.Loci.areEqual(focused.loci, loci)) plugin.managers.structure.focus.setFromLoci(loci);
4447
+ setClipRatio(focusResidue(plugin, loci));
4448
+ }
4449
+ /**
4450
+ * Drops the focus, and the depth clipping anchored to it.
4451
+ *
4452
+ * `resetCamera` separates the two ways this happens. The consumer clearing the
4453
+ * selection means there is nothing to look at, so the view goes back to the
4454
+ * default. A selection that merely resolved to nothing visible - the chain it
4455
+ * covers having been hidden - still stands, so throwing the camera back would
4456
+ * be answering a question nobody asked.
4457
+ */
4458
+ function clearFocus(plugin, setClipRatio, resetCamera) {
4459
+ var _plugin$canvas3d;
4460
+ setClipRatio(null);
4461
+ plugin.managers.structure.focus.clear();
4462
+ if (resetCamera) (_plugin$canvas3d = plugin.canvas3d) === null || _plugin$canvas3d === void 0 || _plugin$canvas3d.requestCameraReset();
4463
+ }
4464
+ /**
4465
+ * Focuses a loci, resizing the focus shell first when this selection wants a
4466
+ * different reach than the last one did.
4467
+ *
4468
+ * The resize is asynchronous and the focus has to follow it - the shell is
4469
+ * sized before focusing so it is not drawn at the old reach and rebuilt - so
4470
+ * this hands back a teardown that abandons the focus if the effect is torn
4471
+ * down while the resize is still in flight.
4472
+ *
4473
+ * `currentShell` is the reach already configured, carried in a ref so that two
4474
+ * selections of the same kind do not each restate it.
4475
+ */
4476
+ function focusWithShell(plugin, loci, includeNeighbours, currentShell, pluginRef, setClipRatio) {
4477
+ if (currentShell.current === includeNeighbours) {
4478
+ focusLoci(plugin, loci, setClipRatio);
4479
+ return;
4480
+ }
4481
+ let cancelled = false;
4482
+ currentShell.current = includeNeighbours;
4483
+ setFocusNeighbourhood(plugin, includeNeighbours).then(() => {
4484
+ if (cancelled || pluginRef.current !== plugin) return;
4485
+ focusLoci(plugin, loci, setClipRatio);
4486
+ });
4487
+ return () => {
4488
+ cancelled = true;
4489
+ };
4490
+ }
2896
4491
  /**
2897
- * Makes `selectedResidue` the one thing that decides where the camera sits.
4492
+ * Makes `selection` the one thing that decides where the camera sits.
2898
4493
  *
2899
- * Setting the prop zooms in on that residue and marks it; clearing it drops the
2900
- * focus and zooms back out. The click subscription only reports the residue it
2901
- * was given, so a click moves the camera only once the consumer accepts it and
2902
- * echoes it back - which is what the prop being controlled is supposed to mean.
4494
+ * Setting the prop frames whatever it covers and marks it; clearing it drops
4495
+ * the focus and zooms back out. The camera frames the selection's bounding
4496
+ * sphere, so a range or a whole chain is fitted rather than approached as a
4497
+ * point - one residue is just the smallest case of that.
2903
4498
  *
2904
- * Clicking still marks the residue immediately, through Mol*'s own focus
2905
- * behavior, so the structure responds to a click whether or not the consumer
2906
- * wires the prop up.
4499
+ * The click subscription only reports the selection it was given, so a click
4500
+ * moves the camera only once the consumer accepts it and echoes it back -
4501
+ * which is what the prop being controlled is supposed to mean. Clicking still
4502
+ * marks immediately, through Mol*'s own focus behavior, so the structure
4503
+ * responds to a click whether or not the consumer wires the prop up.
2907
4504
  */
2908
- function useResidueFocus({ isReady, pluginRef, selectedResidue, setClipRatio }) {
4505
+ function useSelectionFocus({ hiddenChains, isReady, pluginRef, selection, setClipRatio }) {
2909
4506
  /**
2910
- * The residue this hook last pointed the camera at. Tracked rather than
2911
- * compared against the previous prop value so that a selection made before
2912
- * the plugin was ready is still applied once it is, and so that a plugin
2913
- * rebuilt underneath us - which comes up focused on nothing - is not assumed
2914
- * to still be holding the old one.
4507
+ * What this hook last pointed the camera at, as a key rather than the object
4508
+ * itself: a consumer echoing a selection back builds a new object each time,
4509
+ * and comparing identity would refocus on every render.
4510
+ *
4511
+ * Tracked here rather than compared against the previous prop so that a
4512
+ * selection made before the plugin was ready is still applied once it is,
4513
+ * and so that a plugin rebuilt underneath us - which comes up focused on
4514
+ * nothing - is not assumed to still be holding the old one.
2915
4515
  */
2916
4516
  const focusedRef = useRef(null);
4517
+ /**
4518
+ * Whether the focus shell currently reaches beyond the selection, so the
4519
+ * behavior is only updated when the answer actually changes rather than on
4520
+ * every selection.
4521
+ */
4522
+ const neighbourhoodRef = useRef(null);
4523
+ /**
4524
+ * What the camera should be pointed at, as a string. Hiding a chain changes
4525
+ * what there is to focus, so it belongs in here alongside the selection
4526
+ * itself - and comparing strings rather than objects is what keeps a consumer
4527
+ * echoing an equal selection back from refocusing on every render.
4528
+ */
4529
+ const focusKey = selection ? `${selectionKey(selection)}#${[...hiddenChains].sort().join(",")}` : null;
2917
4530
  useEffect(() => {
4531
+ var _chosen$chains;
2918
4532
  const plugin = pluginRef.current;
2919
4533
  if (!plugin || !isReady) {
2920
4534
  focusedRef.current = null;
4535
+ neighbourhoodRef.current = null;
2921
4536
  return;
2922
4537
  }
2923
- const residue = selectedResidue !== null && selectedResidue !== void 0 ? selectedResidue : null;
2924
- if (residue === focusedRef.current) return;
2925
- focusedRef.current = residue;
2926
- if (residue === null) {
2927
- var _plugin$canvas3d;
2928
- setClipRatio(null);
2929
- plugin.managers.structure.focus.clear();
2930
- (_plugin$canvas3d = plugin.canvas3d) === null || _plugin$canvas3d === void 0 || _plugin$canvas3d.requestCameraReset();
4538
+ if (focusKey === focusedRef.current) return;
4539
+ focusedRef.current = focusKey;
4540
+ if (focusKey === null) {
4541
+ clearFocus(plugin, setClipRatio, true);
2931
4542
  return;
2932
4543
  }
2933
- const loci = lociForResidueIndex(plugin, residue);
2934
- if (!loci) return;
2935
- const focused = plugin.managers.structure.focus.behaviors.current.value;
2936
- if (!focused || !StructureElement.Loci.areEqual(focused.loci, loci)) plugin.managers.structure.focus.setFromLoci(loci);
2937
- setClipRatio(focusResidue(plugin, loci));
4544
+ const chosen = selection;
4545
+ const loci = lociForSelection(plugin, chosen, hiddenChains);
4546
+ if (!loci) {
4547
+ clearFocus(plugin, setClipRatio, false);
4548
+ return;
4549
+ }
4550
+ return focusWithShell(plugin, loci, !((_chosen$chains = chosen.chains) === null || _chosen$chains === void 0 ? void 0 : _chosen$chains.length), neighbourhoodRef, pluginRef, setClipRatio);
2938
4551
  }, [
2939
- selectedResidue,
4552
+ focusKey,
2940
4553
  isReady,
2941
4554
  setClipRatio
2942
4555
  ]);
2943
4556
  }
2944
4557
  //#endregion
2945
- //#region src/core/ProteinStructureViewer/hooks/useResidueOverlay.ts
4558
+ //#region src/core/ProteinStructureViewer/hooks/useSelectionReadout.ts
4559
+ /**
4560
+ * Tracks the residue under the pointer, deduplicated, and passes the event on
4561
+ * to the consumer.
4562
+ *
4563
+ * Mol* emits hover events continuously, so a residue the pointer is resting on
4564
+ * would otherwise cost a state update per event. The index alone does not
4565
+ * identify one: loading a new structure into the same plugin renumbers from
4566
+ * zero, so the residue now at an index is not the one that was there before.
4567
+ * Comparing the label as well is enough, because the readout this drives is
4568
+ * built from the label and the index and from nothing else.
4569
+ */
4570
+ function useResidueHoverState(onResidueHover) {
4571
+ const [hoveredResidue, setHoveredResidue] = useState(null);
4572
+ return {
4573
+ handleResidueHover: useCallback((residue) => {
4574
+ setHoveredResidue((prev) => {
4575
+ if (residue === null) return prev === null ? prev : null;
4576
+ const label = residueLabel(residue);
4577
+ return prev !== null && prev.index === residue.index && prev.label === label ? prev : {
4578
+ index: residue.index,
4579
+ label
4580
+ };
4581
+ });
4582
+ onResidueHover === null || onResidueHover === void 0 || onResidueHover(residue);
4583
+ }, [onResidueHover]),
4584
+ hoveredResidue
4585
+ };
4586
+ }
4587
+ /**
4588
+ * Derives the legend's readouts from what is hovered and what is selected.
4589
+ *
4590
+ * The viewer already knows everything they need - the label, the pLDDT, the
4591
+ * overlay value - so they are worked out here rather than asked of the
4592
+ * consumer. A readout covering several residues reports the mean of each,
4593
+ * which is the only reading that survives a range or a whole chain.
4594
+ */
4595
+ function useSelectionReadout({ hoveredResidue, isReady, plddt, pluginRef, residueOverlay, residuesByChainRef, selection }) {
4596
+ const [resolved, setResolved] = useState(null);
4597
+ /**
4598
+ * What the selection covers has to be read off the structure, since a
4599
+ * selection can be made without a click ever naming anything: a lone residue
4600
+ * is named the way the click path names it, so the two readouts cannot
4601
+ * disagree, and a chain is expanded to the residues it actually holds rather
4602
+ * than to the span between its ends.
4603
+ */
4604
+ useEffect(() => {
4605
+ var _residuesByChainRef$c;
4606
+ const plugin = pluginRef.current;
4607
+ if (!plugin || !isReady || isEmptySelection(selection)) {
4608
+ setResolved(null);
4609
+ return;
4610
+ }
4611
+ const chosen = selection;
4612
+ const residues = selectionResidues(chosen, (_residuesByChainRef$c = residuesByChainRef.current) !== null && _residuesByChainRef$c !== void 0 ? _residuesByChainRef$c : /* @__PURE__ */ new Map());
4613
+ if (residues.length === 0) {
4614
+ setResolved(null);
4615
+ return;
4616
+ }
4617
+ if (residues.length > 1) {
4618
+ setResolved({
4619
+ label: selectionLabel(chosen, residues.length),
4620
+ residues
4621
+ });
4622
+ return;
4623
+ }
4624
+ const loci = lociForSelection(plugin, chosen);
4625
+ const residue = loci && residueRefFromLoci(loci);
4626
+ setResolved(residue ? {
4627
+ label: residueLabel(residue),
4628
+ residues
4629
+ } : null);
4630
+ }, [isReady, selection && selectionKey(selection)]);
4631
+ const buildReadout = useCallback((label, residues) => ({
4632
+ label,
4633
+ plddt: meanOverResidues(residues, (i) => plddt === null || plddt === void 0 ? void 0 : plddt[i]),
4634
+ value: meanOverResidues(residues, (i) => residueOverlay === null || residueOverlay === void 0 ? void 0 : residueOverlay.values.get(i)),
4635
+ residueCount: residues.length
4636
+ }), [plddt, residueOverlay]);
4637
+ return {
4638
+ hoveredReadout: useMemo(() => hoveredResidue === null ? null : buildReadout(hoveredResidue.label, [hoveredResidue.index]), [hoveredResidue, buildReadout]),
4639
+ selectedReadout: useMemo(() => resolved === null ? null : buildReadout(resolved.label, resolved.residues), [resolved, buildReadout])
4640
+ };
4641
+ }
4642
+ //#endregion
4643
+ //#region src/core/ProteinStructureViewer/hooks/useStructureColoring.ts
2946
4644
  /**
2947
- * Switches the structure's coloring between the residue value overlay and the
2948
- * default (pLDDT, or chain-id when no scores were supplied).
4645
+ * Decides what is coloring the structure and keeps that theme's data current.
4646
+ *
4647
+ * Three themes compete for the same representations, in a fixed order: a
4648
+ * residue value overlay, then pLDDT scores, then per-chain colors. Only one
4649
+ * can be applied, so the precedence lives here rather than being spread across
4650
+ * the hooks that own each theme's data - and the legend resolves what to
4651
+ * describe the same way, so the two cannot disagree about what is on screen.
2949
4652
  *
2950
4653
  * Recoloring happens in place: the camera is left where it is, so selecting or
2951
4654
  * switching an overlay does not zoom back out to the default view.
2952
4655
  */
2953
- function useResidueOverlay({ hasPlddt, isReady, mode, overlay, pluginRef, residueValueThemeRef }) {
4656
+ function useStructureColoring({ chainColorThemeRef, chainColors, hasPlddt, isReady, mode, overlay, pluginRef, residueValueThemeRef }) {
2954
4657
  useEffect(() => {
2955
4658
  const plugin = pluginRef.current;
2956
- const theme = residueValueThemeRef.current;
2957
- if (!plugin || !theme || !isReady) return;
4659
+ const residueTheme = residueValueThemeRef.current;
4660
+ const chainTheme = chainColorThemeRef.current;
4661
+ if (!plugin || !residueTheme || !chainTheme || !isReady) return;
4662
+ chainTheme.setState(chainColors);
2958
4663
  if (overlay) {
2959
4664
  var _overlay$colorScale, _overlay$min;
2960
- theme.setState({
4665
+ residueTheme.setState({
2961
4666
  colorScale: (_overlay$colorScale = overlay.colorScale) !== null && _overlay$colorScale !== void 0 ? _overlay$colorScale : PLASMA_COLOR_SCALE,
2962
4667
  max: overlay.max,
2963
4668
  min: (_overlay$min = overlay.min) !== null && _overlay$min !== void 0 ? _overlay$min : 0,
@@ -2965,33 +4670,51 @@ function useResidueOverlay({ hasPlddt, isReady, mode, overlay, pluginRef, residu
2965
4670
  values: overlay.values
2966
4671
  });
2967
4672
  applyColorTheme(plugin, RESIDUE_VALUE_THEME_NAME);
2968
- } else {
2969
- theme.setState({
2970
- mode,
2971
- values: /* @__PURE__ */ new Map()
2972
- });
2973
- applyColorTheme(plugin, hasPlddt ? PLDDT_THEME_NAME : FALLBACK_THEME_NAME);
4673
+ return;
2974
4674
  }
4675
+ residueTheme.setState({
4676
+ mode,
4677
+ values: /* @__PURE__ */ new Map()
4678
+ });
4679
+ applyColorTheme(plugin, hasPlddt ? PLDDT_THEME_NAME : FALLBACK_THEME_NAME);
2975
4680
  }, [
2976
4681
  overlay,
2977
4682
  hasPlddt,
2978
4683
  isReady,
2979
- mode
4684
+ mode,
4685
+ chainColors
2980
4686
  ]);
2981
4687
  }
2982
4688
  //#endregion
4689
+ //#region src/core/ProteinStructureViewer/ProteinStructureViewer.types.ts
4690
+ /** Pixel dimensions each resolution produces, for documentation and labels. */
4691
+ const DOWNLOAD_RESOLUTIONS = {
4692
+ high: "3840x2160",
4693
+ low: "1280x720",
4694
+ maximum: "7680x4320",
4695
+ medium: "1920x1080"
4696
+ };
4697
+ //#endregion
2983
4698
  //#region src/core/ProteinStructureViewer/index.tsx
2984
4699
  const _excluded$1 = [
2985
4700
  "backgroundColor",
4701
+ "chainColors",
4702
+ "disableChainHighlightOnHover",
4703
+ "download",
4704
+ "hiddenChains",
4705
+ "onChainVisibilityChange",
4706
+ "onChainsChange",
4707
+ "molstarSpec",
2986
4708
  "onResidueClick",
2987
4709
  "onResidueHover",
2988
- "onSelectionClear",
4710
+ "onSelectionChange",
2989
4711
  "pdb",
2990
4712
  "plddt",
2991
4713
  "residueOverlay",
2992
- "selectedResidue",
4714
+ "selection",
2993
4715
  "sequenceViewerBackgroundColor",
2994
4716
  "showAxes",
4717
+ "showChainLegend",
2995
4718
  "showLegend",
2996
4719
  "showSequenceViewer",
2997
4720
  "stats"
@@ -3030,6 +4753,7 @@ function resolveScaleProps(overlay, hasPlddt) {
3030
4753
  scaleMax: overlay.max,
3031
4754
  scaleMin: (_overlay$min = overlay.min) !== null && _overlay$min !== void 0 ? _overlay$min : 0,
3032
4755
  scaleTooltip: overlay.tooltip,
4756
+ scaleTooltipProps: overlay.tooltipProps,
3033
4757
  valueLabel: (_overlay$readoutLabel = overlay.readoutLabel) !== null && _overlay$readoutLabel !== void 0 ? _overlay$readoutLabel : DEFAULT_READOUT_LABEL
3034
4758
  };
3035
4759
  }
@@ -3038,6 +4762,7 @@ function resolveScaleProps(overlay, hasPlddt) {
3038
4762
  scaleLabel: PLDDT_SCALE_LABEL,
3039
4763
  scaleMax: null,
3040
4764
  scaleTooltip: void 0,
4765
+ scaleTooltipProps: void 0,
3041
4766
  valueLabel: void 0
3042
4767
  };
3043
4768
  return {
@@ -3045,68 +4770,126 @@ function resolveScaleProps(overlay, hasPlddt) {
3045
4770
  scaleLabel: void 0,
3046
4771
  scaleMax: null,
3047
4772
  scaleTooltip: void 0,
4773
+ scaleTooltipProps: void 0,
3048
4774
  valueLabel: void 0
3049
4775
  };
3050
4776
  }
3051
4777
  var ProteinStructureViewer_default = memo(forwardRef((props, ref) => {
3052
- const { backgroundColor, onResidueClick, onResidueHover, onSelectionClear, pdb, plddt, residueOverlay, selectedResidue = null, sequenceViewerBackgroundColor, showAxes = true, showLegend = true, showSequenceViewer = true, stats } = props, rest = _objectWithoutProperties(props, _excluded$1);
4778
+ var _selection$chains;
4779
+ const { backgroundColor, chainColors: chainColorOverrides, disableChainHighlightOnHover = false, download, hiddenChains: hiddenChainsProp, onChainVisibilityChange, onChainsChange, molstarSpec, onResidueClick, onResidueHover, onSelectionChange, pdb, plddt, residueOverlay, selection = null, sequenceViewerBackgroundColor, showAxes = true, showChainLegend = true, showLegend = true, showSequenceViewer = true, stats } = props, rest = _objectWithoutProperties(props, _excluded$1);
3053
4780
  const theme = useTheme();
3054
4781
  const mode = getMode({ theme });
3055
4782
  const semanticColors = getSemanticColors({ theme });
3056
4783
  const pluginMountRef = useRef(null);
3057
- const [hoveredResidue, setHoveredResidue] = useState(null);
3058
- /**
3059
- * Label for the selected residue, read back off the structure rather than
3060
- * remembered from the click that selected it, so the readout follows the
3061
- * prop however the selection was made.
3062
- */
3063
- const [selectedLabel, setSelectedLabel] = useState(null);
3064
4784
  const hasPlddt = Boolean(plddt && plddt.length > 0);
3065
4785
  const processedPdb = useMemo(() => hasPlddt ? injectPlddtIntoPdb(pdb, plddt) : pdb, [
3066
4786
  pdb,
3067
4787
  plddt,
3068
4788
  hasPlddt
3069
4789
  ]);
3070
- const { isReady, pluginRef, residueValueThemeRef, setClipRatio } = useMolstarPlugin({
3071
- backgroundColor: useMemo(() => {
3072
- var _semanticColors$base;
3073
- return themeColor(backgroundColor !== null && backgroundColor !== void 0 ? backgroundColor : semanticColors === null || semanticColors === void 0 || (_semanticColors$base = semanticColors.base) === null || _semanticColors$base === void 0 ? void 0 : _semanticColors$base.backgroundPrimary, FALLBACK_BACKGROUND_COLOR[mode]);
3074
- }, [
3075
- backgroundColor,
3076
- mode,
3077
- semanticColors
3078
- ]),
4790
+ const bgColor = useMemo(() => {
4791
+ var _semanticColors$base;
4792
+ return themeColor(backgroundColor !== null && backgroundColor !== void 0 ? backgroundColor : semanticColors === null || semanticColors === void 0 || (_semanticColors$base = semanticColors.base) === null || _semanticColors$base === void 0 ? void 0 : _semanticColors$base.backgroundPrimary, FALLBACK_BACKGROUND_COLOR[mode]);
4793
+ }, [
4794
+ backgroundColor,
4795
+ mode,
4796
+ semanticColors
4797
+ ]);
4798
+ const edgeColor = useMemo(() => {
4799
+ var _semanticColors$base2;
4800
+ return themeColor(semanticColors === null || semanticColors === void 0 || (_semanticColors$base2 = semanticColors.base) === null || _semanticColors$base2 === void 0 ? void 0 : _semanticColors$base2.borderPrimary, FALLBACK_EDGE_COLOR);
4801
+ }, [semanticColors]);
4802
+ const highlightColor = useMemo(() => {
4803
+ var _semanticColors$base3;
4804
+ return themeColor(semanticColors === null || semanticColors === void 0 || (_semanticColors$base3 = semanticColors.base) === null || _semanticColors$base3 === void 0 ? void 0 : _semanticColors$base3.fillSelected, FALLBACK_HIGHLIGHT_COLOR);
4805
+ }, [semanticColors]);
4806
+ const { handleResidueHover, hoveredResidue } = useResidueHoverState(onResidueHover);
4807
+ const handleSelectionClear = useCallback(() => {
4808
+ onSelectionChange === null || onSelectionChange === void 0 || onSelectionChange(null);
4809
+ }, [onSelectionChange]);
4810
+ /**
4811
+ * Chains the selection covers whole, which is what makes a chain caption a
4812
+ * toggle rather than a one-way switch. Keyed on its contents because the
4813
+ * set is pushed into the views Mol* renders, where a fresh object every
4814
+ * render would be a fresh push every render.
4815
+ */
4816
+ const selectedChainsKey = [...(_selection$chains = selection === null || selection === void 0 ? void 0 : selection.chains) !== null && _selection$chains !== void 0 ? _selection$chains : []].sort().join("\0");
4817
+ const selectedChains = useMemo(() => new Set(selectedChainsKey === "" ? [] : selectedChainsKey.split("\0")), [selectedChainsKey]);
4818
+ /**
4819
+ * Clicking a chain selects it; clicking the selected chain again clears it.
4820
+ *
4821
+ * A whole chain is reported as the chain it is, not as the hundreds of
4822
+ * residue indices it stands for, so echoing it back costs nothing. Only a
4823
+ * selection that is exactly this one chain toggles off - with a residue or
4824
+ * another chain also selected, the click is narrowing to this chain rather
4825
+ * than undoing itself.
4826
+ */
4827
+ const handleChainSelect = useCallback((chainId) => {
4828
+ var _selection$chains2, _selection$residues;
4829
+ const isOnlyThisChain = (selection === null || selection === void 0 || (_selection$chains2 = selection.chains) === null || _selection$chains2 === void 0 ? void 0 : _selection$chains2.length) === 1 && selection.chains[0] === chainId && !((_selection$residues = selection.residues) === null || _selection$residues === void 0 ? void 0 : _selection$residues.length);
4830
+ onSelectionChange === null || onSelectionChange === void 0 || onSelectionChange(isOnlyThisChain ? null : { chains: [chainId] });
4831
+ }, [onSelectionChange, selection]);
4832
+ /**
4833
+ * Lights a chain up while its name is pointed at, in the legend or in the
4834
+ * sequence panel's captions.
4835
+ *
4836
+ * Reached through a ref because the two ends need each other: lighting a
4837
+ * chain up takes the plugin, which the hook below creates, and that hook
4838
+ * has to be handed the callback to give the captions it renders. This one
4839
+ * is stable and forwards to whatever the highlight hook installs once
4840
+ * there is a plugin for it to talk to.
4841
+ */
4842
+ const highlightChainRef = useRef(() => void 0);
4843
+ const highlightChain = useCallback((chainId) => {
4844
+ highlightChainRef.current(chainId);
4845
+ }, []);
4846
+ const [chains, setChains] = useState([]);
4847
+ const { colors: chainColors, hidden: hiddenChains, toggleChain } = useChains({
4848
+ chainColors: chainColorOverrides,
4849
+ chains,
4850
+ hiddenChains: hiddenChainsProp,
4851
+ onChainVisibilityChange
4852
+ });
4853
+ const { chainColorThemeRef, chains: loadedChains, isReady, pluginRef, residuesByChainRef, residueValueThemeRef, setClipRatio } = useMolstarPlugin({
4854
+ backgroundColor: bgColor,
4855
+ chainColors,
3079
4856
  containerRef: pluginMountRef,
3080
- edgeColor: useMemo(() => {
3081
- var _semanticColors$base2;
3082
- return themeColor(semanticColors === null || semanticColors === void 0 || (_semanticColors$base2 = semanticColors.base) === null || _semanticColors$base2 === void 0 ? void 0 : _semanticColors$base2.borderPrimary, FALLBACK_EDGE_COLOR);
3083
- }, [semanticColors]),
4857
+ download,
4858
+ edgeColor,
3084
4859
  hasPlddt,
3085
- highlightColor: useMemo(() => {
3086
- var _semanticColors$base3;
3087
- return themeColor(semanticColors === null || semanticColors === void 0 || (_semanticColors$base3 = semanticColors.base) === null || _semanticColors$base3 === void 0 ? void 0 : _semanticColors$base3.fillSelected, FALLBACK_HIGHLIGHT_COLOR);
3088
- }, [semanticColors]),
4860
+ hiddenChains,
4861
+ highlightColor,
3089
4862
  mode,
4863
+ molstarSpec,
4864
+ onChainHover: highlightChain,
4865
+ onChainSelect: handleChainSelect,
4866
+ onChainToggle: toggleChain,
3090
4867
  onResidueClick,
3091
- onResidueHover: useCallback((residue) => {
3092
- setHoveredResidue((prev) => {
3093
- if (residue === null) return prev === null ? prev : null;
3094
- const label = residueLabel(residue);
3095
- if (prev !== null && prev.index === residue.index && prev.label === label) return prev;
3096
- return {
3097
- index: residue.index,
3098
- label
3099
- };
3100
- });
3101
- onResidueHover === null || onResidueHover === void 0 || onResidueHover(residue);
3102
- }, [onResidueHover]),
3103
- onSelectionClear,
4868
+ onResidueHover: handleResidueHover,
4869
+ onSelectionChange,
4870
+ onSelectionClear: handleSelectionClear,
3104
4871
  pdb: processedPdb,
4872
+ selectedChains,
3105
4873
  sequenceViewerBackgroundColor,
3106
4874
  showAxes,
3107
4875
  showSequenceViewer
3108
4876
  });
3109
- useResidueOverlay({
4877
+ highlightChainRef.current = useChainHighlight({
4878
+ disabled: disableChainHighlightOnHover,
4879
+ pluginRef
4880
+ });
4881
+ useEffect(() => {
4882
+ setChains(loadedChains);
4883
+ }, [loadedChains]);
4884
+ const onChainsChangeRef = useRef(onChainsChange);
4885
+ onChainsChangeRef.current = onChainsChange;
4886
+ useEffect(() => {
4887
+ var _onChainsChangeRef$cu;
4888
+ (_onChainsChangeRef$cu = onChainsChangeRef.current) === null || _onChainsChangeRef$cu === void 0 || _onChainsChangeRef$cu.call(onChainsChangeRef, loadedChains);
4889
+ }, [loadedChains]);
4890
+ useStructureColoring({
4891
+ chainColorThemeRef,
4892
+ chainColors,
3110
4893
  hasPlddt,
3111
4894
  isReady,
3112
4895
  mode,
@@ -3114,46 +4897,49 @@ var ProteinStructureViewer_default = memo(forwardRef((props, ref) => {
3114
4897
  pluginRef,
3115
4898
  residueValueThemeRef
3116
4899
  });
3117
- useResidueFocus({
4900
+ useSelectionFocus({
4901
+ hiddenChains,
3118
4902
  isReady,
3119
4903
  pluginRef,
3120
- selectedResidue,
4904
+ selection,
3121
4905
  setClipRatio
3122
4906
  });
3123
- useEffect(() => {
3124
- const plugin = pluginRef.current;
3125
- if (!plugin || !isReady || selectedResidue === null) {
3126
- setSelectedLabel(null);
3127
- return;
3128
- }
3129
- const loci = lociForResidueIndex(plugin, selectedResidue);
3130
- const residue = loci && residueRefFromLoci(loci);
3131
- setSelectedLabel(residue ? residueLabel(residue) : null);
3132
- }, [
4907
+ const { hoveredReadout, selectedReadout } = useSelectionReadout({
4908
+ hoveredResidue,
3133
4909
  isReady,
4910
+ plddt,
3134
4911
  pluginRef,
3135
- selectedResidue
3136
- ]);
3137
- const buildReadout = useCallback((label, index) => {
3138
- var _plddt$index, _residueOverlay$value;
3139
- return {
3140
- label,
3141
- plddt: (_plddt$index = plddt === null || plddt === void 0 ? void 0 : plddt[index]) !== null && _plddt$index !== void 0 ? _plddt$index : null,
3142
- value: (_residueOverlay$value = residueOverlay === null || residueOverlay === void 0 ? void 0 : residueOverlay.values.get(index)) !== null && _residueOverlay$value !== void 0 ? _residueOverlay$value : null
3143
- };
3144
- }, [plddt, residueOverlay]);
3145
- const hoveredReadout = useMemo(() => hoveredResidue === null ? null : buildReadout(hoveredResidue.label, hoveredResidue.index), [hoveredResidue, buildReadout]);
3146
- const selectedReadout = useMemo(() => selectedResidue === null || selectedLabel === null ? null : buildReadout(selectedLabel, selectedResidue), [
3147
- selectedResidue,
3148
- selectedLabel,
3149
- buildReadout
3150
- ]);
4912
+ residueOverlay,
4913
+ residuesByChainRef,
4914
+ selection
4915
+ });
3151
4916
  const scaleProps = useMemo(() => resolveScaleProps(residueOverlay, hasPlddt), [residueOverlay, hasPlddt]);
4917
+ /**
4918
+ * What a chain's swatch can show follows whatever is painting the
4919
+ * structure, in the same order the color key resolves in.
4920
+ *
4921
+ * Chain coloring gives each chain one color of its own. pLDDT gives every
4922
+ * chain the same four bands, which says they are colored by confidence
4923
+ * rather than telling them apart - so the swatch carries the whole key,
4924
+ * quartered. An overlay is continuous and has no discrete colors to put in
4925
+ * a swatch at all, so the rows keep their labels and toggles and the color
4926
+ * key beside them does the describing.
4927
+ */
4928
+ const chainColoringActive = !residueOverlay && !hasPlddt;
4929
+ const plddtColoringActive = !residueOverlay && hasPlddt;
3152
4930
  return /* @__PURE__ */ jsxs(ViewerRoot, _objectSpread2(_objectSpread2({
3153
4931
  ref,
3154
4932
  showSequenceViewer
3155
4933
  }, rest), {}, { children: [/* @__PURE__ */ jsx(PluginMount, { ref: pluginMountRef }), showLegend && /* @__PURE__ */ jsx(StructureLegend, _objectSpread2({
4934
+ chainBandColors: plddtColoringActive ? PLDDT_BAND_COLORS : void 0,
4935
+ chainColors: chainColoringActive ? chainColors : void 0,
4936
+ chains: showChainLegend ? chains : [],
4937
+ hiddenChains,
3156
4938
  hoveredResidue: hoveredReadout,
4939
+ onChainHover: highlightChain,
4940
+ onChainSelect: handleChainSelect,
4941
+ onChainToggle: toggleChain,
4942
+ selectedChains,
3157
4943
  selectedResidue: selectedReadout,
3158
4944
  showSequenceViewer,
3159
4945
  stats: stats !== null && stats !== void 0 ? stats : []
@@ -3635,4 +5421,4 @@ const StackedBarChart = (props) => {
3635
5421
  return /* @__PURE__ */ jsxs(ChartWrapper, _objectSpread2(_objectSpread2({ width }, rest), {}, { children: [/* @__PURE__ */ jsxs(TitleContainer, { children: [/* @__PURE__ */ jsx(ChartTitle, { children: title }), !hideBadge && displayBadge && /* @__PURE__ */ jsx(StyledBadge, { children: displayBadge })] }), chartContent] }));
3636
5422
  };
3637
5423
  //#endregion
3638
- export { HeatmapChart_default as HeatmapChart, PLASMA_COLOR_SCALE, PLDDT_COLOR_SCALE, ProteinStructureViewer_default as ProteinStructureViewer, StackedBarChart, injectPlddtIntoPdb, sampleColorScale, toCssGradient };
5424
+ export { DOWNLOAD_RESOLUTIONS, HeatmapChart_default as HeatmapChart, PLASMA_COLOR_SCALE, PLDDT_COLOR_SCALE, ProteinStructureViewer_default as ProteinStructureViewer, StackedBarChart, injectPlddtIntoPdb, sampleColorScale, toCssGradient };