Line data Source code
1 : export const DASHBOARD_COLUMN_COUNT = 12;
2 : /// A row is a single line, as high as a collapsed card
3 : export const DASHBOARD_ROW_HEIGHT = 40;
4 : export const DASHBOARD_GRID_GAP = 12;
5 : export const DASHBOARD_DEFAULT_WIDTH = 6;
6 : export const DASHBOARD_DEFAULT_HEIGHT = 6;
7 : /// A script without a visualization only offers its renderers, a third of the board is enough
8 : export const DASHBOARD_COLLAPSED_WIDTH = 4;
9 :
10 : export interface DashboardLayoutHint {
11 : row?: number | null;
12 : column?: number | null;
13 : width?: number | null;
14 : height?: number | null;
15 : }
16 :
17 : /// The board stacks its sections: the scripts without a renderer on top, the visualizations below.
18 : /// Every section is a grid of its own, rows and columns count from its top left corner.
19 : export type DashboardSection = 'scripts' | 'visualizations';
20 :
21 : export interface DashboardLayoutItem extends DashboardLayoutHint {
22 : id: number;
23 : /// Defaults to the visualizations
24 : section?: DashboardSection;
25 : }
26 :
27 : export interface DashboardPlacement {
28 : id: number;
29 : section: DashboardSection;
30 : row: number;
31 : column: number;
32 : width: number;
33 : height: number;
34 : }
35 :
36 1 : function clampInteger(value: number | null | undefined, fallback: number, minimum: number, maximum: number): number {
37 1 : if (value == null || !Number.isFinite(value)) return fallback;
38 1 : return Math.min(maximum, Math.max(minimum, Math.trunc(value)));
39 1 : }
40 :
41 1 : function normalize(item: DashboardLayoutItem): DashboardPlacement {
42 1 : const width = clampInteger(item.width, DASHBOARD_DEFAULT_WIDTH, 1, DASHBOARD_COLUMN_COUNT);
43 1 : return {
44 1 : id: item.id,
45 1 : section: item.section ?? 'visualizations',
46 1 : row: clampInteger(item.row, 0, 0, Number.MAX_SAFE_INTEGER),
47 1 : column: clampInteger(item.column, 0, 0, DASHBOARD_COLUMN_COUNT - width),
48 1 : width,
49 1 : height: clampInteger(item.height, DASHBOARD_DEFAULT_HEIGHT, 1, Number.MAX_SAFE_INTEGER),
50 1 : };
51 1 : }
52 :
53 1 : export function dashboardPlacementsIntersect(a: DashboardPlacement, b: DashboardPlacement): boolean {
54 1 : return a.column < b.column + b.width
55 1 : && a.column + a.width > b.column
56 1 : && a.row < b.row + b.height
57 1 : && a.row + a.height > b.row;
58 1 : }
59 :
60 1 : function findOpenPlacement(item: DashboardPlacement, placed: readonly DashboardPlacement[]): DashboardPlacement {
61 1 : let row = item.row;
62 1 : let column = item.column;
63 1 : while (true) {
64 1 : const candidate = { ...item, row, column };
65 1 : if (!placed.some(other => dashboardPlacementsIntersect(candidate, other))) return candidate;
66 1 : column += 1;
67 1 : if (column + item.width > DASHBOARD_COLUMN_COUNT) {
68 1 : column = 0;
69 1 : row += 1;
70 1 : }
71 1 : }
72 1 : }
73 :
74 : /// Does an item ask for a position, a row or a column?
75 1 : function isPositioned(item: DashboardLayoutItem): boolean {
76 1 : return item.row != null || item.column != null;
77 1 : }
78 :
79 : /**
80 : * Place items deterministically, every section on its own. `ownerId` is placed first and therefore owns its
81 : * requested cells. The items with a position follow, the items without one then flow into the first free space.
82 : */
83 1 : export function placeDashboardItems(
84 1 : items: readonly DashboardLayoutItem[],
85 1 : ownerId: number | null = null,
86 1 : ): DashboardPlacement[] {
87 1 : const rank = (item: DashboardLayoutItem) => item.id === ownerId ? 0 : isPositioned(item) ? 1 : 2;
88 1 : const ordered = items
89 1 : .map((item, index) => ({ item, index }))
90 1 : .sort((a, b) => rank(a.item) - rank(b.item) || a.index - b.index)
91 1 : .map(({ item }) => normalize(item));
92 1 : const placed: DashboardPlacement[] = [];
93 1 : for (const item of ordered) {
94 1 : placed.push(findOpenPlacement(item, placed.filter(other => other.section === item.section)));
95 : }
96 1 : const byId = new Map(placed.map(item => [item.id, item]));
97 1 : return items.map(item => byId.get(item.id)!);
98 1 : }
99 :
100 1 : export function moveDashboardItem(
101 1 : items: readonly DashboardPlacement[],
102 1 : ownerId: number,
103 1 : row: number,
104 1 : column: number,
105 1 : ): DashboardPlacement[] {
106 1 : return placeDashboardItems(items.map(item => item.id === ownerId ? { ...item, row, column } : item), ownerId);
107 1 : }
108 :
109 1 : export function resizeDashboardItem(
110 1 : items: readonly DashboardPlacement[],
111 1 : ownerId: number,
112 1 : width: number,
113 1 : height: number,
114 1 : ): DashboardPlacement[] {
115 1 : return placeDashboardItems(items.map(item => item.id === ownerId ? { ...item, width, height } : item), ownerId);
116 1 : }
117 :
118 : /// The number of rows of a section
119 1 : export function dashboardSectionRows(items: readonly DashboardPlacement[], section: DashboardSection): number {
120 1 : return items.reduce((max, item) => item.section === section ? Math.max(max, item.row + item.height) : max, 0);
121 1 : }
122 :
123 : /// The height of a card in pixels, with the gaps between its rows
124 1 : export function dashboardCardHeight(rows: number): number {
125 1 : return rows * DASHBOARD_ROW_HEIGHT + Math.max(0, rows - 1) * DASHBOARD_GRID_GAP;
126 1 : }
|