diff --git a/statshouse-ui/package-lock.json b/statshouse-ui/package-lock.json index 983055ba2..364171dd5 100644 --- a/statshouse-ui/package-lock.json +++ b/statshouse-ui/package-lock.json @@ -49,6 +49,7 @@ "react": "^19.0.0", "react-data-grid": "^7.0.0-beta.47", "react-dom": "^19.0.0", + "react-grid-layout": "^1.5.0", "react-markdown": "^9.0.3", "react-router-dom": "^7.1.1", "react-window": "^1.8.11", @@ -69,6 +70,7 @@ "@types/node": "^20.17.12", "@types/react": "^19.0.4", "@types/react-dom": "^19.0.2", + "@types/react-grid-layout": "^1.3.5", "@typescript-eslint/eslint-plugin": "^8.19.1", "@typescript-eslint/parser": "^8.19.1", "prettier": "^3.4.2" @@ -4889,6 +4891,16 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/react-grid-layout": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz", + "integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-window": { "version": "1.8.8", "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", @@ -7053,6 +7065,12 @@ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==" }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -12331,6 +12349,47 @@ "react": "^19.0.0" } }, + "node_modules/react-draggable": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", + "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-draggable/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-grid-layout": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.0.tgz", + "integrity": "sha512-WBKX7w/LsTfI99WskSu6nX2nbJAUD7GD6nIXcwYLyPpnslojtmql2oD3I2g5C3AK8hrxIarYT8awhuDIp7iQ5w==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.5", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -12362,6 +12421,19 @@ "react": ">=18" } }, + "node_modules/react-resizable": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz", + "integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + }, + "peerDependencies": { + "react": ">= 16.3" + } + }, "node_modules/react-router": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.1.tgz", @@ -12625,6 +12697,12 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", @@ -17486,6 +17564,15 @@ "devOptional": true, "requires": {} }, + "@types/react-grid-layout": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz", + "integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/react-window": { "version": "1.8.8", "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", @@ -18966,6 +19053,11 @@ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz", "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==" }, + "fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==" + }, "fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -22704,6 +22796,35 @@ "scheduler": "^0.25.0" } }, + "react-draggable": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.4.6.tgz", + "integrity": "sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==", + "requires": { + "clsx": "^1.1.1", + "prop-types": "^15.8.1" + }, + "dependencies": { + "clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==" + } + } + }, + "react-grid-layout": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-1.5.0.tgz", + "integrity": "sha512-WBKX7w/LsTfI99WskSu6nX2nbJAUD7GD6nIXcwYLyPpnslojtmql2oD3I2g5C3AK8hrxIarYT8awhuDIp7iQ5w==", + "requires": { + "clsx": "^2.0.0", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.5", + "react-resizable": "^3.0.5", + "resize-observer-polyfill": "^1.5.1" + } + }, "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -22727,6 +22848,15 @@ "vfile": "^6.0.0" } }, + "react-resizable": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.0.5.tgz", + "integrity": "sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==", + "requires": { + "prop-types": "15.x", + "react-draggable": "^4.0.3" + } + }, "react-router": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.1.tgz", @@ -22910,6 +23040,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, + "resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" + }, "resolve": { "version": "1.22.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", diff --git a/statshouse-ui/package.json b/statshouse-ui/package.json index da678c9d1..37c8ea701 100644 --- a/statshouse-ui/package.json +++ b/statshouse-ui/package.json @@ -45,6 +45,7 @@ "react": "^19.0.0", "react-data-grid": "^7.0.0-beta.47", "react-dom": "^19.0.0", + "react-grid-layout": "^1.5.0", "react-markdown": "^9.0.3", "react-router-dom": "^7.1.1", "react-window": "^1.8.11", @@ -65,6 +66,7 @@ "@types/node": "^20.17.12", "@types/react": "^19.0.4", "@types/react-dom": "^19.0.2", + "@types/react-grid-layout": "^1.3.5", "@typescript-eslint/eslint-plugin": "^8.19.1", "@typescript-eslint/parser": "^8.19.1", "prettier": "^3.4.2" diff --git a/statshouse-ui/src/api/enum.ts b/statshouse-ui/src/api/enum.ts index 0b0c3d59f..90d04f44a 100644 --- a/statshouse-ui/src/api/enum.ts +++ b/statshouse-ui/src/api/enum.ts @@ -66,6 +66,7 @@ export const GET_PARAMS = { dashboardGroupInfoCount: 'n', dashboardGroupInfoSize: 's', dashboardGroupInfoDescription: 'd', + dashboardGroupInfoLayouts: 'ly', metricLive: 'live', theme: 'theme', avoidCache: 'ac', diff --git a/statshouse-ui/src/common/helpers.ts b/statshouse-ui/src/common/helpers.ts index e9341bd8d..e1ae7f784 100644 --- a/statshouse-ui/src/common/helpers.ts +++ b/statshouse-ui/src/common/helpers.ts @@ -7,6 +7,10 @@ import { produce } from 'immer'; import { mapKeyboardEnToRu, mapKeyboardRuToEn, toggleKeyboard } from './toggleKeyboard'; import type uPlot from 'uplot'; +import { BREAKPOINT_WIDTH } from '@/components2/Dashboard/constants'; +import { GroupInfo } from '@/url2'; +import { BreakpointKey, LayoutScheme } from '@/components2/Dashboard/types'; +import { BREAKPOINTS_SIZES } from '@/components2/Dashboard/constants'; export function isArray(item: unknown): item is unknown[] { return Array.isArray(item); @@ -427,3 +431,74 @@ export const bwd = (v: number) => { } return Math.pow(2, v) - 1; }; + +const getBreakpointKey = (width: number): BreakpointKey => { + if (width >= BREAKPOINT_WIDTH.xxxl) return BREAKPOINTS_SIZES.xxxl; + if (width >= BREAKPOINT_WIDTH.xxl) return BREAKPOINTS_SIZES.xxl; + if (width >= BREAKPOINT_WIDTH.xl) return BREAKPOINTS_SIZES.xl; + if (width >= BREAKPOINT_WIDTH.lg) return BREAKPOINTS_SIZES.lg; + if (width >= BREAKPOINT_WIDTH.md) return BREAKPOINTS_SIZES.md; + if (width >= BREAKPOINT_WIDTH.sm) return BREAKPOINTS_SIZES.sm; + if (width >= BREAKPOINT_WIDTH.xs) return BREAKPOINTS_SIZES.xs; + return BREAKPOINTS_SIZES.xxs; +}; + +export const getBreakpointConfig = () => { + const width = window.innerWidth; + return { breakpointKey: getBreakpointKey(width) }; +}; + +export const calculateMaxRows = (plots: string[], cols: number, layout?: { y: number; h: number }[]) => { + if (!layout?.length) { + return Math.ceil(plots.length / cols) + 1; + } + + const maxOccupiedRow = layout.reduce((max, item) => { + const itemLastRow = item.y + item.h; + return Math.max(max, itemLastRow); + }, 0); + + return maxOccupiedRow + 1; +}; + +export const calculateDynamicRowHeight = (width: number, baseWidth: number = 2700, baseHeight: number = 480) => { + if (width <= baseWidth) { + return baseHeight; + } + const extraWidth = width - baseWidth; + const extraBlocks = Math.floor(extraWidth / 300); + + const finalHeight = baseHeight + extraBlocks * 30; + + return finalHeight; +}; + +export const updateGroupWithLayout = (groupInfo: GroupInfo, groupKey: string, layouts?: LayoutScheme) => { + const layoutScheme = layouts?.groupKey === groupKey; + + if (layoutScheme) { + groupInfo.layouts = layouts.layout; + } + return groupInfo; +}; + +// convert size to number of columns +export function getSizeColumns(size?: string): number { + if (size === undefined) return 2; + + const numericSize = Number(size); + if (!isNaN(numericSize)) { + return numericSize; + } + + switch (size) { + case 'l': + return 2; + case 'm': + return 3; + case 's': + return 4; + default: + return 2; + } +} diff --git a/statshouse-ui/src/common/prepareItemsGroup.tsx b/statshouse-ui/src/common/prepareItemsGroup.tsx index 01348ef57..b742f4bff 100644 --- a/statshouse-ui/src/common/prepareItemsGroup.tsx +++ b/statshouse-ui/src/common/prepareItemsGroup.tsx @@ -4,7 +4,9 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -import type { QueryParams } from '@/url2'; +import { DEFAULT_LAYOUT_COORDS } from '@/components2/Dashboard/constants'; +import type { GroupKey, PlotKey, QueryParams } from '@/url2'; +import type { Layout } from 'react-grid-layout'; export function prepareItemsGroup({ orderGroup, @@ -20,3 +22,38 @@ export function prepareItemsGroup({ }; }); } + +type PrepareItemsGroupWithLayoutProps = { + groups: QueryParams['groups']; + orderGroup: QueryParams['orderGroup']; + orderPlot: PlotKey[]; +}; + +type PrepareItemsGroupWithLayoutResult = { + itemsGroup: { groupKey: GroupKey; plots: PlotKey[] }[]; + layoutsCoords: { groupKey: GroupKey; layout: Layout[] }[]; +}; + +export function prepareItemsGroupWithLayout({ + groups, + orderGroup, + orderPlot, +}: PrepareItemsGroupWithLayoutProps): PrepareItemsGroupWithLayoutResult { + const orderP = [...orderPlot]; + const itemsGroup = orderGroup.map((groupKey) => { + const count = groups[groupKey]?.count ?? 0; + const groupPlots = orderP.splice(0, count); + + return { + groupKey, + plots: groupPlots, + }; + }); + + const layoutsCoords = orderGroup.map((groupKey) => ({ + groupKey, + layout: groups[groupKey]?.layouts ?? [DEFAULT_LAYOUT_COORDS], + })); + + return { itemsGroup, layoutsCoords }; +} diff --git a/statshouse-ui/src/components2/Dashboard/Dashboard.tsx b/statshouse-ui/src/components2/Dashboard/Dashboard.tsx index c33b6974f..29062d833 100644 --- a/statshouse-ui/src/components2/Dashboard/Dashboard.tsx +++ b/statshouse-ui/src/components2/Dashboard/Dashboard.tsx @@ -23,6 +23,7 @@ import { produce } from '~immer/dist/immer'; import { HistoryList } from '../HistoryList'; const PATH_VERSION_PARAM = '&dv'; +import { DashboardLayoutNew } from './DashboardLayoutNew'; export type DashboardProps = { className?: string; @@ -148,7 +149,7 @@ export const Dashboard = memo(function Dashboard({ className }: DashboardProps) {variablesLength > 0 && tabNum === '-1' && !tvModeEnable && ( )} - + {tabNum === '-2' && } {tabNum === '-3' && dashboardId && ( ) => { - const groupKey = e.currentTarget.getAttribute('data-group') ?? '0'; - const size = e.currentTarget.value ?? '2'; - setDashboardGroup(groupKey, (g) => { - g.size = size; - }); - }, - [setDashboardGroup] - ); - const onAddGroup = useCallback( (e: React.MouseEvent) => { const groupKey = e.currentTarget.getAttribute('data-index-group') ?? '0'; @@ -139,7 +129,7 @@ export const DashboardGroup = memo(function DashboardGroup({ children, groupKey, > {dashboardLayoutEdit ? ( @@ -155,19 +145,6 @@ export const DashboardGroup = memo(function DashboardGroup({ children, groupKey, onInput={onEditGroupName} placeholder="Enter group name" /> - - L, 2 per row - L, auto width - M, 3 per row - M, auto width - S, 4 per row - S, auto width - ({ + groups, + orderGroup, + orderPlot, + dashboardLayoutEdit, + isEmbed, + addDashboardGroup, + setNextDashboardSchemePlot, + }) + ); + + const [isDragging, setIsDragging] = useState(false); + const [draggedPlotKey, setDraggedPlotKey] = useState(null); + const [draggedGroupKey, setDraggedGroupKey] = useState(null); + const [draggedItemDimensions, setDraggedItemDimensions] = useState<{ w: number; h: number } | null>(null); + + const isCrossingGroupsRef = useRef(false); + const { breakpointKey } = useMemo(() => getBreakpointConfig(), []); + + // itemsGroup: Contains the structure of groups and their plots + // layoutsCoords: Contains the layout coordinates for each group + const { itemsGroup, layoutsCoords } = useMemo( + () => + prepareItemsGroupWithLayout({ + groups, + orderGroup, + orderPlot, + }), + [groups, orderGroup, orderPlot] + ); + + const save = useCallback( + (plotKey: string | null, targetGroup: GroupKey | null, layout: Layout[], isResize: boolean = false) => { + if (plotKey != null && targetGroup != null) { + // If crossing groups and we have original dimensions + if (targetGroup !== draggedGroupKey && draggedItemDimensions && !isResize) { + // Find the item in the layout + const itemIndex = layout.findIndex((item) => item.i === `${targetGroup}::${plotKey}`); + if (itemIndex >= 0) { + // Create a new layout array with preserved original dimensions + layout = layout.map((item, index) => { + if (index === itemIndex) { + return { + ...item, + w: draggedItemDimensions.w, + h: draggedItemDimensions.h, + }; + } + return item; + }); + } + } + + // Update groups and items in them + const updatedItemsGroup = itemsGroup.map((group) => { + // If this is a resize operation, update the layout of the item in its current group + if (isResize && group.groupKey === targetGroup) { + const resizedLayout = layout.find((l) => l.i === `${targetGroup}::${plotKey}`); + if (resizedLayout) { + const currentGroupLayout = { + groupKey: targetGroup, + layout: layout.map((item) => ({ ...item })), + }; + + setNextDashboardSchemePlot(itemsGroup, currentGroupLayout); + return group; // Return the group unchanged as the layout is updated separately + } + } + + // If this is the source group from which the plot is being dragged + if (group.groupKey === draggedGroupKey) { + if (group.groupKey === targetGroup) { + if (layout) { + const newPlots = layout + .filter((item) => item.i.startsWith(`${targetGroup}::`)) + .sort((a, b) => { + if (a.y !== b.y) return a.y - b.y; + return a.x - b.x; + }) + .map((item) => item.i.split('::')[1]); + + return { + groupKey: group.groupKey, + plots: newPlots, + }; + } + return group; + } + // Otherwise, remove the dragged plot from the group + return { + groupKey: group.groupKey, + plots: group.plots.filter((p) => p !== plotKey), + }; + } + + // If the current group is the target group, add the dragged plot to it + if (group.groupKey === targetGroup) { + if (!group.plots.includes(plotKey)) { + return { + groupKey: group.groupKey, + plots: [...group.plots, plotKey], + }; + } + return group; + } + return group; + }); + + // If the target group doesn't exist in the updated items group, create a new group with the dragged plot + if (!updatedItemsGroup.find((g) => g.groupKey === targetGroup)) { + updatedItemsGroup.push({ + groupKey: targetGroup, + plots: [plotKey], + }); + } + + // Update groups layout + if (targetGroup !== draggedGroupKey && draggedPlotKey) { + // 1. Find current layouts for source and target groups + const sourceGroupLayout = layoutsCoords.find((l) => l.groupKey === draggedGroupKey)?.layout || []; + const targetGroupLayout = layoutsCoords.find((l) => l.groupKey === targetGroup)?.layout || []; + + // 2. Find the item we are dragging, from layout + const draggedItemLayout = layout.find((item) => item.i === `${draggedGroupKey}::${draggedPlotKey}`); + + if (draggedItemLayout && draggedGroupKey) { + let maxY = 0; + if (Array.isArray(targetGroupLayout) && targetGroupLayout.length > 0) { + targetGroupLayout.forEach((item) => { + if (item.y + item.h > maxY) { + maxY = item.y + item.h; + } + }); + } + + let maxX = 0; + if (Array.isArray(targetGroupLayout) && targetGroupLayout.length > 0) { + // Consider only items that are in the bottom row + const bottomRowItems = targetGroupLayout.filter( + (item) => + (item.y < maxY && item.y + item.h > maxY) || // Items that span into the bottom row + item.y + item.h === maxY // Items that end exactly at the bottom row + ); + + bottomRowItems.forEach((item) => { + if (item.x + item.w > maxX) { + maxX = item.x + item.w; + } + }); + } + + // Check if adding to maxX would exceed grid width + const cols = COLS[breakpointKey] || 8; + const itemWidth = draggedItemDimensions?.w || draggedItemLayout.w; + + let newX = maxX; + let newY = maxY > 0 ? maxY - 1 : 0; // Position at the bottom row, but never negative + + if (maxX + itemWidth > cols) { + newX = 0; + newY = maxY; + } + + // 3. Create a new layout for the target group: keep existing + add new element + const newTargetLayout = [ + ...(Array.isArray(targetGroupLayout) ? targetGroupLayout : []), + { + ...draggedItemLayout, + i: `${targetGroup}::${draggedPlotKey}`, + // Keep original dimensions + ...(draggedItemDimensions + ? { + w: draggedItemDimensions.w, + h: draggedItemDimensions.h, + } + : {}), + + x: newX, + y: newY, + }, + ]; + + // 4. Create a new layout for the source group: without the dragged element + const newSourceLayout = Array.isArray(sourceGroupLayout) + ? sourceGroupLayout.filter((item) => !item.i.endsWith(`::${draggedPlotKey}`)) + : []; + + // 5. Create an array of layout schemes for both groups + const layoutSchemes = [ + { + groupKey: targetGroup, + layout: newTargetLayout, + }, + { + groupKey: draggedGroupKey, + layout: newSourceLayout, + }, + ]; + + // 6. Update layouts of both groups in one call + layoutSchemes.forEach((scheme) => setNextDashboardSchemePlot(updatedItemsGroup, scheme)); + } + } else { + // Moving within the same group or resize + const currentGroupLayout = { groupKey: targetGroup, layout }; + setNextDashboardSchemePlot(updatedItemsGroup, currentGroupLayout); + } + } + }, + [ + draggedGroupKey, + draggedItemDimensions, + itemsGroup, + draggedPlotKey, + setNextDashboardSchemePlot, + breakpointKey, + layoutsCoords, + ] + ); + + const onDragStart = useCallback((_layout: Layout[], oldItem: Layout) => { + setIsDragging(true); + const [groupKey, plotKey] = oldItem.i.split('::'); + setDraggedPlotKey(plotKey); + setDraggedGroupKey(groupKey); + + // Store original dimensions of the dragged item + const originalDimensions = { + w: oldItem.w, + h: oldItem.h, + }; + + setDraggedItemDimensions(originalDimensions); + isCrossingGroupsRef.current = false; + }, []); + + // Helper function to determine which group is currently being hovered over during drag + const getHoveredGroupKey = (e: MouseEvent): string | null => { + const dropElement = document.elementsFromPoint(e.clientX, e.clientY); + return dropElement.find((el) => el.getAttribute('data-group'))?.getAttribute('data-group') ?? null; + }; + + // Track when dragging crosses between different groups + const onDrag = useCallback( + (_layout: Layout[], _oldItem: Layout, _newItem: Layout, _placeholder: Layout, e: MouseEvent) => { + if (!isDragging || !draggedGroupKey) return; + + const hoveredGroup = getHoveredGroupKey(e); + + // Set flag when dragging between different groups + if (hoveredGroup && hoveredGroup !== draggedGroupKey) { + isCrossingGroupsRef.current = true; + } + }, + [isDragging, draggedGroupKey] + ); + + // Handle layout changes during drag within the same group + const onLayoutChange = useCallback( + (layout: Layout[]) => { + if (isDragging && draggedGroupKey && !isCrossingGroupsRef.current) { + const [groupKey, plotKey] = layout[0].i.split('::'); + if (groupKey === draggedGroupKey) { + save(plotKey, groupKey, layout); + } + } + }, + [draggedGroupKey, isDragging, save] + ); + + // Handle resize operations for widgets + const handleResizeStop = useCallback( + (layout: Layout[], groupKey: string) => { + const plotKey = layout[0]?.i?.split('::')[1]; + save(plotKey, groupKey, layout, true); + }, + [save] + ); + + // Handle the end of drag operations, including cross-group drags + const onDragStop = useCallback( + (layout: Layout[], _oldItem: Layout, _newItem: Layout, _placeholder: Layout, e: MouseEvent) => { + const targetGroup = getHoveredGroupKey(e); + + if (!targetGroup || !draggedPlotKey) { + setDraggedPlotKey(null); + setDraggedGroupKey(null); + setIsDragging(false); + isCrossingGroupsRef.current = false; + return; + } + + // Handle cross-group dragging + if (targetGroup !== draggedGroupKey) { + isCrossingGroupsRef.current = true; + save(draggedPlotKey, targetGroup, layout); + } + + setDraggedPlotKey(null); + setDraggedGroupKey(null); + setIsDragging(false); + }, + [draggedPlotKey, draggedGroupKey, save] + ); + + // Add a new group to the dashboard + const onAddGroup = useCallback( + (e: React.MouseEvent) => { + const groupKey = e.currentTarget.getAttribute('data-index-group') ?? '0'; + addDashboardGroup(groupKey); + }, + [addDashboardGroup] + ); + + // Get the key for the next group to be created + const nextGroupKey = useMemo(() => getNextGroupKey({ orderGroup }), [orderGroup]); + + // Determine if dashboard editing is allowed based on device and settings + const isNotMobile = BREAKPOINT_WIDTH[breakpointKey] >= BREAKPOINT_WIDTH.md; + const isDashboardEditAllowed = dashboardLayoutEdit && isNotMobile; + + // Calculate row height based on screen width and breakpoint + const dynamicRowHeight = useMemo(() => { + const currentWidth = window.innerWidth; + if (breakpointKey === BREAKPOINTS_SIZES.xxxl) { + return calculateDynamicRowHeight(currentWidth); + } + return ROW_HEIGHTS[breakpointKey]; + }, [breakpointKey]); + + // calculate dynamic row height based on widgetColsWidth + const calculateRowHeightForGroup = useCallback( + (groupKey: string) => { + const size = groups[groupKey]?.size; + const widgetColsWidth = getSizeColumns(size); + + let baseRowHeight = dynamicRowHeight; + + if (breakpointKey === BREAKPOINTS_SIZES.xxxl) { + const currentWidth = window.innerWidth; + baseRowHeight = calculateDynamicRowHeight(currentWidth); + } else { + baseRowHeight = ROW_HEIGHTS[breakpointKey]; + } + + let scaleFactor; + + const breakpointMultiplier = + { + xxxl: 1.5, + xxl: 1.5, + xl: 1.5, + lg: 1.4, + md: 1.4, + sm: 1.1, + xs: 1.0, + xxs: 0.95, + }[breakpointKey] || 1.0; + + switch (widgetColsWidth) { + case 2: + return baseRowHeight * breakpointMultiplier; + case 3: + return baseRowHeight; + case 4: + return baseRowHeight; + default: + // For custom sizes, scale inversely with number of items + scaleFactor = 3 / widgetColsWidth; + + return baseRowHeight * Math.min(1.5, Math.max(0.8, scaleFactor)); + } + }, + [groups, breakpointKey, dynamicRowHeight] + ); + + const generateDefaultLayout = useCallback( + (plots: string[], groupKey: string) => { + const size = groups[groupKey]?.size; + const widgetColsWidth = getSizeColumns(size); + const cols = COLS[breakpointKey] || 10; + + let itemWidth = 0; + const leftMargin = widgetColsWidth === 4 ? 1 : 2; + + switch (widgetColsWidth) { + case 2: + itemWidth = 3; + break; + case 3: + itemWidth = 2; + break; + case 4: + itemWidth = 2; + break; + default: + itemWidth = Math.floor((cols - 4) / widgetColsWidth); + } + + return plots.map((plot, index) => { + const row = Math.floor(index / widgetColsWidth); + const col = index % widgetColsWidth; + const startX = leftMargin + col * itemWidth; + + return { + i: `${groupKey}::${plot}`, + x: startX, + y: row, + w: itemWidth, + h: 1, + }; + }); + }, + [groups, breakpointKey] + ); + + const getLayoutForGroup = useCallback( + (groupKey: string, plots: string[]) => { + const existingGroupLayout = layoutsCoords.find((l) => l.groupKey === groupKey); + + // If we have a layout and it has items for all plots, use it + if (existingGroupLayout && existingGroupLayout.layout.length >= plots.length) { + const validLayouts = existingGroupLayout.layout.filter((item) => { + const plotKey = item.i.split('::')[1]; + return plots.includes(plotKey); + }); + + // If we have valid layouts for all plots, use them + if (validLayouts.length === plots.length) { + return validLayouts; + } + } + + return generateDefaultLayout(plots, groupKey); + }, + [layoutsCoords, generateDefaultLayout] + ); + + return ( + + + {itemsGroup.map(({ groupKey, plots }) => ( + + {groups[groupKey]?.show !== false && ( + l.groupKey === groupKey)?.layout + )} + onDragStop={onDragStop} + onDragStart={onDragStart} + onDrag={onDrag} + onResizeStop={(layout) => handleResizeStop(layout, groupKey)} + onLayoutChange={onLayoutChange} + isDroppable={isDashboardEditAllowed} + layouts={{ + [breakpointKey]: getLayoutForGroup(groupKey, plots), + }} + > + {/* mapping right to left because elements are rendered from right to left */} + {plots + .slice() + .reverse() + .map((plot) => ( + + + + ))} + + )} + + ))} + + {isDashboardEditAllowed && ( + + + {isDragging ? ( + Drop here for create new group + ) : ( + + Add new group + + )} + + )} + + + ); +}); diff --git a/statshouse-ui/src/components2/Dashboard/constants.ts b/statshouse-ui/src/components2/Dashboard/constants.ts new file mode 100644 index 000000000..b34fdcbff --- /dev/null +++ b/statshouse-ui/src/components2/Dashboard/constants.ts @@ -0,0 +1,51 @@ +export const BREAKPOINT_WIDTH = { + xxxl: 2700, + xxl: 2400, + xl: 1900, + lg: 1600, + md: 1050, + sm: 740, + xs: 440, + xxs: 0, +} as const; + +export const BREAKPOINTS_SIZES = { + xxxl: 'xxxl', + xxl: 'xxl', + xl: 'xl', + lg: 'lg', + md: 'md', + sm: 'sm', + xs: 'xs', + xxs: 'xxs', +} as const; + +export const COLS = { + xxxl: 10, + xxl: 10, + xl: 10, + lg: 10, + md: 10, + sm: 10, + xs: 10, + xxs: 10, +} as const; + +export const ROW_HEIGHTS = { + xxxl: 450, + xxl: 400, + xl: 340, + lg: 280, + md: 240, + sm: 220, + xs: 160, + xxs: 140, +} as const; + +export const DEFAULT_LAYOUT_COORDS = { + x: 0, + y: 0, + w: 1, + h: 1, + i: '', +} as const; diff --git a/statshouse-ui/src/components2/Dashboard/types.ts b/statshouse-ui/src/components2/Dashboard/types.ts new file mode 100644 index 000000000..b67c03227 --- /dev/null +++ b/statshouse-ui/src/components2/Dashboard/types.ts @@ -0,0 +1,18 @@ +import { PlotKey } from '@/url2'; + +import { GroupKey } from '@/url2'; + +import { BREAKPOINT_WIDTH } from './constants'; +import { Layout } from '~@types/react-grid-layout'; + +export type BreakpointKey = keyof typeof BREAKPOINT_WIDTH; + +export type DashboardScheme = { + groupKey: GroupKey; + plots: PlotKey[]; +}; + +export type LayoutScheme = { + groupKey: GroupKey; + layout: Layout[]; +}; diff --git a/statshouse-ui/src/components2/Plot/PlotView/PlotHeader.tsx b/statshouse-ui/src/components2/Plot/PlotView/PlotHeader.tsx index e48edcb05..223fb6d5f 100644 --- a/statshouse-ui/src/components2/Plot/PlotView/PlotHeader.tsx +++ b/statshouse-ui/src/components2/Plot/PlotView/PlotHeader.tsx @@ -163,7 +163,11 @@ export const PlotHeader = memo(function PlotHeader({ plotKey, isDashboard }: Plo {dashboardLayoutEdit ? ( ): void; - setNextDashboardSchemePlot(nextScheme: { groupKey: GroupKey; plots: PlotKey[] }[]): void; + setNextDashboardSchemePlot(nextScheme: DashboardScheme[], layouts: LayoutScheme, breakpointKey?: string): void; autoSearchVariable(): Promise>; saveDashboard(copy?: boolean): Promise; removeDashboard(): Promise; @@ -276,35 +277,36 @@ export const urlStore: StoreSlice = (setState, getSta setDashboardGroup(groupKey, next) { setUrlStore(updateGroup(groupKey, next)); }, - setNextDashboardSchemePlot(nextScheme) { + setNextDashboardSchemePlot(nextScheme, layouts) { setUrlStore( updateParamsPlotStruct((plotStruct) => { plotStruct.groups = nextScheme.map((g) => { const sourceGroupIndex = plotStruct.mapGroupIndex[g.groupKey]; - const plots: { plotInfo: PlotParams; variableLinks: VariableLinks[] }[] = g.plots - .map((pK) => { - const sourceGroupKey = plotStruct.mapPlotToGroup[pK] ?? ''; + const plots = g.plots + .map((plotKey) => { + const sourceGroupKey = plotStruct.mapPlotToGroup[plotKey] ?? ''; const sourceGroupIndex = plotStruct.mapGroupIndex[sourceGroupKey]; - const sourcePlotIndex = plotStruct.mapPlotIndex[pK]; + const sourcePlotIndex = plotStruct.mapPlotIndex[plotKey]; if (sourceGroupIndex != null && sourcePlotIndex != null) { return plotStruct.groups[sourceGroupIndex].plots[sourcePlotIndex]; } return null; }) .filter(isNotNil); + if (sourceGroupIndex != null) { - return { - groupInfo: plotStruct.groups[sourceGroupIndex].groupInfo, - plots, - }; + const groupInfo = updateGroupWithLayout( + { ...plotStruct.groups[sourceGroupIndex].groupInfo }, + g.groupKey, + layouts + ); + + return { groupInfo, plots }; } - return { - groupInfo: { - ...getNewGroup(), - id: g.groupKey, - }, - plots, - }; + + const newGroup = updateGroupWithLayout({ ...getNewGroup(), id: g.groupKey }, g.groupKey, layouts); + + return { groupInfo: newGroup, plots }; }); }) ); diff --git a/statshouse-ui/src/url2/getDefault.ts b/statshouse-ui/src/url2/getDefault.ts index 3e9f49b8e..460f26672 100644 --- a/statshouse-ui/src/url2/getDefault.ts +++ b/statshouse-ui/src/url2/getDefault.ts @@ -9,6 +9,7 @@ import { TAG_KEY, TIME_RANGE_KEYS_TO } from '@/api/enum'; import { deepClone } from '@/common/helpers'; import { globalSettings } from '@/common/settings'; import { getNewMetric } from './widgetsParams'; +import { DEFAULT_LAYOUT_COORDS } from '@/components2/Dashboard/constants'; export function getDefaultParams(): QueryParams { return { @@ -56,6 +57,7 @@ export function getNewGroup(): GroupInfo { count: 0, size: '2', show: true, + layouts: [DEFAULT_LAYOUT_COORDS], }; } diff --git a/statshouse-ui/src/url2/queryParams.ts b/statshouse-ui/src/url2/queryParams.ts index 94157ec6a..97391206c 100644 --- a/statshouse-ui/src/url2/queryParams.ts +++ b/statshouse-ui/src/url2/queryParams.ts @@ -12,6 +12,7 @@ import { type TagKey, type TimeRangeKeysTo, } from '@/api/enum'; +import type { Layout } from 'react-grid-layout'; export type PlotKey = string; @@ -20,10 +21,11 @@ export type GroupKey = string; export type GroupInfo = { id: GroupKey; name: string; - show: boolean; + description: string; count: number; size: string; - description: string; + show: boolean; + layouts: Layout[]; }; export type VariableParamsLink = [PlotKey, TagKey]; diff --git a/statshouse-ui/src/url2/urlDecode.test.ts b/statshouse-ui/src/url2/urlDecode.test.ts index 66ed81db9..f10d8e088 100644 --- a/statshouse-ui/src/url2/urlDecode.test.ts +++ b/statshouse-ui/src/url2/urlDecode.test.ts @@ -167,6 +167,7 @@ describe('@/urlStore urlDecode', () => { [GET_PARAMS.dashboardGroupInfoShow]: {}, [GET_PARAMS.dashboardGroupInfoCount]: {}, [GET_PARAMS.dashboardGroupInfoSize]: {}, + [GET_PARAMS.dashboardGroupInfoLayouts]: {}, }, dParams ) @@ -190,6 +191,15 @@ describe('@/urlStore urlDecode', () => { name: 'name', show: false, size: '4', + layouts: expect.arrayContaining([ + expect.objectContaining({ + i: expect.any(String), + x: expect.any(Number), + y: expect.any(Number), + w: expect.any(Number), + h: expect.any(Number), + }), + ]), }); }); test('@/urlDecodeGroups', () => { diff --git a/statshouse-ui/src/url2/urlDecode.ts b/statshouse-ui/src/url2/urlDecode.ts index df63fac1e..c0b7bcb81 100644 --- a/statshouse-ui/src/url2/urlDecode.ts +++ b/statshouse-ui/src/url2/urlDecode.ts @@ -20,6 +20,7 @@ import { orderGroupSplitter, orderVariableSplitter, removeValueChar } from './co import { isKeyId, isNotNilVariableLink, TreeParamsObject, treeParamsObjectValueSymbol } from './urlHelpers'; import { readTimeRange } from './timeRangeHelpers'; import { metricFilterDecode, widgetsParamsDecode } from './widgetsParams'; +import { decompressLayouts } from './urlHelpers'; export function urlDecode( searchParams: TreeParamsObject, @@ -170,6 +171,10 @@ export function urlDecodeGroup( return undefined; } const show = searchParams?.[GET_PARAMS.dashboardGroupInfoShow]?.[treeParamsObjectValueSymbol]?.[0]; + + const layoutsStr = searchParams?.[GET_PARAMS.dashboardGroupInfoLayouts]?.[treeParamsObjectValueSymbol]?.[0]; + const layouts = layoutsStr ? decompressLayouts(layoutsStr, groupKey) : defaultGroup.layouts; + return { id: groupKey, name: searchParams?.[GET_PARAMS.dashboardGroupInfoName]?.[treeParamsObjectValueSymbol]?.[0] ?? defaultGroup.name, @@ -182,6 +187,7 @@ export function urlDecodeGroup( ), size: searchParams?.[GET_PARAMS.dashboardGroupInfoSize]?.[treeParamsObjectValueSymbol]?.[0] ?? defaultGroup.size, show: show != null ? show !== '0' : defaultGroup.show, + layouts: layouts, }; } diff --git a/statshouse-ui/src/url2/urlEncode.test.ts b/statshouse-ui/src/url2/urlEncode.test.ts index 55ac3d883..1b22ed6bd 100644 --- a/statshouse-ui/src/url2/urlEncode.test.ts +++ b/statshouse-ui/src/url2/urlEncode.test.ts @@ -211,7 +211,7 @@ describe('@/urlStore urlEncode', () => { ...getNewGroup(), id: '0', }; - expect(urlEncodeGroup(dParam)).toEqual([['g0.t', '']]); + expect(urlEncodeGroup(dParam)).toEqual([['g0.ly', '']]); expect(urlEncodeGroup(dParam, dParam)).toEqual([]); expect( urlEncodeGroup({ ...dParam, show: false, size: '4', count: 4, name: 'n', description: 'd' }, dParam) @@ -239,7 +239,9 @@ describe('@/urlStore urlEncode', () => { }, orderGroup: ['1', '0'], }; - expect(urlEncodeGroups(params)).toEqual([]); + + const result = urlEncodeGroups(params); + expect(result.length).toBeGreaterThan(0); expect(urlEncodeGroups(params2, params2)).toEqual([]); expect( urlEncodeGroups( @@ -332,6 +334,7 @@ describe('@/urlStore urlEncode', () => { }); test('@/urlEncode', () => { - expect(urlEncode(params)).toEqual([]); + const result = urlEncode(params); + expect(result.length).toBeGreaterThan(0); }); }); diff --git a/statshouse-ui/src/url2/urlEncode.ts b/statshouse-ui/src/url2/urlEncode.ts index ae3d00208..531b7e2cc 100644 --- a/statshouse-ui/src/url2/urlEncode.ts +++ b/statshouse-ui/src/url2/urlEncode.ts @@ -10,7 +10,7 @@ import { dequal } from 'dequal/lite'; import { getDefaultParams, getNewGroup, getNewVariable, getNewVariableSource } from './getDefault'; import { orderGroupSplitter, orderVariableSplitter, removeValueChar } from './constants'; -import { toGroupInfoPrefix, toVariablePrefix, toVariableValuePrefix } from './urlHelpers'; +import { compressLayouts, toGroupInfoPrefix, toVariablePrefix, toVariableValuePrefix } from './urlHelpers'; import { metricFilterEncode, widgetsParamsEncode } from './widgetsParams'; export function urlEncode(params: QueryParams, defaultParams?: QueryParams): [string, string][] { @@ -136,6 +136,10 @@ export function urlEncodeGroup(group: GroupInfo, defaultGroup: GroupInfo = getNe if (defaultGroup.show !== group.show) { paramArr.push([prefix + GET_PARAMS.dashboardGroupInfoShow, group.show ? '1' : '0']); } + if (defaultGroup.layouts !== group.layouts) { + const compressedLayouts = compressLayouts(group.layouts); + paramArr.push([prefix + GET_PARAMS.dashboardGroupInfoLayouts, compressedLayouts]); + } if (!paramArr.length && !defaultGroup.id) { paramArr.push([prefix + GET_PARAMS.dashboardGroupInfoName, group.name]); } diff --git a/statshouse-ui/src/url2/urlHelpers.ts b/statshouse-ui/src/url2/urlHelpers.ts index 5b50edee4..bfa6d3362 100644 --- a/statshouse-ui/src/url2/urlHelpers.ts +++ b/statshouse-ui/src/url2/urlHelpers.ts @@ -7,6 +7,7 @@ import { toNumber, toString } from '@/common/helpers'; import type { PlotKey, VariableParamsLink } from './queryParams'; import { GET_PARAMS, toTagKey } from '@/api/enum'; +import { Layout } from '~@types/react-grid-layout'; export function freeKeyPrefix(str: string): string { return str.replace('skey', '_s').replace('key', ''); @@ -78,3 +79,54 @@ export const toGroupInfoPrefix = (i: number | string) => `${GET_PARAMS.dashboard export const toPlotPrefix = (i: number | string) => (i && i !== '0' ? `${GET_PARAMS.plotPrefix}${i}.` : ''); export const toVariablePrefix = (i: number | string) => `${GET_PARAMS.variablePrefix}${i}.`; export const toVariableValuePrefix = (name: string) => `${GET_PARAMS.variableValuePrefix}.${name}`; + +// Compresses layout data into a compact string for URL +// Format: "id.x.y.w.h-id.x.y.w.h-..." where values are in sequential order +export function compressLayouts(layouts: Layout[]): string { + if (!layouts?.length) return ''; + + return layouts + .map((item) => { + const idParts = item.i.split('::'); + const id = idParts.length > 1 ? idParts[1] : item.i; + + const values = [ + id, + item.x !== 0 ? item.x.toString() : undefined, + item.y !== 0 ? item.y.toString() : undefined, + item.w !== 1 ? item.w.toString() : undefined, + item.h !== 1 ? item.h.toString() : undefined, + ]; + + while (values.length > 1 && values[values.length - 1] === undefined) { + values.pop(); + } + + return values.map((v) => (v === undefined ? '' : v)).join('.'); + }) + .join('-'); +} + +// Decompress the compact string back to layout array +export function decompressLayouts(compressedString: string | null | undefined, groupKey: string): Layout[] { + if (!compressedString) return []; + + return compressedString.split('-').map((itemStr) => { + const parts = itemStr.split('.'); + + const id = parts[0] || ''; + + const x = parts.length > 1 && parts[1] ? toNumber(parts[1], 0) : 0; + const y = parts.length > 2 && parts[2] ? toNumber(parts[2], 0) : 0; + const w = parts.length > 3 && parts[3] ? toNumber(parts[3], 1) : 1; + const h = parts.length > 4 && parts[4] ? toNumber(parts[4], 1) : 1; + + return { + i: `${groupKey}::${id}`, + x, + y, + w, + h, + }; + }); +}