Metadata-Version: 2.4
Name: orama
Version: 1.4.0
Summary: Declarative, metadata-driven charting library for Python
Project-URL: Repository, https://gitlab.com/Kencho1/orama
Project-URL: Issues, https://gitlab.com/Kencho1/orama/-/issues
Author: Jesús Alonso Abad
License: MIT License
        
        Copyright (c) 2026 Jesús Alonso Abad
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
License-File: LICENSE
Keywords: charts,dataframes,declarative,plotly,polars,visualization
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering :: Visualization
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Requires-Dist: armonia~=1.2
Requires-Dist: babel~=2.17
Requires-Dist: fields-metadata~=1.6
Requires-Dist: pandas~=2.3
Requires-Dist: plotly[express]~=6.3
Requires-Dist: polars~=1.33
Requires-Dist: polychromos~=1.4
Requires-Dist: pyarrow~=22.0
Requires-Dist: pycountry>=24.6
Requires-Dist: readable-number~=0.1
Requires-Dist: therismos~=1.0
Provides-Extra: demo
Requires-Dist: flask-wtf~=1.2; extra == 'demo'
Requires-Dist: flask~=3.1; extra == 'demo'
Requires-Dist: google-genai; extra == 'demo'
Requires-Dist: wtforms~=3.2; extra == 'demo'
Provides-Extra: docs
Requires-Dist: sphinx~=8.0; extra == 'docs'
Provides-Extra: test
Requires-Dist: mypy~=1.8; extra == 'test'
Requires-Dist: pytest-cov~=6.0; extra == 'test'
Requires-Dist: pytest~=8.3; extra == 'test'
Requires-Dist: ruff; extra == 'test'
Requires-Dist: tox-uv~=1.0; extra == 'test'
Requires-Dist: tox~=4.0; extra == 'test'
Provides-Extra: web
Requires-Dist: wtforms~=3.2; extra == 'web'
Description-Content-Type: text/markdown

# orama

> _**όραμα**_
>
> Greek; noun
>
> Vision, goal.

Declarative, metadata-driven charting library for Python

`orama` produces Plotly figures from structured Polars DataFrames using a pluggable strategy
pattern, `fields-metadata`-driven field discovery, `therismos`-based query descriptors, and
composable color strategies (using `polychromos` and `armonia`). It is designed to be embedded 
in any Python application regardless of domain model or data-access layer.

---

## Overview

`orama` sits between your data layer and your presentation layer. You describe *which* fields
to group and aggregate; `orama` builds the corresponding `QueryParams` for you to execute,
then turns the resulting Polars DataFrames into fully themed, i18n-aware Plotly figures.

### What orama does

- Provides a catalogue of chart strategies: bar, grouped bar, line, categorical line, pie,
  sunburst, heatmap, boxplot, grouped boxplot, and choropleth map.
- Derives query parameters (group-by fields, aggregations, expected schema) directly from the
  strategy configuration.
- Renders themed, i18n-aware `go.Figure` objects from the query results you supply.

### What orama does NOT do

- Execute queries or fetch data — callers are responsible for running the queries and returning
  the results as Polars DataFrames.
- Provide a web layer — dynamic WTForms form generation and HTTP serialization are available
  via `orama[web]` (`orama.web`).

---

## Installation

```bash
pip install orama
```

**Requirements:** Python ≥ 3.11

**Optional extras:**

| Extra | Installs |
|-------|----------|
| `orama[web]` | WTForms (web binding for `orama.web`) |
| `orama[demo]` | `orama[web]` + Flask, Flask-WTF (for the demo application) |

---

## Core Concepts

### Three-step workflow

Every chart follows the same workflow regardless of chart type:

```
1. Extract field metadata
       fields-metadata MetadataExtractor
       ──────────────────────────────────►  dict[str, FieldMetadata]
                                                        │
2. Instantiate a FigureStrategy                         │
       FigureStrategy(fields_metadata, ...)  ◄──────────┘
                        │
3. Get query params, execute, build
       strategy.get_query_params()     ──►  dict[str, QueryParams]  (you execute these)
       strategy.build(dfs, tr, theme)  ──►  FigureView  ← honours pre/post callbacks
       strategy.plot(dfs, tr, theme)   ──►  FigureView  ← raw render, no callbacks
```

**Step 1 — Extract field metadata.**
Use `fields-metadata`'s `MetadataExtractor` on your domain model to produce a
`dict[str, FieldMetadata]` (a `FieldsMetadataMap`). This map drives field validation inside
each strategy: categorical fields go to categorical variables, numeric fields go to numeric
variables, and so on.

**Step 2 — Instantiate a strategy.**
Choose the appropriate `FigureStrategy` subclass and pass it the field metadata, your chosen
variables, options, and optional color rules. The constructor validates all inputs immediately.

**Step 3 — Query, then plot.**
Call `get_query_params()` to obtain a `dict[str, QueryParams]`. Execute each query using your
data layer (MongoDB, Polars, SQL — orama is agnostic) and collect the results as Polars
DataFrames in a dict keyed by the same names. Pass that dict to `build(dfs, tr, theme)` to obtain a `FigureView` (recommended — honours any
registered callbacks). Use `plot(dfs, tr, theme)` for a raw render with no callback side-effects.

---

## Quick Start

The following example renders a simple bar chart from a toy Polars DataFrame.

