diff --git a/src/common/autoarrange-table/autoarrange-table.model.ts b/src/common/autoarrange-table/autoarrange-table.model.ts new file mode 100644 index 00000000..314c11f8 --- /dev/null +++ b/src/common/autoarrange-table/autoarrange-table.model.ts @@ -0,0 +1,6 @@ +export interface Box { + x: number; + y: number; + width: number; + height: number; +} diff --git a/src/common/autoarrange-table/autoarrange-table.utils.ts b/src/common/autoarrange-table/autoarrange-table.utils.ts new file mode 100644 index 00000000..bc1a4166 --- /dev/null +++ b/src/common/autoarrange-table/autoarrange-table.utils.ts @@ -0,0 +1,28 @@ +import { Box } from './autoarrange-table.model'; + +export const getRandomInt = (min: number, max: number): number => { + return Math.floor(Math.random() * (max - min + 1)) + min; +}; + +export const isOverlapping = (box1: Box, box2: Box): boolean => { + return ( + box1.x < box2.x + box2.width && + box1.x + box1.width > box2.x && + box1.y < box2.y + box2.height && + box1.y + box1.height > box2.y + ); +}; + +export const calculateCollisionArea = (box1: Box, box2: Box): number => { + const xOverlap = Math.max( + 0, + Math.min(box1.x + box1.width, box2.x + box2.width) - + Math.max(box1.x, box2.x) + ); + const yOverlap = Math.max( + 0, + Math.min(box1.y + box1.height, box2.y + box2.height) - + Math.max(box1.y, box2.y) + ); + return xOverlap * yOverlap; +}; diff --git a/src/common/autoarrange-table/index.ts b/src/common/autoarrange-table/index.ts new file mode 100644 index 00000000..7d5d8705 --- /dev/null +++ b/src/common/autoarrange-table/index.ts @@ -0,0 +1,84 @@ +import { Size } from '@/core/model'; +import { Box } from './autoarrange-table.model'; +import { + calculateCollisionArea, + isOverlapping, +} from './autoarrange-table.utils'; + +function* spiralPositions( + centerX: number, + centerY: number, + canvasSize: Size +): Generator<[number, number]> { + let x = 0, + y = 0, + dx = 0, + dy = -1; + + for (let i = 0; i < Math.max(canvasSize.width, canvasSize.height) ** 2; i++) { + if ( + -canvasSize.width / 2 < x && + x < canvasSize.width / 2 && + -canvasSize.height / 2 < y && + y < canvasSize.height / 2 + ) { + yield [centerX + x, centerY + y]; + } + if (x === y || (x < 0 && x === -y) || (x > 0 && x === 1 - y)) { + [dx, dy] = [-dy, dx]; + } + x += dx; + y += dy; + } +} + +export function findFreePositionOrMinCollision( + boxes: Box[], + newBoxSize: Size, + canvasSize: Size +): Box | null { + const centerX = Math.floor(canvasSize.width / 2); + const centerY = Math.floor(canvasSize.height / 2); + let minCollisionBox: Box | null = null; + let minCollisionArea = Infinity; + + for (const [x, y] of spiralPositions(centerX, centerY, canvasSize)) { + const newBox = { + x, + y, + width: newBoxSize.width, + height: newBoxSize.height, + }; + if ( + x >= 0 && + y >= 0 && + x + newBoxSize.width <= canvasSize.width && + y + newBoxSize.height <= canvasSize.height + ) { + let collisionArea = 0; + let isFree = true; + + for (const existingBox of boxes) { + if (isOverlapping(newBox, existingBox)) { + isFree = false; + collisionArea += calculateCollisionArea(newBox, existingBox); + } + } + + if (isFree) { + return newBox; + } + + if (collisionArea < minCollisionArea) { + minCollisionArea = collisionArea; + minCollisionBox = newBox; + } + } + } + + if (minCollisionBox !== null) { + return minCollisionBox; + } + // TODO: if no free position is found, return a random one + return null; +} diff --git a/src/common/helpers/set-off-set-zoom-to-coords.helper.spec.tsx b/src/common/helpers/set-off-set-zoom-to-coords.helper.spec.tsx index 40c558de..f5c34c85 100644 --- a/src/common/helpers/set-off-set-zoom-to-coords.helper.spec.tsx +++ b/src/common/helpers/set-off-set-zoom-to-coords.helper.spec.tsx @@ -10,6 +10,7 @@ describe('setOffSetZoomToCoords', () => { const initialContextState: CanvasViewSettingsContextModel = { canvasViewSettings: { canvasSize: { width: 5000, height: 5000 }, + canvasViewSize: { width: 5000, height: 5000 }, viewBoxSize: { width: 20000, height: 20000 }, zoomFactor: 2, scrollPosition: { x: 0, y: 0 }, @@ -25,6 +26,7 @@ describe('setOffSetZoomToCoords', () => { setLoadSample: () => {}, setViewBoxSize: () => {}, setAutoSave: () => {}, + setCanvasViewSize: () => {}, }; // Act @@ -51,6 +53,7 @@ describe('setOffSetZoomToCoords', () => { const initialContextState: CanvasViewSettingsContextModel = { canvasViewSettings: { canvasSize: { width: 10000, height: 10000 }, + canvasViewSize: { width: 5000, height: 5000 }, viewBoxSize: { width: 25000, height: 15000 }, zoomFactor: 2, scrollPosition: { x: 0, y: 0 }, @@ -66,6 +69,7 @@ describe('setOffSetZoomToCoords', () => { setLoadSample: () => {}, setViewBoxSize: () => {}, setAutoSave: () => {}, + setCanvasViewSize: () => {}, }; // Act @@ -92,6 +96,7 @@ describe('setOffSetZoomToCoords', () => { const initialContextState: CanvasViewSettingsContextModel = { canvasViewSettings: { canvasSize: { width: 300, height: 100 }, + canvasViewSize: { width: 5000, height: 5000 }, viewBoxSize: { width: 2000, height: 5000 }, zoomFactor: 5, scrollPosition: { x: 0, y: 0 }, @@ -107,6 +112,7 @@ describe('setOffSetZoomToCoords', () => { setLoadSample: () => {}, setViewBoxSize: () => {}, setAutoSave: () => {}, + setCanvasViewSize: () => {}, }; // Act @@ -135,6 +141,10 @@ describe('setOffSetZoomToCoords', () => { width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER, }, + canvasViewSize: { + width: Number.MAX_SAFE_INTEGER, + height: Number.MAX_SAFE_INTEGER, + }, viewBoxSize: { width: Number.MAX_SAFE_INTEGER, height: Number.MAX_SAFE_INTEGER, @@ -153,6 +163,7 @@ describe('setOffSetZoomToCoords', () => { setLoadSample: () => {}, setViewBoxSize: () => {}, setAutoSave: () => {}, + setCanvasViewSize: () => {}, }; // Act @@ -178,6 +189,7 @@ describe('setOffSetZoomToCoords', () => { const initialContextState: CanvasViewSettingsContextModel = { canvasViewSettings: { canvasSize: { width: 5000, height: 5000 }, + canvasViewSize: { width: 5000, height: 5000 }, viewBoxSize: { width: 20000, height: 20000 }, zoomFactor: 2, scrollPosition: { x: 0, y: 0 }, @@ -193,6 +205,7 @@ describe('setOffSetZoomToCoords', () => { setLoadSample: () => {}, setViewBoxSize: () => {}, setAutoSave: () => {}, + setCanvasViewSize: () => {}, }; // Act diff --git a/src/core/providers/canvas-view-settings/canvas-view-settings.model.ts b/src/core/providers/canvas-view-settings/canvas-view-settings.model.ts index 8ca9b015..54e07945 100644 --- a/src/core/providers/canvas-view-settings/canvas-view-settings.model.ts +++ b/src/core/providers/canvas-view-settings/canvas-view-settings.model.ts @@ -3,6 +3,7 @@ import { Coords, Size } from '@/core/model'; export interface CanvasViewSettingsModel { canvasSize: Size; + canvasViewSize: Size; viewBoxSize: Size; zoomFactor: number; scrollPosition: Coords; @@ -26,6 +27,7 @@ const initialAutoSaveValue = export const createInitialSettings = (DEFAULT_ZOOM_FACTOR: number) => ({ canvasSize: CANVAS_SIZE, + canvasViewSize: CANVAS_SIZE, viewBoxSize: { width: 0, height: 0 }, zoomFactor: DEFAULT_ZOOM_FACTOR, scrollPosition: { x: 0, y: 0 }, @@ -38,6 +40,7 @@ export interface CanvasViewSettingsContextModel { canvasViewSettings: CanvasViewSettingsModel; setScrollPosition: (scrollPosition: Coords) => void; setCanvasSize: (canvasSize: Size) => void; + setCanvasViewSize: (canvasViewSize: Size) => void; setFilename: (filename: string) => void; zoomIn: () => void; zoomOut: () => void; diff --git a/src/core/providers/canvas-view-settings/canvas-view-settings.provider.tsx b/src/core/providers/canvas-view-settings/canvas-view-settings.provider.tsx index 4103500c..b3f2e019 100644 --- a/src/core/providers/canvas-view-settings/canvas-view-settings.provider.tsx +++ b/src/core/providers/canvas-view-settings/canvas-view-settings.provider.tsx @@ -53,6 +53,13 @@ export const CanvasViewSettingsProvider: React.FC = props => { })); }; + const setCanvasViewSize = (canvasSize: Size) => { + setCanvasViewSettings(canvasViewSettings => ({ + ...canvasViewSettings, + canvasViewSize: canvasSize, + })); + }; + const setFilename = (filename: string) => { setCanvasViewSettings(canvasViewSettings => ({ ...canvasViewSettings, @@ -104,6 +111,7 @@ export const CanvasViewSettingsProvider: React.FC = props => { canvasViewSettings, setScrollPosition, setCanvasSize, + setCanvasViewSize, setFilename, zoomIn, zoomOut, diff --git a/src/pods/canvas/canvas.pod.tsx b/src/pods/canvas/canvas.pod.tsx index afcf0168..c60f7dc2 100644 --- a/src/pods/canvas/canvas.pod.tsx +++ b/src/pods/canvas/canvas.pod.tsx @@ -45,6 +45,7 @@ export const CanvasPod: React.FC = () => { } = useCanvasSchemaContext(); const { canvasViewSettings, + setCanvasViewSize, setScrollPosition, setLoadSample, setViewBoxSize, @@ -121,6 +122,34 @@ export const CanvasPod: React.FC = () => { const containerRef = React.useRef(null); + React.useEffect(() => { + if (containerRef.current) { + const container = containerRef.current; + + // TODO: Rename setOffSetZoomToCoords so that it does not only apply to coords + // maybe setOffsetZoom? + + const CANVAS_VIEW_SIZE_WITH_APPLIED_ZOOM_OFFSET = setOffSetZoomToCoords( + container.clientWidth + containerRef.current.scrollLeft * 2, + container.clientHeight + containerRef.current.scrollTop * 2, + viewBoxSize, + canvasSize, + zoomFactor + ); + + setCanvasViewSize({ + width: CANVAS_VIEW_SIZE_WITH_APPLIED_ZOOM_OFFSET.x, + height: CANVAS_VIEW_SIZE_WITH_APPLIED_ZOOM_OFFSET.y, + }); + } + }, [ + containerRef.current?.clientWidth, + containerRef.current?.scrollLeft, + containerRef.current?.clientHeight, + containerRef.current?.scrollTop, + viewBoxSize, + ]); + const handleScroll = () => { if (containerRef.current) { setScrollPosition( diff --git a/src/pods/canvas/components/table/database-table.component.tsx b/src/pods/canvas/components/table/database-table.component.tsx index f486e995..ecaa0167 100644 --- a/src/pods/canvas/components/table/database-table.component.tsx +++ b/src/pods/canvas/components/table/database-table.component.tsx @@ -11,6 +11,7 @@ import { } from './components'; import { renderRows } from './database-table-render-rows.helper'; import classes from './database-table.module.css'; +import { motion } from 'framer-motion'; // TODO: We should add an optional field to indicate FONT_SIZE in case we override the standard class // TODO: There's is a solution more elaborated (using JS) to show elipsis ... if text is too long @@ -82,22 +83,32 @@ export const DatabaseTable: React.FC = ({ }; return ( - | undefined} - > - - - + + | undefined} + initial={{ opacity: 0, scale: 0.8 }} + animate={{ opacity: [0, 1, 0, 1], scale: 1 }} + transition={{ + opacity: { duration: 2 }, + scale: { duration: 0.8 }, + }} + > + + + + ); }; diff --git a/src/pods/toolbar/components/add-collection/add-collection.component.tsx b/src/pods/toolbar/components/add-collection/add-collection.component.tsx index c64c43ad..eb870072 100644 --- a/src/pods/toolbar/components/add-collection/add-collection.component.tsx +++ b/src/pods/toolbar/components/add-collection/add-collection.component.tsx @@ -10,8 +10,10 @@ import { } from '@/core/providers/canvas-schema'; import { ADD_COLLECTION_TITLE } from '@/common/components/modal-dialog'; import { SHORTCUTS } from '../../shortcut/shortcut.const'; - -const BORDER_MARGIN = 40; +import { findFreePositionOrMinCollision } from '@/common/autoarrange-table'; +import { getTableSize } from './add-collection.helper'; +import { mapTableVMtoBoxVMMapper } from './add-collection.mapper'; +import { TABLE_GAP } from './add-collection.model'; export const AddCollection = () => { const { openModal, closeModal } = useModalDialogContext(); @@ -19,10 +21,30 @@ export const AddCollection = () => { const { canvasViewSettings, setLoadSample } = useCanvasViewSettingsContext(); const handleAddTable = (newTable: TableVm) => { - const updatedTable = { + if (!newTable) { + return; + } + + const position = findFreePositionOrMinCollision( + mapTableVMtoBoxVMMapper(canvasSchema.tables), + { + width: getTableSize(newTable.fields).width, + height: getTableSize(newTable.fields).height, + }, + { + width: canvasViewSettings.canvasViewSize.width, + height: canvasViewSettings.canvasViewSize.height, + } + ); + + if (!position) { + return; + } + + const updatedTable: TableVm = { ...newTable, - x: canvasViewSettings.scrollPosition.x + BORDER_MARGIN, - y: canvasViewSettings.scrollPosition.y + BORDER_MARGIN, + x: position.x + TABLE_GAP, + y: position.y, }; addTable(updatedTable); @@ -40,9 +62,11 @@ export const AddCollection = () => { ADD_COLLECTION_TITLE ); }; + const handleCloseModal = () => { closeModal(); }; + return ( } diff --git a/src/pods/toolbar/components/add-collection/add-collection.helper.tsx b/src/pods/toolbar/components/add-collection/add-collection.helper.tsx new file mode 100644 index 00000000..13a156f7 --- /dev/null +++ b/src/pods/toolbar/components/add-collection/add-collection.helper.tsx @@ -0,0 +1,29 @@ +import { Size } from '@/core/model'; +import { FieldVm, TABLE_CONST } from '@/core/providers'; +import { TABLE_GAP } from './add-collection.model'; + +export const getFieldsCount = (fields: FieldVm[]): number => + fields.reduce((acc, field) => { + if (field.children && field.children.length > 0 && !field.isCollapsed) { + return acc + 1 + getFieldsCount(field.children); + } + return acc + 1; + }, 0); + +export const getTableSize = (fields: FieldVm[]): Size => { + const rowHeight = TABLE_CONST.ROW_HEIGHT; + const headerHeight = TABLE_CONST.HEADER_HEIGHT + TABLE_GAP; + const headerTitleGap = TABLE_CONST.HEADER_TITLE_GAP; + const bottomPadding = TABLE_CONST.ROW_PADDING + TABLE_GAP; + + const fieldCount = getFieldsCount(fields); + + const totalHeight = + headerHeight + headerTitleGap + fieldCount * rowHeight + bottomPadding; + const totalWidth = TABLE_CONST.DEFAULT_TABLE_WIDTH + TABLE_GAP * 2; + + return { + width: totalWidth, + height: totalHeight, + }; +}; diff --git a/src/pods/toolbar/components/add-collection/add-collection.mapper.tsx b/src/pods/toolbar/components/add-collection/add-collection.mapper.tsx new file mode 100644 index 00000000..f525ccc9 --- /dev/null +++ b/src/pods/toolbar/components/add-collection/add-collection.mapper.tsx @@ -0,0 +1,17 @@ +import { TableVm, TABLE_CONST } from '@/core/providers'; +import { getTableSize } from './add-collection.helper'; +import { Box } from '@/common/autoarrange-table/autoarrange-table.model'; + +const tableVMtoBoxVMMapper = (table: TableVm): Box => { + const tableSize = getTableSize(table.fields); + + return { + x: table.x, + y: table.y, + width: TABLE_CONST.TABLE_WIDTH, + height: tableSize.height, + }; +}; + +export const mapTableVMtoBoxVMMapper = (tables: TableVm[]): Box[] => + tables.map(tableVMtoBoxVMMapper); diff --git a/src/pods/toolbar/components/add-collection/add-collection.model.ts b/src/pods/toolbar/components/add-collection/add-collection.model.ts new file mode 100644 index 00000000..941ed4c7 --- /dev/null +++ b/src/pods/toolbar/components/add-collection/add-collection.model.ts @@ -0,0 +1 @@ +export const TABLE_GAP = 50;