feat: refactors schema to accomodate model

This commit is contained in:
Mark Mankarious
2023-10-27 15:55:20 +01:00
parent a38afa93a8
commit ad5a4e06f3
107 changed files with 3166 additions and 2816 deletions

View File

@@ -41,7 +41,7 @@
- [x] Nodes, connectors & groups
- [x] Import / export diagrams to local storage (in JSON format)
- [x] Icon support for AWS, GCP, Azure, K8S & generic network hardware (e.g. server, database)
- [x] onSceneUpdated callback
- [x] onModelUpdated callback
- [x] Documentation
- [x] Pipeline for publishing to NPM

View File

@@ -1,4 +1,4 @@
{
"index": "Props",
"initialScene": "initialScene"
"InitialData": "InitialData"
}

View File

@@ -2,10 +2,10 @@
| Name | Type | Description | Default |
| --- | --- | --- | --- |
| `initialScene` | [`object`](/docs/api/initialScene) | The initial scene that Isoflow should render. If `undefined`, isoflow loads a blank scene. | `undefined` |
| `InitialData` | [`object`](/docs/api/InitialData) | The initial Model that Isoflow should render. If `undefined`, isoflow loads a blank Model. | `undefined` |
| `width` | `number` \| `string` | Width of the Isoflow renderer as a CSS value. | `100%` |
| `height` | `number` \| `string` | Height of the Isoflow renderer as a CSS value. | `100%` |
| `onSceneUpdate` | `function` | A callback that is triggered whenever an item is added, updated or removed from the scene. The callback is called with the updated scene as the first argument. | `undefined` |
| `onModelUpdate` | `function` | A callback that is triggered whenever an item is added, updated or removed from the Model. The callback is called with the updated Model as the first argument. | `undefined` |
| `enableDebugTools` | `boolean` | Enables extra tools for debugging purposes. | `false` |
| `editorMode` | `"EXPLORABLE_READONLY"` \| `"NON_INTERACTIVE"` \| `"EDITABLE"` | Enables / disables editor features. | `"EDITABLE"` |
| `mainMenuOptions` | `("ACTION.OPEN" \| "EXPORT.JSON" \| "EXPORT.PNG" \| "ACTION.CLEAR_CANVAS" \| "LINK.GITHUB" \| "LINK.DISCORD" \| "VERSION")[]` | Shows / hides options in the main menu. If `[]` is passed, the menu is hidden. | All enabled |

View File

