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
- - <%= link_to "Posts", report_path("posts") %>
- - <%= link_to "Post approvals", report_path("post_approvals") %>
- - <%= link_to "Post appeals", report_path("post_appeals") %>
- - <%= link_to "Post flags", report_path("post_flags") %>
- - <%= link_to "Post replacements", report_path("post_replacements") %>
- - <%= link_to "Post votes", report_path("post_votes") %>
- - <%= link_to "Media assets", report_path("media_assets") %>
- - <%= link_to "Pools", report_path("pools") %>
- - <%= link_to "Comments", report_path("comments") %>
- - <%= link_to "Comment votes", report_path("comment_votes") %>
- - <%= link_to "Forum posts", report_path("forum_posts") %>
- - <%= link_to "Bulk update requests", report_path("bulk_update_requests") %>
- - <%= link_to "Tag aliases", report_path("tag_aliases") %>
- - <%= link_to "Tag implications", report_path("tag_implications") %>
- - <%= link_to "Artist edits", report_path("artist_versions") %>
- - <%= link_to "Artist commentary edits", report_path("artist_commentary_versions") %>
- - <%= link_to "Note edits", report_path("note_versions") %>
- - <%= link_to "Wiki edits", report_path("wiki_page_versions") %>
- - <%= link_to "Mod actions", report_path("mod_actions") %>
- - <%= link_to "Bans", report_path("bans") %>
- - <%= link_to "Users", report_path("users") %>
+ - <%= link_to "Posts", report_path("posts", search: { period: "day" }) %>
+ - <%= link_to "Post approvals", report_path("post_approvals", search: { period: "day" }) %>
+ - <%= link_to "Post appeals", report_path("post_appeals", search: { period: "day" }) %>
+ - <%= link_to "Post flags", report_path("post_flags", search: { period: "day" }) %>
+ - <%= link_to "Post replacements", report_path("post_replacements", search: { period: "day" }) %>
+ - <%= link_to "Post votes", report_path("post_votes", search: { period: "day" }) %>
+ - <%= link_to "Media assets", report_path("media_assets", search: { period: "day" }) %>
+ - <%= link_to "Pools", report_path("pools", search: { period: "day" }) %>
+ - <%= link_to "Comments", report_path("comments", search: { period: "day" }) %>
+ - <%= link_to "Comment votes", report_path("comment_votes", search: { period: "day" }) %>
+ - <%= link_to "Forum posts", report_path("forum_posts", search: { period: "day" }) %>
+ - <%= link_to "Bulk update requests", report_path("bulk_update_requests", search: { period: "day" }) %>
+ - <%= link_to "Tag aliases", report_path("tag_aliases", search: { period: "day" }) %>
+ - <%= link_to "Tag implications", report_path("tag_implications", search: { period: "day" }) %>
+ - <%= link_to "Artist edits", report_path("artist_versions", search: { period: "day" }) %>
+ - <%= link_to "Artist commentary edits", report_path("artist_commentary_versions", search: { period: "day" }) %>
+ - <%= link_to "Note edits", report_path("note_versions", search: { period: "day" }) %>
+ - <%= link_to "Wiki edits", report_path("wiki_page_versions", search: { period: "day" }) %>
+ - <%= link_to "Mod actions", report_path("mod_actions", search: { period: "day" }) %>
+ - <%= link_to "Bans", report_path("bans", search: { period: "day" }) %>
+ - <%= link_to "Users", report_path("users", search: { period: "day" }) %>
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) %>