import type { ReactNode } from 'react'
import type { StoreApi } from 'zustand'
/** Floating panel anchor position. */
export type LegendPosition =
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right'
// ─── Legend variables (discriminated union) ────────────────────────────────
// The typed payload that drives each item renderer. Keyed by `type` so a
// single `LegendItemUI` can switch and the future adapter has one clear
// target to map `layer.legend.variables` into.
/**
* Layout direction of a category/icon legend's items. `'horizontal'` flows
* the swatch+label pairs left-to-right, wrapping onto multiple rows.
*/
export type LegendOrientation = 'vertical' | 'horizontal'
/** Swatch shape for a category legend. */
export type LegendSwatchShape = 'square' | 'circle' | 'line'
/** Fill pattern for a filled square/circle swatch (`isStrokeColor: false`). */
export type LegendFillPattern =
| 'solid'
| 'horizontal'
| 'vertical'
| 'diagonal'
| 'diagonal-alt'
| 'cross'
| 'dots'
| 'grid'
/**
* Border style for a stroked square/circle swatch (`isStrokeColor: true`), or
* the line style for `shape: 'line'`. `corners` applies to squares only;
* `dash-dot` applies to lines only — inapplicable values degrade gracefully.
*/
export type LegendStrokeStyle =
| 'solid'
| 'dashed'
| 'dotted'
| 'dash-dot'
| 'corners'
export interface LegendCategoryItem {
label: string
/** Fill or stroke color (see `isStrokeColor`). */
color: string
/**
* Optional value shown alongside the label — a pre-formatted node, so the
* consumer controls the formatting (e.g. `'12%'`, `'1.2K'`, or any element).
*/
value?: ReactNode
/** Optional marker diameter/length in px (the "Small/Medium/Large/XL" sizes). */
size?: number
/** Override the variable-level swatch shape for this item. */
shape?: LegendSwatchShape
/** Override the variable-level fill pattern for this item (mix patterns in one list). */
fillPattern?: LegendFillPattern
/** Override the variable-level border/line style for this item. */
strokeStyle?: LegendStrokeStyle
/** Override the variable-level fill-vs-outline mode for this item. */
isStrokeColor?: boolean
}
export interface LegendCategoryVariable {
type: 'category'
items: LegendCategoryItem[]
/** Default swatch shape for all items (each item may override). Default `'circle'`. */
shape?: LegendSwatchShape
/**
* Default fill-vs-outline mode (each item may override). When true, `color`
* paints the marker border/outline instead of its fill.
*/
isStrokeColor?: boolean
/**
* Default fill pattern when filled (each item may override — mix patterns
* in one list). Default `'solid'`.
*/
fillPattern?: LegendFillPattern
/**
* Default border style when stroked, or line style for `shape: 'line'`
* (each item may override). Default `'solid'`.
*/
strokeStyle?: LegendStrokeStyle
/** Item layout direction. Default `'vertical'`. */
orientation?: LegendOrientation
}
export interface LegendRampStop {
label: string
value: number
}
export interface LegendRampVariable {
type: 'ramp'
/** Ordered low→high colors (hex / rgb / css). */
colors: string[]
/**
* Ordered low→high stops. Discrete ramps **require `colors.length + 1`**
* stops — the boundaries between color steps, including both outer edges —
* and each label is centered on its boundary line (keep labels short, e.g.
* `20K`; there is no collision handling). A discrete ramp with any other
* stop count is incoherent and renders an inline error instead of the bar.
* Continuous ramps show only the first/last endpoints.
*/
stops: LegendRampStop[]
/** Continuous gradient bar vs. discrete color steps. */
isContinuous: boolean
/**
* Average value, marked as a dashed vertical line on the bar (tooltip shows
* the formatted value). Positioned linearly between the first and last stop
* `value`s; out-of-range averages clamp to the bar's edge.
*/
avg?: number
/**
* Formats `avg` for its tooltip. Default `String(avg)` — pass e.g.
* `(v) => formatNumber(v, 'es-ES')` or `(v) => `${v}%``. Use a stable
* reference (same contract as the other variable fields).
*/
formatValue?: (value: number) => ReactNode
/** Attribute the color is based on (shown as "Color based on"). */
attribute?: string
}
export interface LegendProportionVariable {
type: 'proportion'
min: number
max: number
/**
* Explicit value breaks to label (order-insensitive — the renderer sorts
* descending and caps at the **4 largest**). When omitted (or fewer than 2
* entries), breaks are evenly spaced between `min` and `max` over `steps`.
*/
stops?: number[]
/**
* Number of nested circles and derived value breaks, clamped to **2–4**.
* Defaults to 4. Ignored when explicit `stops` are provided (their count,
* capped at 4, wins). Circles and labels always match 1:1. Edge case: when
* `min === max` the renderer collapses to a **single** circle/break (the
* shared value), regardless of `steps`.
*/
steps?: number
/**
* Formats each derived break (MAX / middle / MIN). Default `String(value)`
* — pass e.g. `(v) => formatNumber(v, 'es-ES')`. Use a stable reference
* (same contract as the other variable fields).
*/
formatValue?: (value: number) => ReactNode
/** Attribute the size is based on (shown as "Radius range by"). */
attribute?: string
}
export interface LegendIconItem {
label: string
/** Icon URL (data URI or external). */
icon: string
/**
* Optional value shown alongside the label — a pre-formatted node, so the
* consumer controls the formatting (e.g. `'12%'`, `'1.2K'`, or any element).
*/
value?: ReactNode
}
export interface LegendIconVariable {
type: 'icon'
items: LegendIconItem[]
/** Item layout direction. Default `'vertical'`. */
orientation?: LegendOrientation
}
export type LegendVariable =
| LegendCategoryVariable
| LegendRampVariable
| LegendProportionVariable
| LegendIconVariable
// ─── Store entities ─────────────────────────────────────────────────────────
/**
* One section of a layer's configuration select. The select's menu lists each
* section under its `title` (omitted when the layer has a single section);
* every section keeps its own highlighted `active` option. The closed field
* displays the first section's `active`.
*/
export interface LegendConfigSection {
id: string
/** Section heading in the menu (e.g. "Global"). Hidden for a single section. */
title?: string
options: string[]
/** The section's currently-selected option. */
active: string
}
/** A layer row in the legend. Holds both static definition and interactive state. */
export interface LegendLayer {
id: string
name: string
/** Secondary line (e.g. "Zoom level 1 – 12"). */
subtitle?: string
visible: boolean
/** 0–1. */
opacity: number
collapsed: boolean
/**
* @internal Collapsed state snapshotted when the layer was hidden — restored
* on show so a manual collapse/expand persists across visibility toggles.
* Managed by `setVisibility`/`setCollapsed`; do not set from outside.
*/
collapsedBeforeHide?: boolean
/** Sort order among siblings (lower = first). */
order: number
/** Owning group id, if the layer belongs to a group. */
groupId?: string
/** Show the opacity header action (slider + input popover) for this layer. */
opacityControl?: boolean
/**
* Configuration-select sections. When absent (or empty), the row renders no
* select. User-picked `active`s survive Provider re-syncs as long as the
* section id still exists and the pick is still among its `options`.
*/
sections?: LegendConfigSection[]
/** Footer helper note — one per layer, shown after all variables. */
helperText?: ReactNode
/**
* Ordered item renderers, stacked top-to-bottom in the row body (a layer
* can mix e.g. two category lists and a proportion). Pass a stable array
* reference — `_sync` compares it by reference, like the other prop-driven
* fields.
*/
variables: LegendVariable[]
}
/**
* A group of layer rows. A group has no stored visibility of its own — it is
* derived from its member layers (see `selectGroupVisibility`): all visible →
* `'visible'`, none visible → `'hidden'`, otherwise `'mixed'`.
*/
export interface LegendGroup {
id: string
label: string
/**
* Optional header icon. Rendered only when provided — no icon shows by
* default. A same-type element with shallow-equal props is treated as
* unchanged across re-syncs even when re-created inline (e.g.
* `icon={}`); a deeply nested custom node should be memoized if
* you want to avoid unnecessary syncs.
*/
icon?: ReactNode
collapsed: boolean
/**
* @internal Collapsed state snapshotted when the group was hidden via
* `setGroupVisibility` — restored on show. Managed by the setters.
*/
collapsedBeforeHide?: boolean
order: number
}
// ─── Provider input shapes ──────────────────────────────────────────────────
// Interactive fields are optional on input; the store seeds defaults and
// preserves user interaction across re-syncs.
/** @experimental This API is new and may change in a future release. */
export interface LegendLayerInput extends Omit<
LegendLayer,
'visible' | 'opacity' | 'collapsed' | 'collapsedBeforeHide' | 'order'
> {
visible?: boolean
opacity?: number
collapsed?: boolean
order?: number
}
/** @experimental This API is new and may change in a future release. */
export interface LegendGroupInput extends Omit<
LegendGroup,
'collapsed' | 'collapsedBeforeHide' | 'order'
> {
collapsed?: boolean
order?: number
}
// ─── Store shape ──────────────────────────────────────────────────────────────
export interface LegendState {
layers: Record
groups: Record
// Layer setters — the "set layer values" feature that replaces react-maps
// `useLegend`. All mutations go through these.
setVisibility: (id: string, visible: boolean) => void
setOpacity: (id: string, opacity: number) => void
setCollapsed: (id: string, collapsed: boolean) => void
/**
* Pick a section's active option in the layer's configuration select.
* No-op for unknown layers/sections or when `value` is not one of the
* section's `options`.
*/
setSectionValue: (id: string, sectionId: string, value: string) => void
/**
* Move a layer to `toIndex` **within its sibling bucket** (its group, or
* the ungrouped bucket). `toIndex` is clamped; orders stay dense. No-op
* for unknown ids or when the position doesn't change.
*/
moveLayer: (id: string, toIndex: number) => void
// Group setters
/**
* Show/hide **all member layers** of a group (the group itself has no stored
* visibility — it is derived from its members). Each member keeps its own
* collapse snapshot; the group additionally collapses when hiding and
* restores its pre-hide collapse state when showing. No-op when the group
* is already at the target state or has no members.
*/
setGroupVisibility: (id: string, visible: boolean) => void
setGroupCollapsed: (id: string, collapsed: boolean) => void
/**
* Move a group to `toIndex` within the group list. `toIndex` is clamped;
* orders stay dense. No-op for unknown ids or an unchanged position.
*/
moveGroup: (id: string, toIndex: number) => void
// Imperative add/remove/update — an alternative to the Provider's bulk
// `layers`/`groups` props. To use these, keep those props stable (pass the
// initial set once, or `[]`) so the Provider's `_sync` doesn't overwrite
// imperative changes on a later prop change.
/**
* Add a layer, or update it if `input.id` already exists. Interactive state
* (visible/opacity/collapsed/order and section actives) is preserved unless
* the input explicitly overrides it; new layers append to the end of their
* sibling bucket. No-op when an existing layer is unchanged.
*/
setLegendLayer: (input: LegendLayerInput) => void
/**
* Remove a layer and densely re-number the remaining siblings. No-op for an
* unknown id.
*/
removeLegendLayer: (id: string) => void
/**
* Add a group, or update it if `input.id` already exists (collapse/order
* preserved on update). No-op when unchanged.
*/
setLegendGroup: (input: LegendGroupInput) => void
/**
* Remove a group; its member layers are **un-grouped** (their `groupId` is
* cleared) so they survive as ungrouped rows rather than vanish. No-op for an
* unknown id.
*/
removeLegendGroup: (id: string) => void
/**
* @internal Provider prop-sync. Adds new entities, updates static fields of
* known ones (preserving their interactive state), and removes vanished ones.
*/
_sync: (layers: LegendLayerInput[], groups: LegendGroupInput[]) => void
}
export type LegendStoreApi = StoreApi
export interface LegendInit {
layers?: LegendLayerInput[]
groups?: LegendGroupInput[]
}