@@ -1,6 +1,6 @@
# `initialScene`
# `InitialData`
The `initialScene` object contains the following properties:
The `InitialData` object contains the following properties:
| Name | Type | Default |
| --- | --- | --- |
@@ -120,11 +120,11 @@ ref:
}
```
## `initialScene` example
A demo scene is hosted here on [CodeSandbox](https://codesandbox.io/p/sandbox/github/markmanx/isoflow/tree/main).
## `InitialData` example
A demo Model is hosted here on [CodeSandbox](https://codesandbox.io/p/sandbox/github/markmanx/isoflow/tree/main).
## Validation
`initialScene` is validated before Isoflow renders the scene, and an error is thrown if the data is invalid.
`InitialData` is validated before Isoflow renders the Model, and an error is thrown if the data is invalid.
Examples of common errors are as follows:
- A `ConnectorAnchor` references a `Node` that does not exist.

View File

@@ -45,7 +45,7 @@ const icons = flattenCollections([
const App = () => {
return (
<Isoflow initialScene={{ icons }} />
<Isoflow InitialData={{ icons }} />
);
}

View File

@@ -4,44 +4,47 @@ import { ThemeProvider } from '@mui/material/styles';
import { Box } from '@mui/material';
import { theme } from 'src/styles/theme';
import {
SceneInput,
IconInput,
NodeInput,
ConnectorInput,
RectangleInput,
Model,
ModelItem,
Icon,
Connector,
Rectangle,
IsoflowProps,
InitialScene
InitialData
} from 'src/types';
import { sceneToSceneInput, setWindowCursor, CoordsUtils } from 'src/utils';
import { useSceneStore, SceneProvider } from 'src/stores/sceneStore';
import { setWindowCursor, generateId } from 'src/utils';
import { modelSchema } from 'src/validation/model';
import { useModelStore, ModelProvider } from 'src/stores/modelStore';
import { SceneProvider } from 'src/stores/sceneStore';
import { GlobalStyles } from 'src/styles/GlobalStyles';
import { Renderer } from 'src/components/Renderer/Renderer';
import { useWindowUtils } from 'src/hooks/useWindowUtils';
import { sceneInput as sceneValidationSchema } from 'src/validation/scene';
import { UiOverlay } from 'src/components/UiOverlay/UiOverlay';
import { UiStateProvider, useUiStateStore } from 'src/stores/uiStateStore';
import { INITIAL_SCENE, MAIN_MENU_OPTIONS } from 'src/config';
import {
INITIAL_DATA,
MAIN_MENU_OPTIONS,
INITIAL_UI_STATE,
VIEW_DEFAULTS
} from 'src/config';
import { createView } from 'src/stores/reducers';
import { useIconCategories } from './hooks/useIconCategories';
const App = ({
initialScene,
initialData,
mainMenuOptions = MAIN_MENU_OPTIONS,
width = '100%',
height = '100%',
onSceneUpdated,
onModelUpdated,
enableDebugTools = false,
editorMode = 'EDITABLE'
}: IsoflowProps) => {
useWindowUtils();
const prevInitialScene = useRef<SceneInput>(INITIAL_SCENE);
const prevInitialData = useRef<Model>(INITIAL_DATA);
const [isReady, setIsReady] = useState(false);
const scene = useSceneStore(
({ title, nodes, connectors, textBoxes, rectangles, icons }) => {
return { title, nodes, connectors, textBoxes, rectangles, icons };
},
shallow
);
const sceneActions = useSceneStore((state) => {
const model = useModelStore((state) => {
return state;
}, shallow);
const modelActions = useModelStore((state) => {
return state.actions;
});
const uiActions = useUiStateStore((state) => {
@@ -50,18 +53,24 @@ const App = ({
const { setIconCategoriesState } = useIconCategories();
useEffect(() => {
uiActions.setZoom(initialScene?.zoom ?? 1);
uiActions.setScroll({
position: initialScene?.scroll ?? CoordsUtils.zero(),
offset: CoordsUtils.zero()
});
if (initialData?.zoom) {
uiActions.setZoom(initialData.zoom);
}
if (initialData?.scroll) {
uiActions.setScroll({
position: initialData.scroll,
offset: INITIAL_UI_STATE.scroll.offset
});
}
uiActions.setEditorMode(editorMode);
uiActions.setMainMenuOptions(mainMenuOptions);
}, [
initialScene?.zoom,
initialScene?.scroll,
initialData?.zoom,
initialData?.scroll,
editorMode,
sceneActions,
modelActions,
uiActions,
mainMenuOptions
]);
@@ -73,31 +82,42 @@ const App = ({
}, []);
useEffect(() => {
if (!initialScene || prevInitialScene.current === initialScene) return;
if (!initialData || prevInitialData.current === initialData) return;
setIsReady(false);
const fullInitialScene = { ...INITIAL_SCENE, ...initialScene };
let fullInitialData = { ...INITIAL_DATA, ...initialData };
prevInitialScene.current = fullInitialScene;
sceneActions.setScene(fullInitialScene);
if (fullInitialData.views.length === 0) {
const newView = {
...VIEW_DEFAULTS,
id: generateId()
};
fullInitialData = createView(newView, fullInitialData);
}
prevInitialData.current = fullInitialData;
modelActions.set(fullInitialData);
uiActions.setView(fullInitialData.views[0].id);
setIsReady(true);
}, [initialScene, sceneActions, uiActions]);
}, [initialData, modelActions, uiActions]);
useEffect(() => {
setIconCategoriesState();
}, [scene.icons, setIconCategoriesState]);
}, [model.icons, setIconCategoriesState]);
useEffect(() => {
if (!isReady || !onSceneUpdated) return;
if (!isReady || !onModelUpdated) return;
const sceneInput = sceneToSceneInput(scene);
onSceneUpdated(sceneInput);
}, [scene, onSceneUpdated, isReady]);
onModelUpdated(model);
}, [model, onModelUpdated, isReady]);
useEffect(() => {
uiActions.setenableDebugTools(enableDebugTools);
}, [enableDebugTools, uiActions]);
if (!isReady) return null;
return (
<>
<GlobalStyles />
@@ -120,11 +140,13 @@ const App = ({
export const Isoflow = (props: IsoflowProps) => {
return (
<ThemeProvider theme={theme}>
<SceneProvider>
<UiStateProvider>
<App {...props} />
</UiStateProvider>
</SceneProvider>
<ModelProvider>
<SceneProvider>
<UiStateProvider>
<App {...props} />
</UiStateProvider>
</SceneProvider>
</ModelProvider>
</ThemeProvider>
);
};
@@ -134,7 +156,7 @@ const useIsoflow = () => {
return state.rendererEl;
});
const sceneActions = useSceneStore((state) => {
const ModelActions = useModelStore((state) => {
return state.actions;
});
@@ -143,7 +165,7 @@ const useIsoflow = () => {
});
return {
scene: sceneActions,
Model: ModelActions,
uiState: uiStateActions,
rendererEl
};
@@ -151,17 +173,17 @@ const useIsoflow = () => {
export {
useIsoflow,
InitialScene,
SceneInput,
IconInput,
NodeInput,
RectangleInput,
ConnectorInput,
sceneValidationSchema,
InitialData,
Model,
Icon,
ModelItem,
Rectangle,
Connector,
modelSchema,
IsoflowProps
};
export const version = PACKAGE_VERSION;
export const initialScene = INITIAL_SCENE;
export const initialData = INITIAL_DATA;
export default Isoflow;

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { Box } from '@mui/material';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { useScene } from 'src/hooks/useScene';
import { LineItem } from './LineItem';
export const DebugUtils = () => {
@@ -11,9 +11,7 @@ export const DebugUtils = () => {
return { scroll, mouse, zoom, mode, rendererEl };
}
);
const scene = useSceneStore((state) => {
return state;
});
const scene = useScene();
const { size: rendererSize } = useResizeObserver(uiState.rendererEl);
return (
@@ -55,7 +53,10 @@ export const DebugUtils = () => {
title="Size"
value={`${rendererSize.width}, ${rendererSize.height}`}
/>
<LineItem title="Scene info" value={`${scene.nodes.length} nodes`} />
<LineItem
title="Scene info"
value={`${scene.items.length} items in scene`}
/>
<LineItem title="Mode" value={uiState.mode.type} />
<LineItem title="Mode data" value={JSON.stringify(uiState.mode)} />
</Box>

View File

@@ -1,19 +1,19 @@
import React, { useMemo } from 'react';
import { Box } from '@mui/material';
import { Coords, TileOriginEnum, IconInput } from 'src/types';
import { Coords } from 'src/types';
import { getTilePosition } from 'src/utils';
import { useIcon } from 'src/hooks/useIcon';
interface Props {
icon: IconInput;
iconId: string;
tile: Coords;
}
export const DragAndDrop = ({ icon, tile }: Props) => {
const { iconComponent } = useIcon(icon.id);
export const DragAndDrop = ({ iconId, tile }: Props) => {
const { iconComponent } = useIcon(iconId);
const tilePosition = useMemo(() => {
return getTilePosition({ tile, origin: TileOriginEnum.BOTTOM });
return getTilePosition({ tile, origin: 'BOTTOM' });
}, [tile]);
return (

View File

@@ -8,9 +8,8 @@ import {
Stack,
Alert
} from '@mui/material';
import { useSceneStore } from 'src/stores/sceneStore';
import { useModelStore } from 'src/stores/modelStore';
import {
sceneToSceneInput,
exportAsImage,
downloadFile as downloadFileUtil,
getTileScrollPosition,
@@ -34,16 +33,18 @@ export const ExportImageDialog = ({ onClose }: Props) => {
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const scene = useSceneStore((state) => {
return {
title: state.title,
nodes: state.nodes,
connectors: state.connectors,
textBoxes: state.textBoxes,
rectangles: state.rectangles,
icons: state.icons
};
});
const model = useModelStore(
({ views, description, version, title, items, icons }) => {
return {
views,
description,
version,
title,
items,
icons
};
}
);
const unprojectedBounds = useMemo(() => {
return getUnprojectedBounds();
}, [getUnprojectedBounds]);
@@ -116,9 +117,9 @@ export const ExportImageDialog = ({ onClose }: Props) => {
>
<Isoflow
editorMode="NON_INTERACTIVE"
onSceneUpdated={exportImage}
initialScene={{
...sceneToSceneInput(scene),
onModelUpdated={exportImage}
initialData={{
...model,
zoom: previewParams.zoom,
scroll: getTileScrollPosition(previewParams.scrollTarget)
}}

View File

@@ -1,6 +1,5 @@
import React, { useCallback } from 'react';
import { Connector, ConnectorStyleEnum } from 'src/types';
import { connectorStyleOptions } from 'src/validation/sceneItems';
import React from 'react';
import { Connector, connectorStyleOptions } from 'src/types';
import {
useTheme,
Box,
@@ -9,10 +8,10 @@ import {
MenuItem,
TextField
} from '@mui/material';
import { useSceneStore } from 'src/stores/sceneStore';
import { useConnector } from 'src/hooks/useConnector';
import { ColorSelector } from 'src/components/ColorSelector/ColorSelector';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useScene } from 'src/hooks/useScene';
import { ControlsContainer } from '../components/ControlsContainer';
import { Section } from '../components/Section';
import { DeleteButton } from '../components/DeleteButton';
@@ -23,34 +22,22 @@ interface Props {
export const ConnectorControls = ({ id }: Props) => {
const theme = useTheme();
const sceneActions = useSceneStore((state) => {
return state.actions;
});
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const connector = useConnector(id);
const onConnectorUpdated = useCallback(
(updates: Partial<Connector>) => {
sceneActions.updateConnector(id, updates);
},
[sceneActions, id]
);
const onConnectorDeleted = useCallback(() => {
uiStateActions.setItemControls(null);
sceneActions.deleteConnector(id);
}, [sceneActions, id, uiStateActions]);
const { updateConnector, deleteConnector } = useScene();
return (
<ControlsContainer>
<Section>
<TextField
label="Label"
value={connector.label}
value={connector.description}
onChange={(e) => {
return onConnectorUpdated({ label: e.target.value as string });
updateConnector(connector.id, {
description: e.target.value as string
});
}}
/>
</Section>
@@ -58,7 +45,7 @@ export const ConnectorControls = ({ id }: Props) => {
<ColorSelector
colors={Object.values(theme.customVars.customPalette)}
onChange={(color) => {
return onConnectorUpdated({ color });
return updateConnector(connector.id, { color });
}}
activeColor={connector.color}
/>
@@ -71,7 +58,7 @@ export const ConnectorControls = ({ id }: Props) => {
max={30}
value={connector.width}
onChange={(e, newWidth) => {
onConnectorUpdated({ width: newWidth as number });
updateConnector(connector.id, { width: newWidth as number });
}}
/>
</Section>
@@ -79,8 +66,8 @@ export const ConnectorControls = ({ id }: Props) => {
<Select
value={connector.style}
onChange={(e) => {
return onConnectorUpdated({
style: e.target.value as ConnectorStyleEnum
updateConnector(connector.id, {
style: e.target.value as Connector['style']
});
}}
>
@@ -91,7 +78,12 @@ export const ConnectorControls = ({ id }: Props) => {
</Section>
<Section>
<Box>
<DeleteButton onClick={onConnectorDeleted} />
<DeleteButton
onClick={() => {
uiStateActions.setItemControls(null);
deleteConnector(connector.id);
}}
/>
</Box>
</Section>
</ControlsContainer>

View File

@@ -21,12 +21,12 @@ export const IconSelectionControls = () => {
const onMouseDown = useCallback(
(icon: Icon) => {
if (mode.type !== 'PLACE_ELEMENT') return;
if (mode.type !== 'PLACE_ICON') return;
uiStateActions.setMode({
type: 'PLACE_ELEMENT',
type: 'PLACE_ICON',
showCursor: true,
icon
id: icon.id
});
},
[mode, uiStateActions]

View File

@@ -14,7 +14,7 @@ export const ItemControlsManager = () => {
const Controls = useMemo(() => {
switch (itemControls?.type) {
case 'NODE':
case 'ITEM':
return <NodeControls key={itemControls.id} id={itemControls.id} />;
case 'CONNECTOR':
return <ConnectorControls key={itemControls.id} id={itemControls.id} />;

View File

@@ -1,15 +1,15 @@
import React, { useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback } from 'react';
import { Box, Stack, Button } from '@mui/material';
import {
ChevronRight as ChevronRightIcon,
ChevronLeft as ChevronLeftIcon
} from '@mui/icons-material';
import { Node } from 'src/types';
import { useSceneStore } from 'src/stores/sceneStore';
import { useNode } from 'src/hooks/useNode';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useIconCategories } from 'src/hooks/useIconCategories';
import { getItemById } from 'src/utils';
import { useIcon } from 'src/hooks/useIcon';
import { useScene } from 'src/hooks/useScene';
import { useViewItem } from 'src/hooks/useViewItem';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useModelItem } from 'src/hooks/useModelItem';
import { ControlsContainer } from '../components/ControlsContainer';
import { Icons } from '../IconSelectionControls/Icons';
import { NodeSettings } from './NodeSettings/NodeSettings';
@@ -19,44 +19,25 @@ interface Props {
id: string;
}
const ModeEnum = {
Settings: 'Settings',
ChangeIcon: 'ChangeIcon'
const ModeOptions = {
SETTINGS: 'SETTINGS',
CHANGE_ICON: 'CHANGE_ICON'
} as const;
type Mode = keyof typeof ModeOptions;
export const NodeControls = ({ id }: Props) => {
const [mode, setMode] = useState<keyof typeof ModeEnum>('Settings');
const { iconCategories } = useIconCategories();
const icons = useSceneStore((state) => {
return state.icons;
});
const sceneActions = useSceneStore((state) => {
return state.actions;
});
const [mode, setMode] = useState<Mode>('SETTINGS');
const { updateModelItem, updateViewItem, deleteViewItem } = useScene();
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const node = useNode(id);
const viewItem = useViewItem(id);
const modelItem = useModelItem(id);
const { iconCategories } = useIconCategories();
const { icon } = useIcon(modelItem.icon);
const iconUrl = useMemo(() => {
const { item: icon } = getItemById(icons, node.icon);
return icon.url;
}, [node.icon, icons]);
const onNodeUpdated = useCallback(
(updates: Partial<Node>) => {
sceneActions.updateNode(id, updates);
},
[sceneActions, id]
);
const onNodeDeleted = useCallback(() => {
uiStateActions.setItemControls(null);
sceneActions.deleteNode(id);
}, [sceneActions, id, uiStateActions]);
const onSwitchMode = useCallback((newMode: keyof typeof ModeEnum) => {
const onSwitchMode = useCallback((newMode: Mode) => {
setMode(newMode);
}, []);
@@ -76,23 +57,27 @@ export const NodeControls = ({ id }: Props) => {
alignItems="flex-end"
justifyContent="space-between"
>
<Box component="img" src={iconUrl} sx={{ width: 70, height: 70 }} />
{mode === 'Settings' && (
<Box
component="img"
src={icon.url}
sx={{ width: 70, height: 70 }}
/>
{mode === 'SETTINGS' && (
<Button
endIcon={<ChevronRightIcon />}
onClick={() => {
onSwitchMode('ChangeIcon');
onSwitchMode('CHANGE_ICON');
}}
variant="text"
>
Update icon
</Button>
)}
{mode === 'ChangeIcon' && (
{mode === 'CHANGE_ICON' && (
<Button
startIcon={<ChevronLeftIcon />}
onClick={() => {
onSwitchMode('Settings');
onSwitchMode('SETTINGS');
}}
variant="text"
>
@@ -102,20 +87,28 @@ export const NodeControls = ({ id }: Props) => {
</Stack>
</Section>
</Box>
{mode === ModeEnum.Settings && (
{mode === 'SETTINGS' && (
<NodeSettings
key={node.id}
node={node}
onUpdate={onNodeUpdated}
onDelete={onNodeDeleted}
key={viewItem.id}
node={viewItem}
onModelItemUpdated={(updates) => {
updateModelItem(viewItem.id, updates);
}}
onViewItemUpdated={(updates) => {
updateViewItem(viewItem.id, updates);
}}
onDeleted={() => {
uiStateActions.setItemControls(null);
deleteViewItem(viewItem.id);
}}
/>
)}
{mode === ModeEnum.ChangeIcon && (
{mode === 'CHANGE_ICON' && (
<Icons
key={node.id}
key={viewItem.id}
iconCategories={iconCategories}
onClick={(icon) => {
onNodeUpdated({ icon: icon.id });
onClick={(_icon) => {
updateModelItem(viewItem.id, { icon: _icon.id });
}}
/>
)}

View File

@@ -1,37 +1,52 @@
import React from 'react';
import { Slider, Box, TextField } from '@mui/material';
import { Node } from 'src/types';
import { ModelItem, ViewItem } from 'src/types';
import { MarkdownEditor } from 'src/components/MarkdownEditor/MarkdownEditor';
import { useModelItem } from 'src/hooks/useModelItem';
import { DeleteButton } from '../../components/DeleteButton';
import { Section } from '../../components/Section';
export type NodeUpdates = {
model: Partial<ModelItem>;
view: Partial<ViewItem>;
};
interface Props {
node: Node;
onUpdate: (updates: Partial<Node>) => void;
onDelete: () => void;
node: ViewItem;
onModelItemUpdated: (updates: Partial<ModelItem>) => void;
onViewItemUpdated: (updates: Partial<ViewItem>) => void;
onDeleted: () => void;
}
export const NodeSettings = ({ node, onUpdate, onDelete }: Props) => {
export const NodeSettings = ({
node,
onModelItemUpdated,
onViewItemUpdated,
onDeleted
}: Props) => {
const modelItem = useModelItem(node.id);
return (
<>
<Section title="Label">
<Section title="Name">
<TextField
value={node.label}
value={modelItem.name}
onChange={(e) => {
const text = e.target.value as string;
if (node.label !== text) onUpdate({ label: text });
if (modelItem.name !== text) onModelItemUpdated({ name: text });
}}
/>
</Section>
<Section title="Description">
<MarkdownEditor
value={node.description}
value={modelItem.description}
onChange={(text) => {
if (node.description !== text) onUpdate({ description: text });
if (modelItem.description !== text)
onModelItemUpdated({ description: text });
}}
/>
</Section>
{node.label && (
{modelItem.name && (
<Section title="Label height">
<Slider
marks
@@ -40,14 +55,15 @@ export const NodeSettings = ({ node, onUpdate, onDelete }: Props) => {
max={280}
value={node.labelHeight}
onChange={(e, newHeight) => {
onUpdate({ labelHeight: newHeight as number });
const labelHeight = newHeight as number;
onViewItemUpdated({ labelHeight });
}}
/>
</Section>
)}
<Section>
<Box>
<DeleteButton onClick={onDelete} />
<DeleteButton onClick={onDeleted} />
</Box>
</Section>
</>

View File

@@ -1,13 +1,11 @@
import React, { useCallback } from 'react';
import { Rectangle } from 'src/types';
import React from 'react';
import { useTheme, Box } from '@mui/material';
import { useSceneStore } from 'src/stores/sceneStore';
import { useRectangle } from 'src/hooks/useRectangle';
import { ColorSelector } from 'src/components/ColorSelector/ColorSelector';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useScene } from 'src/hooks/useScene';
import { ControlsContainer } from '../components/ControlsContainer';
import { Section } from '../components/Section';
import { Header } from '../components/Header';
import { DeleteButton } from '../components/DeleteButton';
interface Props {
@@ -16,25 +14,11 @@ interface Props {
export const RectangleControls = ({ id }: Props) => {
const theme = useTheme();
const sceneActions = useSceneStore((state) => {
return state.actions;
});
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const rectangle = useRectangle(id);
const onRectangleUpdated = useCallback(
(updates: Partial<Rectangle>) => {
sceneActions.updateRectangle(id, updates);
},
[sceneActions, id]
);
const onRectangleDeleted = useCallback(() => {
uiStateActions.setItemControls(null);
sceneActions.deleteRectangle(id);
}, [sceneActions, id, uiStateActions]);
const { updateRectangle, deleteRectangle } = useScene();
return (
<ControlsContainer>
@@ -42,14 +26,19 @@ export const RectangleControls = ({ id }: Props) => {
<ColorSelector
colors={Object.values(theme.customVars.customPalette)}
onChange={(color) => {
return onRectangleUpdated({ color });
updateRectangle(rectangle.id, { color });
}}
activeColor={rectangle.color}
/>
</Section>
<Section>
<Box>
<DeleteButton onClick={onRectangleDeleted} />
<DeleteButton
onClick={() => {
uiStateActions.setItemControls(null);
deleteRectangle(rectangle.id);
}}
/>
</Box>
</Section>
</ControlsContainer>

View File

@@ -1,5 +1,5 @@
import React, { useCallback } from 'react';
import { TextBox, ProjectionOrientationEnum } from 'src/types';
import React from 'react';
import { ProjectionOrientationEnum } from 'src/types';
import {
Box,
TextField,
@@ -8,13 +8,12 @@ import {
Slider
} from '@mui/material';
import { TextRotationNone as TextRotationNoneIcon } from '@mui/icons-material';
import { useSceneStore } from 'src/stores/sceneStore';
import { useTextBox } from 'src/hooks/useTextBox';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { getIsoProjectionCss } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
import { ControlsContainer } from '../components/ControlsContainer';
import { Section } from '../components/Section';
import { Header } from '../components/Header';
import { DeleteButton } from '../components/DeleteButton';
interface Props {
@@ -22,33 +21,19 @@ interface Props {
}
export const TextBoxControls = ({ id }: Props) => {
const sceneActions = useSceneStore((state) => {
return state.actions;
});
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const textBox = useTextBox(id);
const onTextBoxUpdated = useCallback(
(updates: Partial<TextBox>) => {
sceneActions.updateTextBox(id, updates);
},
[sceneActions, id]
);
const onTextBoxDeleted = useCallback(() => {
uiStateActions.setItemControls(null);
sceneActions.deleteTextBox(id);
}, [sceneActions, id, uiStateActions]);
const { updateTextBox, deleteTextBox } = useScene();
return (
<ControlsContainer>
<Section>
<TextField
value={textBox.text}
value={textBox.content}
onChange={(e) => {
onTextBoxUpdated({ text: e.target.value as string });
updateTextBox(textBox.id, { content: e.target.value as string });
}}
/>
</Section>
@@ -60,7 +45,7 @@ export const TextBoxControls = ({ id }: Props) => {
max={0.9}
value={textBox.fontSize}
onChange={(e, newSize) => {
onTextBoxUpdated({ fontSize: newSize as number });
updateTextBox(textBox.id, { fontSize: newSize as number });
}}
/>
</Section>
@@ -72,7 +57,7 @@ export const TextBoxControls = ({ id }: Props) => {
if (textBox.orientation === orientation || orientation === null)
return;
onTextBoxUpdated({ orientation });
updateTextBox(textBox.id, { orientation });
}}
>
<ToggleButton value={ProjectionOrientationEnum.X}>
@@ -89,7 +74,12 @@ export const TextBoxControls = ({ id }: Props) => {
</Section>
<Section>
<Box>
<DeleteButton onClick={onTextBoxDeleted} />
<DeleteButton
onClick={() => {
uiStateActions.setItemControls(null);
deleteTextBox(textBox.id);
}}
/>
</Box>
</Section>
</ControlsContainer>

View File

@@ -9,12 +9,13 @@ import {
FolderOpen as FolderOpenIcon,
DeleteOutline as DeleteOutlineIcon
} from '@mui/icons-material';
import { Model } from 'src/types/model';
import { UiElement } from 'src/components/UiElement/UiElement';
import { IconButton } from 'src/components/IconButton/IconButton';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { useModelStore } from 'src/stores/modelStore';
import { exportAsJSON } from 'src/utils';
import { INITIAL_SCENE } from 'src/config';
import { INITIAL_DATA } from 'src/config';
import { MenuItem } from './MenuItem';
export const MainMenu = () => {
@@ -25,17 +26,10 @@ export const MainMenu = () => {
const mainMenuOptions = useUiStateStore((state) => {
return state.mainMenuOptions;
});
const scene = useSceneStore((state) => {
return {
title: state.title,
icons: state.icons,
nodes: state.nodes,
connectors: state.connectors,
textBoxes: state.textBoxes,
rectangles: state.rectangles
};
const model = useModelStore((state) => {
return state;
});
const sceneActions = useSceneStore((state) => {
const modelActions = useModelStore((state) => {
return state.actions;
});
const uiStateActions = useUiStateStore((state) => {
@@ -54,7 +48,7 @@ export const MainMenu = () => {
window.open(url, '_blank');
}, []);
const onOpenScene = useCallback(async () => {
const onOpenModel = useCallback(async () => {
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'application/json';
@@ -69,8 +63,8 @@ export const MainMenu = () => {
const fileReader = new FileReader();
fileReader.onload = async (e) => {
const sceneInput = JSON.parse(e.target?.result as string);
sceneActions.setScene(sceneInput);
const loadedModel = JSON.parse(e.target?.result as string);
modelActions.set(loadedModel);
};
fileReader.readAsText(file);
@@ -79,12 +73,21 @@ export const MainMenu = () => {
await fileInput.click();
uiStateActions.setIsMainMenuOpen(false);
}, [uiStateActions, sceneActions]);
}, [uiStateActions, modelActions]);
const onExportAsJSON = useCallback(async () => {
exportAsJSON(scene);
const payload: Model = {
icons: model.icons,
items: model.items,
title: model.title,
version: model.version,
views: model.views,
description: model.description
};
exportAsJSON(payload);
uiStateActions.setIsMainMenuOpen(false);
}, [scene, uiStateActions]);
}, [model, uiStateActions]);
const onExportAsImage = useCallback(() => {
uiStateActions.setIsMainMenuOpen(false);
@@ -92,10 +95,10 @@ export const MainMenu = () => {
}, [uiStateActions]);
const onClearCanvas = useCallback(() => {
sceneActions.setScene({ ...INITIAL_SCENE, icons: scene.icons });
modelActions.set({ ...INITIAL_DATA, icons: model.icons });
uiStateActions.resetUiState();
uiStateActions.setIsMainMenuOpen(false);
}, [sceneActions, uiStateActions, scene.icons]);
}, [modelActions, uiStateActions, model.icons]);
const sectionVisibility = useMemo(() => {
return {
@@ -140,7 +143,7 @@ export const MainMenu = () => {
>
<Card sx={{ py: 1 }}>
{mainMenuOptions.includes('ACTION.OPEN') && (
<MenuItem onClick={onOpenScene} Icon={<FolderOpenIcon />}>
<MenuItem onClick={onOpenModel} Icon={<FolderOpenIcon />}>
Open
</MenuItem>
)}

View File

@@ -12,6 +12,7 @@ import { TextBoxes } from 'src/components/SceneLayers/TextBoxes/TextBoxes';
import { SizeIndicator } from 'src/components/DebugUtils/SizeIndicator';
import { SceneLayer } from 'src/components/SceneLayer/SceneLayer';
import { TransformControlsManager } from 'src/components/TransformControlsManager/TransformControlsManager';
import { useScene } from 'src/hooks/useScene';
export const Renderer = () => {
const containerRef = useRef<HTMLDivElement>();
@@ -25,7 +26,8 @@ export const Renderer = () => {
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const { setElement: setInteractionsElement } = useInteractionManager();
const { setInteractionsElement } = useInteractionManager();
const { items, rectangles, connectors, textBoxes } = useScene();
useEffect(() => {
if (!containerRef.current || !interactionsRef.current) return;
@@ -50,7 +52,7 @@ export const Renderer = () => {
}}
>
<SceneLayer>
<Rectangles />
<Rectangles rectangles={rectangles} />
</SceneLayer>
<Box
sx={{
@@ -69,22 +71,19 @@ export const Renderer = () => {
</SceneLayer>
)}
<SceneLayer>
<Connectors />
<Connectors connectors={connectors} />
</SceneLayer>
<SceneLayer>
<TextBoxes />
<TextBoxes textBoxes={textBoxes} />
</SceneLayer>
<SceneLayer>
<ConnectorLabels />
<ConnectorLabels connectors={connectors} />
</SceneLayer>
{enableDebugTools && (
<SceneLayer>
<SizeIndicator />
</SceneLayer>
)}
<SceneLayer>
<TransformControlsManager />
</SceneLayer>
{/* Interaction layer: this is where events are detected */}
<Box
ref={interactionsRef}
@@ -97,7 +96,10 @@ export const Renderer = () => {
}}
/>
<SceneLayer>
<Nodes />
<Nodes nodes={items} />
</SceneLayer>
<SceneLayer>
<TransformControlsManager />
</SceneLayer>
</Box>
);

View File

@@ -1,12 +1,12 @@
import React, { useMemo } from 'react';
import { Box, Typography } from '@mui/material';
import { Connector } from 'src/types';
import { useScene } from 'src/hooks/useScene';
import { connectorPathTileToGlobal, getTilePosition } from 'src/utils';
import { PROJECTED_TILE_SIZE } from 'src/config';
import { useUiStateStore } from 'src/stores/uiStateStore';
interface Props {
connector: Connector;
connector: ReturnType<typeof useScene>['connectors'][0];
}
export const ConnectorLabel = ({ connector }: Props) => {
@@ -43,7 +43,7 @@ export const ConnectorLabel = ({ connector }: Props) => {
}}
>
<Typography color="text.secondary" variant="body2">
{connector.label}
{connector.description}
</Typography>
</Box>
);

View File

@@ -1,17 +1,17 @@
import React from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { useScene } from 'src/hooks/useScene';
import { ConnectorLabel } from './ConnectorLabel';
export const ConnectorLabels = () => {
const connectors = useSceneStore((state) => {
return state.connectors;
});
interface Props {
connectors: ReturnType<typeof useScene>['connectors'];
}
export const ConnectorLabels = ({ connectors }: Props) => {
return (
<>
{connectors
.filter((con) => {
return con.label !== undefined;
return con.description !== undefined;
})
.map((connector) => {
return <ConnectorLabel key={connector.id} connector={connector} />;

View File

@@ -1,33 +1,24 @@
import React, { useMemo } from 'react';
import { useTheme, Box } from '@mui/material';
import { Connector as ConnectorI } from 'src/types';
import { UNPROJECTED_TILE_SIZE } from 'src/config';
import {
getAnchorTile,
CoordsUtils,
getColorVariant,
getAllAnchors
} from 'src/utils';
import { getAnchorTile, CoordsUtils, getColorVariant } from 'src/utils';
import { Circle } from 'src/components/Circle/Circle';
import { useSceneStore } from 'src/stores/sceneStore';
import { Svg } from 'src/components/Svg/Svg';
import { useIsoProjection } from 'src/hooks/useIsoProjection';
import { useConnector } from 'src/hooks/useConnector';
import { useScene } from 'src/hooks/useScene';
interface Props {
connector: ConnectorI;
connector: ReturnType<typeof useScene>['connectors'][0];
}
export const Connector = ({ connector }: Props) => {
export const Connector = ({ connector: _connector }: Props) => {
const theme = useTheme();
const { currentView } = useScene();
const connector = useConnector(_connector.id);
const { css, pxSize } = useIsoProjection({
...connector.path.rectangle
});
const nodes = useSceneStore((state) => {
return state.nodes;
});
const connectors = useSceneStore((state) => {
return state.connectors;
});
const drawOffset = useMemo(() => {
return {
@@ -46,7 +37,7 @@ export const Connector = ({ connector }: Props) => {
const anchorPositions = useMemo(() => {
return connector.anchors.map((anchor) => {
const position = getAnchorTile(anchor, nodes, getAllAnchors(connectors));
const position = getAnchorTile(anchor, currentView);
return {
id: anchor.id,
@@ -57,7 +48,7 @@ export const Connector = ({ connector }: Props) => {
(connector.path.rectangle.from.y - position.y) * UNPROJECTED_TILE_SIZE
};
});
}, [connector.path.rectangle, connector.anchors, nodes, connectors]);
}, [currentView, connector.path.rectangle, connector.anchors]);
const connectorWidthPx = useMemo(() => {
return (UNPROJECTED_TILE_SIZE / 100) * connector.width;

View File

@@ -1,24 +1,17 @@
import React from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useScene } from 'src/hooks/useScene';
import { Connector } from './Connector';
export const Connectors = () => {
const connectors = useSceneStore((state) => {
return state.connectors;
});
const mode = useUiStateStore((state) => {
return state.mode;
});
interface Props {
connectors: ReturnType<typeof useScene>['connectors'];
}
export const Connectors = ({ connectors }: Props) => {
return (
<>
{connectors.map((connector) => {
return <Connector key={connector.id} connector={connector} />;
})}
{mode.type === 'CONNECTOR' && mode.connector && (
<Connector connector={mode.connector} />
)}
</>
);
};

View File

@@ -1,34 +1,39 @@
import React, { useMemo } from 'react';
import { Box, Typography, useTheme } from '@mui/material';
import { Node as NodeI, TileOriginEnum } from 'src/types';
import { PROJECTED_TILE_SIZE } from 'src/config';
import { PROJECTED_TILE_SIZE, DEFAULT_LABEL_HEIGHT } from 'src/config';
import { getTilePosition } from 'src/utils';
import { useIcon } from 'src/hooks/useIcon';
import { ViewItem } from 'src/types';
import { MarkdownEditor } from 'src/components/MarkdownEditor/MarkdownEditor';
import { useModelItem } from 'src/hooks/useModelItem';
import { LabelContainer } from './LabelContainer/LabelContainer';
interface Props {
node: NodeI;
node: ViewItem;
order: number;
}
export const Node = ({ node, order }: Props) => {
const theme = useTheme();
const { iconComponent } = useIcon(node.icon);
const modelItem = useModelItem(node.id);
const { iconComponent } = useIcon(modelItem.icon);
const position = useMemo(() => {
return getTilePosition({
tile: node.tile,
origin: TileOriginEnum.BOTTOM
origin: 'BOTTOM'
});
}, [node.tile]);
const description = useMemo(() => {
if (node.description === undefined || node.description === '<p><br></p>')
if (
modelItem.description === undefined ||
modelItem.description === '<p><br></p>'
)
return null;
return node.description;
}, [node.description]);
return modelItem.description;
}, [modelItem.description]);
return (
<Box
@@ -44,7 +49,7 @@ export const Node = ({ node, order }: Props) => {
top: position.y
}}
>
{(node.label || description) && (
{(modelItem.name || description) && (
<>
<Box
style={{
@@ -52,15 +57,18 @@ export const Node = ({ node, order }: Props) => {
top: -PROJECTED_TILE_SIZE.height
}}
/>
<LabelContainer labelHeight={node.labelHeight} connectorDotSize={3}>
{node.label && (
<Typography fontWeight={600}>{node.label}</Typography>
<LabelContainer
labelHeight={node.labelHeight ?? DEFAULT_LABEL_HEIGHT}
connectorDotSize={3}
>
{modelItem.name && (
<Typography fontWeight={600}>{modelItem.name}</Typography>
)}
{description && (
<Box sx={{ pt: 0.2, width: 200 }}>
<MarkdownEditor
readOnly
value={node.description}
value={modelItem.description}
styles={{
color: theme.palette.text.secondary
}}

View File

@@ -1,12 +1,12 @@
import React from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { ViewItem } from 'src/types';
import { Node } from './Node/Node';
export const Nodes = () => {
const nodes = useSceneStore((state) => {
return state.nodes;
});
interface Props {
nodes: ViewItem[];
}
export const Nodes = ({ nodes }: Props) => {
return (
<>
{nodes.map((node) => {

View File

@@ -1,15 +1,12 @@
import React from 'react';
import { Coords } from 'src/types';
import { Rectangle as RectangleI } from 'src/types';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
import { getColorVariant } from 'src/utils';
import { DEFAULT_COLOR } from 'src/config';
interface Props {
from: Coords;
to: Coords;
color: string;
}
type Props = RectangleI;
export const Rectangle = ({ from, to, color }: Props) => {
export const Rectangle = ({ from, to, color = DEFAULT_COLOR }: Props) => {
return (
<IsoTileArea
from={from}

View File

@@ -1,29 +1,17 @@
import React from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { DEFAULT_COLOR } from 'src/config';
import { Rectangle as RectangleI } from 'src/types';
import { Rectangle } from './Rectangle';
export const Rectangles = () => {
const rectangles = useSceneStore((state) => {
return state.rectangles;
});
const mode = useUiStateStore((state) => {
return state.mode;
});
interface Props {
rectangles: RectangleI[];
}
export const Rectangles = ({ rectangles }: Props) => {
return (
<>
{rectangles.map((rectangle) => {
return <Rectangle key={rectangle.id} {...rectangle} />;
})}
{mode.type === 'RECTANGLE.DRAW' && mode.area && (
<Rectangle
from={mode.area.from}
to={mode.area.to}
color={DEFAULT_COLOR}
/>
)}
</>
);
};

View File

@@ -1,12 +1,12 @@
import React, { useMemo } from 'react';
import { Box, Typography } from '@mui/material';
import { toPx, CoordsUtils } from 'src/utils';
import { TextBox as TextBoxI } from 'src/types';
import { useIsoProjection } from 'src/hooks/useIsoProjection';
import { useTextBoxProps } from 'src/hooks/useTextBoxProps';
import { useScene } from 'src/hooks/useScene';
interface Props {
textBox: TextBoxI;
textBox: ReturnType<typeof useScene>['textBoxes'][0];
}
export const TextBox = ({ textBox }: Props) => {
@@ -44,7 +44,7 @@ export const TextBox = ({ textBox }: Props) => {
...fontProps
}}
>
{textBox.text}
{textBox.content}
</Typography>
</Box>
</Box>

View File

@@ -1,35 +1,17 @@
import React from 'react';
import { textBoxInputToTextBox } from 'src/utils';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { TEXTBOX_DEFAULTS } from 'src/config';
import { useScene } from 'src/hooks/useScene';
import { TextBox } from './TextBox';
export const TextBoxes = () => {
const mode = useUiStateStore((state) => {
return state.mode;
});
const mouse = useUiStateStore((state) => {
return state.mouse;
});
const textBoxes = useSceneStore((state) => {
return state.textBoxes;
});
interface Props {
textBoxes: ReturnType<typeof useScene>['textBoxes'];
}
export const TextBoxes = ({ textBoxes }: Props) => {
return (
<>
{textBoxes.map((textBox) => {
return <TextBox key={textBox.id} textBox={textBox} />;
})}
{mode.type === 'TEXTBOX' && (
<TextBox
textBox={textBoxInputToTextBox({
id: 'temp-textbox',
...TEXTBOX_DEFAULTS,
tile: mouse.position.tile
})}
/>
)}
</>
);
};

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { Stack } from '@mui/material';
import {
PanToolOutlined as PanToolIcon,
@@ -11,14 +11,37 @@ import {
import { useUiStateStore } from 'src/stores/uiStateStore';
import { IconButton } from 'src/components/IconButton/IconButton';
import { UiElement } from 'src/components/UiElement/UiElement';
import { useScene } from 'src/hooks/useScene';
import { TEXTBOX_DEFAULTS } from 'src/config';
import { generateId } from 'src/utils';
export const ToolMenu = () => {
const { createTextBox } = useScene();
const mode = useUiStateStore((state) => {
return state.mode;
});
const uiStateStoreActions = useUiStateStore((state) => {
return state.actions;
});
const mousePosition = useUiStateStore((state) => {
return state.mouse.position.tile;
});
const createTextBoxProxy = useCallback(() => {
const textBoxId = generateId();
createTextBox({
...TEXTBOX_DEFAULTS,
id: textBoxId,
tile: mousePosition
});
uiStateStoreActions.setMode({
type: 'TEXTBOX',
showCursor: false,
id: textBoxId
});
}, [uiStateStoreActions, createTextBox, mousePosition]);
return (
<UiElement>
@@ -54,12 +77,12 @@ export const ToolMenu = () => {
type: 'ADD_ITEM'
});
uiStateStoreActions.setMode({
type: 'PLACE_ELEMENT',
type: 'PLACE_ICON',
showCursor: true,
icon: null
id: null
});
}}
isActive={mode.type === 'PLACE_ELEMENT'}
isActive={mode.type === 'PLACE_ICON'}
/>
<IconButton
name="Rectangle"
@@ -68,7 +91,7 @@ export const ToolMenu = () => {
uiStateStoreActions.setMode({
type: 'RECTANGLE.DRAW',
showCursor: true,
area: null
id: null
});
}}
isActive={mode.type === 'RECTANGLE.DRAW'}
@@ -79,7 +102,7 @@ export const ToolMenu = () => {
onClick={() => {
uiStateStoreActions.setMode({
type: 'CONNECTOR',
connector: null,
id: null,
showCursor: true
});
}}
@@ -88,12 +111,7 @@ export const ToolMenu = () => {
<IconButton
name="Text"
Icon={<TitleIcon />}
onClick={() => {
uiStateStoreActions.setMode({
type: 'TEXTBOX',
showCursor: false
});
}}
onClick={createTextBoxProxy}
isActive={mode.type === 'TEXTBOX'}
/>
</Stack>

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { useNode } from 'src/hooks/useNode';
import { useViewItem } from 'src/hooks/useViewItem';
import { TransformControls } from './TransformControls';
interface Props {
@@ -7,7 +7,7 @@ interface Props {
}
export const NodeTransformControls = ({ id }: Props) => {
const node = useNode(id);
const node = useViewItem(id);
return <TransformControls from={node.tile} to={node.tile} />;
};

View File

@@ -1,5 +1,7 @@
import React from 'react';
import React, { useCallback } from 'react';
import { useRectangle } from 'src/hooks/useRectangle';
import { AnchorPosition } from 'src/types';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { TransformControls } from './TransformControls';
interface Props {
@@ -8,12 +10,27 @@ interface Props {
export const RectangleTransformControls = ({ id }: Props) => {
const rectangle = useRectangle(id);
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const onAnchorMouseDown = useCallback(
(key: AnchorPosition) => {
uiStateActions.setMode({
type: 'RECTANGLE.TRANSFORM',
id: rectangle.id,
selectedAnchor: key,
showCursor: false
});
},
[rectangle.id, uiStateActions]
);
return (
<TransformControls
showCornerAnchors
from={rectangle.from}
to={rectangle.to}
onAnchorMouseDown={onAnchorMouseDown}
/>
);
};

View File

@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { getTextBoxTo } from 'src/utils';
import { getTextBoxEndTile } from 'src/utils';
import { useTextBox } from 'src/hooks/useTextBox';
import { TransformControls } from './TransformControls';
@@ -11,7 +11,7 @@ export const TextBoxTransformControls = ({ id }: Props) => {
const textBox = useTextBox(id);
const to = useMemo(() => {
return getTextBoxTo(textBox);
return getTextBoxEndTile(textBox, textBox.size);
}, [textBox]);
return <TransformControls from={textBox.tile} to={to} />;

View File

@@ -1,40 +1,89 @@
import React from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { produce } from 'immer';
import { Coords } from 'src/types';
import { useTheme } from '@mui/material';
import { useTheme, Box } from '@mui/material';
import { getIsoProjectionCss } from 'src/utils';
import { Svg } from 'src/components/Svg/Svg';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { TRANSFORM_ANCHOR_SIZE, TRANSFORM_CONTROLS_COLOR } from 'src/config';
interface Props {
position: Coords;
onMouseDown: () => void;
}
const strokeWidth = 2;
export const TransformAnchor = ({ position }: Props) => {
export const TransformAnchor = ({ position, onMouseDown }: Props) => {
const prevIsHoveredStateRef = useRef(false);
const [isHovered, setIsHovered] = useState(false);
const mode = useUiStateStore((state) => {
return state.mode;
});
const uiActions = useUiStateStore((state) => {
return state.actions;
});
const theme = useTheme();
const toggleCursor = useCallback(
(state: boolean) => {
const newMode = produce(mode, (draft) => {
draft.showCursor = state;
});
uiActions.setMode(newMode);
},
[mode, uiActions]
);
useEffect(() => {
if (prevIsHoveredStateRef.current !== isHovered) {
prevIsHoveredStateRef.current = isHovered;
toggleCursor(!isHovered);
}
}, [isHovered, toggleCursor]);
return (
<Svg
style={{
<Box
onMouseOver={() => {
setIsHovered(true);
}}
onMouseOut={() => {
setIsHovered(false);
}}
onMouseDown={onMouseDown}
sx={{
position: 'absolute',
left: position.x - TRANSFORM_ANCHOR_SIZE / 2,
top: position.y - TRANSFORM_ANCHOR_SIZE / 2,
transform: getIsoProjectionCss(),
width: TRANSFORM_ANCHOR_SIZE,
height: TRANSFORM_ANCHOR_SIZE
}}
style={{
left: position.x - TRANSFORM_ANCHOR_SIZE / 2,
top: position.y - TRANSFORM_ANCHOR_SIZE / 2
}}
>
<g transform={`translate(${strokeWidth}, ${strokeWidth})`}>
<rect
fill={theme.palette.common.white}
width={TRANSFORM_ANCHOR_SIZE - strokeWidth * 2}
height={TRANSFORM_ANCHOR_SIZE - strokeWidth * 2}
stroke={TRANSFORM_CONTROLS_COLOR}
strokeWidth={strokeWidth}
rx={3}
/>
</g>
</Svg>
<Svg
style={{
width: TRANSFORM_ANCHOR_SIZE,
height: TRANSFORM_ANCHOR_SIZE
}}
>
<g transform={`translate(${strokeWidth}, ${strokeWidth})`}>
<rect
fill={
isHovered
? theme.palette.primary.dark
: theme.palette.common.white
}
width={TRANSFORM_ANCHOR_SIZE - strokeWidth * 2}
height={TRANSFORM_ANCHOR_SIZE - strokeWidth * 2}
stroke={TRANSFORM_CONTROLS_COLOR}
strokeWidth={strokeWidth}
rx={3}
/>
</g>
</Svg>
</Box>
);
};

View File

@@ -1,55 +1,60 @@
import React, { useMemo } from 'react';
import { Coords } from 'src/types';
import { Coords, AnchorPosition } from 'src/types';
import { Svg } from 'src/components/Svg/Svg';
import { TRANSFORM_CONTROLS_COLOR } from 'src/config';
import { useIsoProjection } from 'src/hooks/useIsoProjection';
import {
getBoundingBox,
outermostCornerPositions,
getTilePosition
getTilePosition,
convertBoundsToNamedAnchors
} from 'src/utils';
import { TransformAnchor } from './TransformAnchor';
interface Props {
from: Coords;
to: Coords;
onMouseOver?: () => void;
showCornerAnchors?: boolean;
onAnchorMouseDown?: (anchorPosition: AnchorPosition) => void;
}
const strokeWidth = 2;
export const TransformControls = ({
from,
to,
onMouseOver,
showCornerAnchors
}: Props) => {
export const TransformControls = ({ from, to, onAnchorMouseDown }: Props) => {
const { css, pxSize } = useIsoProjection({
from,
to
});
const anchorPositions = useMemo<Coords[]>(() => {
if (!showCornerAnchors) return [];
const anchors = useMemo(() => {
if (!onAnchorMouseDown) return [];
const corners = getBoundingBox([from, to]);
const cornerPositions = corners.map((corner, i) => {
return getTilePosition({
tile: corner,
origin: outermostCornerPositions[i]
});
});
const namedCorners = convertBoundsToNamedAnchors(corners);
const cornerPositions = Object.entries(namedCorners).map(
([key, value], i) => {
const position = getTilePosition({
tile: value,
origin: outermostCornerPositions[i]
});
return {
position,
onMouseDown: () => {
onAnchorMouseDown(key as AnchorPosition);
}
};
}
);
return cornerPositions;
}, [showCornerAnchors, from, to]);
}, [onAnchorMouseDown, from, to]);
return (
<>
<Svg
style={css}
onMouseOver={() => {
onMouseOver?.();
style={{
...css,
pointerEvents: 'none'
}}
>
<g transform={`translate(${strokeWidth}, ${strokeWidth})`}>
@@ -60,12 +65,15 @@ export const TransformControls = ({
stroke={TRANSFORM_CONTROLS_COLOR}
strokeDasharray={`${strokeWidth * 2} ${strokeWidth * 2}`}
strokeWidth={strokeWidth}
strokeLinecap="round"
/>
</g>
</Svg>
{anchorPositions.map((position) => {
return <TransformAnchor position={position} />;
{anchors.map(({ position, onMouseDown }) => {
return (
<TransformAnchor position={position} onMouseDown={onMouseDown} />
);
})}
</>
);

View File

@@ -10,7 +10,7 @@ export const TransformControlsManager = () => {
});
switch (itemControls?.type) {
case 'NODE':
case 'ITEM':
return <NodeTransformControls id={itemControls.id} />;
case 'RECTANGLE':
return <RectangleTransformControls id={itemControls.id} />;

View File

@@ -9,7 +9,7 @@ import { ToolMenu } from 'src/components/ToolMenu/ToolMenu';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { MainMenu } from 'src/components/MainMenu/MainMenu';
import { ZoomControls } from 'src/components/ZoomControls/ZoomControls';
import { useSceneStore } from 'src/stores/sceneStore';
import { useModelStore } from 'src/stores/modelStore';
import { DebugUtils } from 'src/components/DebugUtils/DebugUtils';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { ExportImageDialog } from '../ExportImageDialog/ExportImageDialog';
@@ -19,7 +19,7 @@ const ToolsEnum = {
ZOOM_CONTROLS: 'ZOOM_CONTROLS',
TOOL_MENU: 'TOOL_MENU',
ITEM_CONTROLS: 'ITEM_CONTROLS',
SCENE_TITLE: 'SCENE_TITLE'
Model_TITLE: 'Model_TITLE'
} as const;
interface EditorModeMapping {
@@ -32,9 +32,9 @@ const EDITOR_MODE_MAPPING: EditorModeMapping = {
'ZOOM_CONTROLS',
'TOOL_MENU',
'MAIN_MENU',
'SCENE_TITLE'
'Model_TITLE'
],
[EditorModeEnum.EXPLORABLE_READONLY]: ['ZOOM_CONTROLS', 'SCENE_TITLE'],
[EditorModeEnum.EXPLORABLE_READONLY]: ['ZOOM_CONTROLS', 'Model_TITLE'],
[EditorModeEnum.NON_INTERACTIVE]: []
};
@@ -71,7 +71,7 @@ export const UiOverlay = () => {
const itemControls = useUiStateStore((state) => {
return state.itemControls;
});
const sceneTitle = useSceneStore((state) => {
const ModelTitle = useModelStore((state) => {
return state.title;
});
const editorMode = useUiStateStore((state) => {
@@ -155,7 +155,7 @@ export const UiOverlay = () => {
</Box>
)}
{availableTools.includes('SCENE_TITLE') && (
{availableTools.includes('Model_TITLE') && (
<Box
sx={{
position: 'absolute',
@@ -178,7 +178,7 @@ export const UiOverlay = () => {
}}
>
<Typography fontWeight={600} color="text.secondary">
{sceneTitle}
{ModelTitle}
</Typography>
</UiElement>
</Box>
@@ -199,9 +199,9 @@ export const UiOverlay = () => {
</UiElement>
)}
</Box>
{mode.type === 'PLACE_ELEMENT' && mode.icon && (
{mode.type === 'PLACE_ICON' && mode.id && (
<SceneLayer>
<DragAndDrop icon={mode.icon} tile={mouse.position.tile} />
<DragAndDrop iconId={mode.id} tile={mouse.position.tile} />
</SceneLayer>
)}

View File

@@ -1,11 +1,16 @@
import {
Size,
Coords,
SceneInput,
Model,
MainMenuOptions,
Icon,
Connector,
MainMenuOptions
TextBox,
ViewItem,
View,
Rectangle
} from 'src/types';
import { customVars } from './styles/theme';
import { CoordsUtils } from './utils';
// TODO: This file could do with better organisation and convention for easier reading.
export const UNPROJECTED_TILE_SIZE = 100;
@@ -17,33 +22,47 @@ export const PROJECTED_TILE_SIZE = {
width: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.width,
height: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.height
};
export const DEFAULT_COLOR = customVars.customPalette.blue;
export const DEFAULT_FONT_FAMILY = 'Roboto, Arial, sans-serif';
export const NODE_DEFAULTS = {
label: '',
labelHeight: 140,
color: DEFAULT_COLOR
export const VIEW_DEFAULTS: Required<Omit<View, 'id' | 'description'>> = {
name: 'New view',
items: [],
connectors: [],
rectangles: [],
textBoxes: []
};
interface ConnectorDefaults {
width: number;
searchOffset: Coords;
style: Connector['style'];
}
export const VIEW_ITEM_DEFAULTS: Required<Omit<ViewItem, 'id' | 'tile'>> = {
labelHeight: 80
};
export const CONNECTOR_DEFAULTS: ConnectorDefaults = {
export const CONNECTOR_DEFAULTS: Required<Omit<Connector, 'id'>> = {
width: 10,
// The boundaries of the search area for the pathfinder algorithm
// is the grid that encompasses the two nodes + the offset below.
searchOffset: { x: 1, y: 1 },
description: '',
color: DEFAULT_COLOR,
anchors: [],
style: 'SOLID'
};
export const TEXTBOX_DEFAULTS = {
// The boundaries of the search area for the pathfinder algorithm
// is the grid that encompasses the two nodes + the offset below.
export const CONNECTOR_SEARCH_OFFSET = { x: 1, y: 1 };
export const TEXTBOX_DEFAULTS: Required<Omit<TextBox, 'id' | 'tile'>> = {
orientation: 'X',
fontSize: 0.6,
paddingX: 0.2,
text: 'Text',
fontWeight: 'bold'
content: 'Text'
};
export const TEXTBOX_PADDING = 0.2;
export const TEXTBOX_FONT_WEIGHT = 'bold';
export const RECTANGLE_DEFAULTS: Required<
Omit<Rectangle, 'id' | 'from' | 'to'>
> = {
color: DEFAULT_COLOR
};
export const ZOOM_INCREMENT = 0.2;
@@ -51,13 +70,19 @@ export const MIN_ZOOM = 0.2;
export const MAX_ZOOM = 1;
export const TRANSFORM_ANCHOR_SIZE = 30;
export const TRANSFORM_CONTROLS_COLOR = '#0392ff';
export const INITIAL_SCENE: SceneInput = {
export const INITIAL_DATA: Model = {
title: 'Untitled',
version: '',
icons: [],
nodes: [],
connectors: [],
textBoxes: [],
rectangles: []
items: [],
views: []
};
export const INITIAL_UI_STATE = {
zoom: 1,
scroll: {
position: CoordsUtils.zero(),
offset: CoordsUtils.zero()
}
};
export const MAIN_MENU_OPTIONS: MainMenuOptions = [
'ACTION.OPEN',
@@ -68,3 +93,12 @@ export const MAIN_MENU_OPTIONS: MainMenuOptions = [
'LINK.GITHUB',
'VERSION'
];
export const DEFAULT_ICON: Icon = {
id: 'default',
name: 'block',
isIsometric: true,
url: ''
};
export const DEFAULT_LABEL_HEIGHT = 20;

View File

@@ -1,7 +1,7 @@
import React from 'react';
import Isoflow from 'src/Isoflow';
import { initialScene } from '../initialScene';
import { initialData } from '../initialData';
export const BasicEditor = () => {
return <Isoflow initialScene={initialScene} />;
return <Isoflow initialData={initialData} />;
};

View File

@@ -1,7 +1,7 @@
import React from 'react';
import Isoflow from 'src/Isoflow';
import { initialScene } from '../initialScene';
import { initialData } from '../initialData';
export const DebugTools = () => {
return <Isoflow initialScene={initialScene} enableDebugTools height="100%" />;
return <Isoflow initialData={initialData} enableDebugTools height="100%" />;
};

View File

@@ -1,9 +1,7 @@
import React from 'react';
import Isoflow from 'src/Isoflow';
import { initialScene } from '../initialScene';
import { initialData } from '../initialData';
export const ReadonlyMode = () => {
return (
<Isoflow initialScene={initialScene} editorMode="EXPLORABLE_READONLY" />
);
return <Isoflow initialData={initialData} editorMode="EXPLORABLE_READONLY" />;
};

782
src/examples/initialData.ts Normal file
View File

@@ -0,0 +1,782 @@
/* eslint-disable import/no-extraneous-dependencies */
import { InitialData } from 'src/Isoflow';
import { flattenCollections } from '@isoflow/isopacks/dist/utils';
import isoflowIsopack from '@isoflow/isopacks/dist/isoflow';
import awsIsopack from '@isoflow/isopacks/dist/aws';
import gcpIsopack from '@isoflow/isopacks/dist/gcp';
import azureIsopack from '@isoflow/isopacks/dist/azure';
import kubernetesIsopack from '@isoflow/isopacks/dist/kubernetes';
const isopacks = flattenCollections([
isoflowIsopack,
awsIsopack,
azureIsopack,
gcpIsopack,
kubernetesIsopack
]);
// The data used in this visualisation example has been derived from the following blog post
// https://www.altexsoft.com/blog/travel/airport-technology-management-operations-software-solutions-and-vendors/
export const initialData: InitialData = {
icons: isopacks
};
// export const initialData: InitialData = {
// title: 'Airport Management Software',
// icons: isopacks,
// items: [
// {
// id: 'b6cf011d-0bc2-474d-8a4b-022d24ecc5d5',
// tile: { x: 0, y: 0 },
// label: 'Airport Operational Database',
// description:
// '<p>Each airport has its own central database that stores and updates all necessary data regarding daily flights, seasonal schedules, available resources, and other flight-related information, like billing data and flight fees. AODB is a key feature for the functioning of an airport.</p><p><br></p><p>This database is connected to the rest of the airport modules: <em>airport information systems, revenue management systems, and air traffic management</em>.</p><p><br></p><p>The system can supply different information for different segments of users: passengers, airport staff, crew, or members of specific departments, authorities, business partners, or police.</p><p><br></p><p>AODB represents the information on a graphical display.</p><p><br></p><p><strong>AODB functions include:</strong></p><p>- Reference-data processing</p><p>- Seasonal scheduling</p><p>- Daily flight schedule processing</p><p>- Processing of payments</p>',
// labelHeight: 140,
// icon: 'storage'
// },
// {
// id: '815b0205-516c-48a9-ac34-2007bb155d75',
// tile: { x: 11, y: 3 },
// label: 'Apron handling',
// description:
// '<p>Apron (or ground handling) deals with aircraft servicing. This includes passenger boarding and guidance, cargo and mail loading, and apron services. Apron services include aircraft guiding, cleaning, drainage, deicing, catering, and fueling. At this stage, the software facilitates dealing with information about the weight of the baggage and cargo load, number of passengers, boarding bridges parking, and the ground services that must be supplied to the aircraft. By entering this information into the system, their costs can be calculated and invoiced through the billing system.</p>',
// labelHeight: 80,
// icon: 'block'
// },
// {
// id: '085f81b9-651d-47a1-94ec-c4cd02bb08c4',
// tile: { x: 11, y: 0 },
// label: 'ATC Tower',
// description:
// '<p>The Air Traffic Control Tower is a structure that delivers air and ground control of the aircraft. It ensures safety by guiding and navigating the vehicles and aircraft. It is performed by way of visual signaling, radar, and radio communication in the air and on the ground. The main focus of the tower is to make sure that all aircraft have been assigned to the right place, that passengers arent at risk, and that the aircraft will have a suitable passenger boarding bridge allocated on the apron.</p><p><br></p><p>The ATC tower has a control room that serves as a channel between landside (terminal) and airside operations in airports. The control room personnel are tasked with ensuring the security and safety of the passengers as well as ground handling. Usually, a control room has CCTV monitors and air traffic control systems that maintain the order in the terminal and on the apron.</p>',
// labelHeight: 80,
// icon: 'block'
// },
// {
// id: 'e4909db0-da09-49a3-8cfd-927ce191c28d',
// tile: { x: 11, y: -3 },
// label: 'Aeronautical Fixed Telecommunication Network (AFTN) Systems',
// description:
// '<p>AFTN systems handle communication and exchange of data including navigation services. Usually, airports exchange traffic environment messages, safety messages, information about the weather, geographic material, disruptions, etc. They serve as communication between airports and aircraft.</p><p><br></p><p>Software for aeronautical telecommunications stores flight plans and flight information, entered in ICAO format and UTC. The information stored can be used for planning and statistical purposes. For airports, its important to understand the aircraft type and its weight to assign it to the right place on the runway. AFTN systems hold the following information:</p><p><br></p><p>- Aircraft registration</p><p>- Runway used</p><p>- Actual time of landing and departure</p><p>- Number of circuits</p><p>- Number and type of approaches</p><p>- New estimates of arrival and departure</p><p>- New flight information</p><p><br></p><p>Air traffic management is performed from an ATC tower.</p>',
// labelHeight: 80,
// icon: 'block'
// },
// {
// id: '680a2ad0-839f-4cfc-8b35-24c557fa1e8f',
// tile: { x: -9, y: -3 },
// label: 'Passenger facilitation services',
// description:
// '<p>include passenger processing (check-in, boarding, border control) and baggage handling (tagging, dropping and handling). They follow passengers to the shuttle buses to carry them to their flights. Arrival operations include boarding control and baggage handling.</p>',
// labelHeight: 120,
// icon: 'user'
// },
// {
// id: '8277f135-e833-40b3-8232-0461b86added',
// tile: { x: -9, y: 3 },
// label: 'Terminal management systems',
// description:
// '<p>Includes maintenance and monitoring of management systems for assets, buildings, electrical grids, environmental systems, and vertical transportation organization. It also facilitates staff communications and management.</p>',
// labelHeight: 120,
// icon: 'function-module'
// },
// {
// id: 'ca560fc6-da8b-49cc-b4eb-fdc300ff6aed',
// tile: { x: -9, y: 7 },
// label: 'Maintenance and monitoring',
// labelHeight: 80,
// icon: 'block'
// },
// {
// id: '8f02ce50-0aa4-4950-b077-c1daf5da023b',
// tile: { x: -12, y: 7 },
// label: 'Resource management',
// labelHeight: 80,
// icon: 'block'
// },
// {
// id: '308dfac4-fc9a-4985-9b5c-9616e0674ac4',
// tile: { x: -15, y: 7 },
// label: 'Staff management',
// description:
// '<p>Staff modules provide the necessary information about ongoing processes in the airport, such as data on flights (in ICAO or UTC formats) and other important events to keep responsible staff members updated. Information is distributed through the airport radio system, or displayed on a PC connected via the airport LAN or on mobile devices.</p>',
// labelHeight: 80,
// icon: 'block'
// },
// {
// id: '2420494b-312a-4ddf-b8c3-591fa55563cb',
// tile: { x: -9, y: -8 },
// label: 'Border control (customs and security services)',
// description:
// '<p>In airports, security services usually unite perimeter security, terminal security, and border controls. These services require biometric authentication and integration into government systems to allow a customs officer to view the status of a passenger.</p>',
// labelHeight: 60,
// icon: 'block'
// },
// {
// id: '2557a6bd-0c92-4522-b704-f1f6d076be80',
// tile: { x: -12, y: -8 },
// label: 'Common use services (self-service check-in systems)',
// description:
// '<p>An airport must ensure smooth passenger flow. Various&nbsp;digital self-services, like check-in kiosks or automated self-service gates, make it happen. Self-service options, especially check-in kiosks, remain popular. Worldwide in 2018, passengers used kiosks to check themselves in&nbsp;88 percent of the time.</p>',
// labelHeight: 80,
// icon: 'block'
// },
// {
// id: '7c422bf4-bbd2-4364-ad9b-5250c2877695',
// tile: { x: -15, y: -8 },
// label: 'Baggage handling',
// description:
// '<p>A passenger must check a bag before its loaded on the aircraft. The time the baggage is loaded is displayed and tracked until the destination is reached and the bag is returned to the owners.</p>',
// labelHeight: 80,
// icon: 'block'
// },
// {
// id: 'fd5dd3ed-b8de-41ed-a642-b8bd71aaebf8',
// tile: { x: 5, y: 0 },
// label: 'Airside operations',
// description:
// '<p>Includes systems to handle aircraft landing and navigation, airport traffic management, runway management, and ground handling safety.</p>',
// labelHeight: 100,
// icon: 'plane'
// },
// {
// id: 'cb6b557e-01c8-4208-88f8-2076351aa53c',
// tile: { x: 0, y: -5 },
// label: 'Invoicing and billing',
// description:
// '<p>Each flight an airport handles generates a defined revenue for the airport paid by the airline operating the aircraft. Aeronautical invoicing systems make payment possible for any type and size of aircraft. It accepts payments in cash and credit in multiple currencies. The billing also extends to ATC services.</p><p><br></p><p>Depending on the aircraft type and weight and ground services provided, an airport can calculate the aeronautical fee and issue an invoice with a bill.&nbsp;It is calculated using the following data:</p><p><br></p><p>- Aircraft registration</p><p>- Parking time at the airport</p><p>- Airport point of departure and/or landing</p><p>- Times at the different points of entry or departure</p><p><br></p><p>The data is entered or integrated from ATC. Based on this information, the airport calculates the charges and sends the bills.</p>',
// labelHeight: 80,
// icon: 'paymentcard'
// },
// {
// id: '1789808d-6d52-4714-aeea-954f3e4aba66',
// tile: { x: -2, y: -11 },
// label: 'ATC Tower Billing',
// labelHeight: 60,
// icon: 'block'
// },
// {
// id: '5096a0f1-7548-4cba-8477-7f07e3bdc206',
// tile: { x: 2, y: -11 },
// label: 'Non Aeronautical revenue',
// labelHeight: 60,
// icon: 'block'
// },
// {
// id: '0e1ecd31-ee64-49b9-a5be-3375bd3cece6',
// tile: { x: 3, y: 13 },
// label: 'Automatic Terminal Information Service (ATIS)',
// description:
// '<p>Broadcasts the weather reports, the condition of the runway, or other local information for pilots and crews.</p><p><br></p><p>Some airport software vendors offer off-the-shelf solutions to facilitate particular tasks, like maintenance, or airport operations. However, most of them provide integrated systems that comprise modules for several operations.</p>',
// labelHeight: 100,
// icon: 'block'
// },
// {
// id: 'b0368dfe-cc6e-405a-9471-0867c4c51d08',
// tile: { x: 0, y: 13 },
// label: 'Flight Information Display Systems (FIDS)',
// description:
// '<p>Exhibits the status of boarding, gates, aircraft, flight number, and other flight details.&nbsp;A computer controls the screens that are connected to the data management systems and displays up-to-date information about flights in real time. Some airports have a digital FIDS in the form of apps or on their websites. Also, the displays may show other public information such as the weather, news, safety messages, menus, and advertising. Airports can choose the type, languages, and means of entering the information, whether it be manually or loaded from a central database.</p>',
// labelHeight: 100,
// icon: 'block'
// },
// {
// id: '9f1417b9-8785-498b-bb4e-4ee9a827e611',
// tile: { x: -3, y: 13 },
// label: 'Public address (PA) systems',
// description:
// '<p>Informs passengers and airport staff about any changes and processes of importance, for instance, gates, times of arrival, calls, and alerts. Also, information can be communicated to pilots, aircraft staff, crew, etc. PA systems usually include voice messages broadcasted through loudspeakers.</p>',
// labelHeight: 100,
// icon: 'block'
// },
// {
// id: '7f9e0f95-b490-4e03-99c7-8df10788d6df',
// tile: { x: 0, y: 6 },
// label: 'Information management',
// description:
// '<p>This subsystem is responsible for the collection and distribution of daily flight information, storing of seasonal and arrival/departure information, as well as the connection with airlines.</p>',
// labelHeight: 80,
// icon: 'queue'
// },
// {
// id: 'bf5b31ed-97fc-4e23-9ccf-107ba9f1bb58',
// tile: { x: -5, y: 0 },
// label: 'Landside operations',
// description:
// '<p>This subsystem is aimed at serving passengers and maintenance of terminal buildings, parking facilities, and vehicular traffic circular drives. Passenger operations include baggage handling and tagging.</p>',
// labelHeight: 180,
// icon: 'office'
// }
// ],
// connectors: [
// {
// id: 'be41366a-be8e-43f5-a85c-1528cc380b70',
// color: '#a0b9f8',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '612d1f4e-588e-468e-a819-9e9b5528abe2',
// ref: { node: '308dfac4-fc9a-4985-9b5c-9616e0674ac4' }
// },
// {
// id: '82ce8f7c-1315-4bae-9966-435a3504de9a',
// ref: { tile: { x: -15, y: 5 } }
// }
// ]
// },
// {
// id: 'be678975-abc7-4345-82b3-2e829486fe3c',
// color: '#a0b9f8',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '4d4868ff-53df-414b-b51f-2de31fd125e6',
// ref: { tile: { x: -9, y: -5 } }
// },
// {
// id: 'bec7a569-6f1f-46f8-8ddf-b6ace6354f88',
// ref: { node: '680a2ad0-839f-4cfc-8b35-24c557fa1e8f' }
// }
// ]
// },
// {
// id: 'b155cdd6-da92-4553-b295-3c1a3262169d',
// color: '#a0b9f8',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '80a0fa48-f4b2-46e7-8ede-0c725a279b52',
// ref: { node: 'ca560fc6-da8b-49cc-b4eb-fdc300ff6aed' }
// },
// {
// id: '6681a140-8535-4c14-ab87-656476c81c06',
// ref: { tile: { x: -9, y: 5 } }
// }
// ]
// },
// {
// id: '1b3bcf33-ca67-461b-8f80-a70eba87e6ab',
// color: '#a0b9f8',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: 'b1d4cc02-056d-4a06-bb93-932c8630c7a0',
// ref: { tile: { x: -9, y: -5 } }
// },
// {
// id: '72eb7689-fa58-4ac1-88a9-e9208f11928a',
// ref: { tile: { x: -12, y: -5 } }
// },
// {
// id: 'e7b2facb-c137-49f1-acdd-095739b51e05',
// ref: { tile: { x: -15, y: -5 } }
// }
// ]
// },
// {
// id: '87b538b5-e908-495d-bb12-05f068f1b6ee',
// color: '#a0b9f8',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: 'ca0d0583-8d87-4365-ae77-e420ae6a25cf',
// ref: { node: '2557a6bd-0c92-4522-b704-f1f6d076be80' }
// },
// {
// id: 'a5a1520b-5914-414e-82aa-5ebdf3fbda16',
// ref: { tile: { x: -12, y: -5 } }
// }
// ]
// },
// {
// id: 'eb44bfe6-66c3-492c-949c-3f8faba4c455',
// color: '#a0b9f8',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '54d16963-57ad-4252-ae54-42417c4116a5',
// ref: { node: '8277f135-e833-40b3-8232-0461b86added' }
// },
// {
// id: '24eacb98-43cf-4e4c-8e4e-453247edfafc',
// ref: { tile: { x: -9, y: 5 } }
// },
// {
// id: 'e7d4f48d-68a1-48be-9488-173955730d17',
// ref: { tile: { x: -12, y: 5 } }
// },
// {
// id: '95c2eca2-c3ce-4fb3-abac-241ccd7a2f56',
// ref: { tile: { x: -15, y: 5 } }
// }
// ]
// },
// {
// id: '110033ad-b542-4415-bb95-6bad8de33198',
// color: '#a0b9f8',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '9ccd7127-1ed6-4ac0-9fcd-31e17c306987',
// ref: { node: '8f02ce50-0aa4-4950-b077-c1daf5da023b' }
// },
// {
// id: '4c4f7d59-2b06-46cc-8eed-f3cd2b2af9b3',
// ref: { tile: { x: -12, y: 5 } }
// }
// ]
// },
// {
// id: 'f7b5f65c-0503-479b-983d-6b3be7d4355b',
// color: '#bbadfb',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: 'fbeb8d12-597a-4654-92f4-41006b57beb8',
// ref: { node: '815b0205-516c-48a9-ac34-2007bb155d75' }
// },
// {
// id: '6f88cf82-23d9-4d95-9823-3c76b1956c9c',
// ref: { tile: { x: 9, y: 3 } }
// }
// ]
// },
// {
// id: '1c7ccb81-9f31-4cc3-90ee-2f92c4be1e8f',
// color: '#bbadfb',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '199f843d-f8ce-4a26-b71a-1b4f799109a9',
// ref: { node: 'e4909db0-da09-49a3-8cfd-927ce191c28d' }
// },
// {
// id: 'd2d63da6-a60a-4569-af9f-5d497013bd8e',
// ref: { tile: { x: 9, y: -3 } }
// }
// ]
// },
// {
// id: '48435cd4-297f-4078-b5d4-79209babaa0f',
// color: '#bbadfb',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '9316ac7f-7913-489e-8516-fb74eb9aed9c',
// ref: { tile: { x: 9, y: 3 } }
// },
// {
// id: 'd53e7165-c645-4e8f-b4c7-84ebc4369f4a',
// ref: { tile: { x: 9, y: 0 } }
// },
// {
// id: '01860aaf-3b57-45f2-b833-15fcafdd4abb',
// ref: { tile: { x: 9, y: -3 } }
// }
// ]
// },
// {
// id: '1a58a13e-d136-4c99-a111-143097873147',
// color: '#bbadfb',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: 'fe873a58-afdc-4c8b-b89c-b01332e6264b',
// ref: { node: '085f81b9-651d-47a1-94ec-c4cd02bb08c4' }
// },
// {
// id: '93286249-0ee8-4a0d-ae09-c17736b0535f',
// ref: { tile: { x: 9, y: 0 } }
// }
// ]
// },
// {
// id: '3abc77ea-357b-4671-95a7-6b17e0219032',
// color: '#b3e5e3',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '0077477a-8978-48dc-9051-51f9322519bc',
// ref: { node: '9f1417b9-8785-498b-bb4e-4ee9a827e611' }
// },
// {
// id: 'e69310e4-f794-4771-aa5f-39291aa090ef',
// ref: { tile: { x: -3, y: 11 } }
// }
// ]
// },
// {
// id: '75200d73-06f4-4403-923f-32f0041c9701',
// color: '#b3e5e3',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '9f5616ae-e638-40af-ba0b-b25f6adae2f7',
// ref: { node: '0e1ecd31-ee64-49b9-a5be-3375bd3cece6' }
// },
// {
// id: 'e86b8651-a385-4a96-90cc-2ed2b6daa9f2',
// ref: { tile: { x: 3, y: 11 } }
// }
// ]
// },
// {
// id: '4a7ccc74-f296-4d0b-be11-8117c990795d',
// color: '#b3e5e3',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '7b721b82-220d-45f6-87d4-479c1b847a60',
// ref: { node: 'b6cf011d-0bc2-474d-8a4b-022d24ecc5d5' }
// },
// {
// id: '1908c227-8066-4c8d-ba55-d5822c224d52',
// ref: { node: '7f9e0f95-b490-4e03-99c7-8df10788d6df' }
// }
// ]
// },
// {
// id: '9f6bc159-507f-4b56-9171-d95f00490230',
// color: '#a0b9f8',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: 'b41f10e5-5d29-4cee-8102-0005cf146300',
// ref: { node: '8277f135-e833-40b3-8232-0461b86added' }
// },
// {
// id: '3f5bfd1b-f8b3-4a00-976e-6bc4382749ee',
// ref: { tile: { x: -9, y: 0 } }
// },
// {
// id: '3792f3db-edd1-4d43-9f16-0be811687c85',
// ref: { node: 'bf5b31ed-97fc-4e23-9ccf-107ba9f1bb58' }
// }
// ]
// },
// {
// id: '5b47caae-fbdd-4d1b-8cc5-0068536fb8b2',
// color: '#a0b9f8',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '16ba31e6-a713-403d-b23e-4c40b19e7d66',
// ref: { node: '680a2ad0-839f-4cfc-8b35-24c557fa1e8f' }
// },
// {
// id: '59675d93-8ad7-4abd-85d4-cfe0004384c1',
// ref: { tile: { x: -9, y: 0 } }
// },
// {
// id: '1ab79aec-2587-47eb-9b2f-42e9eb44fe77',
// ref: { node: 'bf5b31ed-97fc-4e23-9ccf-107ba9f1bb58' }
// }
// ]
// },
// {
// id: 'a6c63183-ce9a-4d54-9487-569f2a8a8add',
// color: '#a0b9f8',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '8a2d4f72-5f62-4531-b2f7-b0eed5e533a5',
// ref: { node: 'bf5b31ed-97fc-4e23-9ccf-107ba9f1bb58' }
// },
// {
// id: 'ab054b9f-7574-4195-9465-c4a127b8e03e',
// ref: { node: 'b6cf011d-0bc2-474d-8a4b-022d24ecc5d5' }
// }
// ]
// },
// {
// id: 'afa7915a-c1f6-4f40-87c0-ace0ecacf5bb',
// color: '#b3e5e3',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '2b18ab38-7f76-4e63-ae79-ca6cfaa2ee49',
// ref: { tile: { x: -3, y: 11 } }
// },
// {
// id: 'a926bd20-a16e-4349-8e31-bb4109ab0387',
// ref: { tile: { x: 3, y: 11 } }
// }
// ]
// },
// {
// id: 'c1263273-a57c-4e0b-92b8-04c21b805b29',
// color: '#b3e5e3',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: 'b1ee48ad-70f7-44ff-9d84-b3893ce3dff1',
// ref: { node: 'b0368dfe-cc6e-405a-9471-0867c4c51d08' }
// },
// {
// id: 'e6148182-5a09-4e0a-85f4-1389dfb33f8b',
// ref: { tile: { x: 0, y: 11 } }
// }
// ]
// },
// {
// id: '5706f58e-6b29-4ec7-aa0b-c59ce6198870',
// color: '#b3e5e3',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '5a8e0e07-7e0c-40eb-b50c-bba2a2403c2f',
// ref: { tile: { x: 0, y: 11 } }
// },
// {
// id: 'b3611f56-35c2-4cc3-8dc6-461c182937e5',
// ref: { node: '7f9e0f95-b490-4e03-99c7-8df10788d6df' }
// }
// ]
// },
// {
// id: '17547863-f8f3-4653-92ec-f76d3ec0835f',
// color: '#bbadfb',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '0445d65f-05b4-47cf-9163-dd0d6eefa4af',
// ref: { node: 'b6cf011d-0bc2-474d-8a4b-022d24ecc5d5' }
// },
// {
// id: 'e77a4942-1625-4893-a4f5-634dbc528321',
// ref: { node: 'fd5dd3ed-b8de-41ed-a642-b8bd71aaebf8' }
// }
// ]
// },
// {
// id: '2656820b-e1fb-41fd-980f-7b94d6e9906e',
// color: '#bbadfb',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '2717739f-a2d4-44cf-bc45-5d8ae8bb67dd',
// ref: { node: 'fd5dd3ed-b8de-41ed-a642-b8bd71aaebf8' }
// },
// {
// id: 'b0399a5a-3bc3-4864-aa1b-c2194437f644',
// ref: { tile: { x: 9, y: 0 } }
// }
// ]
// },
// {
// id: 'e9f0546e-d2f3-4ae3-b6dd-55d3cf774093',
// color: '#a8dc9d',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '7cca17bf-700b-4192-be94-7b347b73a77a',
// ref: { node: '1789808d-6d52-4714-aeea-954f3e4aba66' }
// },
// {
// id: '6e8d5fb2-b995-4e0e-9465-b664ea793f50',
// ref: { tile: { x: -2, y: -9 } }
// }
// ]
// },
// {
// id: '6dce44fd-0fb5-4a08-9daa-1716313c59d8',
// color: '#a8dc9d',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '5a3751f2-bd9f-4ddc-808a-f0f23a2eecd8',
// ref: { node: '5096a0f1-7548-4cba-8477-7f07e3bdc206' }
// },
// {
// id: '0f6673bf-b5c4-4e1b-b3aa-bcdca9f24e81',
// ref: { tile: { x: 2, y: -9 } }
// }
// ]
// },
// {
// id: '725419a0-6ca4-4d1b-b6a4-a7ae7feabcb0',
// color: '#a8dc9d',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: 'dfdda749-2f06-4a32-903e-35e336d3842d',
// ref: { node: 'b6cf011d-0bc2-474d-8a4b-022d24ecc5d5' }
// },
// {
// id: '354700b6-6b10-49cb-a943-f2eeb8e4741b',
// ref: { node: 'cb6b557e-01c8-4208-88f8-2076351aa53c' }
// }
// ]
// },
// {
// id: '41431713-1647-4b47-b258-9d3ec4665b77',
// color: '#a8dc9d',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: 'f579ae26-9023-4041-ba62-33da290ca83f',
// ref: { tile: { x: 2, y: -9 } }
// },
// {
// id: '2a123a41-1481-4c87-9aca-89a819ee4478',
// ref: { tile: { x: -2, y: -9 } }
// }
// ]
// },
// {
// id: '3410464e-d12b-4cf2-bdc4-9eb338ea1c1a',
// color: '#a8dc9d',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: '832bad08-bd8a-4a57-b849-016b0482138c',
// ref: { node: 'cb6b557e-01c8-4208-88f8-2076351aa53c' }
// },
// {
// id: 'fe2422cd-1ced-4f4a-a79c-a9f052977cc5',
// ref: { tile: { x: 0, y: -9 } }
// }
// ]
// },
// {
// id: '8b95c19e-e5dd-4190-a93a-93fdec1bd37f',
// color: '#a0b9f8',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: 'd1281ea9-0f2c-4e24-a1e9-d0c8a67805ba',
// ref: { tile: { x: -15, y: -5 } }
// },
// {
// id: 'c0a7d637-79aa-4400-ab70-2b874e865ad2',
// ref: { node: '7c422bf4-bbd2-4364-ad9b-5250c2877695' }
// }
// ]
// },
// {
// id: '7013bdc5-31f0-4e64-8604-95eba5e1ca8e',
// color: '#a0b9f8',
// style: 'SOLID',
// width: 10,
// anchors: [
// {
// id: 'e1d4e165-649f-48a7-93aa-2c8181289901',
// ref: { tile: { x: -9, y: -5 } }
// },
// {
// id: '1f3e5712-a5be-4d2b-ba86-a0ec2e4ab439',
// ref: { node: '2420494b-312a-4ddf-b8c3-591fa55563cb' }
// }
// ]
// }
// ],
// textBoxes: [
// {
// id: '29e365cf-5f40-4355-8a2d-db45a509b140',
// orientation: 'Y',
// fontSize: 0.6,
// tile: { x: -2, y: -1 },
// text: 'AODB'
// },
// {
// id: '40964f5b-7ebd-4c0d-9666-f8ff2de65dc8',
// orientation: 'Y',
// fontSize: 0.6,
// tile: { x: 8, y: -1 },
// text: 'Airside operations'
// },
// {
// id: 'c6d16ec6-7110-4082-9370-44e127984b90',
// orientation: 'X',
// fontSize: 0.6,
// tile: { x: -3, y: -13 },
// text: 'Invoicing & Billing'
// },
// {
// id: '57156c5e-9b82-4652-852d-422014228b74',
// orientation: 'X',
// fontSize: 0.6,
// tile: { x: -16, y: -10 },
// text: 'Passenger facilitation'
// },
// {
// id: '92637173-542c-4d25-addf-ab50eda7a573',
// orientation: 'X',
// fontSize: 0.6,
// tile: { x: -16, y: 4 },
// text: 'Terminal management'
// },
// {
// id: 'e555aad0-9e21-48db-9d71-53125c33c214',
// orientation: 'X',
// fontSize: 0.6,
// tile: { x: -4, y: 10 },
// text: 'Information management'
// }
// ],
// rectangles: [
// {
// id: '82ef2751-9ea7-40fc-b3f5-3ea8ce1e1d67',
// color: '#fad6ac',
// from: { x: -1, y: 1 },
// to: { x: 1, y: -1 }
// },
// {
// id: '7d730e86-7884-400d-857a-8af7f22a9937',
// color: '#a0b9f8',
// from: { x: -8, y: -5 },
// to: { x: -16, y: -9 }
// },
// {
// id: 'f41434d1-bbe6-4d6d-a13d-7c9ad52e8e62',
// color: '#a0b9f8',
// from: { x: -16, y: 8 },
// to: { x: -8, y: 5 }
// },
// {
// id: '4ffe42ed-9cd0-48a9-93a0-6099ab53a146',
// color: '#a8dc9d',
// from: { x: -3, y: -9 },
// to: { x: 3, y: -12 }
// },
// {
// id: 'dc93e93b-2c6c-43a1-bb82-ad93c04f7707',
// color: '#bbadfb',
// from: { x: 9, y: 4 },
// to: { x: 12, y: -4 }
// },
// {
// id: 'eec5c861-6192-4eb6-a378-7554bda4d9a7',
// color: '#b3e5e3',
// from: { x: -4, y: 14 },
// to: { x: 4, y: 11 }
// }
// ]
// };

View File

@@ -1,779 +0,0 @@
/* eslint-disable import/no-extraneous-dependencies */
import { InitialScene } from 'src/Isoflow';
import { flattenCollections } from '@isoflow/isopacks/dist/utils';
import isoflowIsopack from '@isoflow/isopacks/dist/isoflow';
import awsIsopack from '@isoflow/isopacks/dist/aws';
import gcpIsopack from '@isoflow/isopacks/dist/gcp';
import azureIsopack from '@isoflow/isopacks/dist/azure';
import kubernetesIsopack from '@isoflow/isopacks/dist/kubernetes';
const isopacks = flattenCollections([
isoflowIsopack,
awsIsopack,
azureIsopack,
gcpIsopack,
kubernetesIsopack
]);
// The data used in this visualisation example has been derived from the following blog post
// https://www.altexsoft.com/blog/travel/airport-technology-management-operations-software-solutions-and-vendors/
export const initialScene: InitialScene = {
title: 'Airport Management Software',
icons: isopacks,
nodes: [
{
id: 'b6cf011d-0bc2-474d-8a4b-022d24ecc5d5',
tile: { x: 0, y: 0 },
label: 'Airport Operational Database',
description:
'<p>Each airport has its own central database that stores and updates all necessary data regarding daily flights, seasonal schedules, available resources, and other flight-related information, like billing data and flight fees. AODB is a key feature for the functioning of an airport.</p><p><br></p><p>This database is connected to the rest of the airport modules: <em>airport information systems, revenue management systems, and air traffic management</em>.</p><p><br></p><p>The system can supply different information for different segments of users: passengers, airport staff, crew, or members of specific departments, authorities, business partners, or police.</p><p><br></p><p>AODB represents the information on a graphical display.</p><p><br></p><p><strong>AODB functions include:</strong></p><p>- Reference-data processing</p><p>- Seasonal scheduling</p><p>- Daily flight schedule processing</p><p>- Processing of payments</p>',
labelHeight: 140,
icon: 'storage'
},
{
id: '815b0205-516c-48a9-ac34-2007bb155d75',
tile: { x: 11, y: 3 },
label: 'Apron handling',
description:
'<p>Apron (or ground handling) deals with aircraft servicing. This includes passenger boarding and guidance, cargo and mail loading, and apron services. Apron services include aircraft guiding, cleaning, drainage, deicing, catering, and fueling. At this stage, the software facilitates dealing with information about the weight of the baggage and cargo load, number of passengers, boarding bridges parking, and the ground services that must be supplied to the aircraft. By entering this information into the system, their costs can be calculated and invoiced through the billing system.</p>',
labelHeight: 80,
icon: 'block'
},
{
id: '085f81b9-651d-47a1-94ec-c4cd02bb08c4',
tile: { x: 11, y: 0 },
label: 'ATC Tower',
description:
'<p>The Air Traffic Control Tower is a structure that delivers air and ground control of the aircraft. It ensures safety by guiding and navigating the vehicles and aircraft. It is performed by way of visual signaling, radar, and radio communication in the air and on the ground. The main focus of the tower is to make sure that all aircraft have been assigned to the right place, that passengers arent at risk, and that the aircraft will have a suitable passenger boarding bridge allocated on the apron.</p><p><br></p><p>The ATC tower has a control room that serves as a channel between landside (terminal) and airside operations in airports. The control room personnel are tasked with ensuring the security and safety of the passengers as well as ground handling. Usually, a control room has CCTV monitors and air traffic control systems that maintain the order in the terminal and on the apron.</p>',
labelHeight: 80,
icon: 'block'
},
{
id: 'e4909db0-da09-49a3-8cfd-927ce191c28d',
tile: { x: 11, y: -3 },
label: 'Aeronautical Fixed Telecommunication Network (AFTN) Systems',
description:
'<p>AFTN systems handle communication and exchange of data including navigation services. Usually, airports exchange traffic environment messages, safety messages, information about the weather, geographic material, disruptions, etc. They serve as communication between airports and aircraft.</p><p><br></p><p>Software for aeronautical telecommunications stores flight plans and flight information, entered in ICAO format and UTC. The information stored can be used for planning and statistical purposes. For airports, its important to understand the aircraft type and its weight to assign it to the right place on the runway. AFTN systems hold the following information:</p><p><br></p><p>- Aircraft registration</p><p>- Runway used</p><p>- Actual time of landing and departure</p><p>- Number of circuits</p><p>- Number and type of approaches</p><p>- New estimates of arrival and departure</p><p>- New flight information</p><p><br></p><p>Air traffic management is performed from an ATC tower.</p>',
labelHeight: 80,
icon: 'block'
},
{
id: '680a2ad0-839f-4cfc-8b35-24c557fa1e8f',
tile: { x: -9, y: -3 },
label: 'Passenger facilitation services',
description:
'<p>include passenger processing (check-in, boarding, border control) and baggage handling (tagging, dropping and handling). They follow passengers to the shuttle buses to carry them to their flights. Arrival operations include boarding control and baggage handling.</p>',
labelHeight: 120,
icon: 'user'
},
{
id: '8277f135-e833-40b3-8232-0461b86added',
tile: { x: -9, y: 3 },
label: 'Terminal management systems',
description:
'<p>Includes maintenance and monitoring of management systems for assets, buildings, electrical grids, environmental systems, and vertical transportation organization. It also facilitates staff communications and management.</p>',
labelHeight: 120,
icon: 'function-module'
},
{
id: 'ca560fc6-da8b-49cc-b4eb-fdc300ff6aed',
tile: { x: -9, y: 7 },
label: 'Maintenance and monitoring',
labelHeight: 80,
icon: 'block'
},
{
id: '8f02ce50-0aa4-4950-b077-c1daf5da023b',
tile: { x: -12, y: 7 },
label: 'Resource management',
labelHeight: 80,
icon: 'block'
},
{
id: '308dfac4-fc9a-4985-9b5c-9616e0674ac4',
tile: { x: -15, y: 7 },
label: 'Staff management',
description:
'<p>Staff modules provide the necessary information about ongoing processes in the airport, such as data on flights (in ICAO or UTC formats) and other important events to keep responsible staff members updated. Information is distributed through the airport radio system, or displayed on a PC connected via the airport LAN or on mobile devices.</p>',
labelHeight: 80,
icon: 'block'
},
{
id: '2420494b-312a-4ddf-b8c3-591fa55563cb',
tile: { x: -9, y: -8 },
label: 'Border control (customs and security services)',
description:
'<p>In airports, security services usually unite perimeter security, terminal security, and border controls. These services require biometric authentication and integration into government systems to allow a customs officer to view the status of a passenger.</p>',
labelHeight: 60,
icon: 'block'
},
{
id: '2557a6bd-0c92-4522-b704-f1f6d076be80',
tile: { x: -12, y: -8 },
label: 'Common use services (self-service check-in systems)',
description:
'<p>An airport must ensure smooth passenger flow. Various&nbsp;digital self-services, like check-in kiosks or automated self-service gates, make it happen. Self-service options, especially check-in kiosks, remain popular. Worldwide in 2018, passengers used kiosks to check themselves in&nbsp;88 percent of the time.</p>',
labelHeight: 80,
icon: 'block'
},
{
id: '7c422bf4-bbd2-4364-ad9b-5250c2877695',
tile: { x: -15, y: -8 },
label: 'Baggage handling',
description:
'<p>A passenger must check a bag before its loaded on the aircraft. The time the baggage is loaded is displayed and tracked until the destination is reached and the bag is returned to the owners.</p>',
labelHeight: 80,
icon: 'block'
},
{
id: 'fd5dd3ed-b8de-41ed-a642-b8bd71aaebf8',
tile: { x: 5, y: 0 },
label: 'Airside operations',
description:
'<p>Includes systems to handle aircraft landing and navigation, airport traffic management, runway management, and ground handling safety.</p>',
labelHeight: 100,
icon: 'plane'
},
{
id: 'cb6b557e-01c8-4208-88f8-2076351aa53c',
tile: { x: 0, y: -5 },
label: 'Invoicing and billing',
description:
'<p>Each flight an airport handles generates a defined revenue for the airport paid by the airline operating the aircraft. Aeronautical invoicing systems make payment possible for any type and size of aircraft. It accepts payments in cash and credit in multiple currencies. The billing also extends to ATC services.</p><p><br></p><p>Depending on the aircraft type and weight and ground services provided, an airport can calculate the aeronautical fee and issue an invoice with a bill.&nbsp;It is calculated using the following data:</p><p><br></p><p>- Aircraft registration</p><p>- Parking time at the airport</p><p>- Airport point of departure and/or landing</p><p>- Times at the different points of entry or departure</p><p><br></p><p>The data is entered or integrated from ATC. Based on this information, the airport calculates the charges and sends the bills.</p>',
labelHeight: 80,
icon: 'paymentcard'
},
{
id: '1789808d-6d52-4714-aeea-954f3e4aba66',
tile: { x: -2, y: -11 },
label: 'ATC Tower Billing',
labelHeight: 60,
icon: 'block'
},
{
id: '5096a0f1-7548-4cba-8477-7f07e3bdc206',
tile: { x: 2, y: -11 },
label: 'Non Aeronautical revenue',
labelHeight: 60,
icon: 'block'
},
{
id: '0e1ecd31-ee64-49b9-a5be-3375bd3cece6',
tile: { x: 3, y: 13 },
label: 'Automatic Terminal Information Service (ATIS)',
description:
'<p>Broadcasts the weather reports, the condition of the runway, or other local information for pilots and crews.</p><p><br></p><p>Some airport software vendors offer off-the-shelf solutions to facilitate particular tasks, like maintenance, or airport operations. However, most of them provide integrated systems that comprise modules for several operations.</p>',
labelHeight: 100,
icon: 'block'
},
{
id: 'b0368dfe-cc6e-405a-9471-0867c4c51d08',
tile: { x: 0, y: 13 },
label: 'Flight Information Display Systems (FIDS)',
description:
'<p>Exhibits the status of boarding, gates, aircraft, flight number, and other flight details.&nbsp;A computer controls the screens that are connected to the data management systems and displays up-to-date information about flights in real time. Some airports have a digital FIDS in the form of apps or on their websites. Also, the displays may show other public information such as the weather, news, safety messages, menus, and advertising. Airports can choose the type, languages, and means of entering the information, whether it be manually or loaded from a central database.</p>',
labelHeight: 100,
icon: 'block'
},
{
id: '9f1417b9-8785-498b-bb4e-4ee9a827e611',
tile: { x: -3, y: 13 },
label: 'Public address (PA) systems',
description:
'<p>Informs passengers and airport staff about any changes and processes of importance, for instance, gates, times of arrival, calls, and alerts. Also, information can be communicated to pilots, aircraft staff, crew, etc. PA systems usually include voice messages broadcasted through loudspeakers.</p>',
labelHeight: 100,
icon: 'block'
},
{
id: '7f9e0f95-b490-4e03-99c7-8df10788d6df',
tile: { x: 0, y: 6 },
label: 'Information management',
description:
'<p>This subsystem is responsible for the collection and distribution of daily flight information, storing of seasonal and arrival/departure information, as well as the connection with airlines.</p>',
labelHeight: 80,
icon: 'queue'
},
{
id: 'bf5b31ed-97fc-4e23-9ccf-107ba9f1bb58',
tile: { x: -5, y: 0 },
label: 'Landside operations',
description:
'<p>This subsystem is aimed at serving passengers and maintenance of terminal buildings, parking facilities, and vehicular traffic circular drives. Passenger operations include baggage handling and tagging.</p>',
labelHeight: 180,
icon: 'office'
}
],
connectors: [
{
id: 'be41366a-be8e-43f5-a85c-1528cc380b70',
color: '#a0b9f8',
style: 'SOLID',
width: 10,
anchors: [
{
id: '612d1f4e-588e-468e-a819-9e9b5528abe2',
ref: { node: '308dfac4-fc9a-4985-9b5c-9616e0674ac4' }
},
{
id: '82ce8f7c-1315-4bae-9966-435a3504de9a',
ref: { tile: { x: -15, y: 5 } }
}
]
},
{
id: 'be678975-abc7-4345-82b3-2e829486fe3c',
color: '#a0b9f8',
style: 'SOLID',
width: 10,
anchors: [
{
id: '4d4868ff-53df-414b-b51f-2de31fd125e6',
ref: { tile: { x: -9, y: -5 } }
},
{
id: 'bec7a569-6f1f-46f8-8ddf-b6ace6354f88',
ref: { node: '680a2ad0-839f-4cfc-8b35-24c557fa1e8f' }
}
]
},
{
id: 'b155cdd6-da92-4553-b295-3c1a3262169d',
color: '#a0b9f8',
style: 'SOLID',
width: 10,
anchors: [
{
id: '80a0fa48-f4b2-46e7-8ede-0c725a279b52',
ref: { node: 'ca560fc6-da8b-49cc-b4eb-fdc300ff6aed' }
},
{
id: '6681a140-8535-4c14-ab87-656476c81c06',
ref: { tile: { x: -9, y: 5 } }
}
]
},
{
id: '1b3bcf33-ca67-461b-8f80-a70eba87e6ab',
color: '#a0b9f8',
style: 'SOLID',
width: 10,
anchors: [
{
id: 'b1d4cc02-056d-4a06-bb93-932c8630c7a0',
ref: { tile: { x: -9, y: -5 } }
},
{
id: '72eb7689-fa58-4ac1-88a9-e9208f11928a',
ref: { tile: { x: -12, y: -5 } }
},
{
id: 'e7b2facb-c137-49f1-acdd-095739b51e05',
ref: { tile: { x: -15, y: -5 } }
}
]
},
{
id: '87b538b5-e908-495d-bb12-05f068f1b6ee',
color: '#a0b9f8',
style: 'SOLID',
width: 10,
anchors: [
{
id: 'ca0d0583-8d87-4365-ae77-e420ae6a25cf',
ref: { node: '2557a6bd-0c92-4522-b704-f1f6d076be80' }
},
{
id: 'a5a1520b-5914-414e-82aa-5ebdf3fbda16',
ref: { tile: { x: -12, y: -5 } }
}
]
},
{
id: 'eb44bfe6-66c3-492c-949c-3f8faba4c455',
color: '#a0b9f8',
style: 'SOLID',
width: 10,
anchors: [
{
id: '54d16963-57ad-4252-ae54-42417c4116a5',
ref: { node: '8277f135-e833-40b3-8232-0461b86added' }
},
{
id: '24eacb98-43cf-4e4c-8e4e-453247edfafc',
ref: { tile: { x: -9, y: 5 } }
},
{
id: 'e7d4f48d-68a1-48be-9488-173955730d17',
ref: { tile: { x: -12, y: 5 } }
},
{
id: '95c2eca2-c3ce-4fb3-abac-241ccd7a2f56',
ref: { tile: { x: -15, y: 5 } }
}
]
},
{
id: '110033ad-b542-4415-bb95-6bad8de33198',
color: '#a0b9f8',
style: 'SOLID',
width: 10,
anchors: [
{
id: '9ccd7127-1ed6-4ac0-9fcd-31e17c306987',
ref: { node: '8f02ce50-0aa4-4950-b077-c1daf5da023b' }
},
{
id: '4c4f7d59-2b06-46cc-8eed-f3cd2b2af9b3',
ref: { tile: { x: -12, y: 5 } }
}
]
},
{
id: 'f7b5f65c-0503-479b-983d-6b3be7d4355b',
color: '#bbadfb',
style: 'SOLID',
width: 10,
anchors: [
{
id: 'fbeb8d12-597a-4654-92f4-41006b57beb8',
ref: { node: '815b0205-516c-48a9-ac34-2007bb155d75' }
},
{
id: '6f88cf82-23d9-4d95-9823-3c76b1956c9c',
ref: { tile: { x: 9, y: 3 } }
}
]
},
{
id: '1c7ccb81-9f31-4cc3-90ee-2f92c4be1e8f',
color: '#bbadfb',
style: 'SOLID',
width: 10,
anchors: [
{
id: '199f843d-f8ce-4a26-b71a-1b4f799109a9',
ref: { node: 'e4909db0-da09-49a3-8cfd-927ce191c28d' }
},
{
id: 'd2d63da6-a60a-4569-af9f-5d497013bd8e',
ref: { tile: { x: 9, y: -3 } }
}
]
},
{
id: '48435cd4-297f-4078-b5d4-79209babaa0f',
color: '#bbadfb',
style: 'SOLID',
width: 10,
anchors: [
{
id: '9316ac7f-7913-489e-8516-fb74eb9aed9c',
ref: { tile: { x: 9, y: 3 } }
},
{
id: 'd53e7165-c645-4e8f-b4c7-84ebc4369f4a',
ref: { tile: { x: 9, y: 0 } }
},
{
id: '01860aaf-3b57-45f2-b833-15fcafdd4abb',
ref: { tile: { x: 9, y: -3 } }
}
]
},
{
id: '1a58a13e-d136-4c99-a111-143097873147',
color: '#bbadfb',
style: 'SOLID',
width: 10,
anchors: [
{
id: 'fe873a58-afdc-4c8b-b89c-b01332e6264b',
ref: { node: '085f81b9-651d-47a1-94ec-c4cd02bb08c4' }
},
{
id: '93286249-0ee8-4a0d-ae09-c17736b0535f',
ref: { tile: { x: 9, y: 0 } }
}
]
},
{
id: '3abc77ea-357b-4671-95a7-6b17e0219032',
color: '#b3e5e3',
style: 'SOLID',
width: 10,
anchors: [
{
id: '0077477a-8978-48dc-9051-51f9322519bc',
ref: { node: '9f1417b9-8785-498b-bb4e-4ee9a827e611' }
},
{
id: 'e69310e4-f794-4771-aa5f-39291aa090ef',
ref: { tile: { x: -3, y: 11 } }
}
]
},
{
id: '75200d73-06f4-4403-923f-32f0041c9701',
color: '#b3e5e3',
style: 'SOLID',
width: 10,
anchors: [
{
id: '9f5616ae-e638-40af-ba0b-b25f6adae2f7',
ref: { node: '0e1ecd31-ee64-49b9-a5be-3375bd3cece6' }
},
{
id: 'e86b8651-a385-4a96-90cc-2ed2b6daa9f2',
ref: { tile: { x: 3, y: 11 } }
}
]
},
{
id: '4a7ccc74-f296-4d0b-be11-8117c990795d',
color: '#b3e5e3',
style: 'SOLID',
width: 10,
anchors: [
{
id: '7b721b82-220d-45f6-87d4-479c1b847a60',
ref: { node: 'b6cf011d-0bc2-474d-8a4b-022d24ecc5d5' }
},
{
id: '1908c227-8066-4c8d-ba55-d5822c224d52',
ref: { node: '7f9e0f95-b490-4e03-99c7-8df10788d6df' }
}
]
},
{
id: '9f6bc159-507f-4b56-9171-d95f00490230',
color: '#a0b9f8',
style: 'SOLID',
width: 10,
anchors: [
{
id: 'b41f10e5-5d29-4cee-8102-0005cf146300',
ref: { node: '8277f135-e833-40b3-8232-0461b86added' }
},
{
id: '3f5bfd1b-f8b3-4a00-976e-6bc4382749ee',
ref: { tile: { x: -9, y: 0 } }
},
{
id: '3792f3db-edd1-4d43-9f16-0be811687c85',
ref: { node: 'bf5b31ed-97fc-4e23-9ccf-107ba9f1bb58' }
}
]
},
{
id: '5b47caae-fbdd-4d1b-8cc5-0068536fb8b2',
color: '#a0b9f8',
style: 'SOLID',
width: 10,
anchors: [
{
id: '16ba31e6-a713-403d-b23e-4c40b19e7d66',
ref: { node: '680a2ad0-839f-4cfc-8b35-24c557fa1e8f' }
},
{
id: '59675d93-8ad7-4abd-85d4-cfe0004384c1',
ref: { tile: { x: -9, y: 0 } }
},
{
id: '1ab79aec-2587-47eb-9b2f-42e9eb44fe77',
ref: { node: 'bf5b31ed-97fc-4e23-9ccf-107ba9f1bb58' }
}
]
},
{
id: 'a6c63183-ce9a-4d54-9487-569f2a8a8add',
color: '#a0b9f8',
style: 'SOLID',
width: 10,
anchors: [
{
id: '8a2d4f72-5f62-4531-b2f7-b0eed5e533a5',
ref: { node: 'bf5b31ed-97fc-4e23-9ccf-107ba9f1bb58' }
},
{
id: 'ab054b9f-7574-4195-9465-c4a127b8e03e',
ref: { node: 'b6cf011d-0bc2-474d-8a4b-022d24ecc5d5' }
}
]
},
{
id: 'afa7915a-c1f6-4f40-87c0-ace0ecacf5bb',
color: '#b3e5e3',
style: 'SOLID',
width: 10,
anchors: [
{
id: '2b18ab38-7f76-4e63-ae79-ca6cfaa2ee49',
ref: { tile: { x: -3, y: 11 } }
},
{
id: 'a926bd20-a16e-4349-8e31-bb4109ab0387',
ref: { tile: { x: 3, y: 11 } }
}
]
},
{
id: 'c1263273-a57c-4e0b-92b8-04c21b805b29',
color: '#b3e5e3',
style: 'SOLID',
width: 10,
anchors: [
{
id: 'b1ee48ad-70f7-44ff-9d84-b3893ce3dff1',
ref: { node: 'b0368dfe-cc6e-405a-9471-0867c4c51d08' }
},
{
id: 'e6148182-5a09-4e0a-85f4-1389dfb33f8b',
ref: { tile: { x: 0, y: 11 } }
}
]
},
{
id: '5706f58e-6b29-4ec7-aa0b-c59ce6198870',
color: '#b3e5e3',
style: 'SOLID',
width: 10,
anchors: [
{
id: '5a8e0e07-7e0c-40eb-b50c-bba2a2403c2f',
ref: { tile: { x: 0, y: 11 } }
},
{
id: 'b3611f56-35c2-4cc3-8dc6-461c182937e5',
ref: { node: '7f9e0f95-b490-4e03-99c7-8df10788d6df' }
}
]
},
{
id: '17547863-f8f3-4653-92ec-f76d3ec0835f',
color: '#bbadfb',
style: 'SOLID',
width: 10,
anchors: [
{
id: '0445d65f-05b4-47cf-9163-dd0d6eefa4af',
ref: { node: 'b6cf011d-0bc2-474d-8a4b-022d24ecc5d5' }
},
{
id: 'e77a4942-1625-4893-a4f5-634dbc528321',
ref: { node: 'fd5dd3ed-b8de-41ed-a642-b8bd71aaebf8' }
}
]
},
{
id: '2656820b-e1fb-41fd-980f-7b94d6e9906e',
color: '#bbadfb',
style: 'SOLID',
width: 10,
anchors: [
{
id: '2717739f-a2d4-44cf-bc45-5d8ae8bb67dd',
ref: { node: 'fd5dd3ed-b8de-41ed-a642-b8bd71aaebf8' }
},
{
id: 'b0399a5a-3bc3-4864-aa1b-c2194437f644',
ref: { tile: { x: 9, y: 0 } }
}
]
},
{
id: 'e9f0546e-d2f3-4ae3-b6dd-55d3cf774093',
color: '#a8dc9d',
style: 'SOLID',
width: 10,
anchors: [
{
id: '7cca17bf-700b-4192-be94-7b347b73a77a',
ref: { node: '1789808d-6d52-4714-aeea-954f3e4aba66' }
},
{
id: '6e8d5fb2-b995-4e0e-9465-b664ea793f50',
ref: { tile: { x: -2, y: -9 } }
}
]
},
{
id: '6dce44fd-0fb5-4a08-9daa-1716313c59d8',
color: '#a8dc9d',
style: 'SOLID',
width: 10,
anchors: [
{
id: '5a3751f2-bd9f-4ddc-808a-f0f23a2eecd8',
ref: { node: '5096a0f1-7548-4cba-8477-7f07e3bdc206' }
},
{
id: '0f6673bf-b5c4-4e1b-b3aa-bcdca9f24e81',
ref: { tile: { x: 2, y: -9 } }
}
]
},
{
id: '725419a0-6ca4-4d1b-b6a4-a7ae7feabcb0',
color: '#a8dc9d',
style: 'SOLID',
width: 10,
anchors: [
{
id: 'dfdda749-2f06-4a32-903e-35e336d3842d',
ref: { node: 'b6cf011d-0bc2-474d-8a4b-022d24ecc5d5' }
},
{
id: '354700b6-6b10-49cb-a943-f2eeb8e4741b',
ref: { node: 'cb6b557e-01c8-4208-88f8-2076351aa53c' }
}
]
},
{
id: '41431713-1647-4b47-b258-9d3ec4665b77',
color: '#a8dc9d',
style: 'SOLID',
width: 10,
anchors: [
{
id: 'f579ae26-9023-4041-ba62-33da290ca83f',
ref: { tile: { x: 2, y: -9 } }
},
{
id: '2a123a41-1481-4c87-9aca-89a819ee4478',
ref: { tile: { x: -2, y: -9 } }
}
]
},
{
id: '3410464e-d12b-4cf2-bdc4-9eb338ea1c1a',
color: '#a8dc9d',
style: 'SOLID',
width: 10,
anchors: [
{
id: '832bad08-bd8a-4a57-b849-016b0482138c',
ref: { node: 'cb6b557e-01c8-4208-88f8-2076351aa53c' }
},
{
id: 'fe2422cd-1ced-4f4a-a79c-a9f052977cc5',
ref: { tile: { x: 0, y: -9 } }
}
]
},
{
id: '8b95c19e-e5dd-4190-a93a-93fdec1bd37f',
color: '#a0b9f8',
style: 'SOLID',
width: 10,
anchors: [
{
id: 'd1281ea9-0f2c-4e24-a1e9-d0c8a67805ba',
ref: { tile: { x: -15, y: -5 } }
},
{
id: 'c0a7d637-79aa-4400-ab70-2b874e865ad2',
ref: { node: '7c422bf4-bbd2-4364-ad9b-5250c2877695' }
}
]
},
{
id: '7013bdc5-31f0-4e64-8604-95eba5e1ca8e',
color: '#a0b9f8',
style: 'SOLID',
width: 10,
anchors: [
{
id: 'e1d4e165-649f-48a7-93aa-2c8181289901',
ref: { tile: { x: -9, y: -5 } }
},
{
id: '1f3e5712-a5be-4d2b-ba86-a0ec2e4ab439',
ref: { node: '2420494b-312a-4ddf-b8c3-591fa55563cb' }
}
]
}
],
textBoxes: [
{
id: '29e365cf-5f40-4355-8a2d-db45a509b140',
orientation: 'Y',
fontSize: 0.6,
tile: { x: -2, y: -1 },
text: 'AODB'
},
{
id: '40964f5b-7ebd-4c0d-9666-f8ff2de65dc8',
orientation: 'Y',
fontSize: 0.6,
tile: { x: 8, y: -1 },
text: 'Airside operations'
},
{
id: 'c6d16ec6-7110-4082-9370-44e127984b90',
orientation: 'X',
fontSize: 0.6,
tile: { x: -3, y: -13 },
text: 'Invoicing & Billing'
},
{
id: '57156c5e-9b82-4652-852d-422014228b74',
orientation: 'X',
fontSize: 0.6,
tile: { x: -16, y: -10 },
text: 'Passenger facilitation'
},
{
id: '92637173-542c-4d25-addf-ab50eda7a573',
orientation: 'X',
fontSize: 0.6,
tile: { x: -16, y: 4 },
text: 'Terminal management'
},
{
id: 'e555aad0-9e21-48db-9d71-53125c33c214',
orientation: 'X',
fontSize: 0.6,
tile: { x: -4, y: 10 },
text: 'Information management'
}
],
rectangles: [
{
id: '82ef2751-9ea7-40fc-b3f5-3ea8ce1e1d67',
color: '#fad6ac',
from: { x: -1, y: 1 },
to: { x: 1, y: -1 }
},
{
id: '7d730e86-7884-400d-857a-8af7f22a9937',
color: '#a0b9f8',
from: { x: -8, y: -5 },
to: { x: -16, y: -9 }
},
{
id: 'f41434d1-bbe6-4d6d-a13d-7c9ad52e8e62',
color: '#a0b9f8',
from: { x: -16, y: 8 },
to: { x: -8, y: 5 }
},
{
id: '4ffe42ed-9cd0-48a9-93a0-6099ab53a146',
color: '#a8dc9d',
from: { x: -3, y: -9 },
to: { x: 3, y: -12 }
},
{
id: 'dc93e93b-2c6c-43a1-bb82-ad93c04f7707',
color: '#bbadfb',
from: { x: 9, y: 4 },
to: { x: 12, y: -4 }
},
{
id: 'eec5c861-6192-4eb6-a378-7554bda4d9a7',
color: '#b3e5e3',
from: { x: -4, y: 14 },
to: { x: 4, y: 11 }
}
]
};

14
src/fixtures/icons.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Model } from 'src/types';
export const icons: Model['icons'] = [
{
id: 'icon1',
name: 'Icon1',
url: 'https://isoflow.io/static/assets/icons/networking/server.svg'
},
{
id: 'icon2',
name: 'Icon2',
url: 'https://isoflow.io/static/assets/icons/networking/block.svg'
}
];

13
src/fixtures/model.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Model } from 'src/types';
import { icons } from './icons';
import { modelItems } from './modelItems';
import { views } from './views';
export const model: Model = {
title: 'TestModel',
description: 'TestModelDescription',
version: '1.0.0',
icons,
items: modelItems,
views
};

View File

@@ -0,0 +1,20 @@
import { Model } from 'src/types';
export const modelItems: Model['items'] = [
{
id: 'node1',
name: 'Node1',
icon: 'icon1',
description: 'Node1Description'
},
{
id: 'node2',
name: 'Node2',
icon: 'icon2'
},
{
id: 'node3',
name: 'Node3',
icon: 'icon1'
}
];

View File

@@ -1,61 +0,0 @@
import { SceneInput } from 'src/types';
export const scene: SceneInput = {
title: 'TestScene',
icons: [
{
id: 'icon1',
name: 'Icon1',
url: 'https://isoflow.io/static/assets/icons/networking/server.svg'
},
{
id: 'icon2',
name: 'Icon2',
url: 'https://isoflow.io/static/assets/icons/networking/block.svg'
}
],
nodes: [
{
id: 'node1',
label: 'Node1',
icon: 'icon1',
tile: {
x: 0,
y: 0
}
},
{
id: 'node2',
label: 'Node2',
icon: 'icon2',
tile: {
x: 1,
y: 1
}
},
{
id: 'node3',
label: 'Node3',
icon: 'icon1',
tile: {
x: 2,
y: 2
}
}
],
connectors: [
{
id: 'connector1',
anchors: [{ ref: { node: 'node1' } }, { ref: { node: 'node2' } }]
},
{
id: 'connector2',
anchors: [
{ id: 'anchor1', ref: { node: 'node2' } },
{ ref: { node: 'node3' } }
]
}
],
textBoxes: [],
rectangles: [{ id: 'rectangle1', from: { x: 0, y: 0 }, to: { x: 2, y: 2 } }]
};

29
src/fixtures/views.ts Normal file
View File

@@ -0,0 +1,29 @@
import { Model } from 'src/types';
export const views: Model['views'] = [
{
id: 'View1',
name: 'View1',
description: 'View1Description',
items: [],
rectangles: [
{ id: 'rectangle1', from: { x: 0, y: 0 }, to: { x: 2, y: 2 } }
],
connectors: [
{
id: 'connector1',
anchors: [
{ id: 'anch1-1', ref: { item: 'node1' } },
{ id: 'anch1-2', ref: { item: 'node2' } }
]
},
{
id: 'connector2',
anchors: [
{ id: 'anch2-1', ref: { item: 'node2' } },
{ id: 'anch2-2', ref: { item: 'node3' } }
]
}
]
}
];

3
src/global.d.ts vendored
View File

@@ -1,4 +1,4 @@
import { Size, Coords, SceneInput } from 'src/types';
import { Size, Coords } from 'src/types';
declare global {
let PACKAGE_VERSION: string;
@@ -8,7 +8,6 @@ declare global {
Isoflow: {
getUnprojectedBounds: () => Size & Coords;
fitToView: () => void;
setScene: (scene: SceneInput) => void;
};
}
}

View File

@@ -1,15 +1,22 @@
import { useMemo } from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { getItemById } from 'src/utils';
import { Connector, SceneConnector } from 'src/types';
import { CONNECTOR_DEFAULTS } from 'src/config';
import { getItemByIdOrThrow } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
export const useConnector = (id: string) => {
const connectors = useSceneStore((state) => {
return state.connectors;
});
export const useConnector = (
id: string
): Required<Connector> & SceneConnector => {
const { connectors } = useScene();
const node = useMemo(() => {
return getItemById(connectors, id).item;
const connector = useMemo(() => {
const con = getItemByIdOrThrow(connectors ?? [], id).value;
return {
...CONNECTOR_DEFAULTS,
...con
};
}, [connectors, id]);
return node;
return connector;
};

View File

@@ -1,5 +1,4 @@
import { useCallback } from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { Size, Coords } from 'src/types';
import {
@@ -7,28 +6,17 @@ import {
getBoundingBoxSize,
sortByPosition,
clamp,
getAnchorTile,
getAllAnchors,
getTilePosition,
CoordsUtils
} from 'src/utils';
import { MAX_ZOOM } from 'src/config';
import { useScene } from 'src/hooks/useScene';
import { useResizeObserver } from './useResizeObserver';
const BOUNDING_BOX_PADDING = 1;
export const useDiagramUtils = () => {
const scene = useSceneStore(
({ nodes, rectangles, connectors, icons, textBoxes }) => {
return {
nodes,
rectangles,
connectors,
icons,
textBoxes
};
}
);
const scene = useScene();
const rendererEl = useUiStateStore((state) => {
return state.rendererEl;
});
@@ -38,50 +26,48 @@ export const useDiagramUtils = () => {
});
const getProjectBounds = useCallback((): Coords[] => {
const items = [
...scene.nodes,
...scene.connectors,
...scene.rectangles,
...scene.textBoxes
];
const itemTiles = scene.items.map((item) => {
return item.tile;
});
let tiles = items.reduce<Coords[]>((acc, item) => {
switch (item.type) {
case 'NODE':
return [...acc, item.tile];
case 'CONNECTOR':
return [
...acc,
...item.anchors.map((anchor) => {
return getAnchorTile(
anchor,
scene.nodes,
getAllAnchors(scene.connectors)
);
})
];
case 'RECTANGLE':
return [...acc, item.from, item.to];
case 'TEXTBOX':
return [
...acc,
item.tile,
CoordsUtils.add(item.tile, {
x: item.size.width,
y: item.size.height
})
];
default:
return acc;
}
const connectorTiles = scene.connectors.reduce<Coords[]>(
(acc, connector) => {
return [...acc, ...connector.path.tiles];
},
[]
);
const rectangleTiles = scene.rectangles.reduce<Coords[]>(
(acc, rectangle) => {
return [...acc, rectangle.from, rectangle.to];
},
[]
);
const textBoxTiles = scene.textBoxes.reduce<Coords[]>((acc, textBox) => {
return [
...acc,
textBox.tile,
CoordsUtils.add(textBox.tile, {
x: textBox.size.width,
y: textBox.size.height
})
];
}, []);
if (tiles.length === 0) {
let allTiles = [
...itemTiles,
...connectorTiles,
...rectangleTiles,
...textBoxTiles
];
if (allTiles.length === 0) {
const centerTile = CoordsUtils.zero();
tiles = [centerTile, centerTile, centerTile, centerTile];
allTiles = [centerTile, centerTile, centerTile, centerTile];
}
const corners = getBoundingBox(tiles, {
const corners = getBoundingBox(allTiles, {
x: BOUNDING_BOX_PADDING,
y: BOUNDING_BOX_PADDING
});

View File

@@ -1,17 +1,20 @@
import React, { useMemo, useEffect } from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { getItemById } from 'src/utils';
import { useModelStore } from 'src/stores/modelStore';
import { getItemByIdOrThrow } from 'src/utils';
import { IsometricIcon } from 'src/components/SceneLayers/Nodes/Node/IconTypes/IsometricIcon';
import { NonIsometricIcon } from 'src/components/SceneLayers/Nodes/Node/IconTypes/NonIsometricIcon';
import { DEFAULT_ICON } from 'src/config';
export const useIcon = (id: string) => {
export const useIcon = (id: string | undefined) => {
const [hasLoaded, setHasLoaded] = React.useState(false);
const icons = useSceneStore((state) => {
const icons = useModelStore((state) => {
return state.icons;
});
const icon = useMemo(() => {
return getItemById(icons, id).item;
if (!id) return DEFAULT_ICON;
return getItemByIdOrThrow(icons, id).value;
}, [icons, id]);
useEffect(() => {

View File

@@ -1,11 +1,11 @@
import { useMemo, useCallback } from 'react';
import { IconCollectionStateWithIcons, IconCollectionState } from 'src/types';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useSceneStore } from 'src/stores/sceneStore';
import { useModelStore } from 'src/stores/modelStore';
import { categoriseIcons } from 'src/utils';
export const useIconCategories = () => {
const icons = useSceneStore((state) => {
const icons = useModelStore((state) => {
return state.icons;
});
const iconCategoriesState = useUiStateStore((state) => {

View File

@@ -1,11 +1,11 @@
import { useState, useMemo } from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { useModelStore } from 'src/stores/modelStore';
import { Icon } from 'src/types';
export const useIconFiltering = () => {
const [filter, setFilter] = useState<string>('');
const icons = useSceneStore((state) => {
const icons = useModelStore((state) => {
return state.icons;
});

View File

@@ -1,10 +1,5 @@
import { useMemo } from 'react';
import {
Coords,
TileOriginEnum,
Size,
ProjectionOrientationEnum
} from 'src/types';
import { Coords, Size, ProjectionOrientationEnum } from 'src/types';
import {
getBoundingBox,
getIsoProjectionCss,
@@ -48,7 +43,7 @@ export const useIsoProjection = ({
const position = useMemo(() => {
const pos = getTilePosition({
tile: origin,
origin: orientation === 'Y' ? TileOriginEnum.TOP : TileOriginEnum.LEFT
origin: orientation === 'Y' ? 'TOP' : 'LEFT'
});
return pos;

16
src/hooks/useModelItem.ts Normal file
View File

@@ -0,0 +1,16 @@
import { useMemo } from 'react';
import { ModelItem } from 'src/types';
import { useModelStore } from 'src/stores/modelStore';
import { getItemByIdOrThrow } from 'src/utils';
export const useModelItem = (id: string): ModelItem => {
const model = useModelStore((state) => {
return state;
});
const modelItem = useMemo(() => {
return getItemByIdOrThrow(model.items, id).value;
}, [id, model.items]);
return modelItem;
};

View File

@@ -1,15 +0,0 @@
import { useMemo } from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { getItemById } from 'src/utils';
export const useNode = (id: string) => {
const nodes = useSceneStore((state) => {
return state.nodes;
});
const node = useMemo(() => {
return getItemById(nodes, id).item;
}, [nodes, id]);
return node;
};

View File

@@ -1,15 +1,17 @@
import { useMemo } from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { getItemById } from 'src/utils';
import { getItemByIdOrThrow } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
import { RECTANGLE_DEFAULTS } from 'src/config';
export const useRectangle = (id: string) => {
const rectangles = useSceneStore((state) => {
return state.rectangles;
});
const { rectangles } = useScene();
const rectangle = useMemo(() => {
return getItemById(rectangles, id).item;
return getItemByIdOrThrow(rectangles, id).value;
}, [rectangles, id]);
return rectangle;
return {
...RECTANGLE_DEFAULTS,
...rectangle
};
};

251
src/hooks/useScene.ts Normal file
View File

@@ -0,0 +1,251 @@
import { useCallback, useMemo } from 'react';
import { ModelItem, ViewItem, Connector, TextBox, Rectangle } from 'src/types';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { useModelStore } from 'src/stores/modelStore';
import { useSceneStore } from 'src/stores/sceneStore';
import * as reducers from 'src/stores/reducers';
import type { State } from 'src/stores/reducers/types';
import { getItemByIdOrThrow } from 'src/utils';
export const useScene = () => {
const model = useModelStore((state) => {
return state;
});
const scene = useSceneStore((state) => {
return state;
});
const currentViewId = useUiStateStore((state) => {
return state.view;
});
const currentView = useMemo(() => {
return getItemByIdOrThrow(model.views, currentViewId).value;
}, [currentViewId, model.views]);
const items = useMemo(() => {
return currentView.items ?? [];
}, [currentView.items]);
const connectors = useMemo(() => {
return (currentView.connectors ?? []).map((connector) => {
const sceneConnector = scene.connectors[connector.id];
return {
...connector,
...sceneConnector
};
});
}, [currentView.connectors, scene.connectors]);
const rectangles = useMemo(() => {
return currentView.rectangles ?? [];
}, [currentView.rectangles]);
const textBoxes = useMemo(() => {
return (currentView.textBoxes ?? []).map((textBox) => {
const sceneTextBox = scene.textBoxes[textBox.id];
return {
...textBox,
...sceneTextBox
};
});
}, [currentView.textBoxes, scene.textBoxes]);
const getState = useCallback(() => {
return {
model: model.actions.get(),
scene: scene.actions.get()
};
}, [model.actions, scene.actions]);
const setState = useCallback(
(newState: State) => {
model.actions.set(newState.model);
scene.actions.set(newState.scene);
},
[model.actions, scene.actions]
);
const createModelItem = useCallback(
(newModelItem: ModelItem) => {
const newState = reducers.createModelItem(newModelItem, getState());
setState(newState);
},
[getState, setState]
);
const updateModelItem = useCallback(
(id: string, updates: Partial<ModelItem>) => {
const newState = reducers.updateModelItem(id, updates, getState());
setState(newState);
},
[getState, setState]
);
const deleteModelItem = useCallback(
(id: string) => {
const newState = reducers.deleteModelItem(id, getState());
setState(newState);
},
[getState, setState]
);
const createViewItem = useCallback(
(newViewItem: ViewItem) => {
const newState = reducers.createViewItem(
newViewItem,
currentViewId,
getState()
);
setState(newState);
},
[getState, setState, currentViewId]
);
const updateViewItem = useCallback(
(id: string, updates: Partial<ViewItem>) => {
const newState = reducers.updateViewItem(
id,
updates,
currentViewId,
getState()
);
setState(newState);
},
[getState, setState, currentViewId]
);
const deleteViewItem = useCallback(
(id: string) => {
const newState = reducers.deleteViewItem(id, currentViewId, getState());
setState(newState);
},
[getState, setState, currentViewId]
);
const createConnector = useCallback(
(newConnector: Connector) => {
const newState = reducers.createConnector(
newConnector,
currentViewId,
getState()
);
setState(newState);
},
[getState, setState, currentViewId]
);
const updateConnector = useCallback(
(id: string, updates: Partial<Connector>) => {
const newState = reducers.updateConnector(
id,
updates,
currentViewId,
getState()
);
setState(newState);
},
[getState, setState, currentViewId]
);
const deleteConnector = useCallback(
(id: string) => {
const newState = reducers.deleteConnector(id, currentViewId, getState());
setState(newState);
},
[getState, setState, currentViewId]
);
const createTextBox = useCallback(
(newTextBox: TextBox) => {
const newState = reducers.createTextBox(
newTextBox,
currentViewId,
getState()
);
setState(newState);
},
[getState, setState, currentViewId]
);
const updateTextBox = useCallback(
(id: string, updates: Partial<TextBox>) => {
const newState = reducers.updateTextBox(
id,
updates,
currentViewId,
getState()
);
setState(newState);
},
[getState, setState, currentViewId]
);
const deleteTextBox = useCallback(
(id: string) => {
const newState = reducers.deleteTextBox(id, currentViewId, getState());
setState(newState);
},
[getState, setState, currentViewId]
);
const createRectangle = useCallback(
(newRectangle: Rectangle) => {
const newState = reducers.createRectangle(
newRectangle,
currentViewId,
getState()
);
setState(newState);
},
[getState, setState, currentViewId]
);
const updateRectangle = useCallback(
(id: string, updates: Partial<Rectangle>) => {
const newState = reducers.updateRectangle(
id,
updates,
currentViewId,
getState()
);
setState(newState);
},
[getState, setState, currentViewId]
);
const deleteRectangle = useCallback(
(id: string) => {
const newState = reducers.deleteRectangle(id, currentViewId, getState());
setState(newState);
},
[getState, setState, currentViewId]
);
return {
items,
connectors,
rectangles,
textBoxes,
currentView,
createModelItem,
updateModelItem,
deleteModelItem,
createViewItem,
updateViewItem,
deleteViewItem,
createConnector,
updateConnector,
deleteConnector,
createTextBox,
updateTextBox,
deleteTextBox,
createRectangle,
updateRectangle,
deleteRectangle
};
};

View File

@@ -1,14 +1,12 @@
import { useMemo } from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { getItemById } from 'src/utils';
import { getItemByIdOrThrow } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
export const useTextBox = (id: string) => {
const textBoxes = useSceneStore((state) => {
return state.textBoxes;
});
const { textBoxes } = useScene();
const textBox = useMemo(() => {
return getItemById(textBoxes, id).item;
return getItemByIdOrThrow(textBoxes, id).value;
}, [textBoxes, id]);
return textBox;

View File

@@ -3,20 +3,23 @@ import { TextBox } from 'src/types';
import {
UNPROJECTED_TILE_SIZE,
DEFAULT_FONT_FAMILY,
TEXTBOX_DEFAULTS
TEXTBOX_DEFAULTS,
TEXTBOX_FONT_WEIGHT,
TEXTBOX_PADDING
} from 'src/config';
export const useTextBoxProps = (textBox: TextBox) => {
const fontProps = useMemo(() => {
return {
fontSize: UNPROJECTED_TILE_SIZE * textBox.fontSize,
fontSize:
UNPROJECTED_TILE_SIZE * (textBox.fontSize ?? TEXTBOX_DEFAULTS.fontSize),
fontFamily: DEFAULT_FONT_FAMILY,
fontWeight: TEXTBOX_DEFAULTS.fontWeight
fontWeight: TEXTBOX_FONT_WEIGHT
};
}, [textBox.fontSize]);
const paddingX = useMemo(() => {
return UNPROJECTED_TILE_SIZE * TEXTBOX_DEFAULTS.paddingX;
return UNPROJECTED_TILE_SIZE * TEXTBOX_PADDING;
}, []);
return { paddingX, fontProps };

13
src/hooks/useViewItem.ts Normal file
View File

@@ -0,0 +1,13 @@
import { useMemo } from 'react';
import { getItemByIdOrThrow } from 'src/utils';
import { useScene } from 'src/hooks/useScene';
export const useViewItem = (id: string) => {
const { items } = useScene();
const viewItem = useMemo(() => {
return getItemByIdOrThrow(items, id).value;
}, [items, id]);
return viewItem;
};

View File

@@ -1,26 +1,13 @@
import { useEffect } from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { useDiagramUtils } from 'src/hooks/useDiagramUtils';
export const useWindowUtils = () => {
const scene = useSceneStore(({ nodes, rectangles, connectors, icons }) => {
return {
nodes,
rectangles,
connectors,
icons
};
});
const sceneActions = useSceneStore(({ actions }) => {
return actions;
});
const { fitToView, getUnprojectedBounds } = useDiagramUtils();
useEffect(() => {
window.Isoflow = {
getUnprojectedBounds,
fitToView,
setScene: sceneActions.setScene
fitToView
};
}, [getUnprojectedBounds, fitToView, scene, sceneActions]);
}, [getUnprojectedBounds, fitToView]);
};

View File

@@ -2,14 +2,11 @@ import { produce } from 'immer';
import {
generateId,
getItemAtTile,
connectorInputToConnector,
connectorToConnectorInput,
getConnectorPath,
getItemByIdOrThrow,
hasMovedTile,
setWindowCursor,
getAllAnchors
setWindowCursor
} from 'src/utils';
import { ModeActions, SceneItemTypeEnum } from 'src/types';
import { ModeActions, Connector as ConnectorI } from 'src/types';
export const Connector: ModeActions = {
entry: () => {
@@ -21,111 +18,75 @@ export const Connector: ModeActions = {
mousemove: ({ uiState, scene }) => {
if (
uiState.mode.type !== 'CONNECTOR' ||
!uiState.mode.connector?.anchors[0] ||
!uiState.mode.id ||
!hasMovedTile(uiState.mouse)
)
return;
// TODO: Items at tile should take the entire scene in and return just the first item of interest
// for efficiency
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
});
if (itemAtTile && itemAtTile.type === 'NODE') {
const newMode = produce(uiState.mode, (draft) => {
if (!draft.connector) return;
draft.connector.anchors[1] = {
id: generateId(),
type: SceneItemTypeEnum.CONNECTOR_ANCHOR,
ref: {
type: 'NODE',
id: itemAtTile.id
}
};
draft.connector.path = getConnectorPath({
anchors: draft.connector.anchors,
nodes: scene.nodes,
allAnchors: getAllAnchors(scene.connectors)
});
});
uiState.actions.setMode(newMode);
} else {
const newMode = produce(uiState.mode, (draft) => {
if (!draft.connector) return;
draft.connector.anchors[1] = {
id: generateId(),
type: SceneItemTypeEnum.CONNECTOR_ANCHOR,
ref: {
type: 'TILE',
coords: uiState.mouse.position.tile
}
};
draft.connector.path = getConnectorPath({
anchors: draft.connector.anchors,
nodes: scene.nodes,
allAnchors: getAllAnchors(scene.connectors)
});
});
uiState.actions.setMode(newMode);
}
},
mousedown: ({ uiState, scene }) => {
if (uiState.mode.type !== 'CONNECTOR') return;
const connector = getItemByIdOrThrow(scene.connectors, uiState.mode.id);
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
});
if (itemAtTile && itemAtTile.type === 'NODE') {
const newMode = produce(uiState.mode, (draft) => {
draft.connector = connectorInputToConnector(
{
id: generateId(),
anchors: [
{ ref: { node: itemAtTile.id } },
{ ref: { node: itemAtTile.id } }
]
},
scene.nodes,
getAllAnchors(scene.connectors)
);
if (itemAtTile?.type === 'ITEM') {
const newConnector = produce(connector.value, (draft) => {
draft.anchors[1] = { id: generateId(), ref: { item: itemAtTile.id } };
});
uiState.actions.setMode(newMode);
scene.updateConnector(uiState.mode.id, newConnector);
} else {
const newMode = produce(uiState.mode, (draft) => {
draft.connector = connectorInputToConnector(
{
id: generateId(),
anchors: [
{ ref: { tile: uiState.mouse.position.tile } },
{ ref: { tile: uiState.mouse.position.tile } }
]
},
scene.nodes,
getAllAnchors(scene.connectors)
);
const newConnector = produce(connector.value, (draft) => {
draft.anchors[1] = {
id: generateId(),
ref: { tile: uiState.mouse.position.tile }
};
});
uiState.actions.setMode(newMode);
scene.updateConnector(uiState.mode.id, newConnector);
}
},
mouseup: ({ uiState, scene, isRendererInteraction }) => {
mousedown: ({ uiState, scene, isRendererInteraction }) => {
if (uiState.mode.type !== 'CONNECTOR' || !isRendererInteraction) return;
if (uiState.mode.connector && uiState.mode.connector.anchors.length >= 2) {
scene.actions.createConnector(
connectorToConnectorInput(uiState.mode.connector)
);
const newConnector: ConnectorI = {
id: generateId(),
anchors: []
};
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
});
if (itemAtTile && itemAtTile.type === 'ITEM') {
newConnector.anchors = [
{ id: generateId(), ref: { item: itemAtTile.id } },
{ id: generateId(), ref: { item: itemAtTile.id } }
];
} else {
newConnector.anchors = [
{ id: generateId(), ref: { tile: uiState.mouse.position.tile } },
{ id: generateId(), ref: { tile: uiState.mouse.position.tile } }
];
}
scene.createConnector(newConnector);
uiState.actions.setMode({
type: 'CONNECTOR',
showCursor: true,
id: newConnector.id
});
},
mouseup: ({ uiState, scene }) => {
if (uiState.mode.type !== 'CONNECTOR' || !uiState.mode.id) return;
const connector = getItemByIdOrThrow(scene.connectors, uiState.mode.id);
if (connector.value.path.tiles.length < 2) {
scene.deleteConnector(uiState.mode.id);
}
uiState.actions.setMode({

View File

@@ -1,33 +1,30 @@
import { produce } from 'immer';
import {
ConnectorAnchor,
Connector,
SceneConnector,
ModeActions,
ModeActionsAction,
SceneItemTypeEnum,
SceneStore,
Coords,
Node
View
} from 'src/types';
import {
getItemAtTile,
hasMovedTile,
getAnchorAtTile,
getItemById,
getItemByIdOrThrow,
generateId,
CoordsUtils,
getAnchorTile,
getAllAnchors,
connectorPathTileToGlobal
} from 'src/utils';
import { useScene } from 'src/hooks/useScene';
const getAnchorOrdering = (
anchor: ConnectorAnchor,
connector: Connector,
nodes: Node[],
allAnchors: ConnectorAnchor[]
connector: SceneConnector,
view: View
) => {
const anchorTile = getAnchorTile(anchor, nodes, allAnchors);
const anchorTile = getAnchorTile(anchor, view);
const index = connector.path.tiles.findIndex((pathTile) => {
const globalTile = connectorPathTileToGlobal(
pathTile,
@@ -45,30 +42,32 @@ const getAnchorOrdering = (
return index;
};
const getAnchor = (connectorId: string, tile: Coords, scene: SceneStore) => {
const connector = getItemById(scene.connectors, connectorId).item;
const getAnchor = (
connectorId: string,
tile: Coords,
scene: ReturnType<typeof useScene>
) => {
const connector = getItemByIdOrThrow(scene.connectors, connectorId).value;
const anchor = getAnchorAtTile(tile, connector.anchors);
if (!anchor) {
const newAnchor: ConnectorAnchor = {
id: generateId(),
type: SceneItemTypeEnum.CONNECTOR_ANCHOR,
ref: { type: 'TILE', coords: tile }
ref: { tile }
};
const allAnchors = getAllAnchors(scene.connectors);
const orderedAnchors = [...connector.anchors, newAnchor]
.map((anch) => {
return {
...anch,
ordering: getAnchorOrdering(anch, connector, scene.nodes, allAnchors)
ordering: getAnchorOrdering(anch, connector, scene.currentView)
};
})
.sort((a, b) => {
return a.ordering - b.ordering;
});
scene.actions.updateConnector(connector.id, { anchors: orderedAnchors });
scene.updateConnector(connector.id, { anchors: orderedAnchors });
return newAnchor;
}
@@ -82,19 +81,19 @@ const mousedown: ModeActionsAction = ({
}) => {
if (uiState.mode.type !== 'CURSOR' || !isRendererInteraction) return;
const item = getItemAtTile({
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
});
if (item) {
if (itemAtTile) {
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
draft.mousedownItem = item;
draft.mousedownItem = itemAtTile;
})
);
uiState.actions.setItemControls(item);
uiState.actions.setItemControls(itemAtTile);
} else {
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
@@ -123,7 +122,11 @@ export const Cursor: ModeActions = {
if (item?.type === 'CONNECTOR' && uiState.mouse.mousedown) {
const anchor = getAnchor(item.id, uiState.mouse.mousedown.tile, scene);
item = anchor;
item = {
type: 'CONNECTOR_ANCHOR',
id: anchor.id
};
}
if (item) {
@@ -140,9 +143,9 @@ export const Cursor: ModeActions = {
if (uiState.mode.type !== 'CURSOR' || !isRendererInteraction) return;
if (uiState.mode.mousedownItem) {
if (uiState.mode.mousedownItem.type === 'NODE') {
if (uiState.mode.mousedownItem.type === 'ITEM') {
uiState.actions.setItemControls({
type: 'NODE',
type: 'ITEM',
id: uiState.mode.mousedownItem.id
});
} else if (uiState.mode.mousedownItem.type === 'RECTANGLE') {
@@ -150,15 +153,6 @@ export const Cursor: ModeActions = {
type: 'RECTANGLE',
id: uiState.mode.mousedownItem.id
});
uiState.actions.setMode({
type: 'RECTANGLE.TRANSFORM',
id: uiState.mode.mousedownItem.id,
showCursor: true,
selectedAnchor: null
});
return;
} else if (uiState.mode.mousedownItem.type === 'CONNECTOR') {
uiState.actions.setItemControls({
type: 'CONNECTOR',

View File

@@ -1,7 +1,8 @@
import { produce } from 'immer';
import { ModeActions, Coords, SceneItemReference, SceneStore } from 'src/types';
import { ModeActions, Coords, ItemReference } from 'src/types';
import { useScene } from 'src/hooks/useScene';
import {
getItemById,
getItemByIdOrThrow,
CoordsUtils,
hasMovedTile,
getAnchorParent,
@@ -9,73 +10,67 @@ import {
} from 'src/utils';
const dragItems = (
items: SceneItemReference[],
items: ItemReference[],
tile: Coords,
delta: Coords,
scene: SceneStore
scene: ReturnType<typeof useScene>
) => {
items.forEach((item) => {
if (item.type === 'NODE') {
const node = getItemById(scene.nodes, item.id).item;
if (item.type === 'ITEM') {
const node = getItemByIdOrThrow(scene.items, item.id).value;
scene.actions.updateNode(item.id, {
scene.updateViewItem(item.id, {
tile: CoordsUtils.add(node.tile, delta)
});
} else if (item.type === 'RECTANGLE') {
const rectangle = getItemById(scene.rectangles, item.id).item;
const rectangle = getItemByIdOrThrow(scene.rectangles, item.id).value;
const newFrom = CoordsUtils.add(rectangle.from, delta);
const newTo = CoordsUtils.add(rectangle.to, delta);
scene.actions.updateRectangle(item.id, { from: newFrom, to: newTo });
scene.updateRectangle(item.id, { from: newFrom, to: newTo });
} else if (item.type === 'TEXTBOX') {
const textBox = getItemById(scene.textBoxes, item.id).item;
const textBox = getItemByIdOrThrow(scene.textBoxes, item.id).value;
scene.actions.updateTextBox(item.id, {
scene.updateTextBox(item.id, {
tile: CoordsUtils.add(textBox.tile, delta)
});
} else if (item.type === 'CONNECTOR_ANCHOR') {
const connector = getAnchorParent(item.id, scene.connectors);
const newConnector = produce(connector, (draft) => {
const { item: anchor, index: anchorIndex } = getItemById(
connector.anchors,
item.id
);
const anchor = getItemByIdOrThrow(connector.anchors, item.id);
const itemAtTile = getItemAtTile({ tile, scene });
switch (itemAtTile?.type) {
case 'NODE':
draft.anchors[anchorIndex] = {
...anchor,
case 'ITEM':
draft.anchors[anchor.index] = {
...anchor.value,
ref: {
type: 'NODE',
id: itemAtTile.id
item: itemAtTile.id
}
};
break;
case 'CONNECTOR_ANCHOR':
draft.anchors[anchorIndex] = {
...anchor,
draft.anchors[anchor.index] = {
...anchor.value,
ref: {
type: 'ANCHOR',
id: itemAtTile.id
anchor: itemAtTile.id
}
};
break;
default:
draft.anchors[anchorIndex] = {
...anchor,
draft.anchors[anchor.index] = {
...anchor.value,
ref: {
type: 'TILE',
coords: tile
tile
}
};
break;
}
});
scene.actions.updateConnector(connector.id, newConnector);
scene.updateConnector(connector.id, newConnector);
}
});
};

View File

@@ -3,7 +3,7 @@
// export const Lasso: ModeActions = {
// type: 'LASSO',
// mousemove: ({ uiState, scene }) => {
// mousemove: ({ uiState, Model }) => {
// if (uiState.mode.type !== 'LASSO') return;
// if (uiState.mouse.mousedown === null) return;
@@ -18,7 +18,7 @@
// if (!uiState.mode.isDragging) {
// const { mousedown } = uiState.mouse;
// const items = scene.nodes.filter((node) => {
// const items = Model.nodes.filter((node) => {
// return CoordsUtils.isEqual(node.tile, mousedown.tile);
// });

View File

@@ -1,13 +1,14 @@
import { produce } from 'immer';
import { ModeActions } from 'src/types';
import { getItemAtTile, generateId } from 'src/utils';
import { generateId, getItemAtTile } from 'src/utils';
import { VIEW_ITEM_DEFAULTS } from 'src/config';
export const PlaceElement: ModeActions = {
export const PlaceIcon: ModeActions = {
mousemove: () => {},
mousedown: ({ uiState, scene, isRendererInteraction }) => {
if (uiState.mode.type !== 'PLACE_ELEMENT' || !isRendererInteraction) return;
if (uiState.mode.type !== 'PLACE_ICON' || !isRendererInteraction) return;
if (!uiState.mode.icon) {
if (!uiState.mode.id) {
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
@@ -23,19 +24,27 @@ export const PlaceElement: ModeActions = {
}
},
mouseup: ({ uiState, scene }) => {
if (uiState.mode.type !== 'PLACE_ELEMENT') return;
if (uiState.mode.type !== 'PLACE_ICON') return;
if (uiState.mode.icon !== null) {
scene.actions.createNode({
id: generateId(),
icon: uiState.mode.icon.id,
if (uiState.mode.id !== null) {
const modelItemId = generateId();
scene.createModelItem({
id: modelItemId,
name: 'Untitled',
icon: uiState.mode.id
});
scene.createViewItem({
...VIEW_ITEM_DEFAULTS,
id: modelItemId,
tile: uiState.mouse.position.tile
});
}
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
draft.icon = null;
draft.id = null;
})
);
}

View File

@@ -10,50 +10,41 @@ export const DrawRectangle: ModeActions = {
exit: () => {
setWindowCursor('default');
},
mousemove: ({ uiState }) => {
mousemove: ({ uiState, scene }) => {
if (
uiState.mode.type !== 'RECTANGLE.DRAW' ||
!hasMovedTile(uiState.mouse) ||
!uiState.mode.area ||
!uiState.mode.id ||
!uiState.mouse.mousedown
)
return;
const newMode = produce(uiState.mode, (draft) => {
if (!draft.area) return;
draft.area.to = uiState.mouse.position.tile;
scene.updateRectangle(uiState.mode.id, {
to: uiState.mouse.position.tile
});
uiState.actions.setMode(newMode);
},
mousedown: ({ uiState }) => {
if (uiState.mode.type !== 'RECTANGLE.DRAW') return;
const newMode = produce(uiState.mode, (draft) => {
draft.area = {
from: uiState.mouse.position.tile,
to: uiState.mouse.position.tile
};
});
uiState.actions.setMode(newMode);
},
mouseup: ({ uiState, scene, isRendererInteraction }) => {
if (
uiState.mode.type !== 'RECTANGLE.DRAW' ||
!uiState.mode.area ||
!isRendererInteraction
)
mousedown: ({ uiState, scene, isRendererInteraction }) => {
if (uiState.mode.type !== 'RECTANGLE.DRAW' || !isRendererInteraction)
return;
scene.actions.createRectangle({
id: generateId(),
const newRectangleId = generateId();
scene.createRectangle({
id: newRectangleId,
color: DEFAULT_COLOR,
from: uiState.mode.area.from,
to: uiState.mode.area.to
from: uiState.mouse.position.tile,
to: uiState.mouse.position.tile
});
const newMode = produce(uiState.mode, (draft) => {
draft.id = newRectangleId;
});
uiState.actions.setMode(newMode);
},
mouseup: ({ uiState }) => {
if (uiState.mode.type !== 'RECTANGLE.DRAW' || !uiState.mode.id) return;
uiState.actions.setMode({
type: 'CURSOR',
showCursor: true,

View File

@@ -1,16 +1,10 @@
import { produce } from 'immer';
import {
isWithinBounds,
getItemById,
getItemAtTile,
getItemByIdOrThrow,
getBoundingBox,
outermostCornerPositions,
convertBoundsToNamedAnchors,
hasMovedTile,
isoToScreen
hasMovedTile
} from 'src/utils';
import { TRANSFORM_ANCHOR_SIZE } from 'src/config';
import { ModeActions, AnchorPositionsEnum } from 'src/types';
import { ModeActions } from 'src/types';
export const TransformRectangle: ModeActions = {
entry: () => {},
@@ -24,123 +18,58 @@ export const TransformRectangle: ModeActions = {
if (uiState.mode.selectedAnchor) {
// User is dragging an anchor
const { item: rectangle } = getItemById(
const rectangle = getItemByIdOrThrow(
scene.rectangles,
uiState.mode.id
);
).value;
const rectangleBounds = getBoundingBox([rectangle.to, rectangle.from]);
const namedBounds = convertBoundsToNamedAnchors(rectangleBounds);
if (
uiState.mode.selectedAnchor === AnchorPositionsEnum.BOTTOM_LEFT ||
uiState.mode.selectedAnchor === AnchorPositionsEnum.TOP_RIGHT
uiState.mode.selectedAnchor === 'BOTTOM_LEFT' ||
uiState.mode.selectedAnchor === 'TOP_RIGHT'
) {
const nextBounds = getBoundingBox([
uiState.mode.selectedAnchor === AnchorPositionsEnum.BOTTOM_LEFT
uiState.mode.selectedAnchor === 'BOTTOM_LEFT'
? namedBounds.TOP_RIGHT
: namedBounds.BOTTOM_LEFT,
uiState.mouse.position.tile
]);
const nextNamedBounds = convertBoundsToNamedAnchors(nextBounds);
scene.actions.updateRectangle(uiState.mode.id, {
scene.updateRectangle(uiState.mode.id, {
from: nextNamedBounds.TOP_RIGHT,
to: nextNamedBounds.BOTTOM_LEFT
});
} else if (
uiState.mode.selectedAnchor === AnchorPositionsEnum.BOTTOM_RIGHT ||
uiState.mode.selectedAnchor === AnchorPositionsEnum.TOP_LEFT
uiState.mode.selectedAnchor === 'BOTTOM_RIGHT' ||
uiState.mode.selectedAnchor === 'TOP_LEFT'
) {
const nextBounds = getBoundingBox([
uiState.mode.selectedAnchor === AnchorPositionsEnum.BOTTOM_RIGHT
uiState.mode.selectedAnchor === 'BOTTOM_RIGHT'
? namedBounds.TOP_LEFT
: namedBounds.BOTTOM_RIGHT,
uiState.mouse.position.tile
]);
const nextNamedBounds = convertBoundsToNamedAnchors(nextBounds);
scene.actions.updateRectangle(uiState.mode.id, {
scene.updateRectangle(uiState.mode.id, {
from: nextNamedBounds.TOP_LEFT,
to: nextNamedBounds.BOTTOM_RIGHT
});
}
}
},
mousedown: ({ uiState, scene, rendererSize }) => {
if (uiState.mode.type !== 'RECTANGLE.TRANSFORM') return;
const { item: rectangle } = getItemById(scene.rectangles, uiState.mode.id);
// Check if the user has mousedown'd on an anchor
const rectangleBounds = getBoundingBox([rectangle.to, rectangle.from]);
const anchorPositions = rectangleBounds.map((corner, i) => {
return isoToScreen({
tile: corner,
origin: outermostCornerPositions[i],
rendererSize
});
});
const activeAnchorIndex = anchorPositions.findIndex((anchorPosition) => {
return isWithinBounds(uiState.mouse.position.screen, [
{
x: anchorPosition.x - TRANSFORM_ANCHOR_SIZE,
y: anchorPosition.y - TRANSFORM_ANCHOR_SIZE
},
{
x: anchorPosition.x + TRANSFORM_ANCHOR_SIZE,
y: anchorPosition.y + TRANSFORM_ANCHOR_SIZE
}
]);
});
if (activeAnchorIndex !== -1) {
const activeAnchor =
Object.values(AnchorPositionsEnum)[activeAnchorIndex];
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
draft.selectedAnchor = activeAnchor;
})
);
return;
}
// Check if the userhas mousedown'd on the rectangle itself
const isMouseWithinRectangle = isWithinBounds(uiState.mouse.position.tile, [
rectangle.from,
rectangle.to
]);
if (isMouseWithinRectangle) {
uiState.actions.setMode({
type: 'CURSOR',
mousedownItem: rectangle,
showCursor: true
});
return;
}
const itemAtTile = getItemAtTile({
tile: uiState.mouse.position.tile,
scene
});
uiState.actions.setMode({
type: 'CURSOR',
mousedownItem: itemAtTile,
showCursor: true
});
mousedown: () => {
// MOUSE_DOWN is triggered by the anchor iteself (see `TransformAnchor.tsx`)
},
mouseup: ({ uiState }) => {
if (uiState.mode.type !== 'RECTANGLE.TRANSFORM') return;
if (uiState.mode.selectedAnchor) {
uiState.actions.setMode(
produce(uiState.mode, (draft) => {
draft.selectedAnchor = null;
})
);
}
uiState.actions.setMode({
type: 'CURSOR',
mousedownItem: null,
showCursor: true
});
}
};

View File

@@ -1,6 +1,5 @@
import { setWindowCursor, generateId } from 'src/utils';
import { setWindowCursor } from 'src/utils';
import { ModeActions } from 'src/types';
import { TEXTBOX_DEFAULTS } from 'src/config';
export const TextBox: ModeActions = {
entry: () => {
@@ -9,27 +8,29 @@ export const TextBox: ModeActions = {
exit: () => {
setWindowCursor('default');
},
mousemove: () => {},
mouseup: ({ scene, uiState, isRendererInteraction }) => {
if (!isRendererInteraction) return;
mousemove: ({ uiState, scene }) => {
if (uiState.mode.type !== 'TEXTBOX' || !uiState.mode.id) return;
const id = generateId();
scene.actions.createTextBox({
...TEXTBOX_DEFAULTS,
id,
scene.updateTextBox(uiState.mode.id, {
tile: uiState.mouse.position.tile
});
},
mouseup: ({ uiState, scene, isRendererInteraction }) => {
if (uiState.mode.type !== 'TEXTBOX' || !uiState.mode.id) return;
if (!isRendererInteraction) {
scene.deleteTextBox(uiState.mode.id);
} else {
uiState.actions.setItemControls({
type: 'TEXTBOX',
id: uiState.mode.id
});
}
uiState.actions.setMode({
type: 'CURSOR',
showCursor: true,
mousedownItem: null
});
uiState.actions.setItemControls({
type: 'TEXTBOX',
id
});
}
};

View File

@@ -1,16 +1,17 @@
import { useCallback, useEffect, useRef } from 'react';
import { useSceneStore } from 'src/stores/sceneStore';
import { useModelStore } from 'src/stores/modelStore';
import { useUiStateStore } from 'src/stores/uiStateStore';
import { ModeActions, State, SlimMouseEvent } from 'src/types';
import { getMouse } from 'src/utils';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { useScene } from 'src/hooks/useScene';
import { Cursor } from './modes/Cursor';
import { DragItems } from './modes/DragItems';
import { DrawRectangle } from './modes/Rectangle/DrawRectangle';
import { TransformRectangle } from './modes/Rectangle/TransformRectangle';
import { Connector } from './modes/Connector';
import { Pan } from './modes/Pan';
import { PlaceElement } from './modes/PlaceElement';
import { PlaceIcon } from './modes/PlaceIcon';
import { TextBox } from './modes/TextBox';
const modes: { [k in string]: ModeActions } = {
@@ -21,7 +22,7 @@ const modes: { [k in string]: ModeActions } = {
'RECTANGLE.TRANSFORM': TransformRectangle,
CONNECTOR: Connector,
PAN: Pan,
PLACE_ELEMENT: PlaceElement,
PLACE_ICON: PlaceIcon,
TEXTBOX: TextBox
};
@@ -44,9 +45,10 @@ export const useInteractionManager = () => {
const uiState = useUiStateStore((state) => {
return state;
});
const scene = useSceneStore((state) => {
const model = useModelStore((state) => {
return state;
});
const scene = useScene();
const { size: rendererSize } = useResizeObserver(uiState.rendererEl);
const onMouseEvent = useCallback(
@@ -70,6 +72,7 @@ export const useInteractionManager = () => {
uiState.actions.setMouse(nextMouse);
const baseState: State = {
model,
scene,
uiState,
rendererRef: rendererRef.current,
@@ -94,7 +97,7 @@ export const useInteractionManager = () => {
modeFunction(baseState);
reducerTypeRef.current = uiState.mode.type;
},
[scene, uiState, rendererSize]
[model, scene, uiState, rendererSize]
);
useEffect(() => {
@@ -146,11 +149,11 @@ export const useInteractionManager = () => {
};
}, [uiState.editorMode, onMouseEvent, uiState.mode.type]);
const setElement = useCallback((element: HTMLElement) => {
const setInteractionsElement = useCallback((element: HTMLElement) => {
rendererRef.current = element;
}, []);
return {
setElement
setInteractionsElement
};
};

View File

@@ -1,5 +1,5 @@
// This file will be exported as it's own bundle (separate to the main bundle). This is because the main
// bundle requires `window` to be present and so can't be imported into a Node environment.
export { INITIAL_SCENE } from 'src/config';
export { sceneInput } from 'src/validation/scene';
export { INITIAL_DATA } from 'src/config';
export { modelSchema } from 'src/validation/model';
export const version = PACKAGE_VERSION;

55
src/stores/modelStore.tsx Normal file
View File

@@ -0,0 +1,55 @@
import React, { createContext, useRef, useContext } from 'react';
import { createStore, useStore } from 'zustand';
import { ModelStore } from 'src/types';
import { INITIAL_DATA } from 'src/config';
const initialState = () => {
return createStore<ModelStore>((set, get) => {
return {
...INITIAL_DATA,
actions: {
get,
set
}
};
});
};
const ModelContext = createContext<ReturnType<typeof initialState> | null>(
null
);
interface ProviderProps {
children: React.ReactNode;
}
// TODO: Typings below are pretty gnarly due to the way Zustand works.
// see https://github.com/pmndrs/zustand/discussions/1180#discussioncomment-3439061
export const ModelProvider = ({ children }: ProviderProps) => {
const storeRef = useRef<ReturnType<typeof initialState>>();
if (!storeRef.current) {
storeRef.current = initialState();
}
return (
<ModelContext.Provider value={storeRef.current}>
{children}
</ModelContext.Provider>
);
};
export function useModelStore<T>(
selector: (state: ModelStore) => T,
equalityFn?: (left: T, right: T) => boolean
) {
const store = useContext(ModelContext);
if (store === null) {
throw new Error('Missing provider in the tree');
}
const value = useStore(store, selector, equalityFn);
return value;
}

View File

@@ -0,0 +1,95 @@
import { Connector } from 'src/types';
import { produce } from 'immer';
import { getItemByIdOrThrow, getConnectorPath, getAllAnchors } from 'src/utils';
import { validateConnector } from 'src/validation/utils';
import { State } from './types';
export const deleteConnector = (
id: string,
viewId: string,
state: State
): State => {
const view = getItemByIdOrThrow(state.model.views, viewId);
const connector = getItemByIdOrThrow(view.value.connectors ?? [], id);
const newState = produce(state, (draft) => {
draft.model.views[view.index].connectors?.splice(connector.index, 1);
delete draft.scene.connectors[connector.index];
});
return newState;
};
export const syncConnector = (id: string, viewId: string, state: State) => {
const newState = produce(state, (draft) => {
const view = getItemByIdOrThrow(draft.model.views, viewId);
const connector = getItemByIdOrThrow(view.value.connectors ?? [], id);
const allAnchors = getAllAnchors(view.value.connectors ?? []);
const issues = validateConnector(connector.value, {
view: view.value,
allAnchors
});
if (issues.length > 0) {
const stateAfterDelete = deleteConnector(id, viewId, draft);
draft.scene = stateAfterDelete.scene;
draft.model = stateAfterDelete.model;
} else {
const path = getConnectorPath({
anchors: connector.value.anchors,
view: view.value
});
draft.scene.connectors[connector.value.id] = { path };
}
});
return newState;
};
export const updateConnector = (
id: string,
updates: Partial<Connector>,
viewId: string,
state: State
): State => {
const view = getItemByIdOrThrow(state.model.views, viewId);
const newState = produce(state, (draft) => {
const { connectors } = draft.model.views[view.index];
if (!connectors) return;
const connector = getItemByIdOrThrow(connectors, id);
const newConnector = { ...connector.value, ...updates };
connectors[connector.index] = newConnector;
if (updates.anchors) {
const stateAfterSync = syncConnector(id, viewId, draft);
draft.scene = stateAfterSync.scene;
draft.model = stateAfterSync.model;
}
});
return newState;
};
export const createConnector = (
newConnector: Connector,
viewId: string,
state: State
): State => {
const view = getItemByIdOrThrow(state.model.views, viewId);
const newState = produce(state, (draft) => {
const { connectors } = draft.model.views[view.index];
if (!connectors) {
draft.model.views[view.index].connectors = [newConnector];
} else {
draft.model.views[view.index].connectors?.push(newConnector);
}
});
return syncConnector(newConnector.id, viewId, newState);
};

View File

@@ -0,0 +1,6 @@
export * from './connector';
export * from './modelItem';
export * from './viewItem';
export * from './rectangle';
export * from './textBox';
export * from './view';

View File

@@ -0,0 +1,39 @@
import { produce } from 'immer';
import { ModelItem } from 'src/types';
import { getItemByIdOrThrow } from 'src/utils';
import { State } from './types';
export const updateModelItem = (
id: string,
updates: Partial<ModelItem>,
state: State
): State => {
const modelItem = getItemByIdOrThrow(state.model.items, id);
const newState = produce(state, (draft) => {
draft.model.items[modelItem.index] = { ...modelItem.value, ...updates };
});
return newState;
};
export const createModelItem = (
newModelItem: ModelItem,
state: State
): State => {
const newState = produce(state, (draft) => {
draft.model.items.push(newModelItem);
});
return updateModelItem(newModelItem.id, newModelItem, newState);
};
export const deleteModelItem = (id: string, state: State): State => {
const modelItem = getItemByIdOrThrow(state.model.items, id);
const newState = produce(state, (draft) => {
delete draft.model.items[modelItem.index];
});
return newState;
};

View File

@@ -0,0 +1,60 @@
import { produce } from 'immer';
import { Rectangle } from 'src/types';
import { getItemByIdOrThrow } from 'src/utils';
import { State } from './types';
export const updateRectangle = (
id: string,
updates: Partial<Rectangle>,
viewId: string,
state: State
): State => {
const view = getItemByIdOrThrow(state.model.views, viewId);
const newState = produce(state, (draft) => {
const { rectangles } = draft.model.views[view.index];
if (!rectangles) return;
const rectangle = getItemByIdOrThrow(rectangles, id);
const newRectangle = { ...rectangle.value, ...updates };
rectangles[rectangle.index] = newRectangle;
});
return newState;
};
export const createRectangle = (
newRectangle: Rectangle,
viewId: string,
state: State
): State => {
const view = getItemByIdOrThrow(state.model.views, viewId);
const newState = produce(state, (draft) => {
const { rectangles } = draft.model.views[view.index];
if (!rectangles) {
draft.model.views[view.index].rectangles = [newRectangle];
} else {
draft.model.views[view.index].rectangles?.push(newRectangle);
}
});
return updateRectangle(newRectangle.id, newRectangle, viewId, newState);
};
export const deleteRectangle = (
id: string,
viewId: string,
state: State
): State => {
const view = getItemByIdOrThrow(state.model.views, viewId);
const rectangle = getItemByIdOrThrow(view.value.rectangles ?? [], id);
const newState = produce(state, (draft) => {
draft.model.views[view.index].rectangles?.splice(rectangle.index, 1);
});
return newState;
};

View File

@@ -0,0 +1,80 @@
import { produce } from 'immer';
import { TextBox } from 'src/types';
import { getItemByIdOrThrow, getTextWidth } from 'src/utils';
import {
DEFAULT_FONT_FAMILY,
TEXTBOX_DEFAULTS,
TEXTBOX_FONT_WEIGHT
} from 'src/config';
import { State } from './types';
export const updateTextBox = (
id: string,
updates: Partial<TextBox>,
viewId: string,
state: State
): State => {
const view = getItemByIdOrThrow(state.model.views, viewId);
const newState = produce(state, (draft) => {
const { textBoxes } = draft.model.views[view.index];
if (!textBoxes) return;
const textBox = getItemByIdOrThrow(textBoxes, id);
const newTextBox = { ...textBox.value, ...updates };
textBoxes[textBox.index] = newTextBox;
if (updates.content !== undefined || updates.fontSize !== undefined) {
const width = getTextWidth(updates.content ?? textBox.value.content, {
fontSize:
updates.fontSize ??
textBox.value.fontSize ??
TEXTBOX_DEFAULTS.fontSize,
fontFamily: DEFAULT_FONT_FAMILY,
fontWeight: TEXTBOX_FONT_WEIGHT
});
const height = 1;
const size = { width, height };
draft.scene.textBoxes[newTextBox.id] = { size };
}
});
return newState;
};
export const createTextBox = (
newTextBox: TextBox,
viewId: string,
state: State
): State => {
const view = getItemByIdOrThrow(state.model.views, viewId);
const newState = produce(state, (draft) => {
const { textBoxes } = draft.model.views[view.index];
if (!textBoxes) {
draft.model.views[view.index].textBoxes = [newTextBox];
} else {
draft.model.views[view.index].textBoxes?.push(newTextBox);
}
});
return updateTextBox(newTextBox.id, newTextBox, viewId, newState);
};
export const deleteTextBox = (
id: string,
viewId: string,
state: State
): State => {
const view = getItemByIdOrThrow(state.model.views, viewId);
const textBox = getItemByIdOrThrow(view.value.textBoxes ?? [], id);
const newState = produce(state, (draft) => {
draft.model.views[view.index].textBoxes?.splice(textBox.index, 1);
});
return newState;
};

View File

@@ -0,0 +1,6 @@
import { Model, Scene } from 'src/types';
export interface State {
model: Model;
scene: Scene;
}

View File

@@ -0,0 +1,12 @@
import { produce } from 'immer';
import { View } from 'src/types';
import { State } from './types';
export const createView = (
newView: View,
model: State['model']
): State['model'] => {
return produce(model, (draft) => {
draft.views.push(newView);
});
};

View File

@@ -0,0 +1,93 @@
import { produce } from 'immer';
import { ViewItem } from 'src/types';
import { getItemByIdOrThrow, getConnectorsByViewItem } from 'src/utils';
import { validateView } from 'src/validation/utils';
import { State } from './types';
import { updateConnector, syncConnector } from './connector';
export const updateViewItem = (
id: string,
updates: Partial<ViewItem>,
viewId: string,
state: State
): State => {
const newState = produce(state, (draft) => {
const view = getItemByIdOrThrow(draft.model.views, viewId);
const { items } = view.value;
if (!items) return;
const viewItem = getItemByIdOrThrow(items, id);
const newItem = { ...viewItem.value, ...updates };
items[viewItem.index] = newItem;
if (updates.tile) {
const connectorsToUpdate = getConnectorsByViewItem(
viewItem.value.id,
view.value.connectors ?? []
);
const updatedConnectors = connectorsToUpdate.reduce((acc, connector) => {
return updateConnector(connector.id, connector, viewId, acc);
}, draft);
draft.model.views[view.index].connectors =
updatedConnectors.model.views[view.index].connectors;
draft.scene.connectors = updatedConnectors.scene.connectors;
}
});
const newView = getItemByIdOrThrow(newState.model.views, viewId);
const issues = validateView(newView.value, { model: newState.model });
if (issues.length > 0) {
throw new Error(issues[0].message);
}
return newState;
};
export const createViewItem = (
newViewItem: ViewItem,
viewId: string,
state: State
): State => {
const view = getItemByIdOrThrow(state.model.views, viewId);
const newState = produce(state, (draft) => {
const { items } = draft.model.views[view.index];
items.push(newViewItem);
});
return updateViewItem(newViewItem.id, newViewItem, viewId, newState);
};
export const deleteViewItem = (
id: string,
viewId: string,
state: State
): State => {
const newState = produce(state, (draft) => {
const view = getItemByIdOrThrow(draft.model.views, viewId);
const viewItem = getItemByIdOrThrow(view.value.items, id);
draft.model.views[view.index].items.splice(viewItem.index, 1);
const connectorsToUpdate = getConnectorsByViewItem(
viewItem.value.id,
view.value.connectors ?? []
);
const updatedConnectors = connectorsToUpdate.reduce((acc, connector) => {
return syncConnector(connector.id, viewId, acc);
}, draft);
draft.model.views[view.index].connectors =
updatedConnectors.model.views[view.index].connectors;
draft.scene.connectors = updatedConnectors.scene.connectors;
});
return newState;
};

View File

@@ -1,229 +1,15 @@
import React, { createContext, useRef, useContext } from 'react';
import { createStore, useStore } from 'zustand';
import { produce } from 'immer';
import { SceneStore, Scene } from 'src/types';
import {
DEFAULT_FONT_FAMILY,
TEXTBOX_DEFAULTS,
INITIAL_SCENE
} from 'src/config';
import { sceneInput } from 'src/validation/scene';
import {
getItemById,
getConnectorPath,
rectangleInputToRectangle,
connectorInputToConnector,
textBoxInputToTextBox,
sceneInputToScene,
nodeInputToNode,
getTextWidth,
getAllAnchors
} from 'src/utils';
export const initialScene: Scene = {
title: INITIAL_SCENE.title,
icons: [],
nodes: [],
connectors: [],
textBoxes: [],
rectangles: []
};
import { SceneStore } from 'src/types';
const initialState = () => {
return createStore<SceneStore>((set, get) => {
return {
...initialScene,
connectors: {},
textBoxes: {},
actions: {
setScene: (scene) => {
sceneInput.parse(scene);
const newScene = sceneInputToScene(scene);
set(newScene);
return newScene;
},
updateScene: (scene) => {
set({
nodes: scene.nodes,
connectors: scene.connectors,
rectangles: scene.rectangles
});
},
createNode: (node) => {
const newScene = produce(get(), (draft) => {
draft.nodes.push(nodeInputToNode(node));
});
set({ nodes: newScene.nodes });
},
updateNode: (id, updates) => {
const newScene = produce(get(), (draft) => {
const { item: node, index } = getItemById(draft.nodes, id);
draft.nodes[index] = {
...node,
...updates
};
draft.connectors.forEach((connector, i) => {
const needsUpdate = connector.anchors.find((anchor) => {
return anchor.ref.type === 'NODE' && anchor.ref.id === id;
});
if (needsUpdate) {
draft.connectors[i].path = getConnectorPath({
anchors: connector.anchors,
nodes: draft.nodes,
allAnchors: getAllAnchors(draft.connectors)
});
}
});
});
set({ nodes: newScene.nodes, connectors: newScene.connectors });
},
deleteNode: (id: string) => {
const newScene = produce(get(), (draft) => {
const { index } = getItemById(draft.nodes, id);
draft.nodes.splice(index, 1);
draft.connectors = draft.connectors.filter((connector) => {
return !connector.anchors.find((anchor) => {
return anchor.ref.type === 'NODE' && anchor.ref.id === id;
});
});
});
set({ nodes: newScene.nodes, connectors: newScene.connectors });
},
createConnector: (connector) => {
const newScene = produce(get(), (draft) => {
draft.connectors.push(
connectorInputToConnector(
connector,
draft.nodes,
getAllAnchors(draft.connectors)
)
);
});
set({ connectors: newScene.connectors });
},
updateConnector: (id, updates) => {
const scene = get();
const { item: connector, index } = getItemById(scene.connectors, id);
const newScene = produce(scene, (draft) => {
draft.connectors[index] = {
...connector,
...updates
};
if (updates.anchors) {
draft.connectors[index].path = getConnectorPath({
anchors: updates.anchors,
nodes: scene.nodes,
allAnchors: getAllAnchors(scene.connectors)
});
}
});
set({ connectors: newScene.connectors });
},
deleteConnector: (id: string) => {
const newScene = produce(get(), (draft) => {
const { index } = getItemById(draft.connectors, id);
draft.connectors.splice(index, 1);
});
set({ connectors: newScene.connectors });
},
createRectangle: (rectangle) => {
const newScene = produce(get(), (draft) => {
draft.rectangles.push(rectangleInputToRectangle(rectangle));
});
set({ rectangles: newScene.rectangles });
},
createTextBox: (textBox) => {
const newScene = produce(get(), (draft) => {
draft.textBoxes.push(textBoxInputToTextBox(textBox));
});
set({ textBoxes: newScene.textBoxes });
},
updateTextBox: (id, updates) => {
const newScene = produce(get(), (draft) => {
const { item: textBox, index } = getItemById(draft.textBoxes, id);
if (updates.text !== undefined || updates.fontSize !== undefined) {
draft.textBoxes[index].size = {
width: getTextWidth(updates.text ?? textBox.text, {
fontSize: updates.fontSize ?? textBox.fontSize,
fontFamily: DEFAULT_FONT_FAMILY,
fontWeight: TEXTBOX_DEFAULTS.fontWeight
}),
height: 1
};
}
draft.textBoxes[index] = {
...textBox,
...updates
};
});
set({ textBoxes: newScene.textBoxes });
},
deleteTextBox: (id: string) => {
const newScene = produce(get(), (draft) => {
const { index } = getItemById(draft.textBoxes, id);
draft.textBoxes.splice(index, 1);
});
set({ textBoxes: newScene.textBoxes });
},
updateRectangle: (id, updates) => {
const newScene = produce(get(), (draft) => {
const { item: rectangle, index } = getItemById(
draft.rectangles,
id
);
draft.rectangles[index] = {
...rectangle,
...updates
};
});
set({ rectangles: newScene.rectangles });
},
deleteRectangle: (id: string) => {
const newScene = produce(get(), (draft) => {
const { index } = getItemById(draft.rectangles, id);
draft.rectangles.splice(index, 1);
});
set({ rectangles: newScene.rectangles });
}
get,
set
}
};
});
@@ -264,5 +50,6 @@ export function useSceneStore<T>(
}
const value = useStore(store, selector, equalityFn);
return value;
}

View File

@@ -8,10 +8,14 @@ import {
getTileScrollPosition
} from 'src/utils';
import { UiStateStore } from 'src/types';
import { INITIAL_UI_STATE } from 'src/config';
const initialState = () => {
return createStore<UiStateStore>((set, get) => {
return {
zoom: INITIAL_UI_STATE.zoom,
scroll: INITIAL_UI_STATE.scroll,
view: '',
mainMenuOptions: [],
editorMode: 'EXPLORABLE_READONLY',
mode: getStartingMode('EXPLORABLE_READONLY'),
@@ -25,13 +29,11 @@ const initialState = () => {
delta: null
},
itemControls: null,
scroll: {
position: { x: 0, y: 0 },
offset: { x: 0, y: 0 }
},
enableDebugTools: false,
zoom: 1,
actions: {
setView: (view) => {
set({ view });
},
setMainMenuOptions: (mainMenuOptions) => {
set({ mainMenuOptions });
},

View File

@@ -1,5 +1,5 @@
export * from './common';
export * from './inputs';
export * from './model';
export * from './scene';
export * from './ui';
export * from './interactions';

View File

@@ -1,37 +0,0 @@
import z from 'zod';
import {
iconInput,
nodeInput,
connectorAnchorInput,
connectorInput,
textBoxInput,
rectangleInput,
connectorStyleEnum
} from 'src/validation/sceneItems';
import { Coords } from 'src/types';
import { sceneInput } from 'src/validation/scene';
import type { EditorModeEnum, MainMenuOptions } from './common';
export type ConnectorStyleEnum = z.infer<typeof connectorStyleEnum>;
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 TextBoxInput = z.infer<typeof textBoxInput>;
export type RectangleInput = z.infer<typeof rectangleInput>;
export type SceneInput = z.infer<typeof sceneInput>;
export type InitialScene = Partial<SceneInput> & {
zoom?: number;
scroll?: Coords;
};
export interface IsoflowProps {
initialScene?: InitialScene;
mainMenuOptions?: MainMenuOptions;
onSceneUpdated?: (scene: SceneInput) => void;
width?: number | string;
height?: number | string;
enableDebugTools?: boolean;
editorMode?: keyof typeof EditorModeEnum;
}

View File

@@ -1,7 +1,9 @@
import { SceneStore, UiStateStore, Size } from 'src/types';
import { ModelStore, UiStateStore, Size } from 'src/types';
import { useScene } from 'src/hooks/useScene';
export interface State {
scene: SceneStore;
model: ModelStore;
scene: ReturnType<typeof useScene>;
uiState: UiStateStore;
rendererRef: HTMLElement;
rendererSize: Size;

53
src/types/model.ts Normal file
View File

@@ -0,0 +1,53 @@
import z from 'zod';
import { iconSchema } from 'src/validation/icons';
import { modelSchema } from 'src/validation/model';
import { modelItemSchema } from 'src/validation/modelItems';
import { viewSchema, viewItemSchema } from 'src/validation/views';
import {
connectorSchema,
anchorSchema,
connectorStyleOptions
} from 'src/validation/annotations/connector';
import { textBoxSchema } from 'src/validation/annotations/textBox';
import { rectangleSchema } from 'src/validation/annotations/rectangle';
import { Coords } from 'src/types';
import { StoreApi } from 'zustand';
import type { EditorModeEnum, MainMenuOptions } from './common';
export { connectorStyleOptions } from 'src/validation/annotations/connector';
export type Model = z.infer<typeof modelSchema>;
export type ModelItems = Model['items'];
export type Views = Model['views'];
export type Icons = Model['icons'];
export type Icon = z.infer<typeof iconSchema>;
export type ModelItem = z.infer<typeof modelItemSchema>;
export type View = z.infer<typeof viewSchema>;
export type ViewItem = z.infer<typeof viewItemSchema>;
export type ConnectorStyle = keyof typeof connectorStyleOptions;
export type ConnectorAnchor = z.infer<typeof anchorSchema>;
export type Connector = z.infer<typeof connectorSchema>;
export type TextBox = z.infer<typeof textBoxSchema>;
export type Rectangle = z.infer<typeof rectangleSchema>;
export type InitialData = Partial<Model> & {
zoom?: number;
scroll?: Coords;
};
export interface IsoflowProps {
initialData?: InitialData;
mainMenuOptions?: MainMenuOptions;
onModelUpdated?: (Model: Model) => void;
width?: number | string;
height?: number | string;
enableDebugTools?: boolean;
editorMode?: keyof typeof EditorModeEnum;
}
export type ModelStore = Model & {
actions: {
get: StoreApi<ModelStore>['getState'];
set: StoreApi<ModelStore>['setState'];
};
};

View File

@@ -1,132 +1,56 @@
import {
IconInput,
SceneInput,
RectangleInput,
ConnectorInput,
TextBoxInput,
NodeInput,
ConnectorStyleEnum
} from 'src/types/inputs';
import { ProjectionOrientationEnum, Coords, Rect, Size } from 'src/types';
import { StoreApi } from 'zustand';
import type { Coords, Rect, Size } from './common';
export enum TileOriginEnum {
CENTER = 'CENTER',
TOP = 'TOP',
BOTTOM = 'BOTTOM',
LEFT = 'LEFT',
RIGHT = 'RIGHT'
}
export const tileOriginOptions = {
CENTER: 'CENTER',
TOP: 'TOP',
BOTTOM: 'BOTTOM',
LEFT: 'LEFT',
RIGHT: 'RIGHT'
} as const;
export enum SceneItemTypeEnum {
NODE = 'NODE',
CONNECTOR = 'CONNECTOR',
CONNECTOR_ANCHOR = 'CONNECTOR_ANCHOR',
TEXTBOX = 'TEXTBOX',
RECTANGLE = 'RECTANGLE'
}
export type TileOrigin = keyof typeof tileOriginOptions;
export interface Node {
export const ItemReferenceTypeOptions = {
ITEM: 'ITEM',
CONNECTOR: 'CONNECTOR',
CONNECTOR_ANCHOR: 'CONNECTOR_ANCHOR',
TEXTBOX: 'TEXTBOX',
RECTANGLE: 'RECTANGLE'
} as const;
export type ItemReferenceType = keyof typeof ItemReferenceTypeOptions;
export type ItemReference = {
type: ItemReferenceType;
id: string;
type: SceneItemTypeEnum.NODE;
icon: string;
label?: string;
description?: string;
labelHeight: number;
tile: Coords;
isSelected: boolean;
}
export type ConnectorAnchorRef =
| {
type: 'NODE';
id: string;
}
| {
type: 'TILE';
coords: Coords;
}
| {
type: 'ANCHOR';
id: string;
};
export type ConnectorAnchor = {
id: string;
type: SceneItemTypeEnum.CONNECTOR_ANCHOR;
ref: ConnectorAnchorRef;
};
export interface Connector {
type: SceneItemTypeEnum.CONNECTOR;
id: string;
label?: string;
color: string;
width: number;
style: ConnectorStyleEnum;
anchors: ConnectorAnchor[];
path: {
tiles: Coords[];
rectangle: Rect;
};
export type ConnectorPath = {
tiles: Coords[];
rectangle: Rect;
};
export interface SceneConnector {
path: ConnectorPath;
}
export interface TextBox {
type: SceneItemTypeEnum.TEXTBOX;
id: string;
fontSize: number;
tile: Coords;
text: string;
orientation: keyof typeof ProjectionOrientationEnum;
export interface SceneTextBox {
size: Size;
}
export interface Rectangle {
type: SceneItemTypeEnum.RECTANGLE;
id: string;
color: string;
from: Coords;
to: Coords;
export interface Scene {
connectors: {
[key: string]: SceneConnector;
};
textBoxes: {
[key: string]: SceneTextBox;
};
}
export type SceneItem =
| Node
| Connector
| TextBox
| Rectangle
| ConnectorAnchor;
export type SceneItemReference = {
type: SceneItemTypeEnum;
id: string;
};
export type Icon = IconInput;
export interface SceneActions {
setScene: (scene: SceneInput) => Scene;
updateScene: (scene: Scene) => void;
createNode: (node: NodeInput) => void;
updateNode: (id: string, updates: Partial<Node>) => void;
deleteNode: (id: string) => void;
createConnector: (connector: ConnectorInput) => void;
updateConnector: (id: string, updates: Partial<Connector>) => void;
deleteConnector: (id: string) => void;
createTextBox: (textBox: TextBoxInput) => void;
updateTextBox: (id: string, updates: Partial<TextBox>) => void;
deleteTextBox: (id: string) => void;
createRectangle: (rectangle: RectangleInput) => void;
updateRectangle: (id: string, updates: Partial<Rectangle>) => void;
deleteRectangle: (id: string) => void;
}
export type Scene = {
title: string;
nodes: Node[];
connectors: Connector[];
textBoxes: TextBox[];
rectangles: Rectangle[];
icons: IconInput[];
};
export type SceneStore = Scene & {
actions: SceneActions;
actions: {
get: StoreApi<SceneStore>['getState'];
set: StoreApi<SceneStore>['setState'];
};
};

View File

@@ -1,44 +1,12 @@
import { Coords, EditorModeEnum, MainMenuOptions } from './common';
import { Connector, SceneItemReference, TileOriginEnum } from './scene';
import { IconInput } from './inputs';
interface NodeControls {
type: 'NODE';
id: string;
}
interface ConnectorControls {
type: 'CONNECTOR';
id: string;
}
interface TextBoxControls {
type: 'TEXTBOX';
id: string;
}
interface ConnectorAnchorControls {
type: 'CONNECTOR_ANCHOR';
id: string;
}
interface RectangleControls {
type: 'RECTANGLE';
id: string;
}
import { Icon } from './model';
import { TileOrigin, ItemReference } from './scene';
interface AddItemControls {
type: 'ADD_ITEM';
}
export type ItemControls =
| NodeControls
| ConnectorControls
| RectangleControls
| AddItemControls
| TextBoxControls
| ConnectorAnchorControls
| null;
export type ItemControls = ItemReference | AddItemControls | null;
export interface Mouse {
position: {
@@ -64,13 +32,13 @@ export interface InteractionsDisabled {
export interface CursorMode {
type: 'CURSOR';
showCursor: boolean;
mousedownItem: SceneItemReference | null;
mousedownItem: ItemReference | null;
}
export interface DragItemsMode {
type: 'DRAG_ITEMS';
showCursor: boolean;
items: SceneItemReference[];
items: ItemReference[];
isInitialMovement: Boolean;
}
@@ -79,51 +47,51 @@ export interface PanMode {
showCursor: boolean;
}
export interface PlaceElementMode {
type: 'PLACE_ELEMENT';
export interface PlaceIconMode {
type: 'PLACE_ICON';
showCursor: boolean;
icon: IconInput | null;
id: string | null;
}
export interface ConnectorMode {
type: 'CONNECTOR';
showCursor: boolean;
connector: Connector | null;
id: string | null;
}
export interface DrawRectangleMode {
type: 'RECTANGLE.DRAW';
showCursor: boolean;
area: {
from: Coords;
to: Coords;
} | null;
id: string | null;
}
export enum AnchorPositionsEnum {
BOTTOM_LEFT = 'BOTTOM_LEFT',
BOTTOM_RIGHT = 'BOTTOM_RIGHT',
TOP_RIGHT = 'TOP_RIGHT',
TOP_LEFT = 'TOP_LEFT'
}
export const AnchorPositionOptions = {
BOTTOM_LEFT: 'BOTTOM_LEFT',
BOTTOM_RIGHT: 'BOTTOM_RIGHT',
TOP_RIGHT: 'TOP_RIGHT',
TOP_LEFT: 'TOP_LEFT'
} as const;
export type AnchorPosition = keyof typeof AnchorPositionOptions;
export interface TransformRectangleMode {
type: 'RECTANGLE.TRANSFORM';
showCursor: boolean;
id: string;
selectedAnchor: AnchorPositionsEnum | null;
selectedAnchor: AnchorPosition | null;
}
export interface TextBoxMode {
type: 'TEXTBOX';
showCursor: boolean;
id: string | null;
}
export type Mode =
| InteractionsDisabled
| CursorMode
| PanMode
| PlaceElementMode
| PlaceIconMode
| ConnectorMode
| DrawRectangleMode
| TransformRectangleMode
@@ -142,7 +110,7 @@ export interface IconCollectionState {
}
export type IconCollectionStateWithIcons = IconCollectionState & {
icons: IconInput[];
icons: Icon[];
};
export const DialogTypeEnum = {
@@ -150,6 +118,7 @@ export const DialogTypeEnum = {
} as const;
export interface UiState {
view: string;
mainMenuOptions: MainMenuOptions;
editorMode: keyof typeof EditorModeEnum;
iconCategoriesState: IconCollectionState[];
@@ -165,6 +134,7 @@ export interface UiState {
}
export interface UiStateActions {
setView: (view: string) => void;
setMainMenuOptions: (options: MainMenuOptions) => void;
setEditorMode: (mode: keyof typeof EditorModeEnum) => void;
setIconCategoriesState: (iconCategoriesState: IconCollectionState[]) => void;
@@ -176,7 +146,7 @@ export interface UiStateActions {
setDialog: (dialog: keyof typeof DialogTypeEnum | null) => void;
setZoom: (zoom: number) => void;
setScroll: (scroll: Scroll) => void;
scrollToTile: (tile: Coords, origin?: TileOriginEnum) => void;
scrollToTile: (tile: Coords, origin?: TileOrigin) => void;
setItemControls: (itemControls: ItemControls) => void;
setMouse: (mouse: Mouse) => void;
setRendererEl: (el: HTMLDivElement) => void;

View File

@@ -78,3 +78,28 @@ export const getStartingMode = (
throw new Error('Invalid editor mode.');
}
};
export function getItemByIdOrThrow<T extends { id: string }>(
values: T[],
id: string
): { value: T; index: number } {
const index = values.findIndex((val) => {
return val.id === id;
});
if (index === -1) {
throw new Error(`Item with id "${id}" not found.`);
}
return { value: values[index], index };
}
export function getItemByIndexOrThrow<T>(items: T[], index: number): T {
const item = items[index];
if (!item) {
throw new Error(`Item with index "${index}" not found.`);
}
return item;
}

View File

@@ -1,7 +1,6 @@
import domtoimage from 'dom-to-image';
import FileSaver from 'file-saver';
import { Scene, Size } from '../types';
import { sceneToSceneInput } from './inputs';
import { Model, Size } from '../types';
export const generateGenericFilename = (extension: string) => {
return `isoflow-export-${new Date().toISOString()}.${extension}`;
@@ -37,10 +36,8 @@ export const downloadFile = (data: Blob, filename: string) => {
FileSaver.saveAs(data, filename);
};
export const exportAsJSON = (scene: Scene) => {
const parsedScene = sceneToSceneInput(scene);
const data = new Blob([JSON.stringify(parsedScene)], {
export const exportAsJSON = (model: Model) => {
const data = new Blob([JSON.stringify(model)], {
type: 'application/json;charset=utf-8'
});

View File

@@ -1,7 +1,7 @@
export * from './CoordsUtils';
export * from './SizeUtils';
export * from './common';
export * from './inputs';
export * from './pathfinder';
export * from './renderer';
export * from './exportOptions';
export * from './model';

View File

@@ -1,303 +0,0 @@
import {
IconInput,
Icon,
SceneInput,
NodeInput,
ConnectorInput,
TextBoxInput,
ProjectionOrientationEnum,
RectangleInput,
SceneItemTypeEnum,
Scene,
Node,
Connector,
TextBox,
Rectangle,
ConnectorAnchorInput,
ConnectorAnchor
} from 'src/types';
import {
NODE_DEFAULTS,
DEFAULT_COLOR,
CONNECTOR_DEFAULTS,
TEXTBOX_DEFAULTS,
DEFAULT_FONT_FAMILY
} from 'src/config';
import { getConnectorPath, getTextWidth, generateId } from 'src/utils';
export const iconInputToIcon = (iconInput: IconInput): Icon => {
return {
id: iconInput.id,
name: iconInput.name,
url: iconInput.url,
collection: iconInput.collection,
isIsometric: iconInput.isIsometric ?? true
};
};
export const nodeInputToNode = (nodeInput: NodeInput): Node => {
return {
type: SceneItemTypeEnum.NODE,
id: nodeInput.id,
label: nodeInput.label ?? NODE_DEFAULTS.label,
description: nodeInput.description,
labelHeight: nodeInput.labelHeight ?? NODE_DEFAULTS.labelHeight,
icon: nodeInput.icon,
tile: nodeInput.tile,
isSelected: false
};
};
export const rectangleInputToRectangle = (
rectangleInput: RectangleInput
): Rectangle => {
return {
type: SceneItemTypeEnum.RECTANGLE,
id: rectangleInput.id,
from: rectangleInput.from,
to: rectangleInput.to,
color: rectangleInput.color ?? DEFAULT_COLOR
};
};
const connectorAnchorInputToConnectorAnchor = (
anchor: ConnectorAnchorInput
): ConnectorAnchor => {
const anchorBase: Required<Pick<ConnectorAnchor, 'id' | 'type'>> = {
id: anchor.id ?? generateId(),
type: SceneItemTypeEnum.CONNECTOR_ANCHOR
};
if (anchor.ref.node) {
return {
...anchorBase,
ref: {
type: 'NODE',
id: anchor.ref.node
}
};
}
if (anchor.ref.tile) {
return {
...anchorBase,
ref: {
type: 'TILE',
coords: anchor.ref.tile
}
};
}
if (anchor.ref.anchor) {
return {
...anchorBase,
ref: {
type: 'ANCHOR',
id: anchor.ref.anchor
}
};
}
throw new Error('Could not render connector anchor');
};
export const connectorInputToConnector = (
connectorInput: ConnectorInput,
nodes: Node[],
allAnchors: ConnectorAnchor[]
): 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,
width: connectorInput.width ?? CONNECTOR_DEFAULTS.width,
style: connectorInput.style ?? CONNECTOR_DEFAULTS.style,
anchors,
path: getConnectorPath({ anchors, nodes, allAnchors })
};
};
export const textBoxInputToTextBox = (textBoxInput: TextBoxInput): TextBox => {
const fontSize = textBoxInput.fontSize ?? TEXTBOX_DEFAULTS.fontSize;
return {
type: SceneItemTypeEnum.TEXTBOX,
id: textBoxInput.id,
orientation: textBoxInput.orientation ?? ProjectionOrientationEnum.X,
fontSize,
tile: textBoxInput.tile,
text: textBoxInput.text,
size: {
width: getTextWidth(textBoxInput.text, {
fontSize,
fontFamily: DEFAULT_FONT_FAMILY,
fontWeight: TEXTBOX_DEFAULTS.fontWeight
}),
height: 1
}
};
};
export const textBoxToTextBoxInput = (textBox: TextBox): TextBoxInput => {
return {
id: textBox.id,
orientation: textBox.orientation,
fontSize: textBox.fontSize,
tile: textBox.tile,
text: textBox.text
};
};
export const getAllAnchorsFromInput = (connectors: ConnectorInput[]) => {
const allAnchors = connectors.reduce((acc, connectorInput) => {
const convertedAnchors = connectorInput.anchors.map((anchor) => {
return connectorAnchorInputToConnectorAnchor(anchor);
});
return [...acc, ...convertedAnchors];
}, [] as ConnectorAnchor[]);
return allAnchors;
};
export const sceneInputToScene = (sceneInput: SceneInput): Scene => {
const icons = sceneInput.icons.map((icon) => {
return iconInputToIcon(icon);
});
const nodes = sceneInput.nodes.map((nodeInput) => {
return nodeInputToNode(nodeInput);
});
const rectangles = sceneInput.rectangles.map((rectangleInput) => {
return rectangleInputToRectangle(rectangleInput);
});
const textBoxes = sceneInput.textBoxes.map((textBoxInput) => {
return textBoxInputToTextBox(textBoxInput);
});
const allAnchors = getAllAnchorsFromInput(sceneInput.connectors);
const connectors = sceneInput.connectors.map((connectorInput) => {
return connectorInputToConnector(connectorInput, nodes, allAnchors);
});
return {
title: sceneInput.title,
icons,
nodes,
rectangles,
connectors,
textBoxes
} as Scene;
};
export const iconToIconInput = (icon: Icon): IconInput => {
return {
id: icon.id,
name: icon.name,
url: icon.url,
collection: icon.collection,
isIsometric: icon.isIsometric
};
};
export const nodeToNodeInput = (node: Node): NodeInput => {
return {
id: node.id,
tile: node.tile,
label: node.label,
description: node.description,
labelHeight: node.labelHeight,
icon: node.icon
};
};
export const connectorAnchorToConnectorAnchorInput = (
anchor: ConnectorAnchor
): ConnectorAnchorInput | null => {
switch (anchor.ref.type) {
case 'NODE':
return {
id: anchor.id,
ref: { node: anchor.ref.id }
};
case 'TILE':
return {
id: anchor.id,
ref: { tile: anchor.ref.coords }
};
case 'ANCHOR':
return {
id: anchor.id,
ref: { anchor: anchor.ref.id }
};
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,
color: connector.color,
style: connector.style,
width: connector.width,
label: connector.label,
anchors
};
};
export const rectangleToRectangleInput = (
rectangle: Rectangle
): RectangleInput => {
return {
id: rectangle.id,
color: rectangle.color,
from: rectangle.from,
to: rectangle.to
};
};
export const sceneToSceneInput = (scene: Scene): SceneInput => {
const icons: IconInput[] = scene.icons.map(iconToIconInput);
const nodes: NodeInput[] = scene.nodes.map(nodeToNodeInput);
const connectors: ConnectorInput[] = scene.connectors.map(
connectorToConnectorInput,
nodes
);
const textBoxes: TextBoxInput[] = scene.textBoxes.map((textBox) => {
return textBoxToTextBoxInput(textBox);
});
const rectangles: RectangleInput[] = scene.rectangles.map(
rectangleToRectangleInput
);
return {
title: scene.title,
icons,
nodes,
connectors,
textBoxes,
rectangles
} as SceneInput;
};

54
src/utils/model.ts Normal file
View File

@@ -0,0 +1,54 @@
import { produce } from 'immer';
import { Model } from 'src/types';
import { validateModel } from 'src/validation/utils';
import { getItemByIdOrThrow } from './common';
export const fixModel = (model: Model): Model => {
const issues = validateModel(model);
return issues.reduce((acc, issue) => {
if (issue.type === 'INVALID_MODEL_TO_ICON_REF') {
return produce(acc, (draft) => {
const { index: itemIndex } = getItemByIdOrThrow(
draft.items,
issue.params.modelItem
);
draft.items[itemIndex].icon = undefined;
});
}
if (issue.type === 'CONNECTOR_TOO_FEW_ANCHORS') {
return produce(acc, (draft) => {
const view = getItemByIdOrThrow(draft.views, issue.params.view);
const connector = getItemByIdOrThrow(
view.value.connectors ?? [],
issue.params.connector
);
draft.views[view.index].connectors?.splice(connector.index, 1);
});
}
if (issue.type === 'INVALID_ANCHOR_TO_ANCHOR_REF') {
return produce(acc, (draft) => {
const view = getItemByIdOrThrow(draft.views, issue.params.view);
const connector = getItemByIdOrThrow(
view.value.connectors ?? [],
issue.params.connector
);
const anchor = getItemByIdOrThrow(
connector.value.anchors,
issue.params.srcAnchor
);
connector.value.anchors.splice(anchor.index, 1);
});
}
return acc;
}, model);
};

View File

@@ -5,26 +5,26 @@ import {
ZOOM_INCREMENT,
MAX_ZOOM,
MIN_ZOOM,
CONNECTOR_DEFAULTS,
TEXTBOX_DEFAULTS
TEXTBOX_PADDING,
CONNECTOR_SEARCH_OFFSET
} from 'src/config';
import {
Coords,
TileOriginEnum,
Node,
TileOrigin,
Connector,
Size,
Scroll,
Mouse,
ConnectorAnchor,
SceneItem,
Scene,
ItemReference,
Rect,
ProjectionOrientationEnum,
AnchorPositionsEnum,
AnchorPositionOptions,
BoundingBox,
TextBox,
SlimMouseEvent
SlimMouseEvent,
View,
AnchorPosition
} from 'src/types';
import {
CoordsUtils,
@@ -32,8 +32,10 @@ import {
clamp,
roundToOneDecimalPlace,
findPath,
toPx
toPx,
getItemByIdOrThrow
} from 'src/utils';
import { useScene } from 'src/hooks/useScene';
interface ScreenToIso {
mouse: Coords;
@@ -74,12 +76,12 @@ export const screenToIso = ({
interface GetTilePosition {
tile: Coords;
origin?: TileOriginEnum;
origin?: TileOrigin;
}
export const getTilePosition = ({
tile,
origin = TileOriginEnum.CENTER
origin = 'CENTER'
}: GetTilePosition) => {
const halfW = PROJECTED_TILE_SIZE.width / 2;
const halfH = PROJECTED_TILE_SIZE.height / 2;
@@ -90,15 +92,15 @@ export const getTilePosition = ({
};
switch (origin) {
case TileOriginEnum.TOP:
case 'TOP':
return CoordsUtils.add(position, { x: 0, y: -halfH });
case TileOriginEnum.BOTTOM:
case 'BOTTOM':
return CoordsUtils.add(position, { x: 0, y: halfH });
case TileOriginEnum.LEFT:
case 'LEFT':
return CoordsUtils.add(position, { x: -halfW, y: 0 });
case TileOriginEnum.RIGHT:
case 'RIGHT':
return CoordsUtils.add(position, { x: halfW, y: 0 });
case TileOriginEnum.CENTER:
case 'CENTER':
default:
return position;
}
@@ -300,48 +302,30 @@ 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 };
}
export const getAllAnchors = (connectors: Connector[]) => {
return connectors.reduce((acc, connector) => {
return [...acc, ...connector.anchors];
}, [] as ConnectorAnchor[]);
};
export const getAnchorTile = (
anchor: ConnectorAnchor,
nodes: Node[],
allAnchors: ConnectorAnchor[]
): Coords => {
if (anchor.ref.type === 'NODE') {
const { item: node } = getItemById(nodes, anchor.ref.id);
return node.tile;
export const getAnchorTile = (anchor: ConnectorAnchor, view: View): Coords => {
if (anchor.ref.item) {
const viewItem = getItemByIdOrThrow(view.items, anchor.ref.item).value;
return viewItem.tile;
}
if (anchor.ref.type === 'ANCHOR') {
const anchorsWithIds = allAnchors.filter((_anchor) => {
return _anchor.id !== undefined;
}) as Required<ConnectorAnchor>[];
if (anchor.ref.anchor) {
const allAnchors = getAllAnchors(view.connectors ?? []);
const nextAnchor = getItemByIdOrThrow(allAnchors, anchor.ref.anchor).value;
const nextAnchor = getItemById(anchorsWithIds, anchor.ref.id);
return getAnchorTile(nextAnchor.item, nodes, allAnchors);
return getAnchorTile(nextAnchor, view);
}
return anchor.ref.coords;
if (anchor.ref.tile) {
return anchor.ref.tile;
}
throw new Error('Could not get anchor tile.');
};
interface NormalisePositionFromOrigin {
@@ -358,14 +342,12 @@ export const normalisePositionFromOrigin = ({
interface GetConnectorPath {
anchors: ConnectorAnchor[];
nodes: Node[];
allAnchors: ConnectorAnchor[];
view: View;
}
export const getConnectorPath = ({
anchors,
nodes,
allAnchors
view
}: GetConnectorPath): {
tiles: Coords[];
rectangle: Rect;
@@ -375,14 +357,11 @@ export const getConnectorPath = ({
`Connector needs at least two anchors (receieved: ${anchors.length})`
);
const anchorPositions = anchors.map((anchor) => {
return getAnchorTile(anchor, nodes, allAnchors);
const anchorPosition = anchors.map((anchor) => {
return getAnchorTile(anchor, view);
});
const searchArea = getBoundingBox(
anchorPositions,
CONNECTOR_DEFAULTS.searchOffset
);
const searchArea = getBoundingBox(anchorPosition, CONNECTOR_SEARCH_OFFSET);
const sorted = sortByPosition(searchArea);
const searchAreaSize = getBoundingBoxSize(searchArea);
@@ -391,7 +370,7 @@ export const getConnectorPath = ({
to: { x: sorted.lowX, y: sorted.lowY }
};
const positionsNormalisedFromSearchArea = anchorPositions.map((position) => {
const positionsNormalisedFromSearchArea = anchorPosition.map((position) => {
return normalisePositionFromOrigin({
position,
origin: rectangle.from
@@ -440,42 +419,47 @@ export const connectorPathTileToGlobal = (
origin: Coords
): Coords => {
return CoordsUtils.subtract(
CoordsUtils.subtract(origin, CONNECTOR_DEFAULTS.searchOffset),
CoordsUtils.subtract(tile, CONNECTOR_DEFAULTS.searchOffset)
CoordsUtils.subtract(origin, CONNECTOR_SEARCH_OFFSET),
CoordsUtils.subtract(tile, CONNECTOR_SEARCH_OFFSET)
);
};
export const getTextBoxTo = (textBox: TextBox) => {
export const getTextBoxEndTile = (textBox: TextBox, size: Size) => {
if (textBox.orientation === ProjectionOrientationEnum.X) {
return CoordsUtils.add(textBox.tile, {
x: textBox.size.width,
x: size.width,
y: 0
});
}
return CoordsUtils.add(textBox.tile, {
x: 0,
y: -textBox.size.width
y: -size.width
});
};
interface GetItemAtTile {
tile: Coords;
scene: Scene;
scene: ReturnType<typeof useScene>;
}
export const getItemAtTile = ({
tile,
scene
}: GetItemAtTile): SceneItem | null => {
const node = scene.nodes.find((n) => {
return CoordsUtils.isEqual(n.tile, tile);
}: GetItemAtTile): ItemReference | null => {
const viewItem = scene.items.find((item) => {
return CoordsUtils.isEqual(item.tile, tile);
});
if (node) return node;
if (viewItem) {
return {
type: 'ITEM',
id: viewItem.id
};
}
const textBox = scene.textBoxes.find((tb) => {
const textBoxTo = getTextBoxTo(tb);
const textBoxTo = getTextBoxEndTile(tb, tb.size);
const textBoxBounds = getBoundingBox([
tb.tile,
{
@@ -490,7 +474,12 @@ export const getItemAtTile = ({
return isWithinBounds(tile, textBoxBounds);
});
if (textBox) return textBox;
if (textBox) {
return {
type: 'TEXTBOX',
id: textBox.id
};
}
const connector = scene.connectors.find((con) => {
return con.path.tiles.find((pathTile) => {
@@ -503,13 +492,23 @@ export const getItemAtTile = ({
});
});
if (connector) return connector;
if (connector) {
return {
type: 'CONNECTOR',
id: connector.id
};
}
const rectangle = scene.rectangles.find(({ from, to }) => {
return isWithinBounds(tile, [from, to]);
});
if (rectangle) return rectangle;
if (rectangle) {
return {
type: 'RECTANGLE',
id: rectangle.id
};
}
return null;
};
@@ -523,7 +522,7 @@ interface FontProps {
export const getTextWidth = (text: string, fontProps: FontProps) => {
if (!text) return 0;
const paddingX = TEXTBOX_DEFAULTS.paddingX * UNPROJECTED_TILE_SIZE;
const paddingX = TEXTBOX_PADDING * UNPROJECTED_TILE_SIZE;
const fontSizePx = toPx(fontProps.fontSize * UNPROJECTED_TILE_SIZE);
const canvas: HTMLCanvasElement = document.createElement('canvas');
const context = canvas.getContext('2d');
@@ -540,26 +539,30 @@ export const getTextWidth = (text: string, fontProps: FontProps) => {
return (metrics.width + paddingX * 2) / UNPROJECTED_TILE_SIZE - 0.8;
};
export const outermostCornerPositions = [
TileOriginEnum.BOTTOM,
TileOriginEnum.RIGHT,
TileOriginEnum.TOP,
TileOriginEnum.LEFT
export const outermostCornerPositions: TileOrigin[] = [
'BOTTOM',
'RIGHT',
'TOP',
'LEFT'
];
export const convertBoundsToNamedAnchors = (boundingBox: BoundingBox) => {
export const convertBoundsToNamedAnchors = (
boundingBox: BoundingBox
): {
[key in AnchorPosition]: Coords;
} => {
return {
[AnchorPositionsEnum.BOTTOM_LEFT]: boundingBox[0],
[AnchorPositionsEnum.BOTTOM_RIGHT]: boundingBox[1],
[AnchorPositionsEnum.TOP_RIGHT]: boundingBox[2],
[AnchorPositionsEnum.TOP_LEFT]: boundingBox[3]
BOTTOM_LEFT: boundingBox[0],
BOTTOM_RIGHT: boundingBox[1],
TOP_RIGHT: boundingBox[2],
TOP_LEFT: boundingBox[3]
};
};
export const getAnchorAtTile = (tile: Coords, anchors: ConnectorAnchor[]) => {
return anchors.find((anchor) => {
return (
anchor.ref.type === 'TILE' && CoordsUtils.isEqual(anchor.ref.coords, tile)
return Boolean(
anchor.ref.tile && CoordsUtils.isEqual(anchor.ref.tile, tile)
);
});
};
@@ -580,7 +583,7 @@ export const getAnchorParent = (anchorId: string, connectors: Connector[]) => {
export const getTileScrollPosition = (
tile: Coords,
origin?: TileOriginEnum
origin?: TileOrigin
): Coords => {
const tilePosition = getTilePosition({ tile, origin });
@@ -589,3 +592,14 @@ export const getTileScrollPosition = (
y: -tilePosition.y
};
};
export const getConnectorsByViewItem = (
viewItemId: string,
connectors: Connector[]
) => {
return connectors.filter((connector) => {
return connector.anchors.find((anchor) => {
return anchor.ref.item === viewItemId;
});
});
};

View File

@@ -0,0 +1,24 @@
import { z } from 'zod';
import { coords, id, constrainedStrings, color } from '../common';
export const connectorStyleOptions = ['SOLID', 'DOTTED', 'DASHED'] as const;
export const anchorSchema = z.object({
id,
ref: z
.object({
item: id,
anchor: id,
tile: coords
})
.partial()
});
export const connectorSchema = z.object({
id,
description: constrainedStrings.description.optional(),
color: color.optional(),
width: z.number().optional(),
style: z.enum(connectorStyleOptions).optional(),
anchors: z.array(anchorSchema)
});

View File

@@ -0,0 +1,9 @@
import { z } from 'zod';
import { id, color, coords } from '../common';
export const rectangleSchema = z.object({
id,
color: color.optional(),
from: coords,
to: coords
});

View File

@@ -0,0 +1,16 @@
import { z } from 'zod';
import { ProjectionOrientationEnum } from 'src/types/common';
import { id, coords, constrainedStrings } from '../common';
export const textBoxSchema = z.object({
id,
tile: coords,
content: constrainedStrings.name,
fontSize: z.number().optional(),
orientation: z
.union([
z.literal(ProjectionOrientationEnum.X),
z.literal(ProjectionOrientationEnum.Y)
])
.optional()
});

14
src/validation/common.ts Normal file
View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
export const coords = z.object({
x: z.number(),
y: z.number()
});
export const id = z.string();
export const color = z.string();
export const constrainedStrings = {
name: z.string().max(100),
description: z.string().max(1000)
};

12
src/validation/icons.ts Normal file
View File

@@ -0,0 +1,12 @@
import { z } from 'zod';
import { id, constrainedStrings } from './common';
export const iconSchema = z.object({
id,
name: constrainedStrings.name,
url: z.string(),
collection: constrainedStrings.name.optional(),
isIsometric: z.boolean().optional()
});
export const iconsSchema = z.array(iconSchema);

Some files were not shown because too many files have changed in this diff Show More