dnakit 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dnakit/__init__.py +108 -0
- dnakit/_version.py +5 -0
- dnakit/alignment/__init__.py +6 -0
- dnakit/alignment/pairwise.py +512 -0
- dnakit/alignment/results.py +188 -0
- dnakit/backends/__init__.py +80 -0
- dnakit/backends/external.py +517 -0
- dnakit/backends/registry.py +207 -0
- dnakit/batch.py +452 -0
- dnakit/cache/__init__.py +5 -0
- dnakit/cache/store.py +424 -0
- dnakit/chunking.py +920 -0
- dnakit/cli/__init__.py +5 -0
- dnakit/cli/__main__.py +6 -0
- dnakit/cli/app.py +814 -0
- dnakit/cli/workflow.py +94 -0
- dnakit/config/__init__.py +5 -0
- dnakit/config/loader.py +381 -0
- dnakit/core/__init__.py +80 -0
- dnakit/core/_json.py +206 -0
- dnakit/core/backend_info.py +70 -0
- dnakit/core/collection.py +163 -0
- dnakit/core/coordinates.py +295 -0
- dnakit/core/enums.py +95 -0
- dnakit/core/facade.py +719 -0
- dnakit/core/feature.py +90 -0
- dnakit/core/gap.py +82 -0
- dnakit/core/issues.py +62 -0
- dnakit/core/provenance.py +305 -0
- dnakit/core/record.py +161 -0
- dnakit/core/results.py +134 -0
- dnakit/core/sequence.py +327 -0
- dnakit/datasets/__init__.py +100 -0
- dnakit/datasets/_advanced_shared.py +198 -0
- dnakit/datasets/_metadata.py +33 -0
- dnakit/datasets/advanced_deduplicate.py +183 -0
- dnakit/datasets/advanced_split.py +302 -0
- dnakit/datasets/clustering.py +286 -0
- dnakit/datasets/config.py +531 -0
- dnakit/datasets/deduplicate.py +303 -0
- dnakit/datasets/evaluation.py +244 -0
- dnakit/datasets/filter.py +135 -0
- dnakit/datasets/neural_clustering.py +221 -0
- dnakit/datasets/results.py +520 -0
- dnakit/datasets/split.py +450 -0
- dnakit/descriptors/__init__.py +102 -0
- dnakit/descriptors/_dinucleotide.py +345 -0
- dnakit/descriptors/_shared.py +180 -0
- dnakit/descriptors/all.py +917 -0
- dnakit/descriptors/basic.py +193 -0
- dnakit/descriptors/codon.py +195 -0
- dnakit/descriptors/complexity.py +201 -0
- dnakit/descriptors/entropy.py +101 -0
- dnakit/descriptors/homopolymer.py +105 -0
- dnakit/descriptors/kmer.py +88 -0
- dnakit/descriptors/results.py +286 -0
- dnakit/descriptors/schema.py +414 -0
- dnakit/descriptors/window.py +203 -0
- dnakit/download/__init__.py +54 -0
- dnakit/download/catalogs.py +166 -0
- dnakit/download/ena.py +131 -0
- dnakit/download/encode.py +103 -0
- dnakit/download/files.py +469 -0
- dnakit/download/indexes.py +233 -0
- dnakit/download/metadata.py +164 -0
- dnakit/download/models.py +227 -0
- dnakit/download/ncbi.py +333 -0
- dnakit/download/sequence.py +133 -0
- dnakit/download/ucsc.py +99 -0
- dnakit/evaluation/__init__.py +81 -0
- dnakit/evaluation/_shared.py +288 -0
- dnakit/evaluation/collection.py +270 -0
- dnakit/evaluation/config.py +591 -0
- dnakit/evaluation/distribution.py +287 -0
- dnakit/evaluation/frechet.py +223 -0
- dnakit/evaluation/generative.py +381 -0
- dnakit/evaluation/reference.py +409 -0
- dnakit/evaluation/results.py +137 -0
- dnakit/evaluation/scorecard.py +211 -0
- dnakit/evaluation/sequence.py +403 -0
- dnakit/evaluation/synthesis.py +249 -0
- dnakit/exceptions.py +189 -0
- dnakit/fingerprints/__init__.py +109 -0
- dnakit/fingerprints/_shared.py +116 -0
- dnakit/fingerprints/advanced.py +829 -0
- dnakit/fingerprints/bit.py +229 -0
- dnakit/fingerprints/encoding.py +351 -0
- dnakit/fingerprints/kmer.py +170 -0
- dnakit/fingerprints/results.py +531 -0
- dnakit/fingerprints/sketch.py +246 -0
- dnakit/io/__init__.py +112 -0
- dnakit/io/_advanced_common.py +105 -0
- dnakit/io/_formats.py +994 -0
- dnakit/io/annotations.py +1179 -0
- dnakit/io/api.py +759 -0
- dnakit/io/config.py +315 -0
- dnakit/io/genbank.py +523 -0
- dnakit/io/indexed.py +1146 -0
- dnakit/io/metadata.py +452 -0
- dnakit/io/parquet.py +56 -0
- dnakit/io/results.py +131 -0
- dnakit/io/source.py +138 -0
- dnakit/io/tables.py +1237 -0
- dnakit/molbio/__init__.py +101 -0
- dnakit/molbio/_shared.py +266 -0
- dnakit/molbio/assembly.py +279 -0
- dnakit/molbio/crispr.py +181 -0
- dnakit/molbio/optimization.py +897 -0
- dnakit/molbio/primer3_design.py +667 -0
- dnakit/molbio/primers.py +899 -0
- dnakit/molbio/restriction.py +573 -0
- dnakit/molbio/results.py +583 -0
- dnakit/ops/__init__.py +588 -0
- dnakit/ops/_common.py +201 -0
- dnakit/ops/circular.py +193 -0
- dnakit/ops/concat.py +204 -0
- dnakit/ops/direction.py +27 -0
- dnakit/ops/edit.py +443 -0
- dnakit/ops/evolution.py +991 -0
- dnakit/ops/mutation.py +151 -0
- dnakit/ops/records.py +1012 -0
- dnakit/ops/translation.py +257 -0
- dnakit/patterns/__init__.py +81 -0
- dnakit/patterns/_shared.py +282 -0
- dnakit/patterns/coding.py +360 -0
- dnakit/patterns/crispr.py +337 -0
- dnakit/patterns/motif.py +721 -0
- dnakit/patterns/regions.py +341 -0
- dnakit/patterns/repeats.py +480 -0
- dnakit/patterns/restriction.py +281 -0
- dnakit/patterns/results.py +411 -0
- dnakit/py.typed +1 -0
- dnakit/references/__init__.py +27 -0
- dnakit/references/models.py +155 -0
- dnakit/references/ncbi.py +785 -0
- dnakit/representations/__init__.py +29 -0
- dnakit/representations/backends.py +928 -0
- dnakit/representations/checkpoints.py +356 -0
- dnakit/representations/extraction.py +220 -0
- dnakit/representations/models.py +395 -0
- dnakit/representations/results.py +79 -0
- dnakit/search/__init__.py +132 -0
- dnakit/search/_http.py +271 -0
- dnakit/search/_shared.py +108 -0
- dnakit/search/blast.py +516 -0
- dnakit/search/ena.py +245 -0
- dnakit/search/encode.py +102 -0
- dnakit/search/ensembl.py +874 -0
- dnakit/search/models.py +159 -0
- dnakit/search/ncbi.py +756 -0
- dnakit/search/ucsc.py +388 -0
- dnakit/secondary_structure/__init__.py +38 -0
- dnakit/secondary_structure/dotbracket.py +410 -0
- dnakit/secondary_structure/nupack.py +589 -0
- dnakit/secondary_structure/results.py +142 -0
- dnakit/similarity/__init__.py +125 -0
- dnakit/similarity/_shared.py +152 -0
- dnakit/similarity/approximate.py +258 -0
- dnakit/similarity/compare.py +162 -0
- dnakit/similarity/dashing.py +936 -0
- dnakit/similarity/distance.py +279 -0
- dnakit/similarity/index.py +365 -0
- dnakit/similarity/matrix.py +254 -0
- dnakit/similarity/results.py +676 -0
- dnakit/similarity/search.py +286 -0
- dnakit/similarity/sketch.py +96 -0
- dnakit/similarity/vector.py +662 -0
- dnakit/standardize/__init__.py +77 -0
- dnakit/standardize/_shared.py +100 -0
- dnakit/standardize/config.py +411 -0
- dnakit/standardize/gaps.py +1060 -0
- dnakit/standardize/normalize.py +565 -0
- dnakit/standardize/results.py +247 -0
- dnakit/standardize/validate.py +487 -0
- dnakit/structure3d/__init__.py +47 -0
- dnakit/structure3d/_shared.py +47 -0
- dnakit/structure3d/geometry.py +773 -0
- dnakit/structure3d/pdb.py +253 -0
- dnakit/structure3d/results.py +233 -0
- dnakit/structure3d/threedna.py +174 -0
- dnakit/thermodynamics/__init__.py +112 -0
- dnakit/thermodynamics/_shared.py +92 -0
- dnakit/thermodynamics/backends.py +653 -0
- dnakit/thermodynamics/calculations.py +837 -0
- dnakit/thermodynamics/config.py +175 -0
- dnakit/thermodynamics/equilibrium.py +340 -0
- dnakit/thermodynamics/optics.py +385 -0
- dnakit/thermodynamics/parameters.py +79 -0
- dnakit/thermodynamics/results.py +445 -0
- dnakit/visualization/__init__.py +43 -0
- dnakit/visualization/_svg.py +185 -0
- dnakit/visualization/advanced.py +314 -0
- dnakit/visualization/config.py +255 -0
- dnakit/visualization/export.py +349 -0
- dnakit/visualization/matrix.py +279 -0
- dnakit/visualization/report.py +431 -0
- dnakit/visualization/results.py +236 -0
- dnakit/visualization/sequence.py +459 -0
- dnakit/workflows/__init__.py +45 -0
- dnakit/workflows/manifest.py +365 -0
- dnakit/workflows/runner.py +1548 -0
- dnakit/workflows/schema.py +898 -0
- dnakit-0.1.0.dev0.dist-info/METADATA +126 -0
- dnakit-0.1.0.dev0.dist-info/RECORD +210 -0
- dnakit-0.1.0.dev0.dist-info/WHEEL +5 -0
- dnakit-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- dnakit-0.1.0.dev0.dist-info/licenses/DISCLAIMER.md +26 -0
- dnakit-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
- dnakit-0.1.0.dev0.dist-info/licenses/THIRD_PARTY_NOTICES.md +73 -0
- dnakit-0.1.0.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Dependency-free SVG renderers for advanced DNAKit result relationships."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
from dnakit.alignment import AlignmentResult
|
|
8
|
+
from dnakit.core import (
|
|
9
|
+
CompoundLocation,
|
|
10
|
+
DNARecord,
|
|
11
|
+
DNASequence,
|
|
12
|
+
Interval,
|
|
13
|
+
Topology,
|
|
14
|
+
UnresolvedLocation,
|
|
15
|
+
)
|
|
16
|
+
from dnakit.core.facade import DNA, resolve_single_dna
|
|
17
|
+
from dnakit.exceptions import ConfigurationError
|
|
18
|
+
|
|
19
|
+
from ._svg import SVGBuilder, number
|
|
20
|
+
from .config import SVGTheme
|
|
21
|
+
from .results import SVGArtifact
|
|
22
|
+
|
|
23
|
+
_MAX_CANVAS_DIMENSION = 1_000_000
|
|
24
|
+
_MAX_MAP_PARTS = 100_000
|
|
25
|
+
_MAX_ALIGNMENT_COLUMNS = 100_000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _bounded_integer(
|
|
29
|
+
value: object,
|
|
30
|
+
name: str,
|
|
31
|
+
*,
|
|
32
|
+
minimum: int = 1,
|
|
33
|
+
maximum: int,
|
|
34
|
+
) -> int:
|
|
35
|
+
if isinstance(value, bool) or not isinstance(value, int) or not minimum <= value <= maximum:
|
|
36
|
+
raise ConfigurationError(f"{name} must be an integer in [{minimum}, {maximum}].")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _validate_text(value: object, name: str) -> str:
|
|
41
|
+
if not isinstance(value, str) or not value.strip():
|
|
42
|
+
raise ConfigurationError(f"{name} must be a non-empty string.")
|
|
43
|
+
if len(value) > 10_000:
|
|
44
|
+
raise ConfigurationError(
|
|
45
|
+
f"{name} exceeds the visualization text limit.",
|
|
46
|
+
code="VISUALIZATION_TEXT_LIMIT",
|
|
47
|
+
context={"name": name, "max_characters": 10_000},
|
|
48
|
+
)
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _resolve_theme(theme: SVGTheme | None) -> SVGTheme:
|
|
53
|
+
if theme is None:
|
|
54
|
+
return SVGTheme()
|
|
55
|
+
if not isinstance(theme, SVGTheme):
|
|
56
|
+
raise ConfigurationError("theme must be SVGTheme or None.")
|
|
57
|
+
return theme
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _sequence_and_features(
|
|
61
|
+
value: DNA | DNASequence | DNARecord,
|
|
62
|
+
) -> tuple[DNASequence, tuple[object, ...]]:
|
|
63
|
+
if isinstance(value, (DNA, DNARecord, DNASequence)):
|
|
64
|
+
sequence, record = resolve_single_dna(value)
|
|
65
|
+
return sequence, () if record is None else record.features
|
|
66
|
+
raise ConfigurationError("value must be DNASequence or DNARecord.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parts(location: object) -> tuple[Interval, ...]:
|
|
70
|
+
if isinstance(location, Interval):
|
|
71
|
+
return (location,)
|
|
72
|
+
if isinstance(location, CompoundLocation):
|
|
73
|
+
return location.parts
|
|
74
|
+
if isinstance(location, UnresolvedLocation):
|
|
75
|
+
return ()
|
|
76
|
+
return ()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _feature_parts(
|
|
80
|
+
features: tuple[object, ...], max_features: int
|
|
81
|
+
) -> tuple[tuple[int, Interval], ...]:
|
|
82
|
+
if len(features) > max_features:
|
|
83
|
+
raise ConfigurationError("Feature count exceeds max_features.")
|
|
84
|
+
output: list[tuple[int, Interval]] = []
|
|
85
|
+
for index, feature in enumerate(features):
|
|
86
|
+
for part in _parts(getattr(feature, "location", None)):
|
|
87
|
+
if len(output) >= max_features:
|
|
88
|
+
raise ConfigurationError(
|
|
89
|
+
"Rendered feature-part count exceeds max_features.",
|
|
90
|
+
code="VISUALIZATION_FEATURE_PART_LIMIT",
|
|
91
|
+
)
|
|
92
|
+
output.append((index, part))
|
|
93
|
+
return tuple(output)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def plot_linear_map(
|
|
97
|
+
value: DNA | DNASequence | DNARecord,
|
|
98
|
+
*,
|
|
99
|
+
width: int = 900,
|
|
100
|
+
height: int = 220,
|
|
101
|
+
max_features: int = 10_000,
|
|
102
|
+
title: str = "Linear DNA map",
|
|
103
|
+
theme: SVGTheme | None = None,
|
|
104
|
+
) -> SVGArtifact:
|
|
105
|
+
"""Draw a resolved linear coordinate axis and feature intervals."""
|
|
106
|
+
|
|
107
|
+
_bounded_integer(width, "width", minimum=120, maximum=_MAX_CANVAS_DIMENSION)
|
|
108
|
+
_bounded_integer(height, "height", minimum=100, maximum=_MAX_CANVAS_DIMENSION)
|
|
109
|
+
_bounded_integer(max_features, "max_features", maximum=_MAX_MAP_PARTS)
|
|
110
|
+
_validate_text(title, "title")
|
|
111
|
+
sequence, features = _sequence_and_features(value)
|
|
112
|
+
if sequence.topology is not Topology.LINEAR or sequence.coordinate_span is None:
|
|
113
|
+
raise ConfigurationError("Linear map requires a resolved linear sequence.")
|
|
114
|
+
parts = _feature_parts(features, max_features)
|
|
115
|
+
resolved_theme = _resolve_theme(theme)
|
|
116
|
+
span = sequence.coordinate_span
|
|
117
|
+
builder = SVGBuilder(
|
|
118
|
+
width,
|
|
119
|
+
height,
|
|
120
|
+
title=title,
|
|
121
|
+
description="DNAKit linear DNA coordinate and feature map",
|
|
122
|
+
kind="linear-map",
|
|
123
|
+
theme=resolved_theme,
|
|
124
|
+
metadata={
|
|
125
|
+
"span": span,
|
|
126
|
+
"feature_count": len(features),
|
|
127
|
+
"rendered_part_count": len(parts),
|
|
128
|
+
"unresolved_feature_count": sum(
|
|
129
|
+
isinstance(getattr(feature, "location", None), UnresolvedLocation)
|
|
130
|
+
for feature in features
|
|
131
|
+
),
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
left, right, axis_y = 60, width - 40, height // 2
|
|
135
|
+
builder.line(left, axis_y, right, axis_y, stroke=resolved_theme.foreground, stroke_width=3)
|
|
136
|
+
scale = (right - left) / max(1, span)
|
|
137
|
+
builder.text(left, axis_y + 28, 0, fill=resolved_theme.muted)
|
|
138
|
+
builder.text(right, axis_y + 28, span, fill=resolved_theme.muted, text_anchor="end")
|
|
139
|
+
for index, part in parts:
|
|
140
|
+
builder.rect(
|
|
141
|
+
left + part.start * scale,
|
|
142
|
+
axis_y - 18 - 18 * (index % 3),
|
|
143
|
+
max(1.0, len(part) * scale),
|
|
144
|
+
12,
|
|
145
|
+
fill=resolved_theme.accent,
|
|
146
|
+
class_="feature",
|
|
147
|
+
data_feature_index=index,
|
|
148
|
+
data_start=part.start,
|
|
149
|
+
data_end=part.end,
|
|
150
|
+
)
|
|
151
|
+
return builder.artifact()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def plot_circular_map(
|
|
155
|
+
value: DNA | DNASequence | DNARecord,
|
|
156
|
+
*,
|
|
157
|
+
size: int = 520,
|
|
158
|
+
max_features: int = 10_000,
|
|
159
|
+
title: str = "Circular DNA map",
|
|
160
|
+
theme: SVGTheme | None = None,
|
|
161
|
+
) -> SVGArtifact:
|
|
162
|
+
"""Draw a resolved circular molecule and angular feature arcs."""
|
|
163
|
+
|
|
164
|
+
_bounded_integer(size, "size", minimum=100, maximum=_MAX_CANVAS_DIMENSION)
|
|
165
|
+
_bounded_integer(max_features, "max_features", maximum=_MAX_MAP_PARTS)
|
|
166
|
+
_validate_text(title, "title")
|
|
167
|
+
sequence, features = _sequence_and_features(value)
|
|
168
|
+
if sequence.topology is not Topology.CIRCULAR or sequence.coordinate_span is None:
|
|
169
|
+
raise ConfigurationError("Circular map requires a resolved circular sequence.")
|
|
170
|
+
parts = _feature_parts(features, max_features)
|
|
171
|
+
resolved_theme = _resolve_theme(theme)
|
|
172
|
+
span = sequence.coordinate_span
|
|
173
|
+
center, radius = size / 2, size * 0.34
|
|
174
|
+
builder = SVGBuilder(
|
|
175
|
+
size,
|
|
176
|
+
size,
|
|
177
|
+
title=title,
|
|
178
|
+
description="DNAKit circular DNA feature map",
|
|
179
|
+
kind="circular-map",
|
|
180
|
+
theme=resolved_theme,
|
|
181
|
+
metadata={
|
|
182
|
+
"span": span,
|
|
183
|
+
"feature_count": len(features),
|
|
184
|
+
"rendered_part_count": len(parts),
|
|
185
|
+
"unresolved_feature_count": sum(
|
|
186
|
+
isinstance(getattr(feature, "location", None), UnresolvedLocation)
|
|
187
|
+
for feature in features
|
|
188
|
+
),
|
|
189
|
+
},
|
|
190
|
+
)
|
|
191
|
+
builder.element(
|
|
192
|
+
"circle",
|
|
193
|
+
cx=center,
|
|
194
|
+
cy=center,
|
|
195
|
+
r=radius,
|
|
196
|
+
fill="none",
|
|
197
|
+
stroke=resolved_theme.foreground,
|
|
198
|
+
stroke_width=4,
|
|
199
|
+
class_="backbone",
|
|
200
|
+
)
|
|
201
|
+
for index, part in parts:
|
|
202
|
+
start_angle = 2 * math.pi * part.start / span - math.pi / 2
|
|
203
|
+
x1 = center + radius * math.cos(start_angle)
|
|
204
|
+
y1 = center + radius * math.sin(start_angle)
|
|
205
|
+
if len(part) == span:
|
|
206
|
+
builder.element(
|
|
207
|
+
"circle",
|
|
208
|
+
cx=center,
|
|
209
|
+
cy=center,
|
|
210
|
+
r=radius,
|
|
211
|
+
fill="none",
|
|
212
|
+
stroke=resolved_theme.accent,
|
|
213
|
+
stroke_width=12,
|
|
214
|
+
class_="feature",
|
|
215
|
+
data_feature_index=index,
|
|
216
|
+
data_start=part.start,
|
|
217
|
+
data_end=part.end,
|
|
218
|
+
)
|
|
219
|
+
continue
|
|
220
|
+
if len(part) == 0:
|
|
221
|
+
builder.element(
|
|
222
|
+
"circle",
|
|
223
|
+
cx=x1,
|
|
224
|
+
cy=y1,
|
|
225
|
+
r=6,
|
|
226
|
+
fill=resolved_theme.accent,
|
|
227
|
+
class_="feature",
|
|
228
|
+
data_feature_index=index,
|
|
229
|
+
data_start=part.start,
|
|
230
|
+
data_end=part.end,
|
|
231
|
+
)
|
|
232
|
+
continue
|
|
233
|
+
end_angle = 2 * math.pi * part.end / span - math.pi / 2
|
|
234
|
+
x2, y2 = center + radius * math.cos(end_angle), center + radius * math.sin(end_angle)
|
|
235
|
+
large = int(len(part) > span / 2)
|
|
236
|
+
path = (
|
|
237
|
+
f"M {number(x1)} {number(y1)} A {number(radius)} {number(radius)} "
|
|
238
|
+
f"0 {large} 1 {number(x2)} {number(y2)}"
|
|
239
|
+
)
|
|
240
|
+
builder.element(
|
|
241
|
+
"path",
|
|
242
|
+
d=path,
|
|
243
|
+
fill="none",
|
|
244
|
+
stroke=resolved_theme.accent,
|
|
245
|
+
stroke_width=12,
|
|
246
|
+
class_="feature",
|
|
247
|
+
data_feature_index=index,
|
|
248
|
+
data_start=part.start,
|
|
249
|
+
data_end=part.end,
|
|
250
|
+
)
|
|
251
|
+
builder.text(center, center, f"{span} bp", text_anchor="middle", fill=resolved_theme.foreground)
|
|
252
|
+
return builder.artifact()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def plot_alignment(
|
|
256
|
+
result: AlignmentResult,
|
|
257
|
+
*,
|
|
258
|
+
columns_per_line: int = 80,
|
|
259
|
+
max_columns: int = 20_000,
|
|
260
|
+
theme: SVGTheme | None = None,
|
|
261
|
+
) -> SVGArtifact:
|
|
262
|
+
"""Render aligned strings in fixed-width blocks with a match line."""
|
|
263
|
+
|
|
264
|
+
if not isinstance(result, AlignmentResult):
|
|
265
|
+
raise ConfigurationError("result must be AlignmentResult.")
|
|
266
|
+
_bounded_integer(
|
|
267
|
+
max_columns,
|
|
268
|
+
"max_columns",
|
|
269
|
+
maximum=_MAX_ALIGNMENT_COLUMNS,
|
|
270
|
+
)
|
|
271
|
+
_bounded_integer(
|
|
272
|
+
columns_per_line,
|
|
273
|
+
"columns_per_line",
|
|
274
|
+
maximum=max_columns,
|
|
275
|
+
)
|
|
276
|
+
if result.alignment_length > max_columns:
|
|
277
|
+
raise ConfigurationError("Alignment exceeds max_columns.")
|
|
278
|
+
resolved_theme = _resolve_theme(theme)
|
|
279
|
+
blocks = max(1, math.ceil(result.alignment_length / columns_per_line))
|
|
280
|
+
width, height = max(640, columns_per_line * 10 + 100), 70 + blocks * 72
|
|
281
|
+
builder = SVGBuilder(
|
|
282
|
+
width,
|
|
283
|
+
height,
|
|
284
|
+
title="DNA alignment",
|
|
285
|
+
description="DNAKit pairwise alignment",
|
|
286
|
+
kind="alignment",
|
|
287
|
+
theme=resolved_theme,
|
|
288
|
+
metadata={
|
|
289
|
+
"mode": result.method,
|
|
290
|
+
"score": result.score,
|
|
291
|
+
"columns": result.alignment_length,
|
|
292
|
+
},
|
|
293
|
+
)
|
|
294
|
+
for block in range(blocks):
|
|
295
|
+
start = block * columns_per_line
|
|
296
|
+
end = min(result.alignment_length, start + columns_per_line)
|
|
297
|
+
query = result.aligned_query[start:end]
|
|
298
|
+
target = result.aligned_target[start:end]
|
|
299
|
+
matches = "".join(
|
|
300
|
+
"|" if left == right and left != "-" else " "
|
|
301
|
+
for left, right in zip(query, target, strict=True)
|
|
302
|
+
)
|
|
303
|
+
y = 50 + block * 72
|
|
304
|
+
builder.text(24, y, query, class_="aligned-query", fill=resolved_theme.foreground)
|
|
305
|
+
builder.text(24, y + 20, matches, class_="match-line", fill=resolved_theme.accent)
|
|
306
|
+
builder.text(24, y + 40, target, class_="aligned-target", fill=resolved_theme.foreground)
|
|
307
|
+
return builder.artifact()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
__all__ = [
|
|
311
|
+
"plot_alignment",
|
|
312
|
+
"plot_circular_map",
|
|
313
|
+
"plot_linear_map",
|
|
314
|
+
]
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Validated configuration for dependency-free SVG visualizations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
from dnakit.exceptions import ConfigurationError
|
|
11
|
+
|
|
12
|
+
_COLOR_PATTERN = re.compile(r"^(?:#[0-9A-Fa-f]{3,8}|[A-Za-z][A-Za-z0-9_-]*)$")
|
|
13
|
+
_FONT_PATTERN = re.compile(r"^[A-Za-z0-9 _,-]+$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LimitPolicy(str, Enum):
|
|
17
|
+
"""Explicit behavior when a plot exceeds its configured display limit."""
|
|
18
|
+
|
|
19
|
+
ERROR = "error"
|
|
20
|
+
TRUNCATE = "truncate"
|
|
21
|
+
STRIDE = "stride"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _positive_int(value: int, name: str) -> None:
|
|
25
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
26
|
+
raise ConfigurationError(
|
|
27
|
+
f"{name} must be a positive integer.",
|
|
28
|
+
code="INVALID_VISUALIZATION_CONFIG",
|
|
29
|
+
context={name: value},
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _non_negative_int(value: int, name: str) -> None:
|
|
34
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
35
|
+
raise ConfigurationError(
|
|
36
|
+
f"{name} must be a non-negative integer.",
|
|
37
|
+
code="INVALID_VISUALIZATION_CONFIG",
|
|
38
|
+
context={name: value},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _finite_optional(value: float | None, name: str) -> None:
|
|
43
|
+
if value is not None and (
|
|
44
|
+
isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value)
|
|
45
|
+
):
|
|
46
|
+
raise ConfigurationError(
|
|
47
|
+
f"{name} must be a finite number or None.",
|
|
48
|
+
code="INVALID_VISUALIZATION_CONFIG",
|
|
49
|
+
context={name: value},
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _color(value: str, name: str) -> None:
|
|
54
|
+
if not isinstance(value, str) or not _COLOR_PATTERN.fullmatch(value):
|
|
55
|
+
raise ConfigurationError(
|
|
56
|
+
f"{name} must be a simple CSS color name or hexadecimal color.",
|
|
57
|
+
code="INVALID_VISUALIZATION_COLOR",
|
|
58
|
+
context={name: value},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _title(value: str | None, name: str = "title") -> None:
|
|
63
|
+
if value is not None and (not isinstance(value, str) or not value.strip()):
|
|
64
|
+
raise ConfigurationError(
|
|
65
|
+
f"{name} must be a non-empty string or None.",
|
|
66
|
+
code="INVALID_VISUALIZATION_CONFIG",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _coerce_policy(value: LimitPolicy | str, *, allowed: set[LimitPolicy]) -> LimitPolicy:
|
|
71
|
+
try:
|
|
72
|
+
policy = value if isinstance(value, LimitPolicy) else LimitPolicy(value)
|
|
73
|
+
except (TypeError, ValueError) as exc:
|
|
74
|
+
raise ConfigurationError(
|
|
75
|
+
"Unknown visualization limit policy.",
|
|
76
|
+
code="INVALID_VISUALIZATION_LIMIT_POLICY",
|
|
77
|
+
context={"policy": value},
|
|
78
|
+
) from exc
|
|
79
|
+
if policy not in allowed:
|
|
80
|
+
raise ConfigurationError(
|
|
81
|
+
"The selected limit policy is not valid for this plot type.",
|
|
82
|
+
code="INVALID_VISUALIZATION_LIMIT_POLICY",
|
|
83
|
+
context={"policy": policy.value, "allowed": sorted(item.value for item in allowed)},
|
|
84
|
+
)
|
|
85
|
+
return policy
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class SVGTheme:
|
|
90
|
+
"""Shared colors and typography embedded directly in an SVG artifact."""
|
|
91
|
+
|
|
92
|
+
background: str = "#ffffff"
|
|
93
|
+
foreground: str = "#1f2937"
|
|
94
|
+
muted: str = "#6b7280"
|
|
95
|
+
grid: str = "#d1d5db"
|
|
96
|
+
accent: str = "#2563eb"
|
|
97
|
+
missing: str = "#e5e7eb"
|
|
98
|
+
font_family: str = "monospace"
|
|
99
|
+
|
|
100
|
+
def __post_init__(self) -> None:
|
|
101
|
+
for name in ("background", "foreground", "muted", "grid", "accent", "missing"):
|
|
102
|
+
_color(getattr(self, name), name)
|
|
103
|
+
if not isinstance(self.font_family, str) or not _FONT_PATTERN.fullmatch(self.font_family):
|
|
104
|
+
raise ConfigurationError(
|
|
105
|
+
"font_family contains unsupported characters.",
|
|
106
|
+
code="INVALID_VISUALIZATION_FONT",
|
|
107
|
+
context={"font_family": self.font_family},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass(frozen=True, slots=True)
|
|
112
|
+
class SequencePlotConfig:
|
|
113
|
+
"""Layout and safety limits for the gap-aware sequence text plot."""
|
|
114
|
+
|
|
115
|
+
bases_per_line: int = 60
|
|
116
|
+
max_symbols: int = 10_000
|
|
117
|
+
max_gaps: int = 1_000
|
|
118
|
+
limit_policy: LimitPolicy = LimitPolicy.ERROR
|
|
119
|
+
start_coordinate: int = 0
|
|
120
|
+
show_coordinates: bool = True
|
|
121
|
+
show_complement: bool = False
|
|
122
|
+
font_size: int = 14
|
|
123
|
+
cell_width: int = 12
|
|
124
|
+
line_height: int = 62
|
|
125
|
+
margin: int = 24
|
|
126
|
+
title: str | None = None
|
|
127
|
+
theme: SVGTheme = field(default_factory=SVGTheme)
|
|
128
|
+
|
|
129
|
+
def __post_init__(self) -> None:
|
|
130
|
+
for name in (
|
|
131
|
+
"bases_per_line",
|
|
132
|
+
"max_symbols",
|
|
133
|
+
"max_gaps",
|
|
134
|
+
"font_size",
|
|
135
|
+
"cell_width",
|
|
136
|
+
"line_height",
|
|
137
|
+
"margin",
|
|
138
|
+
):
|
|
139
|
+
_positive_int(getattr(self, name), name)
|
|
140
|
+
_non_negative_int(self.start_coordinate, "start_coordinate")
|
|
141
|
+
for name in ("show_coordinates", "show_complement"):
|
|
142
|
+
if not isinstance(getattr(self, name), bool):
|
|
143
|
+
raise ConfigurationError(
|
|
144
|
+
f"{name} must be a boolean.", code="INVALID_VISUALIZATION_CONFIG"
|
|
145
|
+
)
|
|
146
|
+
_title(self.title)
|
|
147
|
+
if not isinstance(self.theme, SVGTheme):
|
|
148
|
+
raise ConfigurationError("theme must be SVGTheme.")
|
|
149
|
+
object.__setattr__(
|
|
150
|
+
self,
|
|
151
|
+
"limit_policy",
|
|
152
|
+
_coerce_policy(self.limit_policy, allowed={LimitPolicy.ERROR, LimitPolicy.TRUNCATE}),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True, slots=True)
|
|
157
|
+
class HeatmapConfig:
|
|
158
|
+
"""Limits and color scale for matrix and fingerprint heatmaps."""
|
|
159
|
+
|
|
160
|
+
cell_size: int = 18
|
|
161
|
+
max_rows: int = 200
|
|
162
|
+
max_columns: int = 256
|
|
163
|
+
limit_policy: LimitPolicy = LimitPolicy.ERROR
|
|
164
|
+
value_min: float | None = None
|
|
165
|
+
value_max: float | None = None
|
|
166
|
+
low_color: str = "#f7fbff"
|
|
167
|
+
high_color: str = "#08306b"
|
|
168
|
+
show_values: bool = False
|
|
169
|
+
show_labels: bool = True
|
|
170
|
+
title: str | None = None
|
|
171
|
+
theme: SVGTheme = field(default_factory=SVGTheme)
|
|
172
|
+
|
|
173
|
+
def __post_init__(self) -> None:
|
|
174
|
+
for name in ("cell_size", "max_rows", "max_columns"):
|
|
175
|
+
_positive_int(getattr(self, name), name)
|
|
176
|
+
_finite_optional(self.value_min, "value_min")
|
|
177
|
+
_finite_optional(self.value_max, "value_max")
|
|
178
|
+
if (
|
|
179
|
+
self.value_min is not None
|
|
180
|
+
and self.value_max is not None
|
|
181
|
+
and self.value_min >= self.value_max
|
|
182
|
+
):
|
|
183
|
+
raise ConfigurationError(
|
|
184
|
+
"value_min must be smaller than value_max.",
|
|
185
|
+
code="INVALID_VISUALIZATION_CONFIG",
|
|
186
|
+
)
|
|
187
|
+
for name in ("low_color", "high_color"):
|
|
188
|
+
_color(getattr(self, name), name)
|
|
189
|
+
if not re.fullmatch(r"#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})", getattr(self, name)):
|
|
190
|
+
raise ConfigurationError(
|
|
191
|
+
f"{name} must use #RGB or #RRGGBB for interpolation.",
|
|
192
|
+
code="INVALID_VISUALIZATION_COLOR",
|
|
193
|
+
)
|
|
194
|
+
for name in ("show_values", "show_labels"):
|
|
195
|
+
if not isinstance(getattr(self, name), bool):
|
|
196
|
+
raise ConfigurationError(f"{name} must be a boolean.")
|
|
197
|
+
_title(self.title)
|
|
198
|
+
if not isinstance(self.theme, SVGTheme):
|
|
199
|
+
raise ConfigurationError("theme must be SVGTheme.")
|
|
200
|
+
object.__setattr__(
|
|
201
|
+
self,
|
|
202
|
+
"limit_policy",
|
|
203
|
+
_coerce_policy(self.limit_policy, allowed={LimitPolicy.ERROR, LimitPolicy.STRIDE}),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True, slots=True)
|
|
208
|
+
class SaveConfig:
|
|
209
|
+
"""Filesystem policy for deterministic SVG persistence."""
|
|
210
|
+
|
|
211
|
+
overwrite: bool = False
|
|
212
|
+
create_parents: bool = False
|
|
213
|
+
|
|
214
|
+
def __post_init__(self) -> None:
|
|
215
|
+
for name in ("overwrite", "create_parents"):
|
|
216
|
+
if not isinstance(getattr(self, name), bool):
|
|
217
|
+
raise ConfigurationError(
|
|
218
|
+
f"{name} must be a boolean.", code="INVALID_VISUALIZATION_CONFIG"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True, slots=True)
|
|
223
|
+
class ImageExportConfig:
|
|
224
|
+
"""Safe raster/PDF export settings for the optional graphics backend."""
|
|
225
|
+
|
|
226
|
+
dpi: int = 600
|
|
227
|
+
width: int | None = None
|
|
228
|
+
height: int | None = None
|
|
229
|
+
transparent: bool = False
|
|
230
|
+
max_output_pixels: int = 100_000_000
|
|
231
|
+
overwrite: bool = False
|
|
232
|
+
create_parents: bool = False
|
|
233
|
+
|
|
234
|
+
def __post_init__(self) -> None:
|
|
235
|
+
_positive_int(self.dpi, "dpi")
|
|
236
|
+
_positive_int(self.max_output_pixels, "max_output_pixels")
|
|
237
|
+
for name in ("width", "height"):
|
|
238
|
+
value = getattr(self, name)
|
|
239
|
+
if value is not None:
|
|
240
|
+
_positive_int(value, name)
|
|
241
|
+
for name in ("transparent", "overwrite", "create_parents"):
|
|
242
|
+
if not isinstance(getattr(self, name), bool):
|
|
243
|
+
raise ConfigurationError(
|
|
244
|
+
f"{name} must be a boolean.", code="INVALID_VISUALIZATION_CONFIG"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
__all__ = [
|
|
249
|
+
"HeatmapConfig",
|
|
250
|
+
"ImageExportConfig",
|
|
251
|
+
"LimitPolicy",
|
|
252
|
+
"SVGTheme",
|
|
253
|
+
"SaveConfig",
|
|
254
|
+
"SequencePlotConfig",
|
|
255
|
+
]
|