@fundar/data-chart-telling 0.0.26 → 0.0.28

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.
Files changed (58) hide show
  1. package/dist/charts/BaseChart.svelte +18 -10
  2. package/dist/charts/BaseChart.svelte.d.ts +4 -2
  3. package/dist/charts/heatmap/Chart.svelte +2 -2
  4. package/dist/charts/pyramid/Chart.svelte +2 -2
  5. package/dist/configuration/config.svelte.js +26 -0
  6. package/dist/configuration/themes/dark.js +4 -0
  7. package/dist/configuration/themes/index.d.ts +64 -0
  8. package/dist/index.d.ts +6 -2
  9. package/dist/index.js +2 -0
  10. package/dist/layout/coordinates/CoordinatesLayout.svelte +1 -1
  11. package/dist/layout/facet/FacetIndicator.svelte +31 -0
  12. package/dist/layout/facet/FacetIndicator.svelte.d.ts +8 -0
  13. package/dist/layout/facet/FacetLayout.svelte +249 -24
  14. package/dist/layout/facet/FacetLayout.svelte.d.ts +7 -1
  15. package/dist/layout/legend/ContinuousSection.svelte +1 -1
  16. package/dist/layout/legend/ContinuousSection.svelte.d.ts +1 -1
  17. package/dist/layout/legend/DiscreteSection.svelte +1 -1
  18. package/dist/layout/legend/DiscreteSection.svelte.d.ts +1 -1
  19. package/dist/layout/legend/LegendLayout.svelte +1 -1
  20. package/dist/layout/legend/LegendLayout.svelte.d.ts +1 -1
  21. package/dist/layout/legend/interaction.svelte.d.ts +9 -1
  22. package/dist/layout/legend/interaction.svelte.js +9 -1
  23. package/dist/layout/timeline/Timeline.svelte +1 -1
  24. package/dist/layout/timeline/Timeline.svelte.d.ts +1 -1
  25. package/dist/layout/timeline/TimelineLayout.svelte +1 -1
  26. package/dist/layout/timeline/TimelineLayout.svelte.d.ts +1 -1
  27. package/dist/layout/tooltip/controller.svelte.js +16 -2
  28. package/dist/markers/HoverMarker.svelte +2 -2
  29. package/dist/plots/bar/Plot.svelte +43 -4
  30. package/dist/plots/bar/ValueLabels.svelte +69 -23
  31. package/dist/plots/bar/ValueLabels.svelte.d.ts +10 -1
  32. package/dist/plots/heatmap/Plot.svelte +4 -4
  33. package/dist/plots/heatmap/ValueLabels.svelte +3 -3
  34. package/dist/plots/heatmap/ValueLabels.svelte.d.ts +1 -1
  35. package/dist/plots/line/Plot.svelte +34 -29
  36. package/dist/plots/line/ValueLabels.svelte +73 -51
  37. package/dist/plots/line/ValueLabels.svelte.d.ts +8 -4
  38. package/dist/plots/pyramid/Plot.svelte +41 -6
  39. package/dist/plots/pyramid/ValueLabels.svelte +70 -24
  40. package/dist/plots/pyramid/ValueLabels.svelte.d.ts +10 -1
  41. package/dist/plots/scatter/Plot.svelte +40 -4
  42. package/dist/plots/scatter/ValueLabels.svelte +90 -47
  43. package/dist/plots/scatter/ValueLabels.svelte.d.ts +14 -5
  44. package/dist/plots/utils/labelOverflow.svelte.d.ts +51 -0
  45. package/dist/plots/utils/labelOverflow.svelte.js +116 -0
  46. package/dist/plots/utils/legendDisabled.d.ts +1 -1
  47. package/dist/types/charts/common.d.ts +3 -50
  48. package/dist/types/charts/props.d.ts +3 -2
  49. package/dist/types/configuration/styling.d.ts +19 -0
  50. package/dist/types/layout/facet.d.ts +41 -0
  51. package/dist/types/layout/legend.d.ts +65 -1
  52. package/dist/types/layout/styles.d.ts +1 -12
  53. package/dist/types/layout/timeline.d.ts +32 -0
  54. package/dist/types/layout/timeline.js +1 -0
  55. package/dist/utils/interpolate.d.ts +1 -1
  56. package/package.json +1 -1
  57. package/dist/types/charts/legend.d.ts +0 -60
  58. /package/dist/types/{charts/legend.js → layout/facet.js} +0 -0
@@ -7,14 +7,11 @@
7
7
  import { provideHover } from '../layout/tooltip/hover.svelte';
