From bf8625fcd5a9eb4d11ee8015f10d8b86ca2c4b02 Mon Sep 17 00:00:00 2001 From: alemastro97 Date: Sun, 9 Jun 2024 19:55:15 +0200 Subject: [PATCH] feat: :sparkles: Implementing Alignment Visualizer --- demo/lib/App.tsx | 14 +- demo/lib/fileAlignment.ts | 133 ++++++ package-lock.json | 290 ++++++++++++- package.json | 2 + src/Alignment/Alignment.tsx | 265 ++++++++++++ src/Alignment/AlignmentStatistics.tsx | 45 ++ src/Alignment/InfiniteScroll.tsx | 310 ++++++++++++++ src/Alignment/SeqBlock.tsx | 509 +++++++++++++++++++++++ src/MultipleEventHandler.tsx | 286 +++++++++++++ src/MultipleSequenceSelectionHandler.tsx | 448 ++++++++++++++++++++ src/SeqViewerContainer.tsx | 229 +++++++--- src/SeqViz.tsx | 10 +- 12 files changed, 2450 insertions(+), 91 deletions(-) create mode 100644 demo/lib/fileAlignment.ts create mode 100644 src/Alignment/Alignment.tsx create mode 100644 src/Alignment/AlignmentStatistics.tsx create mode 100644 src/Alignment/InfiniteScroll.tsx create mode 100644 src/Alignment/SeqBlock.tsx create mode 100644 src/MultipleEventHandler.tsx create mode 100644 src/MultipleSequenceSelectionHandler.tsx diff --git a/demo/lib/App.tsx b/demo/lib/App.tsx index f9013723e..b2a66a826 100644 --- a/demo/lib/App.tsx +++ b/demo/lib/App.tsx @@ -21,12 +21,14 @@ import { chooseRandomColor } from "../../src/colors"; import { AnnotationProp, Primer, TranslationProp } from "../../src/elements"; import Header from "./Header"; import file from "./file"; +import fileAlignment from "./fileAlignment"; const viewerTypeOptions = [ { key: "both", text: "Both", value: "both" }, { key: "circular", text: "Circular", value: "circular" }, { key: "linear", text: "Linear", value: "linear" }, { key: "both_flip", text: "Both Flip", value: "both_flip" }, + { key: "alignment", text: "Alignment", value: "alignment" }, ]; interface AppState { @@ -39,6 +41,7 @@ interface AppState { searchResults: any; selection: any; seq: string; + sequenceToCompare: string; showComplement: boolean; showIndex: boolean; showSelectionMeta: boolean; @@ -92,6 +95,7 @@ export default class App extends React.Component { searchResults: {}, selection: {}, seq: "", + sequenceToCompare: "", showComplement: true, showIndex: true, showSelectionMeta: false, @@ -101,7 +105,7 @@ export default class App extends React.Component { { end: 1147, name: "", start: 736 }, { end: 1885, name: "ORF 2", start: 1165 }, ], - viewer: "both", + viewer: "alignment", zoom: 50, }; linearRef: React.RefObject = React.createRef(); @@ -109,8 +113,9 @@ export default class App extends React.Component { componentDidMount = async () => { const seq = await seqparse(file); + const sequenceToCompare = await seqparse(fileAlignment); - this.setState({ annotations: seq.annotations, name: seq.name, seq: seq.seq }); + this.setState({ annotations: seq.annotations, name: seq.name, seq: seq.seq, sequenceToCompare: sequenceToCompare.seq }); }; toggleSidebar = () => { @@ -261,13 +266,14 @@ export default class App extends React.Component { search={this.state.search} selection={this.state.selection} seq={this.state.seq} + sequenceToCompare={this.state.sequenceToCompare} showComplement={this.state.showComplement} showIndex={this.state.showIndex} translations={this.state.translations} viewer={this.state.viewer as "linear" | "circular"} zoom={{ linear: this.state.zoom }} > - {customChildren} + {/* {customChildren} */} )} @@ -283,7 +289,7 @@ const ViewerTypeInput = ({ setType }: { setType: (viewType: string) => void }) =
Topology 1147 + /gene="hns" + /note="HNS" + /codon_start=1 + /transl_table=11 + /product="histone-like nucleoid structuring protein" + /protein_id="QJR97840.1" + /translation="MSEALKILNNIRTLRAQARECTLETLEEMLEKLEVVVNERREEE + SAAAAEVEERTRKLQQYREMLIADGIDPNELLNSLAAVKSGTKAKRAQRPAKYSYVDE + NGETKTWTGQGRTPAVIKKAMDEQGKSLDDFLIKQ" + CDS 1166..1885 + /gene="GFP" + /note="GFP" + /codon_start=1 + /transl_table=11 + /product="Green fluorescent protein" + /protein_id="QJR97841.1" + /translation="MVSKGEELFTGVVPILVELDGDVNGHKFSVSGEGEGDATYGKLT + LKFICTTGKLPVPWPTLVTTLTYGVQCFSRYPDHMKQHDFFKSAMPEGYVQERTIFFK + DDGNYKTRAEVKFEGDTLVNRIELKGIDFKEDGNILGHKLEYNYNSHNVYIMADKQKN + GIKVNFKIRHNIEDGSVQLADHYQQNTPIGDGPVLLPDNHYLSTQSALSKDPNEKRDH + MVLLEFVTAAGITLGMDELYK" + regulatory 1909..1954 + /regulatory_class="terminator" + /note="rrnB T1 terminator" + CDS complement(2959..3618) + /gene="cat" + /codon_start=1 + /transl_table=11 + /product="chloramphenicol acetyltransferase" + /protein_id="QJR97842.1" + /translation="MEKKITGYTTVDISQWHRKEHFEAFQSVAQCTYNQTVQLDITAF + LKTVKKNKHKFYPAFIHILARLMNAHPEFRMAMKDGELVIWDSVHPCYTVFHEQTETF + SSLWSEYHDDFRQFLHIYSQDVACYGENLAYFPKGFIENMFFVSANPWVSFTSFDLNV + ANMDNFFAPVFTMGKYYTQGDKVLMPLAIQVHHAVCDGFHVGRMLNELQQYCDEWQGG + A" +ORIGIN + 1 aaattcttaa gacccacttt cacatttaag ttgtttttct aatccgcata tgatcaattc + 61 aaggccgaat aagaaggctg gctctgcacc ttggtgatca aataattcga tagcttgtcg + 121 taataatggc ggcatactat cagtagtagg tgtttccctt tcttctttag cgacttgatg + 181 ctcttgatct tccaatacgc aacctaaagt aaaatgcccc acagcgctga gtgcatataa + 241 tgcattctct agtgaaaaac cttgttggca taaaaaggct aattgatttt cgagagtttc + 301 atactgtttt tctgtaggcc gtgtacctaa atgtactttt gctccatcgc gatgacttag + 361 taaagcacat ctaaaacttt tagcgttatt acgtaaaaaa tcttgccagc tttccccttc + 421 taaagggcaa aagtgagtat ggtgcctatc taacatctca atggctaagg cgtcgagcaa + 481 agcccgctta ttttttacat gccaatacaa tgtaggctgc tctacaccta gcttctgggc + 541 gagtttacgg gttgttaaac cttcgattcc gacctcatta agcagctcta atgcgctgtt + 601 aatcacttta cttttatcta atctagacat cattaattcc taatttttgt tgacactcta + 661 tcgttgatag agttatttta ccactcccta tcagtgatag agaaaagaat tcgtcgacaa + 721 agaggagaaa gatatcatga gcgaagcact taaaattctg aacaacatcc gtactcttcg + 781 tgcgcaggca agagaatgta cacttgaaac gctggaagaa atgctggaaa aattagaagt + 841 tgttgttaac gaacgtcgcg aagaagaaag cgcggctgct gctgaagttg aagagcgcac + 901 tcgtaaactg cagcaatatc gcgaaatgct gatcgctgac ggtattgacc cgaacgaact + 961 gctgaatagc cttgctgccg ttaaatctgg caccaaagct aaacgtgctc agcgtccggc + 1021 aaaatatagc tacgttgacg aaaacggcga aactaaaacc tggactggcc aaggccgtac + 1081 tccagctgta atcaaaaaag caatggatga gcaaggtaaa tccctcgacg atttcctgat + 1141 caagcaaact agtagatctg gtaccatggt gagcaagggc gaggagctgt tcaccggggt + 1201 ggtgcccatc ctggtcgagc tggacggcga cgtaaacggc cacaagttca gcgtgtccgg + 1261 cgagggcgag ggcgatgcca cctacggcaa gctgaccctg aagttcatct gcaccaccgg + 1321 caagctgccc gtgccctggc ccaccctcgt gaccaccctg acctacggcg tgcagtgctt + 1381 cagccgctac cccgaccaca tgaagcagca cgacttcttc aagtccgcca tgcccgaagg + 1441 ctacgtccag gagcgcacca tcttcttcaa ggacgacggc aactacaaga cccgcgccga + 1501 ggtgaagttc gagggcgaca ccctggtgaa ccgcatcgag ctgaagggca tcgacttcaa + 1561 ggaggacggc aacatcctgg ggcacaagct ggagtacaac tacaacagcc acaacgtcta + 1621 tatcatggcc gacaagcaga agaacggcat caaggtgaac ttcaagatcc gccacaacat + 1681 cgaggacggc agcgtgcagc tcgccgacca ctaccagcag aacaccccca tcggcgacgg + 1741 ccccgtgctg ctgcccgaca accactacct gagcacccag tccgccctga gcaaagaccc + 1801 caacgagaag cgcgatcaca tggtcctgct ggagttcgtg accgccgccg ggatcactct + 1861 cggcatggac gagctgtaca agtaactcga gtaaggatct ccaggcatca aataaaacga + 1921 aaggctcagt cgaaagactg ggcctttcgt tttatctgtt gtttgtcggt gaacgctctc + 1981 tactagagtc acactggctc accttcgggt gggcctttct gcgtttatac ctagggatat + 2041 attccgcttc ctcgctcact gactcgctac gctcggtcgt tcgactgcgg cgagcggaaa + 2101 tggcttacga acggggcgga gatttcctgg aagatgccag gaagatactt aacagggaag + 2161 tgagagggcc gcggcaaagc cgtttttcca taggctccgc ccccctgaca agcatcacga + 2221 aatctgacgc tcaaatcagt ggtggcgaaa cccgacagga ctataaagat accaggcgtt + 2281 tccccctggc ggctccctcg tgcgctctcc tgttcctgcc tttcggttta ccggtgtcat + 2341 tccgctgtta tggccgcgtt tgtctcattc cacgcctgac actcagttcc gggtaggcag + 2401 ttcgctccaa gctggactgt atgcacgaac cccccgttca gtccgaccgc tgcgccttat + 2461 ccggtaacta tcgtcttgag tccaacccgg aaagacatgc aaaagcacca ctggcagcag + 2521 ccactggtaa ttgatttaga ggagttagtc ttgaagtcat gcgccggtta aggctaaact + 2581 gaaaggacaa gttttggtga ctgcgctcct ccaagccagt tacctcggtt caaagagttg + 2641 gtagctcaga gaaccttcga aaaaccgccc tgcaaggcgg ttttttcgtt ttcagagcaa + 2701 gagattacgc gcagaccaaa acgatctcaa gaagatcatc ttattaatca gataaaatat + 2761 ttctagattt cagtgcaatt tatctcttca aatgtagcac ctgaagtcag ccccatacga + 2821 tataagttgt tactagtgct tggattctca ccaataaaaa acgcccggcg gcaaccgagc + 2881 gttctgaaca aatccagatg gagttctgag gtcattactg gatctatcaa caggagtcca + 2941 agcgagctcg atatcaaatt acgccccgcc ctgccactca tcgcagtact gttgtaattc + 3001 attaagcatt ctgccgacat ggaagccatc acaaacggca tgatgaacct gaatcgccag + 3061 cggcatcagc accttgtcgc cttgcgtata atatttgccc atggtgaaaa cgggggcgaa + 3121 gaagttgtcc atattggcca cgtttaaatc aaaactggtg aaactcaccc agggattggc + 3181 tgagacgaaa aacatattct caataaaccc tttagggaaa taggccaggt tttcaccgta + 3241 acacgccaca tcttgcgaat atatgtgtag aaactgccgg aaatcgtcgt ggtattcact + 3301 ccagagcgat gaaaacgttt cagtttgctc atggaaaacg gtgtaacaag ggtgaacact + 3361 atcccatatc accagctcac cgtctttcat tgccatacga aattccggat gagcattcat + 3421 caggcgggca agaatgtgaa taaaggccgg ataaaacttg tgcttatttt tctttacggt + 3481 ctttaaaaag gccgtaatat ccagctgaac ggtctggtta taggtacatt gagcaactga + 3541 ctgaaatgcc tcaaaatgtt ctttacgatg ccattgggat atatcaacgg tggtatatcc + 3601 agtgattttt ttctccattt tagcttcctt agctcctgaa aatctcgata actcaaaaaa + 3661 tacgcccggt agtgatctta tttcattatg gtgaaagttg gaacctctta cgtgccgatc + 3721 aacgtctcat tttcgccaga tatc +//`; diff --git a/package-lock.json b/package-lock.json index 466c17746..8526a6531 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "license": "MIT", "dependencies": { "react-resize-detector": "^7.1.2", + "semantic-ui-css": "^2.5.0", + "semantic-ui-react": "^2.1.5", "seqparse": "^0.2.1", "webfontloader": "^1.6.28" }, @@ -728,7 +730,6 @@ "version": "7.19.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz", "integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.13.4" }, @@ -932,6 +933,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@fluentui/react-component-event-listener": { + "version": "0.63.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-component-event-listener/-/react-component-event-listener-0.63.1.tgz", + "integrity": "sha512-gSMdOh6tI3IJKZFqxfQwbTpskpME0CvxdxGM2tdglmf6ZPVDi0L4+KKIm+2dN8nzb8Ya1A8ZT+Ddq0KmZtwVQg==", + "dependencies": { + "@babel/runtime": "^7.10.4" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/@fluentui/react-component-ref": { + "version": "0.63.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-component-ref/-/react-component-ref-0.63.1.tgz", + "integrity": "sha512-8MkXX4+R3i80msdbD4rFpEB4WWq2UDvGwG386g3ckIWbekdvN9z2kWAd9OXhRGqB7QeOsoAGWocp6gAMCivRlw==", + "dependencies": { + "@babel/runtime": "^7.10.4", + "react-is": "^16.6.3" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/@fluentui/react-component-ref/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.10.7", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.7.tgz", @@ -1727,6 +1758,28 @@ "node": ">= 8" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@semantic-ui-react/event-stack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@semantic-ui-react/event-stack/-/event-stack-3.1.3.tgz", + "integrity": "sha512-FdTmJyWvJaYinHrKRsMLDrz4tTMGdFfds299Qory53hBugiDvGC0tEJf+cHsi5igDwWb/CLOgOiChInHwq8URQ==", + "dependencies": { + "exenv": "^1.2.2", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@sinclair/typebox": { "version": "0.24.44", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.44.tgz", @@ -3584,6 +3637,14 @@ "node": ">=6" } }, + "node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4880,6 +4941,11 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exenv": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", + "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==" + }, "node_modules/exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -7432,6 +7498,11 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" + }, "node_modules/js-sdsl": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.5.tgz", @@ -7562,6 +7633,11 @@ "node": ">=4.0" } }, + "node_modules/keyboard-key": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keyboard-key/-/keyboard-key-1.1.0.tgz", + "integrity": "sha512-qkBzPTi3rlAKvX7k0/ub44sqOfXeLc/jcnGGmj5c7BJpU8eDrEVPyhCvNYAaoubbsLm9uGWwQJO1ytQK1a9/dQ==" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -7637,6 +7713,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -8112,7 +8193,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -8672,7 +8752,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -8682,8 +8761,7 @@ "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/proxy-addr": { "version": "2.0.7", @@ -8846,11 +8924,29 @@ "react": "^18.2.0" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, + "node_modules/react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "dependencies": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + }, + "peerDependencies": { + "@popperjs/core": "^2.0.0", + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" + } }, "node_modules/react-resize-detector": { "version": "7.1.2", @@ -8921,8 +9017,7 @@ "node_modules/regenerator-runtime": { "version": "0.13.9", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", - "dev": true + "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" }, "node_modules/regexp.prototype.flags": { "version": "1.4.3", @@ -9185,6 +9280,38 @@ "node": ">=10" } }, + "node_modules/semantic-ui-css": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/semantic-ui-css/-/semantic-ui-css-2.5.0.tgz", + "integrity": "sha512-jIWn3WXXE2uSaWCcB+gVJVRG3masIKtTMNEP2X8Aw909H2rHpXGneYOxzO3hT8TpyvB5/dEEo9mBFCitGwoj1A==", + "dependencies": { + "jquery": "x.*" + } + }, + "node_modules/semantic-ui-react": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/semantic-ui-react/-/semantic-ui-react-2.1.5.tgz", + "integrity": "sha512-nIqmmUNpFHfovEb+RI2w3E2/maZQutd8UIWyRjf1SLse+XF51hI559xbz/sLN3O6RpLjr/echLOOXwKCirPy3Q==", + "dependencies": { + "@babel/runtime": "^7.10.5", + "@fluentui/react-component-event-listener": "~0.63.0", + "@fluentui/react-component-ref": "~0.63.0", + "@popperjs/core": "^2.6.0", + "@semantic-ui-react/event-stack": "^3.1.3", + "clsx": "^1.1.1", + "keyboard-key": "^1.1.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "prop-types": "^15.7.2", + "react-is": "^16.8.6 || ^17.0.0 || ^18.0.0", + "react-popper": "^2.3.0", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -9380,6 +9507,11 @@ "node": ">=8" } }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -10593,6 +10725,14 @@ "makeerror": "1.0.12" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -11755,7 +11895,6 @@ "version": "7.19.0", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.19.0.tgz", "integrity": "sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==", - "dev": true, "requires": { "regenerator-runtime": "^0.13.4" } @@ -11919,6 +12058,30 @@ } } }, + "@fluentui/react-component-event-listener": { + "version": "0.63.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-component-event-listener/-/react-component-event-listener-0.63.1.tgz", + "integrity": "sha512-gSMdOh6tI3IJKZFqxfQwbTpskpME0CvxdxGM2tdglmf6ZPVDi0L4+KKIm+2dN8nzb8Ya1A8ZT+Ddq0KmZtwVQg==", + "requires": { + "@babel/runtime": "^7.10.4" + } + }, + "@fluentui/react-component-ref": { + "version": "0.63.1", + "resolved": "https://registry.npmjs.org/@fluentui/react-component-ref/-/react-component-ref-0.63.1.tgz", + "integrity": "sha512-8MkXX4+R3i80msdbD4rFpEB4WWq2UDvGwG386g3ckIWbekdvN9z2kWAd9OXhRGqB7QeOsoAGWocp6gAMCivRlw==", + "requires": { + "@babel/runtime": "^7.10.4", + "react-is": "^16.6.3" + }, + "dependencies": { + "react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + } + } + }, "@humanwhocodes/config-array": { "version": "0.10.7", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.7.tgz", @@ -12494,6 +12657,20 @@ "fastq": "^1.6.0" } }, + "@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" + }, + "@semantic-ui-react/event-stack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@semantic-ui-react/event-stack/-/event-stack-3.1.3.tgz", + "integrity": "sha512-FdTmJyWvJaYinHrKRsMLDrz4tTMGdFfds299Qory53hBugiDvGC0tEJf+cHsi5igDwWb/CLOgOiChInHwq8URQ==", + "requires": { + "exenv": "^1.2.2", + "prop-types": "^15.6.2" + } + }, "@sinclair/typebox": { "version": "0.24.44", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.44.tgz", @@ -13963,6 +14140,11 @@ "shallow-clone": "^3.0.0" } }, + "clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==" + }, "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -14938,6 +15120,11 @@ "strip-final-newline": "^2.0.0" } }, + "exenv": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", + "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==" + }, "exit": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", @@ -16832,6 +17019,11 @@ } } }, + "jquery": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", + "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" + }, "js-sdsl": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.1.5.tgz", @@ -16933,6 +17125,11 @@ "object.assign": "^4.1.3" } }, + "keyboard-key": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/keyboard-key/-/keyboard-key-1.1.0.tgz", + "integrity": "sha512-qkBzPTi3rlAKvX7k0/ub44sqOfXeLc/jcnGGmj5c7BJpU8eDrEVPyhCvNYAaoubbsLm9uGWwQJO1ytQK1a9/dQ==" + }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -16987,6 +17184,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -17348,8 +17550,7 @@ "object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" }, "object-inspect": { "version": "1.12.2", @@ -17749,7 +17950,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "requires": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -17759,8 +17959,7 @@ "react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" } } }, @@ -17882,11 +18081,24 @@ "scheduler": "^0.23.0" } }, + "react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + }, "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, + "react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "requires": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + } }, "react-resize-detector": { "version": "7.1.2", @@ -17940,8 +18152,7 @@ "regenerator-runtime": { "version": "0.13.9", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz", - "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==", - "dev": true + "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==" }, "regexp.prototype.flags": { "version": "1.4.3", @@ -18128,6 +18339,34 @@ "node-forge": "^1" } }, + "semantic-ui-css": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/semantic-ui-css/-/semantic-ui-css-2.5.0.tgz", + "integrity": "sha512-jIWn3WXXE2uSaWCcB+gVJVRG3masIKtTMNEP2X8Aw909H2rHpXGneYOxzO3hT8TpyvB5/dEEo9mBFCitGwoj1A==", + "requires": { + "jquery": "x.*" + } + }, + "semantic-ui-react": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/semantic-ui-react/-/semantic-ui-react-2.1.5.tgz", + "integrity": "sha512-nIqmmUNpFHfovEb+RI2w3E2/maZQutd8UIWyRjf1SLse+XF51hI559xbz/sLN3O6RpLjr/echLOOXwKCirPy3Q==", + "requires": { + "@babel/runtime": "^7.10.5", + "@fluentui/react-component-event-listener": "~0.63.0", + "@fluentui/react-component-ref": "~0.63.0", + "@popperjs/core": "^2.6.0", + "@semantic-ui-react/event-stack": "^3.1.3", + "clsx": "^1.1.1", + "keyboard-key": "^1.1.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "prop-types": "^15.7.2", + "react-is": "^16.8.6 || ^17.0.0 || ^18.0.0", + "react-popper": "^2.3.0", + "shallowequal": "^1.1.0" + } + }, "semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -18302,6 +18541,11 @@ "kind-of": "^6.0.2" } }, + "shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, "shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -19177,6 +19421,14 @@ "makeerror": "1.0.12" } }, + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/package.json b/package.json index 03f06c888..30f8aebad 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ ], "dependencies": { "react-resize-detector": "^7.1.2", + "semantic-ui-css": "^2.5.0", + "semantic-ui-react": "^2.1.5", "seqparse": "^0.2.1", "webfontloader": "^1.6.28" }, diff --git a/src/Alignment/Alignment.tsx b/src/Alignment/Alignment.tsx new file mode 100644 index 000000000..149a6c24b --- /dev/null +++ b/src/Alignment/Alignment.tsx @@ -0,0 +1,265 @@ +import { InfiniteScroll } from "./InfiniteScroll"; +import { SeqBlock } from "./SeqBlock"; +import { InputRefFunc } from "../SelectionHandler"; +import { Annotation, CutSite, Highlight, NameRange, Range, SeqType, Size } from "../elements"; +import { createMultiRows, createSingleRows, stackElements } from "../elementsToRows"; +import { isEqual } from "../isEqual"; +import { createTranslations } from "../sequence"; +import * as React from "react"; + + +export interface LinearProps { + annotations: Annotation[]; + bpColors?: { [key: number | string]: string }; + bpsPerBlock: number; + charWidth: number; + compSeq: string; + cutSites: CutSite[]; + viewer:string; + elementHeight: number; + handleMouseEvent: React.MouseEventHandler; + highlights: Highlight[]; + inputRef: InputRefFunc; + lineHeight: number; + onUnmount: (id: string) => void; + search: NameRange[]; + seq: string; + seqToCompare: string; + seqFontSize: number; + seqType: SeqType; + showComplement: boolean; + colorized: boolean; + aagrouping?: boolean; + showIndex: boolean; + size: Size; + translations: Range[]; + zoom: { linear: number }; +} + +/** + * A linear sequence viewer. + * + * Comprised of SeqBlock(s), which are themselves comprised of: + * text (seq) + * Index (axis) + * Annotations + * Finds + * Translations + * Selections + */ +export default class Alignment extends React.Component { + /** + * Deep equality comparison + */ + shouldComponentUpdate = (nextProps: LinearProps) => !isEqual(nextProps, this.props); + + /** + * given all the information needed to render all the seqblocks (ie, sequence, compSeq + * list of annotations), cut up all that information into an array. + * Each element in that array pertaining to one SeqBlock + * + * For example, if each seqblock has 2 bps, and the seq is "ATGCAG", this should first + * make an array of ["AT", "GC", "AG"], and then pass "AT" to the first SeqBlock, "GC" to + * the second seqBlock, and "AG" to the third seqBlock. + */ + render() { + const { + annotations, + bpsPerBlock, + compSeq, + cutSites, + elementHeight, + viewer, + highlights, + lineHeight, + sequenceToCompare, + onUnmount, + search, + seq, + seqType, + // showComplement, + showIndex, + size, + translations, + zoom, + } = this.props; + + // un-official definition for being zoomed in. Being over 10 seems like a decent cut-off + const zoomed = zoom.linear > 10; + const showComplement=false; + + const blossom_block = [ + ['c'], + ['s','t','a','g','p'], + ['d','e','q','n'], + ['k','r','h'], + ['m','i','l','v'], + ['f','y','w'] + ]; + // the actual fragmenting of the sequence into subblocks. generates all info that will be needed + // including sequence blocks, complement blocks, annotations, blockHeights + const seqLength = seq.length; + let arrSize = Math.round(Math.ceil(seqLength / bpsPerBlock)); + if (arrSize === Number.POSITIVE_INFINITY) arrSize = 1; + const seqToCompareLength = sequenceToCompare.length; + let arrCompareSize = Math.round(Math.ceil(seqToCompareLength / bpsPerBlock)); + if (arrSize === Number.POSITIVE_INFINITY) arrSize = 1; + let seqSymbols = ''; + if (seqType === 'aa') { + sequenceToCompare.split('').forEach((c, i) => seqSymbols += (blossom_block.find(block => block.includes(c.toLowerCase())) || []).includes(seq[i].toLowerCase()) ? (seq[i] === c ) ? '|': '.' : ' '); + } else { + sequenceToCompare.split('').forEach((c, i) => seqSymbols += [c ,seq[i]].includes('-') ? ' ' : c === seq[i] ? '|' : '.' ); + } + const ids = new Array(arrSize); // array of SeqBlock ids + const seqs = new Array(arrSize); // arrays for sequences... + const seqsToCompare = new Array(arrCompareSize); // arrays for sequences... + const seqsSymbols = new Array(arrCompareSize); // arrays for sequences... + const idsCp = new Array(arrCompareSize); // array of SeqBlock ids + const idsSy = new Array(arrCompareSize); // array of SeqBlock ids + const compSeqs = new Array(arrSize); // complements... + const blockHeights = new Array(arrSize); // block heights... + + const cutSiteRows = cutSites.length + ? createSingleRows(cutSites, bpsPerBlock, arrSize) + : new Array(arrSize).fill([]); + + /** + * Vet the annotations for starts and ends at zero index + */ + const vetAnnotations = (annotations: Annotation[]) => { + annotations.forEach(ann => { + if (ann.end === 0 && ann.start > ann.end) ann.end = seqLength; + if (ann.start === seqLength && ann.end < ann.start) ann.start = 0; + }); + return annotations; + }; + + const annotationRows = createMultiRows( + stackElements(vetAnnotations(annotations), seq.length), + bpsPerBlock, + arrSize + ); + + const searchRows: NameRange[][] = + search && search.length ? createSingleRows(search, bpsPerBlock, arrSize) : new Array(arrSize).fill([]); + + const highlightRows = createSingleRows(highlights, bpsPerBlock, arrSize); + + const translationRows = translations.length + ? createSingleRows(createTranslations(translations, seq, seqType), bpsPerBlock, arrSize) + : new Array(arrSize).fill([]); + const translationRowsForSymbols = translations.length + ? createSingleRows(createTranslations(translations, seqSymbols, seqType), bpsPerBlock, arrSize) + : new Array(arrSize).fill([]);// seqSymbols; + const translationRowsComparison = translations.length + ? createSingleRows(createTranslations(translations, sequenceToCompare, seqType), bpsPerBlock, arrSize) + : new Array(arrSize).fill([]); + + for (let i = 0; i < arrSize; i += 1) { + const firstBase = i * bpsPerBlock; + const lastBase = firstBase + bpsPerBlock; + + // cut the new sequence and, if also looking for complement, the complement as well + seqs[i] = seq.substring(firstBase, lastBase); + seqsSymbols[i] = seqSymbols.substring(firstBase, lastBase); + seqsToCompare[i] = sequenceToCompare.substring(firstBase, lastBase); + compSeqs[i] = compSeq.substring(firstBase, lastBase); + + // store a unique id from the block + ids[i] = seqs[i] + String(i); + idsCp[i] = seqsToCompare[i] + String(i); + idsSy[i] = seqsSymbols[i] + String(i); + + // find the line height for the seq block based on how many rows need to be shown + let blockHeight = lineHeight * 1.1; // this is for padding between the SeqBlocks + if (seqType != "aa") { + blockHeight += lineHeight; // for sequence row + } + if (zoomed) { + blockHeight += showComplement ? lineHeight : 0; // double for complement + 2px margin + } + if (showIndex) { + blockHeight += lineHeight; // another for index row + } + if (translationRows[i].length ) { + blockHeight += translationRows[i].length * elementHeight; + } + if (annotationRows[i].length) { + blockHeight += annotationRows[i].length * elementHeight; + } + if (cutSiteRows[i].length) { + blockHeight += lineHeight; // space for cutsite name + } + + blockHeights[i] = blockHeight; + } + + const seqBlocks: JSX.Element[] = []; + const seqBlocksCompare: JSX.Element[] = []; + const seqBlocksSymbols: JSX.Element[] = []; + let yDiff = 0; + for (let i = 0; i < arrSize; i += 1) { + const firstBase = i * bpsPerBlock; + [ + { identifier:'seq1', translationRow:translationRows,array:seqBlocks,fullSequence:seq,sequence: seqs, id: ids, multiplyFactor: 0.3, showIndex: false }, + { identifier:'symbol', translationRow:translationRowsForSymbols,array:seqBlocksSymbols,fullSequence:seqSymbols,sequence: seqsSymbols, id: idsSy, multiplyFactor: 0.3, showIndex: false }, + { identifier:'seq2', translationRow:translationRowsComparison,array:seqBlocksCompare,fullSequence:sequenceToCompare,sequence: seqsToCompare, id: idsCp, multiplyFactor: 1, showIndex: true }, + ].forEach(({ identifier, translationRow, array, fullSequence, sequence, id, multiplyFactor, showIndex }) => { + array.push( + + ); + }); + yDiff += blockHeights[i]; + } + + return ( + seqBlocks.length && ( + <> + acc + h, 0)} + /> + + ) + ); + } +} \ No newline at end of file diff --git a/src/Alignment/AlignmentStatistics.tsx b/src/Alignment/AlignmentStatistics.tsx new file mode 100644 index 000000000..6a1a5a089 --- /dev/null +++ b/src/Alignment/AlignmentStatistics.tsx @@ -0,0 +1,45 @@ +import * as React from "react"; + +export default function AlignmentStatistics({ name, nameToCompare, seq, seqToCompare, seqType }) { + const generateFunctionSymbol = () => { + const blossom_block = [ + ['c'], + ['s', 't', 'a', 'g', 'p'], + ['d', 'e', 'q', 'n'], + ['k', 'r', 'h'], + ['m', 'i', 'l', 'v'], + ['f', 'y', 'w'] + ]; + let seqSymbols = ''; + + if (seqType === 'aa') { + seqToCompare.split('').forEach((c, i) => seqSymbols += (blossom_block.find(block => block.includes(c.toLowerCase())) || []).includes(seq[i].toLowerCase()) ? (seq[i] === c) ? '|' : '.' : (seq[i] === '-' || c === '-') ? '-' : '*'); + } else { + seqToCompare.split('').forEach((c, i) => seqSymbols += [c, seq[i]].includes('-') ? ' ' : c === seq[i] ? '|' : '.'); + } + return `${seqSymbols}` + } + return ( + <> + + + + + + +
LengthFraction IdenticalCoverage
{ name }{seq.split("").filter(el => el !== '-').length}{(generateFunctionSymbol().split('').reduce((a, c) => { if (c === '|') return a += 1; return a }, 0) / seq.split("").filter(el => el !== '-').length).toFixed(2)}{(generateFunctionSymbol().split('').reduce((a, c) => { if (['|', '.'].includes(c)) return a += 1; return a }, 0) / seq.split("").filter(el => el !== '-').length).toFixed(2)}
{nameToCompare}{seqToCompare.split("").filter(el => el !== '-').length}{(generateFunctionSymbol().split('').reduce((a, c) => { if (c === '|') return a += 1; return a }, 0) / seqToCompare.split("").filter(el => el !== '-').length).toFixed(2)}{(generateFunctionSymbol().split('').reduce((a, c) => { if (['|', '.'].includes(c)) return a += 1; return a }, 0) / seqToCompare.split("").filter(el => el !== '-').length).toFixed(2)}
+
Number of mismatches: {generateFunctionSymbol().split('').reduce((a, c) => { if ([' ', '.'].includes(c)) return a += 1; return a }, 0)}
+ + ) +} diff --git a/src/Alignment/InfiniteScroll.tsx b/src/Alignment/InfiniteScroll.tsx new file mode 100644 index 000000000..a6a2bb30f --- /dev/null +++ b/src/Alignment/InfiniteScroll.tsx @@ -0,0 +1,310 @@ +import CentralIndexContext from '../centralIndexContext'; +import { Size } from '../elements'; +import { isEqual } from '../isEqual'; +import * as React from 'react'; + +interface InfiniteScrollProps { + blockHeights: number[]; + bpsPerBlock: number; + seqBlocks: JSX.Element[]; + size: Size; + totalHeight: number; + seqBlocksCompare: any; + seqBlocksSymbols: any; + alignment: boolean; +} + +interface InfiniteScrollState { + centralIndex: number; + visibleBlocks: number[]; +} + +/** + * InfiniteScroll is a wrapper around the seqBlocks. Renders only the seqBlocks that are + * within the range of the current dom viewerport + * + * This component should sense scroll events and, during one, recheck which sequences are shown. + */ +export class InfiniteScroll extends React.PureComponent { + static contextType = CentralIndexContext; + static context: React.ContextType; + declare context: React.ContextType; + + scroller: React.RefObject = React.createRef(); // ref to a div for scrolling + insideDOM: React.RefObject = React.createRef(); // ref to a div inside the scroller div + timeoutID; + + constructor(props: InfiniteScrollProps) { + super(props); + + this.state = { + + centralIndex: 0, + // start off with first 5 blocks shown + visibleBlocks: new Array(Math.min((5), props.seqBlocks.length)).fill(null).map((_, i) => i), + }; + } + componentDidMount = () => { + this.handleScrollOrResize(); // ref should now be set + window.addEventListener('resize', this.handleScrollOrResize); + }; + + componentDidUpdate = (prevProps: InfiniteScrollProps, prevState: InfiniteScrollState, snapshot: any) => { + if (!this.scroller.current) { + // scroller not mounted yet + return; + } + + const { seqBlocks, size } = this.props; + const { centralIndex, visibleBlocks } = this.state; + + if (this.context && centralIndex !== this.context.linear) { + this.scrollToCentralIndex(); + } else if (!isEqual(prevProps.size, size) || seqBlocks.length !== prevProps.seqBlocks.length) { + this.handleScrollOrResize(); // reset + } else if (isEqual(prevState.visibleBlocks, visibleBlocks)) { + this.restoreSnapshot(snapshot); // something, like ORFs or index view, has changed + } + }; + + componentWillUnmount = () => { + window.removeEventListener('resize', this.handleScrollOrResize); + }; + + /** + * more info at: https://reactjs.org/docs/react-component.html#getsnapshotbeforeupdate + */ + getSnapshotBeforeUpdate = (prevProps: InfiniteScrollProps) => { + // find the current top block + const top = this.scroller.current ? this.scroller.current.scrollTop : 0; + + // find out 1) which block this is at the edge of the top + // and 2) how far from the top of that block we are right now + const { blockHeights } = prevProps; + let blockIndex = 0; + let accumulatedY = 0; + do { + accumulatedY += blockHeights[blockIndex]; + blockIndex += 1; + } while (accumulatedY + blockHeights[blockIndex] < top && blockIndex < blockHeights.length); + + const blockY = top - accumulatedY; // last extra distance + return { blockIndex, blockY }; + }; + + /** + * Scroll to centralIndex. Likely from circular clicking on an element + * that should then be scrolled to in linear + */ + scrollToCentralIndex = () => { + if (!this.scroller.current) { + return; + } + + const { + blockHeights, + bpsPerBlock, + seqBlocks, + size: { height }, + totalHeight, + } = this.props; + const { visibleBlocks } = this.state; + const { clientHeight, scrollHeight } = this.scroller.current; + const centralIndex = this.context.linear; + + // find the first block that contains the new central index + const centerBlockIndex = seqBlocks.findIndex((block) => block.props.firstBase <= centralIndex && block.props.firstBase + bpsPerBlock >= centralIndex); + + // build up the list of blocks that are visible just beneath this first block + let newVisibleBlocks: number[] = []; + if (scrollHeight <= clientHeight) { + newVisibleBlocks = visibleBlocks; + } else if (centerBlockIndex > -1) { + const centerBlock = seqBlocks[centerBlockIndex]; + + // create some padding above the new center block + const topAdjust = centerBlockIndex > 0 ? blockHeights[centerBlockIndex - 1] : 0; + let top = centerBlock.props.y - topAdjust; + let bottom = top + height; + if (bottom > totalHeight) { + bottom = totalHeight; + top = totalHeight - height; + } + blockHeights.reduce((total, h, i) => { + if (total >= top && total <= bottom) { + newVisibleBlocks.push(i); + } + return total + h; + }, 0); + + // Don't scroll exactly to centralIndex because most of the time + // item of interest is at centralIndex and if this is at the top + // it can be obscured by things like the search box + this.scroller.current.scrollTop = centerBlock.props.y - blockHeights[0] / 2; + } + + if (!isEqual(newVisibleBlocks, visibleBlocks)) { + this.setState({ + centralIndex: centralIndex, + visibleBlocks: newVisibleBlocks, + }); + } + }; + + /** + * the component has mounted to the DOM or updated, and the window should be scrolled downwards + * so that the central index is visible + */ + restoreSnapshot = (snapshot) => { + if (!this.scroller.current) { + return; + } + + const { blockHeights } = this.props; + const { blockIndex, blockY } = snapshot; + + const scrollTop = blockHeights.slice(0, blockIndex).reduce((acc, h) => acc + h, 0) + blockY; + + this.scroller.current.scrollTop = scrollTop; + }; + + /** + * check whether the blocks that should be visible have changed from what's in state, + * update if so + */ + handleScrollOrResize = () => { + if (!this.scroller.current || !this.insideDOM.current) { + return; + } + + const { + blockHeights, + size: { height }, + totalHeight, + } = this.props; + const { visibleBlocks } = this.state; + + const newVisibleBlocks: number[] = []; + + let top = 0; + if (this.scroller && this.insideDOM) { + const { top: parentTop } = this.scroller.current.getBoundingClientRect(); + const { top: childTop } = this.insideDOM.current.getBoundingClientRect(); + top = childTop - parentTop; + } + + top = -top + 35; + top = Math.max(0, top); // don't go too high + top = Math.min(totalHeight - height, top); // don't go too low + const bottom = top + height; + top -= 2 * blockHeights[0]; // add two blocks padding on top + blockHeights.reduce((total, h, i) => { + if (total >= top && total <= bottom) { + newVisibleBlocks.push(i); + } + return total + h; + }, 0); + + if (!isEqual(newVisibleBlocks, visibleBlocks)) { + this.setState({ visibleBlocks: newVisibleBlocks }); + } + }; + + incrementScroller = (incAmount) => { + this.stopIncrementingScroller(); + this.timeoutID = setTimeout(() => { + if (!this.scroller.current) { + return; + } + + this.scroller.current.scrollTop += incAmount; + this.incrementScroller(incAmount); + }, 5); + }; + + stopIncrementingScroller = () => { + if (this.timeoutID) { + clearTimeout(this.timeoutID); + this.timeoutID = null; + } + }; + + /** + * handleMouseOver is for detecting when the user is performing a drag event + * at the very top or the very bottom of DIV. If they are, this starts + * a incrementing the div's scrollTop (ie an upward or downward scroll event) that's + * terminated by the user leaving the scroll area + * + * The rate of the scrollTop is proportional to how far from the top or the + * bottom the user is (within [-40, 0] for top, and [0, 40] for bottom) + */ + handleMouseOver = (e: React.MouseEvent) => { + if (!this.scroller.current) { + return; + } + + // not relevant, some other type of event, not a selection drag + if (e.buttons !== 1) { + if (this.timeoutID) { + this.stopIncrementingScroller(); + } + return; + } + + // check whether the current drag position is near the bottom or the + // top of the viewer and, if it is, try and increment the current + // centralIndex (triggering a downward scroll event) + const scrollerBlock = this.scroller.current.getBoundingClientRect(); + let scrollRatio = (e.clientY - scrollerBlock.top) / scrollerBlock.height; + if (scrollRatio > 0.9) { + scrollRatio = Math.min(1, scrollRatio); + let scalingRatio = scrollRatio - 0.9; + scalingRatio *= 10; + const scaledScroll = 15 * scalingRatio; + + this.incrementScroller(scaledScroll); + } else if (scrollRatio < 0.1) { + scrollRatio = 0.1 - Math.max(0, scrollRatio); + const scalingRatio = 10 * scrollRatio; + const scaledScroll = -15 * scalingRatio; + + this.incrementScroller(scaledScroll); + } else { + this.stopIncrementingScroller(); + } + }; + + + + render() { + const { + blockHeights, + seqBlocks,seqBlocksCompare,seqBlocksSymbols, + size: { width },alignment, + totalHeight: height, + } = this.props; + const { visibleBlocks } = this.state; + + // find the height of the empty div needed to correctly position the rest + const [firstRendered] = visibleBlocks; + const spaceAbove = blockHeights.slice(0, firstRendered).reduce((acc, h) => acc + h, 0); + + return ( +
{ + // do nothing + }} + onMouseOver={this.handleMouseOver} + onScroll={this.handleScrollOrResize}> +
+
+ {!alignment && visibleBlocks.map((i) => seqBlocks[i])} + {alignment && visibleBlocks.map((i) => [seqBlocks[i],seqBlocksSymbols[i],seqBlocksCompare[i]])} +
+
+ ); + } +} diff --git a/src/Alignment/SeqBlock.tsx b/src/Alignment/SeqBlock.tsx new file mode 100644 index 000000000..fae569842 --- /dev/null +++ b/src/Alignment/SeqBlock.tsx @@ -0,0 +1,509 @@ +import * as React from "react"; + +import { InputRefFunc } from "../SelectionHandler"; +import { Annotation, CutSite, Highlight, NameRange, Range, SeqType, Size, Translation } from "../elements"; +import AnnotationRows from "../Linear/Annotations"; +import { CutSites } from "../Linear/CutSites"; +import Find from "../Linear/Find"; +import Highlights from "../Linear/Highlights"; +import IndexRow from "../Linear/Index"; +import Selection from "../Linear/Selection"; +import { TranslationRows } from "../Linear/Translations"; +import { colorByGroup } from "../colors"; +// import { colorByGroup } from "../colors"; + +export type FindXAndWidthType = ( + n1?: number | null, + n2?: number | null +) => { + width: number; + x: number; +}; + +export type FindXAndWidthElementType = ( + i: number, + element: NameRange, + elements: NameRange[] +) => { overflowLeft: boolean; overflowRight: boolean; width: number; x: number }; + +interface SeqBlockProps { + annotationRows: Annotation[][]; + blockHeight: number; + bpColors?: { [key: number | string]: string }; + bpsPerBlock: number; + charWidth: number; + compSeq: string; + symbolSeq: string; + cutSiteRows: CutSite[]; + elementHeight: number; + firstBase: number; + fullSeq: string; + handleMouseEvent: React.MouseEventHandler; + highlights: Highlight[]; + id: string; + viewer: string; + inputRef: InputRefFunc; + key: string; + lineHeight: number; + onUnmount: (a: string) => void; + searchRows: Range[]; + seq: string; + seqFontSize: number; + seqType: SeqType; + showComplement: boolean; + showIndex: boolean; + colorized: boolean; + aagrouping?: boolean; + size: Size; + translations: Translation[]; + y: number; + zoom: { linear: number }; + zoomed: boolean; +} + +/** + * SeqBlock + * + * Comprised of: + * IndexRow (the x axis basepair index) + * AnnotationRows (annotations) + * Selection (cursor selection range) + * Find (regions that match the users current find search) + * CutSites (cut sites) + * Translations + * + * a single block of linear sequence. Essentially a row that holds + * the sequence, and flair around it including the + * complementary sequence, sequence index, and anotations * + */ +export class SeqBlock extends React.PureComponent { + static defaultProps = {}; + + componentWillUnmount = () => { + const { id, onUnmount } = this.props; + onUnmount(id); + }; + + /** + * For elements in arrays, check whether it wraps around the zero index. + */ + findXAndWidthElement = (i: number, element: NameRange, elements: NameRange[]) => { + const { bpsPerBlock, firstBase, fullSeq, seq } = this.props; + const lastBase = firstBase + seq.length; + const { end, start } = element; + + let { width, x } = this.findXAndWidth(start, end); + + // does the element overflow to the left or the right of this seqBlock? + let overflowLeft = start < firstBase; + let overflowRight = end > lastBase || (start === end && fullSeq.length > bpsPerBlock); // start === end means covers whole plasmid + + // if the element starts and ends in a SeqBlock, by circling all the way around, + // it will be rendered twice (once from the firstBase to start and another from end to lastBase) + // eg: https://user-images.githubusercontent.com/13923102/35816281-54571e70-0a68-11e8-92eb-ab56884337ac.png + const split = elements.reduce((acc, el) => (el.id === element.id ? acc + 1 : acc), 0) > 1; // is this element in two pieces? + if (split) { + if (elements.findIndex(el => el.id === element.id) === i) { + // we're in the first half of the split element + ({ width, x } = this.findXAndWidth(firstBase, end)); + overflowLeft = true; + overflowRight = false; + } else { + // we're in the second half of the split element + ({ width, x } = this.findXAndWidth(start, lastBase)); + overflowLeft = false; + overflowRight = true; + } + } else if (start > end) { + // the element crosses over the zero index and this needs to be accounted for + // this is very similar to the Block rendering logic in ../Selection/Selection.jsx + ({ width, x } = this.findXAndWidth( + start > lastBase ? firstBase : Math.max(firstBase, start), + end < firstBase ? lastBase : Math.min(lastBase, end) + )); + + // if this is the first part of element that crosses the zero index + if (start > firstBase) { + overflowLeft = true; + overflowRight = end > lastBase; + } + + // if this is the second part of an element, check if it overflows + if (end < firstBase) { + overflowLeft = start < firstBase; + overflowRight = true; + } + } else if (start === end) { + // the element circles the entire plasmid and we aren't currently in a SeqBlock + // where the element starts or ends + ({ width, x } = this.findXAndWidth(start, end + fullSeq.length)); + } + + return { overflowLeft, overflowRight, width, x }; + }; + + /** + * A helper used in child components to position elements on rows. Given first and last base, how far from the left + * and how wide should it be? + * + * If an element and elements are provided, it also factors in whether the element circles around the 0-index. + */ + findXAndWidth = (firstIndex = 0, lastIndex = 0) => { + const { + bpsPerBlock, + charWidth, + firstBase, + fullSeq: { length: seqLength }, + size, + } = this.props; + + firstIndex |= 0; + lastIndex |= 0; + + const lastBase = Math.min(firstBase + bpsPerBlock, seqLength); + const multiBlock = seqLength >= bpsPerBlock; + + let x = 0; + if (firstIndex >= firstBase) { + x = (firstIndex - firstBase) * charWidth; + x = Math.max(x, 0) || 0; + } + + // find the width for the current element + let width = size.width; + if (firstIndex === lastIndex) { + // it starts on the last bp + width = 0; + } else if (firstIndex >= firstBase || lastIndex < lastBase) { + // it starts or ends in this SeqBlock + const start = Math.max(firstIndex, firstBase); + const end = Math.min(lastIndex, lastBase); + + width = size.width * ((end - start) / bpsPerBlock); + width = Math.abs(width) || 0; + } else if (firstBase + bpsPerBlock > seqLength && multiBlock) { + // it's an element in the last SeqBlock, that doesn't span the whole width + width = size.width * ((seqLength % bpsPerBlock) / bpsPerBlock); + } + + return { width, x }; + }; + + /** + * Given a bp, return either the bp as was or a text span if it should have a color. + * + * We're looking up each bp in the props.bpColors map to see if it should be shaded and, if so, + * wrapping it in a textSpan with that color as a fill + */ + seqTextSpan = (bp: string, i: number, textProps, indexYDiff, lineHeight) => { + const { bpColors, charWidth, firstBase, id, symbolSeq } = this.props; + + let color: string | undefined; + if (bpColors) { + color = + bpColors[bp] || + bpColors[bp.toUpperCase()] || + bpColors[bp.toLowerCase()] || + bpColors[i + firstBase] || + undefined; + } + if (symbolSeq && symbolSeq[i+firstBase] !== '|') { + color = "#FF0000" + } + return ( + // the +0.2 here and above is to offset the characters they're not right on the left edge. When they are, + // other elements look like they're shifted too far to the right. + <> + + + + {bp} + + + ); + }; + alignmentSeqTextSpan = (bp: string, i: number, textProps, indexYDiff, lineHeight) => { + const { bpColors, charWidth, firstBase, id, colorized, viewer, symbolSeq } = this.props; + + let color: string | undefined; + if (bpColors) { + color = + bpColors[bp] || + bpColors[bp.toUpperCase()] || + bpColors[bp.toLowerCase()] || + bpColors[i + firstBase] || + undefined; + } + if (symbolSeq && symbolSeq[i+firstBase] !== '|') { + color = "#FF0000" + } + return ( + // the +0.2 here and above is to offset the characters they're not right on the left edge. When they are, + // other elements look like they're shifted too far to the right. + <> + {colorized && !(viewer ==='alignment') ? + + : + +} + + + + {bp} + + ); + }; + + render() { + const { + annotationRows, + blockHeight, + bpsPerBlock, + charWidth, + compSeq, + cutSiteRows, + elementHeight, + firstBase, + fullSeq, + handleMouseEvent, aagrouping, + highlights, + id, + inputRef, + lineHeight, + onUnmount, + colorized, + searchRows, + seq, + seqFontSize, + seqType, + showComplement, + showIndex, + size, + translations, + zoom, + zoomed, + } = this.props; + if (!size.width || !size.height) return null; + + const textProps = { + fontSize: seqFontSize, + lengthAdjust: "spacing", + textAnchor: "start", + textLength: size.width >= 0 ? size.width : 1, + textRendering: "optimizeLegibility", + }; + + const lastBase = firstBase + seq.length; + + // height and yDiff of cut sites + const cutSiteYDiff = 0; // spacing for cutSite names + const cutSiteHeight = zoomed && cutSiteRows.length ? lineHeight : 0; + + // height and yDiff of the sequence strand + const indexYDiff = cutSiteYDiff + cutSiteHeight; + const indexHeight = seqType === "aa" ? 0 : lineHeight; // if aa, no seq row is shown + + // height and yDiff of the complement strand + const compYDiff = indexYDiff + indexHeight; + const compHeight = zoomed && showComplement ? lineHeight : 0; + + // height and yDiff of translations + const translationYDiff = compYDiff + compHeight; + const translationHeight = elementHeight * translations.length; + + // height and yDiff of annotations + const annYDiff = translationYDiff + translationHeight; + const annHeight = elementHeight * annotationRows.length; + + // height and ydiff of the index row. + const elementGap = annotationRows.length + translations.length ? 3 : 0; + const indexRowYDiff = annYDiff + annHeight + elementGap; + + // calc the height necessary for the sequence selection + // it starts 5 above the top of the SeqBlock + const selectHeight = cutSiteHeight + indexHeight + compHeight + translationHeight + annHeight + elementGap + 5; + let selectEdgeHeight = selectHeight + 9; // +9 is the height of a tick + index row + + // needed because otherwise the selection height is very small + if (!zoomed && selectHeight <= elementHeight) { + selectEdgeHeight = elementHeight; + } + return ( + <> + = 0 ? size.width : 0} + onMouseDown={handleMouseEvent} + onMouseMove={handleMouseEvent} + onMouseUp={handleMouseEvent} + > + + {showIndex && ( + + )} + + {/* */} + + r.direction === 1)} + findXAndWidth={this.findXAndWidth} + firstBase={firstBase} + indexYDiff={indexYDiff - 3} + inputRef={inputRef} + lastBase={lastBase} + lineHeight={lineHeight} + listenerOnly={false} + zoomed={zoomed} + /> + {(colorized && !aagrouping) && translations.length && ( + + + )} + {annotationRows.length && ( + + )} + {zoomed && (seqType !== "aa" || !colorized || aagrouping) ? ( + <> + {seqType !== "aa" && + seq.split("").map((bp, i) => <>{this.seqTextSpan(bp, i, textProps, indexYDiff, lineHeight)}) + } + {seqType == "aa" && + seq.split("").map((bp, i) => this.alignmentSeqTextSpan(bp, i, textProps, indexYDiff, lineHeight)) + } + + ) : null} + {compSeq && zoomed && showComplement && seqType !== "aa" ? ( + compSeq.split("").map((bp, i) => this.seqTextSpan(bp, i, textProps, compYDiff, lineHeight)) + ) : null} + {zoomed && ( + + )} + r.direction === 1)} + findXAndWidth={this.findXAndWidth} + firstBase={firstBase} + indexYDiff={indexYDiff - 3} + inputRef={inputRef} + lastBase={lastBase} + lineHeight={lineHeight} + listenerOnly={true} + zoomed={zoomed} + /> + {/* */} + + + ); + } +} diff --git a/src/MultipleEventHandler.tsx b/src/MultipleEventHandler.tsx new file mode 100644 index 000000000..21b5ec9f9 --- /dev/null +++ b/src/MultipleEventHandler.tsx @@ -0,0 +1,286 @@ +// import FloatingMenu from "./Linear/FloatingMenu"; +import CentralIndexContext from "./centralIndexContext"; +import AlignmentStatistics from "./Alignment/AlignmentStatistics"; +import debounce from "./debounce"; +import { Selection } from "./selectionContext"; +import * as React from "react"; +import { guessType } from "./sequence"; + + +export interface EventsHandlerProps { + bpsPerBlock: number; + children: any; + aagrouping?: boolean; + showDetails?: boolean; + copyEvent: (e: React.KeyboardEvent) => boolean; + handleMouseEvent: (e: any) => void; + selection: Selection; + seq: string[]; + name: string; + nameToCompare?: string; + setSelection: (selection: Selection) => void; +} +export interface EventsHandlerState { + rightClickMenu: Boolean; + xFloatingMenu: number; + yFloatingMenu: number; +} + +/** + * EventHandler handles the routing of all events, including keypresses, mouse clicks, etc. + */ +export class MultipleEventHandler extends React.PureComponent { + static contextType = CentralIndexContext; + static context: React.ContextType; + declare context: React.ContextType; + + clickedOnce: EventTarget | null = null; + clickedTwice: EventTarget | null = null; + state = { rightClickMenu: false, xFloatingMenu: 0, yFloatingMenu: 0 }; + /** + * action handler for a keyboard keypresses. + */ + handleKeyPress = (e: React.KeyboardEvent) => { + const keyType = this.keypressMap(e); + if (!keyType) { + return; // not recognized key + } + this.handleSeqInteraction(keyType); + }; + + /** + * maps a keypress to an interaction (String) + * + * ["All", "Copy", "Up", "Right", "Down", "Left"] + */ + keypressMap = (e: React.KeyboardEvent) => { + const { copyEvent } = this.props; + + if (copyEvent && copyEvent(e)) { + return 'Copy'; + } + + const { key, shiftKey } = e; + switch (key) { + case 'ArrowLeft': + case 'ArrowRight': + case 'ArrowUp': + case 'ArrowDown': + return shiftKey ? `Shift${key}` : key; + default: + return null; + } + }; + + /** + * Respond to any of: + * All: cmd + A, select all + * Copy: cmd + C, copy + * Up, Right, Down, Left: some directional movement of the cursor + */ + handleSeqInteraction = async (type) => { + const { seq } = this.props; + const seqLength = seq.length; + const bpsPerBlock = this.props.bpsPerBlock || 1; + + switch (type) { + case 'SelectAll': { + this.selectAllHotkey(); + break; + } + case 'Copy': { + this.handleCopy(); + break; + } + case 'ArrowUp': + case 'ArrowRight': + case 'ArrowDown': + case 'ArrowLeft': + case 'ShiftArrowUp': + case 'ShiftArrowRight': + case 'ShiftArrowDown': + case 'ShiftArrowLeft': { + const { selection, setSelection } = this.props; + const { end, start } = selection; + + if (typeof start === 'undefined' || typeof end === 'undefined') { + return; + } + + let { clockwise } = selection; + let newPos = end; + if (type === 'ArrowUp' || type === 'ShiftArrowUp') { + // if there are multiple blocks or just one. If one, just inc by one + if (seqLength / bpsPerBlock > 1) { + newPos -= bpsPerBlock; + } else { + newPos -= 1; + } + } else if (type === 'ArrowRight' || type === 'ShiftArrowRight') { + newPos += 1; + } else if (type === 'ArrowDown' || type === 'ShiftArrowDown') { + // if there are multiple blocks or just one. If one, just inc by one + if (seqLength / bpsPerBlock > 1) { + newPos += bpsPerBlock; + } else { + newPos += 1; + } + } else if (type === 'ArrowLeft' || type === 'ShiftArrowLeft') { + newPos -= 1; + } + + if (newPos <= -1) { + newPos = seqLength + newPos; + } + if (newPos >= seqLength + 1) { + newPos -= seqLength; + } + const selLength = Math.abs(start - end); + clockwise = selLength === 0 ? type === 'ArrowRight' || type === 'ShiftArrowRight' || type === 'ArrowDown' || type === 'ShiftArrowDown' : clockwise; + if (newPos !== start && !type.startsWith('Shift')) { + setSelection({ + clockwise: true, + end: newPos, + start: newPos, + type: 'SEQ', + }); + } else if (type.startsWith('Shift')) { + setSelection({ + clockwise: clockwise, + end: newPos, + start: start, + type: 'SEQ', + }); + } + break; + } + default: { + break; + } + } + }; + + /** + * Copy the current sequence selection to the user's clipboard + */ + handleCopy = () => { + const { + selection: { end, ref, start }, + seq, + } = this.props; + if (!document) return; + + const formerFocus = document.activeElement; + const tempNode = document.createElement('textarea'); + if (ref === 'ALL') { + tempNode.innerText = seq[0]; + } else { + tempNode.innerText = seq[0].substring(start || 0, end); + } + if (document.body) { + document.body.appendChild(tempNode); + } + tempNode.select(); + document.execCommand('copy'); + tempNode.remove(); + if (formerFocus) { + // @ts-expect-error ts-migrate(2339) FIXME: Property 'focus' does not exist on type 'Element'. + formerFocus.focus(); + } + }; + + /** + * select all of the sequence + */ + selectAllHotkey = () => { + const { + selection, + selection: { start }, + setSelection, + } = this.props; + + const newSelection = { + ...selection, + clockwise: true, + end: start, + ref: 'ALL', + start: start, // ref to all means select the whole thing + }; + + setSelection(newSelection); + }; + + handleTripleClick = () => { + this.selectAllHotkey(); + }; + + resetClicked = debounce(() => { + this.clickedOnce = null; + this.clickedTwice = null; + }, 250); + + /** + * if the contextMenu button is clicked, check whether it was clicked + * over a noteworthy element, for which db mutations have been written. + * + * if it is, mutate the contextMenu to account for those potential interactions + * and pass on the click. Otherwise, do nothing + * + * if it is a regular click, pass on as normal + */ + handleMouseEvent = (e: React.MouseEvent) => { + const { handleMouseEvent } = this.props; + + // If the right click is performed + if (e.button === 2 && e.type==="contextmenu" ) { + e.preventDefault(); + this.setState({ rightClickMenu: true }); + // Box position (under the mouse) + this.setState({ xFloatingMenu: e.clientX - window.pageXOffset}); + this.setState({ yFloatingMenu: e.clientY - window.pageYOffset}); + return; + } + // Close the context menu if a left click is performed on the screen and the target is not a button + if(e.button !== 2 && this.state.rightClickMenu && !(e.target instanceof HTMLButtonElement)) this.setState({ rightClickMenu: false }); + + if (e.type === 'mouseup') { + this.resetClicked(); + if (this.clickedOnce === e.target && this.clickedTwice === e.target) { + this.handleTripleClick(); + this.resetClicked(); + } else if (this.clickedOnce === e.target && this.clickedTwice === null) { + this.clickedOnce = e.target; + this.clickedTwice = e.target; + this.resetClicked(); + } else { + this.clickedOnce = e.target; + this.resetClicked(); + } + } + const { button, ctrlKey, type } = e; + const ctxMenuClick = type === 'mousedown' && button === 0 && ctrlKey; + + if (e.button === 0 && !ctxMenuClick) { + // it's a mouse drag event or an element was clicked + handleMouseEvent(e); + } + }; + closeMenu = () => { + this.setState({ rightClickMenu: false }); + } + render = () => ( + + ); +} \ No newline at end of file diff --git a/src/MultipleSequenceSelectionHandler.tsx b/src/MultipleSequenceSelectionHandler.tsx new file mode 100644 index 000000000..66a706a4d --- /dev/null +++ b/src/MultipleSequenceSelectionHandler.tsx @@ -0,0 +1,448 @@ +import SelectionContext, { Selection, defaultSelection } from './selectionContext'; +import * as React from 'react'; + +interface RefSelection extends Selection { + viewer: 'LINEAR' | 'CIRCULAR'; +} + +export type InputRefFunc = (id: string, ref: RefSelection) => any; + +export type SeqVizMouseEvent = React.MouseEvent & { + target: { id: string }; +}; + +export interface SelectionHandlerProps { + bpsPerBlock: number; + center: { x: number; y: number }; + centralIndex: number; + children: (inputRef: InputRefFunc, handleMouseEvent: (e: SeqVizMouseEvent) => void, onUnmount: (ref: string) => void) => React.ReactNode; + seq: string[]; + setCentralIndex: (viewer: 'LINEAR' | 'CIRCULAR', index: number) => void; + setSelection: (selection: Selection) => void; + yDiff: number; +} + +/** + * SelectionHandler handles sequence selection. Each click, drag, etc, is + * noted and mapped to a sequence index. + */ +export default class SelectionHandler extends React.PureComponent { + static displayName = 'WithSelectionHandler'; + + static contextType = SelectionContext; + static context: React.ContextType; + declare context: React.ContextType; + + /** Only state is the selection range */ + state = { ...defaultSelection }; + + /* previous base cursor is over, used in circular drag select */ + previousBase: null | number = null; + + /* directionality of drag (true if clockwise), used in circular drag select */ + forward: null | boolean = null; + + /* full selection length, used in circular drag select */ + fullSelectionLength = 0; + + /* is the user currently dragging across the surface of the seqViewer? this is tracked on SeqBlocks in particular (onMouseOver), used in circular drag select */ + dragEvent = false; + + /* is there a selection already, used for shift-click catch up */ + selectionStarted = false; + + /* was the last selection action a shift click, used for shift-click catch up */ + shiftSelection = false; + + /* unix time of the last click (awful attempt at detecting double clicks) */ + lastClick = Date.now(); + + /** a map between the id of child elements and their associated SelectRanges */ + idToRange = new Map(); + + componentDidMount = () => { + if (!document) return; + document.addEventListener('mouseup', this.stopDrag); + }; + + componentWillUnmount = () => { + if (!document) return; + document.removeEventListener('mouseup', this.stopDrag); + }; + + /** Stop the current drag event from happening */ + stopDrag = () => { + this.dragEvent = false; + }; + + /** + * Called at start of drag to make sure checkers are reset to default state + */ + resetCircleDragVars = (start: null | number) => { + this.previousBase = start; + this.forward = null; + this.fullSelectionLength = 0; + this.dragEvent = true; // start a drag event + }; + + /** + * a ref callback for mapping the id of child to its SelectRange + * it stores the id of all elements + **/ + inputRef = (ref: string, selectRange: Selection) => { + this.idToRange.set(ref, { ref, ...selectRange }); + }; + + /** + * remove the ref by ID. + */ + removeMountedBlock = (ref: string) => { + this.idToRange.delete(ref); + }; + + /** + * the selected child element is something that is known by reference. + * update its SeqBlock's range (or any others affected) with the newly + * active range + */ + mouseEvent = (e: SeqVizMouseEvent) => { + const { setCentralIndex } = this.props; + + // should not be updating selection since it's not a drag event time + if ((e.type === 'mousemove' || e.type === 'mouseup') && !this.dragEvent) { + return; + } + + // storing this to figure out if it was a double click + const msSinceLastClick = Date.now() - this.lastClick; + + let knownRange = this.dragEvent + ? this.idToRange.get(e.currentTarget.id) // only look for SeqBlocks + : this.idToRange.get(e.target.id) || this.idToRange.get(e.currentTarget.id); // elements and SeqBlocks + if (!knownRange) { + return; // there isn't a known range with the id of the element + } + knownRange = { ...knownRange, end: knownRange.end || 0, start: knownRange.start || 0 }; + + const { direction, end, start, viewer } = knownRange; + switch (knownRange.type) { + case 'ANNOTATION': + case 'FIND': + case 'TRANSLATION': + case 'ENZYME': + case 'HIGHLIGHT': { + if (viewer !== 'LINEAR' && setCentralIndex) { + // if an element was clicked on the circular viewer, scroll the linear + // viewer so the element starts on the first SeqBlock + setCentralIndex('LINEAR', start || 0); + } + + // Annotation or find selection range + const clockwise = direction ? direction === 1 : true; + const selectionStart = clockwise ? start : end; + const selectionEnd = clockwise ? end : start; + + this.setSelection({ + ...knownRange, + clockwise: clockwise, + end: selectionEnd, + start: selectionStart, + }); + + this.dragEvent = false; + this.lastClick = Date.now(); + + break; + } + case 'AMINOACID': { + // Annotation or find selection range + const clockwise = direction ? direction === 1 : true; + let selectionStart = clockwise ? start : end; + let selectionEnd = clockwise ? end : start; + + // if they double clicked, select the whole translation + // https://en.wikipedia.org/wiki/Double-click#Speed_and_timing + if (msSinceLastClick < 300 && knownRange.parent) { + knownRange = { ...knownRange.parent, end: knownRange.parent.end || 0, start: knownRange.parent.start || 0 }; + selectionStart = clockwise ? knownRange.start : knownRange.end; + selectionEnd = clockwise ? knownRange.end : knownRange.start; + } + + this.setSelection({ + ...knownRange, + clockwise: clockwise, + end: selectionEnd, + start: selectionStart, + }); + + this.dragEvent = false; + this.lastClick = Date.now(); + + e.stopPropagation(); // necessary to stop a double click + + break; + } + case 'SEQ': { + if (viewer === 'LINEAR') { + this.handleLinearSeqEvent(e, { ...knownRange, end: knownRange.end || 0, start: knownRange.start || 0 }); + } else if (viewer === 'CIRCULAR') { + // this.handleCircularSeqEvent(e); + } + + break; + } + default: + } + }; + + /** + * Handle a sequence selection on a linear viewer + */ + handleLinearSeqEvent = (e: SeqVizMouseEvent, knownRange: { end: number; start: number }) => { + const selection = this.context; + + const currBase = this.calculateBaseLinear(e, knownRange); + + const clockwiseDrag = selection.start !== null && currBase >= (selection.start || 0); + + if (e.button === 0 && e.type === 'mousedown' && currBase !== null) { + // this is the start of a drag event + this.setSelection({ + ...defaultSelection, + clockwise: clockwiseDrag, + end: currBase, + start: e.shiftKey ? selection.start : currBase, + }); + this.dragEvent = true; + } else if (e.button === 0 && this.dragEvent && currBase !== null) { + // continue a drag event that's currently happening + this.setSelection({ + ...defaultSelection, + clockwise: clockwiseDrag, + end: currBase, + start: selection.start, + }); + } + }; + + /** + * Handle a sequence selection event on the circular viewer + */ + // handleCircularSeqEvent = (e: SeqVizMouseEvent) => { + // const { seq } = this.props; + // const selection = this.context; + + // const { start } = selection; + // let { clockwise, end } = selection; + + // const currBase = this.calculateBaseCircular(e); + // const seqLength = seq.length; + + // if (e.type === "mousedown") { + // const selStart = e.shiftKey ? start || 0 : currBase; + // const lookahead = e.shiftKey + // ? this.calcSelectionLength(selStart, currBase, false) + // : this.calcSelectionLength(selStart, currBase, true); // check clockwise selection length + // this.selectionStarted = lookahead > 0; // update check for whether there is a prior selection + // this.resetCircleDragVars(selStart); // begin drag event + + // this.setSelection({ + // ...defaultSelection, + // clockwise: clockwise, + // end: currBase, + // ref: "", + // start: selStart, + // type: "SEQ", + // }); + // } else if ( + // e.type === "mousemove" && + // this.dragEvent && + // currBase && + // this.previousBase && + // currBase !== this.previousBase + // ) { + // const increased = currBase > this.previousBase; // bases increased + // const changeThreshold = seqLength * 0.9; // threshold for unrealistic change by mouse movement + // const change = Math.abs(this.previousBase - currBase); // index change from this mouse movement + // const crossedZero = change > changeThreshold; // zero was crossed if base jumped more than changeThreshold + // this.forward = increased ? !crossedZero : crossedZero; // bases increased XOR crossed zero + // const lengthChange = crossedZero ? seqLength - change : change; // the change at the point where we cross zero has to be normalized by seqLength + // let sameDirectionMove = this.forward === selection.clockwise || selection.clockwise === null; // moving in same direction as start of drag or start of drag + + // if (sameDirectionMove) { + // this.fullSelectionLength += lengthChange; + // } else { + // this.fullSelectionLength -= lengthChange; + // } + + // this.previousBase = currBase; // done comparing with previous base, update previous base + // if (this.fullSelectionLength < seqLength * 0.01 && !this.shiftSelection) { + // clockwise = this.forward; // near selection start so selection direction is up for grabs + // const check = this.calcSelectionLength(selection.start || 0, currBase, this.forward); // check actual current selection length + // if (this.fullSelectionLength < 0) { + // // This is to correct for errors when dragging too fast + // this.fullSelectionLength = check; + // } + // if (check > this.fullSelectionLength) { + // // the actual selection length being greater than additive selection + // // length means we have come back to start and want to go in opposite direction + // clockwise = !this.forward; + // } + // end = currBase; + // } + // sameDirectionMove = this.forward === selection.clockwise; // recalculate this in case we've switched selection directionality + + // // check the selection length, this is agnostic to the ALL reference and + // // will always calculate from where you cursor is to the start of selection + // const check = this.calcSelectionLength(selection.start || 0, currBase, selection.clockwise || true); + + // if (this.selectionStarted && this.shiftSelection && check > this.fullSelectionLength) { + // this.fullSelectionLength = check; // shift select catch up + // } + + // // there is an ongoing drag in the same direction as the direction the selection started in + // const sameDirectionDrag = this.dragEvent && sameDirectionMove; + // const fullSelection = false; // selection is full sequence + + // // TODO: fix const fullSelection = currRef === "ALL"; // selection is full sequence + // const hitFullSelection = !fullSelection && this.fullSelectionLength >= seqLength; // selection became full sequence + // if (sameDirectionDrag && hitFullSelection) { + // end = start; + // } else if (fullSelection) { + // // this ensures that backtracking doesn't require making up to your overshoot forward circles + // this.fullSelectionLength = seqLength + (this.fullSelectionLength % seqLength); + + // if ( + // !sameDirectionDrag && // changed direction + // check === this.fullSelectionLength - seqLength && // back tracking + // check > seqLength * 0.9 // passed selection start + // ) { + // end = currBase; // start decreasing selection size due to backtracking + + // // reset calculated additive selection length to normal now that we are not at ALL length + // this.fullSelectionLength = this.fullSelectionLength - seqLength; + // } + // } else { + // end = currBase; // nothing special just update the selection + // } + // this.shiftSelection = false; + + // this.setSelection({ + // ...defaultSelection, + // clockwise: clockwise, + // end: end, + // start: start, + // type: "SEQ", + // }); + // } + // }; + + /** + * in a linear sequence viewer, given the bounding box of a component, the basepairs + * by SeqBlock and the position of the mouse event, find the current base + */ + calculateBaseLinear = (e: SeqVizMouseEvent, knownRange: { end: number; start: number }) => { + const { bpsPerBlock } = this.props; + + const block = e.currentTarget.getBoundingClientRect(); + const distFromLeft: number = e.clientX - block.left; + const ratioFromLeft = distFromLeft / block.width; + const bpsFromLeft = Math.round(ratioFromLeft * (bpsPerBlock as number)); + + return Math.min(knownRange.start + bpsFromLeft, knownRange.end); + }; + + /** + * in a circular plasmid viewer, given the center of the viewer, and position of the + * mouse event, find the currently hovered or clicked basepair + */ + // calculateBaseCircular = (e: SeqVizMouseEvent) => { + // const { center, centralIndex, seq, yDiff } = this.props; + + // if (!center) return 0; + + // const block = e.currentTarget.getBoundingClientRect(); + + // // position on the plasmid viewer + // const distFromLeft = e.clientX - block.left; + // const distFromTop = e.clientY - block.top; + + // // position relative to center + // const x = distFromLeft - center.x; + // const y = distFromTop - (center.y + (yDiff as number)); + + // const riseToRun = y / x; + // const posInRads = Math.atan(riseToRun); + // let posInDeg = posInRads * (180 / Math.PI) + 90; // convert and shift to vertical is 0 + // if (x < 0) { + // posInDeg += 180; // left half of the viewer + // } + // const posInPerc = posInDeg / 360; // position as a percentage + + // let currBase = Math.round(seq.length * posInPerc); // account for rotation of the viewer + // currBase += centralIndex as number; + // if (currBase > seq.length) { + // currBase -= seq.length; + // } + // return currBase; + // }; + + /** + * Update the selection in state. Only update the specified + * properties of the selection that should be updated. + */ + setSelection = (newSelection: Selection) => { + const selection = this.context; + const { setSelection } = this.props; + + if ( + newSelection.start === selection.start && + newSelection.end === selection.end && + newSelection.ref === selection.ref && + // to support re-clicking the annotation and causing it to fire a la gh issue https://github.com/Lattice-Automation/seqviz/issues/142 + ['SEQ', 'AMINOACID', ''].includes(newSelection.type || '') + ) { + return; + } + const { clockwise, end, name, ref, start, type }: any = { + ...selection, + ...newSelection, + }; + + const length = this.calcSelectionLength(start, end, clockwise); + + setSelection({ + clockwise, + end, + length, + name, + ref, + start, + type, + }); + }; + + /** + * Check what the length of the selection is in circle drag select + */ + calcSelectionLength = (start: number, base: number, clock: boolean | null) => { + const [seq] = this.props.seq; + if (base < start && !clock) { + return start - base; + } + if (base > start && !clock) { + return start + (seq.length - base); + } + if (base > start && clock) { + return base - start; + } + if (base < start && clock) { + return seq.length - start + base; + } + return 0; + }; + + render() { + return this.props.children(this.inputRef, this.mouseEvent, this.removeMountedBlock); + } +} diff --git a/src/SeqViewerContainer.tsx b/src/SeqViewerContainer.tsx index e17bd2651..0b844cd99 100644 --- a/src/SeqViewerContainer.tsx +++ b/src/SeqViewerContainer.tsx @@ -9,6 +9,10 @@ import CentralIndexContext from "./centralIndexContext"; import { Annotation, CutSite, Highlight, NameRange, Primer, SeqType } from "./elements"; import { isEqual } from "./isEqual"; import SelectionContext, { ExternalSelection, Selection, defaultSelection } from "./selectionContext"; +import MultipleSequenceSelectionHandler from "./MultipleSequenceSelectionHandler"; +import { MultipleEventHandler } from "./MultipleEventHandler"; +import Alignment from "./Alignment/Alignment"; + /** * This is the width in pixels of a character that's 12px @@ -40,6 +44,7 @@ interface SeqViewerContainerProps { height: number; highlights: Highlight[]; name: string; + nameToCompare: string; onSelection: (selection: Selection) => void; primers: Primer[]; refs?: SeqVizChildRefs; @@ -55,7 +60,7 @@ interface SeqViewerContainerProps { /** testSize is a forced height/width that overwrites anything from sizeMe. For testing */ testSize?: { height: number; width: number }; translations: NameRange[]; - viewer: "linear" | "circular" | "both" | "both_flip"; + viewer: "linear" | "circular" | "both" | "both_flip" | "alignment"; width: number; zoom: { circular: number; linear: number }; } @@ -254,15 +259,62 @@ class SeqViewerContainer extends React.Component { + const { seq, seqType, colorized, aagrouping, viewer } = this.props; + const size = this.props.testSize || { height: this.props.height, width: this.props.width }; + const zoom = this.props.zoom.linear; + + const seqFontSize = Math.min(Math.round(zoom * 0.1 + 9.5), 18); // max 18px + + // otherwise the sequence needs to be cut into smaller subsequences + // a sliding scale in width related to the degree of zoom currently active + let bpsPerBlock = Math.round((size.width / seqFontSize) * 1.4) || 1; // width / 1 * seqFontSize + + if (seqType === "aa" && colorized && !aagrouping) { + bpsPerBlock = Math.round(bpsPerBlock / 3); // more space for each amino acid + } + + if (zoom <= 5) { + bpsPerBlock *= 3; + } else if (zoom <= 10) { + // really ramp up the range, since at this zoom it'll just be a line + bpsPerBlock *= 2; + } else if (zoom > 70) { + // keep font height the same but scale number of bps in one row + bpsPerBlock = Math.round(bpsPerBlock * (70 / zoom)); + } + bpsPerBlock = Math.max(1, bpsPerBlock); + + if (size.width && bpsPerBlock < seq.length) { + size.width -= 28; // -28 px for the padding (10px) + scroll bar (18px) + } + + const charWidth = size.width / bpsPerBlock; // width of each basepair + + const lineHeight = 1.4 * seqFontSize; // aspect ratio is 1.4 for roboto mono + const elementHeight = 16; // the height, in pixels, of annotations, ORFs, etc + return { + ...this.props, + bpsPerBlock, + charWidth, + viewer, + elementHeight, + lineHeight, + seqFontSize, + size, + zoom: { linear: zoom }, + }; + }; render() { - const { selection: selectionProp, seq, viewer } = this.props; + const { selection: selectionProp, seq, sequenceToCompare,viewer } = this.props; const { centralIndex, selection } = this.state; const linearProps = this.linearProps(); const circularProps = this.circularProps(); + const alignmentProps = this.alignmentProps(); const mergedSelection = this.getSelection(selection, selectionProp); - + console.log(this.props.children) return (
- - {(inputRef, handleMouseEvent, onUnmount) => ( - - {this.props.children ? ( - this.props.children({ - circularProps, - handleMouseEvent, - inputRef, - linearProps, - onUnmount, - }) - ) : ( - <> - {/* TODO: this sucks, some breaking refactor in future should get rid of it SeqViewer */} - {viewer === "linear" && ( - - )} - {viewer === "circular" && ( - - )} - {viewer === "both" && ( - <> - - - - )} - {viewer === "both_flip" && ( - <> + {(inputRef, handleMouseEvent, onUnmount) => ( + + + {this.props.children ? ( + this.props.children({ + circularProps, + handleMouseEvent, + inputRef, + linearProps, + onUnmount, + }) + ) : ( + <> + + {viewer === "linear" && ( + )} + {viewer === "circular" && ( - - )} - - )} - + )} + {viewer === "both" && ( + <> + + + + )} + {viewer === "both_flip" && ( + <> + + + + )} + + )} + + + )} + } + {viewer === 'alignment' && + <> + {/* {seq.length !== seqToCompare.length &&
+ Attention: the two inserted sequences do not have the same length +
} */} + + {(inputRef, handleMouseEvent, onUnmount) => ( + + + + + + + )} - -
-
+ + + } + +
); } diff --git a/src/SeqViz.tsx b/src/SeqViz.tsx index 35f1cfa70..728e8c6d7 100644 --- a/src/SeqViz.tsx +++ b/src/SeqViz.tsx @@ -93,6 +93,7 @@ export interface SeqVizProps { /** the name of the sequence to show in the middle of the circular viewer */ name?: string; + nameToCompare?: string; /** a callback that's executed on each change to the search parameters or sequence */ onSearch?: (search: Range[]) => void; @@ -126,6 +127,9 @@ export interface SeqVizProps { /** a sequence to render. Can be DNA, RNA, or an amino acid sequence. Setting accession or file overrides this */ seq?: string; + + /** a sequence to compare in the alignment. Can be DNA, RNA, or an amino acid sequence. Setting accession or file overrides this */ + sequenceToCompare?: string; /** the type of the sequence. If this isn't passed, the type is guessed */ seqType?: "dna" | "rna" | "aa"; @@ -150,7 +154,7 @@ export interface SeqVizProps { translations?: TranslationProp[]; /** the orientation of the viewer(s). "both", the default, has a circular viewer on left and a linear viewer on right. */ - viewer?: "linear" | "circular" | "both" | "both_flip"; + viewer?: "linear" | "circular" | "both" | "both_flip" | "alignment"; /** how large to make the sequence and elements [0,100]. A larger zoom increases the size of text and elements for that viewer. */ zoom?: { @@ -171,8 +175,10 @@ export interface SeqVizState { compSeq: string; cutSites: CutSite[]; name: string; + nameToCompare: string; search: NameRange[]; seq: string; + sequenceToCompare?: string; seqType: SeqType; } @@ -192,6 +198,7 @@ export default class SeqViz extends React.Component { enzymes: [], enzymesCustom: {}, name: "", + nameToCompare: "", onSearch: (_: Range[]) => null, onSelection: (_: Selection) => null, primers: [], @@ -199,6 +206,7 @@ export default class SeqViz extends React.Component { search: { mismatch: 0, query: "" }, selectAllEvent: e => e.key === "a" && (e.metaKey || e.ctrlKey), seq: "", + sequenceToCompare: "", showComplement: true, showIndex: true, style: {},