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.
This commit is contained in:
@@ -3,11 +3,123 @@
|
|||||||
class TimeSeriesComponent < ApplicationComponent
|
class TimeSeriesComponent < ApplicationComponent
|
||||||
delegate :current_page_path, :search_params, to: :helpers
|
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
|
@dataframe = dataframe
|
||||||
@group = group
|
@x_axis = x_axis
|
||||||
|
@y_axis = columns.without(x_axis)
|
||||||
@mode = mode.to_sym
|
@mode = mode.to_sym
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -6,7 +6,16 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if mode == :table %>
|
<% if mode == :chart && x_axis.present? %>
|
||||||
|
<div class="line-chart" style="width: 100%; height: max(90vh, <%= chart_height.to_i %>px);"></div>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
var chart = new Danbooru.TimeSeriesComponent({
|
||||||
|
container: $(".line-chart").get(0),
|
||||||
|
options: <%= raw chart_options.to_json %>,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<% else %>
|
||||||
<table class="striped autofit" width="100%">
|
<table class="striped autofit" width="100%">
|
||||||
<thead>
|
<thead>
|
||||||
<% dataframe.types.keys.each do |column| %>
|
<% dataframe.types.keys.each do |column| %>
|
||||||
@@ -26,16 +35,4 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<% elsif mode == :chart %>
|
|
||||||
<div class="line-chart" style="width: 100%; height: 80vh;"></div>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
var data = <%= raw dataframe.each_row.map(&:values).to_json %>;
|
|
||||||
var columns = <%= raw dataframe.types.keys.without("date").to_json %>;
|
|
||||||
var chart = new Danbooru.TimeSeriesComponent({
|
|
||||||
container: $(".line-chart").get(0),
|
|
||||||
data: data,
|
|
||||||
columns: columns,
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ class ReportsController < ApplicationController
|
|||||||
def show
|
def show
|
||||||
@report = params[:id]
|
@report = params[:id]
|
||||||
@mode = params.dig(:search, :mode) || "chart"
|
@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
|
@from = params.dig(:search, :from) || 1.month.ago
|
||||||
@to = params.dig(:search, :to) || Time.zone.now
|
@to = params.dig(:search, :to) || Time.zone.now
|
||||||
@columns = params.dig(:search, :columns).to_s.split(/[[:space:],]/).map(&:to_sym)
|
@columns = params.dig(:search, :columns).to_s.split(/[[:space:],]/).map(&:to_sym)
|
||||||
@group = params.dig(:search, :group)&.downcase&.tr(" ", "_")
|
@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
|
case @report
|
||||||
when "posts"
|
when "posts"
|
||||||
@@ -138,10 +138,18 @@ class ReportsController < ApplicationController
|
|||||||
@group = nil unless @group&.in?(@available_groups)
|
@group = nil unless @group&.in?(@available_groups)
|
||||||
@columns = @available_columns.slice(*@columns)
|
@columns = @available_columns.slice(*@columns)
|
||||||
@columns = [@available_columns.first].to_h if @columns.blank?
|
@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[@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
|
end
|
||||||
|
|
||||||
respond_with(@dataframe)
|
respond_with(@dataframe)
|
||||||
|
|||||||
@@ -1,70 +1,12 @@
|
|||||||
import * as echarts from "echarts";
|
import * as echarts from "echarts";
|
||||||
import startCase from "lodash/startCase";
|
|
||||||
import CurrentUser from "./current_user.js";
|
import CurrentUser from "./current_user.js";
|
||||||
|
|
||||||
export default class TimeSeriesComponent {
|
export default class TimeSeriesComponent {
|
||||||
constructor({ container = null, data = [], columns = [], theme = null } = {}) {
|
constructor({ container = null, options = {} } = {}) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
this.data = data;
|
this.options = options;
|
||||||
this.columns = columns;
|
|
||||||
this.theme = CurrentUser.darkMode() ? "dark" : null;
|
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 = echarts.init(container, this.theme);
|
||||||
this.chart.setOption(this.options);
|
this.chart.setOption(this.options);
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ module Aggregatable
|
|||||||
from = from.to_date
|
from = from.to_date
|
||||||
to = to.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 }
|
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
|
# 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
|
# ) subquery ON subquery.date = dates.date AND subquery.uploader_id = uploader_ids.uploader_id
|
||||||
# ORDER BY date DESC
|
# ORDER BY date DESC
|
||||||
|
|
||||||
results = query.select_all
|
build_dataframe(query, groups)
|
||||||
types = results.columns.map { |column| [column, :object] }.to_h
|
end
|
||||||
|
|
||||||
dataframe = Danbooru::DataFrame.new(results.to_a, types: types)
|
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"))
|
||||||
dataframe = dataframe.preload_associations(group_associations)
|
group_fields = groups.map { |name| reflections[name.to_s]&.foreign_key || name }
|
||||||
dataframe
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
def group_by_period(period = "day", column = :created_at)
|
def group_by_period(period = "day", column = :created_at)
|
||||||
@@ -106,4 +116,16 @@ module Aggregatable
|
|||||||
def generate_timeseries(from, to, interval)
|
def generate_timeseries(from, to, interval)
|
||||||
generate_series(from, to, Arel.sql("#{connection.quote("1 #{interval}")}::interval"))
|
generate_series(from, to, Arel.sql("#{connection.quote("1 #{interval}")}::interval"))
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -5,27 +5,27 @@
|
|||||||
<h1>Reports</h1>
|
<h1>Reports</h1>
|
||||||
|
|
||||||
<ul class="list-bulleted">
|
<ul class="list-bulleted">
|
||||||
<li><%= link_to "Posts", report_path("posts") %></li>
|
<li><%= link_to "Posts", report_path("posts", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Post approvals", report_path("post_approvals") %></li>
|
<li><%= link_to "Post approvals", report_path("post_approvals", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Post appeals", report_path("post_appeals") %></li>
|
<li><%= link_to "Post appeals", report_path("post_appeals", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Post flags", report_path("post_flags") %></li>
|
<li><%= link_to "Post flags", report_path("post_flags", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Post replacements", report_path("post_replacements") %></li>
|
<li><%= link_to "Post replacements", report_path("post_replacements", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Post votes", report_path("post_votes") %></li>
|
<li><%= link_to "Post votes", report_path("post_votes", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Media assets", report_path("media_assets") %></li>
|
<li><%= link_to "Media assets", report_path("media_assets", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Pools", report_path("pools") %></li>
|
<li><%= link_to "Pools", report_path("pools", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Comments", report_path("comments") %></li>
|
<li><%= link_to "Comments", report_path("comments", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Comment votes", report_path("comment_votes") %></li>
|
<li><%= link_to "Comment votes", report_path("comment_votes", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Forum posts", report_path("forum_posts") %></li>
|
<li><%= link_to "Forum posts", report_path("forum_posts", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Bulk update requests", report_path("bulk_update_requests") %></li>
|
<li><%= link_to "Bulk update requests", report_path("bulk_update_requests", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Tag aliases", report_path("tag_aliases") %></li>
|
<li><%= link_to "Tag aliases", report_path("tag_aliases", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Tag implications", report_path("tag_implications") %></li>
|
<li><%= link_to "Tag implications", report_path("tag_implications", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Artist edits", report_path("artist_versions") %></li>
|
<li><%= link_to "Artist edits", report_path("artist_versions", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Artist commentary edits", report_path("artist_commentary_versions") %></li>
|
<li><%= link_to "Artist commentary edits", report_path("artist_commentary_versions", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Note edits", report_path("note_versions") %></li>
|
<li><%= link_to "Note edits", report_path("note_versions", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Wiki edits", report_path("wiki_page_versions") %></li>
|
<li><%= link_to "Wiki edits", report_path("wiki_page_versions", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Mod actions", report_path("mod_actions") %></li>
|
<li><%= link_to "Mod actions", report_path("mod_actions", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Bans", report_path("bans") %></li>
|
<li><%= link_to "Bans", report_path("bans", search: { period: "day" }) %></li>
|
||||||
<li><%= link_to "Users", report_path("users") %></li>
|
<li><%= link_to "Users", report_path("users", search: { period: "day" }) %></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,12 +12,13 @@
|
|||||||
|
|
||||||
<%= f.input :from, as: :date, html5: true, input_html: { value: params[:search][:from] || 1.month.ago.to_date } %>
|
<%= 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 :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, 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.input :mode, as: :hidden, input_html: { value: params[:search][:mode] } %>
|
||||||
<%= f.submit "Search" %>
|
<%= f.submit "Search" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= render TimeSeriesComponent.new(@dataframe, mode: @mode) %>
|
<%= render TimeSeriesComponent.new(@dataframe, x_axis: @x_axis, mode: @mode) %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user