8
8
  import { provideLegendInteraction } from '../layout/legend/interaction.svelte';
9
9
  import type { Snippet } from 'svelte';
10
- import type { LegendSection } from '../types/charts/legend';
10
+ import type { LegendSection } from '../types/layout/legend';
11
11
  import type { HoverStrategy } from '../types/layout/tooltip';
12
- import type {
13
- ChartPlotSnippet,
14
- FacetConfig,
15
- TimeValue,
16
- TimelineConfig,
17
- } from '../types/charts/common';
12
+ import type { ChartPlotSnippet } from '../types/charts/common';
13
+ import type { FacetConfig } from '../types/layout/facet';
14
+ import type { TimeValue, TimelineConfig } from '../types/layout/timeline';
18
15
 
19
16
  /**
20
17
  * The orchestrator. It wires together the four interchangeable layout
@@ -60,7 +57,11 @@
60
57
  const cfg = $derived(getConfiguration());
61
58
 
62
59
  const hoverStore = provideHover();
63
- provideLegendInteraction();
60
+ const legendInteraction = provideLegendInteraction();
61
+ const legendPlotContext = $derived({
62
+ disabledSeries: new Set(legendInteraction.disabledSeries.keys()),
63
+ isDisabled: (name: string) => legendInteraction.disabledSeries.has(name),
64
+ });
64
65
  $effect(() => {
65
66
  hoverStore.strategy = hover?.strategy ?? 'x';
66
67
  hoverStore.sync = hover?.sync ?? false;
@@ -75,6 +76,12 @@
75
76
  columns={facet.columns}
76
77
  values={facet.values}
77
78
  gap={facet.gap}
79
+ mode={facet.mode}
80
+ visible={facet.visible}
81
+ pageSize={facet.pageSize}
82
+ formatIndicator={facet.formatIndicator}
83
+ indicator={facet.indicator}
84
+ navButton={facet.navButton}
78
85
  width={w}
79
86
  height={h}
80
87
  >
@@ -84,12 +91,13 @@
84
91
  width: cw,
85
92
  height: ch,
86
93
  facetValue,
87
- selectedValue
94
+ selectedValue,
95
+ legend: legendPlotContext
88
96
  })}
89
97
  {/snippet}
90
98
  </FacetLayout>
91
99
  {:else}
92
- {@render plot({ data: rows, width: w, height: h, selectedValue })}
100
+ {@render plot({ data: rows, width: w, height: h, selectedValue, legend: legendPlotContext })}
93
101
  {/if}
94
102
  {/snippet}
95
103
 
@@ -1,7 +1,9 @@
1
1
  import type { Snippet } from 'svelte';
2
- import type { LegendSection } from '../types/charts/legend';
2
+ import type { LegendSection } from '../types/layout/legend';
3
3
  import type { HoverStrategy } from '../types/layout/tooltip';
4
- import type { ChartPlotSnippet, FacetConfig, TimelineConfig } from '../types/charts/common';
4
+ import type { ChartPlotSnippet } from '../types/charts/common';
5
+ import type { FacetConfig } from '../types/layout/facet';
6
+ import type { TimelineConfig } from '../types/layout/timeline';
5
7
  declare function $$render<TRow extends Record<string, unknown>>(): {
6
8
  props: {
7
9
  width: number;
@@ -46,7 +46,7 @@
46
46
  const getX = $derived(resolveAccessor<TRow>(x));
47
47
  const getY = $derived(resolveAccessor<TRow>(y));
48
48
  const getZ = $derived(resolveAccessor<TRow>(z));
49
- const fmtVal = $derived(styles.values?.format ?? ((v: number) => `${v}`));
49
+ const formatValue = $derived(styles.values?.format ?? ((v: number) => `${v}`));
50
50
 
51
51
  // Compute domain over the full dataset so colour is comparable across facets/timeline steps.
52
52
  // Caller-provided `scales.z.domain` always takes precedence.
@@ -66,7 +66,7 @@
66
66
  const defaultFormat = $derived((row: TRow) => {
67
67
  const xl = scales.x?.label || 'x';
68
68
  const yl = scales.y?.label || 'y';
69
- return `${xl} ${getX(row)} · ${yl} ${getY(row)}\n${fmtVal(Number(getZ(row)), '')}`;
69
+ return `${xl} ${getX(row)} · ${yl} ${getY(row)}\n${formatValue(Number(getZ(row)), '')}`;
70
70
  });
71
71
  </script>
72
72
 
@@ -48,7 +48,7 @@
48
48
  const getMagnitude = $derived(resolveAccessor<TRow>(magnitude));
49
49
  const getCategory = $derived(resolveAccessor<TRow>(category));
50
50
  const getSides = $derived(resolveAccessor<TRow>(sides));
51
- const fmtVal = $derived(styles.values?.format ?? ((v: number) => `${v}`));
51
+ const formatValue = $derived(styles.values?.format ?? ((v: number) => `${v}`));
52
52
 
53
53
  // Computed once over the full dataset (not the per-facet/timeline-filtered
54
54
  // rows) so the diverging axis and category bands stay comparable across
@@ -86,7 +86,7 @@
86
86
  const resolvedStrategy = $derived(resolveHoverStrategy(tip.enabled ? tip.strategy : undefined, markers, 'y'));
87
87
  const resolvedSync = $derived(resolveHoverSync(tip.enabled ? tip.sync : undefined, markers));
88
88
  const defaultFormat = $derived(
89
- (row: TRow) => `${String(getSides(row))}\n${String(getCategory(row))}: ${fmtVal(Number(getMagnitude(row)), String(getSides(row)))}`,
89
+ (row: TRow) => `${String(getSides(row))}\n${String(getCategory(row))}: ${formatValue(Number(getMagnitude(row)), String(getSides(row)))}`,
90
90
  );
91
91
  </script>
92
92
 
@@ -89,6 +89,22 @@ export const DEFAULT_CONFIG = {
89
89
  font: { fill: 'currentColor', fontSize: 12, dy: -18 },
90
90
  area: { stroke: '#e6580d', strokeWidth: 2, fillOpacity: 0 },
91
91
  },
92
+ facet: {
93
+ indicator: {
94
+ background: '#111827',
95
+ color: '#ffffff',
96
+ fontSize: '0.8125rem',
97
+ padding: '0.125rem 0.5rem',
98
+ radius: '999px',
99
+ },
100
+ navButton: {
101
+ background: '#111827',
102
+ color: '#ffffff',
103
+ size: '2rem',
104
+ fontSize: '1.1rem',
105
+ disabledOpacity: 0.3,
106
+ },
107
+ },
92
108
  };
93
109
  // ── Merge ─────────────────────────────────────────────────────────────────────
94
110
  function isPlainObject(v) {
@@ -162,6 +178,16 @@ export function configToCssVars(c) {
162
178
  '--dct-heat-min': c.continuous.min,
163
179
  '--dct-heat-max': c.continuous.max,
164
180
  '--dct-heat-text': c.continuous.labelColor,
181
+ '--dct-facet-indicator-bg': c.facet.indicator.background,
182
+ '--dct-facet-indicator-color': c.facet.indicator.color,
183
+ '--dct-facet-indicator-size': c.facet.indicator.fontSize,
184
+ '--dct-facet-indicator-pad': c.facet.indicator.padding,
185
+ '--dct-facet-indicator-radius': c.facet.indicator.radius,
186
+ '--dct-facet-nav-bg': c.facet.navButton.background,
187
+ '--dct-facet-nav-color': c.facet.navButton.color,
188
+ '--dct-facet-nav-btn-size': c.facet.navButton.size,
189
+ '--dct-facet-nav-font-size': c.facet.navButton.fontSize,
190
+ '--dct-facet-nav-disabled-opacity': String(c.facet.navButton.disabledOpacity),
165
191
  };
166
192
  }
167
193
  /** Serialize a CSS-vars record into a `style` attribute string. */