```python
from dataclasses import dataclass

import polars as pl
from babel.support import NullTranslations
from fields_metadata import MetadataExtractor
from polychromos.color import HSLColor

from orama.strategies.bar import BarChartStrategy
from orama.theme import Theme
from orama.variables import Aggregation

# ── Step 1: extract field metadata ────────────────────────────────────────────

@dataclass
class SalesRecord:
    region: str    # categorical — accepted by CategoricalVariable
    revenue: float # numeric    — accepted by NumericalAggregationVariable

fields_metadata = MetadataExtractor(SalesRecord).extract()

# ── Step 2: instantiate the strategy ──────────────────────────────────────────

strategy = BarChartStrategy(
    fields_metadata=fields_metadata,
    category_variables=["region"],
    aggregation=Aggregation(
        name="total_revenue",   # column name in the result DataFrame
        function="sum",         # therismos AggregationFunction value
        field="revenue",        # source field to aggregate
    ),
)

# ── Step 3: get query params, execute, and plot ───────────────────────────────

query_params = strategy.get_query_params()
# query_params["main"].group_fields   == ["region"]
# query_params["main"].aggregations   == [Aggregation(id="total_revenue", ...)]
# query_params["main"].dataframe_schema expresses the expected Polars schema

# Execute query with your data layer; here we build a toy DataFrame directly:
df = pl.DataFrame({
    "region": ["North", "South", "East", "West"],
    "total_revenue": [120.5, 98.3, 145.2, 87.6],
})

theme = Theme(colors={
    "background": HSLColor.from_hex("#ffffff"),
    "primary_color_normal": HSLColor.from_hex("#3b82f6"),
    "secondary_color_normal": HSLColor.from_hex("#f59e0b"),
})

view = strategy.plot(
    dfs={"main": df},
    tr=NullTranslations(),
    theme=theme,
)
view.figure.show()
```

The `Aggregation.function` string must be a valid value of `therismos.grouping.AggregationFunction`
(e.g. `"count"`, `"sum"`, `"average"`, `"min"`, `"max"`). Refer to the therismos documentation
for the complete list.

---

## Labels

Every strategy accepts three universal text parameters and a set of variable-specific label
parameters. All are optional strings that default to `None`; when `None`, each strategy
computes a meaningful default from its configured variables.

| Parameter | Scope | Description |
|-----------|-------|-------------|
| `title` | universal | Figure title. Defaults to a generated description of the chart. |
| `subtitle` | universal | Figure subtitle rendered below the title (Plotly 5.15+). |
| `caption` | universal | Caption intended for `<figcaption>` in an HTML `<figure>` wrapper. Not applied to the Plotly figure — returned in `FigureView.caption`. |
| `description` | universal | Free multiline text explaining how to interpret the figure. Not applied to the Plotly figure — returned in `FigureView.description`. |
| `storytelling_context` | universal | Narrative context for the figure — its goal, target audience, and analytical angle. Not applied to the Plotly figure — returned in `FigureView.storytelling_context`. |
| `category_label` | bar, grouped bar, pie, boxplot | Label for the category axis / legend. |
| `value_label` | bar, grouped bar, line, categorical line, pie, sunburst, heatmap, boxplot, grouped boxplot, choropleth map | Label for the value axis / colorbar. |
| `group_label` | grouped bar, grouped boxplot | Legend title for the grouping variable. |
| `country_label` | choropleth map | Label for the country dimension. |
| `date_label` | line | X-axis label for the date dimension. |
| `category_label` | line | Legend title for the category dimension (only shown when `category_variable` is set). |
| `x_label` | categorical line, heatmap | X-axis label. |
| `series_label` | categorical line | Legend title for the series dimension (only shown when `series_variable` is set). |
| `y_label` | heatmap | Y-axis label. |
| `path_label` | sunburst | Reserved for future use. |

```python
from orama.strategies.bar import BarChartStrategy
from orama.variables import Aggregation

strategy = BarChartStrategy(
    fields_metadata=fields_metadata,
    category_variables=["region"],
    aggregation=Aggregation(name="total_revenue", function="sum", field="revenue"),
    # Universal labels
    title="Revenue by Region",
    subtitle="All regions · Q1 2025",
    caption="Source: Internal sales data. Figures in EUR.",
    description="Revenue is computed as the sum of invoiced amounts net of VAT.\nOnly regions with at least one transaction are shown.",
    # Variable-specific labels
    category_label="Region",
    value_label="Total Revenue (€)",
)

view = strategy.plot(dfs={"main": df}, tr=NullTranslations(), theme=theme)

# Render with caption and/or description
html = view.figure.to_html(full_html=False)
if view.caption:
    html = f"<figure>{html}<figcaption>{view.caption}</figcaption></figure>"
# view.description is available for custom rendering (e.g., a tooltip or popover)
```

### Storytelling context

`storytelling_context` captures the *why* behind a figure: who the audience is, what
question it answers, and what conclusion the analyst wants to convey. It is not rendered
inside the Plotly figure itself — it is returned in `FigureView.storytelling_context` for
use by the caller (e.g. rendered as an aside, fed to an LLM, logged, or displayed as an
annotation outside the chart).

```python
strategy = BarChartStrategy(
    ...,
    storytelling_context=(
        "Audience: regional sales managers. "
        "Goal: identify which regions are underperforming relative to Q1 targets."
    ),
)
view = strategy.build(dfs={"main": df}, tr=tr, theme=theme)
print(view.storytelling_context)
```

A pre-build callback can populate or overwrite `storytelling_context` dynamically — for
example by querying an LLM with the chart data before rendering (see the Callbacks section).

---

### Unit annotations

All strategies recognise `FieldMetadata.extra["unit"]` and automatically append the unit to
the auto-generated default value label. When no explicit `value_label` is provided, the
label becomes `"<default label> (<unit>)"` — for example `"Sum of revenue (EUR)"`.

The unit is **not** appended when the caller provides an explicit `value_label`; the
annotation only affects the fallback.

```python
from fields_metadata import FieldMetadata, MetadataExtractor

metadata = MetadataExtractor().extract(MyModel)

# Annotate numeric fields with their units after extraction
metadata["revenue"].extra["unit"] = "EUR"
metadata["tax_rate"].extra["unit"] = "%"

# Now any strategy using `revenue` as its aggregation field will automatically
# display e.g. "Sum of revenue (EUR)" on the value axis when no explicit
# value_label is passed.
strategy = BarChartStrategy(
    fields_metadata=metadata,
    category_variables=["region"],
    aggregation=Aggregation(name="total_revenue", function="sum", field="revenue"),
)
# value axis label → "Sum of revenue (EUR)"
```

---

## Callbacks

Two optional callbacks can be passed at construction time to hook into the rendering
pipeline. Use `build()` — the recommended entry point — to honour them. `plot()` remains
available as a raw render with no side-effects.

