# 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
)
```

**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)
)
```

`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)
)
```

**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
    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
)
```

When `hole > 0` and `inner_overall > 0`, `get_query_params()` returns both a `"main"` and
an `"overall"` key.

**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
)
```

**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
)
```

`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
)
```

`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
)
```

`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.

---

## 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 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"),
})
```

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`

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`.

---

## Internationalization

All strategies accept a `babel.support.Translations` object as the `tr` argument in `plot()`.
It is used to translate month names and day-of-week labels in time-based charts.

When internationalization is not needed, pass `NullTranslations`:

```python
from babel.support import NullTranslations

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

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

```python
from babel.support import Translations

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

---

## 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)
```

---

## Modebar Configuration

Every strategy exposes a `modebar_config()` 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`).

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)
view = FigureView(
    figure_strategy=BarChartStrategy,
    fields_metadata=fields_metadata,
    dataset_name="sales",
    formdata=request.form,
)

# 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` | ≥0.1 | Field discovery and metadata (`FieldMetadata`, `MetadataExtractor`) |
| `therismos` | ≥0.1 | `GroupSpec`, `AggregationSpec`, `SortSpec` |
| `polychromos` | ~1.4 | Color manipulation: HSL, palettes, sequences, scales |
| `armonia` | ~1.1 | Theme color composition and computed color functions |
| `babel` | ~2.17 | i18n and translations |

---

## License

This project is licensed under the MIT license.