@@ -11,6 +11,10 @@ export const DARK_THEME = {
11
11
  continuous: { min: '#1e293b', max: '#38bdf8', labelColor: '#e5e7eb' },
12
12
  axis: { color: '#94a3b8', fontSize: 13, xTickSize: 6, yTickSize: 6 },
13
13
  tooltip: { background: '#e5e7eb', color: '#0f172a' },
14
+ facet: {
15
+ indicator: { background: '#e5e7eb', color: '#0f172a' },
16
+ navButton: { background: '#e5e7eb', color: '#0f172a' },
17
+ },
14
18
  line: { strokeWidth: 3, dotRadius: 4 },
15
19
  timeline: { activeColor: '#38bdf8', axisColor: '#94a3b8', thickness: 60 },
16
20
  hover: { rule: { stroke: '#38bdf8' } },
@@ -146,6 +146,22 @@ export declare const THEMES: {
146
146
  borderRadius?: number | undefined;
147
147
  } | undefined;
148
148
  } | undefined;
149
+ facet?: {
150
+ indicator?: {
151
+ background?: string | undefined;
152
+ color?: string | undefined;
153
+ fontSize?: string | undefined;
154
+ padding?: string | undefined;
155
+ radius?: string | undefined;
156
+ } | undefined;
157
+ navButton?: {
158
+ background?: string | undefined;
159
+ color?: string | undefined;
160
+ size?: string | undefined;
161
+ fontSize?: string | undefined;
162
+ disabledOpacity?: number | undefined;
163
+ } | undefined;
164
+ } | undefined;
149
165
  };