```python
FigureStrategy(
    ...,
    pre_build_callback=fn,   # fires before plot()
    post_build_callback=fn,  # fires after plot()
)
view = strategy.build(dfs, tr, theme)
```

### `pre_build_callback`

Fires **before** `plot()`. Mutations to `payload["dfs"]` change what gets plotted.
Mutations to `payload["title"]`, `payload["subtitle"]`, `payload["caption"]`, and
`payload["description"]` are reflected in the rendered figure and the returned `FigureView`.

**Payload structure:**

```
payload
├── "dfs"                   dict[str, pl.DataFrame]   Replace entries to change what's plotted
├── "strategy"              str                        e.g. "BarChartStrategy"  (read-only)
├── "title"                 str | None                 Assign to override the figure title
├── "subtitle"              str | None                 Assign to override the figure subtitle
├── "caption"               str | None                 Assign to override FigureView.caption
├── "description"           str | None                 Assign to override FigureView.description
├── "storytelling_context"  str | None                 Assign to override FigureView.storytelling_context
├── "color_rules"           list[dict]                 Serialised color rules  (read-only)
├── "variables"             dict[str, dict]            Variable name/description/value map  (read-only)
└── "options"               dict[str, dict]            Option name/description/value map    (read-only)
```

### `post_build_callback`

Fires **after** `plot()`. Can mutate `payload["figure"]` or overwrite
`payload["caption"]` / `payload["description"]` before `FigureView` is returned.

**Payload structure:**

```
payload
├── "figure"                go.Figure
├── "caption"               str | None       Assign to override FigureView.caption
├── "description"           str | None       Assign to override FigureView.description
├── "storytelling_context"  str | None       Assign to override FigureView.storytelling_context
├── "metadata"              dict[str, Any]   Strategy metadata snapshot  (read-only)
│   ├── "strategy", "title", "subtitle", "caption", "description",
│   └── "storytelling_context", "color_rules"
└── "data"
    └── "<query_key>"
        ├── "columns"  list[str]
        └── "rows"     list[dict]
```

### Examples

**Example 1 — pre-build: LLM-generated data storytelling:**

```python
def storytelling_callback(payload):
    rows = payload["dfs"]["main"].to_dicts()
    payload["storytelling_context"] = llm_client.generate(
        f"Write a one-sentence insight for a '{payload['title']}' chart: {rows}"
    )

strategy = BarChartStrategy(
    ...,
    title="Revenue by Region",
    pre_build_callback=storytelling_callback,
)
view = strategy.build(dfs={"main": df}, tr=tr, theme=theme)
# view.storytelling_context is now populated directly from the pre callback
```

**Example 2 — post-build: override caption and description:**

```python
def annotate(payload):
    payload["description"] = "Automatically generated insight."
    payload["caption"] = "Source: internal CRM data."

strategy = BarChartStrategy(..., post_build_callback=annotate)
view = strategy.build(dfs={"main": df}, tr=tr, theme=theme)
# view.caption == "Source: internal CRM data."
```

**Example 3 — pre-build: mutate title and description in one callback:**

```python
def enrich(payload):
    rows = payload["dfs"]["main"].to_dicts()
    payload["title"] = f"Revenue by Region — {len(rows)} entries"
    payload["description"] = llm_client.generate(f"Summarise: {rows}")

strategy = BarChartStrategy(..., pre_build_callback=enrich)
view = strategy.build(dfs={"main": df}, tr=tr, theme=theme)
# The figure title and view.description both reflect the callback mutations
```

---

## Chart Types Reference

| Strategy | Description |
|----------|-------------|
| `BarChartStrategy` | Single-series bar chart (vertical or horizontal) |
| `GroupedBarChartStrategy` | Multi-series grouped or stacked bar chart |
| `LineChartStrategy` | Time-series line chart grouped by calendar year/month |
| `CategoricalLineChartStrategy` | Line chart over an ordered categorical X axis |
| `PieChartStrategy` | Pie / doughnut chart |
| `SunburstChartStrategy` | Multi-level hierarchical sunburst chart |
| `HeatMapChartStrategy` | Two-dimensional categorical heat map |
| `BoxplotChartStrategy` | Boxplot distribution chart per category |
| `GroupedBoxplotChartStrategy` | Side-by-side boxplots grouped by a second categorical variable |
| `ChoroplethMapChartStrategy` | Country-level choropleth world map |

All strategies share the same base constructor signature and expose both `build(dfs, tr, theme)`
(recommended — honours callbacks) and `plot(dfs, tr, theme)` (raw render), so they are fully
interchangeable from the caller's perspective.

---

### BarChartStrategy

Represents categorical data with rectangular bars whose heights are proportional to
the aggregated values. Bars may be vertical or horizontal.

```python
from orama.strategies.bar import BarChartStrategy
from orama.variables import Aggregation
from orama.enums import SortOrder

strategy = BarChartStrategy(
    fields_metadata=fields_metadata,
    category_variables=["region"],          # list[str] — one or more categorical fields
    aggregation=Aggregation(
        name="count",
        function="count",
        field=None,                         # None for count-only aggregations
    ),
    sort_by_value=SortOrder.DESCENDING,     # SortOrder.NONE | ASCENDING | DESCENDING
    horizontal=False,                       # bool — render bars horizontally
    min_value=None,                         # float | None — axis lower bound
    max_value=None,                         # float | None — axis upper bound
    mean_category_name="",                  # str — add a mean bar with this label, or ""
    mean_annotation=False,                  # bool — draw a mean line annotation
    color_rules=None,                       # list[ColorRule] | None
    title=None,                             # str | None — figure title
    subtitle=None,                          # str | None — figure subtitle
    caption=None,                           # str | None — figure caption (for <figcaption>)
    description=None,                       # str | None — interpretive text (not in figure)
    storytelling_context=None,              # str | None — narrative goal / target audience
    category_label=None,                    # str | None — X/Y axis label for the category
    value_label=None,                       # str | None — X/Y axis label for the value
)
```

`get_query_params()` returns a single `"main"` key.  When `mean_category_name` or
`mean_annotation` is set, a `__total_count__` aggregation is added automatically.

