dsh-molbio-tools 0.22.2 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +173 -0
  2. package/README.md +51 -19
  3. package/benchmark/README.md +7 -7
  4. package/benchmark/fixtures.mjs +32 -4
  5. package/benchmark/run.mjs +1 -0
  6. package/benchmark/sequences.mjs +1 -0
  7. package/benchmark/tasks/02-primers.json +3 -3
  8. package/benchmark/tasks/06-analysis.json +111 -1
  9. package/benchmark/tasks/09-formats.json +44 -1
  10. package/benchmark/verifications.mjs +13 -0
  11. package/build/client-entry.mjs +80 -1
  12. package/build/panel-core.mjs +85 -0
  13. package/build/release-preflight.mjs +1 -1
  14. package/contamination.mjs +229 -0
  15. package/cordis.patch.yml +9 -5
  16. package/design.mjs +111 -78
  17. package/docs/capability-gap-survey.md +5 -0
  18. package/docs/client-panel.md +9 -7
  19. package/docs/history.md +62 -0
  20. package/docs/maintainer.md +8 -5
  21. package/docs/roadmap.md +33 -15
  22. package/docs/rules.md +5 -2
  23. package/docs/store-evidence.md +30 -23
  24. package/docs/store-preset-exemption-issue.md +1 -1
  25. package/docs/store-preset-exemption.md +1 -1
  26. package/docs/v24-plan.md +178 -0
  27. package/docs/workflow.md +11 -10
  28. package/dotplot.mjs +257 -0
  29. package/fastq-qc.mjs +6 -9
  30. package/fastq-trim.mjs +238 -0
  31. package/index.mjs +470 -24
  32. package/lib/client.js +327 -93
  33. package/package.json +8 -3
  34. package/preset/molbio-lab/agent.cordis.yml +19 -13
  35. package/preset/molbio-lab/preset.yml +1 -1
  36. package/protein-charge.mjs +158 -0
  37. package/protein.mjs +36 -14
  38. package/seqio.mjs +10 -0
  39. package/test/client.mjs +12 -5
  40. package/test/contract.mjs +44 -12
  41. package/test/drift-probe.mjs +15 -1
  42. package/test/map-card.mjs +139 -1
  43. package/test/preset-health.mjs +39 -0
  44. package/test/smoke.mjs +288 -11
