mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Theme Builder: accordion panels across the full config surface, vertical-tab nav, reflective gallery
This commit is contained in:
@@ -1,25 +1,40 @@
|
||||
/**
|
||||
* Theme Builder — Legend panel (docs/chart-theming-scope.md §5).
|
||||
*
|
||||
* Structured controls over the base `legend` config: placement (`orient`), the
|
||||
* title's and labels' color and size, and the symbol size. Legend type (size)
|
||||
* lives here rather than the Type panel so every legend property a brand tunes
|
||||
* sits together — the scope's "label/title color+size" grouping.
|
||||
* Structured controls over the base `legend` config: placement, the title's and
|
||||
* labels' colour and size, the symbol keys, the continuous gradient bar, and an
|
||||
* optional box behind the legend. Legend type (size) lives here rather than the
|
||||
* Type panel so every legend property a brand tunes sits together.
|
||||
*/
|
||||
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import { asNumber, asString, type ConfigPath, getConfigValue } from '@core/theme-controls';
|
||||
import {
|
||||
asNumber,
|
||||
asString,
|
||||
type ConfigPath,
|
||||
countSet,
|
||||
enumValue,
|
||||
getConfigValue,
|
||||
} from '@core/theme-controls';
|
||||
import { useConfigSetter } from '../stores/CustomThemeStore';
|
||||
import { ColorRow, ControlSection, NumberRow, SelectRow } from './ThemeFields';
|
||||
import { Accordion, type AccordionSection, ColorRow, NumberRow, SelectRow } from './ThemeFields';
|
||||
import type { SelectControlOption } from './SelectControl';
|
||||
import styles from './ThemeFields.module.css';
|
||||
|
||||
const ORIENT: ConfigPath = ['legend', 'orient'];
|
||||
const DIRECTION: ConfigPath = ['legend', 'direction'];
|
||||
const COLUMNS: ConfigPath = ['legend', 'columns'];
|
||||
const TITLE_COLOR: ConfigPath = ['legend', 'titleColor'];
|
||||
const TITLE_SIZE: ConfigPath = ['legend', 'titleFontSize'];
|
||||
const LABEL_COLOR: ConfigPath = ['legend', 'labelColor'];
|
||||
const LABEL_SIZE: ConfigPath = ['legend', 'labelFontSize'];
|
||||
const SYMBOL_SIZE: ConfigPath = ['legend', 'symbolSize'];
|
||||
const SYMBOL_TYPE: ConfigPath = ['legend', 'symbolType'];
|
||||
const GRADIENT_LENGTH: ConfigPath = ['legend', 'gradientLength'];
|
||||
const GRADIENT_THICKNESS: ConfigPath = ['legend', 'gradientThickness'];
|
||||
const FILL_COLOR: ConfigPath = ['legend', 'fillColor'];
|
||||
const STROKE_COLOR: ConfigPath = ['legend', 'strokeColor'];
|
||||
const CORNER_RADIUS: ConfigPath = ['legend', 'cornerRadius'];
|
||||
const PADDING: ConfigPath = ['legend', 'padding'];
|
||||
|
||||
const TEXT_GREY = '#888888';
|
||||
|
||||
@@ -35,80 +50,205 @@ const orientOptions: SelectControlOption<Orient>[] = [
|
||||
{ value: 'top-right', label: 'Top-right' },
|
||||
{ value: 'none', label: 'Hidden' },
|
||||
];
|
||||
const orientValue = (v: unknown): Orient => {
|
||||
const s = asString(v);
|
||||
return s !== undefined && orientOptions.some((o) => o.value === s) ? (s as Orient) : '';
|
||||
};
|
||||
|
||||
type Direction = '' | 'horizontal' | 'vertical';
|
||||
const directionOptions: SelectControlOption<Direction>[] = [
|
||||
{ value: '', label: 'Theme default' },
|
||||
{ value: 'vertical', label: 'Vertical' },
|
||||
{ value: 'horizontal', label: 'Horizontal' },
|
||||
];
|
||||
|
||||
// Discrete-legend key shapes (continuous legends render a gradient bar instead).
|
||||
type SymbolType = '' | 'circle' | 'square' | 'cross' | 'diamond' | 'triangle-up' | 'stroke';
|
||||
const symbolTypeOptions: SelectControlOption<SymbolType>[] = [
|
||||
{ value: '', label: 'Theme default' },
|
||||
{ value: 'circle', label: 'Circle' },
|
||||
{ value: 'square', label: 'Square' },
|
||||
{ value: 'cross', label: 'Cross' },
|
||||
{ value: 'diamond', label: 'Diamond' },
|
||||
{ value: 'triangle-up', label: 'Triangle' },
|
||||
{ value: 'stroke', label: 'Line' },
|
||||
];
|
||||
|
||||
export function LegendControls({ config }: { config: JsonObject }) {
|
||||
const set = useConfigSetter();
|
||||
|
||||
const orient = orientValue(getConfigValue(config, ORIENT));
|
||||
const titleColor = asString(getConfigValue(config, TITLE_COLOR));
|
||||
const titleSize = asNumber(getConfigValue(config, TITLE_SIZE));
|
||||
const labelColor = asString(getConfigValue(config, LABEL_COLOR));
|
||||
const labelSize = asNumber(getConfigValue(config, LABEL_SIZE));
|
||||
const symbolSize = asNumber(getConfigValue(config, SYMBOL_SIZE));
|
||||
const sections: AccordionSection[] = [
|
||||
{
|
||||
id: 'placement',
|
||||
title: 'Placement',
|
||||
hint: 'Where the legend sits relative to the plot.',
|
||||
badge: countSet(config, [ORIENT, DIRECTION, COLUMNS]),
|
||||
children: (
|
||||
<>
|
||||
<SelectRow
|
||||
id="legend-orient"
|
||||
label="Position"
|
||||
options={orientOptions}
|
||||
value={enumValue(getConfigValue(config, ORIENT), orientOptions)}
|
||||
onSelect={(o) => set(ORIENT, o === '' ? undefined : o)}
|
||||
/>
|
||||
<SelectRow
|
||||
id="legend-direction"
|
||||
label="Direction"
|
||||
options={directionOptions}
|
||||
value={enumValue(getConfigValue(config, DIRECTION), directionOptions)}
|
||||
onSelect={(d) => set(DIRECTION, d === '' ? undefined : d)}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Columns"
|
||||
name="Legend columns"
|
||||
value={asNumber(getConfigValue(config, COLUMNS))}
|
||||
min={0}
|
||||
onChange={(n) => set(COLUMNS, n)}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'title',
|
||||
title: 'Title',
|
||||
badge: countSet(config, [TITLE_COLOR, TITLE_SIZE]),
|
||||
children: (
|
||||
<>
|
||||
<ColorRow
|
||||
label="Color"
|
||||
name="Legend title color"
|
||||
value={asString(getConfigValue(config, TITLE_COLOR))}
|
||||
fallback={TEXT_GREY}
|
||||
onChange={(hex) => set(TITLE_COLOR, hex)}
|
||||
onClear={() => set(TITLE_COLOR, undefined)}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Size"
|
||||
name="Legend title size"
|
||||
value={asNumber(getConfigValue(config, TITLE_SIZE))}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(TITLE_SIZE, n)}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'labels',
|
||||
title: 'Labels',
|
||||
badge: countSet(config, [LABEL_COLOR, LABEL_SIZE]),
|
||||
children: (
|
||||
<>
|
||||
<ColorRow
|
||||
label="Color"
|
||||
name="Legend label color"
|
||||
value={asString(getConfigValue(config, LABEL_COLOR))}
|
||||
fallback={TEXT_GREY}
|
||||
onChange={(hex) => set(LABEL_COLOR, hex)}
|
||||
onClear={() => set(LABEL_COLOR, undefined)}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Size"
|
||||
name="Legend label size"
|
||||
value={asNumber(getConfigValue(config, LABEL_SIZE))}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(LABEL_SIZE, n)}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'symbols',
|
||||
title: 'Symbols',
|
||||
hint: 'The colored keys beside each label.',
|
||||
badge: countSet(config, [SYMBOL_TYPE, SYMBOL_SIZE]),
|
||||
children: (
|
||||
<>
|
||||
<SelectRow
|
||||
id="legend-symbol-type"
|
||||
label="Shape"
|
||||
name="Legend symbol shape"
|
||||
options={symbolTypeOptions}
|
||||
value={enumValue(getConfigValue(config, SYMBOL_TYPE), symbolTypeOptions)}
|
||||
onSelect={(t) => set(SYMBOL_TYPE, t === '' ? undefined : t)}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Symbol size"
|
||||
value={asNumber(getConfigValue(config, SYMBOL_SIZE))}
|
||||
min={0}
|
||||
unit="px²"
|
||||
onChange={(n) => set(SYMBOL_SIZE, n)}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'gradient',
|
||||
title: 'Gradient',
|
||||
hint: 'The continuous color bar (quantitative legends).',
|
||||
badge: countSet(config, [GRADIENT_LENGTH, GRADIENT_THICKNESS]),
|
||||
children: (
|
||||
<>
|
||||
<NumberRow
|
||||
label="Length"
|
||||
name="Gradient length"
|
||||
value={asNumber(getConfigValue(config, GRADIENT_LENGTH))}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(GRADIENT_LENGTH, n)}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Thickness"
|
||||
name="Gradient thickness"
|
||||
value={asNumber(getConfigValue(config, GRADIENT_THICKNESS))}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(GRADIENT_THICKNESS, n)}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'box',
|
||||
title: 'Box',
|
||||
hint: 'An optional filled/bordered panel behind the legend.',
|
||||
badge: countSet(config, [FILL_COLOR, STROKE_COLOR, CORNER_RADIUS, PADDING]),
|
||||
children: (
|
||||
<>
|
||||
<ColorRow
|
||||
label="Fill"
|
||||
name="Legend fill"
|
||||
value={asString(getConfigValue(config, FILL_COLOR))}
|
||||
fallback="#ffffff"
|
||||
onChange={(hex) => set(FILL_COLOR, hex)}
|
||||
onClear={() => set(FILL_COLOR, undefined)}
|
||||
/>
|
||||
<ColorRow
|
||||
label="Border"
|
||||
name="Legend border"
|
||||
value={asString(getConfigValue(config, STROKE_COLOR))}
|
||||
fallback="#cccccc"
|
||||
onChange={(hex) => set(STROKE_COLOR, hex)}
|
||||
onClear={() => set(STROKE_COLOR, undefined)}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Corner radius"
|
||||
name="Legend corner radius"
|
||||
value={asNumber(getConfigValue(config, CORNER_RADIUS))}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(CORNER_RADIUS, n)}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Padding"
|
||||
name="Legend padding"
|
||||
value={asNumber(getConfigValue(config, PADDING))}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(PADDING, n)}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<ControlSection title="Placement" hint="Where the legend sits relative to the plot.">
|
||||
<SelectRow
|
||||
id="legend-orient"
|
||||
label="Position"
|
||||
options={orientOptions}
|
||||
value={orient}
|
||||
onSelect={(o) => set(ORIENT, o === '' ? undefined : o)}
|
||||
/>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Title">
|
||||
<ColorRow
|
||||
label="Color"
|
||||
name="Legend title color"
|
||||
value={titleColor}
|
||||
fallback={TEXT_GREY}
|
||||
onChange={(hex) => set(TITLE_COLOR, hex)}
|
||||
onClear={() => set(TITLE_COLOR, undefined)}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Size"
|
||||
name="Legend title size"
|
||||
value={titleSize}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(TITLE_SIZE, n)}
|
||||
/>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Labels">
|
||||
<ColorRow
|
||||
label="Color"
|
||||
name="Legend label color"
|
||||
value={labelColor}
|
||||
fallback={TEXT_GREY}
|
||||
onChange={(hex) => set(LABEL_COLOR, hex)}
|
||||
onClear={() => set(LABEL_COLOR, undefined)}
|
||||
/>
|
||||
<NumberRow
|
||||
label="Size"
|
||||
name="Legend label size"
|
||||
value={labelSize}
|
||||
min={0}
|
||||
unit="px"
|
||||
onChange={(n) => set(LABEL_SIZE, n)}
|
||||
/>
|
||||
</ControlSection>
|
||||
|
||||
<ControlSection title="Symbols" hint="The colored keys beside each label.">
|
||||
<NumberRow
|
||||
label="Symbol size"
|
||||
value={symbolSize}
|
||||
min={0}
|
||||
unit="px²"
|
||||
onChange={(n) => set(SYMBOL_SIZE, n)}
|
||||
/>
|
||||
</ControlSection>
|
||||
</div>
|
||||
);
|
||||
return <Accordion sections={sections} idPrefix="legend" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user