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
package/dist/grid/pin.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { colWidth } from './column';
|
|
2
|
+
/**
|
|
3
|
+
* Arrange columns for pinning: pinned-left columns move to the front, and each
|
|
4
|
+
* gets a sticky `left` offset (cumulative width of the pinned columns before it).
|
|
5
|
+
* When nothing is pinned, column order is untouched and the grid stays in its
|
|
6
|
+
* default fit-to-width layout.
|
|
7
|
+
*/
|
|
8
|
+
export function arrangePinned(cols) {
|
|
9
|
+
const pinned = cols.filter((c) => c.pinned);
|
|
10
|
+
const anyPinned = pinned.length > 0;
|
|
11
|
+
const columns = anyPinned ? [...pinned, ...cols.filter((c) => !c.pinned)] : [...cols];
|
|
12
|
+
const info = [];
|
|
13
|
+
let left = 0;
|
|
14
|
+
let totalWidth = 0;
|
|
15
|
+
for (let i = 0; i < columns.length; i++) {
|
|
16
|
+
const width = colWidth(columns[i]);
|
|
17
|
+
const isPinned = anyPinned && i < pinned.length;
|
|
18
|
+
info.push({ pinned: isPinned, left: isPinned ? left : 0, width });
|
|
19
|
+
if (isPinned)
|
|
20
|
+
left += width;
|
|
21
|
+
totalWidth += width;
|
|
22
|
+
}
|
|
23
|
+
return { columns, info, totalWidth, anyPinned };
|
|
24
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ColumnDef, GridRow } from './column';
|
|
2
|
+
import { type AggKind } from './aggregate';
|
|
3
|
+
export interface PivotConfig {
|
|
4
|
+
/** Group rows by these fields (≥1); each becomes a leading text column. */
|
|
5
|
+
rowFields: string[];
|
|
6
|
+
/** Distinct values of this field become columns. Omit for a single measure column. */
|
|
7
|
+
columnField?: string;
|
|
8
|
+
/** Numeric field to aggregate into each cell. */
|
|
9
|
+
measure: string;
|
|
10
|
+
/** Aggregation. Default 'sum'. */
|
|
11
|
+
agg?: AggKind;
|
|
12
|
+
/** Decimal places for value columns. Default 0. */
|
|
13
|
+
decimals?: number;
|
|
14
|
+
/** Append a Total column (across all column values). Default true. */
|
|
15
|
+
totals?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export interface PivotResult {
|
|
18
|
+
rows: GridRow[];
|
|
19
|
+
columns: ColumnDef[];
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Transform flat rows into a pivot table: group by `rowFields`, optionally spread
|
|
23
|
+
* `columnField`'s distinct values into columns, and aggregate `measure` into each
|
|
24
|
+
* cell. Returns rows + columns ready to hand to <Grid>. Pure — call it yourself
|
|
25
|
+
* (snapshot or reactively) and feed the result in.
|
|
26
|
+
*/
|
|
27
|
+
export declare function pivot(source: readonly GridRow[], config: PivotConfig): PivotResult;
|
|
Binary file
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Return a new array with the item at index `from` moved to index `to`. */
|
|
2
|
+
export function moveIndex(arr, from, to) {
|
|
3
|
+
const next = [...arr];
|
|
4
|
+
if (from < 0 || to < 0 || from >= arr.length || to >= arr.length || from === to) {
|
|
5
|
+
return next;
|
|
6
|
+
}
|
|
7
|
+
const [moved] = next.splice(from, 1);
|
|
8
|
+
next.splice(to, 0, moved);
|
|
9
|
+
return next;
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Row-height model for the virtualizer. Two implementations behind one
|
|
3
|
+
* interface: a uniform O(1) model (the default fast path) and a variable model
|
|
4
|
+
* backed by prefix sums + binary search. The grid positions rows via offsetOf()
|
|
5
|
+
* and finds the first visible row via indexAt(), so it doesn't care which.
|
|
6
|
+
*/
|
|
7
|
+
export interface HeightModel {
|
|
8
|
+
readonly total: number;
|
|
9
|
+
readonly count: number;
|
|
10
|
+
/** Pixel offset (top) of the row at `index`. */
|
|
11
|
+
offsetOf(index: number): number;
|
|
12
|
+
heightOf(index: number): number;
|
|
13
|
+
/** Index of the row whose vertical span contains `offset`. */
|
|
14
|
+
indexAt(offset: number): number;
|
|
15
|
+
}
|
|
16
|
+
export declare function uniformHeights(count: number, h: number): HeightModel;
|
|
17
|
+
export declare function variableHeights(heights: readonly number[]): HeightModel;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function uniformHeights(count, h) {
|
|
2
|
+
const safe = h > 0 ? h : 1;
|
|
3
|
+
return {
|
|
4
|
+
total: count * safe,
|
|
5
|
+
count,
|
|
6
|
+
offsetOf: (i) => i * safe,
|
|
7
|
+
heightOf: () => safe,
|
|
8
|
+
indexAt: (offset) => {
|
|
9
|
+
if (count === 0)
|
|
10
|
+
return 0;
|
|
11
|
+
return Math.max(0, Math.min(count - 1, Math.floor(offset / safe)));
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function variableHeights(heights) {
|
|
16
|
+
const n = heights.length;
|
|
17
|
+
const prefix = new Float64Array(n + 1);
|
|
18
|
+
for (let i = 0; i < n; i++)
|
|
19
|
+
prefix[i + 1] = prefix[i] + Math.max(0, heights[i]);
|
|
20
|
+
return {
|
|
21
|
+
total: prefix[n],
|
|
22
|
+
count: n,
|
|
23
|
+
offsetOf: (i) => prefix[Math.max(0, Math.min(n, i))],
|
|
24
|
+
heightOf: (i) => (i >= 0 && i < n ? heights[i] : 0),
|
|
25
|
+
indexAt: (offset) => {
|
|
26
|
+
if (n === 0)
|
|
27
|
+
return 0;
|
|
28
|
+
// largest i with prefix[i] <= offset
|
|
29
|
+
let lo = 0;
|
|
30
|
+
let hi = n - 1;
|
|
31
|
+
while (lo < hi) {
|
|
32
|
+
const mid = (lo + hi + 1) >> 1;
|
|
33
|
+
if (prefix[mid] <= offset)
|
|
34
|
+
lo = mid;
|
|
35
|
+
else
|
|
36
|
+
hi = mid - 1;
|
|
37
|
+
}
|
|
38
|
+
return lo;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface CellRef {
|
|
2
|
+
r: number;
|
|
3
|
+
c: number;
|
|
4
|
+
}
|
|
5
|
+
export interface Bounds {
|
|
6
|
+
r0: number;
|
|
7
|
+
c0: number;
|
|
8
|
+
r1: number;
|
|
9
|
+
c1: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Rectangular cell selection defined by an anchor and a focus corner.
|
|
13
|
+
* Positional (by view row/column index), so it survives sort/filter the way a
|
|
14
|
+
* spreadsheet selection does. State is reactive ($state) so the grid re-renders
|
|
15
|
+
* cell highlighting and the aggregation bar as the selection changes.
|
|
16
|
+
*/
|
|
17
|
+
export declare class Selection {
|
|
18
|
+
anchor: CellRef | null;
|
|
19
|
+
focus: CellRef | null;
|
|
20
|
+
get bounds(): Bounds | null;
|
|
21
|
+
get count(): number;
|
|
22
|
+
start(r: number, c: number): void;
|
|
23
|
+
extendTo(r: number, c: number): void;
|
|
24
|
+
selectAll(rows: number, cols: number): void;
|
|
25
|
+
/** Move the focus by (dr, dc), clamped. When extend is false, collapse to it. */
|
|
26
|
+
move(dr: number, dc: number, extend: boolean, maxR: number, maxC: number): void;
|
|
27
|
+
contains(r: number, c: number): boolean;
|
|
28
|
+
isFocus(r: number, c: number): boolean;
|
|
29
|
+
clear(): void;
|
|
30
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rectangular cell selection defined by an anchor and a focus corner.
|
|
3
|
+
* Positional (by view row/column index), so it survives sort/filter the way a
|
|
4
|
+
* spreadsheet selection does. State is reactive ($state) so the grid re-renders
|
|
5
|
+
* cell highlighting and the aggregation bar as the selection changes.
|
|
6
|
+
*/
|
|
7
|
+
export class Selection {
|
|
8
|
+
anchor = $state(null);
|
|
9
|
+
focus = $state(null);
|
|
10
|
+
get bounds() {
|
|
11
|
+
if (!this.anchor || !this.focus)
|
|
12
|
+
return null;
|
|
13
|
+
return {
|
|
14
|
+
r0: Math.min(this.anchor.r, this.focus.r),
|
|
15
|
+
r1: Math.max(this.anchor.r, this.focus.r),
|
|
16
|
+
c0: Math.min(this.anchor.c, this.focus.c),
|
|
17
|
+
c1: Math.max(this.anchor.c, this.focus.c),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
get count() {
|
|
21
|
+
const b = this.bounds;
|
|
22
|
+
if (!b)
|
|
23
|
+
return 0;
|
|
24
|
+
return (b.r1 - b.r0 + 1) * (b.c1 - b.c0 + 1);
|
|
25
|
+
}
|
|
26
|
+
start(r, c) {
|
|
27
|
+
this.anchor = { r, c };
|
|
28
|
+
this.focus = { r, c };
|
|
29
|
+
}
|
|
30
|
+
extendTo(r, c) {
|
|
31
|
+
if (this.anchor)
|
|
32
|
+
this.focus = { r, c };
|
|
33
|
+
else
|
|
34
|
+
this.start(r, c);
|
|
35
|
+
}
|
|
36
|
+
selectAll(rows, cols) {
|
|
37
|
+
if (rows <= 0 || cols <= 0)
|
|
38
|
+
return;
|
|
39
|
+
this.anchor = { r: 0, c: 0 };
|
|
40
|
+
this.focus = { r: rows - 1, c: cols - 1 };
|
|
41
|
+
}
|
|
42
|
+
/** Move the focus by (dr, dc), clamped. When extend is false, collapse to it. */
|
|
43
|
+
move(dr, dc, extend, maxR, maxC) {
|
|
44
|
+
const f = this.focus ?? { r: 0, c: 0 };
|
|
45
|
+
const next = {
|
|
46
|
+
r: Math.max(0, Math.min(maxR, f.r + dr)),
|
|
47
|
+
c: Math.max(0, Math.min(maxC, f.c + dc)),
|
|
48
|
+
};
|
|
49
|
+
this.focus = next;
|
|
50
|
+
if (!extend || !this.anchor)
|
|
51
|
+
this.anchor = next;
|
|
52
|
+
}
|
|
53
|
+
contains(r, c) {
|
|
54
|
+
const b = this.bounds;
|
|
55
|
+
return !!b && r >= b.r0 && r <= b.r1 && c >= b.c0 && c <= b.c1;
|
|
56
|
+
}
|
|
57
|
+
isFocus(r, c) {
|
|
58
|
+
return this.focus?.r === r && this.focus?.c === c;
|
|
59
|
+
}
|
|
60
|
+
clear() {
|
|
61
|
+
this.anchor = null;
|
|
62
|
+
this.focus = null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ColumnDef } from './column';
|
|
2
|
+
/** Smallest width (px) a column may be dragged to. */
|
|
3
|
+
export declare const MIN_COL_WIDTH = 48;
|
|
4
|
+
/** User width overrides, keyed by column `key` so they survive reorder/pin. */
|
|
5
|
+
export type WidthMap = Record<string, number>;
|
|
6
|
+
/** Clamp + round a proposed drag width to the minimum. */
|
|
7
|
+
export declare function clampWidth(w: number): number;
|
|
8
|
+
/** Whether a column may be resized, given the grid-level toggle. */
|
|
9
|
+
export declare function isResizable(col: ColumnDef, gridResizable: boolean): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Apply width overrides to a column list. An overridden column becomes
|
|
12
|
+
* fixed-width (its `flex` cleared) so the dragged size sticks; in fit-to-width
|
|
13
|
+
* mode the remaining flex columns absorb the difference. Untouched columns pass
|
|
14
|
+
* through by reference, so the default fit-to-width layout is unchanged when no
|
|
15
|
+
* column has been resized.
|
|
16
|
+
*/
|
|
17
|
+
export declare function applyWidths(cols: readonly ColumnDef[], widths: WidthMap): ColumnDef[];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Smallest width (px) a column may be dragged to. */
|
|
2
|
+
export const MIN_COL_WIDTH = 48;
|
|
3
|
+
/** Clamp + round a proposed drag width to the minimum. */
|
|
4
|
+
export function clampWidth(w) {
|
|
5
|
+
return Math.max(MIN_COL_WIDTH, Math.round(w));
|
|
6
|
+
}
|
|
7
|
+
/** Whether a column may be resized, given the grid-level toggle. */
|
|
8
|
+
export function isResizable(col, gridResizable) {
|
|
9
|
+
return gridResizable && col.resizable !== false;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Apply width overrides to a column list. An overridden column becomes
|
|
13
|
+
* fixed-width (its `flex` cleared) so the dragged size sticks; in fit-to-width
|
|
14
|
+
* mode the remaining flex columns absorb the difference. Untouched columns pass
|
|
15
|
+
* through by reference, so the default fit-to-width layout is unchanged when no
|
|
16
|
+
* column has been resized.
|
|
17
|
+
*/
|
|
18
|
+
export function applyWidths(cols, widths) {
|
|
19
|
+
let changed = false;
|
|
20
|
+
const out = cols.map((c) => {
|
|
21
|
+
const w = widths[c.key];
|
|
22
|
+
if (w == null)
|
|
23
|
+
return c;
|
|
24
|
+
changed = true;
|
|
25
|
+
return { ...c, width: w, flex: undefined };
|
|
26
|
+
});
|
|
27
|
+
return changed ? out : cols;
|
|
28
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { GridRow, SortState } from './column';
|
|
2
|
+
export interface RowRange {
|
|
3
|
+
/** First row index (inclusive). */
|
|
4
|
+
start: number;
|
|
5
|
+
/** One past the last row index (exclusive). */
|
|
6
|
+
end: number;
|
|
7
|
+
}
|
|
8
|
+
export interface RowSourceParams {
|
|
9
|
+
range: RowRange;
|
|
10
|
+
/** Primary sort key, or null. Equals `sorts[0] ?? null`; kept for sources
|
|
11
|
+
that only support single-column sort. */
|
|
12
|
+
sort: SortState | null;
|
|
13
|
+
/** Full sort order (primary first) for multi-column sort. May be empty. */
|
|
14
|
+
sorts?: SortState[];
|
|
15
|
+
filter: string;
|
|
16
|
+
}
|
|
17
|
+
export interface RowSourceResult {
|
|
18
|
+
/** Rows for the requested range, in order. */
|
|
19
|
+
rows: GridRow[];
|
|
20
|
+
/** Total rows matching the current sort/filter (drives the scrollbar). */
|
|
21
|
+
total: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* A windowed, sort/filter-aware row provider. Implement this to back the grid
|
|
25
|
+
* with paginated/server data instead of an in-memory array. The grid asks only
|
|
26
|
+
* for the visible window plus overscan, so the dataset can be far larger than
|
|
27
|
+
* memory. Results may be returned synchronously or as a Promise.
|
|
28
|
+
*/
|
|
29
|
+
export interface RowSource {
|
|
30
|
+
getRows(params: RowSourceParams): RowSourceResult | Promise<RowSourceResult>;
|
|
31
|
+
}
|
|
32
|
+
export interface ArraySourceOptions {
|
|
33
|
+
/** Artificial latency (ms) to simulate a network round-trip. Default 0. */
|
|
34
|
+
latency?: number;
|
|
35
|
+
/** Row keys to match when filtering. Omit to disable filtering. */
|
|
36
|
+
filterKeys?: string[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Adapt an in-memory array to the RowSource interface — applies sort/filter and
|
|
40
|
+
* slices the requested range. Useful as a real client-side adapter and for
|
|
41
|
+
* exercising the server-side code path in tests/demos (set `latency`).
|
|
42
|
+
*/
|
|
43
|
+
export declare function createArraySource(all: readonly GridRow[], opts?: ArraySourceOptions): RowSource;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { compareBySorts } from './column';
|
|
2
|
+
/**
|
|
3
|
+
* Adapt an in-memory array to the RowSource interface — applies sort/filter and
|
|
4
|
+
* slices the requested range. Useful as a real client-side adapter and for
|
|
5
|
+
* exercising the server-side code path in tests/demos (set `latency`).
|
|
6
|
+
*/
|
|
7
|
+
export function createArraySource(all, opts = {}) {
|
|
8
|
+
const { latency = 0, filterKeys } = opts;
|
|
9
|
+
function compute(params) {
|
|
10
|
+
let rows = all;
|
|
11
|
+
const f = params.filter.trim().toLowerCase();
|
|
12
|
+
if (f && filterKeys && filterKeys.length > 0) {
|
|
13
|
+
rows = rows.filter((r) => filterKeys.some((k) => String(r[k] ?? '').toLowerCase().includes(f)));
|
|
14
|
+
}
|
|
15
|
+
const sorts = params.sorts?.length ? params.sorts : params.sort ? [params.sort] : [];
|
|
16
|
+
if (sorts.length > 0) {
|
|
17
|
+
rows = [...rows].sort((a, b) => compareBySorts(a, b, sorts));
|
|
18
|
+
}
|
|
19
|
+
const total = rows.length;
|
|
20
|
+
return { rows: rows.slice(params.range.start, params.range.end), total };
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
getRows(params) {
|
|
24
|
+
if (latency <= 0)
|
|
25
|
+
return compute(params);
|
|
26
|
+
return new Promise((resolve) => setTimeout(() => resolve(compute(params)), latency));
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { GridRow, SortState } from './column';
|
|
2
|
+
import type { RowRange, RowSource } from './source';
|
|
3
|
+
/**
|
|
4
|
+
* Drives a RowSource for the grid: fetches the visible window, caches rows by
|
|
5
|
+
* index, tracks the total, and guards against stale responses. Cache is keyed by
|
|
6
|
+
* the active sort+filter; changing either drops it (the index→row mapping moved).
|
|
7
|
+
*/
|
|
8
|
+
export declare class RowSourceController {
|
|
9
|
+
total: number;
|
|
10
|
+
loading: boolean;
|
|
11
|
+
/** Bumped whenever cached rows change, so renderers can react. */
|
|
12
|
+
version: number;
|
|
13
|
+
private readonly source;
|
|
14
|
+
private cache;
|
|
15
|
+
private reqId;
|
|
16
|
+
private key;
|
|
17
|
+
constructor(source: RowSource);
|
|
18
|
+
rowAt(index: number): GridRow | null;
|
|
19
|
+
private keyOf;
|
|
20
|
+
fetch(range: RowRange, sorts: SortState[], filter: string): Promise<void>;
|
|
21
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drives a RowSource for the grid: fetches the visible window, caches rows by
|
|
3
|
+
* index, tracks the total, and guards against stale responses. Cache is keyed by
|
|
4
|
+
* the active sort+filter; changing either drops it (the index→row mapping moved).
|
|
5
|
+
*/
|
|
6
|
+
export class RowSourceController {
|
|
7
|
+
total = $state(0);
|
|
8
|
+
loading = $state(false);
|
|
9
|
+
/** Bumped whenever cached rows change, so renderers can react. */
|
|
10
|
+
version = $state(0);
|
|
11
|
+
source;
|
|
12
|
+
cache = new Map();
|
|
13
|
+
reqId = 0;
|
|
14
|
+
key = '';
|
|
15
|
+
constructor(source) {
|
|
16
|
+
this.source = source;
|
|
17
|
+
}
|
|
18
|
+
rowAt(index) {
|
|
19
|
+
return this.cache.get(index) ?? null;
|
|
20
|
+
}
|
|
21
|
+
keyOf(sorts, filter) {
|
|
22
|
+
return `${sorts.map((s) => `${s.key}:${s.dir}`).join(',')}|${filter}`;
|
|
23
|
+
}
|
|
24
|
+
async fetch(range, sorts, filter) {
|
|
25
|
+
const key = this.keyOf(sorts, filter);
|
|
26
|
+
if (key !== this.key) {
|
|
27
|
+
this.key = key;
|
|
28
|
+
this.cache.clear();
|
|
29
|
+
this.total = 0;
|
|
30
|
+
this.version++;
|
|
31
|
+
}
|
|
32
|
+
let missing = false;
|
|
33
|
+
for (let i = range.start; i < range.end; i++) {
|
|
34
|
+
if (!this.cache.has(i)) {
|
|
35
|
+
missing = true;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!missing)
|
|
40
|
+
return;
|
|
41
|
+
const id = ++this.reqId;
|
|
42
|
+
this.loading = true;
|
|
43
|
+
const res = await this.source.getRows({ range, sort: sorts[0] ?? null, sorts, filter });
|
|
44
|
+
if (id !== this.reqId)
|
|
45
|
+
return; // a newer request superseded this one
|
|
46
|
+
this.total = res.total;
|
|
47
|
+
for (let i = 0; i < res.rows.length; i++) {
|
|
48
|
+
this.cache.set(range.start + i, res.rows[i]);
|
|
49
|
+
}
|
|
50
|
+
this.loading = false;
|
|
51
|
+
this.version++;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme = a partial set of design tokens. Applied as inline `--bo-grid-*` custom
|
|
3
|
+
* properties on the grid root, so a theme overrides the built-in dark defaults
|
|
4
|
+
* without any CSS import. Pass a built-in name or your own partial token map.
|
|
5
|
+
*/
|
|
6
|
+
export interface GridTheme {
|
|
7
|
+
bg?: string;
|
|
8
|
+
headerBg?: string;
|
|
9
|
+
rowA?: string;
|
|
10
|
+
rowB?: string;
|
|
11
|
+
rowHover?: string;
|
|
12
|
+
text?: string;
|
|
13
|
+
textDim?: string;
|
|
14
|
+
border?: string;
|
|
15
|
+
up?: string;
|
|
16
|
+
down?: string;
|
|
17
|
+
amber?: string;
|
|
18
|
+
selFill?: string;
|
|
19
|
+
selBorder?: string;
|
|
20
|
+
headerH?: string;
|
|
21
|
+
mono?: string;
|
|
22
|
+
sans?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Serialize a theme to a CSS `--bo-grid-*: …;` declaration string. */
|
|
25
|
+
export declare function themeVars(theme: GridTheme): string;
|
|
26
|
+
export declare const darkTheme: GridTheme;
|
|
27
|
+
export declare const lightTheme: GridTheme;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const VARS = {
|
|
2
|
+
bg: '--bo-grid-bg',
|
|
3
|
+
headerBg: '--bo-grid-header-bg',
|
|
4
|
+
rowA: '--bo-grid-row-a',
|
|
5
|
+
rowB: '--bo-grid-row-b',
|
|
6
|
+
rowHover: '--bo-grid-row-hover',
|
|
7
|
+
text: '--bo-grid-text',
|
|
8
|
+
textDim: '--bo-grid-text-dim',
|
|
9
|
+
border: '--bo-grid-border',
|
|
10
|
+
up: '--bo-grid-up',
|
|
11
|
+
down: '--bo-grid-down',
|
|
12
|
+
amber: '--bo-grid-amber',
|
|
13
|
+
selFill: '--bo-grid-sel-fill',
|
|
14
|
+
selBorder: '--bo-grid-sel-border',
|
|
15
|
+
headerH: '--bo-grid-header-h',
|
|
16
|
+
mono: '--bo-grid-mono',
|
|
17
|
+
sans: '--bo-grid-sans',
|
|
18
|
+
};
|
|
19
|
+
/** Serialize a theme to a CSS `--bo-grid-*: …;` declaration string. */
|
|
20
|
+
export function themeVars(theme) {
|
|
21
|
+
let out = '';
|
|
22
|
+
for (const key of Object.keys(theme)) {
|
|
23
|
+
const value = theme[key];
|
|
24
|
+
if (value != null)
|
|
25
|
+
out += `${VARS[key]}:${value};`;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
export const darkTheme = {
|
|
30
|
+
bg: '#1a1a1a',
|
|
31
|
+
headerBg: '#0f0f0f',
|
|
32
|
+
rowA: '#131313',
|
|
33
|
+
rowB: '#0f0f0f',
|
|
34
|
+
rowHover: '#1f1f24',
|
|
35
|
+
text: '#e5e5e5',
|
|
36
|
+
textDim: '#8a8a8a',
|
|
37
|
+
border: 'rgba(255,255,255,0.06)',
|
|
38
|
+
up: '#34d399',
|
|
39
|
+
down: '#f87171',
|
|
40
|
+
amber: '#f59e0b',
|
|
41
|
+
selFill: 'rgba(99,102,241,0.16)',
|
|
42
|
+
selBorder: '#6366f1',
|
|
43
|
+
};
|
|
44
|
+
// A deliberate light palette (not an inverted dark one): near-white surfaces,
|
|
45
|
+
// stronger green/red for contrast on light, subtle borders.
|
|
46
|
+
export const lightTheme = {
|
|
47
|
+
bg: '#ffffff',
|
|
48
|
+
headerBg: '#f6f7f9',
|
|
49
|
+
rowA: '#fafbfc',
|
|
50
|
+
rowB: '#ffffff',
|
|
51
|
+
rowHover: '#eef1f6',
|
|
52
|
+
text: '#16181d',
|
|
53
|
+
textDim: '#6b7280',
|
|
54
|
+
border: 'rgba(0,0,0,0.10)',
|
|
55
|
+
up: '#16a34a',
|
|
56
|
+
down: '#dc2626',
|
|
57
|
+
amber: '#d97706',
|
|
58
|
+
selFill: 'rgba(99,102,241,0.12)',
|
|
59
|
+
selBorder: '#6366f1',
|
|
60
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { default as Grid } from './grid/Grid.svelte';
|
|
2
|
+
export { default as Sparkline } from './sparkline/Sparkline.svelte';
|
|
3
|
+
export type { ColumnDef, Align, GridRow, SortDir, SortState, CellEditEvent } from './grid/column';
|
|
4
|
+
export type { AggKind, AggResult } from './grid/aggregate';
|
|
5
|
+
export { fmtPrice, fmtPercent, fmtVolume, fmtDate } from './format/format';
|
|
6
|
+
export type { DateStyle } from './format/format';
|
|
7
|
+
export { aggregate } from './grid/aggregate';
|
|
8
|
+
export { heatColor } from './grid/heatmap';
|
|
9
|
+
export { pivot } from './grid/pivot';
|
|
10
|
+
export type { PivotConfig, PivotResult } from './grid/pivot';
|
|
11
|
+
export { themeVars, darkTheme, lightTheme } from './grid/theme';
|
|
12
|
+
export type { GridTheme } from './grid/theme';
|
|
13
|
+
export { drawCandles, setupHiDpiCanvas } from './sparkline/sparkline-render';
|
|
14
|
+
export { toCSV, exportCSV } from './grid/export';
|
|
15
|
+
export { exportXLSX } from './grid/export-xlsx';
|
|
16
|
+
export type { ExportOptions } from './grid/export';
|
|
17
|
+
export { createArraySource } from './grid/source';
|
|
18
|
+
export type { RowSource, RowRange, RowSourceParams, RowSourceResult, ArraySourceOptions, } from './grid/source';
|
|
19
|
+
export type { Candle } from './types';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Public API for the bo-grid package.
|
|
2
|
+
// Consumers: `import { Grid, type ColumnDef } from 'bo-grid';`
|
|
3
|
+
//
|
|
4
|
+
// This surface is intentionally small — every export is a compatibility promise.
|
|
5
|
+
// Internal helpers (layout, sorting, grouping, selection internals) are NOT
|
|
6
|
+
// exported; they can change freely between versions.
|
|
7
|
+
// Components
|
|
8
|
+
export { default as Grid } from './grid/Grid.svelte';
|
|
9
|
+
export { default as Sparkline } from './sparkline/Sparkline.svelte';
|
|
10
|
+
// Value formatters (handy when building custom cell content)
|
|
11
|
+
export { fmtPrice, fmtPercent, fmtVolume, fmtDate } from './format/format';
|
|
12
|
+
// Standalone helpers
|
|
13
|
+
export { aggregate } from './grid/aggregate';
|
|
14
|
+
export { heatColor } from './grid/heatmap';
|
|
15
|
+
export { pivot } from './grid/pivot';
|
|
16
|
+
// Theming
|
|
17
|
+
export { themeVars, darkTheme, lightTheme } from './grid/theme';
|
|
18
|
+
// Sparkline canvas primitives (draw candlesticks on your own canvas)
|
|
19
|
+
export { drawCandles, setupHiDpiCanvas } from './sparkline/sparkline-render';
|
|
20
|
+
// Export (CSV is dependency-free; XLSX dynamic-imports the optional `xlsx` peer)
|
|
21
|
+
export { toCSV, exportCSV } from './grid/export';
|
|
22
|
+
export { exportXLSX } from './grid/export-xlsx';
|
|
23
|
+
// Server-side / windowed data source
|
|
24
|
+
export { createArraySource } from './grid/source';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Candle } from '../types';
|
|
3
|
+
import { setupHiDpiCanvas, drawCandles, summarize, candleAtX } from './sparkline-render';
|
|
4
|
+
import { fmtPrice } from '../format/format';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
candles,
|
|
8
|
+
width = 104,
|
|
9
|
+
height = 26,
|
|
10
|
+
}: { candles: Candle[]; width?: number; height?: number } = $props();
|
|
11
|
+
|
|
12
|
+
let canvas: HTMLCanvasElement;
|
|
13
|
+
let hover = $state<number | null>(null);
|
|
14
|
+
|
|
15
|
+
// Redraw whenever the candle array reference changes. Only mounted (= visible)
|
|
16
|
+
// sparklines run this, so the realtime feed never repaints off-screen charts.
|
|
17
|
+
$effect(() => {
|
|
18
|
+
const ctx = setupHiDpiCanvas(canvas, width, height);
|
|
19
|
+
drawCandles(ctx, candles, width, height);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const label = $derived(summarize(candles));
|
|
23
|
+
|
|
24
|
+
function onMove(e: MouseEvent) {
|
|
25
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
26
|
+
hover = candleAtX(e.clientX - rect.left, width, candles.length);
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<div class="spark" style="width:{width}px;height:{height}px" role="img" aria-label={label}>
|
|
31
|
+
<canvas bind:this={canvas}></canvas>
|
|
32
|
+
<div
|
|
33
|
+
class="hit"
|
|
34
|
+
role="presentation"
|
|
35
|
+
onmousemove={onMove}
|
|
36
|
+
onmouseleave={() => (hover = null)}
|
|
37
|
+
></div>
|
|
38
|
+
{#if hover !== null && candles[hover]}
|
|
39
|
+
<div class="tip">
|
|
40
|
+
O {fmtPrice(candles[hover].open)} · H {fmtPrice(candles[hover].high)} · L
|
|
41
|
+
{fmtPrice(candles[hover].low)} · C {fmtPrice(candles[hover].close)}
|
|
42
|
+
</div>
|
|
43
|
+
{/if}
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<style>
|
|
47
|
+
.spark {
|
|
48
|
+
position: relative;
|
|
49
|
+
display: inline-block;
|
|
50
|
+
}
|
|
51
|
+
canvas {
|
|
52
|
+
display: block;
|
|
53
|
+
}
|
|
54
|
+
.hit {
|
|
55
|
+
position: absolute;
|
|
56
|
+
inset: 0;
|
|
57
|
+
cursor: crosshair;
|
|
58
|
+
}
|
|
59
|
+
.tip {
|
|
60
|
+
position: absolute;
|
|
61
|
+
bottom: calc(100% + 4px);
|
|
62
|
+
right: 0;
|
|
63
|
+
z-index: 10;
|
|
64
|
+
white-space: nowrap;
|
|
65
|
+
padding: 4px 6px;
|
|
66
|
+
font-family: var(--bo-mono, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace);
|
|
67
|
+
font-size: 10px;
|
|
68
|
+
color: var(--bo-text, #e5e5e5);
|
|
69
|
+
background: #000;
|
|
70
|
+
border: 0.5px solid var(--bo-border, rgba(255, 255, 255, 0.12));
|
|
71
|
+
border-radius: 4px;
|
|
72
|
+
pointer-events: none;
|
|
73
|
+
}
|
|
74
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Candle } from '../types';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
candles: Candle[];
|
|
4
|
+
width?: number;
|
|
5
|
+
height?: number;
|
|
6
|
+
};
|
|
7
|
+
declare const Sparkline: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type Sparkline = ReturnType<typeof Sparkline>;
|
|
9
|
+
export default Sparkline;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Candle } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Size a canvas for the current devicePixelRatio so 1px CSS strokes render
|
|
4
|
+
* crisp on HiDPI/Retina. Returns a context already scaled to CSS pixels —
|
|
5
|
+
* callers draw in CSS-pixel coordinates and forget dpr exists.
|
|
6
|
+
*/
|
|
7
|
+
export declare function setupHiDpiCanvas(canvas: HTMLCanvasElement, cssW: number, cssH: number): CanvasRenderingContext2D;
|
|
8
|
+
/**
|
|
9
|
+
* Draw compact candlesticks. Body shows open→close, wick shows low→high.
|
|
10
|
+
* Color by per-candle direction. Canvas (not SVG) so 100+ of these stay cheap.
|
|
11
|
+
*/
|
|
12
|
+
export declare function drawCandles(ctx: CanvasRenderingContext2D, candles: Candle[], w: number, h: number): void;
|
|
13
|
+
/** Screen-reader text — canvas alone is invisible to AT, so we narrate trend. */
|
|
14
|
+
export declare function summarize(candles: Candle[]): string;
|
|
15
|
+
/** Which candle index sits under an x offset (for hover tooltips). */
|
|
16
|
+
export declare function candleAtX(x: number, w: number, n: number): number;
|