diff --git a/README.md b/README.md index 49a0b0f42..18bc41898 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ # scadnano [scadnano](http://scadnano.org) -(" -scriptable-cadnano", [source code repository here](https://github.com/UC-Davis-molecular-computing/scadnano)) +("scriptable-cadnano", [source code repository here](https://github.com/UC-Davis-molecular-computing/scadnano)) is a program for designing synthetic DNA structures such as DNA origami. The scadnano project is developed and maintained by the UC Davis Molecular Computing group. Note that [cadnano](https://cadnano.org) is a separate project, developed and maintained by diff --git a/lib/src/actions/actions.dart b/lib/src/actions/actions.dart index fb83be0c4..a4feaba79 100644 --- a/lib/src/actions/actions.dart +++ b/lib/src/actions/actions.dart @@ -1571,9 +1571,13 @@ abstract class SelectAllWithSameAsSelected BuiltList get traits; + bool get exclude_scaffolds; + /************************ begin BuiltValue boilerplate ************************/ - factory SelectAllWithSameAsSelected({BuiltList templates, BuiltList traits}) = - _$SelectAllWithSameAsSelected._; + factory SelectAllWithSameAsSelected( + {BuiltList templates, + BuiltList traits, + bool exclude_scaffolds}) = _$SelectAllWithSameAsSelected._; SelectAllWithSameAsSelected._(); @@ -2076,6 +2080,8 @@ abstract class ExportDNA with BuiltJsonSerializable implements Action, Built b ..include_scaffold = include_scaffold ..include_only_selected_strands = include_only_selected_strands + ..exclude_selected_strands = exclude_selected_strands ..export_dna_format = export_dna_format ..delimiter = delimiter ..domain_delimiter = domain_delimiter @@ -2153,6 +2162,27 @@ abstract class ExportSvg with BuiltJsonSerializable implements Action, Built { + bool get export_svg_text_separately; + + /************************ begin BuiltValue boilerplate ************************/ + factory ExportSvgTextSeparatelySet(bool export_svg_text_separately) => + ExportSvgTextSeparatelySet.from((b) => b..export_svg_text_separately = export_svg_text_separately); + + /************************ begin BuiltValue boilerplate ************************/ + factory ExportSvgTextSeparatelySet.from([void Function(ExportSvgTextSeparatelySetBuilder) updates]) = + _$ExportSvgTextSeparatelySet; + + ExportSvgTextSeparatelySet._(); + + static Serializer get serializer => _$exportSvgTextSeparatelySetSerializer; +} + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // Strand part action @@ -3981,6 +4011,22 @@ abstract class ExampleDesignsLoad static Serializer get serializer => _$exampleDesignsLoadSerializer; } +///////////////////////////////////////////////////////////////////////////////////////////////////////////// +// pair lines display + +abstract class BasePairTypeSet + with BuiltJsonSerializable + implements Action, Built { + int get selected_idx; + + /************************ begin BuiltValue boilerplate ************************/ + factory BasePairTypeSet({int selected_idx}) = _$BasePairTypeSet._; + + BasePairTypeSet._(); + + static Serializer get serializer => _$basePairTypeSetSerializer; +} + ///////////////////////////////////////////////////////////////////////////////////////////////////////////// // change helix position @@ -4348,6 +4394,25 @@ abstract class DisablePngCachingDnaSequencesSet _$disablePngCachingDnaSequencesSetSerializer; } +abstract class RetainStrandColorOnSelectionSet + with BuiltJsonSerializable + implements Action, Built { + bool get retain_strand_color_on_selection; + + /************************ begin BuiltValue boilerplate ************************/ + factory RetainStrandColorOnSelectionSet(bool retain_strand_color_on_selection) => + RetainStrandColorOnSelectionSet.from( + (b) => b..retain_strand_color_on_selection = retain_strand_color_on_selection); + + factory RetainStrandColorOnSelectionSet.from( + [void Function(RetainStrandColorOnSelectionSetBuilder) updates]) = _$RetainStrandColorOnSelectionSet; + + RetainStrandColorOnSelectionSet._(); + + static Serializer get serializer => + _$retainStrandColorOnSelectionSetSerializer; +} + abstract class DisplayReverseDNARightSideUpSet with BuiltJsonSerializable implements Action, Built { @@ -4458,3 +4523,40 @@ abstract class OxdnaExport @memoized int get hashCode; } + +abstract class OxviewExport + with BuiltJsonSerializable + implements Action, Built { + bool get selected_strands_only; + + /************************ begin BuiltValue boilerplate ************************/ + factory OxviewExport({bool selected_strands_only = false}) { + return OxviewExport.from((b) => b..selected_strands_only = selected_strands_only); + } + + OxviewExport._(); + + factory OxviewExport.from([void Function(OxviewExportBuilder) updates]) = _$OxviewExport; + + static Serializer get serializer => _$oxviewExportSerializer; + + @memoized + int get hashCode; +} + +abstract class OxExportOnlySelectedStrandsSet + with BuiltJsonSerializable + implements Action, Built { + bool get only_selected; + + /************************ begin BuiltValue boilerplate ************************/ + factory OxExportOnlySelectedStrandsSet({bool only_selected}) = _$OxExportOnlySelectedStrandsSet._; + + OxExportOnlySelectedStrandsSet._(); + + static Serializer get serializer => + _$oxExportOnlySelectedStrandsSetSerializer; + + @memoized + int get hashCode; +} diff --git a/lib/src/constants.dart b/lib/src/constants.dart index b9e5426eb..6342bcb80 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -8,12 +8,12 @@ import 'state/grid.dart'; // WARNING: Do not modify line below, except for the version string // (and also add new version string to scadnano_versions_to_link). -const String CURRENT_VERSION = "0.19.1"; +const String CURRENT_VERSION = "0.19.2"; const String INITIAL_VERSION = "0.1.0"; // scadnano versions that we deploy so that older versions can be used. final scadnano_older_versions_to_link = [ - "0.19.0", + "0.19.1", "0.18.10", "0.17.14", // "0.17.13", @@ -428,8 +428,10 @@ const css_selector_scaffold = 'scaffold'; const css_selector_staple = 'staple'; const css_selector_domain = 'domain-line'; const css_selector_base_pair_line = 'base-pair-line'; +const css_selector_base_pair_rect = 'base-pair-rect'; const css_selector_extension = 'extension-line'; const css_selector_crossover = 'crossover-curve'; +const css_selector_crossover_same_helix = 'crossover-curve-same-helix'; const css_selector_loopout = 'loopout-curve'; const css_selector_end_5p_strand = 'five-prime-end-first-substrand'; const css_selector_end_3p_strand = 'three-prime-end-last-substrand'; @@ -465,6 +467,7 @@ const css_selector_deletion = 'deletion-cross'; const css_selector_insertion_group = 'insertion-group'; const css_selector_deletion_group = 'deletion-group'; const css_selector_selected = 'selected'; +const css_selector_selected_pink = 'selected-pink'; const css_selector_context_menu_item_disabled = 'context_menu_item_disabled'; diff --git a/lib/src/middleware/export_dna_sequences.dart b/lib/src/middleware/export_dna_sequences.dart index 5c8e1c1b5..86a50c2ad 100644 --- a/lib/src/middleware/export_dna_sequences.dart +++ b/lib/src/middleware/export_dna_sequences.dart @@ -45,6 +45,11 @@ export_dna_sequences_middleware(Store store, action, NextDispatcher ne List strands; if (action.include_only_selected_strands) { strands = state.ui_state.selectables_store.selected_strands.toList(); + } else if (action.exclude_selected_strands) { + strands = state.design.strands.toList(); + for (var strand in state.ui_state.selectables_store.selected_strands) { + strands.remove(strand); + } } else { strands = state.design.strands.toList(); } @@ -107,15 +112,16 @@ Future export_dna() async { int idx_include_scaffold = 0; int idx_include_only_selected_strands = 1; - int idx_format_str = 2; - int idx_delimiter = 3; - int idx_domain_delimiter = 4; - int idx_column_major_plate = 5; - int idx_sort = 6; - int idx_column_major_strand = 7; - int idx_strand_order_str = 8; + int idx_exclude_selected_strands = 2; + int idx_format_str = 3; + int idx_delimiter = 4; + int idx_domain_delimiter = 5; + int idx_column_major_plate = 6; + int idx_sort = 7; + int idx_column_major_strand = 8; + int idx_strand_order_str = 9; - List items = List.filled(9, null); + List items = List.filled(idx_strand_order_str + 1, null); items[idx_delimiter] = DialogText(label: 'delimiter between IDT fields', value: ',', tooltip: '''\ Delimiter to separate IDT fields in a "bulk input" text file, for instance if set to ";", then a line @@ -132,6 +138,7 @@ if it had three domains each of length 5.'''); items[idx_include_scaffold] = DialogCheckbox(label: 'include scaffold', value: false); items[idx_include_only_selected_strands] = DialogCheckbox(label: 'include only selected strands', value: false); + items[idx_exclude_selected_strands] = DialogCheckbox(label: 'exclude selected strands', value: false); items[idx_format_str] = DialogRadio( label: 'export format', options: export_options, @@ -178,6 +185,10 @@ which part of the strand to use as the address. title: 'export DNA sequences', type: DialogType.export_dna_sequences, items: items, + disable_when_any_checkboxes_on: { + idx_include_only_selected_strands: [idx_exclude_selected_strands], + idx_exclude_selected_strands: [idx_include_only_selected_strands], + }, disable_when_any_checkboxes_off: { idx_column_major_strand: [idx_sort], idx_strand_order_str: [idx_sort], @@ -199,6 +210,7 @@ which part of the strand to use as the address. bool include_scaffold = (results[idx_include_scaffold] as DialogCheckbox).value; bool include_only_selected_strands = (results[idx_include_only_selected_strands] as DialogCheckbox).value; + bool exclude_selected_strands = (results[idx_exclude_selected_strands] as DialogCheckbox).value; String format_str = (results[idx_format_str] as DialogRadio).value; bool sort = (results[idx_sort] as DialogCheckbox).value; StrandOrder strand_order = null; @@ -213,9 +225,12 @@ which part of the strand to use as the address. String delimiter = (results[idx_delimiter] as DialogText).value; String domain_delimiter = (results[idx_domain_delimiter] as DialogText).value; + assert(!(include_only_selected_strands && exclude_selected_strands)); + app.dispatch(actions.ExportDNA( include_scaffold: include_scaffold, include_only_selected_strands: include_only_selected_strands, + exclude_selected_strands: exclude_selected_strands, export_dna_format: format, delimiter: delimiter, domain_delimiter: domain_delimiter, diff --git a/lib/src/middleware/export_svg.dart b/lib/src/middleware/export_svg.dart index 83d3f8636..92e7f3135 100644 --- a/lib/src/middleware/export_svg.dart +++ b/lib/src/middleware/export_svg.dart @@ -1,9 +1,15 @@ import 'dart:html'; import 'dart:svg' as svg; import 'dart:svg'; +import 'dart:math' as math; +import 'package:built_collection/built_collection.dart'; +import 'package:over_react/over_react.dart'; import 'package:redux/redux.dart'; import 'package:scadnano/src/middleware/system_clipboard.dart'; +import 'package:scadnano/src/state/base_pair_display_type.dart'; +import 'package:scadnano/src/state/strand.dart'; +import 'package:scadnano/src/view/design_main_dna_sequence.dart'; import '../app.dart'; import '../state/app_state.dart'; @@ -40,23 +46,28 @@ export_svg_middleware(Store store, dynamic action, NextDispatcher next action.type == actions.ExportSvgType.selected) { var elt = document.getElementById("main-view-svg"); if (action.type == actions.ExportSvgType.selected) { - List selected_elts = get_selected_strands(store); + List selected_elts = get_selected_svg_elements(store.state); if (selected_elts.length == 0) { window.alert("No strands are selected, so there is nothing to export.\n" "Please select some strands before choosing this option."); } else { - var cloned_svg_element_with_style = get_cloned_svg_element_with_style(selected_elts); + var cloned_svg_element_with_style = get_cloned_svg_element_with_style( + selected_elts, store.state.ui_state.export_svg_text_separately); _export_from_element(cloned_svg_element_with_style, 'selected'); } - } else + } else { + if (store.state.ui_state.export_svg_text_separately) { + elt = get_cloned_svg_element_with_style([elt], store.state.ui_state.export_svg_text_separately); + } _export_from_element(elt, 'main'); + } } if (action.type == actions.ExportSvgType.side || action.type == actions.ExportSvgType.both) { var elt = document.getElementById("side-view-svg"); _export_from_element(elt, 'side'); } } else if (action is actions.CopySelectedStandsToClipboardImage) { - List selected_elts = get_selected_strands(store); + List selected_elts = get_selected_svg_elements(store.state); if (selected_elts.length != 0) { _copy_from_elements(selected_elts); } @@ -67,23 +78,188 @@ export_svg_middleware(Store store, dynamic action, NextDispatcher next } } -List get_selected_strands(Store store) { - var selected_strands = store.state.ui_state.selectables_store.selected_strands; +List get_selected_svg_elements(AppState state) { + BuiltSet selected_strands = state.ui_state.selectables_store.selected_strands; List selected_elts = []; - if (selected_strands.length != 0) { - for (var strand in selected_strands) { + if (app.state.ui_state.base_pair_display_type != BasePairDisplayType.none) { + var base_pairs = state.ui_state.show_base_pair_lines_with_mismatches + ? state.design.selected_base_pairs_with_mismatches(selected_strands) + : state.design.selected_base_pairs(selected_strands); + selected_elts.addAll(get_svg_elements_of_base_pairs(base_pairs)); + } + selected_elts.addAll(get_svg_elements_of_strands(selected_strands)); + return selected_elts; +} + +List get_svg_elements_of_strands(BuiltSet strands) { + List elts = []; + if (strands.length != 0) { + for (var strand in strands) { var strand_elt = document.getElementById(strand.id); var dna_seq_elt = document.getElementById('dna-sequence-${strand.id}'); var mismatch_elts = document.getElementsByClassName('mismatch-${strand.id}'); - selected_elts.addAll([strand_elt, if (dna_seq_elt != null) dna_seq_elt, ...mismatch_elts]); + elts.addAll([strand_elt, if (dna_seq_elt != null) dna_seq_elt, ...mismatch_elts]); } } - return selected_elts; + return elts; +} + +List get_svg_elements_of_base_pairs(BuiltMap> base_pairs) { + List elts = []; + for (int helix in base_pairs.keys) { + elts.addAll(base_pairs[helix].map((offset) => document.getElementById('base_pair-${helix}-${offset}'))); + } + return elts; +} + +List rotate_vector(List vec, double ang) { + ang = ang * (math.pi / 180); + var cos = math.cos(ang); + var sin = math.sin(ang); + return [vec[0] * cos - vec[1] * sin, vec[0] * sin + vec[1] * cos]; +} + +// gets the height of a character in font in px +double get_text_height(String font) { + CanvasElement element = document.createElement("canvas"); + CanvasRenderingContext2D context = element.getContext("2d"); + context.font = font; + return double.tryParse(context.font.replaceAll(RegExp(r'[^0-9\.]'), '')); +} + +// returns a matrix that represents the change made by dominant-baseline css property +DomMatrix dominant_baseline_matrix(String dominant_baseline, double rot, String font) { + switch (dominant_baseline) { + case "ideographic": + return new DomMatrix([ + 1, + 0, + 0, + 1, + ...rotate_vector([0, (-3 * get_text_height(font)) / 12], rot) + ]); + case "hanging": + return new DomMatrix([ + 1, + 0, + 0, + 1, + ...rotate_vector([0, (9 * get_text_height(font)) / 12], rot) + ]); + case "central": + return new DomMatrix([ + 1, + 0, + 0, + 1, + ...rotate_vector([0, (4 * get_text_height(font)) / 12], rot) + ]); + default: + return new DomMatrix([1, 0, 0, 1, 0, 0]); + } +} + +Map matrix_to_map(Matrix matrix) { + return { + "a": matrix.a, + "b": matrix.b, + "c": matrix.c, + "d": matrix.d, + "e": matrix.e, + "f": matrix.f, + }; +} + +Map dom_matrix_to_map(DomMatrix matrix) { + return { + "a": matrix.a, + "b": matrix.b, + "c": matrix.c, + "d": matrix.d, + "e": matrix.e, + "f": matrix.f, + }; +} + +Map point_to_map(svg.Point point) { + return {"x": point.x, "y": point.y}; +} + +// creates a new separate text svg for the jth character on a svg text element +TextElement create_portable_element(TextContentElement text_ele, int j) { + TextElement char_ele = document.createElementNS("http://www.w3.org/2000/svg", "text"); + char_ele.text = text_ele.text[j]; + char_ele.setAttribute("style", text_ele.style.cssText); + var pos = DomPoint.fromPoint(point_to_map(text_ele.getStartPositionOfChar(j))); + var rot = text_ele.getRotationOfChar(j); + + for (int i = 0; i < text_ele.transform.baseVal.numberOfItems; ++i) { + var item = text_ele.transform.baseVal.getItem(i); + pos = pos.matrixTransform(matrix_to_map(item.matrix)); + rot = item.angle; + } + if (char_ele.style.getPropertyValue("dominant-baseline") != "") { + pos = pos.matrixTransform(dom_matrix_to_map(dominant_baseline_matrix( + char_ele.style.getPropertyValue("dominant-baseline"), + rot, + text_ele.style.fontSize + " " + text_ele.style.fontFamily))); + } + char_ele.style.setProperty("dominant-baseline", ""); + char_ele.style.setProperty("text-anchor", "start"); + if (text_ele.classes.any([ + "loopout-extension-length", + "dna-seq-insertion", + "dna-seq-loopout", + "dna-seq-extension", + "dna-seq" + ].contains)) { + char_ele.style.setProperty( + "text-shadow", // doesn't work in PowerPoint + "-0.7px -0.7px 0 #fff, 0.7px -0.7px 0 #fff, -0.7px 0.7px 0 #fff, 0.7px 0.7px 0 #fff"); + } + char_ele.setAttribute("x", pos.x.toString()); + char_ele.setAttribute("y", pos.y.toString()); + char_ele.setAttribute("transform", "rotate(${rot} ${pos.x} ${pos.y})"); + return char_ele; } -SvgSvgElement get_cloned_svg_element_with_style(List selected_elts) { +// makes a svg compatible for PowerPoint +Element make_portable(Element src) { + var src_children = src.querySelectorAll("*"); + document.body.append(src); + for (int i = 0; i < src_children.length; ++i) { + if (src_children[i] is svg.TextContentElement) { + TextContentElement text_ele = src_children[i] as TextContentElement; + if (text_ele.children.length == 1 && text_ele.children[0].tagName == "textPath") { + continue; + } + List portable_eles = []; + for (int j = 0; j < text_ele.getNumberOfChars(); ++j) { + var char_ele = create_portable_element(text_ele, j); + portable_eles.add(char_ele); + } + if (text_ele is TextPathElement) { + // move TextPath children up and delete the TextPath + var parent = text_ele.parent; + var new_parent = document.createElementNS("http://www.w3.org/2000/svg", "g"); + parent.parent.append(new_parent); + new_parent.append(text_ele); + parent.remove(); + } + portable_eles.forEach((v) => text_ele.parentNode.append(v)); + text_ele.remove(); + } + } + src.remove(); + return src; +} + +SvgSvgElement get_cloned_svg_element_with_style(List selected_elts, bool separate_text) { var cloned_svg_element_with_style = SvgSvgElement() ..children = selected_elts.map(clone_and_apply_style).toList(); + if (separate_text) { + cloned_svg_element_with_style = make_portable(cloned_svg_element_with_style); + } // we can't get bbox without it being added to the DOM first document.body.append(cloned_svg_element_with_style); @@ -92,7 +268,7 @@ SvgSvgElement get_cloned_svg_element_with_style(List selected_elts) { // have to add some padding to viewbox, for some reason bbox doesn't always fit it by a few pixels?? cloned_svg_element_with_style.setAttribute('viewBox', - '${bbox.x.floor() - 1} ${bbox.y.floor() - 1} ${bbox.width.ceil() + 3} ${bbox.height.ceil() + 3}'); + '${bbox.x.floor() - 1} ${bbox.y.floor() - 1} ${bbox.width.ceil() + 3} ${bbox.height.ceil() + 6}'); return cloned_svg_element_with_style; } @@ -125,16 +301,17 @@ _export_svg(svg.SvgSvgElement svg_element, String filename_append) { } _copy_from_elements(List svg_elements) { - var cloned_svg_element_with_style = get_cloned_svg_element_with_style(svg_elements); + var cloned_svg_element_with_style = get_cloned_svg_element_with_style(svg_elements, false); util.copy_svg_as_png(cloned_svg_element_with_style); } _export_from_element(Element svg_element, String filename_append) { var cloned_svg_element_with_style; - if (filename_append != "selected") + if (filename_append != "selected") { cloned_svg_element_with_style = clone_and_apply_style(svg_element); - else + } else { cloned_svg_element_with_style = svg_element; + } // if element is not an svg element (it can be a child element of svg e.g. groups, lines, text, etc), wrap in svg tag if (!(svg_element is svg.SvgSvgElement)) cloned_svg_element_with_style = SvgSvgElement()..children = [cloned_svg_element_with_style]; @@ -175,12 +352,12 @@ final relevant_styles = { Element clone_and_apply_style(Element elt_orig) { Element elt_styled = elt_orig.clone(true); - bool selected = elt_orig.classes.contains('selected'); + bool selected = elt_orig.classes.contains('selected-pink'); - elt_orig.classes.remove('selected'); + elt_orig.classes.remove('selected-pink'); clone_and_apply_style_rec(elt_styled, elt_orig); - if (selected) elt_orig.classes.add('selected'); + if (selected) elt_orig.classes.add('selected-pink'); // need to get from original since it has been rendered (styled hasn't been rendered so has 0 bounding box // also need to get from g element, not svg element, since svg element dimensions based on original @@ -196,6 +373,7 @@ Element clone_and_apply_style(Element elt_orig) { } clone_and_apply_style_rec(Element elt_styled, Element elt_orig, {int depth = 0}) { + // print('elt_styled ${elt_styled.id} and elt_orig ${elt_orig.id}'); // Set children_styled_to_remove = {}; var tag_name = elt_styled.tagName; @@ -208,7 +386,8 @@ clone_and_apply_style_rec(Element elt_styled, Element elt_orig, {int depth = 0}) if (relevant_styles.keys.contains(tag_name)) { var style_def = elt_orig.getComputedStyle(); - + // print( + // 'id ${elt_styled.id} fill ${style_def.getPropertyValue("fill")} relevant_styles ${relevant_styles[tag_name]}'); //TODO: figure out how to remove nodes that aren't visible; // getting error "Unsupported operation: Cannot setRange on filtered list" when removing children // if (style_def.visibility == 'hidden') { @@ -222,14 +401,17 @@ clone_and_apply_style_rec(Element elt_styled, Element elt_orig, {int depth = 0}) // correcting for this bug in InkScape that causes it to render hidden SVG objects: // https://bugs.launchpad.net/inkscape/+bug/1577763 if (style_name == 'visibility' && style_value == 'hidden') { - style_strings.add('display: none'); + // style_strings.add('display: none'); + elt_styled.style.setProperty('display', 'none'); } - style_strings.add('${style_name}: ${style_value}'); + elt_styled.style.setProperty(style_name, style_value); + // style_strings.add('${style_name}: ${style_value};'); } } - var style_string = style_strings.join('; ') + ';'; + // var style_string = style_strings.join(' '); - elt_styled.setAttribute("style", style_string); + // elt_styled.setAttribute("style", style_string); + // print(elt_styled.styleMap); // print('${' ' * depth * 2} ${tag_name} ${elt_orig.classes.toList()} style: $style_string'); } diff --git a/lib/src/middleware/oxdna_export.dart b/lib/src/middleware/oxdna_export.dart index 6483933e0..eb1692828 100644 --- a/lib/src/middleware/oxdna_export.dart +++ b/lib/src/middleware/oxdna_export.dart @@ -1,6 +1,8 @@ +import 'dart:convert'; import 'dart:html'; import 'dart:math'; import 'package:path/path.dart' as path; +import 'package:quiver/iterables.dart' as quiver; import 'package:redux/redux.dart'; import 'package:scadnano/src/state/design.dart'; @@ -16,9 +18,11 @@ import '../state/app_state.dart'; import '../actions/actions.dart' as actions; import '../state/helix.dart'; import '../util.dart' as util; +import 'export_cadnano_or_codenano_file.dart' as export_cadnano; +import '../constants.dart' as constants; oxdna_export_middleware(Store store, dynamic action, NextDispatcher next) { - if (action is actions.OxdnaExport) { + if (action is actions.OxdnaExport || action is actions.OxviewExport) { AppState state = store.state; List strands_to_export; @@ -34,18 +38,160 @@ First select some strands, or choose Export🡒oxDNA to export all strands in th strands_to_export = state.design.strands.toList(); } - Tuple2 dat_top = to_oxdna_format(state.design, strands_to_export); - String dat = dat_top.item1; - String top = dat_top.item2; + if (action is actions.OxdnaExport) { + Tuple2 dat_top = to_oxdna_format(state.design, strands_to_export); + String dat = dat_top.item1; + String top = dat_top.item2; + + String default_filename = state.ui_state.loaded_filename; + String default_filename_dat = path.setExtension(default_filename, '.dat'); + String default_filename_top = path.setExtension(default_filename, '.top'); + + util.save_file(default_filename_dat, dat); + util.save_file(default_filename_top, top); + } else if (action is actions.OxviewExport) { + String content = to_oxview_format(state.design, strands_to_export); + String default_filename = state.ui_state.loaded_filename; + String default_filename_ext = path.setExtension(default_filename, '.oxview'); + util.save_file(default_filename_ext, content); + } + } + next(action); +} - String default_filename = state.ui_state.loaded_filename; - String default_filename_dat = path.setExtension(default_filename, '.dat'); - String default_filename_top = path.setExtension(default_filename, '.top'); +String to_oxview_format(Design design, List strands_to_export) { + OxdnaSystem system = convert_design_to_oxdna_system(design, strands_to_export); + List> oxview_strands = []; + int nuc_count = 0; + int strand_count = 0; + List strand_nuc_start = [-1]; + assert(strands_to_export.length == system.strands.length); + + for (int i = 0; i < strands_to_export.length; i++) { + Strand sc_strand = strands_to_export[i]; + OxdnaStrand oxdna_strand = system.strands[i]; + + strand_count += 1; + List> oxvnucs = []; + strand_nuc_start.add(nuc_count); + Map oxvstrand = { + 'id': strand_count, + 'class': 'NucleicAcidStrand', + 'end5': nuc_count, + 'end3': nuc_count + system.strands[i].nucleotides.length, + 'monomers': oxvnucs + }; + + int scolor; + if (sc_strand.color != null) { + scolor = export_cadnano.to_cadnano_v2_int_hex(sc_strand.color); + } else { + scolor = null; + } - util.save_file(default_filename_dat, dat); - util.save_file(default_filename_top, top); + for (int index_in_strand = 0; index_in_strand < oxdna_strand.nucleotides.length; index_in_strand++) { + OxdnaNucleotide nuc = oxdna_strand.nucleotides[index_in_strand]; + Map oxvnuc = { + 'id': nuc_count, + 'p': [nuc.r.x, nuc.r.y, nuc.r.z], + 'a1': [nuc.b.x, nuc.b.y, nuc.b.z], + 'a3': [nuc.n.x, nuc.n.y, nuc.n.z], + 'class': 'DNA', + 'type': nuc.base, + 'cluster': 1, + }; + if (index_in_strand != 0) { + oxvnuc['n5'] = nuc_count - 1; + } + if (index_in_strand != oxdna_strand.nucleotides.length - 1) { + oxvnuc['n3'] = nuc_count + 1; + } + if (scolor != null) { + oxvnuc['color'] = scolor; + } + nuc_count += 1; + oxvnucs.add(oxvnuc); + } + oxview_strands.add(oxvstrand); } - next(action); + + for (int si1 = 0; si1 < strands_to_export.length; si1++) { + Strand sc_strand1 = strands_to_export[si1]; + Map oxv_strand1 = oxview_strands[si1]; + for (int si2 = 0; si2 < strands_to_export.length; si2++) { + Strand sc_strand2 = strands_to_export[si2]; + if (!sc_strand1.overlaps(sc_strand2)) { + continue; + } + int s1_nuc_idx = strand_nuc_start[si1 + 1]; + for (var domain1 in sc_strand1.domains) { + if (domain1 is Loopout || domain1 is Extension) { + continue; + } + int s2_nuc_idx = strand_nuc_start[si2 + 1]; + for (var domain2 in sc_strand2.domains) { + if (domain2 is Loopout || domain2 is Extension) { + continue; + } + if (!domain1.overlaps(domain2)) { + continue; + } + Tuple2 overlap = domain1.compute_overlap(domain2); + int overlap_left = overlap.item1; + int overlap_right = overlap.item2; + int s1_left = sc_strand1.domain_offset_to_strand_dna_idx(domain1, overlap_left, false); + int s1_right = sc_strand1.domain_offset_to_strand_dna_idx(domain1, overlap_right, false); + int s2_left = sc_strand2.domain_offset_to_strand_dna_idx(domain2, overlap_left, false); + int s2_right = sc_strand2.domain_offset_to_strand_dna_idx(domain2, overlap_right, false); + List d1range; + List d2range; + if (domain1.forward) { + d1range = List.from(quiver.range(s1_left, s1_right)); + d2range = List.from(quiver.range(s2_left, s2_right, -1)); + } else { + d1range = List.from(quiver.range(s1_right + 1, s1_left + 1)); + d2range = List.from(quiver.range(s2_right - 1, s2_left - 1, -1)); + } + assert(d1range.length == d2range.length); + + // Check for mismatches, and do not add a pair if the bases are *known* + // to mismatch. (FIXME: this must be changed if scadnano later supports + // degenerate base codes.) + for (int i = 0; i < d1range.length; i++) { + int d1 = d1range[i]; + int d2 = d2range[i]; + if (sc_strand1.dna_sequence != null && + sc_strand2.dna_sequence != null && + sc_strand1.dna_sequence[d1] != constants.DNA_BASE_WILDCARD && + sc_strand2.dna_sequence[d2] != constants.DNA_BASE_WILDCARD && + util.wc(sc_strand1.dna_sequence[d1]) != sc_strand2.dna_sequence[d2]) { + continue; + } + oxv_strand1['monomers'][d1]['bp'] = s2_nuc_idx + d2; + if (oxview_strands[si2]['monomers'][d2].containsKey('bp')) { + if (oxview_strands[si2]['monomers'][d2]['bp'] != s1_nuc_idx + d1) { + print('${s2_nuc_idx + d2} ${s1_nuc_idx + d1} ' + '${oxview_strands[si2]['monomers'][d2]['bp']} ${domain1} ${domain2}'); + } + } + } + } + } + } + } + var b = system.compute_bounding_box(); + Map oxvsystem = { + 'box': [b.x, b.y, b.z], + 'date': DateTime.now().toIso8601String(), + 'systems': [ + {'id': 0, 'strands': oxview_strands} + ], + 'forces': [], + 'selections': [], + }; + String content = jsonEncode(oxvsystem); + + return content; } Tuple2 to_oxdna_format(Design design, [List strands_to_export = null]) { diff --git a/lib/src/reducers/app_ui_state_reducer.dart b/lib/src/reducers/app_ui_state_reducer.dart index 0a29b397e..a0dffa766 100644 --- a/lib/src/reducers/app_ui_state_reducer.dart +++ b/lib/src/reducers/app_ui_state_reducer.dart @@ -4,6 +4,7 @@ import 'package:built_collection/built_collection.dart'; import 'package:redux/redux.dart'; import 'package:scadnano/src/reducers/design_reducer.dart'; import 'package:scadnano/src/reducers/strands_copy_info_reducer.dart'; +import 'package:scadnano/src/state/base_pair_display_type.dart'; import 'package:scadnano/src/state/dna_assign_options.dart'; import 'package:scadnano/src/state/modification.dart'; import 'package:scadnano/src/state/strand.dart'; @@ -213,6 +214,9 @@ int slice_bar_offset_set_reducer(int _, actions.SliceBarOffsetSet action) => act bool disable_png_caching_dna_sequences_reducer(bool _, actions.DisablePngCachingDnaSequencesSet action) => action.disable_png_caching_dna_sequences; +bool retain_strand_color_on_selection_reducer(bool _, actions.RetainStrandColorOnSelectionSet action) => + action.retain_strand_color_on_selection; + bool display_reverse_DNA_right_side_up_reducer(bool _, actions.DisplayReverseDNARightSideUpSet action) => action.display_reverse_DNA_right_side_up; @@ -227,12 +231,22 @@ bool display_major_tick_widths_all_helices_reducer( bool _, actions.SetDisplayMajorTickWidthsAllHelices action) => action.show; +BasePairDisplayType base_pair_type_idx_reducer( + BasePairDisplayType set_base_pair_display, actions.BasePairTypeSet action) => + BasePairDisplayType.types[action.selected_idx]; + bool show_base_pair_lines_reducer(bool _, actions.ShowBasePairLinesSet action) => action.show_base_pair_lines; bool show_base_pair_lines_with_mismatches_reducer( bool _, actions.ShowBasePairLinesWithMismatchesSet action) => action.show_base_pair_lines_with_mismatches; +bool export_svg_text_separately_reducer(bool _, actions.ExportSvgTextSeparatelySet action) => + action.export_svg_text_separately; + +bool ox_export_only_selected_strands_reducer(bool _, actions.OxExportOnlySelectedStrandsSet action) => + action.only_selected; + bool display_major_tick_widths_reducer(bool _, actions.SetDisplayMajorTickWidths action) => action.show; bool strand_paste_keep_color_reducer(bool _, actions.StrandPasteKeepColorSet action) => action.keep; @@ -434,6 +448,7 @@ AppUIStateStorables app_ui_state_storable_local_reducer(AppUIStateStorables stor ..show_slice_bar = TypedReducer(show_slice_bar_reducer)(storables.show_slice_bar, action) ..slice_bar_offset = TypedReducer(slice_bar_offset_set_reducer)(storables.slice_bar_offset, action) ..disable_png_caching_dna_sequences = TypedReducer(disable_png_caching_dna_sequences_reducer)(storables.disable_png_caching_dna_sequences, action) + ..retain_strand_color_on_selection = TypedReducer(retain_strand_color_on_selection_reducer)(storables.retain_strand_color_on_selection, action) ..display_reverse_DNA_right_side_up = TypedReducer(display_reverse_DNA_right_side_up_reducer)(storables.display_reverse_DNA_right_side_up, action) ..local_storage_design_choice = TypedReducer(local_storage_design_choice_reducer)(storables.local_storage_design_choice, action).toBuilder() ..clear_helix_selection_when_loading_new_design = TypedReducer(clear_helix_selection_when_loading_new_design_set_reducer)(storables.clear_helix_selection_when_loading_new_design, action) @@ -444,8 +459,11 @@ AppUIStateStorables app_ui_state_storable_local_reducer(AppUIStateStorables stor ..display_base_offsets_of_major_ticks_only_first_helix = TypedReducer(display_base_offsets_of_major_ticks_only_first_helix_reducer)(storables.display_base_offsets_of_major_ticks_only_first_helix, action) ..display_major_tick_widths = TypedReducer(display_major_tick_widths_reducer)(storables.display_major_tick_widths, action) ..display_major_tick_widths_all_helices = TypedReducer(display_major_tick_widths_all_helices_reducer)(storables.display_major_tick_widths_all_helices, action) + ..base_pair_display_type = TypedReducer(base_pair_type_idx_reducer)(storables.base_pair_display_type, action) ..show_base_pair_lines = TypedReducer(show_base_pair_lines_reducer)(storables.show_base_pair_lines, action) ..show_base_pair_lines_with_mismatches = TypedReducer(show_base_pair_lines_with_mismatches_reducer)(storables.show_base_pair_lines_with_mismatches, action) + ..export_svg_text_separately = TypedReducer(export_svg_text_separately_reducer)(storables.export_svg_text_separately, action) + ..ox_export_only_selected_strands = TypedReducer(ox_export_only_selected_strands_reducer)(storables.ox_export_only_selected_strands, action) ..only_display_selected_helices = TypedReducer(only_display_selected_helices_reducer)(storables.only_display_selected_helices, action) ..default_crossover_type_scaffold_for_setting_helix_rolls = TypedReducer(default_crossover_type_scaffold_for_setting_helix_rolls_reducer)(storables.default_crossover_type_scaffold_for_setting_helix_rolls, action) ..default_crossover_type_staple_for_setting_helix_rolls = TypedReducer(default_crossover_type_staple_for_setting_helix_rolls_reducer)(storables.default_crossover_type_staple_for_setting_helix_rolls, action) diff --git a/lib/src/reducers/selection_reducer.dart b/lib/src/reducers/selection_reducer.dart index 40383d496..bec748a2d 100644 --- a/lib/src/reducers/selection_reducer.dart +++ b/lib/src/reducers/selection_reducer.dart @@ -159,6 +159,7 @@ SelectablesStore select_all_with_same_reducer( var selected_strands = selectables_store.selected_strands.toList(); for (var strand in state.design.strands) { if (selected_strands.contains(strand)) continue; + if (action.exclude_scaffolds && strand.is_scaffold) continue; bool include_strand = true; // by default include the strand; now go looking for a trait where it doesn't match for (var trait in trait_values.keys) { diff --git a/lib/src/serializers.dart b/lib/src/serializers.dart index cf843132f..efc6231bc 100644 --- a/lib/src/serializers.dart +++ b/lib/src/serializers.dart @@ -6,6 +6,7 @@ import 'package:built_collection/built_collection.dart'; import 'package:built_value/standard_json_plugin.dart'; import 'package:color/color.dart'; import 'package:scadnano/src/dna_file_type.dart'; +import 'package:scadnano/src/state/base_pair_display_type.dart'; import 'package:scadnano/src/state/dna_extensions_move.dart'; import 'package:scadnano/src/state/undo_redo.dart'; import 'package:tuple/tuple.dart'; @@ -123,6 +124,7 @@ part 'serializers.g.dart'; ShowModificationsSet, ShowMismatchesSet, SetShowEditor, + ExportSvgTextSeparatelySet, SaveDNAFile, PrepareToLoadDNAFile, LoadDNAFile, @@ -136,6 +138,7 @@ part 'serializers.g.dart'; SelectionBoxRemove, SelectionRope, Line, + BasePairDisplayType, SelectionRopeCreate, SelectionRopeMouseMove, SelectionRopeAddPoint, @@ -315,6 +318,7 @@ part 'serializers.g.dart'; StrandPasteKeepColorSet, ExampleDesigns, ExampleDesignsLoad, + BasePairTypeSet, HelixPositionSet, HelixGridPositionSet, InlineInsertionsDeletions, @@ -348,6 +352,7 @@ part 'serializers.g.dart'; SetDisplayMajorTickWidthsAllHelices, SliceBarOffsetSet, DisablePngCachingDnaSequencesSet, + RetainStrandColorOnSelectionSet, DisplayReverseDNARightSideUpSet, SliceBarMoveStart, SliceBarMoveStop, @@ -358,6 +363,8 @@ part 'serializers.g.dart'; ZoomSpeedSet, NewDesignSet, OxdnaExport, + OxviewExport, + OxExportOnlySelectedStrandsSet, Design, AssignDomainNameComplementFromBoundStrands, AssignDomainNameComplementFromBoundDomains, diff --git a/lib/src/state/app_ui_state.dart b/lib/src/state/app_ui_state.dart index a4077c49a..3a6d03f95 100644 --- a/lib/src/state/app_ui_state.dart +++ b/lib/src/state/app_ui_state.dart @@ -12,6 +12,7 @@ import '../state/local_storage_design_choice.dart'; import 'app_ui_state_storables.dart'; import '../serializers.dart'; +import 'base_pair_display_type.dart'; import 'context_menu.dart'; import 'dialog.dart'; import 'design.dart'; @@ -162,6 +163,8 @@ abstract class AppUIState with BuiltJsonSerializable implements Built storables.show_domain_labels; + BasePairDisplayType get base_pair_display_type => storables.base_pair_display_type; + bool get show_base_pair_lines => storables.show_base_pair_lines; bool get show_base_pair_lines_with_mismatches => storables.show_base_pair_lines_with_mismatches; @@ -214,6 +217,8 @@ abstract class AppUIState with BuiltJsonSerializable implements Built storables.show_loopout_extension_length; + bool get export_svg_text_separately => storables.export_svg_text_separately; + bool get default_crossover_type_scaffold_for_setting_helix_rolls => storables.default_crossover_type_scaffold_for_setting_helix_rolls; @@ -233,12 +238,16 @@ abstract class AppUIState with BuiltJsonSerializable implements Built storables.disable_png_caching_dna_sequences; + bool get retain_strand_color_on_selection => storables.retain_strand_color_on_selection; + bool get display_reverse_DNA_right_side_up => storables.display_reverse_DNA_right_side_up; bool get show_mouseover_data => storables.show_mouseover_data; bool get selection_box_intersection => storables.selection_box_intersection; + bool get ox_export_only_selected_strands => storables.ox_export_only_selected_strands; + static void _initializeBuilder(AppUIStateBuilder b) { b.copy_info = null; b.last_mod_5p = null; diff --git a/lib/src/state/app_ui_state_storables.dart b/lib/src/state/app_ui_state_storables.dart index ef429f106..dfbba962a 100644 --- a/lib/src/state/app_ui_state_storables.dart +++ b/lib/src/state/app_ui_state_storables.dart @@ -4,6 +4,7 @@ import 'package:built_value/built_value.dart'; import '../state/local_storage_design_choice.dart'; import '../serializers.dart'; +import 'base_pair_display_type.dart'; import 'select_mode_state.dart'; import 'edit_mode.dart'; import '../constants.dart' as constants; @@ -34,6 +35,8 @@ abstract class AppUIStateStorables bool get show_domain_labels; + BasePairDisplayType get base_pair_display_type; + bool get show_base_pair_lines; bool get show_base_pair_lines_with_mismatches; @@ -119,10 +122,16 @@ abstract class AppUIStateStorables bool get disable_png_caching_dna_sequences; + bool get retain_strand_color_on_selection; + bool get display_reverse_DNA_right_side_up; bool get selection_box_intersection; + bool get export_svg_text_separately; + + bool get ox_export_only_selected_strands; + static void _initializeBuilder(AppUIStateStorablesBuilder b) { // This ensures that even if these keys are not in localStorage (e.g., due to upgrading), // then they will be populated with a default value instead of raising an exception. @@ -135,6 +144,7 @@ abstract class AppUIStateStorables b.show_strand_labels = false; b.show_domain_names = false; b.show_domain_labels = false; + b.base_pair_display_type = BasePairDisplayType.none; b.show_base_pair_lines = false; b.show_base_pair_lines_with_mismatches = false; b.strand_name_font_size = constants.default_strand_name_font_size; @@ -173,11 +183,14 @@ abstract class AppUIStateStorables b.show_slice_bar = false; b.slice_bar_offset = null; b.disable_png_caching_dna_sequences = false; + b.retain_strand_color_on_selection = false; b.display_reverse_DNA_right_side_up = false; b.local_storage_design_choice = LocalStorageDesignChoice().toBuilder(); b.clear_helix_selection_when_loading_new_design = false; b.show_mouseover_data = false; b.selection_box_intersection = false; + b.export_svg_text_separately = false; + b.ox_export_only_selected_strands = false; } /************************ begin BuiltValue boilerplate ************************/ diff --git a/lib/src/state/base_pair_display_type.dart b/lib/src/state/base_pair_display_type.dart new file mode 100644 index 000000000..cabba9bd5 --- /dev/null +++ b/lib/src/state/base_pair_display_type.dart @@ -0,0 +1,66 @@ +import 'dart:html'; + +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; +import 'package:built_collection/built_collection.dart'; + +part 'base_pair_display_type.g.dart'; + +class BasePairDisplayType extends EnumClass { + const BasePairDisplayType._(String name) : super(name); + + @memoized + int get hashCode; + + static Serializer get serializer => _$basePairDisplayTypeSerializer; + + /******************** end BuiltValue boilerplate *********************/ + + static const BasePairDisplayType none = _$none; + static const BasePairDisplayType lines = _$lines; + static const BasePairDisplayType rectangle = _$rectangle; // used to join two Domains with Crossover + static BuiltList types = const [none, lines, rectangle].toBuiltList(); + + static BuiltSet get values => _$values; + + static BasePairDisplayType valueOf(String name) => _$valueOf(name); + + String to_json() => name; + + int toIndex() { + switch (this) { + case none: + return 0; + case lines: + return 1; + case rectangle: + return 2; + } + return 0; + } + + String display_name() { + // edit this to display a different string than the identifier name above + switch (this) { + case none: + return 'none'; + case lines: + return 'lines'; + case rectangle: + return 'rectangle'; + } + return super.toString(); + } + + @override + String toString() => display_name(); + + static BasePairDisplayType from_json(String the_name) { + for (var val in values) { + if (val.name == the_name) { + return val; + } + } + throw ArgumentError('there is no base pair display type with name "${the_name}"'); + } +} diff --git a/lib/src/state/design.dart b/lib/src/state/design.dart index f9f766c3e..d97aad80a 100644 --- a/lib/src/state/design.dart +++ b/lib/src/state/design.dart @@ -2101,21 +2101,39 @@ abstract class Design with UnusedFields implements Built, /// maps each helix_idx to a list of offsets where there is a complementary base pair on each strand @memoized - BuiltMap> get base_pairs => this._base_pairs(false); + BuiltMap> get base_pairs => this._base_pairs(false, strands.toBuiltSet()); /// maps each helix_idx to a list of offsets where there is a base on each strand, /// NOT necessarily complementary @memoized - BuiltMap> get base_pairs_with_mismatches => this._base_pairs(true); + BuiltMap> get base_pairs_with_mismatches => + this._base_pairs(true, strands.toBuiltSet()); - BuiltMap> _base_pairs(bool allow_mismatches) { + // returns a subset of base_pairs that is connected to selected_strands + BuiltMap> selected_base_pairs(BuiltSet selected_strands) => + this._base_pairs(false, selected_strands); + + // returns a subset of base_pairs_with_mismatches that is connected to selected_strands + BuiltMap> selected_base_pairs_with_mismatches(BuiltSet selected_strands) => + this._base_pairs(true, selected_strands); + + BuiltMap> _base_pairs(bool allow_mismatches, BuiltSet selected_strands) { var base_pairs = Map>(); + BuiltSet selected_domains = selected_strands + .map((s) => s.substrands) + .expand((x) => x) + .where((x) => x is Domain) + .map((x) => x as Domain) + .toBuiltSet(); for (int idx in this.helices.keys) { List offsets = []; List> overlapping_domains = find_overlapping_domains_on_helix(idx); for (var domain_pair in overlapping_domains) { Domain dom1 = domain_pair.item1; Domain dom2 = domain_pair.item2; + if (!selected_domains.contains(dom1) || !selected_domains.contains(dom2)) { + continue; + } var start_and_end = dom1.compute_overlap(dom2); int start = start_and_end.item1; int end = start_and_end.item2; diff --git a/lib/src/state/dialog.dart b/lib/src/state/dialog.dart index 74c53ebb6..ca37e13c4 100644 --- a/lib/src/state/dialog.dart +++ b/lib/src/state/dialog.dart @@ -47,6 +47,7 @@ class DialogType extends EnumClass { static const DialogType move_selected_helices_to_group = _$move_selected_helices_to_group; static const DialogType export_dna_sequences = _$export_dna_sequences; static const DialogType load_example_dna_design = _$load_example_dna_design; + static const DialogType base_pair_display = _$base_pair_display; static const DialogType add_extension = _$add_extension; static const DialogType set_extension_name = _$set_extension_name; static const DialogType set_extension_display_length_angle = _$set_extension_display_length_angle; diff --git a/lib/src/state/selectable.dart b/lib/src/state/selectable.dart index 66182b373..e80c417ef 100644 --- a/lib/src/state/selectable.dart +++ b/lib/src/state/selectable.dart @@ -670,12 +670,15 @@ Future ask_for_select_all_with_same_as_selected() async { } var all_traits = List.from(SelectableTrait.values); - var items = List.filled(all_traits.length, null); + var items = List.filled(all_traits.length + 1, null); for (int idx = 0; idx < all_traits.length; idx++) { var trait = all_traits[idx]; items[idx] = DialogCheckbox(label: trait.description, value: false); } + items[all_traits.length] = DialogCheckbox(label: '(Exclude scaffold(s))', value: false, tooltip: '''\ +If checked, then only strands that are not scaffolds will be selected. +However, *currently* selected scaffold strands will remain selected.'''); var dialog = Dialog( title: "Select all strands with same traits as currently selected strand(s)", @@ -693,10 +696,12 @@ Future ask_for_select_all_with_same_as_selected() async { traits_for_selection.add(trait); } } + bool exclude_scaffolds = (results[all_traits.length] as DialogCheckbox).value; var action = actions.SelectAllWithSameAsSelected( templates: selected_strands, traits: traits_for_selection.build(), + exclude_scaffolds: exclude_scaffolds, ); app.dispatch(action); } diff --git a/lib/src/state/strand.dart b/lib/src/state/strand.dart index 52ee4f9e5..96d3e955d 100644 --- a/lib/src/state/strand.dart +++ b/lib/src/state/strand.dart @@ -753,6 +753,59 @@ abstract class Strand return rebuild((strand) => strand..substrands.replace(substrands_new)); } + /// Convert from offset on the given Domain's Helix to string index on the parent Strand's DNA sequence. + /// If `offset_closer_to_5p` is ``true``, (this only matters if `offset` contains an insertion) + /// then the only leftmost string index corresponding to this offset is included, + /// otherwise up to the rightmost string index (including all insertions) is included. + int domain_offset_to_strand_dna_idx(Domain domain, int offset, bool offset_closer_to_5p) { + if (domain.deletions.contains(offset)) { + throw ArgumentError('offset ${offset} illegally contains a deletion from ${domain.deletions}'); + } + + int len_adjust = this._net_ins_del_length_increase_from_5p_to(domain, offset, offset_closer_to_5p); + + int domain_str_idx; + if (domain.forward) { + offset += len_adjust; + domain_str_idx = offset - domain.start; + } else { + offset -= len_adjust; + domain_str_idx = domain.end - 1 - offset; + } + + return domain_str_idx + get_seq_start_idx(domain); + } + + /// Net number of insertions from 5'/3' end to offset_edge, + /// INCLUSIVE on 5'/3' end, EXCLUSIVE on offset_edge. + /// Set `five_p` ``= False`` to test from 3' end to `offset_edge`. + int _net_ins_del_length_increase_from_5p_to(Domain domain, int offset_edge, bool offset_closer_to_5p) { + int length_increase = 0; + for (int deletion in domain.deletions) { + if (_between_5p_and_offset(domain, deletion, offset_edge)) { + length_increase -= 1; + } + } + for (var insertion in domain.insertions) { + if (_between_5p_and_offset(domain, insertion.offset, offset_edge)) { + length_increase += insertion.length; + } + } + if (!offset_closer_to_5p) { + Map insertion_map = + Map.fromIterable(domain.insertions, key: (e) => e.offset, value: (e) => e.length); + if (insertion_map.containsKey(offset_edge)) { + int insertion_length = insertion_map[offset_edge]; + length_increase += insertion_length; + } + } + return length_increase; + } + + bool _between_5p_and_offset(Domain domain, int offset_to_test, int offset_edge) => + (domain.forward && domain.start <= offset_to_test && offset_to_test < offset_edge) || + (!domain.forward && offset_edge < offset_to_test && offset_to_test < domain.end); + String _trim_or_pad_sequence_to_desired_length(String dna_sequence_new, int desired_length) { // truncate dna_sequence_new if too long; pad with ?'s if to short int seq_len = dna_sequence_new.length; diff --git a/lib/src/util.dart b/lib/src/util.dart index 4100f1c96..bfa87ed68 100644 --- a/lib/src/util.dart +++ b/lib/src/util.dart @@ -1053,7 +1053,7 @@ num current_zoom(bool is_main) => is_main ? current_zoom_main_js() : current_zoo CssStyleSheet get_scadnano_stylesheet() { for (var stylesheet in document.styleSheets) { - if (stylesheet.href.contains(constants.scadnano_css_stylesheet_name)) { + if (stylesheet.href != null && stylesheet.href.contains(constants.scadnano_css_stylesheet_name)) { return stylesheet; } } diff --git a/lib/src/view/design_main.dart b/lib/src/view/design_main.dart index c9772dbbe..ae1f8c5e1 100644 --- a/lib/src/view/design_main.dart +++ b/lib/src/view/design_main.dart @@ -6,11 +6,13 @@ import 'package:built_collection/built_collection.dart'; import 'package:over_react/over_react.dart'; import 'package:over_react/over_react_redux.dart'; import 'package:react/react_client/react_interop.dart'; +import 'package:scadnano/src/state/base_pair_display_type.dart'; import 'package:scadnano/src/view/design_main_unpaired_insertion_deletions.dart'; import 'package:scadnano/src/view/design_main_slice_bar.dart'; import 'package:scadnano/src/view/potential_extensions_view.dart'; import '../state/selection_rope.dart'; +import 'design_main_base_pair_rectangle.dart'; import 'design_main_domains_moving.dart'; import 'selection_rope_view.dart'; import '../actions/actions.dart' as actions; @@ -65,6 +67,7 @@ UiFactory ConnectedDesignMain = connect ConnectedDesignMain = connect> helix_idx_to_svg_position_map; bool invert_y; @@ -208,7 +214,7 @@ class DesignMainComponent extends UiComponent2 { .map((helix_idx, svg_position) => MapEntry(helix_idx, svg_position.y)) ..key = 'unpaired-insertion-deletions')(), - if (props.show_base_pair_lines) + if (props.base_pair_display_type == BasePairDisplayType.lines) (DesignMainBasePairLines() ..with_mismatches = props.show_base_pair_lines_with_mismatches ..design = props.design @@ -218,6 +224,16 @@ class DesignMainComponent extends UiComponent2 { .map((helix_idx, svg_position) => MapEntry(helix_idx, svg_position.y)) ..key = 'base-pair-lines')(), + if (props.base_pair_display_type == BasePairDisplayType.rectangle) + (DesignMainBasePairRectangle() + ..with_mismatches = props.show_base_pair_lines_with_mismatches + ..design = props.design + ..only_display_selected_helices = props.only_display_selected_helices + ..side_selected_helix_idxs = props.side_selected_helix_idxs + ..helix_idx_to_svg_position_y_map = props.helix_idx_to_svg_position_map + .map((helix_idx, svg_position) => MapEntry(helix_idx, svg_position.y)) + ..key = 'base-pair-rectangle')(), + (ConnectedDesignMainStrands()..key = 'strands')(), // after strands so can click when crossover overlaps potential crossover @@ -263,6 +279,7 @@ class DesignMainComponent extends UiComponent2 { ..only_display_selected_helices = props.only_display_selected_helices ..helix_idx_to_svg_position_map = props.helix_idx_to_svg_position_map ..disable_png_caching_dna_sequences = props.disable_png_caching_dna_sequences + ..retain_strand_color_on_selection = props.retain_strand_color_on_selection ..display_reverse_DNA_right_side_up = props.display_reverse_DNA_right_side_up ..key = 'dna-sequences')(), diff --git a/lib/src/view/design_main_base_pair_lines.dart b/lib/src/view/design_main_base_pair_lines.dart index 6ffcc1481..a4f77667d 100644 --- a/lib/src/view/design_main_base_pair_lines.dart +++ b/lib/src/view/design_main_base_pair_lines.dart @@ -2,6 +2,7 @@ import 'dart:html'; import 'package:over_react/over_react.dart'; import 'package:built_collection/built_collection.dart'; +import 'package:scadnano/scadnano.dart'; import 'package:scadnano/src/state/group.dart'; import 'package:scadnano/src/state/helix.dart'; @@ -28,14 +29,16 @@ mixin DesignMainBasePairLinesProps on UiProps { class DesignMainBasePairLinesComponent extends UiComponent2 with PureComponent { @override render() { - List base_pair_lines_components = this._create_base_pair_lines_components(); + List base_pair_lines_components = + this.create_base_pair_lines_components(app.state.design.strands.toBuiltSet()); return (Dom.g()..className = 'base-pair-lines-main-view')(base_pair_lines_components); } - List _create_base_pair_lines_components() { + List create_base_pair_lines_components(BuiltSet strands) { List base_pair_lines_components = []; - var base_pairs = - props.with_mismatches ? props.design.base_pairs_with_mismatches : props.design.base_pairs; + BuiltMap> base_pairs = props.with_mismatches + ? props.design.selected_base_pairs_with_mismatches(strands) + : props.design.selected_base_pairs(strands); for (int helix_idx in base_pairs.keys) { if (!props.only_display_selected_helices || props.side_selected_helix_idxs.contains(helix_idx)) { @@ -52,6 +55,7 @@ class DesignMainBasePairLinesComponent extends UiComponent2 DesignMainBasePairRectangle = _$DesignMainBasePairRectangle; + +mixin DesignMainBasePairRectangleProps on UiProps { + bool with_mismatches; + Design design; + bool only_display_selected_helices; + BuiltSet side_selected_helix_idxs; + BuiltMap helix_idx_to_svg_position_y_map; +} + +class DesignMainBasePairRectangleComponent extends UiComponent2 + with PureComponent { + @override + render() { + List base_pair_lines_components = + this.create_base_pair_lines_components(app.state.design.strands.toBuiltSet()); + return (Dom.g()..className = 'base-pair-lines-main-view')(base_pair_lines_components); + } + + List create_base_pair_lines_components(BuiltSet strands) { + List base_pair_lines_components = []; + BuiltMap> base_pairs = props.with_mismatches + ? props.design.selected_base_pairs_with_mismatches(strands) + : props.design.selected_base_pairs(strands); + + for (int helix_idx in base_pairs.keys) { + if (!props.only_display_selected_helices || props.side_selected_helix_idxs.contains(helix_idx)) { + var helix = props.design.helices[helix_idx]; + HelixGroup group = props.design.groups[helix.group]; + String transform_str = group.transform_str(props.design.geometry); + + // code below draws one line for each base pair, should render somewhat slowly + // however, this makes it easier to associate base pair lines to individual strands, + // convenient when exporting SVG + List helix_components = []; + int last_offset = -2; + var last_svg_forward_pos = null; + for (int offset in base_pairs[helix_idx]) { + var svg_position_y = props.helix_idx_to_svg_position_y_map[helix_idx]; + var base_svg_forward_pos = helix.svg_base_pos(offset, true, svg_position_y); + var base_svg_reverse_pos = helix.svg_base_pos(offset, false, svg_position_y); + + var base_pair_ele = null; + + if (offset - last_offset == 1) { + base_pair_ele = (Dom.rect() + ..id = 'base_pair-${helix_idx}-${offset}' + ..x = last_svg_forward_pos.x - 0.5 + ..y = base_svg_forward_pos.y + ..width = base_svg_reverse_pos.x - last_svg_forward_pos.x + 0.8 + ..height = base_svg_reverse_pos.y - base_svg_forward_pos.y + ..className = constants.css_selector_base_pair_rect + ..fill = 'grey' + ..key = 'base-pair-rect-H${helix_idx}-${offset}')(); + } else { + base_pair_ele = (Dom.line() + ..id = 'base_pair-${helix_idx}-${offset}' + ..x1 = base_svg_forward_pos.x + ..y1 = base_svg_forward_pos.y + ..x2 = base_svg_reverse_pos.x + ..y2 = base_svg_reverse_pos.y + ..className = constants.css_selector_base_pair_line + ..stroke = 'grey' + ..key = 'base-pair-line-H${helix_idx}-${offset}')(); + } + + helix_components.add(base_pair_ele); + last_offset = offset; + last_svg_forward_pos = base_svg_forward_pos; + } + var helix_dom_group = (Dom.g() + ..transform = transform_str + ..className = 'base-pair-lines-components-in-helix' + ..key = 'base-pair-lines-components-in-helix-H${helix_idx}')(helix_components); + base_pair_lines_components.add(helix_dom_group); + } + } + + return base_pair_lines_components; + } +} diff --git a/lib/src/view/design_main_dna_sequence.dart b/lib/src/view/design_main_dna_sequence.dart index 26aedb26e..1c4585e17 100644 --- a/lib/src/view/design_main_dna_sequence.dart +++ b/lib/src/view/design_main_dna_sequence.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:over_react/over_react.dart'; import 'package:built_collection/built_collection.dart'; import 'package:platform_detect/platform_detect.dart'; +import 'package:react/react_client/react_interop.dart'; import 'package:scadnano/src/state/group.dart'; import 'package:scadnano/src/view/transform_by_helix_group.dart'; import 'package:tuple/tuple.dart'; @@ -91,6 +92,7 @@ class DesignMainDNASequenceComponent extends UiComponent2> helix_idx_to_svg_position_map; bool disable_png_caching_dna_sequences; + bool retain_strand_color_on_selection; bool display_reverse_DNA_right_side_up; } diff --git a/lib/src/view/design_main_strand.dart b/lib/src/view/design_main_strand.dart index 31a47e7ff..f649df8d9 100644 --- a/lib/src/view/design_main_strand.dart +++ b/lib/src/view/design_main_strand.dart @@ -85,6 +85,7 @@ mixin DesignMainStrandPropsMixin on UiProps { num modification_font_size; bool invert_y; BuiltMap> helix_idx_to_svg_position_map; + bool retain_strand_color_on_selection; } class DesignMainStrandProps = UiProps with DesignMainStrandPropsMixin, TransformByHelixGroupPropsMixin; @@ -100,7 +101,11 @@ class DesignMainStrandComponent extends UiComponent2 var classname = constants.css_selector_strand; if (props.selected) { - classname += ' ' + constants.css_selector_selected; + if (props.retain_strand_color_on_selection) { + classname += ' ' + constants.css_selector_selected; + } else { + classname += ' ' + constants.css_selector_selected_pink; + } } if (props.strand.is_scaffold) { classname += ' ' + constants.css_selector_scaffold; @@ -149,7 +154,8 @@ class DesignMainStrandComponent extends UiComponent2 ..moving_dna_ends = props.moving_dna_ends ..geometry = props.geometry ..helix_idx_to_svg_position_map = helix_idx_to_svg_position_y_map_on_strand - ..only_display_selected_helices = props.only_display_selected_helices)(), + ..only_display_selected_helices = props.only_display_selected_helices + ..retain_strand_color_on_selection = props.retain_strand_color_on_selection)(), _insertions(), _deletions(), if (props.show_domain_names || @@ -188,6 +194,7 @@ class DesignMainStrandComponent extends UiComponent2 ..display_connector = props.modification_display_connector ..helix_idx_to_svg_position_y_map = props.helix_idx_to_svg_position_map.map((i, p) => MapEntry(i, p.y)) + ..retain_strand_color_on_selection = props.retain_strand_color_on_selection ..key = 'modifications')(), ]); } @@ -273,16 +280,19 @@ class DesignMainStrandComponent extends UiComponent2 assign_plate_well_fields() => app.disable_keyboard_shortcuts_while(ask_for_assign_plate_well_fields); - set_strand_name() => app.disable_keyboard_shortcuts_while(ask_for_strand_name); + set_strand_name() => app.disable_keyboard_shortcuts_while( + () => ask_for_strand_name(props.strand, app.state.ui_state.selectables_store.selected_strands)); set_strand_label() => app.disable_keyboard_shortcuts_while( () => ask_for_label(props.strand, null, app.state.ui_state.selectables_store.selected_strands)); - set_substrand_name(Substrand substrand) => - app.disable_keyboard_shortcuts_while(() => ask_for_substrand_name(substrand)); + set_domain_names(BuiltSet domains) => + app.disable_keyboard_shortcuts_while(() => ask_for_domain_names(domains)); - set_substrand_label(Substrand substrand) => app.disable_keyboard_shortcuts_while( - () => ask_for_label(props.strand, substrand, app.state.ui_state.selectables_store.selected_substrands)); + set_domain_labels(Substrand substrand, BuiltSet domains) { + return app.disable_keyboard_shortcuts_while( + () => ask_for_label(props.strand, substrand, get_selected_domains())); + } ReactElement _insertions() { List paths = []; @@ -298,6 +308,7 @@ class DesignMainStrandComponent extends UiComponent2 ..transform = transform_of_helix(domain.helix) ..svg_position_y = props.helix_idx_to_svg_position_map[helix.idx].y ..display_reverse_DNA_right_side_up = props.display_reverse_DNA_right_side_up + ..retain_strand_color_on_selection = props.retain_strand_color_on_selection ..key = util.id_insertion(domain, selectable_insertion.insertion.offset))()); } } @@ -332,6 +343,7 @@ class DesignMainStrandComponent extends UiComponent2 ..selected = props.selected_deletions_in_strand.contains(selectable_deletion) ..transform = transform_of_helix(domain.helix) ..svg_position_y = props.helix_idx_to_svg_position_map[domain.helix].y + ..retain_strand_color_on_selection = props.retain_strand_color_on_selection ..key = id)()); } } @@ -474,10 +486,14 @@ assigned, assign the complementary DNA sequence to this strand. if (props.strand.name != null) ContextMenuItem( title: 'remove strand name', - on_click: () => app.dispatch(actions.StrandNameSet(name: null, strand: props.strand))), + on_click: () => app.dispatch(batch_if_multiple_selected( + (strand) => actions.StrandNameSet(name: null, strand: strand), + props.strand, + app.state.ui_state.selectables_store.selected_strands, + 'remove strand name'))), ContextMenuItem( title: 'set domain name', - on_click: () => set_substrand_name(substrand), + on_click: () => set_domain_names(get_selected_domains()), ), ContextMenuItem( title: 'assign domain name complement from bound strands', @@ -491,7 +507,9 @@ feature for individual domains, set select mode to domain. if (substrand.name != null) ContextMenuItem( title: 'remove domain name', - on_click: () => app.dispatch(actions.SubstrandNameSet(name: null, substrand: substrand))), + on_click: () => app.dispatch(actions.BatchAction( + get_selected_domains().map((d) => actions.SubstrandNameSet(name: null, substrand: d)), + 'remove domain names'))), ].build()), ContextMenuItem( title: 'edit label', @@ -503,15 +521,21 @@ feature for individual domains, set select mode to domain. if (props.strand.label != null) ContextMenuItem( title: 'remove strand label', - on_click: () => app.dispatch(actions.StrandLabelSet(label: null, strand: props.strand))), + on_click: () => app.dispatch(batch_if_multiple_selected( + (strand) => actions.StrandLabelSet(label: null, strand: strand), + props.strand, + app.state.ui_state.selectables_store.selected_strands, + 'remove strand label'))), ContextMenuItem( title: 'set domain label', - on_click: () => set_substrand_label(substrand), + on_click: () => set_domain_labels(substrand, get_selected_domains()), ), if (substrand.label != null) ContextMenuItem( title: 'remove domain label', - on_click: () => app.dispatch(actions.SubstrandLabelSet(label: null, substrand: substrand))), + on_click: () => app.dispatch(actions.BatchAction( + get_selected_domains().map((d) => actions.SubstrandLabelSet(label: null, substrand: d)), + 'remove domain labels'))), ].build()), ContextMenuItem( title: 'reflect', @@ -901,7 +925,7 @@ PAGEHPLC : Dual PAGE & HPLC app.dispatch(batch_action); } - Future ask_for_strand_name() async { + Future ask_for_strand_name(Strand strand, BuiltSet selected_strands) async { int name_idx = 0; var items = List.filled(1, null); items[name_idx] = DialogText(label: 'name', value: props.strand.name ?? ''); @@ -912,17 +936,17 @@ PAGEHPLC : Dual PAGE & HPLC if (results == null) return; String name = (results[name_idx] as DialogText).value; - actions.UndoableAction action = actions.StrandNameSet(name: name, strand: props.strand); + actions.UndoableAction action = batch_if_multiple_selected( + name_set_strand_action_creator(name), strand, selected_strands, "set strand names"); app.dispatch(action); } - Future ask_for_substrand_name(Substrand substrand) async { + Future ask_for_domain_names(BuiltSet domains) async { int name_idx = 0; var items = List.filled(1, null); - - items[name_idx] = DialogText(label: 'name', value: substrand.name ?? ''); + items[name_idx] = DialogText(label: 'name', value: ""); var dialog = Dialog( - title: 'set ${substrand.type_description()} name', + title: 'set ${domains.first.type_description()} name', items: items, type: DialogType.set_domain_name, use_saved_response: false); @@ -931,12 +955,13 @@ PAGEHPLC : Dual PAGE & HPLC if (results == null) return; String name = (results[name_idx] as DialogText).value; - actions.UndoableAction action = actions.SubstrandNameSet(name: name, substrand: substrand); - app.dispatch(action); + return app.dispatch(actions.BatchAction( + domains.map((d) => actions.SubstrandNameSet(name: name, substrand: d)), "set domain names")); } } -Future ask_for_label(Strand strand, Substrand substrand, BuiltSet selected_strands) async { +Future ask_for_label( + Strand strand, Substrand substrand, BuiltSet selected_strands) async { String part_name = 'strand'; if (substrand != null) { part_name = substrand.type_description(); @@ -972,12 +997,12 @@ Future ask_for_label(Strand strand, Substrand substrand, BuiltSet actions.UndoableAction action; if (substrand == null) { - action = batch_if_multiple_selected( - label_set_strand_action_creator(label), strand, selected_strands, "set strand label"); + action = batch_if_multiple_selected(label_set_strand_action_creator(label), strand, + selected_strands as BuiltSet, "set strand label"); } else { - action = actions.SubstrandLabelSet(label: label, substrand: substrand); - // action = batch_if_multiple_selected( - // label_set_strand_action_creator(label), props.strand, selected_strands, "set domain label"); + action = actions.BatchAction( + selected_strands.map((s) => actions.SubstrandLabelSet(label: label, substrand: (s as Substrand))), + "set substrand labels"); } app.dispatch(action); @@ -1000,6 +1025,16 @@ actions.UndoableAction batch_if_multiple_selected(StrandActionCreator action_cre return action; } +// this is so if user selects with the domain select tool, it still returns the selected domains +// as opposed to just using "selected_strands" +BuiltSet get_selected_domains() { + return BuiltSet(app.state.ui_state.selectables_store.selected_strands + .map((s) => s.substrands) + .expand((l) => l) + .toBuiltSet()) + .union(app.state.ui_state.selectables_store.selected_domains); +} + typedef StrandActionCreator = actions.UndoableAction Function(Strand strand); StrandActionCreator scaffold_set_strand_action_creator(bool is_scaffold) => @@ -1016,6 +1051,9 @@ StrandActionCreator color_set_substrand_action_creator(Substrand substrand, Stri ((Strand strand) => actions.StrandOrSubstrandColorSet(strand: strand, substrand: substrand, color: Color.hex(color_hex))); +StrandActionCreator name_set_strand_action_creator(String name) => + ((Strand strand) => actions.StrandNameSet(strand: strand, name: name)); + StrandActionCreator label_set_strand_action_creator(String label) => ((Strand strand) => actions.StrandLabelSet(strand: strand, label: label)); diff --git a/lib/src/view/design_main_strand_and_domain_texts.dart b/lib/src/view/design_main_strand_and_domain_texts.dart index 2ecc3646d..97b8477f0 100644 --- a/lib/src/view/design_main_strand_and_domain_texts.dart +++ b/lib/src/view/design_main_strand_and_domain_texts.dart @@ -16,7 +16,7 @@ import '../state/loopout.dart'; import 'design_main_strand_paths.dart'; import 'design_main_strand_domain_text.dart'; import 'design_main_strand_loopout_name.dart'; -import 'design_main_strand_extension_name.dart'; +import 'design_main_strand_extension_text.dart'; import 'pure_component.dart'; import '../state/strand.dart'; import '../state/helix.dart'; @@ -241,11 +241,11 @@ class DesignMainStrandAndDomainTextsComponent extends UiComponent2 selectable_deletion.domain; int get deletion => selectable_deletion.offset; num svg_position_y; + bool retain_strand_color_on_selection; } class DesignMainStrandDeletionProps = UiProps with DesignMainStrandDeletionPropsMixin; @@ -55,7 +56,11 @@ class DesignMainStrandDeletionComponent extends UiComponent2 helix_svg_position; + bool retain_strand_color_on_selection; } class DesignMainDNAEndProps = UiProps with DesignMainDNAEndPropsMixin; @@ -94,7 +95,11 @@ class DesignMainDNAEndComponent extends UiComponent2 with } if (props.selected) { - classname += ' ' + constants.css_selector_selected; + if (props.retain_strand_color_on_selection) { + classname += ' ' + constants.css_selector_selected; + } else { + classname += ' ' + constants.css_selector_selected_pink; + } } if (props.is_scaffold) { classname += ' ' + constants.css_selector_scaffold; diff --git a/lib/src/view/design_main_strand_domain.dart b/lib/src/view/design_main_strand_domain.dart index 96dff5df8..3cf08fd4c 100644 --- a/lib/src/view/design_main_strand_domain.dart +++ b/lib/src/view/design_main_strand_domain.dart @@ -51,6 +51,7 @@ mixin DesignMainDomainPropsMixin on UiProps { BuiltMap helices; BuiltMap groups; Geometry geometry; + bool retain_strand_color_on_selection; } class DesignMainDomainProps = UiProps with DesignMainDomainPropsMixin, TransformByHelixGroupPropsMixin; @@ -70,7 +71,11 @@ class DesignMainDomainComponent extends UiComponent2 var classname = constants.css_selector_domain; if (props.selected) { - classname += ' ' + constants.css_selector_selected; + if (props.retain_strand_color_on_selection) { + classname += ' ' + constants.css_selector_selected; + } else { + classname += ' ' + constants.css_selector_selected_pink; + } } if (props.strand.is_scaffold) { classname += ' ' + constants.css_selector_scaffold; diff --git a/lib/src/view/design_main_strand_extension.dart b/lib/src/view/design_main_strand_extension.dart index 43437b758..47894e680 100644 --- a/lib/src/view/design_main_strand_extension.dart +++ b/lib/src/view/design_main_strand_extension.dart @@ -53,6 +53,7 @@ mixin DesignMainExtensionPropsMixin on UiProps { BuiltMap helices; BuiltMap groups; Geometry geometry; + bool retain_strand_color_on_selection; } class DesignMainExtensionProps = UiProps with DesignMainExtensionPropsMixin, TransformByHelixGroupPropsMixin; @@ -76,7 +77,11 @@ class DesignMainExtensionComponent extends UiComponent2 right_svg.x) { - var swap = left_svg; - left_svg = right_svg; - right_svg = swap; + //NOTE: this causes the DNA to appear backwards in cases when the 3' end appears to the left + // of the 5' end. For now we will ditch this and draw the extension path from 5' to 3' + // (see svg_5p and svg_3p below), and figure out later how to display the name properly. + // Leaving this code here to help with that later. + // var left_svg = extension_free_end_svg; + // var right_svg = extension_attached_end_svg; + // if (left_svg.x > right_svg.x) { + // var swap = left_svg; + // left_svg = right_svg; + // right_svg = swap; + // } + + var svg_5p = extension_free_end_svg; + var svg_3p = extension_attached_end_svg; + if (!ext.is_5p) { + var swap = svg_5p; + svg_5p = svg_3p; + svg_3p = swap; } var color = ext.color ?? props.strand_color; @@ -97,8 +114,10 @@ class DesignMainExtensionComponent extends UiComponent2 app.dispatch(actions.SubstrandNameSet(name: null, substrand: props.ext))), + on_click: () => app.dispatch(actions.BatchAction( + app.state.ui_state.selectables_store.selected_extensions + .map((e) => actions.SubstrandNameSet(name: null, substrand: e)), + "remove extension names"))), ContextMenuItem( title: 'set extension label', on_click: set_extension_label, @@ -181,7 +203,10 @@ class DesignMainExtensionComponent extends UiComponent2 app.dispatch(actions.SubstrandLabelSet(label: null, substrand: props.ext))), + on_click: () => app.dispatch(actions.BatchAction( + app.state.ui_state.selectables_store.selected_extensions + .map((e) => actions.SubstrandLabelSet(label: null, substrand: e)), + "remove extension labels"))), ContextMenuItem( title: 'set extension color', on_click: () => app @@ -217,7 +242,7 @@ class DesignMainExtensionComponent extends UiComponent2 app.disable_keyboard_shortcuts_while(() => design_main_strand.ask_for_label( props.strand, props.ext, - app.state.ui_state.selectables_store.selected_substrands, + app.state.ui_state.selectables_store.selected_extensions, )); Future ask_for_extension_name() async { @@ -230,7 +255,15 @@ class DesignMainExtensionComponent extends UiComponent2 1) { + action = actions.BatchAction( + selected_exts.map((e) => actions.SubstrandNameSet(name: name, substrand: e)), + "set extension names"); + } else { + action = actions.SubstrandNameSet(name: name, substrand: props.ext); + } app.dispatch(action); } diff --git a/lib/src/view/design_main_strand_extension_name.dart b/lib/src/view/design_main_strand_extension_text.dart similarity index 96% rename from lib/src/view/design_main_strand_extension_name.dart rename to lib/src/view/design_main_strand_extension_text.dart index 3115b01c0..78ea89026 100644 --- a/lib/src/view/design_main_strand_extension_name.dart +++ b/lib/src/view/design_main_strand_extension_text.dart @@ -6,7 +6,7 @@ import 'pure_component.dart'; import '../util.dart' as util; import '../constants.dart' as constants; -part 'design_main_strand_extension_name.over_react.g.dart'; +part 'design_main_strand_extension_text.over_react.g.dart'; UiFactory DesignMainStrandExtensionText = _$DesignMainStrandExtensionText; diff --git a/lib/src/view/design_main_strand_insertion.dart b/lib/src/view/design_main_strand_insertion.dart index 5e5338c66..4f181086f 100644 --- a/lib/src/view/design_main_strand_insertion.dart +++ b/lib/src/view/design_main_strand_insertion.dart @@ -39,6 +39,8 @@ mixin DesignMainStrandInsertionPropsMixin on UiProps { Insertion get insertion => selectable_insertion.insertion; Domain get domain => selectable_insertion.domain; + + bool retain_strand_color_on_selection; } class DesignMainStrandInsertionProps = UiProps with DesignMainStrandInsertionPropsMixin; @@ -50,7 +52,11 @@ class DesignMainStrandInsertionComponent extends UiComponent2 app.dispatch(actions.SubstrandNameSet(name: null, substrand: props.loopout))), + on_click: () => app.dispatch(actions.BatchAction( + app.state.ui_state.selectables_store.selected_loopouts + .map((l) => actions.SubstrandNameSet(name: null, substrand: l)), + "remove loopout names"))), ContextMenuItem( title: 'set loopout label', on_click: set_loopout_label, @@ -174,7 +182,10 @@ class DesignMainLoopoutComponent extends UiStatefulComponent2 app.dispatch(actions.SubstrandLabelSet(substrand: props.loopout, label: null))), + on_click: () => app.dispatch(actions.BatchAction( + app.state.ui_state.selectables_store.selected_loopouts + .map((l) => actions.SubstrandLabelSet(label: null, substrand: l)), + "remove loopout names"))), ContextMenuItem( title: 'set loopout color', on_click: () => app.dispatch( @@ -224,7 +235,7 @@ class DesignMainLoopoutComponent extends UiStatefulComponent2 app.disable_keyboard_shortcuts_while(() => design_main_strand.ask_for_label( props.strand, props.loopout, - app.state.ui_state.selectables_store.selected_substrands, + app.state.ui_state.selectables_store.selected_loopouts, )); Future ask_for_loopout_name() async { @@ -237,8 +248,10 @@ class DesignMainLoopoutComponent extends UiStatefulComponent2 actions.SubstrandNameSet(name: name, substrand: l)), + "set loopout names")); } String loopout_path_description_between_groups() { diff --git a/lib/src/view/design_main_strand_modification.dart b/lib/src/view/design_main_strand_modification.dart index b5da8437a..880ad157d 100644 --- a/lib/src/view/design_main_strand_modification.dart +++ b/lib/src/view/design_main_strand_modification.dart @@ -49,6 +49,8 @@ mixin DesignMainStrandModificationProps on UiProps { num helix_svg_position_y; Extension ext; // optional; used if mod is on extension + + bool retain_strand_color_on_selection; } class DesignMainStrandModificationComponent extends UiComponent2 { @@ -87,7 +89,11 @@ class DesignMainStrandModificationComponent extends UiComponent2 selected_modifications_in_strand; BuiltMap helix_idx_to_svg_position_y_map; + + bool retain_strand_color_on_selection; } class DesignMainStrandModificationsProps = UiProps @@ -65,6 +67,7 @@ class DesignMainStrandModificationsComponent extends UiComponent2 Function(Strand strand, {Substrand substrand, Address address, ModificationType type}) context_menu_strand; BuiltMap> helix_idx_to_svg_position_map; + bool retain_strand_color_on_selection; } class DesignMainStrandPathsProps = UiProps @@ -113,8 +114,10 @@ class DesignMainStrandPathsComponent extends UiComponent2 ConnectedDesignMainStrands = ..domain_label_font_size = state.ui_state.domain_label_font_size ..helix_idx_to_svg_position_map = state.helix_idx_to_svg_position_map ..display_reverse_DNA_right_side_up = state.ui_state.display_reverse_DNA_right_side_up - ..geometry = state.design.geometry; + ..geometry = state.design.geometry + ..retain_strand_color_on_selection = state.ui_state.retain_strand_color_on_selection; })(DesignMainStrands); UiFactory DesignMainStrands = _$DesignMainStrands; @@ -73,6 +74,7 @@ mixin DesignMainStrandsProps on UiProps { bool display_reverse_DNA_right_side_up; Geometry geometry; BuiltMap> helix_idx_to_svg_position_map; + bool retain_strand_color_on_selection; } class DesignMainStrandsComponent extends UiComponent2 with PureComponent { @@ -131,6 +133,7 @@ class DesignMainStrandsComponent extends UiComponent2 wi ..geometry = props.geometry ..helix_idx_to_svg_position_map = props.helix_idx_to_svg_position_map ..display_reverse_DNA_right_side_up = props.display_reverse_DNA_right_side_up + ..retain_strand_color_on_selection = props.retain_strand_color_on_selection ..key = strand.toString())()); } diff --git a/lib/src/view/menu.dart b/lib/src/view/menu.dart index 5575f9f66..973f8d482 100644 --- a/lib/src/view/menu.dart +++ b/lib/src/view/menu.dart @@ -4,6 +4,7 @@ import 'package:built_collection/built_collection.dart'; import 'package:path/path.dart' as path; import 'package:over_react/over_react.dart'; import 'package:over_react/over_react_redux.dart'; +import 'package:scadnano/src/state/base_pair_display_type.dart'; import 'react_bootstrap.dart'; @@ -47,6 +48,7 @@ UiFactory ConnectedMenu = connect( ..no_grid_is_none = state.design == null ? false : state.design.groups.values.every((group) => group.grid != Grid.none) ..show_dna = state.ui_state.show_dna + ..base_pair_display_type = state.ui_state.base_pair_display_type ..show_strand_names = state.ui_state.show_strand_names ..show_strand_labels = state.ui_state.show_strand_labels ..show_domain_names = state.ui_state.show_domain_names @@ -86,9 +88,11 @@ UiFactory ConnectedMenu = connect( ..show_grid_coordinates_side_view = state.ui_state.show_grid_coordinates_side_view ..show_helices_axis_arrows = state.ui_state.show_helices_axis_arrows ..show_loopout_extension_length = state.ui_state.show_loopout_extension_length + ..export_svg_text_separately = state.ui_state.export_svg_text_separately ..show_slice_bar = state.ui_state.show_slice_bar ..show_mouseover_data = state.ui_state.show_mouseover_data ..disable_png_caching_dna_sequences = state.ui_state.disable_png_caching_dna_sequences + ..retain_strand_color_on_selection = state.ui_state.retain_strand_color_on_selection ..display_reverse_DNA_right_side_up = state.ui_state.display_reverse_DNA_right_side_up ..local_storage_design_choice = state.ui_state.local_storage_design_choice ..clear_helix_selection_when_loading_new_design = @@ -98,6 +102,7 @@ UiFactory ConnectedMenu = connect( ..default_crossover_type_staple_for_setting_helix_rolls = state.ui_state.default_crossover_type_staple_for_setting_helix_rolls ..selection_box_intersection = state.ui_state.selection_box_intersection + ..ox_export_only_selected_strands = state.ui_state.ox_export_only_selected_strands ..undo_redo = state.undo_redo); }, // Used for component test. @@ -132,6 +137,7 @@ mixin MenuPropsMixin on UiProps { bool autofit; bool only_display_selected_helices; ExampleDesigns example_designs; + BasePairDisplayType base_pair_display_type; bool design_has_insertions_or_deletions; bool undo_stack_empty; bool redo_stack_empty; @@ -151,9 +157,12 @@ mixin MenuPropsMixin on UiProps { bool show_loopout_extension_length; bool show_mouseover_data; bool disable_png_caching_dna_sequences; + bool retain_strand_color_on_selection; bool display_reverse_DNA_right_side_up; bool default_crossover_type_scaffold_for_setting_helix_rolls; bool default_crossover_type_staple_for_setting_helix_rolls; + bool export_svg_text_separately; + bool ox_export_only_selected_strands; LocalStorageDesignChoice local_storage_design_choice; bool clear_helix_selection_when_loading_new_design; bool show_slice_bar; @@ -998,23 +1007,38 @@ or real coordinates in nanometers, depending on whether a grid is selected).''' ..key = 'view_menu_base_pairs-dropdown' ..className = 'submenu_item')([ (MenuBoolean() - ..value = props.show_base_pair_lines - ..display = 'Base pair lines' - ..tooltip = 'Draw vertical lines between pairs of bases at the same offset on the same helix.' - ..onChange = ((_) => - props.dispatch(actions.ShowBasePairLinesSet(show_base_pair_lines: !props.show_base_pair_lines))) - ..key = 'base_pair_lines')(), + ..value = props.base_pair_display_type.toIndex() == 1 + ..display = 'Display as ${BasePairDisplayType.lines.display_name()}' + ..key = 'base-pair-display-lines' + ..onChange = (_) { + if (props.base_pair_display_type == BasePairDisplayType.lines) { + props.dispatch(actions.BasePairTypeSet(selected_idx: BasePairDisplayType.none.toIndex())); + } else if (props.base_pair_display_type == BasePairDisplayType.none) { + props.dispatch(actions.BasePairTypeSet(selected_idx: BasePairDisplayType.lines.toIndex())); + } + })(), + (MenuBoolean() + ..value = props.base_pair_display_type.toIndex() == 2 + ..display = 'Display as ${BasePairDisplayType.rectangle.display_name()}' + ..key = 'base-pair-display-rectangle' + ..onChange = (_) { + if (props.base_pair_display_type == BasePairDisplayType.rectangle) { + props.dispatch(actions.BasePairTypeSet(selected_idx: BasePairDisplayType.none.toIndex())); + } else if (props.base_pair_display_type == BasePairDisplayType.none) { + props.dispatch(actions.BasePairTypeSet(selected_idx: BasePairDisplayType.rectangle.toIndex())); + } + })(), (MenuBoolean() ..value = props.show_base_pair_lines_with_mismatches - ..hide = !props.show_base_pair_lines ..display = '... even if bases mismatch' + ..key = 'base-pair-display-even-if-bases-mismatch' + ..hide = props.base_pair_display_type.toIndex() == 0 ..tooltip = '''\ Lines are drawn between all pairs of bases at the same offset on the same helix, regardless of whether the bases are complementary. If unchecked then lines are only shown between pairs of complementary bases.''' - ..onChange = ((_) => props.dispatch(actions.ShowBasePairLinesWithMismatchesSet( - show_base_pair_lines_with_mismatches: !props.show_base_pair_lines_with_mismatches))) - ..key = 'base_pair_lines_mismatches')(), + ..onChange = (_) => props.dispatch(actions.ShowBasePairLinesWithMismatchesSet( + show_base_pair_lines_with_mismatches: !props.show_base_pair_lines_with_mismatches)))(), ]); } @@ -1141,6 +1165,19 @@ debugging, but be warned that it will be very slow to render a large number of D props.dispatch(actions.DisablePngCachingDnaSequencesSet(!props.disable_png_caching_dna_sequences)); } ..key = 'disable-png-caching-dna-sequences')(), + (MenuBoolean() + ..value = props.retain_strand_color_on_selection + ..display = 'Retain strand color on selection' + ..tooltip = '''\ +Selected strands are normally highlighted in hot pink, which overrides the strand's color. +Select this option to not override the strand's color when it is selected. +A highlighting effect will still appear. + ''' + ..name = 'retain-strand-color-on-selection' + ..onChange = (_) { + props.dispatch(actions.RetainStrandColorOnSelectionSet(!props.retain_strand_color_on_selection)); + } + ..key = 'retain-strand-color-on-selection')(), ]; } @@ -1165,6 +1202,19 @@ debugging, but be warned that it will be very slow to render a large number of D ..on_click = ((_) => props.dispatch(actions.ExportSvg(type: actions.ExportSvgType.selected))) ..tooltip = "Export SVG figure of selected strands" ..display = 'SVG of selected strands')(), + (MenuBoolean() + ..value = props.export_svg_text_separately + ..display = 'export svg text separately (PPT)' + ..tooltip = '''\ +When selected, every symbol of the text in a DNA sequence is exported as a separate +SVG text element. This is useful if the SVG will be imported into Powerpoint, which +is less expressive than SVG and can render the text strangely.''' + ..name = 'export-svg-text-separately' + ..onChange = (_) { + props.dispatch(actions.ExportSvgTextSeparatelySet(!props.export_svg_text_separately)); + } + ..key = 'export-svg-text-separately')(), + DropdownDivider({'key': 'divider-export-svg'}), (MenuDropdownItem() ..on_click = ((_) => app.disable_keyboard_shortcuts_while(export_dna_sequences.export_dna)) ..tooltip = "Export DNA sequences of strands to a file." @@ -1174,7 +1224,7 @@ debugging, but be warned that it will be very slow to render a large number of D ..tooltip = "Export design's DNA sequences as a CSV in the same way as cadnano v2.\n" "This is useful, for example, with CanDo's atomic model generator." ..display = 'DNA sequences (cadnano v2 format)')(), - DropdownDivider({'key': 'divider-not-full-design'}), + DropdownDivider({'key': 'divider-export-dna'}), (MenuDropdownItem() ..on_click = ((_) => props.dispatch(actions.ExportCadnanoFile(whitespace: true))) ..tooltip = "Export design to cadnano (version 2) .json file." @@ -1201,23 +1251,30 @@ cadnano files that have whitespace. ("Bad .json file format is detected in 'structure.json'. Or no dsDNA or strand crossovers exist.")""" ..display = 'cadnano v2 no whitespace' ..key = 'export-cadnano-no-whitespace')(), + DropdownDivider({'key': 'divider-cadnano'}), + (MenuDropdownItem() + ..on_click = ((_) => props + .dispatch(actions.OxviewExport(selected_strands_only: props.ox_export_only_selected_strands))) + ..tooltip = "Export design to oxView files, which can be loaded in oxView." + ..display = 'oxView' + ..key = 'export-oxview')(), (MenuDropdownItem() - ..on_click = ((_) => props.dispatch(actions.OxdnaExport())) + ..on_click = ((_) => + props.dispatch(actions.OxdnaExport(selected_strands_only: props.ox_export_only_selected_strands))) ..tooltip = "Export design to oxDNA .dat and .top files, which can be loaded in oxDNA or oxView." ..display = 'oxDNA' ..key = 'export-oxdna')(), - (MenuDropdownItem() - ..on_click = ((_) => props.dispatch(actions.OxdnaExport(selected_strands_only: true))) - ..tooltip = "Export design to oxDNA .dat and .top files, which can be loaded in oxDNA or oxView.\n" - "Only exports the currently selected strands." - ..display = 'oxDNA (selected strands)' - ..key = 'export-oxdna-selected-strands')(), - //TODO: figure out if ENSnano is close to codenano format; if so this might work for exporting to it. - // (MenuDropdownItem() - // ..on_click = ((_) => props.dispatch(actions.ExportCodenanoFile())) - // ..tooltip = "Export design to codenano format." - // ..display = 'codenano' - // ..key = 'export-codenano')(), + (MenuBoolean() + ..value = props.ox_export_only_selected_strands + ..display = 'export only selected strands' + ..tooltip = '''\ +When selected, only selected strands will be exported to oxDNA or oxView formats.''' + ..name = 'ox-export-only-selected-strands' + ..onChange = (_) { + props.dispatch( + actions.OxExportOnlySelectedStrandsSet(only_selected: !props.ox_export_only_selected_strands)); + } + ..key = 'ox-export-only-selected-strands')(), ); } diff --git a/web/scadnano-styles.css b/web/scadnano-styles.css index ee21607cd..ffab8b271 100644 --- a/web/scadnano-styles.css +++ b/web/scadnano-styles.css @@ -684,7 +684,6 @@ label + select { fill: lightgray; } - /* https://www.w3schools.com/cssref/css_selectors.asp */ /* These are needed to make parts pink when they are selected individually. */ .selected.crossover-curve, @@ -693,7 +692,31 @@ label + select { .selected.deletion-cross, .selected.modification, .selected.domain-line, -.selected.extension-line { +.selected.extension-line, +.selected .crossover-curve, +.selected .loopout-curve, +.selected .insertion-curve, +.selected .deletion-cross, +.selected .modification, +.selected .domain-line, +.selected .extension-line { + stroke-width: 5pt; +} + +.selected-pink.crossover-curve, +.selected-pink.loopout-curve, +.selected-pink.insertion-curve, +.selected-pink.deletion-cross, +.selected-pink.modification, +.selected-pink.domain-line, +.selected-pink.extension-line, +.selected-pink .crossover-curve, +.selected-pink .loopout-curve, +.selected-pink .insertion-curve, +.selected-pink .deletion-cross, +.selected-pink .modification, +.selected-pink .domain-line, +.selected-pink .extension-line { stroke: hotpink; stroke-width: 5pt; } @@ -702,36 +725,36 @@ label + select { .selected.three-prime-end, .selected.five-prime-end-first-substrand, .selected.three-prime-end-last-substrand { - stroke: red; - fill: hotpink; visibility: visible; stroke-width: 1pt; } -/* https://www.w3schools.com/cssref/css_selectors.asp */ -/* These are needed to make all parts pink when whole strand is selected, */ -/* i.e., a parent of these components is selected */ -.selected .crossover-curve, -.selected .loopout-curve, -.selected .insertion-curve, -.selected .deletion-cross, -.selected .modification, -.selected .domain-line, -.selected .extension-line { - stroke: hotpink; - stroke-width: 5pt; +.selected-pink.five-prime-end, +.selected-pink.three-prime-end, +.selected-pink.five-prime-end-first-substrand, +.selected-pink.three-prime-end-last-substrand { + stroke: red; + fill: hotpink; + visibility: visible; + stroke-width: 1pt; } .selected .five-prime-end, .selected .three-prime-end, .selected .five-prime-end-first-substrand, .selected .three-prime-end-last-substrand { + stroke-width: 1pt; +} + +.selected-pink .five-prime-end, +.selected-pink .three-prime-end, +.selected-pink .five-prime-end-first-substrand, +.selected-pink .three-prime-end-last-substrand { stroke: red; fill: hotpink; stroke-width: 1pt; } - /* end formatting of Strand elements when selected/selectable/not selectable */ /*****************************************************************************/ @@ -742,6 +765,10 @@ label + select { fill: none; } +.crossover-curve-same-helix { + stroke-opacity: 0.55; +} + .potential-vertical-crossover-curve { stroke-width: 4; fill: none;