150
166
  Editorial: {
151
167
  font?: string | undefined;
@@ -288,6 +304,22 @@ export declare const THEMES: {
288
304
  borderRadius?: number | undefined;
289
305
  } | undefined;
290
306
  } | undefined;
307
+ facet?: {
308
+ indicator?: {
309
+ background?: string | undefined;
310
+ color?: string | undefined;
311
+ fontSize?: string | undefined;
312
+ padding?: string | undefined;
313
+ radius?: string | undefined;
314
+ } | undefined;
315
+ navButton?: {
316
+ background?: string | undefined;
317
+ color?: string | undefined;
318
+ size?: string | undefined;
319
+ fontSize?: string | undefined;
320
+ disabledOpacity?: number | undefined;
321
+ } | undefined;
322
+ } | undefined;
291
323
  };
292
324
  Dark: {
293
325
  font?: string | undefined;
@@ -430,6 +462,22 @@ export declare const THEMES: {
430
462
  borderRadius?: number | undefined;
431
463
  } | undefined;
432
464
  } | undefined;
465
+ facet?: {
466
+ indicator?: {
467
+ background?: string | undefined;
468
+ color?: string | undefined;
469
+ fontSize?: string | undefined;
470
+ padding?: string | undefined;
471
+ radius?: string | undefined;
472
+ } | undefined;
473
+ navButton?: {
474
+ background?: string | undefined;
475
+ color?: string | undefined;
476
+ size?: string | undefined;
477
+ fontSize?: string | undefined;
478
+ disabledOpacity?: number | undefined;
479
+ } | undefined;
480
+ } | undefined;
433
481
  };
434
482
  Compact: {
435
483
  font?: string | undefined;
@@ -572,6 +620,22 @@ export declare const THEMES: {
572
620
  borderRadius?: number | undefined;
573
621
  } | undefined;
574
622
  } | undefined;
623
+ facet?: {
624
+ indicator?: {
625
+ background?: string | undefined;
626
+ color?: string | undefined;
627
+ fontSize?: string | undefined;
628
+ padding?: string | undefined;
629
+ radius?: string | undefined;
630
+ } | undefined;
631
+ navButton?: {
632
+ background?: string | undefined;
633
+ color?: string | undefined;
634
+ size?: string | undefined;
635
+ fontSize?: string | undefined;
636
+ disabledOpacity?: number | undefined;
637
+ } | undefined;
638
+ } | undefined;
575
639
  };
576
640
  };
577
641
  export type ThemeName = keyof typeof THEMES;
package/dist/index.d.ts CHANGED
@@ -18,8 +18,10 @@ export { argentinaBicontinental } from './layout/coordinates/projections/argenti
18
18
  export { default as Timeline } from './layout/timeline/Timeline.svelte';
19
19
  export { default as TimelineLayout } from './layout/timeline/TimelineLayout.svelte';
20
20
  export { default as FacetLayout } from './layout/facet/FacetLayout.svelte';
21
+ export { default as FacetIndicator } from './layout/facet/FacetIndicator.svelte';
21
22
  export { default as LegendLayout } from './layout/legend/LegendLayout.svelte';
22
23
  export { default as Tooltip } from './layout/tooltip/Tooltip.svelte';
24
+ export { getLegendInteraction, provideLegendInteraction, createLegendInteraction, } from './layout/legend/interaction.svelte';
23
25
  export { default as BarMarker } from './markers/BarMarker.svelte';
24
26
  export { default as CellMarker } from './markers/CellMarker.svelte';
25
27
  export { default as DeltaMarker } from './markers/DeltaMarker.svelte';
@@ -49,8 +51,10 @@ export type { GeoFeature, GeoJsonInput, GeoDataProps, Topology } from './types/p
49
51
  export type { ScatterDataProps } from './types/plots/data/scatter';
