@sentropic/dataviz-svelte 0.1.0 → 0.2.0
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.
- package/dist/CrossfilteredBarChart.svelte +80 -0
- package/dist/CrossfilteredBarChart.svelte.d.ts +32 -0
- package/dist/CrossfilteredBarChart.svelte.d.ts.map +1 -0
- package/dist/CrossfilteredBarChart.test.js +63 -0
- package/dist/DashboardFilterBar.svelte +51 -0
- package/dist/DashboardFilterBar.svelte.d.ts +14 -0
- package/dist/DashboardFilterBar.svelte.d.ts.map +1 -0
- package/dist/DashboardFilterBar.test.js +59 -0
- package/dist/DrillBarChart.svelte +83 -0
- package/dist/DrillBarChart.svelte.d.ts +23 -0
- package/dist/DrillBarChart.svelte.d.ts.map +1 -0
- package/dist/DrillBarChart.test.js +52 -0
- package/dist/DrillBreadcrumb.svelte +57 -0
- package/dist/DrillBreadcrumb.svelte.d.ts +18 -0
- package/dist/DrillBreadcrumb.svelte.d.ts.map +1 -0
- package/dist/DrillBreadcrumb.test.js +37 -0
- package/dist/FieldPane.svelte +38 -0
- package/dist/FieldPane.svelte.d.ts +14 -0
- package/dist/FieldPane.svelte.d.ts.map +1 -0
- package/dist/FieldPane.test.js +36 -0
- package/dist/KpiCardGroup.svelte +63 -0
- package/dist/KpiCardGroup.svelte.d.ts +17 -0
- package/dist/KpiCardGroup.svelte.d.ts.map +1 -0
- package/dist/KpiCardGroup.test.js +33 -0
- package/dist/PivotDataTable.svelte +62 -0
- package/dist/PivotDataTable.svelte.d.ts +15 -0
- package/dist/PivotDataTable.svelte.d.ts.map +1 -0
- package/dist/PivotDataTable.test.js +37 -0
- package/dist/RecordsTable.svelte +53 -0
- package/dist/RecordsTable.svelte.d.ts +17 -0
- package/dist/RecordsTable.svelte.d.ts.map +1 -0
- package/dist/RecordsTable.test.js +43 -0
- package/dist/SelectionLegend.svelte +42 -0
- package/dist/SelectionLegend.svelte.d.ts +14 -0
- package/dist/SelectionLegend.svelte.d.ts.map +1 -0
- package/dist/SelectionLegend.test.js +47 -0
- package/dist/SmallMultiples.svelte +81 -0
- package/dist/SmallMultiples.svelte.d.ts +25 -0
- package/dist/SmallMultiples.svelte.d.ts.map +1 -0
- package/dist/SmallMultiples.test.js +47 -0
- package/package.json +21 -9
- package/dist/index.d.ts +0 -41
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -42
- package/dist/index.js.map +0 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { DashboardStore } from '@sentropic/dataviz-core';
|
|
3
|
+
import type { BarChartTone } from '@sentropic/design-system-svelte';
|
|
4
|
+
|
|
5
|
+
export type CrossfilteredBarChartProps = {
|
|
6
|
+
/** The dashboard store to bind to. */
|
|
7
|
+
store: DashboardStore;
|
|
8
|
+
/** This chart's view id in the cross-filter graph. */
|
|
9
|
+
viewId: string;
|
|
10
|
+
/** Dimension id to group rows by (one bar per distinct value). */
|
|
11
|
+
dimension: string;
|
|
12
|
+
/** Measure id to aggregate into each bar's value. */
|
|
13
|
+
measure: string;
|
|
14
|
+
/** Accessible label of the chart (required by the design-system BarChart). */
|
|
15
|
+
label: string;
|
|
16
|
+
/** Bar colour tone from the design system. */
|
|
17
|
+
tone?: BarChartTone;
|
|
18
|
+
/**
|
|
19
|
+
* When true (default) clicking a bar toggles this view's selection
|
|
20
|
+
* (brushing input → `store.toggleSelection`); selected bars are highlighted.
|
|
21
|
+
* Set false for an output-only facet.
|
|
22
|
+
*/
|
|
23
|
+
selectable?: boolean;
|
|
24
|
+
/** Fixed value-axis domain `[min, max]` for a shared scale across facets. */
|
|
25
|
+
domain?: [number, number];
|
|
26
|
+
orientation?: 'vertical' | 'horizontal';
|
|
27
|
+
width?: number;
|
|
28
|
+
height?: number;
|
|
29
|
+
class?: string;
|
|
30
|
+
};
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<script lang="ts">
|
|
34
|
+
import { BarChart, type BarChartDatum } from '@sentropic/design-system-svelte';
|
|
35
|
+
import { findDimension, findMeasure, groupAggregate } from '@sentropic/dataviz-core';
|
|
36
|
+
import { useDashboard } from '../adapter.js';
|
|
37
|
+
|
|
38
|
+
let {
|
|
39
|
+
store,
|
|
40
|
+
viewId,
|
|
41
|
+
dimension,
|
|
42
|
+
measure,
|
|
43
|
+
label,
|
|
44
|
+
tone,
|
|
45
|
+
selectable = true,
|
|
46
|
+
domain,
|
|
47
|
+
orientation = 'vertical',
|
|
48
|
+
width,
|
|
49
|
+
height,
|
|
50
|
+
class: className,
|
|
51
|
+
}: CrossfilteredBarChartProps = $props();
|
|
52
|
+
|
|
53
|
+
// `$dash` establishes the reactive dependency so the chart re-aggregates on
|
|
54
|
+
// every store mutation; the rows visible to this view come from the core
|
|
55
|
+
// cross-filter scope.
|
|
56
|
+
const dash = $derived(useDashboard(store));
|
|
57
|
+
const data = $derived.by((): BarChartDatum[] => {
|
|
58
|
+
void $dash;
|
|
59
|
+
const dim = findDimension(store.model, dimension);
|
|
60
|
+
const m = findMeasure(store.model, measure);
|
|
61
|
+
if (!dim || !m) return [];
|
|
62
|
+
const rows = store.applyCrossfilter(viewId);
|
|
63
|
+
return groupAggregate(rows, dimension, m).map(({ key, value }) =>
|
|
64
|
+
tone ? { label: key, value, tone } : { label: key, value },
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
const selectedKeys = $derived($dash.selections[viewId] ?? []);
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<BarChart
|
|
71
|
+
{data}
|
|
72
|
+
{label}
|
|
73
|
+
{orientation}
|
|
74
|
+
{width}
|
|
75
|
+
{height}
|
|
76
|
+
{domain}
|
|
77
|
+
class={className}
|
|
78
|
+
selectedKeys={selectable ? selectedKeys : []}
|
|
79
|
+
onSelect={selectable ? (key) => store.toggleSelection(viewId, key) : undefined}
|
|
80
|
+
/>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { DashboardStore } from '@sentropic/dataviz-core';
|
|
2
|
+
import type { BarChartTone } from '@sentropic/design-system-svelte';
|
|
3
|
+
export type CrossfilteredBarChartProps = {
|
|
4
|
+
/** The dashboard store to bind to. */
|
|
5
|
+
store: DashboardStore;
|
|
6
|
+
/** This chart's view id in the cross-filter graph. */
|
|
7
|
+
viewId: string;
|
|
8
|
+
/** Dimension id to group rows by (one bar per distinct value). */
|
|
9
|
+
dimension: string;
|
|
10
|
+
/** Measure id to aggregate into each bar's value. */
|
|
11
|
+
measure: string;
|
|
12
|
+
/** Accessible label of the chart (required by the design-system BarChart). */
|
|
13
|
+
label: string;
|
|
14
|
+
/** Bar colour tone from the design system. */
|
|
15
|
+
tone?: BarChartTone;
|
|
16
|
+
/**
|
|
17
|
+
* When true (default) clicking a bar toggles this view's selection
|
|
18
|
+
* (brushing input → `store.toggleSelection`); selected bars are highlighted.
|
|
19
|
+
* Set false for an output-only facet.
|
|
20
|
+
*/
|
|
21
|
+
selectable?: boolean;
|
|
22
|
+
/** Fixed value-axis domain `[min, max]` for a shared scale across facets. */
|
|
23
|
+
domain?: [number, number];
|
|
24
|
+
orientation?: 'vertical' | 'horizontal';
|
|
25
|
+
width?: number;
|
|
26
|
+
height?: number;
|
|
27
|
+
class?: string;
|
|
28
|
+
};
|
|
29
|
+
declare const CrossfilteredBarChart: import("svelte").Component<CrossfilteredBarChartProps, {}, "">;
|
|
30
|
+
type CrossfilteredBarChart = ReturnType<typeof CrossfilteredBarChart>;
|
|
31
|
+
export default CrossfilteredBarChart;
|
|
32
|
+
//# sourceMappingURL=CrossfilteredBarChart.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CrossfilteredBarChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/CrossfilteredBarChart.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAEpE,MAAM,MAAM,0BAA0B,GAAG;IACvC,sCAAsC;IACtC,KAAK,EAAE,cAAc,CAAC;IACtB,sDAAsD;IACtD,MAAM,EAAE,MAAM,CAAC;IACf,kEAAkE;IAClE,SAAS,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,8EAA8E;IAC9E,KAAK,EAAE,MAAM,CAAC;IACd,8CAA8C;IAC9C,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,6EAA6E;IAC7E,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC1B,WAAW,CAAC,EAAE,UAAU,GAAG,YAAY,CAAC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAkDJ,QAAA,MAAM,qBAAqB,gEAAwC,CAAC;AACpE,KAAK,qBAAqB,GAAG,UAAU,CAAC,OAAO,qBAAqB,CAAC,CAAC;AACtE,eAAe,qBAAqB,CAAC"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { render } from '@testing-library/svelte';
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { createDashboardStore } from '@sentropic/dataviz-core';
|
|
5
|
+
import CrossfilteredBarChart from './CrossfilteredBarChart.svelte';
|
|
6
|
+
const model = {
|
|
7
|
+
dimensions: [
|
|
8
|
+
{ id: 'country', label: 'Pays', type: 'discrete' },
|
|
9
|
+
{ id: 'product', label: 'Produit', type: 'discrete' },
|
|
10
|
+
],
|
|
11
|
+
measures: [{ id: 'sales', label: 'Ventes', aggregation: 'sum' }],
|
|
12
|
+
};
|
|
13
|
+
const data = [
|
|
14
|
+
{ country: 'FR', product: 'A', sales: 10 },
|
|
15
|
+
{ country: 'FR', product: 'B', sales: 5 },
|
|
16
|
+
{ country: 'US', product: 'A', sales: 20 },
|
|
17
|
+
];
|
|
18
|
+
const newStore = () => createDashboardStore({ model, data });
|
|
19
|
+
const bars = (c) => c.querySelectorAll('.st-barChart__bar').length;
|
|
20
|
+
const baseProps = { viewId: 'byCountry', dimension: 'country', measure: 'sales', label: 'Ventes par pays' };
|
|
21
|
+
describe('CrossfilteredBarChart', () => {
|
|
22
|
+
it('exposes the chart with its accessible label', () => {
|
|
23
|
+
const { getByRole } = render(CrossfilteredBarChart, { props: { store: newStore(), ...baseProps } });
|
|
24
|
+
expect(getByRole('img', { name: 'Ventes par pays' })).toBeTruthy();
|
|
25
|
+
});
|
|
26
|
+
it('aggregates rows into one bar (and one selection chip) per distinct value', () => {
|
|
27
|
+
const { container, getByRole } = render(CrossfilteredBarChart, { props: { store: newStore(), ...baseProps } });
|
|
28
|
+
expect(bars(container)).toBe(2);
|
|
29
|
+
expect(getByRole('button', { name: /^FR:/ })).toBeTruthy();
|
|
30
|
+
expect(getByRole('button', { name: /^US:/ })).toBeTruthy();
|
|
31
|
+
});
|
|
32
|
+
it('toggles this view selection when a bar chip is clicked (brushing input)', () => {
|
|
33
|
+
const store = newStore();
|
|
34
|
+
const { getByRole } = render(CrossfilteredBarChart, { props: { store, ...baseProps } });
|
|
35
|
+
getByRole('button', { name: /^FR:/ }).click();
|
|
36
|
+
expect(store.getState().selections.byCountry).toEqual(['FR']);
|
|
37
|
+
});
|
|
38
|
+
it('re-aggregates reactively as the shared filter state narrows the rows', async () => {
|
|
39
|
+
const store = newStore();
|
|
40
|
+
const { container, queryByRole } = render(CrossfilteredBarChart, { props: { store, ...baseProps } });
|
|
41
|
+
expect(bars(container)).toBe(2);
|
|
42
|
+
store.setFilter('country', { kind: 'include', values: ['FR'] });
|
|
43
|
+
await tick();
|
|
44
|
+
expect(bars(container)).toBe(1);
|
|
45
|
+
expect(queryByRole('button', { name: /^US:/ })).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
it('is output-only when selectable=false (no selection chips)', () => {
|
|
48
|
+
const { queryByRole } = render(CrossfilteredBarChart, {
|
|
49
|
+
props: { store: newStore(), ...baseProps, selectable: false },
|
|
50
|
+
});
|
|
51
|
+
expect(queryByRole('button', { name: /^FR:/ })).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
it('renders no bars when the measure or dimension is unknown', () => {
|
|
54
|
+
const { container: c1 } = render(CrossfilteredBarChart, {
|
|
55
|
+
props: { store: newStore(), viewId: 'v', dimension: 'country', measure: 'nope', label: 'Vide' },
|
|
56
|
+
});
|
|
57
|
+
expect(bars(c1)).toBe(0);
|
|
58
|
+
const { container: c2 } = render(CrossfilteredBarChart, {
|
|
59
|
+
props: { store: newStore(), viewId: 'v', dimension: 'nope', measure: 'sales', label: 'Vide' },
|
|
60
|
+
});
|
|
61
|
+
expect(bars(c2)).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { DashboardStore } from '@sentropic/dataviz-core';
|
|
3
|
+
|
|
4
|
+
export type DashboardFilterBarProps = {
|
|
5
|
+
/** The dashboard store to bind to. */
|
|
6
|
+
store: DashboardStore;
|
|
7
|
+
/** Aria-label of the filter group. */
|
|
8
|
+
label?: string;
|
|
9
|
+
/** Label of the "clear all" button (design-system default otherwise). */
|
|
10
|
+
clearAllLabel?: string;
|
|
11
|
+
class?: string;
|
|
12
|
+
};
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<script lang="ts">
|
|
16
|
+
import { FilterBar, FilterPill } from '@sentropic/design-system-svelte';
|
|
17
|
+
import { findDimension, describeFilterSpec } from '@sentropic/dataviz-core';
|
|
18
|
+
import { useDashboard } from '../adapter.js';
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
store,
|
|
22
|
+
label = 'Filtres actifs',
|
|
23
|
+
clearAllLabel,
|
|
24
|
+
class: className,
|
|
25
|
+
}: DashboardFilterBarProps = $props();
|
|
26
|
+
|
|
27
|
+
// `store` is read inside `$derived` (a reactive context) so the readable is
|
|
28
|
+
// re-created if the prop changes; `$dash` auto-subscribes (SSR-safe).
|
|
29
|
+
const dash = $derived(useDashboard(store));
|
|
30
|
+
const entries = $derived(Object.entries($dash.filters));
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<FilterBar
|
|
34
|
+
{label}
|
|
35
|
+
{clearAllLabel}
|
|
36
|
+
class={className}
|
|
37
|
+
onClearAll={entries.length > 0
|
|
38
|
+
? () => {
|
|
39
|
+
for (const id of Object.keys($dash.filters)) store.clearFilter(id);
|
|
40
|
+
}
|
|
41
|
+
: undefined}
|
|
42
|
+
>
|
|
43
|
+
{#each entries as [dimensionId, spec] (dimensionId)}
|
|
44
|
+
{@const dimension = findDimension(store.model, dimensionId)}
|
|
45
|
+
<FilterPill
|
|
46
|
+
field={dimension?.label ?? dimensionId}
|
|
47
|
+
value={describeFilterSpec(spec, dimension)}
|
|
48
|
+
onRemove={() => store.clearFilter(dimensionId)}
|
|
49
|
+
/>
|
|
50
|
+
{/each}
|
|
51
|
+
</FilterBar>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DashboardStore } from '@sentropic/dataviz-core';
|
|
2
|
+
export type DashboardFilterBarProps = {
|
|
3
|
+
/** The dashboard store to bind to. */
|
|
4
|
+
store: DashboardStore;
|
|
5
|
+
/** Aria-label of the filter group. */
|
|
6
|
+
label?: string;
|
|
7
|
+
/** Label of the "clear all" button (design-system default otherwise). */
|
|
8
|
+
clearAllLabel?: string;
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
declare const DashboardFilterBar: import("svelte").Component<DashboardFilterBarProps, {}, "">;
|
|
12
|
+
type DashboardFilterBar = ReturnType<typeof DashboardFilterBar>;
|
|
13
|
+
export default DashboardFilterBar;
|
|
14
|
+
//# sourceMappingURL=DashboardFilterBar.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DashboardFilterBar.svelte.d.ts","sourceRoot":"","sources":["../src/lib/DashboardFilterBar.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAE9D,MAAM,MAAM,uBAAuB,GAAG;IACpC,sCAAsC;IACtC,KAAK,EAAE,cAAc,CAAC;IACtB,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAwCJ,QAAA,MAAM,kBAAkB,6DAAwC,CAAC;AACjE,KAAK,kBAAkB,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAChE,eAAe,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/svelte';
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { createDashboardStore } from '@sentropic/dataviz-core';
|
|
5
|
+
import DashboardFilterBar from './DashboardFilterBar.svelte';
|
|
6
|
+
const model = {
|
|
7
|
+
dimensions: [
|
|
8
|
+
{ id: 'country', label: 'Pays', type: 'discrete' },
|
|
9
|
+
{ id: 'price', label: 'Prix', type: 'continuous' },
|
|
10
|
+
],
|
|
11
|
+
measures: [{ id: 'sales', label: 'Ventes', aggregation: 'sum' }],
|
|
12
|
+
};
|
|
13
|
+
const newStore = () => createDashboardStore({ model, data: [] });
|
|
14
|
+
describe('DashboardFilterBar', () => {
|
|
15
|
+
it('exposes the filter group with its aria-label', () => {
|
|
16
|
+
const store = newStore();
|
|
17
|
+
render(DashboardFilterBar, { props: { store, label: 'Filtres actifs' } });
|
|
18
|
+
expect(screen.getByRole('group', { name: 'Filtres actifs' })).toBeTruthy();
|
|
19
|
+
});
|
|
20
|
+
it('renders one pill per active filter, labelled by the dimension', () => {
|
|
21
|
+
const store = newStore();
|
|
22
|
+
store.setFilter('country', { kind: 'include', values: ['France', 'Italie'] });
|
|
23
|
+
render(DashboardFilterBar, { props: { store } });
|
|
24
|
+
expect(screen.getByText('Pays')).toBeTruthy();
|
|
25
|
+
expect(screen.getByText('France, Italie')).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
it('clears a single filter via its pill remove button', () => {
|
|
28
|
+
const store = newStore();
|
|
29
|
+
store.setFilter('country', { kind: 'include', values: ['France'] });
|
|
30
|
+
render(DashboardFilterBar, { props: { store } });
|
|
31
|
+
const remove = screen.getByRole('button', { name: 'Retirer le filtre Pays' });
|
|
32
|
+
remove.click();
|
|
33
|
+
expect(store.getState().filters.country).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
it('clears every filter — but preserves selections — via the clear-all button', () => {
|
|
36
|
+
const store = newStore();
|
|
37
|
+
store.setFilter('country', { kind: 'include', values: ['France'] });
|
|
38
|
+
store.setFilter('price', { kind: 'range', min: 10 });
|
|
39
|
+
store.toggleSelection('byCountry', 'FR');
|
|
40
|
+
render(DashboardFilterBar, { props: { store } });
|
|
41
|
+
screen.getByRole('button', { name: 'Tout effacer' }).click();
|
|
42
|
+
expect(Object.keys(store.getState().filters)).toHaveLength(0);
|
|
43
|
+
expect(store.getState().selections.byCountry).toEqual(['FR']);
|
|
44
|
+
});
|
|
45
|
+
it('shows no clear-all button when there are no filters', () => {
|
|
46
|
+
const store = newStore();
|
|
47
|
+
render(DashboardFilterBar, { props: { store } });
|
|
48
|
+
expect(screen.queryByRole('button', { name: 'Tout effacer' })).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
it('reacts to a filter set after mount', async () => {
|
|
51
|
+
const store = newStore();
|
|
52
|
+
render(DashboardFilterBar, { props: { store } });
|
|
53
|
+
expect(screen.queryByText('≥ 5')).toBeNull();
|
|
54
|
+
store.setFilter('price', { kind: 'range', min: 5 });
|
|
55
|
+
await tick();
|
|
56
|
+
expect(screen.getByText('≥ 5')).toBeTruthy();
|
|
57
|
+
expect(screen.getByText('Prix')).toBeTruthy();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { DashboardStore } from '@sentropic/dataviz-core';
|
|
3
|
+
import type { BarChartTone } from '@sentropic/design-system-svelte';
|
|
4
|
+
|
|
5
|
+
export type DrillBarChartProps = {
|
|
6
|
+
/** The dashboard store to bind to. */
|
|
7
|
+
store: DashboardStore;
|
|
8
|
+
/** This view's id (drill state + cross-filter scope live under it). */
|
|
9
|
+
viewId: string;
|
|
10
|
+
/** Ordered dimension hierarchy; bars group by the current drill level. */
|
|
11
|
+
hierarchy: string[];
|
|
12
|
+
/** Measure id aggregated into each bar's value. */
|
|
13
|
+
measure: string;
|
|
14
|
+
/** Accessible label of the chart. */
|
|
15
|
+
label: string;
|
|
16
|
+
tone?: BarChartTone;
|
|
17
|
+
orientation?: 'vertical' | 'horizontal';
|
|
18
|
+
width?: number;
|
|
19
|
+
height?: number;
|
|
20
|
+
class?: string;
|
|
21
|
+
};
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<script lang="ts">
|
|
25
|
+
import { BarChart, type BarChartDatum } from '@sentropic/design-system-svelte';
|
|
26
|
+
import { findMeasure, groupAggregate } from '@sentropic/dataviz-core';
|
|
27
|
+
import { useDashboard } from '../adapter.js';
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
store,
|
|
31
|
+
viewId,
|
|
32
|
+
hierarchy,
|
|
33
|
+
measure,
|
|
34
|
+
label,
|
|
35
|
+
tone,
|
|
36
|
+
orientation = 'vertical',
|
|
37
|
+
width,
|
|
38
|
+
height,
|
|
39
|
+
class: className,
|
|
40
|
+
}: DrillBarChartProps = $props();
|
|
41
|
+
|
|
42
|
+
const dash = $derived(useDashboard(store));
|
|
43
|
+
// Current depth = length of the drill path, capped at the deepest level.
|
|
44
|
+
const level = $derived(
|
|
45
|
+
Math.min(($dash.drill[viewId] ?? []).length, Math.max(hierarchy.length - 1, 0)),
|
|
46
|
+
);
|
|
47
|
+
const currentDim = $derived(hierarchy[level]);
|
|
48
|
+
const canDrill = $derived(level < hierarchy.length - 1);
|
|
49
|
+
|
|
50
|
+
const data = $derived.by((): BarChartDatum[] => {
|
|
51
|
+
void $dash;
|
|
52
|
+
const m = findMeasure(store.model, measure);
|
|
53
|
+
if (!m || !currentDim) return [];
|
|
54
|
+
return groupAggregate(store.applyCrossfilter(viewId), currentDim, m).map(({ key, value }) =>
|
|
55
|
+
tone ? { label: key, value, tone } : { label: key, value },
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const selectedKeys = $derived($dash.selections[viewId] ?? []);
|
|
60
|
+
|
|
61
|
+
// Clicking a bar drills one level deeper (filter the clicked value, push the
|
|
62
|
+
// next hierarchy dimension as group-by). At the deepest level there is nowhere
|
|
63
|
+
// to drill, so a click toggles this view's selection (brushing) instead.
|
|
64
|
+
function onSelect(key: string) {
|
|
65
|
+
if (canDrill) {
|
|
66
|
+
store.setFilter(currentDim, { kind: 'include', values: [key] });
|
|
67
|
+
store.drillDown(viewId, hierarchy[level + 1]);
|
|
68
|
+
} else {
|
|
69
|
+
store.toggleSelection(viewId, key);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<BarChart
|
|
75
|
+
{data}
|
|
76
|
+
{label}
|
|
77
|
+
{orientation}
|
|
78
|
+
{width}
|
|
79
|
+
{height}
|
|
80
|
+
class={className}
|
|
81
|
+
selectedKeys={canDrill ? [] : selectedKeys}
|
|
82
|
+
{onSelect}
|
|
83
|
+
/>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { DashboardStore } from '@sentropic/dataviz-core';
|
|
2
|
+
import type { BarChartTone } from '@sentropic/design-system-svelte';
|
|
3
|
+
export type DrillBarChartProps = {
|
|
4
|
+
/** The dashboard store to bind to. */
|
|
5
|
+
store: DashboardStore;
|
|
6
|
+
/** This view's id (drill state + cross-filter scope live under it). */
|
|
7
|
+
viewId: string;
|
|
8
|
+
/** Ordered dimension hierarchy; bars group by the current drill level. */
|
|
9
|
+
hierarchy: string[];
|
|
10
|
+
/** Measure id aggregated into each bar's value. */
|
|
11
|
+
measure: string;
|
|
12
|
+
/** Accessible label of the chart. */
|
|
13
|
+
label: string;
|
|
14
|
+
tone?: BarChartTone;
|
|
15
|
+
orientation?: 'vertical' | 'horizontal';
|
|
16
|
+
width?: number;
|
|
17
|
+
height?: number;
|
|
18
|
+
class?: string;
|
|
19
|
+
};
|
|
20
|
+
declare const DrillBarChart: import("svelte").Component<DrillBarChartProps, {}, "">;
|
|
21
|
+
type DrillBarChart = ReturnType<typeof DrillBarChart>;
|
|
22
|
+
export default DrillBarChart;
|
|
23
|
+
//# sourceMappingURL=DrillBarChart.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DrillBarChart.svelte.d.ts","sourceRoot":"","sources":["../src/lib/DrillBarChart.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iCAAiC,CAAC;AAEpE,MAAM,MAAM,kBAAkB,GAAG;IAC/B,sCAAsC;IACtC,KAAK,EAAE,cAAc,CAAC;IACtB,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,mDAAmD;IACnD,OAAO,EAAE,MAAM,CAAC;IAChB,qCAAqC;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,WAAW,CAAC,EAAE,UAAU,GAAG,YAAY,CAAC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA+DJ,QAAA,MAAM,aAAa,wDAAwC,CAAC;AAC5D,KAAK,aAAa,GAAG,UAAU,CAAC,OAAO,aAAa,CAAC,CAAC;AACtD,eAAe,aAAa,CAAC"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { render } from '@testing-library/svelte';
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { createDashboardStore } from '@sentropic/dataviz-core';
|
|
5
|
+
import DrillBarChart from './DrillBarChart.svelte';
|
|
6
|
+
const model = {
|
|
7
|
+
dimensions: [
|
|
8
|
+
{ id: 'region', label: 'Région', type: 'discrete' },
|
|
9
|
+
{ id: 'city', label: 'Ville', type: 'discrete' },
|
|
10
|
+
],
|
|
11
|
+
measures: [{ id: 'sales', label: 'Ventes', aggregation: 'sum' }],
|
|
12
|
+
};
|
|
13
|
+
const data = [
|
|
14
|
+
{ region: 'Nord', city: 'Lille', sales: 10 },
|
|
15
|
+
{ region: 'Nord', city: 'Lille', sales: 5 },
|
|
16
|
+
{ region: 'Nord', city: 'Rouen', sales: 7 },
|
|
17
|
+
{ region: 'Sud', city: 'Nice', sales: 20 },
|
|
18
|
+
];
|
|
19
|
+
const newStore = () => createDashboardStore({ model, data });
|
|
20
|
+
const base = { viewId: 'd', hierarchy: ['region', 'city'], measure: 'sales', label: 'Ventes' };
|
|
21
|
+
describe('DrillBarChart', () => {
|
|
22
|
+
it('groups by the first hierarchy level initially', () => {
|
|
23
|
+
const { getByRole } = render(DrillBarChart, { props: { store: newStore(), ...base } });
|
|
24
|
+
expect(getByRole('button', { name: /^Nord:/ })).toBeTruthy();
|
|
25
|
+
expect(getByRole('button', { name: /^Sud:/ })).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
it('drills down on bar click: filters the clicked value and pushes the next level', () => {
|
|
28
|
+
const store = newStore();
|
|
29
|
+
const { getByRole } = render(DrillBarChart, { props: { store, ...base } });
|
|
30
|
+
getByRole('button', { name: /^Nord:/ }).click();
|
|
31
|
+
expect(store.getState().drill.d).toEqual(['city']);
|
|
32
|
+
expect(store.getState().filters.region).toEqual({ kind: 'include', values: ['Nord'] });
|
|
33
|
+
});
|
|
34
|
+
it('shows the cities of the drilled value after drilling', async () => {
|
|
35
|
+
const store = newStore();
|
|
36
|
+
const { getByRole, queryByRole } = render(DrillBarChart, { props: { store, ...base } });
|
|
37
|
+
getByRole('button', { name: /^Nord:/ }).click();
|
|
38
|
+
await tick();
|
|
39
|
+
expect(getByRole('button', { name: /^Lille:/ })).toBeTruthy();
|
|
40
|
+
expect(getByRole('button', { name: /^Rouen:/ })).toBeTruthy();
|
|
41
|
+
expect(queryByRole('button', { name: /^Nice:/ })).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
it('toggles selection at the deepest level instead of drilling', async () => {
|
|
44
|
+
const store = newStore();
|
|
45
|
+
const { getByRole } = render(DrillBarChart, { props: { store, ...base } });
|
|
46
|
+
getByRole('button', { name: /^Nord:/ }).click();
|
|
47
|
+
await tick();
|
|
48
|
+
getByRole('button', { name: /^Lille:/ }).click();
|
|
49
|
+
expect(store.getState().selections.d).toEqual(['Lille']);
|
|
50
|
+
expect(store.getState().drill.d).toEqual(['city']);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { DashboardStore } from '@sentropic/dataviz-core';
|
|
3
|
+
|
|
4
|
+
export type DrillBreadcrumbProps = {
|
|
5
|
+
/** The dashboard store to bind to. */
|
|
6
|
+
store: DashboardStore;
|
|
7
|
+
/** This view's id (must match the DrillBarChart it accompanies). */
|
|
8
|
+
viewId: string;
|
|
9
|
+
/** The same ordered dimension hierarchy used by the DrillBarChart. */
|
|
10
|
+
hierarchy: string[];
|
|
11
|
+
/** Aria-label of the breadcrumb trail. */
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Label of the "go up one level" button. */
|
|
14
|
+
backLabel?: string;
|
|
15
|
+
class?: string;
|
|
16
|
+
};
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<script lang="ts">
|
|
20
|
+
import { Breadcrumb, Button, Inline } from '@sentropic/design-system-svelte';
|
|
21
|
+
import { findDimension } from '@sentropic/dataviz-core';
|
|
22
|
+
import { useDashboard } from '../adapter.js';
|
|
23
|
+
|
|
24
|
+
let {
|
|
25
|
+
store,
|
|
26
|
+
viewId,
|
|
27
|
+
hierarchy,
|
|
28
|
+
label = 'Chemin de drill',
|
|
29
|
+
backLabel = 'Remonter',
|
|
30
|
+
class: className,
|
|
31
|
+
}: DrillBreadcrumbProps = $props();
|
|
32
|
+
|
|
33
|
+
const dash = $derived(useDashboard(store));
|
|
34
|
+
const path = $derived($dash.drill[viewId] ?? []);
|
|
35
|
+
const dimLabel = (id: string) => findDimension(store.model, id)?.label ?? id;
|
|
36
|
+
const items = $derived(
|
|
37
|
+
hierarchy
|
|
38
|
+
.slice(0, path.length + 1)
|
|
39
|
+
.map((dim, i) => ({ label: dimLabel(dim), current: i === path.length })),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
// Going up pops the drill path and clears the value-filter that was applied
|
|
43
|
+
// when drilling away from the level we are returning to (see DrillBarChart).
|
|
44
|
+
function back() {
|
|
45
|
+
const p = store.getState().drill[viewId] ?? [];
|
|
46
|
+
if (p.length === 0) return;
|
|
47
|
+
store.drillUp(viewId);
|
|
48
|
+
store.clearFilter(hierarchy[p.length - 1]);
|
|
49
|
+
}
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<Inline gap={2} class={className}>
|
|
53
|
+
<Breadcrumb {items} {label} />
|
|
54
|
+
{#if path.length > 0}
|
|
55
|
+
<Button variant="ghost" onclick={back}>{backLabel}</Button>
|
|
56
|
+
{/if}
|
|
57
|
+
</Inline>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { DashboardStore } from '@sentropic/dataviz-core';
|
|
2
|
+
export type DrillBreadcrumbProps = {
|
|
3
|
+
/** The dashboard store to bind to. */
|
|
4
|
+
store: DashboardStore;
|
|
5
|
+
/** This view's id (must match the DrillBarChart it accompanies). */
|
|
6
|
+
viewId: string;
|
|
7
|
+
/** The same ordered dimension hierarchy used by the DrillBarChart. */
|
|
8
|
+
hierarchy: string[];
|
|
9
|
+
/** Aria-label of the breadcrumb trail. */
|
|
10
|
+
label?: string;
|
|
11
|
+
/** Label of the "go up one level" button. */
|
|
12
|
+
backLabel?: string;
|
|
13
|
+
class?: string;
|
|
14
|
+
};
|
|
15
|
+
declare const DrillBreadcrumb: import("svelte").Component<DrillBreadcrumbProps, {}, "">;
|
|
16
|
+
type DrillBreadcrumb = ReturnType<typeof DrillBreadcrumb>;
|
|
17
|
+
export default DrillBreadcrumb;
|
|
18
|
+
//# sourceMappingURL=DrillBreadcrumb.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DrillBreadcrumb.svelte.d.ts","sourceRoot":"","sources":["../src/lib/DrillBreadcrumb.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAE9D,MAAM,MAAM,oBAAoB,GAAG;IACjC,sCAAsC;IACtC,KAAK,EAAE,cAAc,CAAC;IACtB,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC;IACf,sEAAsE;IACtE,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,0CAA0C;IAC1C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6CAA6C;IAC7C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAmDJ,QAAA,MAAM,eAAe,0DAAwC,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/svelte';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { createDashboardStore } from '@sentropic/dataviz-core';
|
|
4
|
+
import DrillBreadcrumb from './DrillBreadcrumb.svelte';
|
|
5
|
+
const model = {
|
|
6
|
+
dimensions: [
|
|
7
|
+
{ id: 'region', label: 'Région', type: 'discrete' },
|
|
8
|
+
{ id: 'city', label: 'Ville', type: 'discrete' },
|
|
9
|
+
],
|
|
10
|
+
measures: [{ id: 'sales', label: 'Ventes', aggregation: 'sum' }],
|
|
11
|
+
};
|
|
12
|
+
const newStore = () => createDashboardStore({ model, data: [] });
|
|
13
|
+
const base = { viewId: 'd', hierarchy: ['region', 'city'] };
|
|
14
|
+
describe('DrillBreadcrumb', () => {
|
|
15
|
+
it('shows only the root level and no back button initially', () => {
|
|
16
|
+
render(DrillBreadcrumb, { props: { store: newStore(), ...base } });
|
|
17
|
+
expect(screen.getByText('Région')).toBeTruthy();
|
|
18
|
+
expect(screen.queryByRole('button', { name: 'Remonter' })).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
it('shows the drilled trail and a back button after drilling', () => {
|
|
21
|
+
const store = newStore();
|
|
22
|
+
store.drillDown('d', 'city');
|
|
23
|
+
render(DrillBreadcrumb, { props: { store, ...base } });
|
|
24
|
+
expect(screen.getByText('Région')).toBeTruthy();
|
|
25
|
+
expect(screen.getByText('Ville')).toBeTruthy();
|
|
26
|
+
expect(screen.getByRole('button', { name: 'Remonter' })).toBeTruthy();
|
|
27
|
+
});
|
|
28
|
+
it('back pops the drill path and clears the level filter', () => {
|
|
29
|
+
const store = newStore();
|
|
30
|
+
store.setFilter('region', { kind: 'include', values: ['Nord'] });
|
|
31
|
+
store.drillDown('d', 'city');
|
|
32
|
+
render(DrillBreadcrumb, { props: { store, ...base } });
|
|
33
|
+
screen.getByRole('button', { name: 'Remonter' }).click();
|
|
34
|
+
expect(store.getState().drill.d ?? []).toEqual([]);
|
|
35
|
+
expect(store.getState().filters.region).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { DataModel, FieldId } from '@sentropic/dataviz-core';
|
|
3
|
+
|
|
4
|
+
export type FieldPaneProps = {
|
|
5
|
+
model: DataModel;
|
|
6
|
+
includeDimensions?: boolean;
|
|
7
|
+
includeMeasures?: boolean;
|
|
8
|
+
selectedId?: FieldId | string;
|
|
9
|
+
defaultExpandedIds?: string[];
|
|
10
|
+
label?: string;
|
|
11
|
+
class?: string;
|
|
12
|
+
};
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<script lang="ts">
|
|
16
|
+
import { TreeView } from '@sentropic/design-system-svelte';
|
|
17
|
+
import { buildFieldPaneTree } from '@sentropic/dataviz-core';
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
model,
|
|
21
|
+
includeDimensions,
|
|
22
|
+
includeMeasures,
|
|
23
|
+
selectedId,
|
|
24
|
+
defaultExpandedIds,
|
|
25
|
+
label = 'Fields',
|
|
26
|
+
class: className,
|
|
27
|
+
}: FieldPaneProps = $props();
|
|
28
|
+
|
|
29
|
+
const tree = $derived(buildFieldPaneTree(model, { includeDimensions, includeMeasures }));
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<TreeView
|
|
33
|
+
nodes={tree.nodes}
|
|
34
|
+
selected={selectedId}
|
|
35
|
+
defaultExpanded={defaultExpandedIds ?? tree.defaultExpandedIds}
|
|
36
|
+
{label}
|
|
37
|
+
class={className}
|
|
38
|
+
/>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DataModel, FieldId } from '@sentropic/dataviz-core';
|
|
2
|
+
export type FieldPaneProps = {
|
|
3
|
+
model: DataModel;
|
|
4
|
+
includeDimensions?: boolean;
|
|
5
|
+
includeMeasures?: boolean;
|
|
6
|
+
selectedId?: FieldId | string;
|
|
7
|
+
defaultExpandedIds?: string[];
|
|
8
|
+
label?: string;
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
declare const FieldPane: import("svelte").Component<FieldPaneProps, {}, "">;
|
|
12
|
+
type FieldPane = ReturnType<typeof FieldPane>;
|
|
13
|
+
export default FieldPane;
|
|
14
|
+
//# sourceMappingURL=FieldPane.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"FieldPane.svelte.d.ts","sourceRoot":"","sources":["../src/lib/FieldPane.svelte.ts"],"names":[],"mappings":"AAGE,OAAO,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,yBAAyB,CAAC;AAElE,MAAM,MAAM,cAAc,GAAG;IAC3B,KAAK,EAAE,SAAS,CAAC;IACjB,iBAAiB,CAAC,EAAE,OAAO,CAAC;IAC5B,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,UAAU,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC9B,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC9B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AA6BJ,QAAA,MAAM,SAAS,oDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
|