**Supported color selectors:** `ColorSelectorStrategy`, `ColorSelectByCategoryName`,
`ColorSelectValuesBelow`, `ColorSelectValuesAbove`, `ColorSelectMaxValue`,
`ColorSelectMinValue`, `ColorSelectValuesBelowMean`, `ColorSelectValuesAboveMean`.

**Supported color assigners:** `ColorAssignerByCategory`, `ColorAssignerByValue`.

---

### GroupedBarChartStrategy

Extends bar charts with a grouping variable that splits each category into separate
sub-bars. Supports stacked, relative (100 %), and horizontal layouts.

```python
from orama.strategies.grouped_bar import GroupedBarChartStrategy

strategy = GroupedBarChartStrategy(
    fields_metadata=fields_metadata,
    category_variables=["region"],
    group_variable="product_line",          # str — single categorical field for groups
    aggregation=Aggregation(name="revenue", function="sum", field="revenue"),
    stacked=False,                          # bool — stack bars instead of grouping
    relative=False,                         # bool — relative % (requires stacked=True)
    horizontal=False,
    color_rules=None,
    title=None,                             # str | None
    subtitle=None,                          # str | None
    caption=None,                           # str | None
    description=None,                       # str | None — interpretive text (not in figure)
    storytelling_context=None,              # str | None — narrative goal / target audience
    category_label=None,                    # str | None — axis label for the category
    value_label=None,                       # str | None — axis label for the value
    group_label=None,                       # str | None — legend title for the grouping variable
    show_legend=True,                       # bool — show the figure legend
)
```

**Supported color selectors:** `ColorSelectorStrategy`, `ColorSelectByCategoryName`.

**Supported color assigners:** `ColorAssignerByGroup`.

---

### LineChartStrategy

Plots one line per category value over a time axis grouped by calendar year and month.
The `date_variable` must be a datetime field whose `__year` and `__month` derived fields
are present in the `fields_metadata` map.

```python
from orama.strategies.line import LineChartStrategy

strategy = LineChartStrategy(
    fields_metadata=fields_metadata,
    date_variable="created_at",             # str — a date or datetime field (DateTimeVariable)
    aggregation=Aggregation(name="count", function="count"),
    category_variable="status",             # str | None — splits data into lines
    min_value=None,
    max_value=None,
    color_rules=None,
    title=None,                             # str | None
    subtitle=None,                          # str | None
    caption=None,                           # str | None
    description=None,                       # str | None — interpretive text (not in figure)
    storytelling_context=None,              # str | None — narrative goal / target audience
    date_label=None,                        # str | None — X-axis label for the date dimension
    value_label=None,                       # str | None — Y-axis label for the value
    category_label=None,                    # str | None — legend title (when category_variable is set)
    show_legend=True,                       # bool — show the figure legend (no effect in ridgeline mode)
)
```

`get_query_params()` groups by `created_at__year`, `created_at__month`, and `status`.

**Supported color selectors:** `ColorSelectorStrategy`, `ColorSelectByCategoryName`.

**Supported color assigners:** `ColorAssignerByCategory`.

---

### CategoricalLineChartStrategy

Plots lines over an ordered categorical X axis (e.g. fiscal quarters, ordinal labels).
Unlike `LineChartStrategy`, the X axis is not calendar-based.

```python
from orama.strategies.categorical_line import CategoricalLineChartStrategy

strategy = CategoricalLineChartStrategy(
    fields_metadata=fields_metadata,
    x_variables=["year", "quarter"],        # list[str] — ordered categorical X axis
    aggregation=Aggregation(name="revenue", function="sum", field="revenue"),
    series_variable="region",              # str | None — optional series split
    color_rules=None,
    title=None,                             # str | None
    subtitle=None,                          # str | None
    caption=None,                           # str | None
    description=None,                       # str | None — interpretive text (not in figure)
    storytelling_context=None,              # str | None — narrative goal / target audience
    x_label=None,                           # str | None — X-axis label
    value_label=None,                       # str | None — Y-axis label for the value
    series_label=None,                      # str | None — legend title (when series_variable is set)
    show_legend=True,                       # bool — show the figure legend (no effect in ridgeline mode)
)
```

**Supported color selectors:** `ColorSelectorStrategy`, `ColorSelectByCategoryName`.

**Supported color assigners:** `ColorAssignerByCategory`.

---

### PieChartStrategy

Circular chart where arc length is proportional to the aggregated value. Setting
`hole > 0` produces a doughnut chart.

```python
from orama.strategies.pie import PieChartStrategy

strategy = PieChartStrategy(
    fields_metadata=fields_metadata,
    category_variables=["region"],
    aggregation=Aggregation(name="count", function="count"),
    sort_by_value=SortOrder.NONE,
    hole=0.4,                          # float in [0, 0.9] — doughnut hole fraction; 0 = full pie
    inner_overall=0.0,                 # float — font size for the overall value in the hole; 0 = hidden
    inner_overall_label=None,          # str | None — text label rendered below the overall value
    inner_overall_human_readable=False, # bool — format overall value as compact shortform (e.g. 1.2k, 3d 12h)
    color_rules=None,
    title=None,         # str | None
    subtitle=None,      # str | None
    caption=None,       # str | None
    description=None,   # str | None — interpretive text (not in figure)
    storytelling_context=None,  # str | None — narrative goal / target audience
    category_label=None,  # str | None — reserved for future legend/hover use
    value_label=None,     # str | None — reserved for future legend/hover use
    show_legend=True,     # bool — show the figure legend
)
```

When `hole > 0` and `inner_overall > 0`, `get_query_params()` returns both a `"main"` and
an `"overall"` key. `inner_overall_label` and `inner_overall_human_readable` only take
effect when `inner_overall > 0`.

**Supported color selectors:** `ColorSelectorStrategy`, `ColorSelectByCategoryName`,
`ColorSelectValuesBelow`, `ColorSelectValuesAbove`, `ColorSelectMaxValue`, `ColorSelectMinValue`.

**Supported color assigners:** `ColorAssignerByCategory`, `ColorAssignerByValue`.

---

### SunburstChartStrategy

