Charts: count by artists and count by genre
Generated via Cursor and Claude 3.5 - initial version
This commit is contained in:
@@ -267,4 +267,38 @@ defmodule MusicLibrary.Records do
|
|||||||
def change_record(%Record{} = record, attrs \\ %{}) do
|
def change_record(%Record{} = record, attrs \\ %{}) do
|
||||||
Record.changeset(record, attrs)
|
Record.changeset(record, attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a list of tuples containing artist names and their record count,
|
||||||
|
ordered by count in descending order.
|
||||||
|
"""
|
||||||
|
def count_records_by_artist do
|
||||||
|
q =
|
||||||
|
from r in fragment("""
|
||||||
|
select json_extract(artist.value, '$.name') as name, count(1) as count
|
||||||
|
from records, json_each(records.artists) artist
|
||||||
|
group by name
|
||||||
|
order by count desc
|
||||||
|
"""),
|
||||||
|
select: {fragment("name"), fragment("count")}
|
||||||
|
|
||||||
|
Repo.all(q)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a list of tuples containing genre names and their record count,
|
||||||
|
ordered by count in descending order.
|
||||||
|
"""
|
||||||
|
def count_records_by_genre do
|
||||||
|
q =
|
||||||
|
from r in fragment("""
|
||||||
|
select genre.value as name, count(1) as count
|
||||||
|
from records, json_each(records.genres) genre
|
||||||
|
group by name
|
||||||
|
order by count desc
|
||||||
|
"""),
|
||||||
|
select: {fragment("name"), fragment("count")}
|
||||||
|
|
||||||
|
Repo.all(q)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
defmodule MusicLibraryWeb.ChartComponents do
|
||||||
|
use Phoenix.Component
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a horizontal bar chart.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
<.vertical_bar_chart
|
||||||
|
data={[{"Artist 1", 5}, {"Artist 2", 3}]}
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
bar_color="rgb(79, 70, 229)"
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
attr :data, :list, required: true, doc: "List of {label, value} tuples"
|
||||||
|
attr :width, :integer, default: 400
|
||||||
|
attr :height, :integer, default: 300
|
||||||
|
attr :bar_color, :string, default: "rgb(79, 70, 229)"
|
||||||
|
attr :class, :string, default: ""
|
||||||
|
|
||||||
|
def vertical_bar_chart(assigns) do
|
||||||
|
assigns =
|
||||||
|
assigns
|
||||||
|
|> assign(:padding, 40)
|
||||||
|
|> assign(:max_value, max_value(assigns.data))
|
||||||
|
# Account for labels
|
||||||
|
|> assign(:chart_width, assigns.width - 150)
|
||||||
|
# Account for bottom padding
|
||||||
|
|> assign(:chart_height, assigns.height - 40)
|
||||||
|
|> assign(:bar_height, calculate_bar_height(assigns.data, assigns.height - 40))
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<svg class={@class} width={@width} height={@height}>
|
||||||
|
<%!-- X-axis labels and lines --%>
|
||||||
|
<%= for {value, x} <- x_axis_values(@max_value, @chart_width) do %>
|
||||||
|
<text
|
||||||
|
x={x + 150}
|
||||||
|
y={@height - 10}
|
||||||
|
text-anchor="middle"
|
||||||
|
class="text-xs fill-zinc-500 dark:fill-zinc-400"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</text>
|
||||||
|
<line
|
||||||
|
x1={x + 150}
|
||||||
|
y1={@padding / 2}
|
||||||
|
x2={x + 150}
|
||||||
|
y2={@height - @padding / 2}
|
||||||
|
stroke="rgb(228, 228, 231)"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-dasharray="4,4"
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%!-- Bars and labels --%>
|
||||||
|
<%= for {{label, value}, index} <- Enum.with_index(@data) do %>
|
||||||
|
<% bar_width = @chart_width * value / @max_value %>
|
||||||
|
<% y = @padding / 2 + index * (@bar_height + 4) %>
|
||||||
|
|
||||||
|
<%!-- Label --%>
|
||||||
|
<text
|
||||||
|
x="140"
|
||||||
|
y={y + @bar_height / 2 + 4}
|
||||||
|
text-anchor="end"
|
||||||
|
class="text-xs fill-zinc-500 dark:fill-zinc-400"
|
||||||
|
>
|
||||||
|
{truncate_label(label, 20)}
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<%!-- Bar --%>
|
||||||
|
<rect
|
||||||
|
x="150"
|
||||||
|
y={y}
|
||||||
|
width={bar_width}
|
||||||
|
height={@bar_height}
|
||||||
|
fill={@bar_color}
|
||||||
|
class="opacity-80 hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<title>{label}: {value}</title>
|
||||||
|
</rect>
|
||||||
|
|
||||||
|
<%!-- Value label --%>
|
||||||
|
<text
|
||||||
|
x={150 + bar_width + 5}
|
||||||
|
y={y + @bar_height / 2 + 4}
|
||||||
|
class="text-xs fill-zinc-500 dark:fill-zinc-400"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</text>
|
||||||
|
<% end %>
|
||||||
|
</svg>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp max_value(data) do
|
||||||
|
{_, max} = Enum.max_by(data, fn {_, value} -> value end)
|
||||||
|
max
|
||||||
|
end
|
||||||
|
|
||||||
|
defp calculate_bar_height(data, available_height) do
|
||||||
|
bar_count = length(data)
|
||||||
|
max_height = min(30, (available_height - (bar_count - 1) * 4) / bar_count)
|
||||||
|
max(20, max_height) |> trunc()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp x_axis_values(max_value, width) do
|
||||||
|
step = max_value / 5
|
||||||
|
|
||||||
|
0..5
|
||||||
|
|> Enum.map(fn i ->
|
||||||
|
value = i * step
|
||||||
|
x = i * width / 5
|
||||||
|
{trunc(value), trunc(x)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp truncate_label(label, max_length) when byte_size(label) > max_length do
|
||||||
|
String.slice(label, 0, max_length - 2) <> ".."
|
||||||
|
end
|
||||||
|
|
||||||
|
defp truncate_label(label, _), do: label
|
||||||
|
end
|
||||||
@@ -3,6 +3,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do
|
|||||||
|
|
||||||
import MusicLibraryWeb.DataComponents
|
import MusicLibraryWeb.DataComponents
|
||||||
import MusicLibraryWeb.RecordComponents, only: [format_label: 1, type_label: 1]
|
import MusicLibraryWeb.RecordComponents, only: [format_label: 1, type_label: 1]
|
||||||
|
import MusicLibraryWeb.ChartComponents
|
||||||
|
|
||||||
alias MusicLibrary.{Artists, Collection, Records, Wishlist}
|
alias MusicLibrary.{Artists, Collection, Records, Wishlist}
|
||||||
|
|
||||||
@@ -28,7 +29,9 @@ defmodule MusicLibraryWeb.StatsLive.Index do
|
|||||||
scrobble_activity_mode: :albums,
|
scrobble_activity_mode: :albums,
|
||||||
latest_record: latest_record,
|
latest_record: latest_record,
|
||||||
page_title: gettext("Stats"),
|
page_title: gettext("Stats"),
|
||||||
nav_section: :stats
|
nav_section: :stats,
|
||||||
|
records_by_artist: Records.count_records_by_artist(),
|
||||||
|
records_by_genre: Records.count_records_by_genre()
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,38 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-base lg:text-2xl text-zinc-900 dark:text-zinc-200 font-semibold">
|
||||||
|
{gettext("Records by Artist")}
|
||||||
|
</h1>
|
||||||
|
<div class="mt-5 bg-white dark:bg-zinc-800 rounded-md shadow-sm p-4">
|
||||||
|
<.vertical_bar_chart
|
||||||
|
data={@records_by_artist}
|
||||||
|
width={600}
|
||||||
|
height={600}
|
||||||
|
bar_color="rgb(79, 70, 229)"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="text-base lg:text-2xl text-zinc-900 dark:text-zinc-200 font-semibold">
|
||||||
|
{gettext("Records by Genre")}
|
||||||
|
</h1>
|
||||||
|
<div class="mt-5 bg-white dark:bg-zinc-800 rounded-md shadow-sm p-4">
|
||||||
|
<.vertical_bar_chart
|
||||||
|
data={@records_by_genre}
|
||||||
|
width={600}
|
||||||
|
height={600}
|
||||||
|
bar_color="rgb(244, 63, 94)"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flow-root">
|
<div class="flow-root">
|
||||||
<div class="mt-5 flex justify-between items-center">
|
<div class="mt-5 flex justify-between items-center">
|
||||||
<h1 class="text-base lg:text-2xl text-zinc-900 dark:text-zinc-200 font-semibold">
|
<h1 class="text-base lg:text-2xl text-zinc-900 dark:text-zinc-200 font-semibold">
|
||||||
|
|||||||
@@ -691,3 +691,13 @@ msgstr ""
|
|||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Tracks"
|
msgid "Tracks"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/live/stats_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Records by Artist"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/live/stats_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Records by Genre"
|
||||||
|
msgstr ""
|
||||||
|
|||||||
Reference in New Issue
Block a user