mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-24 06:58:48 -05:00
refactor: connector functionality
This commit is contained in:
@@ -27,7 +27,7 @@
|
||||
"consistent-return": [0],
|
||||
"react/no-unused-prop-types": ["warn"],
|
||||
"react/require-default-props": [0],
|
||||
"no-param-reassign": ["error", { "props": true, "ignorePropertyModificationsFor": ["draftState"] }],
|
||||
"no-param-reassign": ["error", { "props": true, "ignorePropertyModificationsFor": ["draftState", "draft"] }],
|
||||
"arrow-body-style": ["error", "always"]
|
||||
},
|
||||
"ignorePatterns": [
|
||||
|
||||
15
src/components/Circle/Circle.tsx
Normal file
15
src/components/Circle/Circle.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Coords } from 'src/types';
|
||||
|
||||
interface Props {
|
||||
position: Coords;
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
export const Circle = ({
|
||||
position,
|
||||
radius,
|
||||
...rest
|
||||
}: Props & React.SVGProps<SVGCircleElement>) => {
|
||||
return <circle cx={position.x} cy={position.y} r={radius} {...rest} />;
|
||||
};
|
||||
@@ -1,82 +1,80 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { Node, Coords, Connector as ConnectorI } from 'src/types';
|
||||
import { Box, useTheme } from '@mui/material';
|
||||
import { Connector as ConnectorI } from 'src/types';
|
||||
import { UNPROJECTED_TILE_SIZE } from 'src/config';
|
||||
import {
|
||||
findPath,
|
||||
getBoundingBox,
|
||||
getBoundingBoxSize,
|
||||
sortByPosition,
|
||||
CoordsUtils
|
||||
} from 'src/utils';
|
||||
import { getAnchorPosition } 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';
|
||||
|
||||
interface Props {
|
||||
connector: ConnectorI;
|
||||
fromNode: Node;
|
||||
toNode: Node;
|
||||
}
|
||||
|
||||
// The boundaries of the search area for the pathfinder algorithm
|
||||
// is the grid that encompasses the two nodes + the offset below.
|
||||
const BOUNDS_OFFSET: Coords = { x: 3, y: 3 };
|
||||
|
||||
export const Connector = ({ fromNode, toNode, connector }: Props) => {
|
||||
export const Connector = ({ connector }: Props) => {
|
||||
const theme = useTheme();
|
||||
const zoom = useUiStateStore((state) => {
|
||||
return state.zoom;
|
||||
});
|
||||
const nodes = useSceneStore((state) => {
|
||||
return state.nodes;
|
||||
});
|
||||
const { getTilePosition } = useGetTilePosition();
|
||||
const route = useMemo(() => {
|
||||
const searchArea = getBoundingBox(
|
||||
[fromNode.position, toNode.position],
|
||||
BOUNDS_OFFSET
|
||||
);
|
||||
const searchAreaSize = getBoundingBoxSize(searchArea);
|
||||
const sorted = sortByPosition(searchArea);
|
||||
const topLeftTile = { x: sorted.highX, y: sorted.highY };
|
||||
|
||||
const positionsNormalisedFromSearchArea = {
|
||||
from: CoordsUtils.subtract(topLeftTile, fromNode.position),
|
||||
to: CoordsUtils.subtract(topLeftTile, toNode.position)
|
||||
};
|
||||
|
||||
const connectorRoute = findPath({
|
||||
from: positionsNormalisedFromSearchArea.from,
|
||||
to: positionsNormalisedFromSearchArea.to,
|
||||
gridSize: searchAreaSize
|
||||
});
|
||||
const pathString = useMemo(() => {
|
||||
const unprojectedTileSize = UNPROJECTED_TILE_SIZE * zoom;
|
||||
const path = connectorRoute.reduce((acc, tile) => {
|
||||
return connector.path.tiles.reduce((acc, tile) => {
|
||||
return `${acc} ${tile.x * unprojectedTileSize},${
|
||||
tile.y * unprojectedTileSize
|
||||
}`;
|
||||
}, '');
|
||||
}, [zoom, connector.path.tiles]);
|
||||
|
||||
const position = getTilePosition({
|
||||
tile: topLeftTile
|
||||
const pathOrigin = useMemo(() => {
|
||||
return getTilePosition({ tile: connector.path.origin });
|
||||
}, [getTilePosition, connector.path.origin]);
|
||||
|
||||
const anchorPositions = useMemo(() => {
|
||||
const unprojectedTileSize = UNPROJECTED_TILE_SIZE * zoom;
|
||||
|
||||
return connector.anchors.map((anchor) => {
|
||||
const position = getAnchorPosition({ anchor, nodes });
|
||||
|
||||
return {
|
||||
x: (connector.path.origin.x - position.x) * unprojectedTileSize,
|
||||
y: (connector.path.origin.y - position.y) * unprojectedTileSize
|
||||
};
|
||||
});
|
||||
|
||||
return { path, searchAreaSize, topLeftTile, position };
|
||||
}, [fromNode.position, toNode.position, zoom, getTilePosition]);
|
||||
}, [connector.path.origin, connector.anchors, zoom, nodes]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
id="connector"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: route.position.x,
|
||||
top: route.position.y
|
||||
left: pathOrigin.x,
|
||||
top: pathOrigin.y
|
||||
}}
|
||||
>
|
||||
<IsoTileArea tileArea={route.searchAreaSize} zoom={zoom} fill="none">
|
||||
<IsoTileArea tileArea={connector.path.areaSize} zoom={zoom} fill="none">
|
||||
<polyline
|
||||
points={route.path}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,14 @@ export const DebugUtils = () => {
|
||||
title="Mouse"
|
||||
value={`${mouse.position.tile.x}, ${mouse.position.tile.y}`}
|
||||
/>
|
||||
<LineItem
|
||||
title="Mouse down"
|
||||
value={
|
||||
mouse.mousedown
|
||||
? `${mouse.mousedown.tile.x}, ${mouse.mousedown.tile.y}`
|
||||
: 'null'
|
||||
}
|
||||
/>
|
||||
<LineItem
|
||||
title="Scroll"
|
||||
value={`${scroll.position.x}, ${scroll.position.y}`}
|
||||
|
||||
@@ -106,20 +106,11 @@ export const Renderer = () => {
|
||||
</SceneLayer>
|
||||
<SceneLayer>
|
||||
{scene.connectors.map((connector) => {
|
||||
const connectorNodes = getNodesFromIds([
|
||||
connector.from,
|
||||
connector.to
|
||||
]);
|
||||
|
||||
return (
|
||||
<Connector
|
||||
key={connector.id}
|
||||
connector={connector}
|
||||
fromNode={connectorNodes[0]}
|
||||
toNode={connectorNodes[1]}
|
||||
/>
|
||||
);
|
||||
return <Connector key={connector.id} connector={connector} />;
|
||||
})}
|
||||
{mode.type === 'CONNECTOR' && mode.connector && (
|
||||
<Connector connector={mode.connector} />
|
||||
)}
|
||||
</SceneLayer>
|
||||
<SceneLayer>
|
||||
{nodes.map((node) => {
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
ZoomOut as ZoomOutIcon,
|
||||
NearMe as NearMeIcon,
|
||||
CenterFocusStrong as CenterFocusStrongIcon,
|
||||
Add as AddIcon
|
||||
Add as AddIcon,
|
||||
EastOutlined as ConnectorIcon
|
||||
} from '@mui/icons-material';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useDiagramUtils } from 'src/hooks/useDiagramUtils';
|
||||
@@ -52,6 +53,18 @@ export const ToolMenu = () => {
|
||||
}}
|
||||
size={theme.customVars.toolMenu.height}
|
||||
/>
|
||||
<IconButton
|
||||
name="Connector"
|
||||
Icon={<ConnectorIcon />}
|
||||
onClick={() => {
|
||||
uiStateStoreActions.setMode({
|
||||
type: 'CONNECTOR',
|
||||
connector: null,
|
||||
showCursor: true
|
||||
});
|
||||
}}
|
||||
size={theme.customVars.toolMenu.height}
|
||||
/>
|
||||
<IconButton
|
||||
name="Select"
|
||||
Icon={<NearMeIcon />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Size } from 'src/types';
|
||||
import { Size, Coords } from 'src/types';
|
||||
import { customVars } from './styles/theme';
|
||||
|
||||
export const UNPROJECTED_TILE_SIZE = 100;
|
||||
@@ -7,8 +7,17 @@ export const TILE_PROJECTION_MULTIPLIERS: Size = {
|
||||
height: 0.819
|
||||
};
|
||||
export const DEFAULT_COLOR = customVars.diagramPalette.blue;
|
||||
export const CONNECTOR_DEFAULTS = {
|
||||
width: 4
|
||||
|
||||
interface ConnectorDefaults {
|
||||
width: number;
|
||||
searchOffset: Coords;
|
||||
}
|
||||
|
||||
export const CONNECTOR_DEFAULTS: ConnectorDefaults = {
|
||||
width: 4,
|
||||
// The boundaries of the search area for the pathfinder algorithm
|
||||
// is the grid that encompasses the two nodes + the offset below.
|
||||
searchOffset: { x: 3, y: 3 }
|
||||
};
|
||||
export const NODE_DEFAULTS = {
|
||||
label: '',
|
||||
|
||||
@@ -56,9 +56,14 @@ export const CustomNode = () => {
|
||||
connectors: [
|
||||
{
|
||||
id: 'connector1',
|
||||
from: 'database',
|
||||
to: 'server',
|
||||
label: 'connection'
|
||||
anchors: [
|
||||
{
|
||||
nodeId: 'server'
|
||||
},
|
||||
{
|
||||
nodeId: 'database'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
groups: [
|
||||
|
||||
@@ -7,14 +7,7 @@ export const DebugTools = () => {
|
||||
<Isoflow
|
||||
initialScene={{
|
||||
icons,
|
||||
connectors: [
|
||||
{
|
||||
id: 'connector1',
|
||||
from: 'database',
|
||||
to: 'server',
|
||||
label: 'connection'
|
||||
}
|
||||
],
|
||||
connectors: [],
|
||||
groups: [
|
||||
{
|
||||
id: 'group1',
|
||||
@@ -36,7 +29,7 @@ export const DebugTools = () => {
|
||||
id: 'database',
|
||||
label: 'Transactions',
|
||||
labelHeight: 40,
|
||||
iconId: 'server',
|
||||
iconId: 'block',
|
||||
position: {
|
||||
x: 5,
|
||||
y: 3
|
||||
|
||||
87
src/interaction/reducers/Connector.ts
Normal file
87
src/interaction/reducers/Connector.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { InteractionReducer } from 'src/types';
|
||||
import {
|
||||
filterNodesByTile,
|
||||
connectorInputToConnector,
|
||||
getConnectorPath
|
||||
} from 'src/utils';
|
||||
|
||||
export const Connector: InteractionReducer = {
|
||||
type: 'CONNECTOR',
|
||||
mousemove: (draftState) => {
|
||||
if (
|
||||
draftState.mode.type !== 'CONNECTOR' ||
|
||||
!draftState.mode.connector?.anchors[0]
|
||||
)
|
||||
return;
|
||||
|
||||
const itemsAtTile = filterNodesByTile({
|
||||
tile: draftState.mouse.position.tile,
|
||||
nodes: draftState.scene.nodes
|
||||
});
|
||||
|
||||
if (itemsAtTile.length > 0) {
|
||||
const node = itemsAtTile[0];
|
||||
|
||||
draftState.mode.connector.anchors[1] = {
|
||||
type: 'NODE',
|
||||
id: node.id
|
||||
};
|
||||
} else {
|
||||
draftState.mode.connector.anchors[1] = {
|
||||
type: 'TILE',
|
||||
coords: draftState.mouse.position.tile
|
||||
};
|
||||
}
|
||||
|
||||
draftState.mode.connector.path = getConnectorPath({
|
||||
anchors: draftState.mode.connector.anchors,
|
||||
nodes: draftState.scene.nodes
|
||||
});
|
||||
},
|
||||
mousedown: (draftState) => {
|
||||
if (draftState.mode.type !== 'CONNECTOR') return;
|
||||
|
||||
const itemsAtTile = filterNodesByTile({
|
||||
tile: draftState.mouse.position.tile,
|
||||
nodes: draftState.scene.nodes
|
||||
});
|
||||
|
||||
if (itemsAtTile.length > 0) {
|
||||
const node = itemsAtTile[0];
|
||||
|
||||
draftState.mode.connector = connectorInputToConnector(
|
||||
{
|
||||
id: uuid(),
|
||||
anchors: [{ nodeId: node.id }, { nodeId: node.id }]
|
||||
},
|
||||
draftState.scene.nodes
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
draftState.mode.connector = connectorInputToConnector(
|
||||
{
|
||||
id: uuid(),
|
||||
anchors: [
|
||||
{ tile: draftState.mouse.position.tile },
|
||||
{ tile: draftState.mouse.position.tile }
|
||||
]
|
||||
},
|
||||
draftState.scene.nodes
|
||||
);
|
||||
},
|
||||
mouseup: (draftState) => {
|
||||
if (draftState.mode.type !== 'CONNECTOR') return;
|
||||
|
||||
if (
|
||||
draftState.mode.connector &&
|
||||
draftState.mode.connector.anchors.length >= 2
|
||||
) {
|
||||
draftState.scene.connectors.push(draftState.mode.connector);
|
||||
}
|
||||
|
||||
draftState.mode.connector = null;
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { produce } from 'immer';
|
||||
import { CoordsUtils } from 'src/utils';
|
||||
import { InteractionReducer } from 'src/types';
|
||||
|
||||
@@ -17,18 +18,23 @@ export const DragItems: InteractionReducer = {
|
||||
!CoordsUtils.isEqual(draftState.mouse.delta.tile, CoordsUtils.zero())
|
||||
) {
|
||||
// User has moved tile since the last mouse event
|
||||
draftState.mode.items.forEach((node) => {
|
||||
const nodeIndex = draftState.scene.nodes.findIndex((sceneNode) => {
|
||||
return sceneNode.id === node.id;
|
||||
const newScene = draftState.mode.items.reduce((acc, node) => {
|
||||
return produce(acc, (draft) => {
|
||||
const afterNodeUpdates = draftState.sceneActions.updateNode(
|
||||
node.id,
|
||||
{
|
||||
position: draftState.mouse.position.tile
|
||||
},
|
||||
acc
|
||||
);
|
||||
|
||||
draft.nodes = afterNodeUpdates.nodes;
|
||||
draft.connectors = afterNodeUpdates.connectors;
|
||||
});
|
||||
}, draftState.scene);
|
||||
|
||||
if (nodeIndex === -1) return;
|
||||
|
||||
draftState.scene.nodes[nodeIndex].position =
|
||||
draftState.mouse.position.tile;
|
||||
|
||||
draftState.contextMenu = null;
|
||||
});
|
||||
draftState.scene = newScene;
|
||||
draftState.contextMenu = null;
|
||||
}
|
||||
},
|
||||
mouseup: (draftState) => {
|
||||
|
||||
@@ -9,13 +9,15 @@ import { Pan } from './reducers/Pan';
|
||||
import { Cursor } from './reducers/Cursor';
|
||||
import { Lasso } from './reducers/Lasso';
|
||||
import { PlaceElement } from './reducers/PlaceElement';
|
||||
import { Connector } from './reducers/Connector';
|
||||
|
||||
const reducers: { [k in string]: InteractionReducer } = {
|
||||
CURSOR: Cursor,
|
||||
DRAG_ITEMS: DragItems,
|
||||
PAN: Pan,
|
||||
LASSO: Lasso,
|
||||
PLACE_ELEMENT: PlaceElement
|
||||
PLACE_ELEMENT: PlaceElement,
|
||||
CONNECTOR: Connector
|
||||
};
|
||||
|
||||
export const useInteractionManager = () => {
|
||||
@@ -47,9 +49,11 @@ export const useInteractionManager = () => {
|
||||
const sceneActions = useSceneStore((state) => {
|
||||
return state.actions;
|
||||
});
|
||||
const scene = useSceneStore(({ nodes, connectors, groups, icons }) => {
|
||||
return { nodes, connectors, groups, icons };
|
||||
});
|
||||
const scene = useSceneStore(
|
||||
({ nodes, connectors, groups, icons, actions }) => {
|
||||
return { nodes, connectors, groups, icons, actions };
|
||||
}
|
||||
);
|
||||
const rendererSize = useUiStateStore((state) => {
|
||||
return state.rendererSize;
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createStore, useStore } from 'zustand';
|
||||
import { produce } from 'immer';
|
||||
import { Scene, SceneActions } from 'src/types';
|
||||
import { sceneInput } from 'src/validation/scene';
|
||||
import { sceneInputtoScene } from 'src/utils';
|
||||
import { sceneInputtoScene, getItemById, getConnectorPath } from 'src/utils';
|
||||
|
||||
interface Actions {
|
||||
actions: SceneActions;
|
||||
@@ -29,21 +29,28 @@ const initialState = () => {
|
||||
updateScene: (scene) => {
|
||||
set(scene);
|
||||
},
|
||||
updateNode: (id, updates) => {
|
||||
const { nodes } = get();
|
||||
const nodeIndex = nodes.findIndex((node) => {
|
||||
return node.id === id;
|
||||
updateNode: (id, updates, scene) => {
|
||||
return produce(scene ?? get(), (draftState) => {
|
||||
const { item: node, index } = getItemById(draftState.nodes, id);
|
||||
|
||||
draftState.nodes[index] = {
|
||||
...node,
|
||||
...updates
|
||||
};
|
||||
|
||||
draftState.connectors.forEach((connector, i) => {
|
||||
const needsUpdate = connector.anchors.find((anchor) => {
|
||||
return anchor.type === 'NODE' && anchor.id === id;
|
||||
});
|
||||
|
||||
if (needsUpdate) {
|
||||
draftState.connectors[i].path = getConnectorPath({
|
||||
anchors: connector.anchors,
|
||||
nodes: draftState.nodes
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (nodeIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newNodes = produce(nodes, (draftState) => {
|
||||
draftState[nodeIndex] = { ...draftState[nodeIndex], ...updates };
|
||||
});
|
||||
|
||||
set({ nodes: newNodes });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
10
src/tests/fixtures/scene.ts
vendored
10
src/tests/fixtures/scene.ts
vendored
@@ -43,8 +43,14 @@ export const scene: SceneInput = {
|
||||
}
|
||||
],
|
||||
connectors: [
|
||||
{ id: 'connector1', label: 'Connector1', from: 'node1', to: 'node2' },
|
||||
{ id: 'connector2', label: 'Connector2', from: 'node2', to: 'node3' }
|
||||
{
|
||||
id: 'connector1',
|
||||
anchors: [{ nodeId: 'node1' }, { nodeId: 'node2' }]
|
||||
},
|
||||
{
|
||||
id: 'connector2',
|
||||
anchors: [{ nodeId: 'node2' }, { nodeId: 'node3' }]
|
||||
}
|
||||
],
|
||||
groups: [{ id: 'group1', nodeIds: ['node1', 'node2'] }]
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import z from 'zod';
|
||||
import {
|
||||
iconInput,
|
||||
nodeInput,
|
||||
connectorAnchorInput,
|
||||
connectorInput,
|
||||
groupInput
|
||||
} from 'src/validation/sceneItems';
|
||||
@@ -9,6 +10,7 @@ import { sceneInput } from 'src/validation/scene';
|
||||
|
||||
export type IconInput = z.infer<typeof iconInput>;
|
||||
export type NodeInput = z.infer<typeof nodeInput>;
|
||||
export type ConnectorAnchorInput = z.infer<typeof connectorAnchorInput>;
|
||||
export type ConnectorInput = z.infer<typeof connectorInput>;
|
||||
export type GroupInput = z.infer<typeof groupInput>;
|
||||
export type SceneInput = z.infer<typeof sceneInput>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Coords } from './common';
|
||||
import { Coords, Size } from './common';
|
||||
import { IconInput, SceneInput } from './inputs';
|
||||
|
||||
export enum TileOriginEnum {
|
||||
@@ -26,13 +26,26 @@ export interface Node {
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
export type ConnectorAnchor =
|
||||
| {
|
||||
type: 'NODE';
|
||||
id: string;
|
||||
}
|
||||
| {
|
||||
type: 'TILE';
|
||||
coords: Coords;
|
||||
};
|
||||
|
||||
export interface Connector {
|
||||
type: SceneItemTypeEnum.CONNECTOR;
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
from: string;
|
||||
to: string;
|
||||
anchors: ConnectorAnchor[];
|
||||
path: {
|
||||
tiles: Coords[];
|
||||
origin: Coords;
|
||||
areaSize: Size;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
@@ -46,15 +59,15 @@ export type SceneItem = Node | Connector | Group;
|
||||
|
||||
export type Icon = IconInput;
|
||||
|
||||
export interface SceneActions {
|
||||
setScene: (scene: SceneInput) => void;
|
||||
updateScene: (scene: Scene) => void;
|
||||
updateNode: (id: string, updates: Partial<Node>, scene?: Scene) => Scene;
|
||||
}
|
||||
|
||||
export type Scene = {
|
||||
nodes: Node[];
|
||||
connectors: Connector[];
|
||||
groups: Group[];
|
||||
icons: IconInput[];
|
||||
};
|
||||
|
||||
export interface SceneActions {
|
||||
setScene: (scene: SceneInput) => void;
|
||||
updateScene: (scene: Scene) => void;
|
||||
updateNode: (id: string, updates: Partial<Node>) => void;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Coords, Size } from './common';
|
||||
import { SceneItem } from './scene';
|
||||
import { SceneItem, Connector } from './scene';
|
||||
import { IconInput } from './inputs';
|
||||
|
||||
export enum ItemControlsTypeEnum {
|
||||
@@ -73,19 +73,26 @@ export interface DragItemsMode {
|
||||
items: SceneItem[];
|
||||
}
|
||||
|
||||
export interface PlaceElement {
|
||||
export interface PlaceElementMode {
|
||||
type: 'PLACE_ELEMENT';
|
||||
showCursor: boolean;
|
||||
icon: IconInput | null;
|
||||
}
|
||||
|
||||
export interface ConnectorMode {
|
||||
type: 'CONNECTOR';
|
||||
showCursor: boolean;
|
||||
connector: Connector | null;
|
||||
}
|
||||
|
||||
export type Mode =
|
||||
| InteractionsDisabled
|
||||
| CursorMode
|
||||
| PanMode
|
||||
| DragItemsMode
|
||||
| LassoMode
|
||||
| PlaceElement;
|
||||
| PlaceElementMode
|
||||
| ConnectorMode;
|
||||
// End mode types
|
||||
|
||||
export type ContextMenu =
|
||||
|
||||
@@ -7,9 +7,13 @@ import {
|
||||
Scene,
|
||||
Node,
|
||||
Connector,
|
||||
Group
|
||||
Group,
|
||||
ConnectorAnchorInput,
|
||||
ConnectorAnchor,
|
||||
Coords
|
||||
} from 'src/types';
|
||||
import { NODE_DEFAULTS, DEFAULT_COLOR } from 'src/config';
|
||||
import { getConnectorPath } from './renderer';
|
||||
|
||||
export const nodeInputToNode = (nodeInput: NodeInput): Node => {
|
||||
return {
|
||||
@@ -33,16 +37,40 @@ export const groupInputToGroup = (groupInput: GroupInput): Group => {
|
||||
};
|
||||
};
|
||||
|
||||
const connectorAnchorInputToConnectorAnchor = (
|
||||
anchor: ConnectorAnchorInput
|
||||
): ConnectorAnchor => {
|
||||
if (anchor.nodeId) {
|
||||
return {
|
||||
type: 'NODE',
|
||||
id: anchor.nodeId
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'TILE',
|
||||
coords: anchor.tile as Coords
|
||||
};
|
||||
};
|
||||
|
||||
export const connectorInputToConnector = (
|
||||
connectorInput: ConnectorInput
|
||||
connectorInput: ConnectorInput,
|
||||
nodes: Node[]
|
||||
): Connector => {
|
||||
const anchors = connectorInput.anchors
|
||||
.map((anchor) => {
|
||||
return connectorAnchorInputToConnectorAnchor(anchor);
|
||||
})
|
||||
.filter((anchor) => {
|
||||
return anchor !== null;
|
||||
});
|
||||
|
||||
return {
|
||||
type: SceneItemTypeEnum.CONNECTOR,
|
||||
id: connectorInput.id,
|
||||
label: connectorInput.label ?? '',
|
||||
color: connectorInput.color ?? DEFAULT_COLOR,
|
||||
from: connectorInput.from,
|
||||
to: connectorInput.to
|
||||
anchors,
|
||||
path: getConnectorPath({ anchors, nodes })
|
||||
};
|
||||
};
|
||||
|
||||
@@ -56,7 +84,7 @@ export const sceneInputtoScene = (sceneInput: SceneInput): Scene => {
|
||||
});
|
||||
|
||||
const connectors = sceneInput.connectors.map((connectorInput) => {
|
||||
return connectorInputToConnector(connectorInput);
|
||||
return connectorInputToConnector(connectorInput, nodes);
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -79,15 +107,38 @@ export const nodeToNodeInput = (node: Node): NodeInput => {
|
||||
};
|
||||
};
|
||||
|
||||
export const connectorAnchorToConnectorAnchorInput = (
|
||||
anchor: ConnectorAnchor
|
||||
): ConnectorAnchorInput | null => {
|
||||
switch (anchor.type) {
|
||||
case 'NODE':
|
||||
return {
|
||||
nodeId: anchor.id
|
||||
};
|
||||
case 'TILE':
|
||||
return {
|
||||
tile: anchor.coords
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const connectorToConnectorInput = (
|
||||
connector: Connector
|
||||
): ConnectorInput => {
|
||||
const anchors = connector.anchors
|
||||
.map((anchor) => {
|
||||
return connectorAnchorToConnectorAnchorInput(anchor);
|
||||
})
|
||||
.filter((anchor): anchor is ConnectorAnchorInput => {
|
||||
return !!anchor;
|
||||
});
|
||||
|
||||
return {
|
||||
id: connector.id,
|
||||
label: connector.label,
|
||||
from: connector.from,
|
||||
to: connector.to,
|
||||
color: connector.color
|
||||
color: connector.color,
|
||||
anchors
|
||||
};
|
||||
};
|
||||
|
||||
@@ -102,7 +153,8 @@ export const groupToGroupInput = (group: Group): GroupInput => {
|
||||
export const sceneToSceneInput = (scene: Scene): SceneInput => {
|
||||
const nodes: NodeInput[] = scene.nodes.map(nodeInputToNode);
|
||||
const connectors: ConnectorInput[] = scene.connectors.map(
|
||||
connectorToConnectorInput
|
||||
connectorToConnectorInput,
|
||||
nodes
|
||||
);
|
||||
const groups: GroupInput[] = scene.groups.map(groupToGroupInput);
|
||||
|
||||
|
||||
@@ -3,10 +3,24 @@ import {
|
||||
UNPROJECTED_TILE_SIZE,
|
||||
ZOOM_INCREMENT,
|
||||
MAX_ZOOM,
|
||||
MIN_ZOOM
|
||||
MIN_ZOOM,
|
||||
CONNECTOR_DEFAULTS
|
||||
} from 'src/config';
|
||||
import { Coords, TileOriginEnum, Node, Size, Scroll, Mouse } from 'src/types';
|
||||
import { CoordsUtils, clamp, roundToOneDecimalPlace } from 'src/utils';
|
||||
import {
|
||||
Coords,
|
||||
TileOriginEnum,
|
||||
Node,
|
||||
Size,
|
||||
Scroll,
|
||||
Mouse,
|
||||
ConnectorAnchor
|
||||
} from 'src/types';
|
||||
import {
|
||||
CoordsUtils,
|
||||
clamp,
|
||||
roundToOneDecimalPlace,
|
||||
findPath
|
||||
} from 'src/utils';
|
||||
|
||||
interface GetProjectedTileSize {
|
||||
zoom: number;
|
||||
@@ -272,3 +286,93 @@ export const getMouse = ({
|
||||
|
||||
return nextMouse;
|
||||
};
|
||||
|
||||
export function getItemById<T extends { id: string }>(
|
||||
items: T[],
|
||||
id: string
|
||||
): { item: T; index: number } {
|
||||
const index = items.findIndex((item) => {
|
||||
return item.id === id;
|
||||
});
|
||||
|
||||
if (index === -1) {
|
||||
throw new Error(`Item with id ${id} not found.`);
|
||||
}
|
||||
|
||||
return { item: items[index], index };
|
||||
}
|
||||
|
||||
interface GetAnchorPositions {
|
||||
anchor: ConnectorAnchor;
|
||||
nodes: Node[];
|
||||
}
|
||||
|
||||
export const getAnchorPosition = ({
|
||||
anchor,
|
||||
nodes
|
||||
}: GetAnchorPositions): Coords => {
|
||||
if (anchor.type === 'NODE') {
|
||||
const { item: node } = getItemById(nodes, anchor.id);
|
||||
return node.position;
|
||||
}
|
||||
|
||||
return anchor.coords;
|
||||
};
|
||||
|
||||
interface NormalisePositionFromOrigin {
|
||||
position: Coords;
|
||||
origin: Coords;
|
||||
}
|
||||
|
||||
export const normalisePositionFromOrigin = ({
|
||||
position,
|
||||
origin
|
||||
}: NormalisePositionFromOrigin) => {
|
||||
return CoordsUtils.subtract(origin, position);
|
||||
};
|
||||
|
||||
interface GetConnectorPath {
|
||||
anchors: ConnectorAnchor[];
|
||||
nodes: Node[];
|
||||
}
|
||||
|
||||
export const getConnectorPath = ({
|
||||
anchors,
|
||||
nodes
|
||||
}: GetConnectorPath): { tiles: Coords[]; origin: Coords; areaSize: Size } => {
|
||||
if (anchors.length < 2)
|
||||
throw new Error(
|
||||
`Connector needs at least two anchors (receieved: ${anchors.length})`
|
||||
);
|
||||
|
||||
const anchorPositions = anchors.map((anchor) => {
|
||||
return getAnchorPosition({ anchor, nodes });
|
||||
});
|
||||
const searchArea = getBoundingBox(
|
||||
anchorPositions,
|
||||
CONNECTOR_DEFAULTS.searchOffset
|
||||
);
|
||||
const searchAreaSize = getBoundingBoxSize(searchArea);
|
||||
const sorted = sortByPosition(searchArea);
|
||||
const origin = { x: sorted.highX, y: sorted.highY };
|
||||
const positionsNormalisedFromSearchArea = anchorPositions.map((position) => {
|
||||
return normalisePositionFromOrigin({ position, origin });
|
||||
});
|
||||
const tiles = positionsNormalisedFromSearchArea.reduce<Coords[]>(
|
||||
(acc, position, i) => {
|
||||
if (i === 0) return [position];
|
||||
|
||||
const prev = positionsNormalisedFromSearchArea[i - 1];
|
||||
const path = findPath({
|
||||
from: prev,
|
||||
to: position,
|
||||
gridSize: searchAreaSize
|
||||
});
|
||||
|
||||
return [...acc, ...path];
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return { tiles, origin, areaSize: searchAreaSize };
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import z from 'zod';
|
||||
|
||||
const coords = z.object({
|
||||
x: z.number(),
|
||||
y: z.number()
|
||||
});
|
||||
|
||||
export const iconInput = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
@@ -13,18 +18,29 @@ export const nodeInput = z.object({
|
||||
labelHeight: z.number().optional(),
|
||||
color: z.string().optional(),
|
||||
iconId: z.string(),
|
||||
position: z.object({
|
||||
x: z.number(),
|
||||
y: z.number()
|
||||
})
|
||||
position: coords
|
||||
});
|
||||
|
||||
export const connectorAnchorInput = z
|
||||
.object({
|
||||
nodeId: z.string(),
|
||||
tile: coords
|
||||
})
|
||||
.partial()
|
||||
.superRefine((data, ctx) => {
|
||||
if (!data.nodeId && !data.tile) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['connectorAnchor'],
|
||||
message: 'Connector anchor needs either a nodeId or tile coords.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const connectorInput = z.object({
|
||||
id: z.string(),
|
||||
label: z.string().nullable(),
|
||||
color: z.string().optional(),
|
||||
from: z.string(),
|
||||
to: z.string()
|
||||
anchors: z.array(connectorAnchorInput)
|
||||
});
|
||||
|
||||
export const groupInput = z.object({
|
||||
|
||||
@@ -36,9 +36,7 @@ describe('scene validation works correctly', () => {
|
||||
const { nodes } = scene;
|
||||
const invalidConnector = {
|
||||
id: 'invalidConnector',
|
||||
from: 'node1',
|
||||
to: 'invalidNode',
|
||||
label: null
|
||||
anchors: [{ nodeId: 'node1' }, { nodeId: 'invalidNode' }]
|
||||
};
|
||||
const connectors: ConnectorInput[] = [
|
||||
...scene.connectors,
|
||||
|
||||
@@ -19,14 +19,20 @@ export const findInvalidConnector = (
|
||||
nodes: NodeInput[]
|
||||
) => {
|
||||
return connectors.find((con) => {
|
||||
const fromNode = nodes.find((node) => {
|
||||
return con.from === node.id;
|
||||
});
|
||||
const toNode = nodes.find((node) => {
|
||||
return con.to === node.id;
|
||||
const invalidAnchor = con.anchors.find((anchor) => {
|
||||
if (
|
||||
anchor.nodeId &&
|
||||
!nodes.find((node) => {
|
||||
return node.id === anchor.nodeId;
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return Boolean(!fromNode || !toNode);
|
||||
return Boolean(invalidAnchor);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user