feat: implements rectangle tool basics

This commit is contained in:
Mark Mankarious
2023-08-16 14:27:31 +01:00
parent b2bf329e84
commit bd7a11849c
26 changed files with 306 additions and 433 deletions

View File

@@ -1,11 +1,14 @@
import React, { useMemo } from 'react';
import { Box, useTheme } from '@mui/material';
import { useTheme } from '@mui/material';
import { Connector as ConnectorI } from 'src/types';
import { UNPROJECTED_TILE_SIZE } from 'src/config';
import { getAnchorPosition } from 'src/utils';
import {
getAnchorPosition,
getRectangleFromSize,
CoordsUtils
} from 'src/utils';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
import { Circle } from 'src/components/Circle/Circle';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
@@ -21,23 +24,27 @@ export const Connector = ({ connector }: Props) => {
const nodes = useSceneStore((state) => {
return state.nodes;
});
const { getTilePosition } = useGetTilePosition();
const unprojectedTileSize = useMemo(() => {
return UNPROJECTED_TILE_SIZE * zoom;
}, [zoom]);
const drawOffset = useMemo(() => {
return {
x: unprojectedTileSize / 2,
y: unprojectedTileSize / 2
};
}, [unprojectedTileSize]);
const pathString = useMemo(() => {
const unprojectedTileSize = UNPROJECTED_TILE_SIZE * zoom;
return connector.path.tiles.reduce((acc, tile) => {
return `${acc} ${tile.x * unprojectedTileSize},${
tile.y * unprojectedTileSize
return `${acc} ${tile.x * unprojectedTileSize + drawOffset.x},${
tile.y * unprojectedTileSize + drawOffset.y
}`;
}, '');
}, [zoom, connector.path.tiles]);
const pathOrigin = useMemo(() => {
return getTilePosition({ tile: connector.path.origin });
}, [getTilePosition, connector.path.origin]);
}, [unprojectedTileSize, connector.path.tiles, drawOffset]);
const anchorPositions = useMemo(() => {
const unprojectedTileSize = UNPROJECTED_TILE_SIZE * zoom;
return connector.anchors.map((anchor) => {
const position = getAnchorPosition({ anchor, nodes });
@@ -46,36 +53,32 @@ export const Connector = ({ connector }: Props) => {
y: (connector.path.origin.y - position.y) * unprojectedTileSize
};
});
}, [connector.path.origin, connector.anchors, zoom, nodes]);
}, [connector.path.origin, connector.anchors, nodes, unprojectedTileSize]);
return (
<Box
id="connector"
sx={{
position: 'absolute',
left: pathOrigin.x,
top: pathOrigin.y
}}
<IsoTileArea
{...getRectangleFromSize(connector.path.origin, connector.path.areaSize)}
origin={connector.path.origin}
zoom={zoom}
fill="none"
>
<IsoTileArea tileArea={connector.path.areaSize} zoom={zoom} fill="none">
<polyline
points={pathString}
stroke={connector.color}
strokeWidth={10 * zoom}
fill="none"
/>
{anchorPositions.map((anchor) => {
return (
<Circle
position={anchor}
radius={10 * zoom}
stroke={theme.palette.common.black}
fill={theme.palette.common.white}
strokeWidth={4 * zoom}
/>
);
})}
</IsoTileArea>
</Box>
<polyline
points={pathString}
stroke={connector.color}
strokeWidth={10 * zoom}
fill="none"
/>
{anchorPositions.map((anchor) => {
return (
<Circle
position={CoordsUtils.add(anchor, drawOffset)}
radius={10 * zoom}
stroke={theme.palette.common.black}
fill={theme.palette.common.white}
strokeWidth={4 * zoom}
/>
);
})}
</IsoTileArea>
);
};

View File

@@ -1,9 +1,8 @@
import React, { useRef, useMemo } from 'react';
import { Box, useTheme } from '@mui/material';
import { Coords, TileOriginEnum } from 'src/types';
import React from 'react';
import { useTheme } from '@mui/material';
import { Coords } from 'src/types';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
interface Props {
tile: Coords;
@@ -14,28 +13,14 @@ export const Cursor = ({ tile }: Props) => {
return state.zoom;
});
const theme = useTheme();
const containerRef = useRef<HTMLDivElement>();
const { getTilePosition } = useGetTilePosition();
const position = useMemo(() => {
return getTilePosition({ tile, origin: TileOriginEnum.TOP });
}, [getTilePosition, tile]);
return (
<Box
ref={containerRef}
sx={{
position: 'absolute',
left: position.x,
top: position.y
}}
>
<IsoTileArea
fill={theme.palette.primary.main}
tileArea={{ width: 1, height: 1 }}
zoom={zoom}
cornerRadius={10 * zoom}
/>
</Box>
<IsoTileArea
from={tile}
to={tile}
fill={theme.palette.primary.main}
zoom={zoom}
cornerRadius={10 * zoom}
/>
);
};

View File

@@ -1,60 +1,31 @@
import React, { useMemo } from 'react';
import React from 'react';
import chroma from 'chroma-js';
import { Box } from '@mui/material';
import { Node, TileOriginEnum, Group as GroupI } from 'src/types';
import { getBoundingBox, getBoundingBoxSize } from 'src/utils';
import { Coords } from 'src/types';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
import { useUiStateStore } from 'src/stores/uiStateStore';
interface Props {
nodes: Node[];
group: GroupI;
from: Coords;
to: Coords;
color: string;
}
export const Group = ({ nodes, group }: Props) => {
export const Group = ({ from, to, color }: Props) => {
const zoom = useUiStateStore((state) => {
return state.zoom;
});
const { getTilePosition } = useGetTilePosition();
const nodePositions = useMemo(() => {
return nodes.map((node) => {
return node.position;
});
}, [nodes]);
const groupAttrs = useMemo(() => {
const corners = getBoundingBox(nodePositions, { x: 1, y: 1 });
const size = getBoundingBoxSize(corners);
const position = getTilePosition({
tile: corners[2],
origin: TileOriginEnum.TOP
});
return { size, position };
}, [nodePositions, getTilePosition]);
if (!groupAttrs) return null;
return (
<Box
sx={{
position: 'absolute',
left: groupAttrs.position.x,
top: groupAttrs.position.y
<IsoTileArea
from={from}
to={to}
fill={chroma(color).alpha(0.6).css()}
zoom={zoom}
cornerRadius={22 * zoom}
stroke={{
color,
width: 1 * zoom
}}
>
<IsoTileArea
tileArea={groupAttrs.size}
fill={chroma(group.color).alpha(0.6).css()}
zoom={zoom}
cornerRadius={22 * zoom}
stroke={{
color: group.color,
width: 1 * zoom
}}
/>
</Box>
/>
);
};

View File

@@ -1,12 +1,19 @@
import React, { useMemo } from 'react';
import { Box } from '@mui/material';
import { UNPROJECTED_TILE_SIZE } from 'src/config';
import { Size, Coords } from 'src/types';
import { getIsoMatrixCSS, getProjectedTileSize } from 'src/utils';
import { Size, Coords, TileOriginEnum } from 'src/types';
import {
getIsoMatrixCSS,
getProjectedTileSize,
getBoundingBox
} from 'src/utils';
import { Svg } from 'src/components/Svg/Svg';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
interface Props {
tileArea: Size;
from: Coords;
to: Coords;
origin?: Coords;
fill: string;
cornerRadius?: number;
stroke?: {
@@ -18,29 +25,52 @@ interface Props {
}
export const IsoTileArea = ({
tileArea,
from,
to,
origin: _origin,
fill,
cornerRadius = 0,
stroke,
zoom,
children
}: Props) => {
const { getTilePosition } = useGetTilePosition();
const projectedTileSize = useMemo(() => {
return getProjectedTileSize({ zoom });
}, [zoom]);
const size = useMemo(() => {
return {
width: Math.abs(from.x - to.x) + 1,
height: Math.abs(from.y - to.y) + 1
};
}, [from, to]);
const origin = useMemo(() => {
if (_origin) return _origin;
const boundingBox = getBoundingBox([from, to]);
return boundingBox[2];
}, [from, to, _origin]);
const position = useMemo(() => {
return getTilePosition({
tile: origin,
origin: TileOriginEnum.TOP
});
}, [origin, getTilePosition]);
const viewbox = useMemo<Size>(() => {
return {
width:
(tileArea.width / 2 + tileArea.height / 2) * projectedTileSize.width,
height:
(tileArea.width / 2 + tileArea.height / 2) * projectedTileSize.height
width: (size.width / 2 + size.height / 2) * projectedTileSize.width,
height: (size.width / 2 + size.height / 2) * projectedTileSize.height
};
}, [tileArea, projectedTileSize]);
}, [size, projectedTileSize]);
const translate = useMemo<Coords>(() => {
return { x: tileArea.width * (projectedTileSize.width / 2), y: 0 };
}, [tileArea, projectedTileSize]);
return { x: size.width * (projectedTileSize.width / 2), y: 0 };
}, [size, projectedTileSize]);
const strokeParams = useMemo(() => {
if (!stroke) return {};
@@ -52,14 +82,16 @@ export const IsoTileArea = ({
}, [stroke]);
const marginLeft = useMemo(() => {
return -(tileArea.width * projectedTileSize.width * 0.5);
}, [projectedTileSize.width, tileArea.width]);
return -(size.width * projectedTileSize.width * 0.5);
}, [projectedTileSize.width, size.width]);
return (
<Box
sx={{
position: 'absolute',
marginLeft: `${marginLeft}px`
marginLeft: `${marginLeft}px`,
left: position.x,
top: position.y
}}
>
<Svg
@@ -70,8 +102,8 @@ export const IsoTileArea = ({
<g transform={`translate(${translate.x}, ${translate.y})`}>
<g transform={getIsoMatrixCSS()}>
<rect
width={tileArea.width * UNPROJECTED_TILE_SIZE * zoom}
height={tileArea.height * UNPROJECTED_TILE_SIZE * zoom}
width={size.width * UNPROJECTED_TILE_SIZE * zoom}
height={size.height * UNPROJECTED_TILE_SIZE * zoom}
fill={fill}
rx={cornerRadius}
{...strokeParams}

View File

@@ -1,12 +1,11 @@
import React, { useEffect, useRef, useCallback } from 'react';
import React, { useRef, useCallback, useMemo } from 'react';
import { Box } from '@mui/material';
import gsap from 'gsap';
import { Coords, TileOriginEnum, Node as NodeI, IconInput } from 'src/types';
import { getColorVariant } from 'src/utils';
import { Node as NodeI, IconInput, TileOriginEnum } from 'src/types';
import { getColorVariant, getRectangleFromSize } from 'src/utils';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
import { useProjectedTileSize } from 'src/hooks/useProjectedTileSize';
import { useGetTilePosition } from 'src/hooks/useGetTilePosition';
import { LabelContainer } from './LabelContainer';
import { MarkdownLabel } from './LabelTypes/MarkdownLabel';
import { NodeIcon } from './NodeIcon';
@@ -22,32 +21,8 @@ export const Node = ({ node, icon, order }: Props) => {
return state.zoom;
});
const nodeRef = useRef<HTMLDivElement>();
const { getTilePosition } = useGetTilePosition();
const projectedTileSize = useProjectedTileSize();
const moveToTile = useCallback(
({
tile,
animationDuration = 0.15
}: {
tile: Coords;
animationDuration?: number;
}) => {
if (!nodeRef.current) return;
const position = getTilePosition({
tile,
origin: TileOriginEnum.BOTTOM
});
gsap.to(nodeRef.current, {
duration: animationDuration,
x: position.x,
y: position.y
});
},
[getTilePosition]
);
const { getTilePosition } = useGetTilePosition();
const onImageLoaded = useCallback(() => {
if (!nodeRef.current) return;
@@ -55,61 +30,57 @@ export const Node = ({ node, icon, order }: Props) => {
nodeRef.current.style.opacity = '1';
}, []);
useEffect(() => {
moveToTile({ tile: node.position, animationDuration: 0 });
}, [node.position, moveToTile]);
const position = useMemo(() => {
return getTilePosition({
tile: node.position,
origin: TileOriginEnum.BOTTOM
});
}, [node.position, getTilePosition]);
return (
<Box
ref={nodeRef}
sx={{
position: 'absolute',
zIndex: order,
opacity: 0
zIndex: order
}}
>
<Box
ref={nodeRef}
sx={{
position: 'absolute'
position: 'absolute',
opacity: 0,
left: position.x,
top: position.y
}}
>
<Box
sx={{
position: 'absolute',
top: -projectedTileSize.height
}}
>
<IsoTileArea
tileArea={{
width: 1,
height: 1
}}
fill={node.color}
cornerRadius={15 * zoom}
stroke={{
width: 1 * zoom,
color: getColorVariant(node.color, 'dark', { grade: 1.5 })
}}
zoom={zoom}
/>
</Box>
<LabelContainer
labelHeight={node.labelHeight + 100}
tileSize={projectedTileSize}
connectorDotSize={5 * zoom}
>
{node.label && <MarkdownLabel label={node.label} />}
</LabelContainer>
</Box>
{icon && (
<Box
sx={{
position: 'absolute'
}}
>
<NodeIcon icon={icon} onImageLoaded={onImageLoaded} />
<Box
sx={{
position: 'absolute',
top: -projectedTileSize.height
}}
/>
<LabelContainer
labelHeight={node.labelHeight + 100}
tileSize={projectedTileSize}
connectorDotSize={5 * zoom}
>
{node.label && <MarkdownLabel label={node.label} />}
</LabelContainer>
</Box>
)}
{icon && (
<Box
sx={{
position: 'absolute'
}}
>
<NodeIcon icon={icon} onImageLoaded={onImageLoaded} />
</Box>
)}
</Box>
</Box>
);
};

View File

@@ -1,6 +1,5 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import { Box } from '@mui/material';
import { Node as NodeI } from 'src/types';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { useInteractionManager } from 'src/interaction/useInteractionManager';
@@ -12,6 +11,8 @@ import { Connector } from 'src/components/Connector/Connector';
import { DebugUtils } from 'src/components/DebugUtils/DebugUtils';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { SceneLayer } from 'src/components/SceneLayer/SceneLayer';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
import { DEFAULT_COLOR } from 'src/config';
export const Renderer = () => {
const containerRef = useRef<HTMLDivElement>();
@@ -51,21 +52,6 @@ export const Renderer = () => {
} = useInteractionManager();
const { observe, disconnect, size: rendererSize } = useResizeObserver();
const getNodesFromIds = useCallback(
(nodeIds: string[]) => {
return nodeIds
.map((nodeId) => {
return nodes.find((node) => {
return node.id === nodeId;
});
})
.filter((node) => {
return node !== undefined;
}) as NodeI[];
},
[nodes]
);
useEffect(() => {
if (!containerRef.current) return;
@@ -96,21 +82,25 @@ export const Renderer = () => {
<Grid scroll={scroll} zoom={zoom} />
<SceneLayer>
{scene.groups.map((group) => {
const groupNodes = getNodesFromIds(group.nodeIds);
return <Group key={group.id} group={group} nodes={groupNodes} />;
return <Group key={group.id} {...group} />;
})}
</SceneLayer>
<SceneLayer>
{mode.showCursor && <Cursor tile={mouse.position.tile} />}
</SceneLayer>
<SceneLayer>
{mode.type === 'AREA_TOOL' && mode.area && (
<Group
from={mode.area.from}
to={mode.area.to}
color={DEFAULT_COLOR}
/>
)}
</SceneLayer>
<SceneLayer>
{scene.connectors.map((connector) => {
return <Connector key={connector.id} connector={connector} />;
})}
{mode.type === 'CONNECTOR' && mode.connector && (
<Connector connector={mode.connector} />
)}
</SceneLayer>
<SceneLayer>
{nodes.map((node) => {
@@ -126,6 +116,11 @@ export const Renderer = () => {
);
})}
</SceneLayer>
<SceneLayer>
{mode.type === 'CONNECTOR' && mode.connector && (
<Connector connector={mode.connector} />
)}
</SceneLayer>
{debugMode && (
<SceneLayer>
<DebugUtils />

View File

@@ -8,7 +8,8 @@ import {
NearMe as NearMeIcon,
CenterFocusStrong as CenterFocusStrongIcon,
Add as AddIcon,
EastOutlined as ConnectorIcon
EastOutlined as ConnectorIcon,
CropSquare as CropSquareIcon
} from '@mui/icons-material';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useDiagramUtils } from 'src/hooks/useDiagramUtils';
@@ -53,6 +54,19 @@ export const ToolMenu = () => {
}}
size={theme.customVars.toolMenu.height}
/>
<IconButton
name="Area"
Icon={<CropSquareIcon />}
onClick={() => {
uiStateStoreActions.setMode({
type: 'AREA_TOOL',
showCursor: true,
area: null
});
}}
isActive={mode.type === 'AREA_TOOL'}
size={theme.customVars.toolMenu.height}
/>
<IconButton
name="Connector"
Icon={<ConnectorIcon />}
@@ -63,6 +77,7 @@ export const ToolMenu = () => {
showCursor: true
});
}}
isActive={mode.type === 'CONNECTOR'}
size={theme.customVars.toolMenu.height}
/>
<IconButton

View File

@@ -1,17 +0,0 @@
import React from 'react';
import Isoflow from 'src/Isoflow';
import { icons } from '../icons';
export const BasicEditor = () => {
return (
<Isoflow
initialScene={{
icons,
connectors: [],
groups: [],
nodes: []
}}
height="100%"
/>
);
};

View File

@@ -1,101 +0,0 @@
import React from 'react';
import Isoflow from 'src/Isoflow';
import { Box, useTheme } from '@mui/material';
// eslint-disable-next-line import/no-extraneous-dependencies
import {
AreaChart,
Area,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer
} from 'recharts';
import { icons } from '../icons';
import graphData from './graphData';
const CustomLabel = () => {
const theme = useTheme();
return (
<Box
sx={{
width: '300px',
height: '125px',
pt: 2
}}
>
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={graphData}
margin={{
top: 0,
right: 0,
left: 0,
bottom: 0
}}
>
<CartesianGrid strokeDasharray="3 3" />
<YAxis width={25} />
<Tooltip />
<Area
type="monotone"
dataKey="uv"
fill={theme.customVars.diagramPalette.blue}
/>
</AreaChart>
</ResponsiveContainer>
</Box>
);
};
export const CustomNode = () => {
return (
<Isoflow
initialScene={{
icons,
connectors: [
{
id: 'connector1',
anchors: [
{
nodeId: 'server'
},
{
nodeId: 'database'
}
]
}
],
groups: [
{
id: 'group1',
nodeIds: ['server', 'database']
}
],
nodes: [
{
id: 'server',
label: 'Requests per minute',
labelHeight: 40,
iconId: 'server',
position: {
x: 0,
y: 0
}
},
{
id: 'database',
label: 'Transactions',
labelHeight: 40,
iconId: 'server',
position: {
x: 5,
y: 3
}
}
]
}}
height="100%"
/>
);
};

View File

@@ -1,23 +0,0 @@
export default [
{
uv: 40
},
{
uv: 30
},
{
uv: 20
},
{
uv: 27
},
{
uv: 18
},
{
uv: 23
},
{
uv: 34
}
];

View File

@@ -11,7 +11,14 @@ export const DebugTools = () => {
groups: [
{
id: 'group1',
nodeIds: ['server', 'database']
from: {
x: 5,
y: 5
},
to: {
x: 0,
y: 0
}
}
],
nodes: [

View File

@@ -1,15 +1,11 @@
import React, { useState, useMemo } from 'react';
import { Box, Select, MenuItem, useTheme } from '@mui/material';
import { BasicEditor } from './BasicEditor/BasicEditor';
import { CustomNode } from './CustomNode/CustomNode';
import { Callbacks } from './Callbacks/Callbacks';
import { DebugTools } from './DebugTools/DebugTools';
const examples = [
{ name: 'Debug tools', component: DebugTools },
{ name: 'Live Diagrams', component: CustomNode },
{ name: 'Callbacks', component: Callbacks },
{ name: 'Basic Editor', component: BasicEditor }
{ name: 'Callbacks', component: Callbacks }
];
export const Examples = () => {

View File

@@ -0,0 +1,38 @@
import { InteractionReducer } from 'src/types';
import { generateId } from 'src/utils';
import { DEFAULT_COLOR } from 'src/config';
export const AreaTool: InteractionReducer = {
type: 'AREA_TOOL',
mousemove: (draftState) => {
if (
draftState.mode.type !== 'AREA_TOOL' ||
!draftState.mode.area ||
!draftState.mouse.mousedown
)
return;
draftState.mode.area.to = draftState.mouse.position.tile;
},
mousedown: (draftState) => {
if (draftState.mode.type !== 'AREA_TOOL') return;
draftState.mode.area = {
from: draftState.mouse.position.tile,
to: draftState.mouse.position.tile
};
},
mouseup: (draftState) => {
if (draftState.mode.type !== 'AREA_TOOL' || !draftState.mode.area) return;
const newGroups = draftState.sceneActions.createGroup({
id: generateId(),
color: DEFAULT_COLOR,
from: draftState.mode.area.from,
to: draftState.mode.area.to
});
draftState.scene.groups = newGroups.groups;
draftState.mode.area = null;
}
};

View File

@@ -10,6 +10,7 @@ import { Cursor } from './reducers/Cursor';
import { Lasso } from './reducers/Lasso';
import { PlaceElement } from './reducers/PlaceElement';
import { Connector } from './reducers/Connector';
import { AreaTool } from './reducers/AreaTool';
const reducers: { [k in string]: InteractionReducer } = {
CURSOR: Cursor,
@@ -17,7 +18,8 @@ const reducers: { [k in string]: InteractionReducer } = {
PAN: Pan,
LASSO: Lasso,
PLACE_ELEMENT: PlaceElement,
CONNECTOR: Connector
CONNECTOR: Connector,
AREA_TOOL: AreaTool
};
export const useInteractionManager = () => {

View File

@@ -1,9 +1,15 @@
import React, { createContext, useRef, useContext } from 'react';
import { v4 as uuid } from 'uuid';
import { createStore, useStore } from 'zustand';
import { produce } from 'immer';
import { Scene, SceneActions } from 'src/types';
import { Scene, SceneActions, GroupInput } from 'src/types';
import { sceneInput } from 'src/validation/scene';
import { sceneInputtoScene, getItemById, getConnectorPath } from 'src/utils';
import {
sceneInputtoScene,
getItemById,
getConnectorPath,
groupInputToGroup
} from 'src/utils';
interface Actions {
actions: SceneActions;
@@ -51,6 +57,11 @@ const initialState = () => {
}
});
});
},
createGroup: (group) => {
return produce(get(), (draftState) => {
draftState.groups.push(groupInputToGroup(group));
});
}
}
};

View File

@@ -29,7 +29,7 @@ export const customVars: CustomThemeVars = {
y: 40
},
toolMenu: {
height: 50
height: 40
},
diagramPalette: {
green: '#53b435',

View File

@@ -52,5 +52,5 @@ export const scene: SceneInput = {
anchors: [{ nodeId: 'node2' }, { nodeId: 'node3' }]
}
],
groups: [{ id: 'group1', nodeIds: ['node1', 'node2'] }]
groups: [{ id: 'group1', from: { x: 0, y: 0 }, to: { x: 2, y: 2 } }]
};

View File

@@ -1,5 +1,5 @@
import { Coords, Size } from './common';
import { IconInput, SceneInput } from './inputs';
import { IconInput, SceneInput, GroupInput } from './inputs';
export enum TileOriginEnum {
CENTER = 'CENTER',
@@ -51,8 +51,9 @@ export interface Connector {
export interface Group {
type: SceneItemTypeEnum.GROUP;
id: string;
nodeIds: string[];
color: string;
from: Coords;
to: Coords;
}
export type SceneItem = Node | Connector | Group;
@@ -63,6 +64,7 @@ export interface SceneActions {
setScene: (scene: SceneInput) => void;
updateScene: (scene: Scene) => void;
updateNode: (id: string, updates: Partial<Node>, scene?: Scene) => Scene;
createGroup: (group: GroupInput) => Scene;
}
export type Scene = {

View File

@@ -85,6 +85,15 @@ export interface ConnectorMode {
connector: Connector | null;
}
export interface AreaToolMode {
type: 'AREA_TOOL';
showCursor: boolean;
area: {
from: Coords;
to: Coords;
} | null;
}
export type Mode =
| InteractionsDisabled
| CursorMode
@@ -92,7 +101,8 @@ export type Mode =
| DragItemsMode
| LassoMode
| PlaceElementMode
| ConnectorMode;
| ConnectorMode
| AreaToolMode;
// End mode types
export type ContextMenu =

View File

@@ -1,4 +1,9 @@
import chroma from 'chroma-js';
import { v4 as uuid } from 'uuid';
export const generateId = () => {
return uuid();
};
export const clamp = (num: number, min: number, max: number) => {
return Math.max(Math.min(num, max), min);

View File

@@ -32,7 +32,8 @@ export const groupInputToGroup = (groupInput: GroupInput): Group => {
return {
type: SceneItemTypeEnum.GROUP,
id: groupInput.id,
nodeIds: groupInput.nodeIds,
from: groupInput.from,
to: groupInput.to,
color: groupInput.color ?? DEFAULT_COLOR
};
};
@@ -145,8 +146,9 @@ export const connectorToConnectorInput = (
export const groupToGroupInput = (group: Group): GroupInput => {
return {
id: group.id,
nodeIds: group.nodeIds,
color: group.color
color: group.color,
from: group.from,
to: group.to
};
};

View File

@@ -376,3 +376,15 @@ export const getConnectorPath = ({
return { tiles, origin, areaSize: searchAreaSize };
};
type GetRectangleFromSize = (
from: Coords,
size: Size
) => { from: Coords; to: Coords };
export const getRectangleFromSize: GetRectangleFromSize = (from, size) => {
return {
from,
to: { x: from.x + size.width, y: from.y + size.height }
};
};

View File

@@ -1,11 +1,7 @@
// TODO: Split into individual files
import { z } from 'zod';
import { iconInput, nodeInput, connectorInput, groupInput } from './sceneItems';
import {
findInvalidConnector,
findInvalidGroup,
findInvalidNode
} from './utils';
import { findInvalidConnector, findInvalidNode } from './utils';
export const sceneInput = z
.object({
@@ -38,17 +34,5 @@ export const sceneInput = z
path: ['connectors', invalidConnector.id],
message: 'Invalid connector found in scene'
});
return;
}
const invalidGroup = findInvalidGroup(scene.groups, scene.nodes);
if (invalidGroup) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['groups', invalidGroup.id],
message: 'Invalid group found in scene'
});
}
});

View File

@@ -5,6 +5,11 @@ const coords = z.object({
y: z.number()
});
const size = z.object({
width: z.number(),
height: z.number()
});
export const iconInput = z.object({
id: z.string(),
name: z.string(),
@@ -46,5 +51,6 @@ export const connectorInput = z.object({
export const groupInput = z.object({
id: z.string(),
color: z.string().optional(),
nodeIds: z.array(z.string())
from: coords,
to: coords
});

View File

@@ -1,10 +1,6 @@
import { NodeInput, ConnectorInput, GroupInput } from 'src/types/inputs';
import { NodeInput, ConnectorInput } from 'src/types/inputs';
import { sceneInput } from '../scene';
import {
findInvalidNode,
findInvalidConnector,
findInvalidGroup
} from '../utils';
import { findInvalidNode, findInvalidConnector } from '../utils';
import { scene } from '../../tests/fixtures/scene';
describe('scene validation works correctly', () => {
@@ -47,17 +43,4 @@ describe('scene validation works correctly', () => {
expect(result).toEqual(invalidConnector);
});
test('finds invalid groups in scene', () => {
const { nodes } = scene;
const invalidGroup: GroupInput = {
id: 'invalidGroup',
nodeIds: ['invalidNode', 'node1']
};
const groups: GroupInput[] = [...scene.groups, invalidGroup];
const result = findInvalidGroup(groups, nodes);
expect(result).toEqual(invalidGroup);
});
});

View File

@@ -1,9 +1,4 @@
import {
ConnectorInput,
NodeInput,
GroupInput,
IconInput
} from 'src/types/inputs';
import { ConnectorInput, NodeInput, IconInput } from 'src/types/inputs';
export const findInvalidNode = (nodes: NodeInput[], icons: IconInput[]) => {
return nodes.find((node) => {
@@ -35,14 +30,3 @@ export const findInvalidConnector = (
return Boolean(invalidAnchor);
});
};
export const findInvalidGroup = (groups: GroupInput[], nodes: NodeInput[]) => {
return groups.find((grp) => {
return grp.nodeIds.find((nodeId) => {
const validNode = nodes.find((node) => {
return node.id === nodeId;
});
return Boolean(!validNode);
});
});
};