50
52
  export type { GeoProjectionName, GeoProjectionConfig, GeoCustomProjection, GeoCustomProjectionFactory, GeoScalesConfig, GeoZoomConfig, CoordinatesConfig, } from './types/layout/coordinates';
51
53
  export type { GeoMarkersConfig, GeoInsetMarkerConfig, GeoInsetTooltipConfig, GeoInsetLocation, GeoInsetProjectionName, GeoInsetProjectionConfig, } from './types/markers/geo';
52
- export type { TimeValue, Orientation, FacetColumns, FacetConfig, TimelineConfig, ChartPlotContext, ChartPlotSnippet, } from './types/charts/common';
53
- export type { LegendSection, DiscreteLegendSection, ContinuousLegendSection, LegendItem, LegendDisabledStyle, } from './types/charts/legend';
54
+ export type { ChartPlotContext, ChartPlotSnippet, } from './types/charts/common';
55
+ export type { TimeValue, Orientation, TimelineConfig, } from './types/layout/timeline';
56
+ export type { Responsive, FacetColumns, FacetMode, FacetConfig, FacetIndicatorContext, FacetIndicatorSnippet, FacetNavDirection, FacetNavContext, FacetNavSnippet, } from './types/layout/facet';
57
+ export type { LegendSection, DiscreteLegendSection, ContinuousLegendSection, LegendItem, LegendDisabledStyle, LegendInteractionStore, LegendPlotContext, } from './types/layout/legend';
54
58
  export type { HoverStrategy, TooltipOptions, TooltipProp, TooltipAnchorX, TooltipAnchorY, } from './types/layout/tooltip';
55
59
  export type { ChartConfig, ChartConfigInput, TextStyle } from './types/configuration/styling';
56
60
  export type { ChartProps } from './types/charts/props';
package/dist/index.js CHANGED
@@ -24,8 +24,10 @@ export { argentinaBicontinental } from './layout/coordinates/projections/argenti
24
24
  export { default as Timeline } from './layout/timeline/Timeline.svelte';
25
25
  export { default as TimelineLayout } from './layout/timeline/TimelineLayout.svelte';
26
26
  export { default as FacetLayout } from './layout/facet/FacetLayout.svelte';
27
+ export { default as FacetIndicator } from './layout/facet/FacetIndicator.svelte';
27
28
  export { default as LegendLayout } from './layout/legend/LegendLayout.svelte';
28
29
  export { default as Tooltip } from './layout/tooltip/Tooltip.svelte';
30
+ export { getLegendInteraction, provideLegendInteraction, createLegendInteraction, } from './layout/legend/interaction.svelte';
29
31
  // Markers
30
32
  export { default as BarMarker } from './markers/BarMarker.svelte';
31
33
  export { default as CellMarker } from './markers/CellMarker.svelte';
@@ -42,7 +42,7 @@
42
42
  } = $props();
43
43
 
44
44
  const resolvedProjection = $derived(
45
- coordinates?.projection ? resolveProjection(coordinates.projection, data) : undefined,
45
+ coordinates ? resolveProjection(coordinates.projection, data) : undefined,
46
46
  );
47
47
 
48
48
  const zoomEnabled = $derived(coordinates?.zoom !== false && coordinates?.zoom != null);