Multi-level pie chart (sunburst) for hierarchical data. Each entry in `path_variables`
represents one ring, innermost first.

```python
from orama.strategies.sunburst import SunburstChartStrategy

strategy = SunburstChartStrategy(
    fields_metadata=fields_metadata,
    path_variables=["continent", "country"],  # list[str] — hierarchical path, innermost first
    aggregation=Aggregation(name="count", function="count"),
    sort_by_value=SortOrder.NONE,
    color_rules=None,
    title=None,       # str | None
    subtitle=None,    # str | None
    caption=None,     # str | None
    description=None, # str | None — interpretive text (not in figure)
    storytelling_context=None,  # str | None — narrative goal / target audience
    path_label=None,  # str | None — reserved for future use
    value_label=None, # str | None — reserved for future use
)
```

**Supported color selectors:** `ColorSelectorStrategy`, `ColorSelectByCategoryName`,
`ColorSelectValuesBelow`, `ColorSelectValuesAbove`, `ColorSelectMaxValue`, `ColorSelectMinValue`.

**Supported color assigners:** `ColorAssignerByCategory`, `ColorAssignerByValue`.

---

### HeatMapChartStrategy

Two-dimensional matrix colored by aggregated value. Accepts at most one `ColorRule`.

```python
from orama.strategies.heatmap import HeatMapChartStrategy

strategy = HeatMapChartStrategy(
    fields_metadata=fields_metadata,
    x_category_variables=["month"],
    y_category_variables=["region"],
    aggregation=Aggregation(name="revenue", function="sum", field="revenue"),
    display_values=True,    # bool — annotate each cell with its numeric value
    color_rules=None,
    title=None,         # str | None
    subtitle=None,      # str | None
    caption=None,       # str | None
    description=None,   # str | None — interpretive text (not in figure)
    storytelling_context=None,  # str | None — narrative goal / target audience
    x_label=None,       # str | None — X-axis label
    y_label=None,       # str | None — Y-axis label
    value_label=None,   # str | None — colorbar title
    show_colorbar=True, # bool — show the colorbar
)
```

**Supported color selectors:** `ColorSelectorStrategy` (all entries — select the scale).

**Supported color assigners:** `ColorAssignerByValue` (defines the color scale and optional
range bounds via `range_min`, `range_max`, `range_center_at`).

---

### BoxplotChartStrategy

Statistical distribution chart showing Q1, median, Q3, and computed IQR fences per
category. An optional overall boxplot can be added.

```python
from orama.strategies.boxplot import BoxplotChartStrategy

strategy = BoxplotChartStrategy(
    fields_metadata=fields_metadata,
    category_variables=["region"],
    value_variable="revenue",       # str — a NonDerivedNumericalVariable field
    horizontal=False,
    overall_category_name="",       # str — label for an optional overall boxplot, or ""
    display_mean=False,             # bool — show mean marker
    display_sd=False,               # bool — show standard deviation (requires display_mean)
    color_rules=None,
    title=None,                     # str | None
    subtitle=None,                  # str | None
    caption=None,                   # str | None
    description=None,               # str | None — interpretive text (not in figure)
    storytelling_context=None,      # str | None — narrative goal / target audience
    category_label=None,            # str | None — axis label for the category
    value_label=None,               # str | None — axis label for the value
    show_legend=True,               # bool — show the figure legend
)
```

`get_query_params()` aggregates Q1, median, Q3, mean, standard deviation, min, and max.
When `overall_category_name` is set, a second `"overall"` query is added.

**Supported color selectors:** `ColorSelectorStrategy`, `ColorSelectByCategoryName`,
`ColorSelectValuesBelow`, `ColorSelectValuesAbove`, `ColorSelectMaxValue`,
`ColorSelectMinValue`, `ColorSelectValuesBelowMean`, `ColorSelectValuesAboveMean`.

**Supported color assigners:** `ColorAssignerByCategory`, `ColorAssignerByValue`.

---

### GroupedBoxplotChartStrategy

Extends the standard boxplot with an additional grouping dimension, producing
side-by-side boxplots per category — one per unique value of the grouping variable.

```python
from orama.strategies.grouped_boxplot import GroupedBoxplotChartStrategy

strategy = GroupedBoxplotChartStrategy(
    fields_metadata=fields_metadata,
    category_variables=["region"],      # list[str] — multicategorical
    value_variable="revenue",           # str — NonDerivedNumericalVariable field
    group_variable="product_line",      # str — single categorical field for groups
    horizontal=False,
    display_mean=False,
    display_sd=False,                   # bool — requires display_mean=True
    color_rules=None,
    title=None,                         # str | None
    subtitle=None,                      # str | None
    caption=None,                       # str | None
    description=None,                   # str | None — interpretive text (not in figure)
    storytelling_context=None,          # str | None — narrative goal / target audience
    category_label=None,                # str | None — axis label for the category
    value_label=None,                   # str | None — axis label for the value
    group_label=None,                   # str | None — legend title for the grouping variable
    show_legend=True,                   # bool — show the figure legend
)
```

`get_query_params()` returns a single `"main"` key.

**Supported color selectors:** `ColorSelectorStrategy`, `ColorSelectByCategoryName`.

**Supported color assigners:** `ColorAssignerByGroup`.

---

### ChoroplethMapChartStrategy

Renders country-level data on an interactive world map using ISO 3166-1 alpha-2
country codes. No Mapbox token is required.

```python
from orama.strategies.choropleth_map import ChoroplethMapChartStrategy
from orama.enums import GeoProjection
from orama.variables import Aggregation

strategy = ChoroplethMapChartStrategy(
    fields_metadata=fields_metadata,
    country_variable="country_code",    # str — CountryCodeVariable (ISO 3166-1 alpha-2)
    aggregation=Aggregation(name="count", function="count"),
    map_style=GeoProjection.EQUIRECTANGULAR,  # GeoProjection enum
    fit_bounds=False,                   # bool — auto-zoom to countries with data
    color_rules=None,
    title=None,                         # str | None
    subtitle=None,                      # str | None
    caption=None,                       # str | None
    description=None,                   # str | None — interpretive text (not in figure)
    storytelling_context=None,          # str | None — narrative goal / target audience
    country_label=None,                 # str | None — label for the country dimension
    value_label=None,                   # str | None — colorbar title
    show_colorbar=True,                 # bool — show the colorbar (continuous color mode only)
)
```

