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[] }