@@ -0,0 +1,31 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Default position badge for `carousel`/`paginated` facet modes. Styled from
4
+ * the `--dct-facet-indicator-*` config vars; positioning is owned by the
5
+ * parent that renders it.
6
+ */
7
+ let {
8
+ current,
9
+ total,
10
+ format,
11
+ }: {
12
+ current: number;
13
+ total: number;
14
+ format?: (current: number, total: number) => string;
15
+ } = $props();
16
+
17
+ const text = $derived(format ? format(current, total) : `${current} / ${total}`);
18
+ </script>
19
+
20
+ <div class="dct-facet-indicator">{text}</div>
21
+
22
+ <style>
23
+ .dct-facet-indicator {
24
+ padding: var(--dct-facet-indicator-pad, 0.125rem 0.5rem);
25
+ border-radius: var(--dct-facet-indicator-radius, 999px);
26
+ font-size: var(--dct-facet-indicator-size, 0.8125rem);
27
+ background: var(--dct-facet-indicator-bg, #111827);
28
+ color: var(--dct-facet-indicator-color, #fff);
29
+ pointer-events: none;
30
+ }
31
+ </style>
@@ -0,0 +1,8 @@
1
+ type $$ComponentProps = {
2
+ current: number;
3
+ total: number;
4
+ format?: (current: number, total: number) => string;
5
+ };
6
+ declare const FacetIndicator: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type FacetIndicator = ReturnType<typeof FacetIndicator>;
8
+ export default FacetIndicator;
@@ -1,17 +1,27 @@
1
1
  <script lang="ts" generics="TRow extends Record<string, unknown>">
2
2
  import { isMobile } from '../../stores/device';
3
3
  import { groupBy } from '../../utils/grouping';
4
+ import FacetIndicator from './FacetIndicator.svelte';
4
5
  import type {
5
6
  FacetColumns,
7
+ FacetMode,
8
+ Responsive,
6
9
  FacetCellSnippet,
7
- } from '../../types/charts/common';
10
+ FacetIndicatorSnippet,
11
+ FacetNavSnippet,
12
+ FacetNavDirection,
13
+ } from '../../types/layout/facet';
8
14
  import type { Accessor } from '../../types/plots/data/common';
9
15
 
10
16
  /**
11
- * Small-multiples layout. It splits the data by `by`, arranges the groups in a
12
- * responsive grid and renders each group through the `cell` snippet with its
13
- * own measured box. Like the other primitives it never draws the plot itself
14
- * any plot that accepts `{ data, width, height }` can be faceted.
17
+ * Small-multiples layout. It splits the data by `by` and hands each group to
18
+ * the `cell` snippet with its own measured box. Like the other primitives it
19
+ * never draws the plot itself any plot that accepts `{ data, width, height }`
20
+ * can be faceted.
21
+ *
22
+ * `mode` picks how the groups are arranged: `'grid'` (default) lays them all
23
+ * out at once; `'paginated'` pages through the same grid in fixed chunks;
24
+ * `'carousel'` shows `visible` groups at a time with touch-drag/snap.
15
25
  */
16
26
  let {
17
27
  data,
@@ -19,6 +29,12 @@
19
29
  columns = { desktop: 3, mobile: 1 },
20
30
  values,
21
31
  gap = 12,
32
+ mode = 'grid',
33
+ visible = 1,
34
+ pageSize,
35
+ formatIndicator,
36
+ indicator,
37
+ navButton,
22
38
  width,
23
39
  height,
24
40
  cell,
@@ -28,11 +44,24 @@
28
44
  columns?: FacetColumns;
29
45
  values?: string[];
30
46
  gap?: number;
47
+ mode?: Responsive<FacetMode>;
48
+ visible?: number;
49
+ pageSize?: number;
50
+ formatIndicator?: (current: number, total: number) => string;
51
+ indicator?: FacetIndicatorSnippet;
52
+ navButton?: FacetNavSnippet;
31
53
  width: number;
32
54
  height: number;
33
55
  cell: FacetCellSnippet<TRow>;
34
56
  } = $props();
35
57
 
58
+ function resolveResponsive<T>(value: Responsive<T>, mobile: boolean): T {
59
+ if (typeof value === 'object' && value !== null && 'desktop' in value) {
60
+ return mobile ? (value as { desktop: T; mobile: T }).mobile : (value as { desktop: T; mobile: T }).desktop;
61
+ }
62
+ return value as T;
63
+ }
64
+
36
65
  const groups = $derived(groupBy(data, by));
37
66
 
38
67
  const entries = $derived.by((): [string, TRow[]][] => {
@@ -42,13 +71,10 @@
42
71
  .map((v) => [v, groups.get(v)!] as [string, TRow[]]);
43
72
  });
44
73
 
74
+ const resolvedMode = $derived(resolveResponsive(mode, $isMobile));
75
+
45
76
  const cols = $derived.by(() => {
46
- const desired =
47
- typeof columns === 'number'
48
- ? columns
49
- : $isMobile
50
- ? columns.mobile
51
- : columns.desktop;
77
+ const desired = resolveResponsive(columns, $isMobile);
52
78
  return Math.max(1, Math.min(desired, entries.length || 1));
53
79
  });
54
80
 
@@ -58,22 +84,164 @@
58
84
  const cellHeight = $derived(
59
85
  Math.max(0, Math.floor((height - (rows - 1) * gap) / rows) - TITLE_H),
60
86
  );
87
+
88
+ // ── paginated ────────────────────────────────────────────────────────────
89
+ const resolvedPageSize = $derived(Math.max(1, pageSize ?? cols * rows));
90
+ let page = $state(0);
91
+ const totalPages = $derived(Math.max(1, Math.ceil(entries.length / resolvedPageSize)));
92
+ const pageEntries = $derived(
93
+ entries.slice(page * resolvedPageSize, page * resolvedPageSize + resolvedPageSize),
94
+ );
95
+
96
+ $effect(() => {
97
+ if (page > totalPages - 1) page = Math.max(0, totalPages - 1);
98
+ });
99
+
100
+ function prevPage(): void {
101
+ page = Math.max(0, page - 1);
102
+ }
103
+ function nextPage(): void {
104
+ page = Math.min(totalPages - 1, page + 1);
105
+ }
106
+
107
+ // ── carousel ─────────────────────────────────────────────────────────────
108
+ const visibleCount = $derived(Math.max(1, Math.min(visible, entries.length || 1)));
109
+ const carouselCellWidth = $derived(
110
+ Math.max(0, Math.floor((width - (visibleCount - 1) * gap) / visibleCount)),
111
+ );
112
+ const carouselCellHeight = $derived(Math.max(0, height - TITLE_H));
113
+ const maxIndex = $derived(Math.max(0, entries.length - visibleCount));
114
+ const stepWidth = $derived(carouselCellWidth + gap);
115
+
116
+ let activeIndex = $state(0);
117
+ let dragOffset = $state(0);
118
+ let dragging = $state(false);
119
+ let dragStartX = 0;
120
+
121
+ const translate = $derived(-activeIndex * stepWidth + dragOffset);
122
+
123
+ $effect(() => {
124
+ if (activeIndex > maxIndex) activeIndex = maxIndex;
125
+ });
126
+
127
+ // Reset browsing position whenever the faceted field or its value set changes.
128
+ $effect(() => {
129
+ void by;
130
+ void values;
131
+ page = 0;
132
+ activeIndex = 0;
133
+ });
134
+
135
+ function handleTouchStart(event: TouchEvent): void {
136
+ dragging = true;
137
+ dragStartX = event.touches[0].clientX;
138
+ dragOffset = 0;
139
+ }
140
+
141
+ function handleTouchMove(event: TouchEvent): void {
142
+ if (!dragging) return;
143
+ dragOffset = event.touches[0].clientX - dragStartX;
144
+ }
145
+
146
+ function handleTouchEnd(): void {
147
+ if (!dragging) return;
148
+ dragging = false;
149
+ const stepsMoved = Math.round(-dragOffset / stepWidth);
150
+ activeIndex = Math.max(0, Math.min(maxIndex, activeIndex + stepsMoved));
151
+ dragOffset = 0;
152
+ }
61
153
  </script>
62
154
 
63
- <div
64
- class="dct-facet"
65
- style:grid-template-columns="repeat({cols}, 1fr)"
66
- style:gap="{gap}px"
67
- >
68
- {#each entries as [facetValue, rowsForCell] (facetValue)}
69
- <div class="dct-facet__cell">
70
- <div class="dct-facet__title">{facetValue}</div>
71
- {#if cellWidth > 0 && cellHeight > 0}
72
- {@render cell({ facetValue, data: rowsForCell, width: cellWidth, height: cellHeight })}
73
- {/if}
155
+ {#snippet indicatorSlot(current: number, total: number)}
156
+ <div class="dct-facet-indicator-slot">
157
+ {#if indicator}
158
+ {@render indicator({ current, total })}
159
+ {:else}
160
+ <FacetIndicator {current} {total} format={formatIndicator} />
161
+ {/if}
162
+ </div>
163
+ {/snippet}
164
+
165
+ {#snippet navButtons(direction: FacetNavDirection, disabled: boolean, onclick: () => void, label: string, glyph: string)}
166
+ <span class="dct-facet-nav-slot">
167
+ {#if navButton}
168
+ {@render navButton({ direction, disabled, onclick })}
169
+ {:else}
170
+ <button
171
+ type="button"
172
+ class="dct-facet-paginated__btn"
173
+ {disabled}
174
+ {onclick}
175
+ aria-label={label}
176
+ >
177
+ {glyph}
178
+ </button>
179
+ {/if}
180
+ </span>
181
+ {/snippet}
182
+
183
+ {#snippet grid(gridEntries: [string, TRow[]][])}
184
+ <div class="dct-facet" style:grid-template-columns="repeat({cols}, 1fr)" style:gap="{gap}px">
185
+ {#each gridEntries as [facetValue, rowsForCell] (facetValue)}
186
+ <div class="dct-facet__cell">
187
+ <div class="dct-facet__title">{facetValue}</div>
188
+ {#if cellWidth > 0 && cellHeight > 0}
189
+ {@render cell({ facetValue, data: rowsForCell, width: cellWidth, height: cellHeight })}
190
+ {/if}
191
+ </div>
192
+ {/each}
193
+ </div>
194
+ {/snippet}
195
+
196
+ {#if resolvedMode === 'paginated'}
197
+ <div class="dct-facet-paginated" style:width="{width}px" style:height="{height}px">
198
+ {@render grid(pageEntries)}
199
+ {#if totalPages > 1}
200
+ <div class="dct-facet-paginated__nav">
201
+ {@render navButtons('prev', page === 0, prevPage, 'Previous page', '‹')}
202
+ {@render navButtons('next', page >= totalPages - 1, nextPage, 'Next page', '›')}
203
+ </div>
204
+ {@render indicatorSlot(page + 1, totalPages)}
205
+ {/if}
206
+ </div>
207
+ {:else if resolvedMode === 'carousel'}
208
+ <div
209
+ class="dct-facet-carousel"
210
+ role="group"
211
+ aria-roledescription="carousel"
212
+ style:width="{width}px"
213
+ style:height="{height}px"
214
+ ontouchstart={handleTouchStart}
215
+ ontouchmove={handleTouchMove}
216
+ ontouchend={handleTouchEnd}
217
+ >
218
+ <div
219
+ class="dct-facet-carousel__track"
220
+ class:dct-facet-carousel__track--dragging={dragging}
221
+ style:gap="{gap}px"
222
+ style:transform="translateX({translate}px)"
223
+ >
224
+ {#each entries as [facetValue, rowsForCell] (facetValue)}
225
+ <div class="dct-facet__cell dct-facet-carousel__cell" style:width="{carouselCellWidth}px">
226
+ <div class="dct-facet__title">{facetValue}</div>
227
+ {#if carouselCellWidth > 0 && carouselCellHeight > 0}
228
+ {@render cell({
229
+ facetValue,
230
+ data: rowsForCell,
231
+ width: carouselCellWidth,
232
+ height: carouselCellHeight,
233
+ })}
234
+ {/if}
235
+ </div>
236
+ {/each}
74
237
  </div>
75
- {/each}
76
- </div>
238
+ {#if entries.length > 1}
239
+ {@render indicatorSlot(activeIndex + 1, entries.length)}
240
+ {/if}
241
+ </div>
242
+ {:else}
243
+ {@render grid(entries)}
244
+ {/if}
77
245
 
78
246
  <style>
79
247
  .dct-facet {
@@ -94,4 +262,61 @@
94
262
  text-align: center;
95
263
  opacity: 0.9;
96
264
  }
265
+
266
+ .dct-facet-paginated {
267
+ position: relative;
268
+ display: flex;
269
+ flex-direction: column;
270
+ }
271
+ .dct-facet-paginated__nav {
272
+ position: absolute;
273
+ top: 50%;
274
+ left: 0;
275
+ right: 0;
276
+ display: flex;
277
+ justify-content: space-between;
278
+ transform: translateY(-50%);
279
+ pointer-events: none;
280
+ }
281
+ .dct-facet-nav-slot {
282
+ pointer-events: auto;
283
+ }
284
+ .dct-facet-paginated__btn {
285
+ border: none;
286
+ border-radius: 999px;
287
+ width: var(--dct-facet-nav-btn-size, 2rem);
288
+ height: var(--dct-facet-nav-btn-size, 2rem);
289
+ font-size: var(--dct-facet-nav-font-size, 1.1rem);
290
+ line-height: 1;
291
+ background: var(--dct-facet-nav-bg, #111827);
292
+ color: var(--dct-facet-nav-color, #fff);
293
+ cursor: pointer;
294
+ }
295
+ .dct-facet-paginated__btn:disabled {
296
+ opacity: var(--dct-facet-nav-disabled-opacity, 0.3);
297
+ cursor: default;
298
+ }
299
+
300
+ .dct-facet-indicator-slot {
301
+ position: absolute;
302
+ right: 0.5rem;
303
+ bottom: 0.5rem;
304
+ }
305
+
306
+ .dct-facet-carousel {
307
+ position: relative;
308
+ overflow: hidden;
309
+ touch-action: pan-y;
310
+ }
311
+ .dct-facet-carousel__track {
312
+ display: flex;
313
+ height: 100%;
314
+ transition: transform 0.25s ease;
315
+ }
316
+ .dct-facet-carousel__track--dragging {
317
+ transition: none;
318
+ }
319
+ .dct-facet-carousel__cell {
320
+ flex: 0 0 auto;
321
+ }
97
322
  </style>