├── .travis.yml ├── README.md ├── app ├── controllers │ └── custom_reports_controller.rb ├── helpers │ └── custom_reports_helper.rb ├── models │ ├── custom_report.rb │ ├── custom_report_series.rb │ └── query_ext.rb └── views │ └── custom_reports │ ├── _form.html.erb │ ├── _links.html.erb │ ├── _series.html.erb │ ├── _series_filters.html.erb │ ├── _sidebar.html.erb │ ├── edit.html.erb │ ├── index.html.erb │ ├── new.html.erb │ └── show.html.erb ├── assets ├── javascripts │ ├── custom_report_charts.js │ ├── custom_report_edit.js │ ├── d3.v2.js │ ├── d3.v2.min.js │ ├── nv.d3.js │ └── nv.d3.min.js └── stylesheets │ ├── custom_report.css │ └── nv.d3.css ├── config ├── database-mysql-travis.yml ├── database-postgresql-travis.yml ├── locales │ ├── en.yml │ ├── es.yml │ ├── hu.yml │ ├── pt-br.yml │ ├── ru.yml │ └── tr.yml └── routes.rb ├── db └── migrate │ ├── 20121212125001_create_custom_reports.rb │ ├── 20121212125002_create_custom_report_series.rb │ └── 20121212125003_remove_filters_from_custom_reports.rb ├── init.rb ├── lib ├── redmine_custom_reports.rb └── redmine_custom_reports │ ├── project_patch.rb │ └── user_patch.rb ├── screenshot.png └── test ├── functional └── custom_reports_controller_test.rb └── test_helper.rb /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | services: 4 | - mysql 5 | - postgresql 6 | 7 | rvm: 8 | - 2.2.0 9 | 10 | gemfile: 11 | - $REDMINE_PATH/Gemfile 12 | 13 | env: 14 | - REDMINE_VER=3.1.6 DB=mysql 15 | - REDMINE_VER=3.2.3 DB=mysql 16 | - REDMINE_VER=3.3.0 DB=mysql 17 | 18 | - REDMINE_VER=3.1.6 DB=postgresql 19 | - REDMINE_VER=3.2.3 DB=postgresql 20 | - REDMINE_VER=3.3.0 DB=postgresql 21 | 22 | before_install: 23 | - export PLUGIN_NAME=redmine_custom_reports 24 | - export REDMINE_PATH=$HOME/redmine 25 | - svn co http://svn.redmine.org/redmine/tags/$REDMINE_VER $REDMINE_PATH 26 | - ln -s $TRAVIS_BUILD_DIR $REDMINE_PATH/plugins/$PLUGIN_NAME 27 | - cp config/database-$DB-travis.yml $REDMINE_PATH/config/database.yml 28 | - cd $REDMINE_PATH 29 | 30 | before_script: 31 | - bundle exec rake db:create 32 | - bundle exec rake db:migrate 33 | - bundle exec rake redmine:plugins:migrate 34 | 35 | script: 36 | - bundle exec ruby plugins/$PLUGIN_NAME/test/**/*_test.rb 37 | 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redmine Custom Reports Plugin (with charts) 2 | 3 | [](https://travis-ci.org/Restream/redmine_custom_reports) 4 | [](https://codeclimate.com/github/Restream/redmine_custom_reports) 5 | 6 | Redmine plugin to create project reports using [d3.js](http://d3js.org/) charts (with using [NVD3](http://nvd3.org/)). The data for the report - the number of filtered issues grouped by a column. 7 | 8 | You can use multiple data series. Issues filtered by ordinary redmine filters. 9 | 10 | Tickets are grouped together on the same field, on how tickets can be grouped in a query. Additionally, you can group tickets by text custom fields. In addition to regular columns, you can use custom fields with type "text" to group. 11 | 12 | ## Permissions 13 | 14 | There are public and private custom reports. Permission "View custom reports" allows user to view public and private reports. Permission "Manage custom reports" allows user to manage their (private) reports. And last permission "Manage public custom reports" allows user to manage public reports. 15 | 16 | ## Installing a plugin 17 | 18 | 1. Copy plugin directory into #{RAILS_ROOT}/plugins. 19 | If you are downloading the plugin directly from GitHub, 20 | you can do so by changing into your plugin directory and issuing a command like 21 | 22 | git clone git://github.com/Restream/redmine_custom_reports.git 23 | 24 | 2. Run the following command to upgrade your database (make a db backup before). 25 | 26 | bundle exec rake redmine:plugins:migrate RAILS_ENV=production 27 | 28 | 3. Restart Redmine 29 | 30 | 4. Go to one of your project settings. Click on the Modules tab. 31 | You should see the "Custom reports" module at the end of the modules list. 32 | Enable plugin at project level. Now you will see "Custom report" tab at the project menu. 33 | 34 | ## Screenshot 35 | 36 |  37 | 38 | ## Compatibility 39 | 40 | This version supports redmine 2.x and 3.x 41 | 42 | For all tested versions see the "tests matrix":https://travis-ci.org/Restream/redmine_custom_reports 43 | -------------------------------------------------------------------------------- /app/controllers/custom_reports_controller.rb: -------------------------------------------------------------------------------- 1 | class CustomReportsController < ApplicationController 2 | unloadable 3 | 4 | before_filter :find_project_by_project_id 5 | before_filter :authorize 6 | before_filter :find_custom_reports, only: [:index, :show, :new, :edit] 7 | before_filter :find_custom_report, only: [:show, :edit, :update, :destroy] 8 | before_filter :authorize_to_manage, only: [:edit, :update, :destroy] 9 | 10 | helper :queries 11 | include QueriesHelper 12 | 13 | def index 14 | end 15 | 16 | def show 17 | end 18 | 19 | def new 20 | @custom_report = @project.custom_reports.build 21 | @custom_report.series.build 22 | end 23 | 24 | def create 25 | params.required(:custom_report).permit! if params.class.method_defined? :required 26 | @custom_report = @project.custom_reports.build(params[:custom_report]) 27 | @custom_report.user = User.current 28 | unless User.current.allowed_to?(:manage_public_custom_reports, @project) || 29 | User.current.admin? 30 | @custom_report.is_public = false 31 | end 32 | 33 | if @custom_report.save 34 | redirect_to url_for( 35 | controller: 'custom_reports', 36 | action: 'show', project_id: @project, id: @custom_report.id), 37 | notice: l(:message_custom_reports_created) 38 | else 39 | render action: 'new' 40 | end 41 | end 42 | 43 | def edit 44 | @custom_report.series.build if @custom_report.series.empty? 45 | end 46 | 47 | def update 48 | unless User.current.allowed_to?(:manage_public_custom_reports, @project) || 49 | User.current.admin? 50 | @custom_report.is_public = false 51 | end 52 | 53 | params.required(:custom_report).permit! if params.class.method_defined? :required 54 | if @custom_report.update_attributes(params[:custom_report]) 55 | redirect_to url_for( 56 | controller: 'custom_reports', 57 | action: 'show', project_id: @project, id: @custom_report.id), 58 | notice: l(:message_custom_reports_updated) 59 | else 60 | render action: 'edit' 61 | end 62 | end 63 | 64 | def destroy 65 | if @custom_report.destroy 66 | flash[:notice] = l(:message_custom_reports_destroyed) 67 | else 68 | flash[:alert] = l(:message_custom_reports_not_destroyed) 69 | end 70 | redirect_to project_custom_reports_url(@project) 71 | end 72 | 73 | private 74 | 75 | def find_custom_reports 76 | @custom_reports = @project.custom_reports.visible.by_name 77 | grouped_reports = @custom_reports.group_by(&:is_public) 78 | @own_custom_reports = grouped_reports[false] 79 | @public_custom_reports = grouped_reports[true] 80 | end 81 | 82 | def find_custom_report 83 | @custom_report = @project.custom_reports.visible.find(params[:id]) 84 | end 85 | 86 | def authorize_to_manage 87 | @custom_report.allowed_to_manage? || deny_access 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /app/helpers/custom_reports_helper.rb: -------------------------------------------------------------------------------- 1 | module CustomReportsHelper 2 | def sanitized_object_name(object_name) 3 | object_name.gsub(/\]\[|[^-a-zA-Z0-9:.]/, '_').sub(/_$/, '') 4 | end 5 | 6 | def operators_for_select(filter_type) 7 | Query.operators_by_filter_type[filter_type].collect { |o| [l(Query.operators[o]), o] } 8 | end 9 | 10 | def query_options_for_select(query) 11 | options = query.available_filters.collect do |field, options| 12 | unless query.has_filter?(field) 13 | [options[:name] || l(('field_' + field.to_s.gsub(/_id$/, '')).to_sym), field] 14 | end 15 | end 16 | options = [['', '']] + options.compact 17 | options_for_select(options) 18 | end 19 | 20 | def link_to_add_custom_report_series(name, f) 21 | new_object = f.object.series.build 22 | id = new_object.object_id 23 | fields = f.fields_for(:series, new_object, child_index: id) do |builder| 24 | render('series', f: builder) 25 | end 26 | link_to(name, '#', 27 | class: 'add-custom-report-series', 28 | 'data-id' => id, 29 | 'data-fields' => fields.gsub("\n", '') 30 | ) 31 | end 32 | 33 | def width_style_for_series(custom_report) 34 | 'width:100%;' 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /app/models/custom_report.rb: -------------------------------------------------------------------------------- 1 | class CustomReport < ActiveRecord::Base 2 | unloadable 3 | 4 | CHART_TYPES = %w(undev_pie pie donut bar horizontal_bar stacked_bar) 5 | MULTI_SERIES = %w(horizontal_bar stacked_bar) 6 | 7 | belongs_to :project 8 | belongs_to :user 9 | has_many :series, class_name: 'CustomReportSeries' 10 | 11 | validates_presence_of :project 12 | validates_presence_of :user 13 | validates_presence_of :name 14 | validates_presence_of :group_by 15 | validates_presence_of :null_text 16 | validates_inclusion_of :chart_type, in: CHART_TYPES 17 | 18 | accepts_nested_attributes_for :series, allow_destroy: true 19 | 20 | scope :visible, lambda { |*args| 21 | user = args.shift || User.current 22 | user_id = user.logged? ? user.id : 0 23 | where "(#{table_name}.is_public = ? OR #{table_name}.user_id = ?)", true, user_id 24 | } 25 | 26 | scope :by_name, -> { order('name') } 27 | 28 | def groupable_columns 29 | QueryExt.new().groupable_columns.select do |col| 30 | if col.respond_to? :custom_field 31 | col.custom_field.is_for_all || 32 | project.all_issue_custom_fields.include?(col.custom_field) 33 | else 34 | true 35 | end 36 | end 37 | end 38 | 39 | def info 40 | { 41 | chart_type: chart_type, 42 | group_by_caption: group_by_column.try(:caption), 43 | series_count: series.count, 44 | multi_series: multi_series? 45 | } 46 | end 47 | 48 | def multi_series? 49 | MULTI_SERIES.include?(chart_type) 50 | end 51 | 52 | def data 53 | if multi_series? 54 | # all series must have the same keys 55 | keys = series.map { |s| s.data_hash.keys }.flatten.uniq 56 | series.map { |s| s.data(keys) } 57 | else 58 | series.map { |s| s.data } 59 | end 60 | end 61 | 62 | def allowed_to_manage?(user = User.current) 63 | user.allowed_to?( 64 | is_public? ? :manage_public_custom_reports : :manage_custom_reports, 65 | project 66 | ) 67 | end 68 | 69 | def group_by_column 70 | groupable_columns.detect { |col| col.name.to_s == group_by } 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /app/models/custom_report_series.rb: -------------------------------------------------------------------------------- 1 | class CustomReportSeries < ActiveRecord::Base 2 | unloadable 3 | 4 | serialize :filters 5 | 6 | belongs_to :custom_report, inverse_of: :series 7 | 8 | validates :name, presence: true 9 | 10 | def query 11 | @query ||= build_query 12 | end 13 | 14 | def data(data_keys = []) 15 | _keys = data_keys.dup 16 | _data = { 17 | key: name, 18 | values: data_hash.map do |k, v| 19 | _keys.delete(k) 20 | { label: data_label_text(k), value: v } 21 | end 22 | } 23 | _keys.each do |key| 24 | _data[:values] << { label: data_label_text(key), value: 0 } 25 | end 26 | _data 27 | end 28 | 29 | def data_hash 30 | true 31 | @data_hash ||= (query.result_count_by_group || {}) 32 | end 33 | 34 | def flt=(*args) 35 | filters_hash = args.extract_options! 36 | query.filters = {} 37 | query.add_filters(filters_hash[:f], filters_hash[:op], filters_hash[:v]) 38 | self.filters = query.filters 39 | end 40 | 41 | private 42 | 43 | def build_query 44 | QueryExt.new( 45 | name: name, 46 | filters: filters, 47 | group_by: custom_report.try(:group_by), 48 | project: custom_report.try(:project)) 49 | end 50 | 51 | def data_label_text(label) 52 | (label.present? ? label : custom_report.null_text).to_s 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/models/query_ext.rb: -------------------------------------------------------------------------------- 1 | ISSUE_QUERY_CLASS = Redmine::VERSION.to_s >= '2.3.0' ? IssueQuery : Query 2 | 3 | class QueryExt < ISSUE_QUERY_CLASS 4 | unloadable 5 | 6 | def initialize(*args) 7 | super 8 | available_columns.each do |col| 9 | make_groupable!(col) if groupable_ext?(col) 10 | end 11 | end 12 | 13 | def model_name 14 | superclass.model_name 15 | end 16 | 17 | private 18 | 19 | def groupable_ext?(col) 20 | col.respond_to?(:custom_field) && !col.custom_field.multiple? && 21 | %w(string).include?(col.custom_field.field_format) 22 | end 23 | 24 | def make_groupable!(col) 25 | col.groupable = col.custom_field.order_statement 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/views/custom_reports/_form.html.erb: -------------------------------------------------------------------------------- 1 | <% content_for :header_tags do %> 2 | <%= javascript_include_tag 'custom_report_edit.js', plugin: 'redmine_custom_reports' %> 3 | <% end %> 4 | 5 | <%= labelled_form_for custom_report, 6 | url: url, 7 | html: { id: 'custom-report-form' } do |f| %> 8 | 9 | <%= error_messages_for custom_report %> 10 | 11 |
15 | <%= f.text_field :name, size: 80, required: true %> 16 |
17 | 18 |19 | 20 | <%= text_field 'custom_report', 'description', size: 80 %> 21 |
22 | 23 |24 | 25 | <%= select 'custom_report', 'chart_type', 26 | CustomReport::CHART_TYPES.collect { |ct| [l("label_chart_type_#{ct}"), ct] }, 27 | include_blank: false %> 28 |
29 | 30 | <% if User.current.admin? || User.current.allowed_to?(:manage_public_custom_reports, @project) %> 31 |32 | 33 | <%= check_box 'custom_report', 'is_public', 34 | onchange: (User.current.admin? ? nil : 'if (this.checked) {$("custom_report_is_for_all").checked = false; $("custom_report_is_for_all").disabled = true;} else {$("custom_report_is_for_all").disabled = false;}') %> 35 |
36 | <% end %> 37 | 38 |39 | 40 | <%= select 'custom_report', 'group_by', 41 | @custom_report.groupable_columns.collect { |c| [c.caption, c.name.to_s] }, 42 | include_blank: false %> 43 |
44 | 45 |46 | 47 | <%= text_field 'custom_report', 'null_text', size: 80 %> 48 |
49 |
6 |
|
107 | 108 | <%= label_tag("#{series_id}_add_filter_select", l(:label_filter_add)) %> 109 | <%= select_tag "#{series_id}_add_filter_select", 110 | query_options_for_select(query), 111 | class: 'add_series_filter', 112 | name: nil %> 113 | | 114 |
<%= l(:field_name) %> | 16 |<%= l(:field_description) %> | 17 |<%= l(:field_chart_type) %> | 18 | <% if User.current.allowed_to?(:manage_custom_reports, @project) %> 19 |<%= l(:field_is_public) %> | 20 | <% end %> 21 |22 | 23 | 24 | <% @custom_reports.each do |custom_report| %> 25 | |
---|---|---|---|---|
27 | <%= link_to custom_report.name,
28 | { controller: 'custom_reports',
29 | action: 'show',
30 | project_id: @project,
31 | id: custom_report.id } %> 32 | |
33 | <%= custom_report.description %> | 34 |<%= custom_report.chart_type %> | 35 | <% if User.current.allowed_to?(:manage_custom_reports, @project) %> 36 |<%= custom_report.is_public %> | 37 | <% end %> 38 |39 | <% if custom_report.allowed_to_manage? %> 40 | <%= link_to(l(:button_edit), 41 | edit_project_custom_report_path(@project, custom_report), 42 | class: 'icon icon-edit') %> 43 | 44 | <%= link_to(l(:button_delete), 45 | project_custom_report_path(@project, custom_report), 46 | confirm: l(:text_are_you_sure), 47 | method: :delete, 48 | class: 'icon icon-del') %> 49 | <% end %> 50 | | 51 |
<%= @custom_report.description %>
27 | 28 | <%= content_tag :div, 29 | class: 'custom-report', 30 | 'data-custom_report_info' => @custom_report.info.to_json do %> 31 | <% if @custom_report.multi_series? %> 32 | <% content_tag :div, 33 | class: 'custom-report-chart', 34 | style: width_style_for_series(@custom_report), 35 | 'data-chart_data' => @custom_report.data.to_json do %> 36 | 37 | <% end %> 38 | <% else %> 39 | <% @custom_report.series.each do |series| %> 40 | <%= content_tag :div, 41 | class: 'custom-report-chart', 42 | style: width_style_for_series(@custom_report), 43 | 'data-chart_data' => [series.data].to_json do %> 44 |0&&(e=r)}return e}function _r(e,t){return e.x-t.x}function Dr(e,t){return t.x-e.x}function Pr(e,t){return e.depth-t.depth}function Hr(e,t){function n(e,r){var i=e.children;if(i&&(a=i.length)){var s,o=null,u=-1,a;while(++u=0)s=r[i]._tree,s.prelim+=t,s.mod+=t,t+=s.shift+(n+=s.change)}function jr(e,t,n){e=e._tree,t=t._tree;var r=n/(t.number-e.number);e.change+=r,t.change-=r,t.shift+=n,t.prelim+=n,t.mod+=n}function Fr(e,t,n){return e._tree.ancestor.parent==t.parent?e._tree.ancestor:n}function Ir(e){return{x:e.x,y:e.y,dx:e.dx,dy:e.dy}}function qr(e,t){var n=e.x+t[3],r=e.y+t[0],i=e.dx-t[1]-t[3],s=e.dy-t[0]-t[2];return i<0&&(n+=i/2,i=0),s<0&&(r+=s/2,s=0),{x:n,y:r,dx:i,dy:s}}function Rr(e,t){function n(e,r){d3.text(e,t,function(e){r(e&&n.parse(e))})}function r(t){return t.map(i).join(e)}function i(e){return o.test(e)?'"'+e.replace(/\"/g,'""')+'"':e}var s=new RegExp("\r\n|["+e+"\r\n]","g"),o=new RegExp('["'+e+"\n]"),u=e.charCodeAt(0);return n.parse=function(e){var t;return n.parseRows(e,function(e,n){if(n){var r={},i=-1,s=t.length;while(++i=e.length)return i;if(l)return l=!1,r;var t=s.lastIndex;if(e.charCodeAt(t)===34){var n=t;while(n++s&&(i=s),o1);return e+t*n*Math.sqrt(-2*Math.log(i)/i)}},logNormal:function(e,t){var n=arguments.length;n<2&&(t=1),n<1&&(e=0);var r=d3.random.normal();return function(){return Math.exp(e+t*r())}},irwinHall:function(e){return function(){for(var t=0,n=0;n=^]))?([+\- ])?(#)?(0)?([0-9]+)?(,)?(\.[0-9]+)?([a-zA-Z%])?/,rs=d3.map({g:function(e,t){return e.toPrecision(t)},e:function(e,t){return e.toExponential(t)},f:function(e,t){return e.toFixed(t)},r:function(e,t){return d3.round(e,t=m(e,t)).toFixed(Math.max(0,Math.min(20,t)))}}),is=["y","z","a","f","p","n","μ","m","","k","M","G","T","P","E","Z","Y"].map(b);d3.formatPrefix=function(e,t){var n=0;return e&&(e<0&&(e*=-1),t&&(e=d3.round(e,m(e,t))),n=1+Math.floor(1e-12+Math.log(e)/Math.LN10),n=Math.max(-24,Math.min(24,Math.floor((n<=0?n+1:n-1)/3)*3))),is[8+n/3]};var ss=T(2),os=T(3),us=function(){return x},as=d3.map({linear:us,poly:T,quad:function(){return ss},cubic:function(){return os},sin:function(){return N},exp:function(){return C},circle:function(){return k},elastic:L,back:A,bounce:function(){return O}}),fs=d3.map({"in":x,out:E,"in-out":S,"out-in":function(e){return S(E(e))}});d3.ease=function(e){var t=e.indexOf("-"),n=t>=0?e.substring(0,t):e,r=t>=0?e.substring(t+1):"in";return n=as.get(n)||us,r=fs.get(r)||x,w(r(n.apply(null,Array.prototype.slice.call(arguments,1))))},d3.event=null,d3.transform=function(e){var t=document.createElementNS(d3.ns.prefix.svg,"g");return(d3.transform=function(e){t.setAttribute("transform",e);var n=t.transform.baseVal.consolidate();return new P(n?n.matrix:cs)})(e)},P.prototype.toString=function(){return"translate("+this.translate+")rotate("+this.rotate+")skewX("+this.skew+")scale("+this.scale+")"};var ls=180/Math.PI,cs={a:1,b:0,c:0,d:1,e:0,f:0};d3.interpolate=function(e,t){var n=d3.interpolators.length,r;while(--n>=0&&!(r=d3.interpolators[n](e,t)));return r},d3.interpolateNumber=function(e,t){return t-=e,function(n){return e+t*n}},d3.interpolateRound=function(e,t){return t-=e,function(n){return Math.round(e+t*n)}},d3.interpolateString=function(e,t){var n,r,i,s=0,o=0,u=[],a=[],f,l;hs.lastIndex=0;for(r=0;n=hs.exec(t);++r)n.index&&u.push(t.substring(s,o=n.index)),a.push({i:u.length,x:n[0]}),u.push(null),s=hs.lastIndex;sn.dx)f=n.dx;while(++i50?n:s<-140?r:o<21?i:t)(e)}var t=d3.geo.albers(),n=d3.geo.albers().origin([-160,60]).parallels([55,65]),r=d3.geo.albers().origin([-160,20]).parallels([8,18]),i=d3.geo.albers().origin([-60,10]).parallels([8,18]);return e.scale=function(s){return arguments.length?(t.scale(s),n.scale(s*.6),r.scale(s),i.scale(s*1.5),e.translate(t.translate())):t.scale()},e.translate=function(s){if(!arguments.length)return t.translate();var o=t.scale()/1e3,u=s[0],a=s[1];return t.translate(s),n.translate([u-400*o,a+170*o]),r.translate([u-190*o,a+200*o]),i.translate([u+580*o,a+430*o]),e},e.scale(t.scale())},d3.geo.bonne=function(){function e(e){var u=e[0]*mo-r,a=e[1]*mo-i;if(s){var f=o+s-a,l=u*Math.cos(a)/f;u=f*Math.sin(l),a=f*Math.cos(l)-o}else u*=Math.cos(a),a*=-1;return[t*u+n[0],t*a+n[1]]}var t=200,n=[480,250],r,i,s,o;return e.invert=function(e){var i=(e[0]-n[0])/t,u=(e[1]-n[1])/t;if(s){var a=o+u,f=Math.sqrt(i*i+a*a);u=o+s-f,i=r+f*Math.atan2(i,a)/Math.cos(u)}else u*=-1,i/=Math.cos(u);return[i/mo,u/mo]},e.parallel=function(t){return arguments.length?(o=1/Math.tan(s=t*mo),e):s/mo},e.origin=function(t){return arguments.length?(r=t[0]*mo,i=t[1]*mo,e):[r/mo,i/mo]},e.scale=function(
4 | n){return arguments.length?(t=+n,e):t},e.translate=function(t){return arguments.length?(n=[+t[0],+t[1]],e):n},e.origin([0,0]).parallel(45)},d3.geo.equirectangular=function(){function e(e){var r=e[0]/360,i=-e[1]/360;return[t*r+n[0],t*i+n[1]]}var t=500,n=[480,250];return e.invert=function(e){var r=(e[0]-n[0])/t,i=(e[1]-n[1])/t;return[360*r,-360*i]},e.scale=function(n){return arguments.length?(t=+n,e):t},e.translate=function(t){return arguments.length?(n=[+t[0],+t[1]],e):n},e},d3.geo.mercator=function(){function e(e){var r=e[0]/360,i=-(Math.log(Math.tan(Math.PI/4+e[1]*mo/2))/mo)/360;return[t*r+n[0],t*Math.max(-0.5,Math.min(.5,i))+n[1]]}var t=500,n=[480,250];return e.invert=function(e){var r=(e[0]-n[0])/t,i=(e[1]-n[1])/t;return[360*r,2*Math.atan(Math.exp(-360*i*mo))/mo-90]},e.scale=function(n){return arguments.length?(t=+n,e):t},e.translate=function(t){return arguments.length?(n=[+t[0],+t[1]],e):n},e},d3.geo.path=function(){function e(e,t){typeof s=="function"&&(o=zr(s.apply(this,arguments))),f(e);var n=a.length?a.join(""):null;return a=[],n}function t(e){return u(e).join(",")}function n(e){var t=i(e[0]),n=0,r=e.length;while(++n