Refactor charts to use plain HTML
More responsive, less edge cases, and simpler to maintain.
This commit is contained in:
@@ -285,7 +285,7 @@ All authenticated routes live inside a single `live_session` with three `on_moun
|
|||||||
| `CoreComponents` | Forms, buttons, icons, tables, flash messages |
|
| `CoreComponents` | Forms, buttons, icons, tables, flash messages |
|
||||||
| `Layouts` | Application layout templates, navigation components (`dropdown_nav/1`) |
|
| `Layouts` | Application layout templates, navigation components (`dropdown_nav/1`) |
|
||||||
| `RecordComponents` | Record cards, cover images, artist images, labels, grids, shared show-page sections (title, external links, genres, releases, timestamps, debug) |
|
| `RecordComponents` | Record cards, cover images, artist images, labels, grids, shared show-page sections (title, external links, genres, releases, timestamps, debug) |
|
||||||
| `ChartComponents` | SVG charts for stats |
|
| `ChartComponents` | Charts for stats dashboard |
|
||||||
| `StatsComponents` | Stats dashboard widgets |
|
| `StatsComponents` | Stats dashboard widgets |
|
||||||
| `ScrobbleComponents` | Scrobble activity displays |
|
| `ScrobbleComponents` | Scrobble activity displays |
|
||||||
| `SearchComponents` | Search result rendering |
|
| `SearchComponents` | Search result rendering |
|
||||||
|
|||||||
@@ -1,24 +1,29 @@
|
|||||||
defmodule MusicLibraryWeb.ChartComponents do
|
defmodule MusicLibraryWeb.ChartComponents do
|
||||||
|
@moduledoc """
|
||||||
|
Chart components for the stats dashboard.
|
||||||
|
"""
|
||||||
|
|
||||||
use MusicLibraryWeb, :live_component
|
use MusicLibraryWeb, :live_component
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a horizontal bar chart.
|
Renders a horizontal bar chart using CSS Grid.
|
||||||
|
|
||||||
|
The label column auto-sizes to the widest label (up to 130px max),
|
||||||
|
bars fill remaining space proportionally, and values are displayed
|
||||||
|
to the right of each bar.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
<.vertical_bar_chart
|
<.vertical_bar_chart
|
||||||
data={[{"Artist 1", 5}, {"Artist 2", 3}]}
|
data={[{"Artist 1", 5}, {"Artist 2", 3}]}
|
||||||
label_fn={&elem(&1, 0)}
|
label_fn={&elem(&1, 0)}
|
||||||
value_fn={&elem(&1, 1)}
|
value_fn={&elem(&1, 1)}
|
||||||
width={400}
|
color_class="bg-red-500"
|
||||||
height={300}
|
|
||||||
color_class="fill-red-500"
|
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
attr :data, :list, required: true
|
attr :data, :list, required: true
|
||||||
attr :label_fn, :any, required: true
|
attr :label_fn, :any, required: true
|
||||||
attr :value_fn, :any, required: true
|
attr :value_fn, :any, required: true
|
||||||
attr :width, :integer, default: 400
|
|
||||||
attr :height, :integer, default: 300
|
|
||||||
attr :color_class, :string, required: true
|
attr :color_class, :string, required: true
|
||||||
attr :class, :string, default: ""
|
attr :class, :string, default: ""
|
||||||
attr :datum_click, :any, default: nil, doc: "the function for handling phx-click on each datum"
|
attr :datum_click, :any, default: nil, doc: "the function for handling phx-click on each datum"
|
||||||
@@ -26,71 +31,38 @@ defmodule MusicLibraryWeb.ChartComponents do
|
|||||||
def vertical_bar_chart(assigns) when assigns.data != [] do
|
def vertical_bar_chart(assigns) when assigns.data != [] do
|
||||||
assigns =
|
assigns =
|
||||||
assigns
|
assigns
|
||||||
|> assign(:padding, 40)
|
|
||||||
|> assign(:max_value, max_value(assigns.data, assigns.value_fn))
|
|> assign(:max_value, max_value(assigns.data, assigns.value_fn))
|
||||||
# Account for labels and padding
|
|
||||||
|> assign(:chart_width, assigns.width - 150 - 40)
|
|
||||||
# Account for bottom padding
|
|
||||||
|> assign(:chart_height, assigns.height - 40)
|
|
||||||
|> assign(:bar_height, calculate_bar_height(assigns.data, assigns.height - 40))
|
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div class={["w-full", @class]}>
|
<div class={["w-full p-4", @class]}>
|
||||||
<svg
|
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-1.5">
|
||||||
viewBox={"0 0 #{@width} #{@height}"}
|
<%= for datum <- @data do %>
|
||||||
preserveAspectRatio="xMidYMid meet"
|
<% percentage = bar_percentage(@value_fn.(datum), @max_value) %>
|
||||||
class="size-full"
|
<% label = to_string(@label_fn.(datum)) %>
|
||||||
>
|
|
||||||
<%!-- Bars and labels --%>
|
|
||||||
<%= for {datum, index} <- Enum.with_index(@data) do %>
|
|
||||||
<% bar_width = @chart_width * @value_fn.(datum) / @max_value %>
|
|
||||||
<% y = @padding / 2 + index * (@bar_height + 4) %>
|
|
||||||
|
|
||||||
<%!-- Label --%>
|
<%= if @datum_click do %>
|
||||||
<text
|
<div
|
||||||
x="140"
|
class="group col-span-3 grid grid-cols-subgrid cursor-pointer items-center"
|
||||||
y={y + @bar_height / 2 + 4}
|
phx-click={@datum_click.(datum)}
|
||||||
text-anchor="end"
|
>
|
||||||
class={[
|
<.bar_row
|
||||||
"fill-zinc-500 text-xs font-medium hover:fill-zinc-700 dark:fill-zinc-400 dark:hover:fill-zinc-200",
|
label={label}
|
||||||
@datum_click && "cursor-pointer"
|
percentage={percentage}
|
||||||
]}
|
value={@value_fn.(datum)}
|
||||||
phx-click={@datum_click && @datum_click.(datum)}
|
color_class={@color_class}
|
||||||
>
|
grouped
|
||||||
{truncate_label(@label_fn.(datum), 20)}
|
/>
|
||||||
</text>
|
</div>
|
||||||
|
<% else %>
|
||||||
<%!-- Bar --%>
|
<.bar_row
|
||||||
<rect
|
label={label}
|
||||||
x="150"
|
percentage={percentage}
|
||||||
y={y}
|
value={@value_fn.(datum)}
|
||||||
width={bar_width}
|
color_class={@color_class}
|
||||||
height={@bar_height}
|
/>
|
||||||
rx="4"
|
<% end %>
|
||||||
class={[
|
|
||||||
"opacity-80 transition-opacity hover:opacity-100",
|
|
||||||
@color_class,
|
|
||||||
@datum_click && "cursor-pointer"
|
|
||||||
]}
|
|
||||||
phx-click={@datum_click && @datum_click.(datum)}
|
|
||||||
>
|
|
||||||
<title>{@label_fn.(datum)}: {@value_fn.(datum)}</title>
|
|
||||||
</rect>
|
|
||||||
|
|
||||||
<%!-- Value label --%>
|
|
||||||
<text
|
|
||||||
x={150 + bar_width + 5}
|
|
||||||
y={y + @bar_height / 2 + 4}
|
|
||||||
class={[
|
|
||||||
"fill-zinc-500 text-xs font-semibold dark:fill-zinc-400",
|
|
||||||
@datum_click && "cursor-pointer"
|
|
||||||
]}
|
|
||||||
phx-click={@datum_click && @datum_click.(datum)}
|
|
||||||
>
|
|
||||||
{@value_fn.(datum)}
|
|
||||||
</text>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</svg>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
@@ -103,20 +75,46 @@ defmodule MusicLibraryWeb.ChartComponents do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
attr :label, :string, required: true
|
||||||
|
attr :percentage, :float, required: true
|
||||||
|
attr :value, :any, required: true
|
||||||
|
attr :color_class, :string, required: true
|
||||||
|
attr :grouped, :boolean, default: false
|
||||||
|
|
||||||
|
defp bar_row(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"max-w-[130px] truncate text-right text-xs font-medium",
|
||||||
|
"text-zinc-500 dark:text-zinc-400",
|
||||||
|
if(@grouped, do: "group-hover:text-zinc-700 dark:group-hover:text-zinc-200")
|
||||||
|
]}
|
||||||
|
title={@label}
|
||||||
|
>
|
||||||
|
{@label}
|
||||||
|
</div>
|
||||||
|
<div class="h-5 rounded bg-zinc-200 dark:bg-zinc-700">
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"h-full rounded opacity-80 transition-opacity",
|
||||||
|
@color_class,
|
||||||
|
if(@grouped, do: "group-hover:opacity-100")
|
||||||
|
]}
|
||||||
|
style={"width: #{@percentage}%"}
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs font-semibold text-zinc-500 dark:text-zinc-400">
|
||||||
|
{@value}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
defp max_value(data, value_fn) do
|
defp max_value(data, value_fn) do
|
||||||
max = Enum.max_by(data, value_fn)
|
max = Enum.max_by(data, value_fn)
|
||||||
value_fn.(max)
|
value_fn.(max)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp calculate_bar_height(data, available_height) do
|
defp bar_percentage(_value, max_value) when max_value == 0, do: 0.0
|
||||||
bar_count = length(data)
|
defp bar_percentage(value, max_value), do: value / max_value * 100
|
||||||
max_height = min(15, (available_height - (bar_count - 1) * 4) / bar_count)
|
|
||||||
max(20, max_height) |> trunc()
|
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -300,9 +300,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do
|
|||||||
<div class="mt-5 rounded-md bg-white shadow-sm dark:bg-zinc-800">
|
<div class="mt-5 rounded-md bg-white shadow-sm dark:bg-zinc-800">
|
||||||
<.vertical_bar_chart
|
<.vertical_bar_chart
|
||||||
data={@records_by_artist}
|
data={@records_by_artist}
|
||||||
width={600}
|
color_class="bg-red-500"
|
||||||
height={26 * length(@records_by_artist)}
|
|
||||||
color_class="fill-red-500"
|
|
||||||
label_fn={fn datum -> datum.name end}
|
label_fn={fn datum -> datum.name end}
|
||||||
value_fn={fn datum -> datum.count end}
|
value_fn={fn datum -> datum.count end}
|
||||||
datum_click={
|
datum_click={
|
||||||
@@ -322,9 +320,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do
|
|||||||
<div class="mt-5 rounded-md bg-white shadow-sm dark:bg-zinc-800">
|
<div class="mt-5 rounded-md bg-white shadow-sm dark:bg-zinc-800">
|
||||||
<.vertical_bar_chart
|
<.vertical_bar_chart
|
||||||
data={@records_by_genre}
|
data={@records_by_genre}
|
||||||
width={600}
|
color_class="bg-zinc-500"
|
||||||
height={26 * length(@records_by_genre)}
|
|
||||||
color_class="fill-zinc-500"
|
|
||||||
label_fn={fn {genre, _count} -> genre end}
|
label_fn={fn {genre, _count} -> genre end}
|
||||||
value_fn={fn {_genre, count} -> count end}
|
value_fn={fn {_genre, count} -> count end}
|
||||||
datum_click={
|
datum_click={
|
||||||
@@ -344,9 +340,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do
|
|||||||
<div class="mt-5 rounded-md bg-white shadow-sm dark:bg-zinc-800">
|
<div class="mt-5 rounded-md bg-white shadow-sm dark:bg-zinc-800">
|
||||||
<.vertical_bar_chart
|
<.vertical_bar_chart
|
||||||
data={@records_by_release_year}
|
data={@records_by_release_year}
|
||||||
width={600}
|
color_class="bg-red-500"
|
||||||
height={26 * length(@records_by_release_year)}
|
|
||||||
color_class="fill-red-500"
|
|
||||||
label_fn={fn {year, _count} -> year end}
|
label_fn={fn {year, _count} -> year end}
|
||||||
value_fn={fn {_year, count} -> count end}
|
value_fn={fn {_year, count} -> count end}
|
||||||
datum_click={
|
datum_click={
|
||||||
|
|||||||
Reference in New Issue
Block a user