|
| 1 | +import React, { useMemo } from "react"; |
| 2 | +import { Responsive, WidthProvider } from "react-grid-layout/legacy"; |
| 3 | +import type { FieldDataType } from "../types"; |
| 4 | +import RenderChart from "./RenderChart"; |
| 5 | + |
| 6 | +// ── Types ───────────────────────────────────────────────────────────────────── |
| 7 | + |
| 8 | +export interface DashboardSlot { |
| 9 | + settings: { |
| 10 | + i: string; |
| 11 | + x: number; |
| 12 | + y: number; |
| 13 | + /** Span bucket: 1 = narrow (~1/3), 2 = medium (~2/3), 3 = full width */ |
| 14 | + w: number; |
| 15 | + /** Row height multiplier */ |
| 16 | + h: number; |
| 17 | + }; |
| 18 | + chart: FieldDataType; |
| 19 | +} |
| 20 | + |
| 21 | +export interface DashboardData { |
| 22 | + id?: string; |
| 23 | + name?: string; |
| 24 | + description?: string; |
| 25 | + publish?: boolean; |
| 26 | + slots: DashboardSlot[]; |
| 27 | +} |
| 28 | + |
| 29 | +export interface RenderDashboardProps { |
| 30 | + data: DashboardData; |
| 31 | + /** Base row height in px. Defaults to 380. */ |
| 32 | + rowHeight?: number; |
| 33 | + /** Gap between slots in px. Defaults to 16. */ |
| 34 | + margin?: number; |
| 35 | + /** Show dashboard name and description heading. Defaults to true. */ |
| 36 | + showHeading?: boolean; |
| 37 | +} |
| 38 | + |
| 39 | +// ── Grid config ─────────────────────────────────────────────────────────────── |
| 40 | + |
| 41 | +const TOTAL_COLS = 12; |
| 42 | +const ALL_COLS = { lg: 12, md: 12, sm: 12, xs: 12, xxs: 12 }; |
| 43 | +const BREAKPOINTS = { lg: 1200, md: 768, sm: 480, xs: 320, xxs: 0 }; |
| 44 | + |
| 45 | +const SPAN_UNITS: Record<string, number[]> = { |
| 46 | + lg: [4, 8, 12], |
| 47 | + md: [6, 12, 12], |
| 48 | + sm: [12, 12, 12], |
| 49 | + xs: [12, 12, 12], |
| 50 | + xxs: [12, 12, 12], |
| 51 | +}; |
| 52 | + |
| 53 | +function toGridW(span: number, bp: string): number { |
| 54 | + return (SPAN_UNITS[bp] ?? SPAN_UNITS.sm)[Math.min(span, 3) - 1] ?? TOTAL_COLS; |
| 55 | +} |
| 56 | + |
| 57 | +type TLayoutItem = { i: string; x: number; y: number; w: number; h: number }; |
| 58 | + |
| 59 | +function buildLayouts(items: TLayoutItem[]) { |
| 60 | + const lgRgl = items.map((item) => ({ |
| 61 | + i: item.i, |
| 62 | + x: item.x, |
| 63 | + y: item.y, |
| 64 | + w: toGridW(item.w, "lg"), |
| 65 | + h: item.h, |
| 66 | + })); |
| 67 | + |
| 68 | + function packBp(bp: string) { |
| 69 | + const sorted = [...items].sort((a, b) => |
| 70 | + a.y !== b.y ? a.y - b.y : a.x - b.x |
| 71 | + ); |
| 72 | + let x = 0, y = 0, rowMaxH = 1; |
| 73 | + return sorted.map((item) => { |
| 74 | + const w = toGridW(item.w, bp); |
| 75 | + if (x + w > TOTAL_COLS) { |
| 76 | + x = 0; |
| 77 | + y += rowMaxH; |
| 78 | + rowMaxH = item.h; |
| 79 | + } else { |
| 80 | + rowMaxH = Math.max(rowMaxH, item.h); |
| 81 | + } |
| 82 | + const out = { i: item.i, x, y, w, h: item.h }; |
| 83 | + x += w; |
| 84 | + return out; |
| 85 | + }); |
| 86 | + } |
| 87 | + |
| 88 | + return { |
| 89 | + lg: lgRgl, |
| 90 | + md: packBp("md"), |
| 91 | + sm: packBp("sm"), |
| 92 | + xs: packBp("xs"), |
| 93 | + xxs: packBp("xxs"), |
| 94 | + }; |
| 95 | +} |
| 96 | + |
| 97 | +const ResponsiveGrid = WidthProvider(Responsive); |
| 98 | + |
| 99 | +// ── Component ───────────────────────────────────────────────────────────────── |
| 100 | + |
| 101 | +export function RenderDashboard({ |
| 102 | + data, |
| 103 | + rowHeight = 380, |
| 104 | + margin = 16, |
| 105 | + showHeading = true, |
| 106 | +}: RenderDashboardProps) { |
| 107 | + const { layouts, items, chartMap } = useMemo(() => { |
| 108 | + const items: TLayoutItem[] = data.slots.map(({ settings }) => ({ |
| 109 | + i: settings.i, |
| 110 | + x: settings.x, |
| 111 | + y: settings.y, |
| 112 | + w: settings.w, |
| 113 | + h: settings.h, |
| 114 | + })); |
| 115 | + const chartMap = data.slots.reduce<Record<string, FieldDataType>>( |
| 116 | + (acc, { settings, chart }) => { acc[settings.i] = chart; return acc; }, |
| 117 | + {} |
| 118 | + ); |
| 119 | + return { layouts: buildLayouts(items), items, chartMap }; |
| 120 | + }, [data.slots]); |
| 121 | + |
| 122 | + return ( |
| 123 | + <div> |
| 124 | + {showHeading && data.name && ( |
| 125 | + <h2 |
| 126 | + style={{ |
| 127 | + margin: `0 0 ${margin / 2}px`, |
| 128 | + fontSize: "1.5rem", |
| 129 | + fontWeight: 700, |
| 130 | + lineHeight: 1.3, |
| 131 | + }} |
| 132 | + > |
| 133 | + {data.name} |
| 134 | + </h2> |
| 135 | + )} |
| 136 | + {showHeading && data.description && ( |
| 137 | + <p |
| 138 | + style={{ |
| 139 | + margin: `0 0 ${margin}px`, |
| 140 | + fontSize: "1rem", |
| 141 | + opacity: 0.7, |
| 142 | + }} |
| 143 | + > |
| 144 | + {data.description} |
| 145 | + </p> |
| 146 | + )} |
| 147 | + |
| 148 | + <ResponsiveGrid |
| 149 | + layouts={layouts} |
| 150 | + breakpoints={BREAKPOINTS} |
| 151 | + cols={ALL_COLS} |
| 152 | + rowHeight={rowHeight} |
| 153 | + margin={[margin, margin]} |
| 154 | + isDraggable={false} |
| 155 | + isResizable={false} |
| 156 | + > |
| 157 | + {items.map((item) => { |
| 158 | + const chart = chartMap[item.i] as FieldDataType; |
| 159 | + const chartHeight = rowHeight * item.h + margin * (item.h - 1); |
| 160 | + |
| 161 | + return ( |
| 162 | + <div |
| 163 | + key={item.i} |
| 164 | + style={{ |
| 165 | + overflow: "hidden", |
| 166 | + borderRadius: 8, |
| 167 | + border: "1px solid rgba(128,128,128,0.15)", |
| 168 | + boxShadow: "0 1px 3px rgba(0,0,0,0.06)", |
| 169 | + }} |
| 170 | + > |
| 171 | + {chart && ( |
| 172 | + <RenderChart |
| 173 | + {...chart} |
| 174 | + rowHeight={chartHeight} |
| 175 | + hFactor={1} |
| 176 | + /> |
| 177 | + )} |
| 178 | + </div> |
| 179 | + ); |
| 180 | + })} |
| 181 | + </ResponsiveGrid> |
| 182 | + </div> |
| 183 | + ); |
| 184 | +} |
| 185 | + |
| 186 | +export default RenderDashboard; |
0 commit comments