package/lib/client.js CHANGED
@@ -4964,6 +4964,16 @@ window.__ModuleLoader__.load({
4964
4964
  return entries.map((entry) => `>${entry.id}${entry.description !== '' ? ' ' + entry.description : ''}\n${entry.sequence}`).join('\n') + '\n';
4965
4965
  }
4966
4966
 
4967
+ /** Render entries as four-line FASTQ text (quality is preserved verbatim). */
4968
+ function toFastq(entries) {
4969
+ return entries.map((entry) => {
4970
+ if (typeof entry.quality !== 'string' || entry.quality.length !== entry.sequence.length) {
4971
+ throw new MolbioInputError(`entry ${entry.id}: sequence and quality lengths differ`);
4972
+ }
4973
+ return `@${entry.id}${entry.description !== undefined && entry.description !== '' ? ' ' + entry.description : ''}\n${entry.sequence}\n+\n${entry.quality}`;
4974
+ }).join('\n') + '\n';
4975
+ }
4976
+
4967
4977
  /** Summary statistics over parsed entries. */
4968
4978
  function entryStats(entries, qualityMode = false) {
4969
4979
  const lengths = entries.map((entry) => entry.sequence.length);
@@ -5008,6 +5018,7 @@ window.__ModuleLoader__.load({
5008
5018
  exports.parseFasta = parseFasta;
5009
5019
  exports.parseFastq = parseFastq;
5010
5020
  exports.toFasta = toFasta;
5021
+ exports.toFastq = toFastq;
5011
5022
  exports.entryStats = entryStats;
5012
5023
  return exports;
5013
5024
  };
@@ -5051,17 +5062,17 @@ window.__ModuleLoader__.load({
5051
5062
  * from `anchor` and not from the RC-scan `start`. It is the REVERSE primer,
5052
5063
  * and the amplicon ends at `anchor + length - 1`.
5053
5064
  *
5054
- * NOTE — `designIntronSpanningPrimers` (tool: `molbio_design_intron_primers`)
5055
- * deliberately does NOT follow this convention and is unchanged in 0.16.0: it
5056
- * reports both primers as sense substrings of the spliced transcript, which is
5057
- * what makes its `exon`/`junction_left`/`junction_right` geometry, its
5058
- * spliced-vs-genomic mismatch report and its 3'-tail mispriming check read
5059
- * uniformly on one sequence. Both ends are therefore reported 5'->3' on the
5060
- * sense strand, `forward` is downstream of `reverse`, and the molecule to order
5061
- * as the reverse primer is the reverse complement of the reported `reverse`.
5062
- * That is a real usability wart, not a silent one (nothing feeds this pair to
5063
- * `molbio_pcr_simulate`), so it is recorded on the roadmap rather than changed
5064
- * in the same release that moves `designPrimerPairs`.
5065
+ * `designIntronSpanningPrimers` (tool: `molbio_design_intron_primers`)
5066
+ * follows the SAME convention since 0.23.0. The junction is carried by the
5067
+ * REVERSE primer (the assay sits 5' of the junction), so the reverse candidate
5068
+ * is scanned on the reverse complement of the spliced transcript: every 3'-end
5069
+ * constraint is then evaluated on the molecule that is ordered, and the hit is
5070
+ * mapped back to sense coordinates through `anchor` (its 3' end, the LEFT edge
5071
+ * of its site). `forward` is upstream of `reverse`, the spliced amplicon runs
5072
+ * from the forward 5' end to the reverse 5' end, and the reported reverse
5073
+ * sequence is what goes on the order form. Before 0.23.0 the tool reported both
5074
+ * primers as sense substrings, so `forward` sat downstream of `reverse` and the
5075
+ * reported reverse was the reverse complement of the molecule to order.
5065
5076
  */
5066
5077
 
5067
5078
  const { DNA_BASES, MolbioInputError, baseCounts, complement, dimerThermo, endGcCount5, endStability5, findRuns, hairpinThermo, normalizeSequence, primerTm, reverseComplement, selfAnyScore, selfEndScore } = __molbio_require("lib.mjs");
@@ -5737,37 +5748,60 @@ window.__ModuleLoader__.load({
5737
5748
  // ── cross-intron primer design ──────────────────────────────────────────────
5738
5749
 
5739
5750
  /**
5740
- * Mismatch report for cross-intron candidates. Both primer scans run on the
5741
- * spliced transcript in the sense orientation (the reported reverse.sequence
5742
- * follows the established sense-substring convention of this tool), so the
5743
- * mapping is uniform: `template_base` is what a perfectly matching primer
5744
- * carries at the position on the spliced transcript, and coordinates come back
5745
- * both spliced and genomic.
5751
+ * Mismatch report for cross-intron candidates. The forward primer is the
5752
+ * sense-strand substring at its binding site; the reverse primer is the REVERSE
5753
+ * COMPLEMENT of the sense-strand substring at its site (the molecule that is
5754
+ * ordered). `anchorStart` is the 0-based sense-strand position of the primer's
5755
+ * 3' end — for the reverse primer that is the LEFT edge of its site — and
5756
+ * `isReverse` flips both the perfect-match base and how a primer offset maps
5757
+ * onto the transcript.
5746
5758
  */
5747
- function intronMismatchReport(candidate, spliced, splicedToGenomic) {
5759
+ function intronMismatchReport(candidate, spliced, splicedToGenomic, anchorStart, isReverse) {
5748
5760
  const len = candidate.length;
5749
- return candidate.mismatches.map(({ p, base }) => ({
5750
- position: p + 1,
5751
- template_base: spliced[candidate.start + p],
5752
- primer_base: base,
5753
- spliced_position: candidate.start + p + 1,
5754
- genomic_position: splicedToGenomic[candidate.start + p] + 1,
5755
- distance_from_3prime: len - 1 - p,
5756
- }));
5761
+ return candidate.mismatches.map(({ p, base }) => {
5762
+ const offset = isReverse ? len - 1 - p : p;
5763
+ const index = anchorStart + offset;
5764
+ return {
5765
+ position: p + 1,
5766
+ template_base: isReverse ? complement(spliced[index]) : spliced[index],
5767
+ primer_base: base,
5768
+ spliced_position: index + 1,
5769
+ genomic_position: splicedToGenomic[index] + 1,
5770
+ distance_from_3prime: len - 1 - p,
5771
+ };
5772
+ });
5757
5773
  }
5758
5774
 
5759
5775
  /**
5760
- * Design qPCR primer pairs where the forward primer spans an exon-exon
5761
- * junction (>= min_junction_bases on each side) so genomic DNA cannot be
5762
- * amplified, and the reverse primer sits in a different exon. Coordinates are
5763
- * reported both on the spliced transcript and on the genomic sequence.
5776
+ * The same weights as `mismatchPenalty`, over the snake_case report fields.
5777
+ * The old intron path handed snake_case reports to the camelCase helper, so the
5778
+ * 3'-zone surcharge never applied there; this keeps the two conventions apart on
5779
+ * purpose instead of loosening the shared helper.
5780
+ */
5781
+ function intronMismatchPenalty(report, zone) {
5782
+ return report.reduce((sum, mismatch) => sum + 8 + (mismatch.distance_from_3prime <= zone ? 4 : 0), 0);
5783
+ }
5784
+
5785
+ /**
5786
+ * Design qPCR primer pairs across an exon-exon junction so genomic DNA cannot
5787
+ * be amplified: the REVERSE primer straddles the junction (>= minJunctionBases
5788
+ * on each side), the FORWARD primer sits upstream inside a single exon, and the
5789
+ * spliced amplicon crosses the junction inside the reverse primer's site.
5790
+ *
5791
+ * Coordinates are reported both on the spliced transcript and on the genomic
5792
+ * sequence. Orientation follows `designPrimerPairs` (see the file header):
5793
+ * `forward.sequence` is the sense-strand substring at its site and
5794
+ * `reverse.sequence` is the reverse complement of the sense-strand substring at
5795
+ * its site. The reverse candidate is scanned on the reverse complement of the
5796
+ * spliced transcript, so every 3'-end constraint (GC clamp, end stability, end
5797
+ * GC, self-end, hairpin) applies to the molecule that is ordered.
5764
5798
  */
5765
5799
  function designIntronSpanningPrimers(genomic, exons, opts) {
5766
5800
  if (!Array.isArray(exons) || exons.length < 2) throw new MolbioInputError('exons must be an array of at least two {start, end} spans');
5767
5801
  const sorted = [...exons].sort((a, b) => a.start - b.start);
5768
5802
  for (const exon of sorted) {
5769
5803
  if (!Number.isInteger(exon.start) || !Number.isInteger(exon.end) || exon.start < 1 || exon.end > genomic.length || exon.start > exon.end) {
5770
- throw new MolbioInputError(`exon ${JSON.stringify(exon)} is outside the genomic sequence (length ${genomic.length})`);
5804
+ throw new MolbioInputError('exon ' + JSON.stringify(exon) + ' is outside the genomic sequence (length ' + genomic.length + ')');
5771
5805
  }
5772
5806
  }
5773
5807
  for (let i = 1; i < sorted.length; i++) {
@@ -5790,33 +5824,39 @@ window.__ModuleLoader__.load({
5790
5824
  }
5791
5825
  });
5792
5826
  if (spliced.length < 24) throw new MolbioInputError('the spliced transcript is too short for primer design');
5827
+ const splicedLength = spliced.length;
5828
+ const rcSpliced = reverseComplement(spliced);
5793
5829
 
5794
5830
  // v13 target position preference (coordinates on the spliced transcript).
5795
5831
  const targetPosition = opts.targetPosition;
5796
- if (targetPosition !== undefined && (!Number.isInteger(targetPosition) || targetPosition < 1 || targetPosition > spliced.length)) {
5797
- throw new MolbioInputError(`target_position ${targetPosition} is outside the spliced transcript (length ${spliced.length})`);
5832
+ if (targetPosition !== undefined && (!Number.isInteger(targetPosition) || targetPosition < 1 || targetPosition > splicedLength)) {
5833
+ throw new MolbioInputError('target_position ' + targetPosition + ' is outside the spliced transcript (length ' + splicedLength + ')');
5798
5834
  }
5799
5835
  const targetWeight = opts.targetPenalty ?? DESIGN_DEFAULTS.targetPenalty;
5800
5836
 
5801
- // Forward candidates must span a junction with minSide bases on each side.
5802
- const fwd = [];
5803
- for (const candidate of scanCandidates(spliced, 0, spliced.length - opts.lenMin, opts)) {
5837
+ // Forward candidates: entirely inside one exon on the sense strand.
5838
+ const fwd = scanCandidates(spliced, 0, splicedLength - opts.lenMin, opts)
5839
+ .filter((candidate) => splicedToExon[candidate.start] === splicedToExon[candidate.start + candidate.length - 1])
5840
+ .map((candidate) => ({ ...candidate, exon: splicedToExon[candidate.start] }))
5841
+ .sort((a, b) => a.start - b.start);
5842
+
5843
+ // Reverse candidates: scanned on the reverse complement (the ordered
5844
+ // molecule), then mapped back to the sense-strand 3' end `anchor`.
5845
+ const rev = [];
5846
+ for (const candidate of scanCandidates(rcSpliced, 0, splicedLength - opts.lenMin, opts)) {
5847
+ const anchor = splicedLength - (candidate.start + candidate.length);
5848
+ const topEnd = anchor + candidate.length - 1;
5804
5849
  for (const junction of junctionPositions) {
5805
- const left = junction.splicedPos - candidate.start;
5806
- const right = candidate.start + candidate.length - junction.splicedPos;
5850
+ const left = junction.splicedPos - anchor;
5851
+ const right = topEnd + 1 - junction.splicedPos;
5807
5852
  if (left >= minSide && right >= minSide) {
5808
- fwd.push({ ...candidate, junction, junction_left: left, junction_right: right });
5853
+ rev.push({ ...candidate, anchor, junction, junction_left: left, junction_right: right });
5809
5854
  break;
5810
5855
  }
5811
5856
  }
5812
5857
  }
5813
-
5814
- // Reverse candidates sit entirely inside one exon.
5815
- const rev = scanCandidates(spliced, 0, spliced.length - opts.lenMin, opts)
5816
- .filter((candidate) => splicedToExon[candidate.start] === splicedToExon[candidate.start + candidate.length - 1])
5817
- .map((candidate) => ({ ...candidate, exon: splicedToExon[candidate.start] }))
5818
- .sort((a, b) => a.start - b.start);
5819
- const revStarts = rev.map((candidate) => candidate.start);
5858
+ rev.sort((a, b) => a.anchor - b.anchor);
5859
+ const revAnchors = rev.map((candidate) => candidate.anchor);
5820
5860
 
5821
5861
  const tmCenter = (opts.tmMin + opts.tmMax) / 2;
5822
5862
  const misprimingIndex = opts.checkMispriming
@@ -5825,48 +5865,52 @@ window.__ModuleLoader__.load({
5825
5865
  const maxSites = opts.misprimingMaxSites ?? DESIGN_DEFAULTS.misprimingMaxSites;
5826
5866
  const pairs = [];
5827
5867
  for (const f of fwd) {
5828
- const fwdEnd = f.start + f.length - 1;
5829
- const aMin = fwdEnd - opts.ampliconMax + 1;
5830
- const aMax = fwdEnd - opts.ampliconMin + 1;
5868
+ // The reverse site must start after the forward 3' end, and the amplicon
5869
+ // (forward 5' -> reverse 5') must fit the window. The bounds are widened by
5870
+ // the primer-length range and the exact length is checked below, because a
5871
+ // long reverse primer can start earlier and still close inside the window.
5872
+ const aMin = Math.max(f.start + f.length, f.start + opts.ampliconMin - opts.lenMax);
5873
+ const aMax = f.start + opts.ampliconMax - opts.lenMin;
5874
+ if (aMax < aMin) continue;
5831
5875
  let lo = 0;
5832
- let hi = revStarts.length;
5876
+ let hi = revAnchors.length;
5833
5877
  while (lo < hi) {
5834
5878
  const mid = (lo + hi) >> 1;
5835
- if (revStarts[mid] < aMin) lo = mid + 1;
5879
+ if (revAnchors[mid] < aMin) lo = mid + 1;
5836
5880
  else hi = mid;
5837
5881
  }
5838
5882
  for (let i = lo; i < rev.length; i++) {
5839
5883
  const r = rev[i];
5840
- if (r.start > aMax) break;
5841
- if (r.exon === f.junction.down) continue; // reverse primer must sit in a different exon
5842
- const gEnd = splicedToGenomic[fwdEnd];
5843
- const gStart = splicedToGenomic[r.start];
5884
+ if (r.anchor > aMax) break;
5885
+ const ampliconStart = f.start;
5886
+ const ampliconEnd = r.anchor + r.length - 1;
5887
+ const ampliconLength = ampliconEnd - ampliconStart + 1;
5888
+ if (ampliconLength < opts.ampliconMin || ampliconLength > opts.ampliconMax) continue;
5889
+ const gStart = splicedToGenomic[ampliconStart];
5890
+ const gEnd = splicedToGenomic[ampliconEnd];
5844
5891
  if (gEnd - gStart + 1 < minGenomicSpan) continue;
5845
5892
  if (Math.abs(f.tm - r.tm) > opts.maxTmDelta) continue;
5846
5893
  const dimer = dimerThermo(f.sequence, r.sequence, opts.ctMolar ?? DEFAULT_CT_MOLAR);
5847
5894
  if (dimer.any_tm > opts.maxDimerTm || dimer.end_tm > opts.maxDimerEndTm) continue;
5895
+ const misK = opts.mispriming3PrimeBases ?? DESIGN_DEFAULTS.mispriming3PrimeBases;
5848
5896
  let fMispriming = { count: 0, sites: [] };
5849
5897
  let rMispriming = { count: 0, sites: [] };
5850
5898
  if (misprimingIndex !== undefined) {
5851
- const misK = opts.mispriming3PrimeBases ?? DESIGN_DEFAULTS.mispriming3PrimeBases;
5852
- fMispriming = misprimingForCandidate(f, f.sequence.slice(-misK), misprimingIndex, opts, f.start);
5899
+ // Exclude each primer's own 3'-tail window (not its 5' end).
5900
+ fMispriming = misprimingForCandidate(f, f.sequence.slice(-misK), misprimingIndex, opts, f.start + f.length - misK);
5853
5901
  if (fMispriming.count > maxSites) continue;
5854
- // the reported reverse sequence follows the sense-substring convention:
5855
- // the real oligo is its reverse complement, so its 3' tail is the RC
5856
- // of the FIRST k bases and the intended site is r.start
5857
- const rTail = reverseComplement(r.sequence.slice(0, misK));
5858
- rMispriming = misprimingForCandidate(r, rTail, misprimingIndex, opts, r.start);
5902
+ rMispriming = misprimingForCandidate(r, r.sequence.slice(-misK), misprimingIndex, opts, r.anchor);
5859
5903
  if (rMispriming.count > maxSites) continue;
5860
5904
  }
5861
- const fMismatches = intronMismatchReport(f, spliced, splicedToGenomic);
5862
- const rMismatches = intronMismatchReport(r, spliced, splicedToGenomic);
5905
+ const fMismatches = intronMismatchReport(f, spliced, splicedToGenomic, f.start, false);
5906
+ const rMismatches = intronMismatchReport(r, spliced, splicedToGenomic, r.anchor, true);
5863
5907
  const zone = opts.mismatch3PrimeZone ?? DESIGN_DEFAULTS.mismatch3PrimeZone;
5864
5908
  let fTargetDistance;
5865
5909
  let rTargetDistance;
5866
5910
  let targetDistance;
5867
5911
  if (targetPosition !== undefined) {
5868
- fTargetDistance = Math.abs(f.start + f.length - targetPosition); // spliced 1-based 3' end
5869
- rTargetDistance = Math.abs(r.start + 1 - targetPosition);
5912
+ fTargetDistance = Math.abs(f.start + f.length - targetPosition); // forward 3' end (1-based)
5913
+ rTargetDistance = Math.abs(r.anchor + 1 - targetPosition); // reverse 3' end (1-based)
5870
5914
  targetDistance = Math.min(fTargetDistance, rTargetDistance);
5871
5915
  }
5872
5916
  const penalty = 0.6 * Math.abs(f.tm - r.tm) + Math.abs(f.tm - tmCenter) + Math.abs(r.tm - tmCenter)
@@ -5874,8 +5918,8 @@ window.__ModuleLoader__.load({
5874
5918
  + 1.0 * Math.max(0, f.self_end - 1) + 1.0 * Math.max(0, r.self_end - 1)
5875
5919
  + 0.2 * Math.max(0, f.hairpin_tm - 40) + 0.2 * Math.max(0, r.hairpin_tm - 40)
5876
5920
  + 0.2 * Math.max(0, dimer.any_tm - 40) + 0.2 * Math.max(0, dimer.end_tm - 40)
5877
- + mismatchPenalty(fMismatches, zone)
5878
- + mismatchPenalty(rMismatches, zone)
5921
+ + intronMismatchPenalty(fMismatches, zone)
5922
+ + intronMismatchPenalty(rMismatches, zone)
5879
5923
  + 8 * fMispriming.count + 8 * rMispriming.count
5880
5924
  + (targetDistance !== undefined ? targetWeight * targetDistance : 0);
5881
5925
  pairs.push({
@@ -5893,9 +5937,7 @@ window.__ModuleLoader__.load({
5893
5937
  spliced_end: f.start + f.length,
5894
5938
  genomic_start: splicedToGenomic[f.start] + 1,
5895
5939
  genomic_end: splicedToGenomic[f.start + f.length - 1] + 1,
5896
- exons: [String(f.junction.up + 1), String(f.junction.down + 1)],
5897
- junction_left: f.junction_left,
5898
- junction_right: f.junction_right,
5940
+ exon: f.exon + 1,
5899
5941
  mismatch_count: fMismatches.length,
5900
5942
  mismatches: fMismatches,
5901
5943
  mispriming_count: fMispriming.count,
@@ -5917,11 +5959,13 @@ window.__ModuleLoader__.load({
5917
5959
  hairpin_tm: r.hairpin_tm,
5918
5960
  end_stability_kcal: r.end_stability_kcal,
5919
5961
  end_gc_count: r.end_gc_count,
5920
- spliced_start: r.start + 1,
5921
- spliced_end: r.start + r.length,
5922
- genomic_start: splicedToGenomic[r.start] + 1,
5923
- genomic_end: splicedToGenomic[r.start + r.length - 1] + 1,
5924
- exon: r.exon + 1,
5962
+ spliced_start: r.anchor + 1,
5963
+ spliced_end: r.anchor + r.length,
5964
+ genomic_start: splicedToGenomic[r.anchor] + 1,
5965
+ genomic_end: splicedToGenomic[r.anchor + r.length - 1] + 1,
5966
+ exons: [String(r.junction.up + 1), String(r.junction.down + 1)],
5967
+ junction_left: r.junction_left,
5968
+ junction_right: r.junction_right,
5925
5969
  mismatch_count: rMismatches.length,
5926
5970
  mismatches: rMismatches,
5927
5971
  mispriming_count: rMispriming.count,
@@ -5933,7 +5977,7 @@ window.__ModuleLoader__.load({
5933
5977
  })),
5934
5978
  ...(rTargetDistance !== undefined ? { target_distance: rTargetDistance } : {}),
5935
5979
  },
5936
- spliced_amplicon: { start: r.start + 1, end: fwdEnd + 1, length: fwdEnd - r.start + 1 },
5980
+ spliced_amplicon: { start: ampliconStart + 1, end: ampliconEnd + 1, length: ampliconLength },
5937
5981
  genomic_amplicon_length: gEnd - gStart + 1,
5938
5982
  ...(targetDistance !== undefined ? { target_distance: targetDistance } : {}),
5939
5983
  penalty: Math.round(penalty * 100) / 100,
@@ -5944,7 +5988,7 @@ window.__ModuleLoader__.load({
5944
5988
  const seen = new Set();
5945
5989
  const unique = [];
5946
5990
  for (const pair of pairs) {
5947
- const key = `${pair.spliced_amplicon.start}:${pair.spliced_amplicon.end}`;
5991
+ const key = pair.spliced_amplicon.start + ':' + pair.spliced_amplicon.end;
5948
5992
  if (seen.has(key)) continue;
5949
5993
  seen.add(key);
5950
5994
  unique.push(pair);
@@ -7093,12 +7137,27 @@ window.__ModuleLoader__.load({
7093
7137
  };
7094
7138
  const WATER = 18.015;
7095
7139
 
7096
- // Bjellqvist 1993 pK values (EMBOSS-style)
7097
- const PK = {
7098
- Nterm: 8.6, Cterm: 3.6, C: 9.0, D: 4.05, E: 4.45, H: 6.04,
7099
- K: 10.8, R: 12.0, Y: 10.3,
7140
+ /**
7141
+ * Selectable pK sets for the Henderson-Hasselbalch charge model. `bjellqvist`
7142
+ * is the set `proteinProperties` has always used for its pI; `emboss` is the
7143
+ * Epk.dat table EMBOSS ships with `iep`. They are NOT interchangeable — the
7144
+ * reported set name travels with every charge/pI value.
7145
+ */
7146
+ const PK_SETS = {
7147
+ bjellqvist: {
7148
+ label: 'Bjellqvist 1993',
7149
+ Nterm: 8.6, Cterm: 3.6, C: 9.0, D: 4.05, E: 4.45, H: 6.04,
7150
+ K: 10.8, R: 12.0, Y: 10.3,
7151
+ },
7152
+ emboss: {
7153
+ label: 'EMBOSS Epk.dat',
7154
+ Nterm: 8.6, Cterm: 3.6, C: 8.5, D: 3.9, E: 4.1, H: 6.5,
7155
+ K: 10.8, R: 12.5, Y: 10.1,
7156
+ },
7100
7157
  };
7101
7158
 
7159
+ const PK = PK_SETS.bjellqvist;
7160
+
7102
7161
  const HYDROPATHY = {
7103
7162
  A: 1.8, R: -4.5, N: -3.5, D: -3.5, C: 2.5, Q: -3.5, E: -3.5,
7104
7163
  G: -0.4, H: -3.2, I: 4.5, L: 3.8, K: -3.9, M: 1.9, F: 2.8,
@@ -7111,30 +7170,37 @@ window.__ModuleLoader__.load({
7111
7170
  return counts;
7112
7171
  }
7113
7172
 
7114
- function chargeAt(seq, counts, pH) {
7173
+ /**
7174
+ * Net charge at a given pH by Henderson-Hasselbalch summation. The terminal
7175
+ * groups count once each; K/R/H are positive, D/E/C/Y negative. `pkaSet` is a
7176
+ * key of PK_SETS (or a set object), so callers can name the table they used.
7177
+ */
7178
+ function netCharge(seq, counts, pH, pkaSet = 'bjellqvist') {
7179
+ const pk = typeof pkaSet === 'string' ? PK_SETS[pkaSet] : pkaSet;
7180
+ if (pk === undefined) throw new MolbioInputError('unknown pK set ' + JSON.stringify(pkaSet) + '; expected one of ' + Object.keys(PK_SETS).join(', '));
7115
7181
  let charge = 0;
7116
7182
  const pos = (pKa) => 1 / (1 + Math.pow(10, pH - pKa));
7117
7183
  const neg = (pKa) => 1 / (1 + Math.pow(10, pKa - pH));
7118
- charge += pos(PK.Nterm);
7119
- for (const [aa, pKa] of [['K', PK.K], ['R', PK.R], ['H', PK.H]]) {
7184
+ charge += pos(pk.Nterm);
7185
+ for (const [aa, pKa] of [['K', pk.K], ['R', pk.R], ['H', pk.H]]) {
7120
7186
  if (counts[aa] !== undefined) charge += counts[aa] * pos(pKa);
7121
7187
  }
7122
- charge -= neg(PK.Cterm);
7123
- for (const [aa, pKa] of [['D', PK.D], ['E', PK.E], ['C', PK.C], ['Y', PK.Y]]) {
7188
+ charge -= neg(pk.Cterm);
7189
+ for (const [aa, pKa] of [['D', pk.D], ['E', pk.E], ['C', pk.C], ['Y', pk.Y]]) {
7124
7190
  if (counts[aa] !== undefined) charge -= counts[aa] * neg(pKa);
7125
7191
  }
7126
7192
  return charge;
7127
7193
  }
7128
7194
 
7129
7195
  /** Isoelectric point by bisection over the net-charge function. */
7130
- function isoelectricPoint(seq, counts) {
7196
+ function isoelectricPoint(seq, counts, pkaSet = 'bjellqvist') {
7131
7197
  let lo = 0;
7132
7198
  let hi = 14;
7133
- let chargeLo = chargeAt(seq, counts, lo);
7134
- let chargeHi = chargeAt(seq, counts, hi);
7199
+ let chargeLo = netCharge(seq, counts, lo, pkaSet);
7200
+ let chargeHi = netCharge(seq, counts, hi, pkaSet);
7135
7201
  for (let i = 0; i < 60; i++) {
7136
7202
  const mid = (lo + hi) / 2;
7137
- const chargeMid = chargeAt(seq, counts, mid);
7203
+ const chargeMid = netCharge(seq, counts, mid, pkaSet);
7138
7204
  if (Math.abs(chargeMid) < 1e-4) return Math.round(mid * 100) / 100;
7139
7205
  if (Math.sign(chargeMid) === Math.sign(chargeLo)) {
7140
7206
  lo = mid;
@@ -7439,6 +7505,9 @@ window.__ModuleLoader__.load({
7439
7505
  }
7440
7506
 
7441
7507
  exports.normalizeProtein = normalizeProtein;
7508
+ exports.PK_SETS = PK_SETS;
7509
+ exports.countAminoAcids = countAminoAcids;
7510
+ exports.netCharge = netCharge;
7442
7511
  exports.isoelectricPoint = isoelectricPoint;
7443
7512
  exports.proteinProperties = proteinProperties;
7444
7513
  exports.peptideDigest = peptideDigest;
@@ -8394,6 +8463,91 @@ window.__ModuleLoader__.load({
8394
8463
  return parts.join(' · ');
8395
8464
  }
8396
8465
 
8466
+ // ── figure cards (the same `tool.call.toolview` seat) ───────────────────────
8467
+
8468
+ /** The meta kind the sequence-logo and gRNA tools attach to their results. */
8469
+ const FIGURE_CARD_KIND = 'molbio-figure';
8470
+
8471
+ const CARD_SUMMARY_MAX = 300;
8472
+ const CARD_PATH_MAX = 500;
8473
+ const CARD_GUIDES_MAX = 5;
8474
+
8475
+ /** A bounded string, or '' when the value is not a string. */
8476
+ function cardText(value, max) {
8477
+ return typeof value === 'string' ? value.slice(0, max) : '';
8478
+ }
8479
+
8480
+ /** One guide row, validated field by field; malformed rows are dropped. */
8481
+ function cardGuide(row) {
8482
+ if (row === null || typeof row !== 'object' || Array.isArray(row)) return undefined;
8483
+ const sequence = cardText(row.sequence, 60);
8484
+ if (sequence === '') return undefined;
8485
+ return {
8486
+ rank: Number.isInteger(row.rank) ? row.rank : 0,
8487
+ sequence,
8488
+ pam: cardText(row.pam, 12),
8489
+ strand: row.strand === 'reverse' ? 'reverse' : 'forward',
8490
+ start: Number.isInteger(row.start) ? row.start : 0,
8491
+ end: Number.isInteger(row.end) ? row.end : 0,
8492
+ score: typeof row.score === 'number' && Number.isFinite(row.score) ? row.score : 0,
8493
+ offTargets: Number.isInteger(row.off_target_count) ? row.off_target_count : 0,
8494
+ };
8495
+ }
8496
+
8497
+ /**
8498
+ * Read a tool-result block's meta into what the figure card draws.
8499
+ *
8500
+ * Like `mapCardView`, the value is a projection this package produced itself,
8501
+ * but the card renders inside the conversation, so every field is checked and
8502
+ * bounded rather than trusted: a block from an older version, another tool, a
8503
+ * running call or a failed run must degrade to a notice, never throw.
8504
+ *
8505
+ * @param {unknown} meta the block's meta, as the client received it.
8506
+ * @returns {{kind: 'notice', message: string, ...} | {kind: 'figure', figure:
8507
+ * 'logo'|'grna', summary: string, svg: string, svgPath: string,
8508
+ * csvPath: string, mapPath: string, guides: Array}}
8509
+ */
8510
+ function figureCardView(meta) {
8511
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
8512
+ return { kind: 'notice', message: 'No figure data was attached to this call.' };
8513
+ }
8514
+ if (meta.kind !== FIGURE_CARD_KIND) {
8515
+ return { kind: 'notice', message: 'This call carries no figure.' };
8516
+ }
8517
+ const figure = meta.figure === 'grna' ? 'grna' : meta.figure === 'logo' ? 'logo' : '';
8518
+ if (figure === '') {
8519
+ return { kind: 'notice', message: 'This call carries an unknown figure type.' };
8520
+ }
8521
+ const base = {
8522
+ figure,
8523
+ summary: cardText(meta.summary, CARD_SUMMARY_MAX),
8524
+ svgPath: cardText(meta.svg_path, CARD_PATH_MAX),
8525
+ csvPath: cardText(meta.csv_path, CARD_PATH_MAX),
8526
+ mapPath: cardText(meta.map_path, CARD_PATH_MAX),
8527
+ guides: Array.isArray(meta.guides)
8528
+ // Bound the work before validating, then keep at most five VALID rows:
8529
+ // malformed entries must not consume the budget and hide good ones.
8530
+ ? meta.guides.slice(0, 50).map(cardGuide).filter((guide) => guide !== undefined).slice(0, CARD_GUIDES_MAX)
8531
+ : [],
8532
+ svg: '',
8533
+ };
8534
+ if (!isSvgMarkup(meta.svg)) {
8535
+ const message = meta.svg_missing === true
8536
+ ? 'No picture was requested for this call; the numbers are in the tool result.'
8537
+ : meta.svg_omitted === true
8538
+ ? `The figure is too large to draw inline (${Math.round((Number.isInteger(meta.svg_bytes) ? meta.svg_bytes : 0) / 1024)} KB); the written file is named below.`
8539
+ : 'The figure markup did not travel with this call.';
8540
+ return { kind: 'notice', message, ...base };
8541
+ }
8542
+ return { kind: 'figure', ...base, svg: meta.svg };
8543
+ }
8544
+
8545
+ /** The header line for a figure card, with a stable fallback. */
8546
+ function figureCardSummary(view) {
8547
+ if (view.summary !== '') return view.summary;
8548
+ return view.figure === 'logo' ? 'Sequence logo' : 'gRNA design';
8549
+ }
8550
+
8397
8551
  // ── literature library (papers.json) ────────────────────────────────────────
8398
8552
 
8399
8553
  /** The library file the molbio_paper_* tools write, relative to the workspace. */
@@ -8571,6 +8725,9 @@ window.__ModuleLoader__.load({
8571
8725
  exports.MAP_CARD_KIND = MAP_CARD_KIND;
8572
8726
  exports.mapCardView = mapCardView;
8573
8727
  exports.mapCardSummary = mapCardSummary;
8728
+ exports.FIGURE_CARD_KIND = FIGURE_CARD_KIND;
8729
+ exports.figureCardView = figureCardView;
8730
+ exports.figureCardSummary = figureCardSummary;
8574
8731
  exports.LIBRARY_FILE = LIBRARY_FILE;
8575
8732
  exports.readWorkspaceText = readWorkspaceText;
8576
8733
  exports.parseLibrary = parseLibrary;
@@ -8619,7 +8776,7 @@ window.__ModuleLoader__.load({
8619
8776
  * inside the factory would also have to be torn down by hand on unload.
8620
8777
  */
8621
8778
  const { createElement: h, useEffect, useMemo, useRef, useState } = __ext0;
8622
- const { LIBRARY_FILE, NCBI_KEY_REF, childPath, classifyEntry, credentialInfo, isMolbioDetailSubject, remoteFailure, decodeWorkspaceBytes, filterPapers, isOpenable, libraryTags, logoSvg, mapCardSummary, mapCardView, paperFields, paperLink, paperSummary, parseAlignmentFile, parseLibrary, parsePlasmidFile, plasmidSvg, readWorkspaceText, sortEntries } = __molbio_require("build/panel-core.mjs");
8779
+ const { LIBRARY_FILE, NCBI_KEY_REF, childPath, classifyEntry, credentialInfo, figureCardSummary, figureCardView, isMolbioDetailSubject, remoteFailure, decodeWorkspaceBytes, filterPapers, isOpenable, libraryTags, logoSvg, mapCardSummary, mapCardView, paperFields, paperLink, paperSummary, parseAlignmentFile, parseLibrary, parsePlasmidFile, plasmidSvg, readWorkspaceText, sortEntries } = __molbio_require("build/panel-core.mjs");
8623
8780
  /** This implementation's identity, and the key its body/title register under. */
8624
8781
  const PANEL_ID = 'dsh-molbio-tools';
8625
8782
  /** Type discriminator the tab is opened by. */
@@ -8858,8 +9015,10 @@ window.__ModuleLoader__.load({
8858
9015
  return 'Papers';
8859
9016
  }
8860
9017
 
8861
- /** The tool names whose transcript card this package draws. */
9018
+ /** The tool names whose transcript card this package draws (plasmid maps). */
8862
9019
  const MAP_TOOL_KEYS = ['molbio_plasmid_map', 'molbio_plasmid_map_file'];
9020
+ /** Figure tools: a drawn logo / gRNA map plus (for gRNA) the ranked list. */
9021
+ const FIGURE_TOOL_KEYS = ['molbio_sequence_logo', 'molbio_grna_design'];
8863
9022
 
8864
9023
  /**
8865
9024
  * The transcript card for the map tools (`tool.call.toolview`, keyed by tool
@@ -8928,6 +9087,72 @@ window.__ModuleLoader__.load({
8928
9087
  return h('div', { style: styles.card }, ...content);
8929
9088
  }
8930
9089
 
9090
+ /**
9091
+ * The transcript card for the sequence-logo and gRNA tools. The logo card draws
9092
+ * the logo; the gRNA card draws the map when `map_path` was requested AND lists
9093
+ * the top-ranked guides (a gRNA call without a map still gets the table). Like
9094
+ * the map card it is a pure function of the block, and everything it reads is
9095
+ * validated and bounded by `figureCardView`.
9096
+ */
9097
+ function MolbioFigureCard({ block, inspect }) {
9098
+ const settled = block !== null && typeof block === 'object' && block.kind === 'tool-result';
9099
+ const view = figureCardView(settled ? block.meta : undefined);
9100
+ const host = useRef(null);
9101
+ const svg = view.kind === 'figure' ? view.svg : '';
9102
+ const [drawError, setDrawError] = useState(null);
9103
+
9104
+ useEffect(() => {
9105
+ const node = host.current;
9106
+ if (node === null) return;
9107
+ node.replaceChildren();
9108
+ if (svg === '') return;
9109
+ const parsed = new DOMParser().parseFromString(svg, 'image/svg+xml');
9110
+ if (parsed.querySelector('parsererror') !== null) {
9111
+ setDrawError('the figure markup could not be parsed as SVG');
9112
+ return;
9113
+ }
9114
+ setDrawError(null);
9115
+ const element = parsed.documentElement;
9116
+ element.setAttribute('style', 'width:100%;height:auto;max-width:640px');
9117
+ node.append(document.importNode(element, true));
9118
+ }, [svg]);
9119
+
9120
+ const content = [h('div', { key: 'head', style: styles.cardHead }, settled ? figureCardSummary(view) : 'Drawing the figure…')];
9121
+ if (view.kind === 'figure') {
9122
+ if (drawError !== null) content.push(h('div', { key: 'drawerr', style: styles.cardNote }, drawError));
9123
+ content.push(h('div', { key: 'svg', ref: host, style: styles.cardSvg }));
9124
+ } else {
9125
+ content.push(h('div', { key: 'note', style: styles.cardNote }, view.message));
9126
+ }
9127
+ if (view.guides.length > 0) {
9128
+ const head = ['#', 'Guide', 'PAM', 'Position', 'Score', 'Off-targets'];
9129
+ const rows = view.guides.map((guide) => h('tr', { key: guide.rank },
9130
+ h('td', { style: styles.td }, String(guide.rank)),
9131
+ h('td', { style: styles.td }, guide.sequence),
9132
+ h('td', { style: styles.td }, guide.pam === '' ? '—' : guide.pam),
9133
+ h('td', { style: styles.td }, `${guide.strand === 'reverse' ? '−' : '+'}${guide.start}-${guide.end}`),
9134
+ h('td', { style: styles.td }, String(guide.score)),
9135
+ h('td', { style: styles.td }, String(guide.offTargets)),
9136
+ ));
9137
+ content.push(h('table', { key: 'guides', style: styles.table },
9138
+ h('thead', null, h('tr', null, ...head.map((label) => h('th', { key: label, style: styles.th }, label)))),
9139
+ h('tbody', null, ...rows)));
9140
+ }
9141
+ if (view.svgPath !== '') content.push(h('div', { key: 'path', style: styles.cardPath }, view.svgPath));
9142
+ if (view.csvPath !== '') content.push(h('div', { key: 'csv', style: styles.cardPath }, view.csvPath));
9143
+ if (settled && block.isError === true) {
9144
+ const text = (Array.isArray(block.content) ? block.content : [])
9145
+ .map((item) => (item !== null && typeof item === 'object' && item.type === 'text' ? String(item.text ?? '') : ''))
9146
+ .join('\n')
9147
+ .trim();
9148
+ content.push(h('div', { key: 'err', style: styles.error }, text === '' ? 'The call failed.' : text));
9149
+ if (typeof inspect === 'function') {
9150
+ content.push(h('button', { key: 'inspect', style: styles.cardButton, onClick: () => inspect() }, 'Inspect'));
9151
+ }
9152
+ }
9153
+ return h('div', { style: styles.card }, ...content);
9154
+ }
9155
+
8931
9156
  /**
8932
9157
  * The literature panel: the workspace `papers.json` the molbio_paper_* tools
8933
9158
  * maintain, rendered as a searchable reading list with a detail pane.
@@ -9276,6 +9501,15 @@ window.__ModuleLoader__.load({
9276
9501
  key: toolName,
9277
9502
  }, MolbioMapCard)), `molbio panel: ${toolName} card`);
9278
9503
  }
9504
+ // Figure cards: the sequence-logo and gRNA tools declare a `molbio-figure`
9505
+ // projection; the same component draws a logo or a gRNA map and, for gRNA,
9506
+ // lists the top-ranked guides even when no map was requested.
9507
+ for (const toolName of FIGURE_TOOL_KEYS) {
9508
+ ctx.effect(() => ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
9509
+ name: 'tool.call.toolview',
9510
+ key: toolName,
9511
+ }, MolbioFigureCard)), `molbio panel: ${toolName} card`);
9512
+ }
9279
9513
  // Plugins -> the installed plugin's detail page: the one place a user can
9280
9514
  // store the NCBI key without editing a YAML file or exporting a variable.
9281
9515
  //