`get_query_params()` returns a single `"main"` key.

**Supported color selectors:** `ColorSelectorStrategy`, `ColorSelectByCategoryName`,
`ColorSelectValuesBelow`, `ColorSelectValuesAbove`, `ColorSelectMaxValue`,
`ColorSelectMinValue`, `ColorSelectValuesBelowMean`, `ColorSelectValuesAboveMean`.

**Supported color assigners:** `ColorAssignerByCategory`, `ColorAssignerByValue`.

---

## Variables

Variables describe the roles that DataFrame fields play in a chart. Each strategy
declares a fixed `_variables` map; callers select fields from their metadata to fill
those roles.

### `Aggregation` dataclass

```python
from orama.variables import Aggregation

Aggregation(
    name="revenue_sum",   # str — output column name in the result DataFrame
    function="sum",       # str — therismos AggregationFunction value
    field="revenue",      # str | None — source field; None for count-only aggregations
)
```

### Variable types

| Class | Candidate fields |
|-------|-----------------|
| `CategoricalVariable` | Fields where `FieldMetadata.categorical` is `True` |
| `NumericalVariable` | Fields where `FieldMetadata.numeric` is `True` |
| `NonDerivedNumericalVariable` | Numeric fields that are not derived (`FieldMetadata.derived` is `False`) |
| `NumericalAggregationVariable` | Same as `NonDerivedNumericalVariable`; used as aggregation targets |
| `DateTimeVariable` | `date` or `datetime` fields for which `{field}__year` and `{field}__month` derived fields are present in the metadata map; or standalone integer fields annotated with `suggested_validation` of `"year"` or `"month"` |
| `CountryCodeVariable` | Fields with `extra["suggested_validation"] == "iso_a2"` (ISO 3166-1 alpha-2 country codes) |

All variable types validate the fields passed by the caller at strategy construction time and
raise `ValueError` if an invalid field is supplied.

### Semantic sort ordering

When `sort_by_value` is set to `SortOrder.ASCENDING` or `SortOrder.DESCENDING` and a
category field's `effective_type` is a subclass of `therismos.SemanticallyOrderedEnum`,
the category column is sorted by the enum's declared semantic order (via `semantic_order()`)
rather than lexicographically. This ensures that semantically ordered categories such as
`very small → small → medium → large → very large` are always displayed in their intended
order regardless of alphabetical sort.

---

## Options

Options tune chart appearance and behaviour. Each strategy declares a fixed `_options` map
and validates option values at construction time.

| Class | Python type | Notes |
|-------|-------------|-------|
| `BooleanOption` | `bool` | Default: `False` unless declared otherwise |
| `FloatOption` | `float \| int` | Supports optional `min_value` / `max_value` bounds |
| `IntOption` | `int` | Supports optional `min_value` / `max_value` bounds |
| `StringOption` | `str` | Default: `None` unless declared otherwise |
| `SortOrderOption` | `SortOrder` | Default: `SortOrder.NONE` |
| `LinePlacementOption` | `LinePlacement` | Default: `LinePlacement.OVERLAPPING` |
| `LineInterpolationOption` | `LineInterpolation` | Default: `LineInterpolation.LINEAR` |
| `LineSortOrderOption` | `LineSortOrder` | Default: `LineSortOrder.NONE` |
| `GeoProjectionOption` | `GeoProjection` | Default: `GeoProjection.NATURAL_EARTH` |

---

## Enums

### `SortOrder`

```python
from orama.enums import SortOrder

SortOrder.NONE        # 0 — no sorting applied
SortOrder.ASCENDING   # 1
SortOrder.DESCENDING  # -1
```

### `LinePlacement`

Controls how multiple line series are positioned relative to each other.

```python
from orama.enums import LinePlacement

LinePlacement.OVERLAPPING       # lines drawn on top of each other (default)
LinePlacement.STACKED           # lines stacked additively
LinePlacement.STACKED_RELATIVE  # lines stacked as relative percentages (100 %)
LinePlacement.RIDGELINE         # lines offset vertically (ridgeline / joy plot)
```

### `LineInterpolation`

Controls the interpolation mode between data points on a line trace.

```python
from orama.enums import LineInterpolation

LineInterpolation.LINEAR   # straight segments between points (default)
LineInterpolation.SPLINE   # smooth curved interpolation
LineInterpolation.STEP_HV  # horizontal then vertical step ("hv")
LineInterpolation.STEP_VH  # vertical then horizontal step ("vh")
```

### `LineSortOrder`

Controls how line series or categories are sorted in the legend.

```python
from orama.enums import LineSortOrder

LineSortOrder.NONE              # no sorting (default)
LineSortOrder.ALPHABETICAL_ASC  # alphabetically ascending
LineSortOrder.ALPHABETICAL_DESC # alphabetically descending
LineSortOrder.FIRST_VALUE_ASC   # ascending by first data-point value
LineSortOrder.FIRST_VALUE_DESC  # descending by first data-point value
LineSortOrder.LAST_VALUE_ASC    # ascending by last data-point value
LineSortOrder.LAST_VALUE_DESC   # descending by last data-point value
```

### `GeoProjection`

Controls the map projection used by `ChoroplethMapChartStrategy`.

```python
from orama.enums import GeoProjection

GeoProjection.NATURAL_EARTH    # Natural Earth (default for GeoProjectionOption)
GeoProjection.MERCATOR         # Mercator
GeoProjection.ORTHOGRAPHIC     # Orthographic
GeoProjection.EQUIRECTANGULAR  # Equirectangular (default in ChoroplethMapChartStrategy)
GeoProjection.ROBINSON         # Robinson
GeoProjection.KAVRAYSKIY7      # Kavrayskiy VII
```

---

## Color System

Every color rule is a `ColorRule` — a pair of one **selector** (which rows to affect) and
one **assigner** (what colors to apply to those rows). Rules are applied in order; later
rules override earlier ones for the selected rows.

