require 'active_support' require 'active_support/core_ext/array/wrap' # for Array#wrap module Squid # A Plotter wraps a Prawn::Document object in order to provide new methods # like `gridline` or `ticks` used by Squid::Graph to plot graph elements. class Plotter attr_accessor :paddings # @param [Prawn::Document] a PDF document to wrap in a Plotter instance. def initialize(pdf, bottom:) @pdf = pdf @bottom = bottom end # Draws a bounding box of the given height, rendering the block inside it. def box(x: 0, y: @pdf.cursor, w: @pdf.bounds.width, h:, border: false) @pdf.bounding_box [x, y], width: w, height: h do @pdf.stroke_bounds if border yield end end # Draws the graph legend with the given labels. # @param [Array] The labels to write as part of the legend. def legend(labels, height:, offset: 0, colors: []) left = @pdf.bounds.width/2 box(x: left, y: @pdf.bounds.top, w: left, h: height) do x = @pdf.bounds.right - offset options = {size: 7, height: @pdf.bounds.height, valign: :center} labels.each.with_index do |label, i| color = Array.wrap(colors[labels.size - 1 - i]).first x = legend_item label, x, color, options end end end # Draws a horizontal line. def horizontal_line(y, options = {}) with options do at = y + @bottom @pdf.stroke_horizontal_line left, @pdf.bounds.right - right, at: at end end def width_of(label) @pdf.width_of(label, size: 8).ceil end def axis_labels(labels) labels.each do |label| x = (label.align == :right) ? 0 : @pdf.bounds.right - label.width y = label.y + @bottom + text_options[:height] / 2 options = text_options.merge width: label.width, at: [x, y] @pdf.text_box label.label, options.merge(align: label.align) end end def categories(labels, every:, ticks:) labels.each.with_index do |label, index| w = width / labels.count.to_f x = left + w * (index) padding = 2 options = category_options.merge(width: every*w-2*padding, at: [x+padding-w*(every/2.0-0.5), @bottom]) @pdf.text_box label, options if (index % every).zero? @pdf.stroke_vertical_line @bottom, @bottom - 2, at: x + w/2 if ticks end end def points(series, colors: []) items(series, colors: colors) do |point, w, i, padding| x, y = (point.index + 0.5)*w + left, point.y + @bottom @pdf.fill_circle [x, y], 5 end end def lines(series, colors: [], line_widths: []) x, y = nil, nil items(series, colors: colors) do |point, w, i, padding| prev_x, prev_y = x, y x, y = (point.index + 0.5)*w + left, point.y + @bottom line_width = Array.wrap(line_widths).fetch(i, 1) with line_width: line_width, cap_style: :round do @pdf.line [prev_x, prev_y], [x,y] unless point.index.zero? || prev_y.nil? || prev_x > x end end end def stacks(series, colors: []) items(series, colors: colors, fill: true) do |point, w, i, padding| x, y = point.index*w + padding + left, point.y + @bottom @pdf.fill_rectangle [x, y], w - 2*padding, point.height end end def columns(series, colors: []) items(series, colors: colors, fill: true, count: series.size) do |point, w, i, padding| item_w = (w - 2 * padding)/ series.size x, y = point.index*w + padding + left + i*item_w, point.y + @bottom @pdf.fill_rectangle [x, y], item_w, point.height end end private def left @paddings[:left].zero? ? 0 : @paddings[:left] + 5 end def right @paddings[:right].zero? ? 0 : @paddings[:right] + 5 end def width @pdf.bounds.width - left - right end def category_options text_options.merge align: :center, leading: -3, disable_wrap_by_char: true end def text_options options = {} options[:height] = 20 options[:size] = 8 options[:valign] = :center options[:overflow] = :shrink_to_fit options end def items(series, colors: [], fill: false, count: 1, &block) series.reverse_each.with_index do |points, reverse_index| index = series.size - reverse_index - 1 w = width / points.size.to_f series_colors = Array.wrap(colors[index]).cycle points.select(&:y).each do |point| item point, series_colors.next, w, fill, index, count, &block end end end def item(point, color, w, fill, index, count) padding = w / 8 with transparency: 0.95, fill_color: color, stroke_color: color do yield point, w, index, padding end with fill_color: (point.negative && fill ? 'ffffff' : color) do options = [{size: 10, styles: [:bold], text: point.label}] position = {align: :center, valign: :bottom, height: 20} position[:width] = (w - 2*padding) / count x = left + point.index*w + padding x += index * position[:width] if count > 1 position[:at] = [x, point.y + @bottom + 24] @pdf.formatted_text_box options, position end if point.label end # Draws a single item of the legend, which includes the label and the # symbol with the matching color. Labels are written from right to left. # @param def legend_item(label, x, color, options) size, symbol_padding, entry_padding = 5, 3, 12 x -= @pdf.width_of(label, size: 7).ceil @pdf.text_box label, options.merge(at: [x, @pdf.bounds.height]) x -= (symbol_padding + size) with fill_color: color do @pdf.fill_rectangle [x, @pdf.bounds.height - size], size, size end x - entry_padding end # Convenience method to wrap a block by setting and unsetting a Prawn # property such as line_width. def with(new_values = {}) transparency = new_values.delete(:transparency) { 1.0 } old_values = Hash[new_values.map{|k,_| [k,@pdf.public_send(k)]}] new_values.each{|k, new_value| @pdf.public_send "#{k}=", new_value } @pdf.transparent(transparency) do @pdf.stroke { yield } end old_values.each{|k, old_value| @pdf.public_send "#{k}=", old_value } end end end