diff --git a/python/out.svg b/python/out.svg deleted file mode 100644 index 3831ed0..0000000 --- a/python/out.svg +++ /dev/null @@ -1,123 +0,0 @@ - - -4.6 - - Only the biggest PLOT - Position (bp) - - okay X10 - -1 - - -549 - 550 - 4.7 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 1 - - - - - - 1_imported - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/python/plot.py b/python/plot.py index 7360905..d9f11fe 100644 --- a/python/plot.py +++ b/python/plot.py @@ -10,7 +10,7 @@ # Class that generates composite and reference lines svg elements class Plot: def __init__(self, title=None, xmin=None, xmax=None, ymin=None, ymax=None, xlabel=None, ylabel=None, - opacity=None, smoothing=None, bp_shift=None, combined=False, color_trace=False, hide_legend=False): + opacity=None, smoothing=None, bp_shift=None, combined=False, color_trace=False, hide_legend=False, resolution=None): # Set variables to defaults if argument passed into constructor was None self.title = title if title is not None else "Composite plot" self.xmin = xmin if xmin is not None else -500 @@ -29,6 +29,9 @@ def __init__(self, title=None, xmin=None, xmax=None, ymin=None, ymax=None, xlabe self.width = 460 self.height = 300 self.margins = {'top': 30, 'right': 170, 'bottom': 35, 'left': 40} + resolution = (int(resolution.split("x")[0]), int(resolution.split("x")[1])) if resolution is not None else (300, 300) + self.width = 160 + resolution[0] + self.height = resolution[1] # Create groups for adding composites and reference lines self.plot = document.createElement("g") self.composite_group = document.createElement("g") @@ -262,16 +265,19 @@ def scale_axes(self, xmin=None, xmax=None, ymin=None, ymax=None): self.yscale = YScale(self) # Finds the max/min x and y values from composites on plot and scales axes accordingly - def autoscale_axes(self, allow_shrink): + def autoscale_axes(self, args): xmin = min([group.xmin for group in self.composites]) xmax = max([group.xmax for group in self.composites]) - if self.combined: + if self.combined: ymin = 0 ymax = round(max([(group.sense[i] + group.sense[i]) * group.scale for group in self.composites for i in range(min(len(group.sense), len(group.anti)))]), 2) else: ymin = min([-val * group.scale for group in self.composites for val in group.anti]) ymax = max([val * group.scale for group in self.composites for val in group.sense]) - self.scale_axes(xmin,xmax,ymin if allow_shrink else None,ymax if allow_shrink else None) + if args.no_resize: + self.scale_axes(args.xmin, args.xmax, args.ymin, args.ymax) + else: + self.scale_axes(args.xmin or xmin, args.xmax or xmax, args.ymin or ymin, args.ymax or ymax) # Adds composite group object to plot def add_composite_group(self, composite_group): @@ -482,10 +488,10 @@ def generateGradients(self, opacity, i, color, secondary_color=None): # Class that mimics d3 scaleLinear() for x-axis of plot class XScale: def __init__(self, plot): - self.plot = plot + svg_width = plot.width - (plot.margins.get('right') + plot.margins.get('left')) self.domain = [plot.xmin, plot.xmax, plot.xmax - plot.xmin] - self.range = [plot.margins.get('left'), plot.width - plot.margins.get('right'), plot.width - (plot.margins.get('right') + plot.margins.get('left'))] - self.zero = (plot.width - (plot.margins.get('right') + plot.margins.get('left'))) * (abs(plot.xmin) / (abs(plot.xmin) + abs(plot.xmax))) + plot.margins.get('left') + self.range = [plot.margins.get('left'), plot.width - plot.margins.get('right'), svg_width] + self.zero = svg_width * (abs(plot.xmin) / (abs(plot.xmin) + abs(plot.xmax))) + plot.margins.get('left') # Returns position given bp def get(self, value): return (self.range[2] / self.domain[2]) * value + self.zero @@ -495,9 +501,10 @@ def inverse(self, value): # Class that mimics d3 scaleLinear() for y-axis of plot class YScale: def __init__(self, plot): + svg_height = plot.height - (plot.margins.get('top') + plot.margins.get('bottom')) self.domain = [plot.ymin, plot.ymax, abs(plot.ymax) + abs(plot.ymin)] - self.range = [plot.margins.get('top'), plot.height - plot.margins.get('bottom'), plot.height - (plot.margins.get('top') + plot.margins.get('bottom'))] - self.zero = (plot.height - (plot.margins.get('top') + plot.margins.get('bottom'))) * (0.5) + plot.margins.get('top') if plot.combined is False else self.range[1] + self.range = [plot.margins.get('top'), plot.height - plot.margins.get('bottom'), svg_height] + self.zero = svg_height * (abs(plot.ymax) / (abs(plot.ymin) + abs(plot.ymax))) + plot.margins.get('top') if plot.combined is False else self.range[1] # Returns position on svg given occupancy def get(self, value): return self.zero - (self.range[2] / self.domain[2]) * value diff --git a/python/plotter.py b/python/plotter.py index 0bf5d6b..8384c84 100644 --- a/python/plotter.py +++ b/python/plotter.py @@ -53,14 +53,15 @@ def main(): plot_parser.add_argument("--xmin",type=int) plot_parser.add_argument("--xmax",type=int) plot_parser.add_argument("--xlabel", nargs="+") - plot_parser.add_argument("--ymin", type=int) - plot_parser.add_argument("--ymax", type=int) + plot_parser.add_argument("--ymin", type=float) + plot_parser.add_argument("--ymax", type=float) plot_parser.add_argument("--ylabel", nargs="+") plot_parser.add_argument("--color-trace", action="store_true", default=False) plot_parser.add_argument("--combined", action="store_true", default=False) plot_parser.add_argument("--hide-legend", action="store_true", default=False) plot_parser.add_argument("--no-resize", action="store_true", default=False) plot_parser.add_argument("--no-shrink", action="store_true", default=False) + plot_parser.add_argument("--resolution") plot_parser.add_argument("--out") plot_parser.add_argument("--export-json") plot_parser.add_argument("--import-json") @@ -69,7 +70,8 @@ def main(): # Create plot based on plot subcommand, default values in Plot class will be used if argument is not specified plot_args = plot_parser.parse_args(plot_command.split()) p = plot.Plot(title=" ".join(plot_args.title) if plot_args.title is not None else None, xmin=plot_args.xmin, xmax=plot_args.xmax, ymin=plot_args.ymin, ymax=plot_args.ymax, xlabel=" ".join(plot_args.xlabel) if plot_args.xlabel is not None else None, - ylabel=" ".join(plot_args.ylabel) if plot_args.ylabel is not None else None, opacity=plot_args.opacity, smoothing=plot_args.smoothing, bp_shift=plot_args.bp_shift, combined=plot_args.combined, color_trace=plot_args.color_trace, hide_legend=plot_args.hide_legend) + ylabel=" ".join(plot_args.ylabel) if plot_args.ylabel is not None else None, opacity=plot_args.opacity, smoothing=plot_args.smoothing, bp_shift=plot_args.bp_shift, combined=plot_args.combined, color_trace=plot_args.color_trace, hide_legend=plot_args.hide_legend, + resolution=plot_args.resolution) # Create arrays for default composite names and colors names = range(1, len(composite_commands) + 1) @@ -115,12 +117,8 @@ def main(): elif plot_args.import_settings_json: p.import_data(plot_args.import_settings_json, plot_args, False) - # If --no-shrink is specified, don't change y-axis but resize x-axis - if plot_args.no_shrink: - p.autoscale_axes(False) - # If --no-resize is specified, don't change either axis - elif not plot_args.no_resize: - p.autoscale_axes(True) + # Autoscale axis if options don't specify limits + p.autoscale_axes(plot_args) p.plot_composites() diff --git a/python/readme.md b/python/readme.md index 5da10d4..59703b1 100644 --- a/python/readme.md +++ b/python/readme.md @@ -22,7 +22,7 @@ The `composite` and `reference-line` subcommands can be repeated for as many com plot [plot options] ``` -The `plot` subcommand takes no positional arguments, and the options specify properties for the entire plot, such as domain, range, and the axis labels. Options can also be used to specify default properties for all composites such as `opacity` and `smoothing`. This implementation of the plotter autoscales the axes to fit the largest composite by default, ignoring the `xmin`, `xmax`, `ymin`, and `ymax` options unless `--no-shrink` or `--no-resize` is specified. +The `plot` subcommand takes no positional arguments, and the options specify properties for the entire plot, such as domain, range, and the axis labels. Options can also be used to specify default properties for all composites such as `opacity` and `smoothing`. This implementation of the plotter autoscales the axes to fit the largest composite by default, ignoring the `xmin`, `xmax`, `ymin`, and `ymax` options unless `--no-resize` is specified. The available options for the `plot` subcommand are: @@ -42,7 +42,7 @@ The available options for the `plot` subcommand are: | --combined | Boolean | Draws a combined plot | False | | --hide-legend | Boolean | Hides the plot legend | False | | --no-resize | Boolean | Prevents plotter from autoscaling the x and y axes | False | -| --no-shrink | Boolean | Prevents plotter from autoscaling the y-axis | False | +| --resolution | String | Resolution of the output. Can influence aspect ratio | "300x300" | | --out | String | Name and filepath of svg output | `out.svg` | | --export-json | String | JSON file to export composites and plot settings | `None` | | --import-json | String | JSON file to import composites and plot settings | `None` | diff --git a/python/svgFactory.py b/python/svgFactory.py index b43023b..e2dfae8 100644 --- a/python/svgFactory.py +++ b/python/svgFactory.py @@ -17,7 +17,7 @@ def generateSVG(plot): svg.setAttribute("xmlns", "http://www.w3.org/2000/svg") svg.setAttribute("id", "main-plot") svg.setAttribute("font-family", "Helvetica") - svg.setAttribute("viewBox", "0 0 460 300") + svg.setAttribute("viewBox", f"0 0 {plot.width} {plot.height}") svg.setAttribute("style", "height: 50vh; max-width: 100%; overflow: hide;") svg.setAttribute("baseProfile", "full") # Create title @@ -57,7 +57,8 @@ def generateSVG(plot): ylabel.setAttribute("y", str((plot.height + plot.margins.get('top') - plot.margins.get('bottom')) / 2)) ylabel.setAttribute("label", "ylabel") ylabel.setAttribute("id", "main-plot-ylabel") - ylabel.setAttribute("transform", "rotate(-90 12 147.5)") + # Maintains the default 147.5px when plot is 300x300 + ylabel.setAttribute("transform", f"rotate(-90 12 {plot.height / 2 - plot.height / 120})") ylabel.setAttribute("style", "text-anchor: middle; cursor: pointer;") round_exp = 1 - math.floor(math.log10(plot.ymax - plot.ymin)) round_factor = 10 ** round_exp @@ -132,66 +133,213 @@ def axis(orient, scale, plot, document): top = plot.margins.get("top") right = plot.width - (plot.margins.get('right')) left = plot.margins.get("left") + + # Determine how ticks should be drawn for y-axis + numDigits = math.floor(math.log10(max(plot.ymin, plot.ymax))) + # If the sum of the first digits of the min and max are less than 5 (eg. -1 to 3, -200 to 200, -.1 to .15) add minor ticks to the axis + minorYTicks = abs(plot.ymin / 10 ** (numDigits)) + abs(plot.ymax / 10 ** (numDigits)) < 5 + # If the sum of the first digits of the min and max are greater than 10 (eg. -7 to 8, -500 to 700, -.6 to .55) add half as many ticks to avoid overcrowding + skipYTicks = abs(plot.ymin / 10 ** (numDigits)) + abs(plot.ymax / 10 ** (numDigits)) > 10 + # Determine how ticks should be drawn for x-axis + numDigits = math.floor(math.log10(max(abs(plot.xmax), abs(plot.xmin)))) + minorXTicks = abs(plot.xmin / 10 ** (numDigits)) + abs(plot.xmax / 10 ** (numDigits)) < 5 + skipXTicks = abs(plot.xmin / 10 ** (numDigits)) + abs(plot.xmax / 10 ** (numDigits)) > 10 # Draw left axis if (orient == "left"): + # Draw axis line axis.setAttribute("x1", str(left)) axis.setAttribute("x2", str(left)) axis.setAttribute("y1", str(top)) axis.setAttribute("y2", str(bottom)) - i = top - while i < bottom: + numDigits = math.floor(math.log10(max(abs(plot.ymin), abs(plot.ymax)))) + # Returns tick on left axis at given occupancy + def leftTick(val): tick = document.createElement("line") - tick.setAttribute("y1", str(i)) - tick.setAttribute("y2", str(i)) + tick.setAttribute("y1", str(plot.yscale.get(val))) + tick.setAttribute("y2", str(plot.yscale.get(val))) tick.setAttribute("x1", str(left)) tick.setAttribute("x2", str(left + tickSize)) - axis_group.appendChild(tick) - i += tickSpacing + return tick + i = 0 + halfTick = False + # Add left ticks less than zero + while i > plot.ymin: + t = leftTick(i) + # Add minor ticks between whole numbers + if minorYTicks: + i -= 10 ** numDigits / 2 + if halfTick: + t.setAttribute("x2", str(left + tickSize / 2)) + halfTick = False + else: + halfTick = True + # Skip every other tick to avoid overcrowding + elif skipYTicks: + i -= 10 ** numDigits * 2 + else: + i -= 10 ** numDigits + axis_group.appendChild(t) + i = 0 + halfTick = False + # Add left ticks greater than zero + while i < plot.ymax: + t = leftTick(i) + if minorYTicks: + i += 10 ** numDigits / 2 + if halfTick: + t.setAttribute("x2", str(left + tickSize / 2)) + halfTick = False + else: + halfTick = True + elif skipYTicks: + i += 10 ** numDigits * 2 + else: + i += 10 ** numDigits + axis_group.appendChild(t) # Draw right axis elif (orient == "right"): axis.setAttribute("x1", str(right)) axis.setAttribute("x2", str(right)) axis.setAttribute("y1", str(top)) axis.setAttribute("y2", str(bottom)) - i = top - while i < bottom: + numDigits = math.floor(math.log10(max(abs(plot.ymin), abs(plot.ymax)))) + # Returns tick on right axis at given occupancy + def rightTick(val): tick = document.createElement("line") - tick.setAttribute("y1", str(i)) - tick.setAttribute("y2", str(i)) + tick.setAttribute("y1", str(plot.yscale.get(val))) + tick.setAttribute("y2", str(plot.yscale.get(val))) tick.setAttribute("x1", str(right)) tick.setAttribute("x2", str(right + tickSize)) - axis_group.appendChild(tick) - i += tickSpacing + return tick + i = 0 + halfTick = False + while i > plot.ymin: + t = rightTick(i) + # Add half ticks or skip if necessary + if minorYTicks: + i -= 10 ** numDigits / 2 + if halfTick: + t.setAttribute("x2", str(right + tickSize / 2)) + halfTick = False + else: + halfTick = True + elif skipYTicks: + i -= 10 ** numDigits * 2 + else: + i -= 10 ** numDigits + axis_group.appendChild(t) + i = 0 + halfTick = False + while i < plot.ymax: + t = rightTick(i) + if minorYTicks: + i += 10 ** numDigits / 2 + if halfTick: + t.setAttribute("x2", str(right + tickSize / 2)) + halfTick = False + else: + halfTick = True + elif skipYTicks: + i += 10 ** numDigits * 2 + else: + i += 10 ** numDigits + axis_group.appendChild(t) + # Draw bottom axis elif(orient == "bottom"): axis.setAttribute("x1", str(left)) axis.setAttribute("x2", str(right)) axis.setAttribute("y1", str(bottom)) axis.setAttribute("y2", str(bottom)) - i = left - while i < right: + numDigits = math.floor(math.log10(max(abs(plot.xmin), abs(plot.xmax)))) + # Returns tick on bottom axis at given coord + def bottomTick(coord): tick = document.createElement("line") + tick.setAttribute("x1", str(plot.xscale.get(coord))) + tick.setAttribute("x2", str(plot.xscale.get(coord))) tick.setAttribute("y1", str(bottom)) tick.setAttribute("y2", str(bottom + tickSize)) - tick.setAttribute("x1", str(i)) - tick.setAttribute("x2", str(i)) - axis_group.appendChild(tick) - i += tickSpacing + return tick + i = 0 + halfTick = False + while i > plot.xmin: + t = bottomTick(i) + # Add half ticks or skip if necessary + if minorXTicks: + i -= 10 ** numDigits / 2 + if halfTick: + t.setAttribute("y2", str(bottom + tickSize / 2)) + halfTick = False + else: + halfTick = True + elif skipXTicks: + i -= 10 ** numDigits * 2 + else: + i -= 10 ** numDigits + axis_group.appendChild(t) + i = 0 + halfTick = False + while i < plot.xmax: + t = bottomTick(i) + if minorXTicks: + i += 10 ** numDigits / 2 + if halfTick: + t.setAttribute("y2", str(bottom + tickSize / 2)) + halfTick = False + else: + halfTick = True + elif skipXTicks: + i += 10 ** numDigits * 2 + else: + i += 10 ** numDigits + axis_group.appendChild(t) # Draw top axis elif (orient == "top"): axis.setAttribute("x1", str(left)) axis.setAttribute("x2", str(right)) axis.setAttribute("y1", str(top)) axis.setAttribute("y2", str(top)) - i = left - while i < right: + numDigits = math.floor(math.log10(max(abs(plot.xmin), abs(plot.xmax)))) + # Returns tick on top axis at given coord + def topTick(coord): tick = document.createElement("line") + tick.setAttribute("x1", str(plot.xscale.get(coord))) + tick.setAttribute("x2", str(plot.xscale.get(coord))) tick.setAttribute("y1", str(top)) tick.setAttribute("y2", str(top + tickSize)) - tick.setAttribute("x1", str(i)) - tick.setAttribute("x2", str(i)) - axis_group.appendChild(tick) - i += tickSpacing + return tick + i = 0 + halfTick = False + while i > plot.xmin: + t = topTick(i) + if minorXTicks: + i -= 10 ** numDigits / 2 + if halfTick: + t.setAttribute("y2", str(top + tickSize / 2)) + halfTick = False + else: + halfTick = True + elif skipXTicks: + i -= 10 ** numDigits * 2 + else: + i -= 10 ** numDigits + axis_group.appendChild(t) + i = 0 + halfTick = False + while i < plot.xmax: + t = topTick(i) + if minorXTicks: + i += 10 ** numDigits / 2 + if halfTick: + t.setAttribute("y2", str(top + tickSize / 2)) + halfTick = False + else: + halfTick = True + elif skipXTicks: + i += 10 ** numDigits * 2 + else: + i += 10 ** numDigits + axis_group.appendChild(t) # Draw middle axis if plot is not combined elif (orient == "middle"): if not plot.combined == True: @@ -199,15 +347,49 @@ def axis(orient, scale, plot, document): axis.setAttribute("x2", str(right)) axis.setAttribute("y1", str(plot.yscale.get(0))) axis.setAttribute("y2", str(plot.yscale.get(0))) - i = left - while i < right: - tick = document.createElement("line") - tick.setAttribute("y1", str(plot.yscale.get(0) - tickSize)) - tick.setAttribute("y2", str(plot.yscale.get(0) + tickSize)) - tick.setAttribute("x1", str(i)) - tick.setAttribute("x2", str(i)) - axis_group.appendChild(tick) - i += tickSpacing + numDigits = math.floor(math.log10(max(abs(plot.xmin), abs(plot.xmax)))) + # Returns tick on middle axis at given coord + def midTick(coord): + tick = document.createElement("line") + tick.setAttribute("x1", str(plot.xscale.get(coord))) + tick.setAttribute("x2", str(plot.xscale.get(coord))) + tick.setAttribute("y1", str(plot.yscale.get(0) - tickSize)) + tick.setAttribute("y2", str(plot.yscale.get(0) + tickSize)) + return tick + i = 0 + halfTick = False + while i > plot.xmin: + t = midTick(i) + if minorXTicks: + i -= 10 ** numDigits / 2 + if halfTick: + t.setAttribute("y1", str(plot.yscale.get(0) - tickSize / 2)) + t.setAttribute("y2", str(plot.yscale.get(0) + tickSize / 2)) + halfTick = False + else: + halfTick = True + elif skipXTicks: + i -= 10 ** numDigits * 2 + else: + i -= 10 ** numDigits + axis_group.appendChild(t) + i = 0 + halfTick = False + while i < plot.xmax: + t = midTick(i) + if minorXTicks: + i += 10 ** numDigits / 2 + if halfTick: + t.setAttribute("y1", str(plot.yscale.get(0) - tickSize / 2)) + t.setAttribute("y2", str(plot.yscale.get(0) + tickSize / 2)) + halfTick = False + else: + halfTick = True + elif skipXTicks: + i += 10 ** numDigits * 2 + else: + i += 10 ** numDigits + axis_group.appendChild(t) axis_group.setAttribute("stroke", "black") axis_group.appendChild(axis) return axis_group \ No newline at end of file