```python
from orama.color import (
    ColorRule,
    ColorSelectMaxValue,
    ColorAssignerByCategory,
)
from polychromos.color import HSLColor

rule = ColorRule(
    assigner=ColorAssignerByCategory(
        anchor_colors=[HSLColor.from_hex("#ef4444")],
    ),
    selector=ColorSelectMaxValue(),  # highlight only the maximum bar
)

strategy = BarChartStrategy(
    fields_metadata=fields_metadata,
    category_variables=["region"],
    aggregation=Aggregation(name="count", function="count"),
    color_rules=[rule],
)
```

### Selectors

| Class | Selects |
|-------|---------|
| `ColorSelectorStrategy` | All entries (use as first rule to override defaults) |
| `ColorSelectByCategoryName(names)` | Entries whose category name is in `names` |
| `ColorSelectValuesBelow(threshold)` | Entries with value ≤ threshold |
| `ColorSelectValuesAbove(threshold)` | Entries with value ≥ threshold |
| `ColorSelectMinValue()` | Entry/entries with the minimum value |
| `ColorSelectMaxValue()` | Entry/entries with the maximum value |
| `ColorSelectValuesBelowMean()` | Entries with value ≤ weighted mean (bar charts only) |
| `ColorSelectValuesAboveMean()` | Entries with value ≥ weighted mean (bar charts only) |

### Assigners

| Class | Assigns |
|-------|---------|
| `ColorAssignerByCategory(anchor_colors, shuffle=False)` | One color per distinct category, interpolated from anchor colors; `shuffle=True` alternates colors across categories |
| `ColorAssignerByGroup(anchor_colors, shuffle=False)` | One color per distinct group; `shuffle=True` alternates colors across groups |
| `ColorAssignerByValue(anchor_colors, range_min, range_max, range_center_at)` | Continuous color scale mapped to values; optionally centered at a pivot |

`anchor_colors` is an `HSLColorSequence` from `polychromos`. When two colors are provided and
more entries exist, colors are interpolated via cylindrical shortest-path. When more than two
colors are provided, a multi-stop color scale is constructed.

Each strategy documents which selectors and assigners it accepts. Passing an unsupported
combination raises `ValueError` at construction time.

---

## Theming

`Theme` wraps an armonia `Theme` and exposes colors and fonts to chart strategies.

```python
from orama.theme import Theme
from polychromos.color import HSLColor

theme = Theme(colors={
    # Required for all charts
    "background":              HSLColor.from_hex("#1e293b"),
    "primary_color_normal":    HSLColor.from_hex("#3b82f6"),
    # Optional — used by strategies and computed color variants
    "secondary_color_normal":  HSLColor.from_hex("#f59e0b"),
    "accent_color_normal":     HSLColor.from_hex("#10b981"),
    # Optional — semantic sentiment colors
    "positive_color_normal":   HSLColor.from_hex("#22c55e"),
    "neutral_color_normal":    HSLColor.from_hex("#94a3b8"),
    "negative_color_normal":   HSLColor.from_hex("#ef4444"),
})
```

When `"background"` is provided, the following computed colors are automatically registered:

- `background_inverted`, `background_inverted_mild`, `background_inverted_milder`, `background_inverted_faint`
- `{prefix}_color_softer`, `{prefix}_color_stronger` for each of `primary`, `secondary`, `accent`, `positive`, `neutral`, `negative`

When `"primary_color_normal"` is provided, the Plotly template's `sequential` and
`sequentialminus` color scales and the figure `colorway` are automatically derived from the
primary palette via cylindrical interpolation.

When `"positive_color_normal"`, `"neutral_color_normal"`, and `"negative_color_normal"` are
all provided, a `diverging` color scale is also generated automatically.

Use `theme.get_color(name)` to retrieve any named or computed color as an `HSLColor`.
Use `theme.get_all_colors()` to list all registered colors.

The theme is passed to `strategy.plot(dfs, tr, theme)` and applied to every figure via a
Plotly template derived from `plotly_white`.

### Fonts

`Theme` manages a set of named fonts via armonia's font system. The following fonts are
registered automatically on every theme:

| Name | Kind | Description |
|---|---|---|
| `title` | manual | Figure title — base font (default: Open Sans 17px weight 600) |
| `subtitle` | computed | 70% the size of `title` |
| `axis_tick` | computed | 60% the size of `title` — applied to x/y tick labels |
| `legend` | computed | 60% the size of `title` — applied to the legend |
| `annotation` | computed | `title` scaled −4px, italic — for callout text |

Override any of these by passing a `fonts` dict to the constructor:

```python
from orama.theme import Font, Theme

theme = Theme(
    colors={"background": HSLColor.from_hex("#ffffff"), ...},
    fonts={
        "title": Font('"Open Sans", verdana, arial, sans-serif', 20.0, 700, False),
    },
)
```

Computed fonts (`subtitle`, `axis_tick`, `legend`, `annotation`) are re-derived from whatever
`title` resolves to, so overriding `title` automatically scales all derived fonts.

Use `theme.get_font(name)` to retrieve any font as an `armonia.typography.Font`.
Use `theme.get_all_fonts()` to list all registered fonts.

### Plotly template customization hook

`Theme` exposes a `_plotly_template_customization(template)` method that is called at the end
of template construction. Subclass `Theme` and override it to apply additional Plotly layout
or trace defaults without reimplementing the full template build:

```python
import plotly.graph_objects as go
from orama.theme import Theme

class MyTheme(Theme):
    def _plotly_template_customization(self, template: go.layout.Template) -> None:
        template.layout.colorway = ["#3b82f6", "#f59e0b", "#10b981"]
```

The method receives the fully built (mutable) `go.layout.Template` object and may modify
any layout property or trace default in-place. It is called once per `plot()` / `build()`
invocation and its return value is ignored.

---

## Internationalization

All strategies accept a `babel.support.Translations` object as the `tr` argument in `plot()`
and `build()`. The same object is accepted by `FigureView` (web layer) to translate the
dynamically generated WTForms form labels. Together they cover the full user-visible surface:

