bo-grid 0.1.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/LICENSE +21 -0
- package/README.md +461 -0
- package/dist/format/format.d.ts +5 -0
- package/dist/format/format.js +25 -0
- package/dist/grid/AggregationBar.svelte +45 -0
- package/dist/grid/AggregationBar.svelte.d.ts +8 -0
- package/dist/grid/Cell.svelte +230 -0
- package/dist/grid/Cell.svelte.d.ts +32 -0
- package/dist/grid/Grid.svelte +1339 -0
- package/dist/grid/Grid.svelte.d.ts +76 -0
- package/dist/grid/GroupRow.svelte +110 -0
- package/dist/grid/GroupRow.svelte.d.ts +11 -0
- package/dist/grid/aggregate.d.ts +11 -0
- package/dist/grid/aggregate.js +23 -0
- package/dist/grid/clipboard.d.ts +9 -0
- package/dist/grid/clipboard.js +24 -0
- package/dist/grid/column.d.ts +95 -0
- package/dist/grid/column.js +62 -0
- package/dist/grid/export-xlsx.d.ts +8 -0
- package/dist/grid/export-xlsx.js +19 -0
- package/dist/grid/export.d.ts +19 -0
- package/dist/grid/export.js +48 -0
- package/dist/grid/grouping.d.ts +36 -0
- package/dist/grid/grouping.js +62 -0
- package/dist/grid/heatmap.d.ts +1 -0
- package/dist/grid/heatmap.js +12 -0
- package/dist/grid/pin.d.ts +23 -0
- package/dist/grid/pin.js +24 -0
- package/dist/grid/pivot.d.ts +27 -0
- package/dist/grid/pivot.js +0 -0
- package/dist/grid/reorder.d.ts +2 -0
- package/dist/grid/reorder.js +10 -0
- package/dist/grid/rowheight.d.ts +17 -0
- package/dist/grid/rowheight.js +41 -0
- package/dist/grid/selection.svelte.d.ts +30 -0
- package/dist/grid/selection.svelte.js +64 -0
- package/dist/grid/sizing.d.ts +17 -0
- package/dist/grid/sizing.js +28 -0
- package/dist/grid/source.d.ts +43 -0
- package/dist/grid/source.js +29 -0
- package/dist/grid/source.svelte.d.ts +21 -0
- package/dist/grid/source.svelte.js +53 -0
- package/dist/grid/theme.d.ts +27 -0
- package/dist/grid/theme.js +60 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +24 -0
- package/dist/sparkline/Sparkline.svelte +74 -0
- package/dist/sparkline/Sparkline.svelte.d.ts +9 -0
- package/dist/sparkline/sparkline-render.d.ts +16 -0
- package/dist/sparkline/sparkline-render.js +83 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.js +1 -0
- package/package.json +82 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { ColumnDef, GridRow, SortState, CellEditEvent } from './column';
|
|
3
|
+
import { type GridTheme } from './theme';
|
|
4
|
+
import { type AggKind } from './aggregate';
|
|
5
|
+
import type { RowSource } from './source';
|
|
6
|
+
type $$ComponentProps = {
|
|
7
|
+
rows: GridRow[];
|
|
8
|
+
columns: ColumnDef[];
|
|
9
|
+
height: number;
|
|
10
|
+
/** Row height in px (uniform), or a function for variable heights
|
|
11
|
+
(in-memory mode only). Default 36. */
|
|
12
|
+
rowHeight?: number | ((row: GridRow, index: number) => number);
|
|
13
|
+
/** Built-in theme name or a custom token map. Default 'dark'. */
|
|
14
|
+
theme?: 'dark' | 'light' | GridTheme;
|
|
15
|
+
/** Allow drag-to-resize column widths. Default true; opt out per column
|
|
16
|
+
with `resizable: false`. */
|
|
17
|
+
resizable?: boolean;
|
|
18
|
+
/** Show a leading checkbox column for whole-row selection (keyed by row id,
|
|
19
|
+
stable across sort/filter). Default false. */
|
|
20
|
+
rowSelection?: boolean;
|
|
21
|
+
/** Called with the selected row ids whenever the row-selection set changes. */
|
|
22
|
+
onRowSelectionChange?: (selectedIds: Array<string | number>) => void;
|
|
23
|
+
/** Column keys to hide (controlled). Build your own column-picker UI and
|
|
24
|
+
drive this prop — the grid stays presentation-only. */
|
|
25
|
+
hiddenColumns?: string[];
|
|
26
|
+
/** Return extra CSS class(es) for a data row (e.g. to colour by value).
|
|
27
|
+
Style them via `:global(.your-class)` since rows live inside the grid. */
|
|
28
|
+
rowClass?: (row: GridRow) => string | undefined;
|
|
29
|
+
/** Identity key for row selection. Defaults to `row.id`; override for
|
|
30
|
+
string/UUID/composite keys. */
|
|
31
|
+
getRowId?: (row: GridRow) => string | number;
|
|
32
|
+
/** Called when a data row is activated by click or Enter (open a detail
|
|
33
|
+
view, navigate, …). Edit-input and checkbox clicks are excluded. */
|
|
34
|
+
onRowClick?: (row: GridRow, event: MouseEvent | KeyboardEvent) => void;
|
|
35
|
+
/** Controlled sort order (multi-key, primary first). When set, the grid
|
|
36
|
+
reflects this and reports changes via `onSortChange` instead of holding
|
|
37
|
+
its own state. Omit for uncontrolled sorting. */
|
|
38
|
+
sort?: SortState[];
|
|
39
|
+
/** Called with the new sort order whenever a header is clicked. */
|
|
40
|
+
onSortChange?: (sort: SortState[]) => void;
|
|
41
|
+
/** Show a pinned totals row: each column with a `groupAgg` shows that
|
|
42
|
+
aggregate over all (filtered) rows. In-memory mode only. Default false. */
|
|
43
|
+
footer?: boolean;
|
|
44
|
+
/** Called when a cell is clicked, with its row, column and value. Fires in
|
|
45
|
+
addition to `onRowClick`; excluded for the edit input. */
|
|
46
|
+
onCellClick?: (info: {
|
|
47
|
+
row: GridRow;
|
|
48
|
+
column: ColumnDef;
|
|
49
|
+
value: unknown;
|
|
50
|
+
}, event: MouseEvent) => void;
|
|
51
|
+
/** Rows pinned to the top, always visible above the scroll (a benchmark, a
|
|
52
|
+
summary, "your position"). Display-only — not virtualized or selectable. */
|
|
53
|
+
pinnedRows?: GridRow[];
|
|
54
|
+
/** Show a per-column filter input row under the header. Rows must match every
|
|
55
|
+
non-empty column filter (AND). In-memory mode only. Default false. */
|
|
56
|
+
filterRow?: boolean;
|
|
57
|
+
filter?: string;
|
|
58
|
+
groupBy?: string[];
|
|
59
|
+
aggregations?: AggKind[];
|
|
60
|
+
persistKey?: string;
|
|
61
|
+
/** Back the grid with a windowed/server data source instead of `rows`.
|
|
62
|
+
In source mode, sort + filter are delegated to the source; grouping is
|
|
63
|
+
not applied. */
|
|
64
|
+
source?: RowSource;
|
|
65
|
+
/** Called when an editable cell is committed. Update your row data in here. */
|
|
66
|
+
onCellEdit?: (e: CellEditEvent) => void;
|
|
67
|
+
/** Render content for `type: 'custom'` columns. */
|
|
68
|
+
cell?: Snippet<[{
|
|
69
|
+
row: GridRow;
|
|
70
|
+
column: ColumnDef;
|
|
71
|
+
value: unknown;
|
|
72
|
+
}]>;
|
|
73
|
+
};
|
|
74
|
+
declare const Grid: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
75
|
+
type Grid = ReturnType<typeof Grid>;
|
|
76
|
+
export default Grid;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ColumnDef, GridRow } from './column';
|
|
3
|
+
import { colStyle, formatCell } from './column';
|
|
4
|
+
import { aggregate } from './aggregate';
|
|
5
|
+
import type { GroupNode } from './grouping';
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
group,
|
|
9
|
+
columns,
|
|
10
|
+
onToggle,
|
|
11
|
+
rowIndex,
|
|
12
|
+
}: {
|
|
13
|
+
group: GroupNode;
|
|
14
|
+
columns: ColumnDef[];
|
|
15
|
+
onToggle: (path: string) => void;
|
|
16
|
+
rowIndex?: number;
|
|
17
|
+
} = $props();
|
|
18
|
+
|
|
19
|
+
// Aggregate over the group's leaf rows. Reads row $state values, so group
|
|
20
|
+
// subtotals stay live as the feed ticks (only on-screen groups are rendered).
|
|
21
|
+
function aggText(col: ColumnDef): string {
|
|
22
|
+
if (col.type === 'sparkline' || col.type === 'text' || !col.groupAgg) return '';
|
|
23
|
+
const vals: number[] = [];
|
|
24
|
+
for (const row of group.rows) {
|
|
25
|
+
const v = Number((row as GridRow)[col.key]);
|
|
26
|
+
if (Number.isFinite(v)) vals.push(v);
|
|
27
|
+
}
|
|
28
|
+
const a = aggregate(vals);
|
|
29
|
+
if (!a) return '';
|
|
30
|
+
if (col.groupAgg === 'count') return String(a.count);
|
|
31
|
+
return formatCell(col, a[col.groupAgg]);
|
|
32
|
+
}
|
|
33
|
+
</script>
|
|
34
|
+
|
|
35
|
+
<div class="group" role="row" aria-rowindex={rowIndex}>
|
|
36
|
+
<button
|
|
37
|
+
class="toggle"
|
|
38
|
+
type="button"
|
|
39
|
+
style={colStyle(columns[0])}
|
|
40
|
+
aria-expanded={!group.collapsed}
|
|
41
|
+
onclick={() => onToggle(group.path)}
|
|
42
|
+
>
|
|
43
|
+
<span class="chev" style="margin-left:{group.depth * 12}px">{group.collapsed ? '▸' : '▾'}</span>
|
|
44
|
+
<span class="val">{group.value}</span>
|
|
45
|
+
<span class="count">{group.count}</span>
|
|
46
|
+
</button>
|
|
47
|
+
|
|
48
|
+
{#each columns as col, ci (ci)}
|
|
49
|
+
{#if ci > 0}
|
|
50
|
+
<span class="agg" style={colStyle(col)}>{aggText(col)}</span>
|
|
51
|
+
{/if}
|
|
52
|
+
{/each}
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<style>
|
|
56
|
+
.group {
|
|
57
|
+
display: flex;
|
|
58
|
+
align-items: stretch;
|
|
59
|
+
width: 100%;
|
|
60
|
+
height: 100%;
|
|
61
|
+
background: color-mix(in srgb, var(--bo-header-bg) 85%, var(--bo-text) 4%);
|
|
62
|
+
border-bottom: 0.5px solid var(--bo-border);
|
|
63
|
+
}
|
|
64
|
+
.toggle {
|
|
65
|
+
display: flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: 6px;
|
|
68
|
+
min-width: 0;
|
|
69
|
+
padding: 0 8px;
|
|
70
|
+
font: inherit;
|
|
71
|
+
font-size: 12px;
|
|
72
|
+
font-weight: 600;
|
|
73
|
+
color: var(--bo-text);
|
|
74
|
+
background: transparent;
|
|
75
|
+
border: 0;
|
|
76
|
+
cursor: pointer;
|
|
77
|
+
text-align: left;
|
|
78
|
+
}
|
|
79
|
+
.chev {
|
|
80
|
+
font-size: 10px;
|
|
81
|
+
color: var(--bo-text-dim);
|
|
82
|
+
}
|
|
83
|
+
.val {
|
|
84
|
+
overflow: hidden;
|
|
85
|
+
white-space: nowrap;
|
|
86
|
+
text-overflow: ellipsis;
|
|
87
|
+
}
|
|
88
|
+
.count {
|
|
89
|
+
flex: none;
|
|
90
|
+
padding: 0 5px;
|
|
91
|
+
font-family: var(--bo-mono);
|
|
92
|
+
font-size: 10px;
|
|
93
|
+
font-weight: 500;
|
|
94
|
+
color: var(--bo-text-dim);
|
|
95
|
+
background: var(--bo-row-hover);
|
|
96
|
+
border-radius: 999px;
|
|
97
|
+
}
|
|
98
|
+
.agg {
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
justify-content: flex-end;
|
|
102
|
+
padding: 0 8px;
|
|
103
|
+
font-family: var(--bo-mono);
|
|
104
|
+
font-size: 11px;
|
|
105
|
+
font-variant-numeric: tabular-nums;
|
|
106
|
+
color: var(--bo-text-dim);
|
|
107
|
+
overflow: hidden;
|
|
108
|
+
white-space: nowrap;
|
|
109
|
+
}
|
|
110
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ColumnDef } from './column';
|
|
2
|
+
import type { GroupNode } from './grouping';
|
|
3
|
+
type $$ComponentProps = {
|
|
4
|
+
group: GroupNode;
|
|
5
|
+
columns: ColumnDef[];
|
|
6
|
+
onToggle: (path: string) => void;
|
|
7
|
+
rowIndex?: number;
|
|
8
|
+
};
|
|
9
|
+
declare const GroupRow: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
10
|
+
type GroupRow = ReturnType<typeof GroupRow>;
|
|
11
|
+
export default GroupRow;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type AggKind = 'sum' | 'avg' | 'count' | 'min' | 'max';
|
|
2
|
+
export interface AggResult {
|
|
3
|
+
sum: number;
|
|
4
|
+
avg: number;
|
|
5
|
+
count: number;
|
|
6
|
+
min: number;
|
|
7
|
+
max: number;
|
|
8
|
+
}
|
|
9
|
+
/** Aggregate a list of numbers. Returns null for an empty list. */
|
|
10
|
+
export declare function aggregate(values: number[]): AggResult | null;
|
|
11
|
+
export declare const AGG_LABELS: Record<AggKind, string>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Aggregate a list of numbers. Returns null for an empty list. */
|
|
2
|
+
export function aggregate(values) {
|
|
3
|
+
if (values.length === 0)
|
|
4
|
+
return null;
|
|
5
|
+
let sum = 0;
|
|
6
|
+
let min = Infinity;
|
|
7
|
+
let max = -Infinity;
|
|
8
|
+
for (const v of values) {
|
|
9
|
+
sum += v;
|
|
10
|
+
if (v < min)
|
|
11
|
+
min = v;
|
|
12
|
+
if (v > max)
|
|
13
|
+
max = v;
|
|
14
|
+
}
|
|
15
|
+
return { sum, avg: sum / values.length, count: values.length, min, max };
|
|
16
|
+
}
|
|
17
|
+
export const AGG_LABELS = {
|
|
18
|
+
sum: 'Sum',
|
|
19
|
+
avg: 'Avg',
|
|
20
|
+
count: 'Count',
|
|
21
|
+
min: 'Min',
|
|
22
|
+
max: 'Max',
|
|
23
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse clipboard text into a rectangular grid of cell strings.
|
|
3
|
+
*
|
|
4
|
+
* Tolerates CRLF / lone CR line endings and a single trailing newline
|
|
5
|
+
* (spreadsheets append one when copying a range). Empty input yields `[]`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function parseClipboard(text: string): string[][];
|
|
8
|
+
/** True when the parsed clipboard holds exactly one cell. */
|
|
9
|
+
export declare function isSingleCell(grid: string[][]): boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Clipboard TSV (tab-separated values) parsing for paste support.
|
|
2
|
+
//
|
|
3
|
+
// Spreadsheets (Excel, Google Sheets) and the grid's own copy use a simple
|
|
4
|
+
// TSV shape: rows joined by "\n", cells within a row joined by "\t". This is
|
|
5
|
+
// deliberately not full CSV — no quoting/escaping — to match what copy emits
|
|
6
|
+
// and what spreadsheets put on the clipboard for a plain rectangular range.
|
|
7
|
+
/**
|
|
8
|
+
* Parse clipboard text into a rectangular grid of cell strings.
|
|
9
|
+
*
|
|
10
|
+
* Tolerates CRLF / lone CR line endings and a single trailing newline
|
|
11
|
+
* (spreadsheets append one when copying a range). Empty input yields `[]`.
|
|
12
|
+
*/
|
|
13
|
+
export function parseClipboard(text) {
|
|
14
|
+
let t = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
15
|
+
if (t.endsWith('\n'))
|
|
16
|
+
t = t.slice(0, -1);
|
|
17
|
+
if (t === '')
|
|
18
|
+
return [];
|
|
19
|
+
return t.split('\n').map((line) => line.split('\t'));
|
|
20
|
+
}
|
|
21
|
+
/** True when the parsed clipboard holds exactly one cell. */
|
|
22
|
+
export function isSingleCell(grid) {
|
|
23
|
+
return grid.length === 1 && grid[0].length === 1;
|
|
24
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Candle } from '../types';
|
|
2
|
+
import { type DateStyle } from '../format/format';
|
|
3
|
+
import type { AggKind } from './aggregate';
|
|
4
|
+
export type Align = 'left' | 'right';
|
|
5
|
+
interface ColBase {
|
|
6
|
+
/** Field on the row to read for this column's value. */
|
|
7
|
+
key: string;
|
|
8
|
+
header: string;
|
|
9
|
+
/** Fixed width in px. Ignored when `flex` is set. */
|
|
10
|
+
width?: number;
|
|
11
|
+
/** flex-grow weight; column stretches to fill remaining space. */
|
|
12
|
+
flex?: number;
|
|
13
|
+
align?: Align;
|
|
14
|
+
/** Amber flash on value change (drives off the row's flashSeq/flashDir). */
|
|
15
|
+
flash?: boolean;
|
|
16
|
+
/** Set false to disable header-click sorting on this column. */
|
|
17
|
+
sortable?: boolean;
|
|
18
|
+
/** When grouping is active, show this aggregate of the column on group headers. */
|
|
19
|
+
groupAgg?: AggKind;
|
|
20
|
+
/** Pin this column to the left; it stays visible during horizontal scroll. */
|
|
21
|
+
pinned?: boolean;
|
|
22
|
+
/** Allow inline editing (double-click or Enter on the focused cell). */
|
|
23
|
+
editable?: boolean;
|
|
24
|
+
/** Validate an edited value before it commits (inline edit or paste). Return
|
|
25
|
+
false to reject and keep the old value. Receives the coerced value. */
|
|
26
|
+
validate?: (value: string | number, row: GridRow) => boolean;
|
|
27
|
+
/** Set false to disable drag-to-resize on this column (default on). */
|
|
28
|
+
resizable?: boolean;
|
|
29
|
+
/** Parent header label. Consecutive columns sharing a `group` render under a
|
|
30
|
+
spanning header. Best with fixed-width columns. */
|
|
31
|
+
group?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface CellEditEvent {
|
|
34
|
+
row: GridRow;
|
|
35
|
+
column: ColumnDef;
|
|
36
|
+
/** Parsed value: a number for numeric columns, otherwise the raw string. */
|
|
37
|
+
value: string | number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Discriminated column config — the public Phase 0 "columns + rows" surface,
|
|
41
|
+
* extended with the Phase 1 sparkline + heatmap cell types.
|
|
42
|
+
*/
|
|
43
|
+
export type ColumnDef = (ColBase & {
|
|
44
|
+
type: 'text';
|
|
45
|
+
sub?: string;
|
|
46
|
+
}) | (ColBase & {
|
|
47
|
+
type: 'price';
|
|
48
|
+
}) | (ColBase & {
|
|
49
|
+
type: 'percent';
|
|
50
|
+
}) | (ColBase & {
|
|
51
|
+
type: 'volume';
|
|
52
|
+
}) | (ColBase & {
|
|
53
|
+
type: 'number';
|
|
54
|
+
decimals?: number;
|
|
55
|
+
}) | (ColBase & {
|
|
56
|
+
type: 'date';
|
|
57
|
+
dateStyle?: DateStyle;
|
|
58
|
+
}) | (ColBase & {
|
|
59
|
+
type: 'heatmap';
|
|
60
|
+
min: number;
|
|
61
|
+
max: number;
|
|
62
|
+
decimals?: number;
|
|
63
|
+
}) | (ColBase & {
|
|
64
|
+
type: 'sparkline';
|
|
65
|
+
sparkKey: string;
|
|
66
|
+
}) | (ColBase & {
|
|
67
|
+
type: 'custom';
|
|
68
|
+
});
|
|
69
|
+
export type SortDir = 'asc' | 'desc';
|
|
70
|
+
export interface SortState {
|
|
71
|
+
key: string;
|
|
72
|
+
dir: SortDir;
|
|
73
|
+
}
|
|
74
|
+
/** Minimal row contract the grid relies on. Concrete rows add their own fields. */
|
|
75
|
+
export interface GridRow {
|
|
76
|
+
id: number;
|
|
77
|
+
flashSeq: number;
|
|
78
|
+
flashDir: 'up' | 'down';
|
|
79
|
+
[field: string]: unknown;
|
|
80
|
+
}
|
|
81
|
+
export declare function formatCell(col: ColumnDef, value: unknown): string;
|
|
82
|
+
export declare function isSortable(col: ColumnDef): boolean;
|
|
83
|
+
export declare function isEditable(col: ColumnDef): boolean;
|
|
84
|
+
export declare function compareRows(a: GridRow, b: GridRow, sort: SortState): number;
|
|
85
|
+
/**
|
|
86
|
+
* Multi-key comparison: apply each sort in order, returning the first that
|
|
87
|
+
* separates the rows. An empty list leaves rows in their original order.
|
|
88
|
+
*/
|
|
89
|
+
export declare function compareBySorts(a: GridRow, b: GridRow, sorts: readonly SortState[]): number;
|
|
90
|
+
export declare function colStyle(col: ColumnDef): string;
|
|
91
|
+
/** Concrete pixel width for a column (flex columns get a sensible default). */
|
|
92
|
+
export declare function colWidth(col: ColumnDef): number;
|
|
93
|
+
export declare function isNumeric(col: ColumnDef): boolean;
|
|
94
|
+
export declare function candlesOf(row: GridRow, key: string): Candle[];
|
|
95
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { fmtPrice, fmtPercent, fmtVolume, fmtDate } from '../format/format';
|
|
2
|
+
export function formatCell(col, value) {
|
|
3
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
4
|
+
switch (col.type) {
|
|
5
|
+
case 'price':
|
|
6
|
+
return fmtPrice(n);
|
|
7
|
+
case 'percent':
|
|
8
|
+
return fmtPercent(n);
|
|
9
|
+
case 'volume':
|
|
10
|
+
return fmtVolume(n);
|
|
11
|
+
case 'number':
|
|
12
|
+
return n.toFixed(col.decimals ?? 2);
|
|
13
|
+
case 'date':
|
|
14
|
+
return fmtDate(n, col.dateStyle);
|
|
15
|
+
case 'heatmap':
|
|
16
|
+
return n.toFixed(col.decimals ?? 2);
|
|
17
|
+
default:
|
|
18
|
+
return value == null ? '' : String(value);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function isSortable(col) {
|
|
22
|
+
return col.type !== 'sparkline' && col.sortable !== false;
|
|
23
|
+
}
|
|
24
|
+
export function isEditable(col) {
|
|
25
|
+
return !!col.editable && col.type !== 'sparkline' && col.type !== 'date' && col.type !== 'custom';
|
|
26
|
+
}
|
|
27
|
+
function rawCompare(a, b) {
|
|
28
|
+
if (typeof a === 'number' && typeof b === 'number')
|
|
29
|
+
return a - b;
|
|
30
|
+
return String(a ?? '').localeCompare(String(b ?? ''));
|
|
31
|
+
}
|
|
32
|
+
export function compareRows(a, b, sort) {
|
|
33
|
+
const d = rawCompare(a[sort.key], b[sort.key]);
|
|
34
|
+
return sort.dir === 'asc' ? d : -d;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Multi-key comparison: apply each sort in order, returning the first that
|
|
38
|
+
* separates the rows. An empty list leaves rows in their original order.
|
|
39
|
+
*/
|
|
40
|
+
export function compareBySorts(a, b, sorts) {
|
|
41
|
+
for (const sort of sorts) {
|
|
42
|
+
const d = compareRows(a, b, sort);
|
|
43
|
+
if (d !== 0)
|
|
44
|
+
return d;
|
|
45
|
+
}
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
export function colStyle(col) {
|
|
49
|
+
if (col.flex)
|
|
50
|
+
return `flex:${col.flex} 1 0;min-width:0;`;
|
|
51
|
+
return `flex:0 0 ${col.width ?? 96}px;`;
|
|
52
|
+
}
|
|
53
|
+
/** Concrete pixel width for a column (flex columns get a sensible default). */
|
|
54
|
+
export function colWidth(col) {
|
|
55
|
+
return col.flex ? (col.width ?? 160) : (col.width ?? 96);
|
|
56
|
+
}
|
|
57
|
+
export function isNumeric(col) {
|
|
58
|
+
return col.type !== 'text' && col.type !== 'sparkline' && col.type !== 'custom';
|
|
59
|
+
}
|
|
60
|
+
export function candlesOf(row, key) {
|
|
61
|
+
return row[key] ?? [];
|
|
62
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ColumnDef, GridRow } from './column';
|
|
2
|
+
import { type ExportOptions } from './export';
|
|
3
|
+
/**
|
|
4
|
+
* Export rows to a .xlsx file. SheetJS is loaded via dynamic import, so it lands
|
|
5
|
+
* in its own lazy chunk and never bloats the core bundle. `xlsx` is an optional
|
|
6
|
+
* peer dependency — install it only if you use this function.
|
|
7
|
+
*/
|
|
8
|
+
export declare function exportXLSX(filename: string, rows: readonly GridRow[], columns: readonly ColumnDef[], opts?: ExportOptions): Promise<void>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { rowsToMatrix } from './export';
|
|
2
|
+
/**
|
|
3
|
+
* Export rows to a .xlsx file. SheetJS is loaded via dynamic import, so it lands
|
|
4
|
+
* in its own lazy chunk and never bloats the core bundle. `xlsx` is an optional
|
|
5
|
+
* peer dependency — install it only if you use this function.
|
|
6
|
+
*/
|
|
7
|
+
export async function exportXLSX(filename, rows, columns, opts = {}) {
|
|
8
|
+
let XLSX;
|
|
9
|
+
try {
|
|
10
|
+
XLSX = await import('xlsx');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error("bo-grid: xlsx export requires the optional peer dependency 'xlsx' (npm i xlsx)");
|
|
14
|
+
}
|
|
15
|
+
const ws = XLSX.utils.aoa_to_sheet(rowsToMatrix(rows, columns, opts));
|
|
16
|
+
const wb = XLSX.utils.book_new();
|
|
17
|
+
XLSX.utils.book_append_sheet(wb, ws, 'Sheet1');
|
|
18
|
+
XLSX.writeFile(wb, filename);
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ColumnDef, GridRow } from './column';
|
|
2
|
+
export interface ExportOptions {
|
|
3
|
+
/** Use formatted display strings instead of raw values. Default false. */
|
|
4
|
+
formatted?: boolean;
|
|
5
|
+
/** Include a header row. Default true. */
|
|
6
|
+
header?: boolean;
|
|
7
|
+
}
|
|
8
|
+
type Cell = string | number;
|
|
9
|
+
/**
|
|
10
|
+
* Build a 2-D matrix (rows × columns) for export. Sparkline columns are skipped.
|
|
11
|
+
* `formatted` produces display strings; otherwise numeric columns stay numbers
|
|
12
|
+
* so spreadsheets can compute on them.
|
|
13
|
+
*/
|
|
14
|
+
export declare function rowsToMatrix(rows: readonly GridRow[], columns: readonly ColumnDef[], opts?: ExportOptions): Cell[][];
|
|
15
|
+
export declare function toCSV(rows: readonly GridRow[], columns: readonly ColumnDef[], opts?: ExportOptions): string;
|
|
16
|
+
/** Trigger a browser download of text content. No-op outside the browser. */
|
|
17
|
+
export declare function download(filename: string, content: string, mime?: string): void;
|
|
18
|
+
export declare function exportCSV(filename: string, rows: readonly GridRow[], columns: readonly ColumnDef[], opts?: ExportOptions): void;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { formatCell, isNumeric } from './column';
|
|
2
|
+
function rawValue(col, v) {
|
|
3
|
+
if (col.type === 'date')
|
|
4
|
+
return formatCell(col, v); // epoch ms isn't useful raw
|
|
5
|
+
if (isNumeric(col)) {
|
|
6
|
+
const n = Number(v);
|
|
7
|
+
return Number.isFinite(n) ? n : '';
|
|
8
|
+
}
|
|
9
|
+
return v == null ? '' : String(v);
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Build a 2-D matrix (rows × columns) for export. Sparkline columns are skipped.
|
|
13
|
+
* `formatted` produces display strings; otherwise numeric columns stay numbers
|
|
14
|
+
* so spreadsheets can compute on them.
|
|
15
|
+
*/
|
|
16
|
+
export function rowsToMatrix(rows, columns, opts = {}) {
|
|
17
|
+
const cols = columns.filter((c) => c.type !== 'sparkline' && c.type !== 'custom');
|
|
18
|
+
const matrix = [];
|
|
19
|
+
if (opts.header !== false)
|
|
20
|
+
matrix.push(cols.map((c) => c.header));
|
|
21
|
+
for (const row of rows) {
|
|
22
|
+
matrix.push(cols.map((c) => (opts.formatted ? formatCell(c, row[c.key]) : rawValue(c, row[c.key]))));
|
|
23
|
+
}
|
|
24
|
+
return matrix;
|
|
25
|
+
}
|
|
26
|
+
function csvCell(v) {
|
|
27
|
+
const s = String(v ?? '');
|
|
28
|
+
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
29
|
+
}
|
|
30
|
+
export function toCSV(rows, columns, opts = {}) {
|
|
31
|
+
return rowsToMatrix(rows, columns, opts)
|
|
32
|
+
.map((r) => r.map(csvCell).join(','))
|
|
33
|
+
.join('\r\n');
|
|
34
|
+
}
|
|
35
|
+
/** Trigger a browser download of text content. No-op outside the browser. */
|
|
36
|
+
export function download(filename, content, mime = 'text/csv;charset=utf-8') {
|
|
37
|
+
if (typeof document === 'undefined' || typeof URL.createObjectURL !== 'function')
|
|
38
|
+
return;
|
|
39
|
+
const url = URL.createObjectURL(new Blob([content], { type: mime }));
|
|
40
|
+
const a = document.createElement('a');
|
|
41
|
+
a.href = url;
|
|
42
|
+
a.download = filename;
|
|
43
|
+
a.click();
|
|
44
|
+
URL.revokeObjectURL(url);
|
|
45
|
+
}
|
|
46
|
+
export function exportCSV(filename, rows, columns, opts = {}) {
|
|
47
|
+
download(filename, toCSV(rows, columns, opts));
|
|
48
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { GridRow } from './column';
|
|
2
|
+
export interface GroupNode {
|
|
3
|
+
/** Unique path, e.g. "Tech" or "Tech/NYSE" for nested groups. */
|
|
4
|
+
path: string;
|
|
5
|
+
/** Nesting level, 0 = outermost. */
|
|
6
|
+
depth: number;
|
|
7
|
+
/** The group key value (stringified). */
|
|
8
|
+
value: string;
|
|
9
|
+
/** All leaf data rows under this group (across nested sub-groups). */
|
|
10
|
+
rows: GridRow[];
|
|
11
|
+
count: number;
|
|
12
|
+
collapsed: boolean;
|
|
13
|
+
}
|
|
14
|
+
export type VisualRow = {
|
|
15
|
+
kind: 'data';
|
|
16
|
+
row: GridRow;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'group';
|
|
19
|
+
group: GroupNode;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Flatten data rows into the visual row list the grid renders: a stream of
|
|
23
|
+
* group-header rows interleaved with their data rows, honoring collapse state.
|
|
24
|
+
* Collapsed groups omit their children entirely. Reads only the group-key
|
|
25
|
+
* fields (which are static), so a realtime tick never rebuilds this list.
|
|
26
|
+
*
|
|
27
|
+
* Group headers are the same height as data rows, so the uniform-height virtual
|
|
28
|
+
* scroller works unchanged — it just windows over a longer mixed list.
|
|
29
|
+
*/
|
|
30
|
+
export declare function buildFlatRows(rows: GridRow[], groupBy: string[], collapsed: Set<string>): VisualRow[];
|
|
31
|
+
/**
|
|
32
|
+
* The chain of group nodes (outermost → innermost) that the row at `idx` belongs
|
|
33
|
+
* to. Used to render sticky group headers: scan backward from idx, taking the
|
|
34
|
+
* nearest preceding header at each depth until the depth-0 group is found.
|
|
35
|
+
*/
|
|
36
|
+
export declare function activeGroupsAt(flat: VisualRow[], idx: number): GroupNode[];
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flatten data rows into the visual row list the grid renders: a stream of
|
|
3
|
+
* group-header rows interleaved with their data rows, honoring collapse state.
|
|
4
|
+
* Collapsed groups omit their children entirely. Reads only the group-key
|
|
5
|
+
* fields (which are static), so a realtime tick never rebuilds this list.
|
|
6
|
+
*
|
|
7
|
+
* Group headers are the same height as data rows, so the uniform-height virtual
|
|
8
|
+
* scroller works unchanged — it just windows over a longer mixed list.
|
|
9
|
+
*/
|
|
10
|
+
export function buildFlatRows(rows, groupBy, collapsed) {
|
|
11
|
+
if (groupBy.length === 0)
|
|
12
|
+
return rows.map((row) => ({ kind: 'data', row }));
|
|
13
|
+
const out = [];
|
|
14
|
+
const recurse = (subset, depth, parentPath) => {
|
|
15
|
+
const key = groupBy[depth];
|
|
16
|
+
const buckets = new Map();
|
|
17
|
+
for (const row of subset) {
|
|
18
|
+
const gv = String(row[key] ?? '');
|
|
19
|
+
const arr = buckets.get(gv);
|
|
20
|
+
if (arr)
|
|
21
|
+
arr.push(row);
|
|
22
|
+
else
|
|
23
|
+
buckets.set(gv, [row]);
|
|
24
|
+
}
|
|
25
|
+
for (const gv of [...buckets.keys()].sort()) {
|
|
26
|
+
const groupRows = buckets.get(gv);
|
|
27
|
+
const path = parentPath ? `${parentPath}/${gv}` : gv;
|
|
28
|
+
const isCollapsed = collapsed.has(path);
|
|
29
|
+
out.push({
|
|
30
|
+
kind: 'group',
|
|
31
|
+
group: { path, depth, value: gv, rows: groupRows, count: groupRows.length, collapsed: isCollapsed },
|
|
32
|
+
});
|
|
33
|
+
if (isCollapsed)
|
|
34
|
+
continue;
|
|
35
|
+
if (depth + 1 < groupBy.length)
|
|
36
|
+
recurse(groupRows, depth + 1, path);
|
|
37
|
+
else
|
|
38
|
+
for (const row of groupRows)
|
|
39
|
+
out.push({ kind: 'data', row });
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
recurse(rows, 0, '');
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The chain of group nodes (outermost → innermost) that the row at `idx` belongs
|
|
47
|
+
* to. Used to render sticky group headers: scan backward from idx, taking the
|
|
48
|
+
* nearest preceding header at each depth until the depth-0 group is found.
|
|
49
|
+
*/
|
|
50
|
+
export function activeGroupsAt(flat, idx) {
|
|
51
|
+
const found = new Map();
|
|
52
|
+
for (let i = Math.min(idx, flat.length - 1); i >= 0; i--) {
|
|
53
|
+
const item = flat[i];
|
|
54
|
+
if (item.kind !== 'group')
|
|
55
|
+
continue;
|
|
56
|
+
if (!found.has(item.group.depth))
|
|
57
|
+
found.set(item.group.depth, item.group);
|
|
58
|
+
if (item.group.depth === 0)
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
return [...found.values()].sort((a, b) => a.depth - b.depth);
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function heatColor(value: number, min: number, max: number): string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Diverging background ramp for the heatmap column type
|
|
2
|
+
// (proposal Phase 1 §Heatmap column): red below mid, green above.
|
|
3
|
+
const MAX_ALPHA = 0.34;
|
|
4
|
+
export function heatColor(value, min, max) {
|
|
5
|
+
const span = max - min || 1;
|
|
6
|
+
const t = Math.max(0, Math.min(1, (value - min) / span)); // 0..1
|
|
7
|
+
const k = (t - 0.5) * 2; // -1 (cold) .. +1 (hot)
|
|
8
|
+
const alpha = Math.min(MAX_ALPHA, Math.abs(k) * MAX_ALPHA).toFixed(3);
|
|
9
|
+
return k < 0
|
|
10
|
+
? `rgba(248, 113, 113, ${alpha})` // --down
|
|
11
|
+
: `rgba(52, 211, 153, ${alpha})`; // --up
|
|
12
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ColumnDef } from './column';
|
|
2
|
+
export interface PinInfo {
|
|
3
|
+
pinned: boolean;
|
|
4
|
+
/** Sticky left offset (px) when pinned. */
|
|
5
|
+
left: number;
|
|
6
|
+
/** Concrete width (px). */
|
|
7
|
+
width: number;
|
|
8
|
+
}
|
|
9
|
+
export interface PinLayout {
|
|
10
|
+
/** Columns with pinned ones moved to the front. */
|
|
11
|
+
columns: ColumnDef[];
|
|
12
|
+
info: PinInfo[];
|
|
13
|
+
/** Sum of all column widths (the horizontally-scrollable content width). */
|
|
14
|
+
totalWidth: number;
|
|
15
|
+
anyPinned: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Arrange columns for pinning: pinned-left columns move to the front, and each
|
|
19
|
+
* gets a sticky `left` offset (cumulative width of the pinned columns before it).
|
|
20
|
+
* When nothing is pinned, column order is untouched and the grid stays in its
|
|
21
|
+
* default fit-to-width layout.
|
|
22
|
+
*/
|
|
23
|
+
export declare function arrangePinned(cols: readonly ColumnDef[]): PinLayout;
|