@fundar/data-chart-telling 0.0.33 → 0.0.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,7 +22,7 @@ const text = (over) => ({
22
22
  color: 'inherit',
23
23
  leading: '1.3',
24
24
  spacing: 'normal',
25
- ...over,
25
+ ...over
26
26
  });
27
27
  export const DEFAULT_CONFIG = {
28
28
  font: 'inherit',
@@ -32,10 +32,14 @@ export const DEFAULT_CONFIG = {
32
32
  spacing: {
33
33
  headerPadding: '0.75rem 0 1rem',
34
34
  legendPadding: '0.75rem 0',
35
- captionPadding: '0.5rem 0',
35
+ captionPadding: '0.5rem 0'
36
36
  },
37
37
  title: text({ size: '1.5rem', weight: 500, leading: '1.2' }),
38
- subtitle: text({ size: '1rem', weight: 400, color: 'color-mix(in srgb, currentColor 85%, transparent)' }),
38
+ subtitle: text({
39
+ size: '1rem',
40
+ weight: 400,
41
+ color: 'color-mix(in srgb, currentColor 85%, transparent)'
42
+ }),
39
43
  caption: text({ size: '0.75rem', color: 'color-mix(in srgb, currentColor 75%, transparent)' }),
40
44
  legend: {
41
45
  font: 'inherit',
@@ -47,6 +51,13 @@ export const DEFAULT_CONFIG = {
47
51
  gap: '0.5rem 2rem',
48
52
  swatchSize: '1.25rem',
49
53
  disabledOpacity: 0.12,
54
+ navButton: {
55
+ background: 'transparent',
56
+ color: 'currentColor',
57
+ size: '1.5rem',
58
+ fontSize: '1rem',
59
+ disabledOpacity: 0.3
60
+ }
50
61
  },
51
62
  axis: {
52
63
  color: 'currentColor',
@@ -57,7 +68,7 @@ export const DEFAULT_CONFIG = {
57
68
  yTickSize: 5,
58
69
  xTickPadding: 10,
59
70
  yTickPadding: 10,
60
- grid: false,
71
+ grid: false
61
72
  },
62
73
  tooltip: {
63
74
  background: '#111827',
@@ -65,7 +76,7 @@ export const DEFAULT_CONFIG = {
65
76
  fontSize: '0.8125rem',
66
77
  padding: '0.5rem 0.75rem',
67
78
  radius: '0.375rem',
68
- border: 'none',
79
+ border: 'none'
69
80
  },
70
81
  margins: { top: 'auto', right: 'auto', bottom: 'auto', left: 'auto' },
71
82
  palette: ['#4f46e5', '#e6580d', '#16a34a', '#dc2626', '#0891b2', '#9333ea', '#ca8a04', '#db2777'],
@@ -75,15 +86,15 @@ export const DEFAULT_CONFIG = {
75
86
  dotRadius: 4,
76
87
  gap: {
77
88
  stroke: { strokeOpacity: 0.6, strokeDasharray: '4 4' },
78
- dots: {},
89
+ dots: {}
79
90
  },
80
91
  valueLabels: {
81
92
  elbowGap: 10,
82
93
  laneStep: 7,
83
94
  labelGap: 6,
84
95
  connectorColor: '#94a3b8',
85
- connectorWidth: 0.5,
86
- },
96
+ connectorWidth: 0.5
97
+ }
87
98
  },
88
99
  bar: { gap: { fillOpacity: 0.5 } },
89
100
  pyramid: { gap: { fillOpacity: 0.5 } },
@@ -93,7 +104,7 @@ export const DEFAULT_CONFIG = {
93
104
  rule: { stroke: '#e6580d', strokeOpacity: 0.35, strokeWidth: 1.5, strokeDasharray: '4 4' },
94
105
  dot: { dotRadius: 5, dotFill: 'currentColor' },
95
106
  font: { fill: 'currentColor', fontSize: 12, dy: -18 },
96
- area: { stroke: '#e6580d', strokeWidth: 2, fillOpacity: 0 },
107
+ area: { stroke: '#e6580d', strokeWidth: 2, fillOpacity: 0 }
97
108
  },
98
109
  facet: {
99
110
  indicator: {
@@ -101,16 +112,16 @@ export const DEFAULT_CONFIG = {
101
112
  color: '#ffffff',
102
113
  fontSize: '0.8125rem',
103
114
  padding: '0.125rem 0.5rem',
104
- radius: '999px',
115
+ radius: '999px'
105
116
  },
106
117
  navButton: {
107
118
  background: '#111827',
108
119
  color: '#ffffff',
109
120
  size: '2rem',
110
121
  fontSize: '1.1rem',
111
- disabledOpacity: 0.3,
112
- },
113
- },
122
+ disabledOpacity: 0.3
123
+ }
124
+ }
114
125
  };
115
126
  // ── Merge ─────────────────────────────────────────────────────────────────────
116
127
  function isPlainObject(v) {
@@ -151,7 +162,7 @@ export function configToCssVars(c) {
151
162
  [`--dct-${prefix}-weight`]: String(s.weight),
152
163
  [`--dct-${prefix}-color`]: s.color,
153
164
  [`--dct-${prefix}-leading`]: s.leading,
154
- [`--dct-${prefix}-spacing`]: s.spacing,
165
+ [`--dct-${prefix}-spacing`]: s.spacing
155
166
  });
156
167
  return {
157
168
  '--dct-font': c.font,
@@ -172,6 +183,11 @@ export function configToCssVars(c) {
172
183
  '--dct-legend-title-weight': String(c.legend.titleWeight),
173
184
  '--dct-legend-gap': c.legend.gap,
174
185
  '--dct-legend-swatch': c.legend.swatchSize,
186
+ '--dct-legend-nav-bg': c.legend.navButton.background,
187
+ '--dct-legend-nav-color': c.legend.navButton.color,
188
+ '--dct-legend-nav-size': c.legend.navButton.size,
189
+ '--dct-legend-nav-font-size': c.legend.navButton.fontSize,
190
+ '--dct-legend-nav-disabled-opacity': String(c.legend.navButton.disabledOpacity),
175
191
  '--dct-axis-color': c.axis.color,
176
192
  '--dct-tooltip-bg': c.tooltip.background,
177
193
  '--dct-tooltip-color': c.tooltip.color,
@@ -193,7 +209,7 @@ export function configToCssVars(c) {
193
209
  '--dct-facet-nav-color': c.facet.navButton.color,
194
210
  '--dct-facet-nav-btn-size': c.facet.navButton.size,
195
211
  '--dct-facet-nav-font-size': c.facet.navButton.fontSize,
196
- '--dct-facet-nav-disabled-opacity': String(c.facet.navButton.disabledOpacity),
212
+ '--dct-facet-nav-disabled-opacity': String(c.facet.navButton.disabledOpacity)
197
213
  };
198
214
  }
199
215
  /** Serialize a CSS-vars record into a `style` attribute string. */
@@ -49,6 +49,13 @@ export declare const THEMES: {
49
49
  gap?: string | undefined;
50
50
  swatchSize?: string | undefined;
51
51
  disabledOpacity?: number | undefined;
52
+ navButton?: {
53
+ background?: string | undefined;
54
+ color?: string | undefined;
55
+ size?: string | undefined;
56
+ fontSize?: string | undefined;
57
+ disabledOpacity?: number | undefined;
58
+ } | undefined;
52
59
  } | undefined;
53
60
  axis?: {
54
61
  color?: string | undefined;
@@ -249,6 +256,13 @@ export declare const THEMES: {
249
256
  gap?: string | undefined;
250
257
  swatchSize?: string | undefined;
251
258
  disabledOpacity?: number | undefined;
259
+ navButton?: {
260
+ background?: string | undefined;
261
+ color?: string | undefined;
262
+ size?: string | undefined;
263
+ fontSize?: string | undefined;
264
+ disabledOpacity?: number | undefined;
265
+ } | undefined;
252
266
  } | undefined;
253
267
  axis?: {
254
268
  color?: string | undefined;
@@ -449,6 +463,13 @@ export declare const THEMES: {
449
463
  gap?: string | undefined;
450
464
  swatchSize?: string | undefined;
451
465
  disabledOpacity?: number | undefined;
466
+ navButton?: {
467
+ background?: string | undefined;
468
+ color?: string | undefined;
469
+ size?: string | undefined;
470
+ fontSize?: string | undefined;
471
+ disabledOpacity?: number | undefined;
472
+ } | undefined;
452
473
  } | undefined;
453
474
  axis?: {
454
475
  color?: string | undefined;
@@ -649,6 +670,13 @@ export declare const THEMES: {
649
670
  gap?: string | undefined;
650
671
  swatchSize?: string | undefined;
651
672
  disabledOpacity?: number | undefined;
673
+ navButton?: {
674
+ background?: string | undefined;
675
+ color?: string | undefined;
676
+ size?: string | undefined;
677
+ fontSize?: string | undefined;
678
+ disabledOpacity?: number | undefined;
679
+ } | undefined;
652
680
  } | undefined;
653
681
  axis?: {
654
682
  color?: string | undefined;
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@ export { default as FacetLayout } from './layout/facet/FacetLayout.svelte';
21
21
  export { default as FacetIndicator } from './layout/facet/FacetIndicator.svelte';
22
22
  export { default as LegendLayout } from './layout/legend/LegendLayout.svelte';
23
23
  export { default as Tooltip } from './layout/tooltip/Tooltip.svelte';
24
- export { getLegendInteraction, provideLegendInteraction, createLegendInteraction, } from './layout/legend/interaction.svelte';
24
+ export { getLegendInteraction, provideLegendInteraction, createLegendInteraction } from './layout/legend/interaction.svelte';
25
25
  export { default as BarMarker } from './markers/BarMarker.svelte';
26
26
  export { default as CellMarker } from './markers/CellMarker.svelte';
27
27
  export { default as DeltaMarker } from './markers/DeltaMarker.svelte';
@@ -31,31 +31,31 @@ export { default as LineMarker } from './markers/LineMarker.svelte';
31
31
  export { default as PeakMarker } from './markers/PeakMarker.svelte';
32
32
  export { default as RuleMarker } from './markers/RuleMarker.svelte';
33
33
  export { default as TextMarker } from './markers/TextMarker.svelte';
34
- export { setConfiguration, getConfiguration, resetConfiguration, configToCssVars, cssVarsStyle, DEFAULT_CONFIG, } from './configuration/config.svelte';
34
+ export { setConfiguration, getConfiguration, resetConfiguration, configToCssVars, cssVarsStyle, DEFAULT_CONFIG } from './configuration/config.svelte';
35
35
  export { THEMES } from './configuration/themes';
36
36
  export type { ThemeName } from './configuration/themes';
37
37
  export { groupBy, buildSeries } from './utils/grouping';
38
38
  export { makeColorScale, normalize, resolveCssColor, paletteColor } from './utils/color';
39
39
  export type { AxisValue, AxisScale, AxisBasedScalesConfig } from './types/layout/scales';
40
40
  export type { Accessor, Series, SeriesProps, DataProps } from './types/plots/data/common';
41
- export type { BasePlotProps, StylesConfig, MarkersConfig, SegmentsConfig, ScalesConfig, MarginConfig, TooltipConfig, HoverConfig, PointMatchingStrategy, } from './types/plots/props';
42
- export type { ValuesStyle, ColorsStyle, FrameStyle, FrameProp, BasePlotStylesConfig, AxisBasedStylesConfig, GapsAwarePlotStylesConfig, GeoStylesConfig, GeoTileLayerConfig, } from './types/layout/styles';
43
- export type { GapsConfig, GapZonesConfig, GapZoneConfig, OpenGapZoneConfig, } from './types/plots/gaps';
41
+ export type { BasePlotProps, StylesConfig, MarkersConfig, SegmentsConfig, ScalesConfig, MarginConfig, TooltipConfig, HoverConfig, PointMatchingStrategy } from './types/plots/props';
42
+ export type { ValuesStyle, ColorsStyle, FrameStyle, FrameProp, BasePlotStylesConfig, AxisBasedStylesConfig, GapsAwarePlotStylesConfig, GeoStylesConfig, GeoTileLayerConfig } from './types/layout/styles';
43
+ export type { GapsConfig, GapZonesConfig, GapZoneConfig, OpenGapZoneConfig } from './types/plots/gaps';
44
44
  export type { LineSegmentStyle, BarSegmentStyle, CellSegmentStyle, GeoSegmentStyle, ScatterSegmentStyle } from './types/plots/segments/common';
45
45
  export type { Range, Area, Segment } from './types/plots/segments/config';
46
- export type { ValueAnchor, SymbolType, LineStyle, LineCap, LineJoin, FontWeight, FontStyleKeyword, TextAnchor, LineAnchor, } from './types/plots/constants';
46
+ export type { ValueAnchor, SymbolType, LineStyle, LineCap, LineJoin, FontWeight, FontStyleKeyword, TextAnchor, LineAnchor } from './types/plots/constants';
47
47
  export type { FontStyle, StrokeStyle, DotStyle, AreaStyle } from './types/plots/styling';
48
48
  export type { AxisBasedMarkersConfig } from './types/markers/common';
49
49
  export type { LineMarkersConfig, DeltaMarkerConfig, DeltaConfig, DeltaTarget } from './types/markers/line';
50
50
  export type { MarkerProps } from './types/markers/props';
51
51
  export type { GeoFeature, GeoJsonInput, GeoDataProps, Topology } from './types/plots/data/geo';
52
52
  export type { ScatterDataProps } from './types/plots/data/scatter';
53
- export type { GeoProjectionName, GeoProjectionConfig, GeoCustomProjection, GeoCustomProjectionFactory, GeoScalesConfig, GeoZoomConfig, CoordinatesConfig, } from './types/layout/coordinates';
54
- export type { GeoMarkersConfig, GeoInsetMarkerConfig, GeoInsetTooltipConfig, GeoInsetLocation, GeoInsetProjectionName, GeoInsetProjectionConfig, } from './types/markers/geo';
55
- export type { ChartPlotContext, ChartPlotSnippet, } from './types/charts/common';
56
- export type { TimeValue, Orientation, TimelineConfig, } from './types/layout/timeline';
57
- export type { Responsive, FacetColumns, FacetMode, FacetNavLayout, FacetConfig, FacetIndicatorContext, FacetIndicatorSnippet, FacetNavDirection, FacetNavContext, FacetNavSnippet, } from './types/layout/facet';
58
- export type { LegendSection, DiscreteLegendSection, ContinuousLegendSection, LegendItem, LegendDisabledStyle, LegendInteractionStore, LegendPlotContext, } from './types/layout/legend';
59
- export type { HoverStrategy, TooltipOptions, TooltipProp, TooltipAnchorX, TooltipAnchorY, } from './types/layout/tooltip';
53
+ export type { GeoProjectionName, GeoProjectionConfig, GeoCustomProjection, GeoCustomProjectionFactory, GeoScalesConfig, GeoZoomConfig, CoordinatesConfig } from './types/layout/coordinates';
54
+ export type { GeoMarkersConfig, GeoInsetMarkerConfig, GeoInsetTooltipConfig, GeoInsetLocation, GeoInsetProjectionName, GeoInsetProjectionConfig } from './types/markers/geo';
55
+ export type { ChartPlotContext, ChartPlotSnippet } from './types/charts/common';
56
+ export type { TimeValue, Orientation, TimelineConfig } from './types/layout/timeline';
57
+ export type { Responsive, FacetColumns, FacetMode, FacetNavLayout, FacetConfig, FacetIndicatorContext, FacetIndicatorSnippet, FacetNavDirection, FacetNavContext, FacetNavSnippet } from './types/layout/facet';
58
+ export type { LegendSection, DiscreteLegendSection, ContinuousLegendSection, LegendItem, LegendDisabledStyle, LegendItemsLayout, LegendNavDirection, LegendNavContext, LegendNavSnippet, LegendInteractionStore, LegendPlotContext } from './types/layout/legend';
59
+ export type { HoverStrategy, TooltipOptions, TooltipProp, TooltipAnchorX, TooltipAnchorY } from './types/layout/tooltip';
60
60
  export type { ChartConfig, ChartConfigInput, TextStyle } from './types/configuration/styling';
61
61
  export type { ChartProps, GapsAwareChartProps } from './types/charts/props';
package/dist/index.js CHANGED
@@ -27,7 +27,7 @@ export { default as FacetLayout } from './layout/facet/FacetLayout.svelte';
27
27
  export { default as FacetIndicator } from './layout/facet/FacetIndicator.svelte';
28
28
  export { default as LegendLayout } from './layout/legend/LegendLayout.svelte';
29
29
  export { default as Tooltip } from './layout/tooltip/Tooltip.svelte';
30
- export { getLegendInteraction, provideLegendInteraction, createLegendInteraction, } from './layout/legend/interaction.svelte';
30
+ export { getLegendInteraction, provideLegendInteraction, createLegendInteraction } from './layout/legend/interaction.svelte';
31
31
  // Markers
32
32
  export { default as BarMarker } from './markers/BarMarker.svelte';
33
33
  export { default as CellMarker } from './markers/CellMarker.svelte';
@@ -38,7 +38,7 @@ export { default as LineMarker } from './markers/LineMarker.svelte';
38
38
  export { default as PeakMarker } from './markers/PeakMarker.svelte';
39
39
  export { default as RuleMarker } from './markers/RuleMarker.svelte';
40
40
  export { default as TextMarker } from './markers/TextMarker.svelte';
41
- export { setConfiguration, getConfiguration, resetConfiguration, configToCssVars, cssVarsStyle, DEFAULT_CONFIG, } from './configuration/config.svelte';
41
+ export { setConfiguration, getConfiguration, resetConfiguration, configToCssVars, cssVarsStyle, DEFAULT_CONFIG } from './configuration/config.svelte';
42
42
  export { THEMES } from './configuration/themes';
43
43
  // ── Utils ─────────────────────────────────────────────────────────────────────
44
44
  export { groupBy, buildSeries } from './utils/grouping';
@@ -1,139 +1,354 @@
1
1
  <script lang="ts">
2
- import { SvelteSet } from 'svelte/reactivity';
3
- import { getConfiguration } from '../../configuration/config.svelte';
4
- import { paletteColor } from '../../utils/color';
5
- import { getLegendInteraction, createLegendInteraction } from './interaction.svelte';
6
- import type { DiscreteLegendSection, LegendItem } from '../../types/layout/legend';
7
- import type { LineStyle } from '../../types/plots/constants';
8
-
9
- let { section }: { section: DiscreteLegendSection } = $props();
10
-
11
- const cfg = $derived(getConfiguration());
12
- const columns = $derived(section.columns ?? 1);
13
- const interaction = getLegendInteraction() ?? createLegendInteraction();
14
-
15
- const seeded = new SvelteSet<string>();
16
- $effect(() => {
17
- for (const item of section.items) {
18
- if (seeded.has(item.name)) continue;
19
- seeded.add(item.name);
20
- if (item.disabled) {
21
- interaction.disabledSeries.set(item.name, section.disabledStyle ?? { opacity: cfg.legend.disabledOpacity });
22
- }
23
- }
24
- });
25
-
26
- function toggle(item: LegendItem): void {
27
- if (!section.interactive) return;
28
- if (interaction.disabledSeries.has(item.name)) {
29
- interaction.disabledSeries.delete(item.name);
30
- } else {
31
- interaction.disabledSeries.set(item.name, section.disabledStyle ?? { opacity: cfg.legend.disabledOpacity });
32
- }
33
- }
34
-
35
- function onKeydown(event: KeyboardEvent, item: LegendItem): void {
36
- if (event.key !== 'Enter' && event.key !== ' ') return;
37
- event.preventDefault();
38
- toggle(item);
39
- }
40
-
41
- function dashArray(style?: LineStyle): string | undefined {
42
- switch (style) {
43
- case 'dashed':
44
- return '2,5';
45
- case 'dotted':
46
- return '1,4';
47
- case 'dashdot':
48
- return '5,3,1,3';
49
- default:
50
- return undefined;
51
- }
52
- }
2
+ import { SvelteSet } from 'svelte/reactivity';
3
+ import { getConfiguration } from '../../configuration/config.svelte';
4
+ import { paletteColor } from '../../utils/color';
5
+ import {
6
+ getLegendInteraction,
7
+ createLegendInteraction
8
+ } from './interaction.svelte';
9
+ import type {
10
+ DiscreteLegendSection,
11
+ LegendItem,
12
+ LegendNavDirection
13
+ } from '../../types/layout/legend';
14
+ import type { LineStyle } from '../../types/plots/constants';
15
+
16
+ let { section }: { section: DiscreteLegendSection } = $props();
17
+
18
+ const cfg = $derived(getConfiguration());
19
+ const layout = $derived(section.layout ?? { type: 'vertical' as const });
20
+ const isHorizontal = $derived(layout.type === 'horizontal');
21
+ const maxRows = $derived(layout.type === 'horizontal' ? Math.max(1, layout.maxRows ?? 1) : 1);
22
+ const columns = $derived(section.columns ?? 1);
23
+ const interaction = getLegendInteraction() ?? createLegendInteraction();
24
+
25
+ const seeded = new SvelteSet<string>();
26
+ $effect(() => {
27
+ for (const item of section.items) {
28
+ if (seeded.has(item.name)) continue;
29
+ seeded.add(item.name);
30
+ if (item.disabled) {
31
+ interaction.disabledSeries.set(
32
+ item.name,
33
+ section.disabledStyle ?? { opacity: cfg.legend.disabledOpacity }
34
+ );
35
+ }
36
+ }
37
+ });
38
+
39
+ function toggle(item: LegendItem): void {
40
+ if (!section.interactive) return;
41
+ if (interaction.disabledSeries.has(item.name)) {
42
+ interaction.disabledSeries.delete(item.name);
43
+ } else {
44
+ interaction.disabledSeries.set(
45
+ item.name,
46
+ section.disabledStyle ?? { opacity: cfg.legend.disabledOpacity }
47
+ );
48
+ }
49
+ }
50
+
51
+ function onKeydown(event: KeyboardEvent, item: LegendItem): void {
52
+ if (event.key !== 'Enter' && event.key !== ' ') return;
53
+ event.preventDefault();
54
+ toggle(item);
55
+ }
56
+
57
+ function dashArray(style?: LineStyle): string | undefined {
58
+ switch (style) {
59
+ case 'dashed':
60
+ return '2,5';
61
+ case 'dotted':
62
+ return '1,4';
63
+ case 'dashdot':
64
+ return '5,3,1,3';
65
+ default:
66
+ return undefined;
67
+ }
68
+ }
69
+
70
+ // ── Horizontal pagination ──────────────────────────────────────────────
71
+ // Rows are discovered by measuring where each item actually lands (its
72
+ // `offsetTop`) in a full, hidden copy of the list, laid out *in normal
73
+ // flow* right alongside the visible (paginated) one rather than
74
+ // `position: absolute`. That in-flow placement is deliberate, not
75
+ // incidental: an absolutely-positioned element is excluded from its
76
+ // ancestors' own width calculations, so once pagination narrowed the
77
+ // visible box down to one or two items, every ancestor up to the legend
78
+ // section — which sizes itself to fit its content, not the other way
79
+ // around — shrank down to hug that now-narrow content instead of staying
80
+ // pinned at the section's actual available width. Each further
81
+ // remeasurement then ran at that smaller width, wrapping even fewer
82
+ // items per row and shrinking again — a runaway feedback loop that read
83
+ // as items "disappearing" into ever-smaller pages. Keeping the full
84
+ // (wide) measurer in flow means its width, not the narrow visible page's,
85
+ // is what ancestors size against — `height: 0; overflow: hidden` below
86
+ // then clips away the vertical space it would otherwise take, so only
87
+ // its width — never its height — affects the layout, and the legend
88
+ // still visually shrinks the moment pagination reduces what's shown.
89
+ let measurerEl: HTMLDivElement | undefined = $state();
90
+ // Resize trigger only, so a later container resize re-runs the effect —
91
+ // its value isn't otherwise used (see the effect body).
92
+ let containerWidth = $state(0);
93
+ let rows = $state<number[][]>([]);
94
+
95
+ $effect(() => {
96
+ if (!isHorizontal || !measurerEl) {
97
+ rows = [];
98
+ return;
99
+ }
100
+ void section.items;
101
+ void containerWidth;
102
+ const children = Array.from(measurerEl.children) as HTMLElement[];
103
+ const grouped: number[][] = [];
104
+ let lastTop = Number.NaN;
105
+ for (let i = 0; i < children.length; i++) {
106
+ const top = children[i].offsetTop;
107
+ if (top !== lastTop) {
108
+ grouped.push([i]);
109
+ lastTop = top;
110
+ } else {
111
+ grouped[grouped.length - 1].push(i);
112
+ }
113
+ }
114
+ rows = grouped;
115
+ });
116
+
117
+ const totalPages = $derived(isHorizontal ? Math.max(1, Math.ceil(rows.length / maxRows)) : 1);
118
+ let page = $state(0);
119
+ $effect(() => {
120
+ if (page > totalPages - 1) page = Math.max(0, totalPages - 1);
121
+ });
122
+
123
+ const showNav = $derived(isHorizontal && totalPages > 1);
124
+
125
+ const visibleItems = $derived.by((): { item: LegendItem; i: number }[] => {
126
+ const all = section.items.map((item, i) => ({ item, i }));
127
+ if (!isHorizontal || rows.length === 0) return all;
128
+ const start = page * maxRows;
129
+ const end = Math.min(rows.length, start + maxRows);
130
+ // `rows` entries are already index-ascending, so flattening the page's
131
+ // row slice preserves item order without needing a lookup set.
132
+ return rows
133
+ .slice(start, end)
134
+ .flat()
135
+ .map((i) => all[i]);
136
+ });
137
+
138
+ function prevPage(): void {
139
+ page = Math.max(0, page - 1);
140
+ }
141
+ function nextPage(): void {
142
+ page = Math.min(totalPages - 1, page + 1);
143
+ }
53
144
  </script>
54
145
 
55
- <div class="dct-discrete" style:grid-template-columns="repeat({columns}, max-content)">
56
- {#each section.items as item, i (item.name)}
57
- {@const disabledStyle = interaction.disabledSeries.get(item.name)}
58
- {@const color = disabledStyle?.fill ?? disabledStyle?.stroke?.stroke ?? item.color ?? paletteColor(i, cfg.palette)}
59
- {@const itemOpacity = disabledStyle ? (disabledStyle.opacity ?? cfg.legend.disabledOpacity) : (item.opacity ?? 1)}
60
- <div
61
- class="dct-discrete__item"
62
- class:dct-discrete__item--interactive={section.interactive}
63
- style:opacity={itemOpacity}
64
- {...section.interactive
65
- ? {
66
- role: 'button',
67
- tabindex: 0,
68
- 'aria-pressed': disabledStyle != null,
69
- onclick: () => toggle(item),
70
- onkeydown: (e: KeyboardEvent) => onKeydown(e, item),
71
- }
72
- : {}}
73
- >
74
- <svg class="dct-discrete__swatch" viewBox="0 0 15 10">
75
- {#if item.symbol === 'circle'}
76
- <circle cx="5" cy="5" r="4" fill={color} />
77
- {:else if item.symbol === 'square'}
78
- <rect x="1" y="1" width="8" height="8" rx="2" fill={color} />
79
- {:else if item.symbol === 'triangle'}
80
- <path
81
- d="M 6 2 L 9 7.5 A 1.5 1.5 0 0 1 7.8 10 L 2.2 10 A 1.5 1.5 0 0 1 1 7.5 L 4 2 A 1.5 1.5 0 0 1 6 2"
82
- fill={color}
83
- transform="translate(0,-1)"
84
- />
85
- {:else if item.symbol === 'line'}
86
- <line
87
- x1="2"
88
- y1="5"
89
- x2="13"
90
- y2="5"
91
- stroke={color}
92
- stroke-width="3"
93
- stroke-linecap="round"
94
- stroke-dasharray={dashArray(item.lineStyle)}
95
- />
96
- {:else}
97
- <rect x="1" y="1" width="8" height="8" fill={color} />
98
- {/if}
99
- </svg>
100
- <span class="dct-discrete__label">{item.label || item.name}</span>
101
- </div>
102
- {/each}
103
- </div>
146
+ {#snippet discreteItem(item: LegendItem, i: number)}
147
+ {@const disabledStyle = interaction.disabledSeries.get(item.name)}
148
+ {@const color =
149
+ disabledStyle?.fill ??
150
+ disabledStyle?.stroke?.stroke ??
151
+ item.color ??
152
+ paletteColor(i, cfg.palette)}
153
+ {@const itemOpacity = disabledStyle
154
+ ? (disabledStyle.opacity ?? cfg.legend.disabledOpacity)
155
+ : (item.opacity ?? 1)}
156
+ <div
157
+ class="dct-discrete__item"
158
+ class:dct-discrete__item--interactive={section.interactive}
159
+ style:opacity={itemOpacity}
160
+ {...section.interactive
161
+ ? {
162
+ role: 'button',
163
+ tabindex: 0,
164
+ 'aria-pressed': disabledStyle != null,
165
+ onclick: () => toggle(item),
166
+ onkeydown: (e: KeyboardEvent) => onKeydown(e, item)
167
+ }
168
+ : {}}
169
+ >
170
+ <svg class="dct-discrete__swatch" viewBox="0 0 15 10">
171
+ {#if item.symbol === 'circle'}
172
+ <circle cx="5" cy="5" r="4" fill={color} />
173
+ {:else if item.symbol === 'square'}
174
+ <rect x="1" y="1" width="8" height="8" rx="2" fill={color} />
175
+ {:else if item.symbol === 'triangle'}
176
+ <path
177
+ d="M 6 2 L 9 7.5 A 1.5 1.5 0 0 1 7.8 10 L 2.2 10 A 1.5 1.5 0 0 1 1 7.5 L 4 2 A 1.5 1.5 0 0 1 6 2"
178
+ fill={color}
179
+ transform="translate(0,-1)"
180
+ />
181
+ {:else if item.symbol === 'line'}
182
+ <line
183
+ x1="2"
184
+ y1="5"
185
+ x2="13"
186
+ y2="5"
187
+ stroke={color}
188
+ stroke-width="3"
189
+ stroke-linecap="round"
190
+ stroke-dasharray={dashArray(item.lineStyle)}
191
+ />
192
+ {:else}
193
+ <rect x="1" y="1" width="8" height="8" fill={color} />
194
+ {/if}
195
+ </svg>
196
+ <span class="dct-discrete__label">{item.label || item.name}</span>
197
+ </div>
198
+ {/snippet}
199
+
200
+ {#snippet navButtonSlot(
201
+ direction: LegendNavDirection,
202
+ disabled: boolean,
203
+ onclick: () => void,
204
+ label: string
205
+ )}
206
+ {#if section.navButton}
207
+ {@render section.navButton({ direction, disabled, onclick })}
208
+ {:else}
209
+ <button
210
+ type="button"
211
+ class="dct-discrete-paginated__btn"
212
+ {disabled}
213
+ {onclick}
214
+ aria-label={label}
215
+ >
216
+ {direction === 'prev' ? '‹' : '›'}
217
+ </button>
218
+ {/if}
219
+ {/snippet}
220
+
221
+ {#if isHorizontal}
222
+ <div class="dct-discrete-paginated">
223
+ {#if showNav}
224
+ {@render navButtonSlot('prev', page === 0, prevPage, 'Página anterior de referencias')}
225
+ {/if}
226
+ <div class="dct-discrete-horizontal" bind:clientWidth={containerWidth}>
227
+ <div class="dct-discrete dct-discrete--horizontal">
228
+ {#each visibleItems as { item, i } (item.name)}
229
+ {@render discreteItem(item, i)}
230
+ {/each}
231
+ </div>
232
+ <div
233
+ class="dct-discrete dct-discrete--horizontal dct-discrete--measure"
234
+ bind:this={measurerEl}
235
+ aria-hidden="true"
236
+ >
237
+ {#each section.items as item, i (item.name)}
238
+ {@render discreteItem(item, i)}
239
+ {/each}
240
+ </div>
241
+ </div>
242
+ {#if showNav}
243
+ {@render navButtonSlot(
244
+ 'next',
245
+ page >= totalPages - 1,
246
+ nextPage,
247
+ 'Página siguiente de referencias'
248
+ )}
249
+ {/if}
250
+ </div>
251
+ {:else}
252
+ <div class="dct-discrete" style:grid-template-columns="repeat({columns}, max-content)">
253
+ {#each section.items as item, i (item.name)}
254
+ {@render discreteItem(item, i)}
255
+ {/each}
256
+ </div>
257
+ {/if}
104
258
 
105
259
  <style>
106
- .dct-discrete {
107
- display: grid;
108
- column-gap: 1.25rem;
109
- row-gap: 0.4rem;
110
- max-width: 100%;
111
- }
112
- .dct-discrete__item {
113
- display: flex;
114
- align-items: center;
115
- gap: 0.35rem;
116
- min-width: 0;
117
- transition: opacity 0.15s ease;
118
- }
119
- .dct-discrete__item--interactive {
120
- cursor: pointer;
121
- }
122
- .dct-discrete__item--interactive:focus-visible {
123
- outline: 2px solid currentColor;
124
- outline-offset: 2px;
125
- }
126
- .dct-discrete__swatch {
127
- width: var(--dct-legend-swatch, 1.25rem);
128
- height: var(--dct-legend-swatch, 1.25rem);
129
- flex-shrink: 0;
130
- }
131
- .dct-discrete__label {
132
- font-size: var(--dct-legend-size, 0.875rem);
133
- font-weight: var(--dct-legend-weight, 400);
134
- white-space: nowrap;
135
- overflow: hidden;
136
- text-overflow: ellipsis;
137
- min-width: 0;
138
- }
260
+ .dct-discrete {
261
+ display: grid;
262
+ column-gap: 1.25rem;
263
+ row-gap: 0.4rem;
264
+ max-width: 100%;
265
+ }
266
+ .dct-discrete--horizontal {
267
+ display: flex;
268
+ flex-wrap: wrap;
269
+ }
270
+ /* Items keep their natural (max-content) width and wrap to a new row
271
+ instead of shrinking/truncating — matching the grid variant, where
272
+ `grid-template-columns: repeat(n, max-content)` never shrinks a column
273
+ either. Without this, flex's default `flex-shrink: 1` would cram every
274
+ item onto one line by squeezing labels down via their own
275
+ `overflow: hidden` before ever wrapping. */
276
+ .dct-discrete--horizontal .dct-discrete__item {
277
+ flex-shrink: 0;
278
+ }
279
+ /* Flanks the rows with the prev/next buttons (when present) instead of
280
+ stacking them below — `align-items: center` keeps each button centered
281
+ against the full block of rows, not pinned to its first/last row. */
282
+ .dct-discrete-paginated {
283
+ display: flex;
284
+ align-items: center;
285
+ gap: var(--dct-legend-nav-gap, 0.35rem);
286
+ }
287
+ .dct-discrete-horizontal {
288
+ flex: 1 1 auto;
289
+ min-width: 0;
290
+ }
291
+ /* Full, untruncated copy of the list used only to measure row breaks — kept
292
+ in normal flow (see the long comment on the measurement effect for why
293
+ it must NOT be `position: absolute` here), with its height collapsed to
294
+ 0 and clipped so it never adds any visible space of its own. Reading
295
+ `offsetTop` off its children still works: `overflow: hidden` only clips
296
+ what's *painted*, not each child's actual computed layout position. */
297
+ .dct-discrete--measure {
298
+ height: 0;
299
+ overflow: hidden;
300
+ visibility: hidden;
301
+ pointer-events: none;
302
+ }
303
+ .dct-discrete__item {
304
+ display: flex;
305
+ align-items: center;
306
+ gap: 0.35rem;
307
+ min-width: 0;
308
+ transition: opacity 0.15s ease;
309
+ }
310
+ .dct-discrete__item--interactive {
311
+ cursor: pointer;
312
+ }
313
+ .dct-discrete__item--interactive:focus-visible {
314
+ outline: 2px solid currentColor;
315
+ outline-offset: 2px;
316
+ }
317
+ .dct-discrete__swatch {
318
+ width: var(--dct-legend-swatch, 1.25rem);
319
+ height: var(--dct-legend-swatch, 1.25rem);
320
+ flex-shrink: 0;
321
+ }
322
+ .dct-discrete__label {
323
+ font-size: var(--dct-legend-size, 0.875rem);
324
+ font-weight: var(--dct-legend-weight, 400);
325
+ white-space: nowrap;
326
+ overflow: hidden;
327
+ text-overflow: ellipsis;
328
+ min-width: 0;
329
+ }
330
+
331
+ .dct-discrete-paginated__btn {
332
+ display: flex;
333
+ align-items: center;
334
+ justify-content: center;
335
+ flex-shrink: 0;
336
+ border: none;
337
+ border-radius: var(--dct-legend-nav-radius, 999px);
338
+ width: var(--dct-legend-nav-size, 1.5rem);
339
+ height: var(--dct-legend-nav-size, 1.5rem);
340
+ padding: 0;
341
+ font-size: var(--dct-legend-nav-font-size, 1rem);
342
+ line-height: 1;
343
+ background: var(--dct-legend-nav-bg, transparent);
344
+ color: var(--dct-legend-nav-color, currentColor);
345
+ cursor: pointer;
346
+ }
347
+ .dct-discrete-paginated__btn:hover:not(:disabled) {
348
+ background: var(--dct-legend-nav-hover-bg, color-mix(in srgb, currentColor 10%, transparent));
349
+ }
350
+ .dct-discrete-paginated__btn:disabled {
351
+ opacity: var(--dct-legend-nav-disabled-opacity, 0.3);
352
+ cursor: default;
353
+ }
139
354
  </style>
@@ -39,6 +39,14 @@ export type ChartConfig = {
39
39
  swatchSize: string;
40
40
  /** Fallback opacity for a legend-disabled series/value when a section doesn't set its own `disabledStyle`. */
41
41
  disabledOpacity: number;
42
+ /** Prev/next arrows shown when a `layout: { type: 'horizontal' }` discrete section overflows `maxRows`. */
43
+ navButton: {
44
+ background: string;
45
+ color: string;
46
+ size: string;
47
+ fontSize: string;
48
+ disabledOpacity: number;
49
+ };
42
50
  };
43
51
  axis: {
44
52
  color: string;
@@ -1,3 +1,4 @@
1
+ import type { Snippet } from 'svelte';
1
2
  import type { SvelteMap } from 'svelte/reactivity';
2
3
  import type { SymbolType, LineStyle } from '../plots/constants';
3
4
  import type { StrokeStyle, DotStyle } from '../plots/styling';
@@ -26,15 +27,37 @@ export type LegendItem = {
26
27
  /** Initial toggled-off state (uncontrolled — the user can still click it back on). */
27
28
  disabled?: boolean;
28
29
  };
30
+ /**
31
+ * How a discrete section's items flow. `'vertical'` (the default) stacks them
32
+ * in a `columns`-wide grid, as it always has. `'horizontal'` flows them left
33
+ * to right, wrapping to new rows as needed — and once wrapped rows exceed
34
+ * `maxRows` (default 1), the section paginates instead of growing taller,
35
+ * surfacing prev/next arrows to page through the rest.
36
+ */
37
+ export type LegendItemsLayout = {
38
+ type: 'vertical';
39
+ } | {
40
+ type: 'horizontal';
41
+ maxRows?: number;
42
+ };
43
+ export type LegendNavDirection = 'prev' | 'next';
44
+ export type LegendNavContext = {
45
+ direction: LegendNavDirection;
46
+ disabled: boolean;
47
+ onclick: () => void;
48
+ };
49
+ /** Custom prev/next button renderer for a paginated horizontal section. Falls back to a small default arrow button. */
50
+ export type LegendNavSnippet = Snippet<[LegendNavContext]>;
29
51
  export type DiscreteLegendSection = {
30
52
  type: 'discrete';
31
53
  title?: string;
32
54
  items: LegendItem[];
33
55
  columns?: number;
34
- /** Enables click-to-toggle on each item. */
56
+ /** Item flow direction. Defaults to `{ type: 'vertical' }` (unchanged prior behaviour). */
57
+ layout?: LegendItemsLayout;
35
58
  interactive?: boolean;
36
- /** Style applied to a toggled-off item's series. Falls back to `cfg.legend.disabledOpacity`. */
37
59
  disabledStyle?: LegendDisabledStyle;
60
+ navButton?: LegendNavSnippet;
38
61
  };
39
62
  export type ContinuousLegendSection = {
40
63
  type: 'continuous';
@@ -47,7 +70,6 @@ export type ContinuousLegendSection = {
47
70
  ticks?: number[];
48
71
  width?: number;
49
72
  format?: (v: number) => string;
50
- /** Enables drag handles on the bar's edges to narrow the visible range. */
51
73
  interactive?: boolean;
52
74
  /**
53
75
  * How the color ramp responds to a narrowed range: `'rescale'` reprojects
@@ -55,7 +77,6 @@ export type ContinuousLegendSection = {
55
77
  * excludes out-of-range values. Defaults to `'filter'`.
56
78
  */
57
79
  mode?: 'rescale' | 'filter';
58
- /** Style applied to values outside the active range. Falls back to `cfg.legend.disabledOpacity`. */
59
80
  disabledStyle?: LegendDisabledStyle;
60
81
  };
61
82
  export type LegendSection = DiscreteLegendSection | ContinuousLegendSection;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fundar/data-chart-telling",
3
- "version": "0.0.33",
3
+ "version": "0.0.34",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"