| Layer | Translated strings |
|---|---|
| Strategy (`plot` / `build`) | Month and day-of-week labels on time-based axes |
| Web form (`FigureView`, `form_factory`) | Variable names, option names, aggregation function labels, enum option labels, fixed form labels (`Title`, `Subtitle`, …), `StringOption` default values |
| Demo templates | All static UI text (accordion headings, buttons, placeholders, empty-state messages) |

When internationalization is not needed, pass `NullTranslations` (identity, returns the
original English strings as-is):

```python
from babel.support import NullTranslations

view = strategy.plot(dfs={"main": df}, tr=NullTranslations(), theme=theme)
```

To load real translations from compiled `.mo` files, use Babel's standard `Translations.load()`:

```python
from babel.support import Translations

tr = Translations.load("locale", locales=["es"])
view = strategy.plot(dfs={"main": df}, tr=tr, theme=theme)
```

For cases where `.po`/`.mo` tooling is not available (e.g. demos or tests), a lightweight
dict-based subclass works equally well, since `Translations` is the only required interface:

```python
from babel.support import Translations

class DictTranslations(Translations):
    def __init__(self, catalog: dict[str, str]) -> None:
        super().__init__()
        self._catalog = catalog

    def gettext(self, message: str) -> str:
        return self._catalog.get(message, message)

tr = DictTranslations({"January": "Enero", "February": "Febrero", ...})
```

The demo application ships a complete Spanish catalog in `demo/translations.py` and selects
the active locale via the `ORAMA_LOCALE` environment variable (default `"en"`):

```bash
ORAMA_LOCALE=es flask --app demo.app run
```

---

## Strategy Introspection

Every strategy class exposes a `describe()` class method that returns a
`FigureStrategyDescription` without requiring an instance:

```python
from orama.strategies.bar import BarChartStrategy
from orama.strategies.base import FigureStrategyDescription

desc: FigureStrategyDescription = BarChartStrategy.describe(fields_metadata)

desc.name          # "Bar Chart"
desc.description   # human-readable strategy description
desc.variables     # dict[str, dict[str, Any]] — variable descriptors
desc.options       # dict[str, dict[str, Any]] — option descriptors
desc.colors        # ColorStrategyDescription — supported selectors and assigners
```

Each variable descriptor includes `type`, `name`, `description`, `map_to`,
`candidate_fields`, and (for categorical variables) `multicategorical`, `ordered`,
`optional`. Each option descriptor includes `type`, `name`, `description`, `map_to`,
`default_value`, and `value_type`.

`get_query_params()` returns a `dict[str, QueryParams]`. Inspecting each
`QueryParams` reveals the `group_fields`, `aggregations`, and `dataframe_schema` that
your data layer must satisfy:

```python
params = strategy.get_query_params()
for query_name, qp in params.items():
    print(query_name, qp.group_fields, qp.dataframe_schema)
```

---

## Figure Configuration

Every strategy exposes a `figure_config(**kwargs)` instance method that returns a
serialisable Plotly config dict. The web layer passes this to
`Figure.to_html(config=...)` when rendering each figure. By default the config:

- Hides the Plotly logo (`displaylogo: False`).
- Removes the box-select and lasso tools (`modeBarButtonsToRemove`).

Any extra keyword arguments are merged in, with caller-supplied values taking precedence.
This allows passing chart-specific config (e.g. a map centre) without losing modebar
defaults:

```python
config=strategy_instance.figure_config(mapbox={"center": {"lat": 51.5, "lon": -0.1}})
```

The demo additionally injects a **Download SVG** button next to the default PNG button
via a `post_script` using `Plotly.react()` (JavaScript callbacks cannot be serialised to
JSON and therefore cannot be part of the Python config dict).

Override `modebar_config()` in a subclass to customise the modebar for a specific strategy:

```python
class MyBarChartStrategy(BarChartStrategy):
    def modebar_config(self) -> dict:
        cfg = super().modebar_config()
        cfg["modeBarButtonsToRemove"].append("zoom2d")
        return cfg
```

---

## Web Layer

`orama.web.FigureView` binds a WTForms form to a strategy class and provides
serialization, deserialization, and instantiation from form data.

```python
from orama.web.figureview import FigureView
from orama.strategies.bar import BarChartStrategy

# Create from HTTP form data (POST); pass tr to translate form labels
view = FigureView(
    figure_strategy=BarChartStrategy,
    fields_metadata=fields_metadata,
    dataset_name="sales",
    formdata=request.form,
    tr=tr,  # babel.support.Translations — translates variable/option labels
)

# Instantiate the strategy from the submitted form
strategy = view.instantiate_figure_strategy(theme)

# Serialize to a JSON-serializable dict (for persistence)
state = view.serialize()

# Reconstruct from a previously serialized dict
view = FigureView.deserialize(
    data=state,
    strategy_registry={"BarChartStrategy": BarChartStrategy},
    fields_metadata_registry={"sales": fields_metadata},
)
```

The full web binding — dynamic WTForms `Form` subclasses, color rule forms, and HTTP
serialization — is available via `pip install 'orama[web]'`.

---

## Dependencies

| Package | Version | Role |
|---------|---------|------|
| `plotly[express]` | ~6.3 | Primary rendering backend (`go.Figure`) |
| `polars` | ~1.33 | DataFrame manipulation and schema validation |
| `pandas` | ~2.3 | Plotly bridge (`df.to_pandas()` at plot boundary) |
| `pyarrow` | ~22.0 | Polars/Pandas interchange |
| `fields-metadata` | ~1.6 | Field discovery and metadata (`FieldMetadata`, `MetadataExtractor`) |
| `therismos` | ~1.0 | `GroupSpec`, `AggregationSpec`, `SortSpec`, `SemanticallyOrderedEnum` |
| `polychromos` | ~1.4 | Color manipulation: HSL, palettes, sequences, scales |
| `armonia` | ~1.2 | Theme color and font composition with computed variants |
| `babel` | ~2.17 | i18n and translations |
| `readable-number` | ~0.1 | Compact human-readable number formatting for `inner_overall_human_readable` |

---

## License

This project is licensed under the MIT license.
