From 203067b5ede3742a79819e3547b016287bcb591b Mon Sep 17 00:00:00 2001 From: evazion Date: Sun, 23 Oct 2022 04:42:51 -0500 Subject: [PATCH] reports: add non-timeseries charts. Add bar charts for non-timeseries data. For example, a bar chart of the top 10 uploaders overall in the last month, rather than a timeseries chart of the number of uploads per day for the last month. --- app/components/time_series_component.rb | 118 +++++++++++++++++- .../time_series_component.html.erb | 23 ++-- app/controllers/reports_controller.rb | 18 ++- .../src/javascripts/time_series_component.js | 62 +-------- app/logical/concerns/aggregatable.rb | 34 ++++- app/views/reports/index.html.erb | 42 +++---- app/views/reports/show.html.erb | 5 +- 7 files changed, 192 insertions(+), 110 deletions(-) diff --git a/app/components/time_series_component.rb b/app/components/time_series_component.rb index 064117a1e..2bb42e805 100644 --- a/app/components/time_series_component.rb +++ b/app/components/time_series_component.rb @@ -3,11 +3,123 @@ class TimeSeriesComponent < ApplicationComponent delegate :current_page_path, :search_params, to: :helpers - attr_reader :dataframe, :group, :mode + attr_reader :dataframe, :x_axis, :y_axis, :mode - def initialize(dataframe, group: nil, mode: :table) + def initialize(dataframe, x_axis:, mode: :table) @dataframe = dataframe - @group = group + @x_axis = x_axis + @y_axis = columns.without(x_axis) @mode = mode.to_sym end + + def columns + dataframe.types.keys + end + + def chart_height + if x_axis != "date" + dataframe[x_axis].size * 30 + end + end + + def chart_options + if x_axis == "date" + stacked_area_chart + else + horizontal_bar_chart + end + end + + def base_options + { + dataset: { + dimensions: columns, + source: dataframe.each_row.map(&:values), + }, + tooltip: { + trigger: "axis", + axisPointer: { + type: "cross", + label: { + backgroundColor: "#6a7985" + } + } + }, + toolbox: { + feature: { + dataView: {}, + restore: {}, + saveAsImage: {} + } + }, + grid: { + left: "1%", + right: "1%", + containLabel: true + }, + legend: { + data: y_axis.map(&:capitalize), + type: "scroll", + left: 0, + padding: [8, 200, 0, 15], + orient: "horizontal", + }, + } + end + + def stacked_area_chart + base_options.deep_merge( + toolbox: { + feature: { + dataZoom: { + yAxisIndex: "none" + }, + magicType: { + type: ["line", "bar"], + }, + } + }, + dataZoom: [ + { type: "inside" }, + { type: "slider" } + ], + xAxis: { type: "time" }, + yAxis: [type: "value"] * y_axis.size, + series: y_axis.map do |name| + { + name: name.capitalize, + type: "line", + areaStyle: {}, + stack: "all", + emphasis: { + focus: "series" + }, + encode: { + x: x_axis, + y: name + } + } + end + ) + end + + def horizontal_bar_chart + base_options.deep_merge( + xAxis: { type: "value" }, + yAxis: [type: "category", inverse: true] * y_axis.size, + series: y_axis.map do |name| + { + name: name.capitalize, + type: "bar", + emphasis: { + focus: "series" + }, + encode: { + x: name, + y: x_axis, + } + } + end + ) + end end diff --git a/app/components/time_series_component/time_series_component.html.erb b/app/components/time_series_component/time_series_component.html.erb index 447d5c0aa..2c4848be9 100644 --- a/app/components/time_series_component/time_series_component.html.erb +++ b/app/components/time_series_component/time_series_component.html.erb @@ -6,7 +6,16 @@ <% end %> -<% if mode == :table %> +<% if mode == :chart && x_axis.present? %> +
+ + +<% else %> <% dataframe.types.keys.each do |column| %> @@ -26,16 +35,4 @@ <% end %>
-<% elsif mode == :chart %> -
- - <% end %> diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index 7b393cc6d..5e3f9f0c4 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -11,12 +11,12 @@ class ReportsController < ApplicationController def show @report = params[:id] @mode = params.dig(:search, :mode) || "chart" - @period = params.dig(:search, :period)&.downcase || "day" + @period = params.dig(:search, :period)&.downcase @from = params.dig(:search, :from) || 1.month.ago @to = params.dig(:search, :to) || Time.zone.now @columns = params.dig(:search, :columns).to_s.split(/[[:space:],]/).map(&:to_sym) @group = params.dig(:search, :group)&.downcase&.tr(" ", "_") - @group_limit = params.dig(:search, :group_limit) || 10 + @group_limit = params.dig(:search, :group_limit)&.to_i || 10 case @report when "posts" @@ -138,10 +138,18 @@ class ReportsController < ApplicationController @group = nil unless @group&.in?(@available_groups) @columns = @available_columns.slice(*@columns) @columns = [@available_columns.first].to_h if @columns.blank? - @dataframe = @model.search(params[:search], CurrentUser.user).timeseries(period: @period, from: @from, to: @to, groups: [@group].compact_blank, group_limit: @group_limit, columns: @columns) - @dataframe["date"] = @dataframe["date"].map(&:to_date) + + if @period.present? + @dataframe = @model.search(params[:search], CurrentUser.user).timeseries(period: @period, from: @from, to: @to, groups: [@group].compact_blank, group_limit: @group_limit, columns: @columns) + @x_axis = "date" + else + @dataframe = @model.search(params[:search], CurrentUser.user).aggregate(from: @from, to: @to, groups: [@group].compact_blank, limit: @group_limit, columns: @columns) + @x_axis = @group + end + @dataframe[@group] = @dataframe[@group].map(&:pretty_name) if @group.in?(%w[creator updater uploader banner approver user]) - @dataframe = @dataframe.crosstab("date", @group) if @group + @dataframe["date"] = @dataframe["date"].map(&:to_date) if @dataframe["date"] + @dataframe = @dataframe.crosstab("date", @group) if @group && @period.present? end respond_with(@dataframe) diff --git a/app/javascript/src/javascripts/time_series_component.js b/app/javascript/src/javascripts/time_series_component.js index ada9c1bb6..89c633a76 100644 --- a/app/javascript/src/javascripts/time_series_component.js +++ b/app/javascript/src/javascripts/time_series_component.js @@ -1,70 +1,12 @@ import * as echarts from "echarts"; -import startCase from "lodash/startCase"; import CurrentUser from "./current_user.js"; export default class TimeSeriesComponent { - constructor({ container = null, data = [], columns = [], theme = null } = {}) { + constructor({ container = null, options = {} } = {}) { this.container = container; - this.data = data; - this.columns = columns; + this.options = options; this.theme = CurrentUser.darkMode() ? "dark" : null; - this.options = { - dataset: { - dimensions: ["date", ...this.columns], - source: data, - }, - tooltip: { - trigger: "axis", - axisPointer: { - type: "cross", - label: { - backgroundColor: '#6a7985' - } - } - }, - toolbox: { - feature: { - dataView: {}, - dataZoom: { - yAxisIndex: "none" - }, - magicType: { - type: ["line", "bar"], - }, - restore: {}, - saveAsImage: {} - } - }, - dataZoom: [ - { type: "inside" }, - { type: "slider" } - ], - grid: { - left: "1%", - right: "1%", - containLabel: true - }, - legend: { - data: this.columns.map(startCase), - }, - xAxis: { type: "time" }, - yAxis: this.columns.map(name => ({ type: "value" })), - series: this.columns.map(name => ({ - name: startCase(name), - type: "line", - areaStyle: {}, - stack: "all", - emphasis: { - focus: "series" - }, - encode: { - x: "date", - y: name - } - })) - }; - this.chart = echarts.init(container, this.theme); this.chart.setOption(this.options); diff --git a/app/logical/concerns/aggregatable.rb b/app/logical/concerns/aggregatable.rb index 53ce5c1ee..ea00454a2 100644 --- a/app/logical/concerns/aggregatable.rb +++ b/app/logical/concerns/aggregatable.rb @@ -10,7 +10,6 @@ module Aggregatable from = from.to_date to = to.to_date - group_associations = groups.map { |name| reflections[name.to_s] }.compact_blank group_fields = groups.map { |name| reflections[name.to_s]&.foreign_key || name } # SELECT date_trunc('day', posts.created_at) AS date FROM posts WHERE created_at BETWEEN from AND to GROUP BY date @@ -75,12 +74,23 @@ module Aggregatable # ) subquery ON subquery.date = dates.date AND subquery.uploader_id = uploader_ids.uploader_id # ORDER BY date DESC - results = query.select_all - types = results.columns.map { |column| [column, :object] }.to_h + build_dataframe(query, groups) + end - dataframe = Danbooru::DataFrame.new(results.to_a, types: types) - dataframe = dataframe.preload_associations(group_associations) - dataframe + def aggregate(date_column: :created_at, from: first[date_column], to: Time.now.utc, groups: [], limit: 50, columns: { count: "COUNT(*)" }, order: Arel.sql("#{columns.first.second} DESC")) + group_fields = groups.map { |name| reflections[name.to_s]&.foreign_key || name } + + query = where(date_column => (from..to)).reorder(order).limit(limit) + + group_fields.each do |name| + query = query.select(name).group(name).where.not(name => nil) + end + + columns.each do |name, sql| + query = query.select(Arel.sql(sql).as(name.to_s).to_sql) + end + + build_dataframe(query, groups) end def group_by_period(period = "day", column = :created_at) @@ -106,4 +116,16 @@ module Aggregatable def generate_timeseries(from, to, interval) generate_series(from, to, Arel.sql("#{connection.quote("1 #{interval}")}::interval")) end + + private + + def build_dataframe(query, groups) + results = query.select_all + types = results.columns.map { |column| [column, :object] }.to_h + associations = groups.map { |name| reflections[name.to_s] }.compact_blank + + dataframe = Danbooru::DataFrame.new(results.to_a, types: types) + dataframe = dataframe.preload_associations(associations) + dataframe + end end diff --git a/app/views/reports/index.html.erb b/app/views/reports/index.html.erb index e29cb0162..9a3827ddd 100644 --- a/app/views/reports/index.html.erb +++ b/app/views/reports/index.html.erb @@ -5,27 +5,27 @@

Reports

diff --git a/app/views/reports/show.html.erb b/app/views/reports/show.html.erb index e85925de8..da14dd387 100644 --- a/app/views/reports/show.html.erb +++ b/app/views/reports/show.html.erb @@ -12,12 +12,13 @@ <%= f.input :from, as: :date, html5: true, input_html: { value: params[:search][:from] || 1.month.ago.to_date } %> <%= f.input :to, as: :date, html5: true, input_html: { value: params[:search][:to] || Time.zone.now.to_date } %> - <%= f.input :period, collection: %w[Day Week Month Year], selected: params[:search][:period] %> + <%= f.input :period, collection: %w[day week month year].map { [_1.capitalize, _1] }, include_blank: true, selected: @period %> <%= f.input :group, label: "Group By", collection: @available_groups.map { |group| [group.titleize, group] }, include_blank: true, selected: params[:search][:group] if @available_groups.present? %> + <%= f.input :group_limit, label: "Top", collection: [10, 25, 50, 100], selected: @group_limit if @group %> <%= f.input :mode, as: :hidden, input_html: { value: params[:search][:mode] } %> <%= f.submit "Search" %> <% end %> - <%= render TimeSeriesComponent.new(@dataframe, mode: @mode) %> + <%= render TimeSeriesComponent.new(@dataframe, x_axis: @x_axis, mode: @mode) %>