Packages
noora
0.11.2
0.84.1
0.84.0
0.83.2
0.83.1
0.83.0
0.82.7
0.82.6
0.82.5
0.82.4
0.82.3
0.82.2
0.82.1
0.82.0
0.81.4
0.81.3
0.81.2
0.81.1
0.81.0
0.80.1
0.80.0
0.79.1
0.79.0
0.78.1
0.78.0
0.77.4
0.77.3
0.77.2
0.77.1
0.77.0
0.76.0
0.75.0
0.74.0
0.73.0
0.72.0
0.71.0
0.70.0
0.69.1
0.69.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.2
0.64.1
0.64.0
0.63.2
0.63.1
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.1
0.53.0
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.3
0.44.2
0.44.1
0.44.0
0.43.0
0.42.0
0.41.0
0.40.6
0.40.5
0.40.4
0.40.3
0.40.2
0.40.1
0.40.0
0.39.1
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.1
0.32.0
0.31.0
0.30.0
0.29.2
0.29.1
0.29.0
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.0
0.26.1
0.26.0
0.25.0
0.24.0
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.1
0.12.0
0.11.2
0.11.1
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.0
0.1.0
0.1.0-rc.2
0.1.0-rc.1
0.1.0-alpha.6
0.1.0-alpha.5
0.1.0-alpha.4
0.1.0-alpha.3
0.1.0-alpha.2
0.1.0-alpha.1
A component library for Phoenix LiveView applications
Current section
Files
Jump to
Current section
Files
lib/noora/chart.ex
defmodule Noora.Chart do
@moduledoc """
A powerful charting component powered by ECharts.
## Example
```elixir
<.chart
id="revenue-chart"
type="line"
series={[10, 20, 30, 40]}
labels={["Q1", "Q2", "Q3", "Q4"]}
title="Quarterly Revenue"
/>
```
## Custom formatters
For labels, we offer several built-in formatters:
- `formatSeconds`: Formats seconds into human readable time (e.g., "30s", "2m 30s", "1h 5m")
- `formatMilliseconds`: Formats milliseconds into human readable time
- `formatHours`: Formats hours into human readable time (e.g., "30m", "2h 30m", "1d 5h")
- `formatBytes`: Formats bytes into human readable size (e.g., "1.5 KB", "2 MB", "1 GB")
- `toLocaleDate`: Formats dates into locale-specific format
- `firstAndLastDate`: Renders the first and last label only as dates. This is useful for time series charts.
If this does not match your use case, you can also pass a completely custom formatter. This expects a global `nooraChartFormatters` object
with a function named after the formatter you want to use. For example, if you want to use a "first label only" formatter, you can define
a function named `firstLabelOnly` in the `nooraChartFormatters` object as such:
```js
nooraChartFormatters = {
firstLabelOnly: (el) => (value, index) => {
if (index === 0) {
return value;
}
}
};
```
You can use these function-based formatters in your chart configuration as such:
```elixir
%{
extra_options: %{
xAxis: %{
axisLabel: %{
formatter: "fn:firstLabelOnly"
}
}
}
```
"""
use Phoenix.Component
attr(:id, :string, required: true, doc: "The ID used for the chart container")
attr(:type, :string,
default: "bar",
values: ["bar", "line", "pie", "scatter", "radar"],
doc: """
The type of chart to render. Defaults to "bar".
Available types: bar, line, pie, scatter, radar
"""
)
attr(:series, :any,
default: nil,
doc: """
The series data for the chart. Structure depends on chart type:
For bar/line charts:
- A list of values: [10, 20, 30]
- A list of maps with names: [%{name: "Series 1", values: [10, 20, 30]}]
- A list of series with different types: [%{name: "Bar", type: "bar", values: [10, 20, 30]}, %{name: "Line", type: "line", values: [5, 15, 25]}]
- A list of time series: [%{name: "Series 1", data: [["2024-01-01", 10], ["2024-01-02", 20]]}]
For pie charts:
- A list of maps with name and value: [%{name: "A", value: 10}, %{name: "B", value: 20}]
For scatter charts:
- A list of [x, y] points: [[10, 20], [30, 40]]
- A list of maps with series: [%{name: "Series 1", data: [[10, 20], [30, 40]]}]
"""
)
attr(:labels, :list,
default: [],
doc: """
Category labels for the x-axis (for bar/line charts) or data point names (for pie charts).
Example: ["Jan", "Feb", "Mar"]
"""
)
attr(:title, :string,
default: nil,
doc: """
Main title for the chart.
"""
)
attr(:subtitle, :string,
default: nil,
doc: """
Subtitle shown below the main title.
"""
)
attr(:show_legend, :boolean,
default: true,
doc: """
Whether to show the chart legend.
"""
)
attr(:legend_position, :string,
default: "top",
values: ["top", "bottom", "left", "right"],
doc: """
Position of the legend. Options: "top", "bottom", "left", "right"
"""
)
attr(:colors, :list,
default: [
"var:noora-chart-primary",
"var:noora-chart-secondary",
"var:noora-chart-tertiary",
"var:noora-chart-quaternary"
],
doc: """
Custom colors for data series.
The given items can either be a hex string, such as "#4C9AFF", or a CSS variable, prefixed with `var:`, but omitting the `--` prefix,
such as `chart-primary`.
The CSS variable is expected to be on the `:root` element, so the variable would be defined as such:
```css
:root {
--chart-primary: #4C9AFF;
}
```
In addition, the color setting supports the `light-dark()` function, which can be used to automatically resolve the appropriate color
based on the the color scheme. That way, the variable can be defined as such:
```css
:root {
--chart-primary: light-dark(#4C9AFF, #36B37E);
}
```
Example: ["#4C9AFF", "#36B37E", "#FF5630", "#FFAB00", "#6554C0"]
"""
)
attr(:stacked, :boolean,
default: false,
doc: """
For bar/line charts, whether series should be stacked.
"""
)
attr(:show_values, :boolean,
default: false,
doc: """
Whether to show data values directly on the chart.
"""
)
attr(:smooth, :boolean,
default: false,
doc: """
For line charts, whether to use smooth curves instead of straight lines.
"""
)
attr(:donut, :boolean,
default: false,
doc: """
For pie charts, whether to render as a donut chart.
"""
)
attr(:donut_radius, :list,
default: ["50%", "70%"],
doc: """
For donut charts, the inner and outer radius. Default: ["50%", "70%"]
"""
)
attr(:x_axis_name, :string,
default: nil,
doc: """
Name for the X axis. Example: "Month"
"""
)
attr(:y_axis_name, :string,
default: nil,
doc: """
Name for the Y axis. Example: "Revenue ($)"
"""
)
attr(:x_axis_min, :integer,
default: nil,
doc: """
Minimum value for the X axis. Example: 0
"""
)
attr(:x_axis_max, :integer,
default: nil,
doc: """
Maximum value for the X axis. Example: 100
"""
)
attr(:y_axis_min, :integer,
default: nil,
doc: """
Minimum value for the Y axis. Example: 0
"""
)
attr(:y_axis_max, :integer,
default: nil,
doc: """
Maximum value for the Y axis. Example: 100
"""
)
attr(:grid_lines, :boolean,
default: true,
doc: """
Whether to show grid lines on the chart.
"""
)
attr(:horizontal, :boolean,
default: false,
doc: """
Whether to display a horizontal bar chart (with category axis on y-axis).
"""
)
attr(:bar_width, :integer,
default: nil,
doc: """
Width of bars in bar charts (in pixels).
"""
)
attr(:bar_radius, :integer,
default: nil,
doc: """
Border radius of bars in bar charts (in pixels).
"""
)
attr(:extra_options, :map,
default: %{},
doc: """
Additional ECharts options for advanced customization.
These will be deeply merged with options generated from other attributes.
"""
)
attr(:rest, :global, doc: "Additional HTML attributes to add to the container div")
def chart(assigns) do
# Load chart type specific defaults and build the complete chart options
type_defaults = get_type_defaults(assigns.type)
chart_options = build_options(assigns, type_defaults)
assigns = assign(assigns, option: chart_options)
~H"""
<div id={@id} class="noora-chart" phx-hook="NooraChart" {@rest}>
{# The actual chart is managed by ECharts, so we are ignoring any updates by LiveView. The chart should be updated by changing the component attributes. #}
<div id={"#{@id}-chart"} data-part="chart" phx-update="ignore"></div>
<div data-part="data" hidden>{Jason.encode!(@option)}</div>
</div>
"""
end
# Main function to build all chart options by combining defaults with user-provided configuration
defp build_options(assigns, type_defaults) do
# Start with default options and merge with chart type-specific defaults
base_options = DeepMerge.deep_merge(default(), type_defaults)
# Extract specific attributes to avoid passing full assigns map to helper functions
title = assigns.title
subtitle = assigns.subtitle
chart_type = assigns.type
show_legend = assigns.show_legend
legend_position = assigns.legend_position
colors = assigns.colors
grid_lines = assigns.grid_lines
horizontal = assigns.horizontal
labels = assigns.labels
# Axis configuration
axis_config = %{
x_axis_name: assigns.x_axis_name,
y_axis_name: assigns.y_axis_name,
x_axis_min: assigns.x_axis_min,
x_axis_max: assigns.x_axis_max,
y_axis_min: assigns.y_axis_min,
y_axis_max: assigns.y_axis_max,
extra_options: assigns.extra_options,
horizontal: horizontal,
labels: labels
}
# Series configuration
series_config = %{
stacked: assigns.stacked,
show_values: assigns.show_values,
smooth: assigns.smooth,
donut: assigns.donut,
donut_radius: assigns.donut_radius,
bar_width: assigns.bar_width,
bar_radius: assigns.bar_radius,
horizontal: horizontal
}
# Build options by adding each component
custom_options =
%{}
|> add_title_options(title, subtitle)
|> add_legend_options(show_legend, legend_position)
|> add_tooltip_options(chart_type)
|> add_colors(colors)
|> add_grid_options(grid_lines)
|> add_axis_options(chart_type, axis_config)
|> add_series_options(chart_type, assigns.series, labels, series_config)
# Merge the base options with custom options and extra options
merged_options =
base_options
|> DeepMerge.deep_merge(custom_options)
|> DeepMerge.deep_merge(Map.delete(assigns.extra_options, :series))
# Apply bar radius to all bar series if it's set
merged_options =
if chart_type == "bar" && assigns.bar_radius do
apply_bar_radius_to_series(merged_options, assigns.bar_radius)
else
merged_options
end
merged_options
end
# Adds chart title and subtitle if provided
defp add_title_options(options, title, subtitle) do
title_opts =
%{}
|> maybe_add_to_map(:text, title)
|> maybe_add_to_map(:subtext, subtitle)
if map_size(title_opts) > 0 do
Map.put(options, :title, title_opts)
else
options
end
end
# Configures the legend display and position
defp add_legend_options(options, show_legend, legend_position) do
if show_legend do
legend_position =
case legend_position do
"top" -> %{top: "0", left: "center"}
"bottom" -> %{bottom: "0", left: "center"}
"left" -> %{left: "0", top: "center", orient: "vertical"}
"right" -> %{right: "0", top: "center", orient: "vertical"}
_ -> %{top: "0", left: "center"}
end
Map.put(options, :legend, legend_position)
else
Map.put(options, :legend, %{show: false})
end
end
# Sets tooltip trigger based on chart type (item for pie, axis for others)
defp add_tooltip_options(options, chart_type) do
trigger_value = if chart_type == "pie", do: "item", else: "axis"
tooltip = %{
trigger: trigger_value
}
Map.put(options, :tooltip, tooltip)
end
# Adds custom color configuration if provided
defp add_colors(options, colors) do
if length(colors) > 0 do
Map.put(options, :colors, colors)
else
options
end
end
# Configures grid display
defp add_grid_options(options, grid_lines) do
grid = Map.get(options, :grid, %{})
grid =
if grid_lines do
grid
else
Map.put(grid, :show, false)
end
Map.put(options, :grid, grid)
end
# Handles axis configuration based on chart type
defp add_axis_options(options, chart_type, axis_config) do
# Skip axis configuration for pie and radar charts
if chart_type in ["pie", "radar"] do
options
else
x_axis = build_axis_options(:x, axis_config)
y_axis = build_axis_options(:y, axis_config)
# Handle horizontal bar charts by swapping axis types
{x_axis, y_axis} =
if axis_config.horizontal && chart_type == "bar" do
# For horizontal bars, x is value axis, y is category axis
x_axis = Map.put_new(x_axis, :type, "value")
y_axis = Map.put_new(y_axis, :type, "category")
# Add labels to y-axis for horizontal charts
# credo:disable-for-next-line
if length(axis_config.labels) > 0 do
y_axis = Map.put(y_axis, :data, axis_config.labels)
x_axis = Map.delete(x_axis, :data)
{x_axis, y_axis}
else
{x_axis, y_axis}
end
else
# Default orientation: x is category, y is value
x_axis = Map.put_new(x_axis, :type, "category")
y_axis = Map.put_new(y_axis, :type, "value")
{x_axis, y_axis}
end
options
|> Map.put(:xAxis, x_axis)
|> Map.put(:yAxis, y_axis)
end
end
# Builds the axis configuration for either x or y axis
# credo:disable-for-next-line
defp build_axis_options(axis_type, config) do
axis_key = if axis_type == :x, do: :xAxis, else: :yAxis
# Start with any user-provided axis config from extra_options
base_axis = Map.get(config.extra_options, axis_key, %{})
# Add axis name if provided
axis_name = if axis_type == :x, do: config.x_axis_name, else: config.y_axis_name
base_axis = if axis_name, do: Map.put(base_axis, :name, axis_name), else: base_axis
# Add min/max values if provided
min_value = if axis_type == :x, do: config.x_axis_min, else: config.y_axis_min
max_value = if axis_type == :x, do: config.x_axis_max, else: config.y_axis_max
base_axis = if min_value, do: Map.put(base_axis, :min, min_value), else: base_axis
base_axis = if max_value, do: Map.put(base_axis, :max, max_value), else: base_axis
# Add labels as data for the x-axis (when not horizontal)
if axis_type == :x && length(config.labels) > 0 && !config.horizontal do
Map.put(base_axis, :data, config.labels)
else
base_axis
end
end
# Configures series data based on chart type and provided data
defp add_series_options(options, _chart_type, nil, _labels, _config) do
# No series data provided
options
end
defp add_series_options(options, chart_type, series, labels, config) do
processed_series = process_data_for_chart_type(chart_type, series, labels, config)
Map.put(options, :series, processed_series)
end
# Process data for different chart types
defp process_data_for_chart_type(type, series, labels, opts) when type in ["bar", "line"] do
process_bar_line_data(type, series, labels, opts)
end
# Pie chart configuration
defp process_data_for_chart_type("pie", series, _labels, opts) do
[
%{
type: "pie",
radius: if(opts.donut, do: opts.donut_radius, else: "60%"),
data: normalize_pie_data(series),
label: %{
show: opts.show_values
}
}
]
end
# Scatter chart configuration
defp process_data_for_chart_type("scatter", series, _labels, opts) do
normalize_scatter_data(series, opts)
end
# Radar chart configuration
defp process_data_for_chart_type("radar", series, _labels, _opts) do
[%{type: "radar", data: normalize_radar_data(series)}]
end
# Default fallback for other chart types
defp process_data_for_chart_type(_type, series, _labels, _opts) do
# Default fallback - pass through the data for advanced usage
series
end
# Processes data for bar and line charts, handling different input formats
# credo:disable-for-next-line
defp process_bar_line_data(type, series, _labels, opts) do
cond do
# Simple array of numbers: [10, 20, 30]
is_list(series) && series |> List.first() |> is_number() ->
[
build_series_config(%{
type: type,
data: series,
opts: opts
})
]
# Array of series objects: [%{name: "Series 1", values: [10, 20, 30]}]
is_list(series) && series |> List.first() |> is_map() ->
Enum.map(series, fn series_item ->
# Support both 'values' and 'data' keys for consistency
values = series_item[:values] || series_item[:data] || []
# Extract known properties we handle
known_props = [:type, :name, :values, :data, :show_values, :smooth]
# Get all additional series options that should be passed through
additional_options = Map.drop(series_item, known_props)
build_series_config(%{
# Allow mixed chart types (bar/line)
type: series_item[:type] || type,
name: series_item[:name] || "",
data: values,
# Override global setting per series
show_values: series_item[:show_values],
# Override global setting per series
smooth: series_item[:smooth],
additional_options: additional_options,
opts: opts
})
end)
# Fallback for any other data format
true ->
[
build_series_config(%{
type: type,
data: series,
opts: opts
})
]
end
end
# Creates configuration for a single chart series
defp build_series_config(%{type: type, data: data, opts: opts} = config) do
# Base series configuration
series = %{
type: type,
data: data,
stack: if(opts.stacked, do: "total"),
label: %{
show: config[:show_values] || opts.show_values
}
}
# Add line chart specific options
series =
if type == "line" do
series
|> Map.put(:smooth, config[:smooth] || opts.smooth)
# Remove data point dots
|> Map.put(:symbol, "none")
else
series
end
# Add bar chart specific options
series =
if type == "bar" do
series
|> maybe_add_to_map(:barWidth, opts.bar_width)
|> maybe_add_item_style(opts.bar_radius)
else
series
end
# Add series name if provided
series = if config[:name], do: Map.put(series, :name, config[:name]), else: series
# Merge any additional options passed directly from the series item
if config[:additional_options] && map_size(config[:additional_options]) > 0 do
Map.merge(series, config[:additional_options])
else
series
end
end
# Helper to add item style with border radius to bar charts
defp maybe_add_item_style(series, nil), do: series
defp maybe_add_item_style(series, bar_radius) do
item_style = Map.get(series, :itemStyle, %{})
item_style = Map.put(item_style, :borderRadius, bar_radius)
Map.put(series, :itemStyle, item_style)
end
# Apply border radius to all bar series in a chart
defp apply_bar_radius_to_series(options, bar_radius) do
case Map.get(options, :series) do
nil ->
options
series when is_list(series) ->
updated_series =
Enum.map(series, fn series_item ->
# credo:disable-for-next-line
if series_item[:type] == "bar" do
item_style = Map.get(series_item, :itemStyle, %{})
item_style = Map.put(item_style, :borderRadius, bar_radius)
Map.put(series_item, :itemStyle, item_style)
else
series_item
end
end)
Map.put(options, :series, updated_series)
_ ->
options
end
end
# Normalizes pie chart data to expected format
# credo:disable-for-next-line
defp normalize_pie_data(series) when is_list(series) do
cond do
# Series already in correct format: [%{name: "A", value: 10}]
series |> List.first() |> is_map() && Map.has_key?(List.first(series), :value) ->
series
# Series in different format: [%{name: "A", count: 10}]
series |> List.first() |> is_map() ->
Enum.map(series, fn item ->
%{
name: item[:name] || item[:label] || "",
value: item[:value] || item[:count] || 0
}
end)
# Just values without names: [10, 20, 30]
series |> List.first() |> is_number() ->
Enum.with_index(series, fn value, index ->
%{name: "Item #{index + 1}", value: value}
end)
# Fallback for any other format
true ->
series
end
end
# Normalizes scatter chart data to expected format
defp normalize_scatter_data(series, _opts) when is_list(series) do
cond do
# Multiple series: [%{name: "Series 1", data: [[10, 20], [30, 40]]}]
series |> List.first() |> is_map() &&
(Map.has_key?(List.first(series), :data) || Map.has_key?(List.first(series), :values)) ->
Enum.map(series, fn series_item ->
values = series_item[:data] || series_item[:values] || []
%{
type: "scatter",
name: series_item[:name] || "",
data: values
}
end)
# Direct array of points: [[10, 20], [30, 40]]
series |> List.first() |> is_list() ->
[
%{
type: "scatter",
data: series
}
]
# Fallback for any other format
true ->
[%{type: "scatter", data: series}]
end
end
# Normalizes radar chart data to expected format
defp normalize_radar_data(series) when is_list(series) do
if series |> List.first() |> is_map() do
series
else
[%{value: series, name: "Series"}]
end
end
# Utility to conditionally add a key-value pair to a map
defp maybe_add_to_map(map, _key, nil), do: map
defp maybe_add_to_map(map, _key, ""), do: map
defp maybe_add_to_map(map, key, value), do: Map.put(map, key, value)
# Default base configuration for all charts
defp default do
%{
grid: %{
containLabel: true
},
tooltip: %{
show: true
},
xAxis: %{
axisLabel: %{fontSize: 10},
splitLine: %{
lineStyle: %{
type: "dashed"
}
},
# Hide axis line and ticks by default
axisLine: %{show: false},
axisTick: %{show: false}
},
yAxis: %{
axisLabel: %{fontSize: 10},
splitLine: %{
lineStyle: %{
type: "dashed"
}
},
# Hide axis line and ticks by default
axisLine: %{show: false},
axisTick: %{show: false}
},
textStyle: %{
fontFamily: "Geist Mono"
}
}
end
# Chart type specific default configurations
defp get_type_defaults("scatter") do
%{
xAxis: %{
axisLabel: %{fontSize: 10},
# Scale axes to data range rather than starting at zero
scale: true
},
yAxis: %{
axisLabel: %{fontSize: 10},
scale: true
}
}
end
defp get_type_defaults("radar") do
%{
radar: %{
shape: "circle",
# Will be populated with data categories
indicator: []
}
}
end
defp get_type_defaults(_) do
%{}
end
end