refactor: migrate away from paperjs [PHASE 2]

This commit is contained in:
Mark Mankarious
2023-08-05 09:14:14 +01:00
committed by GitHub
parent 8e6995c615
commit 4da4235eda
72 changed files with 1499 additions and 1968 deletions

110
package-lock.json generated
View File

@@ -59,6 +59,7 @@
"react-dom": "^18.2.0",
"recharts": "^2.7.2",
"style-loader": "^3.3.3",
"svg-url-loader": "^8.0.0",
"ts-jest": "^29.0.5",
"ts-loader": "^9.4.2",
"tsconfig-paths-webpack-plugin": "^4.1.0",
@@ -4007,6 +4008,15 @@
"node": ">=0.6"
}
},
"node_modules/big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
"integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
"dev": true,
"engines": {
"node": "*"
}
},
"node_modules/binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -5482,6 +5492,15 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true
},
"node_modules/emojis-list": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
"integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
"dev": true,
"engines": {
"node": ">= 4"
}
},
"node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
@@ -6837,6 +6856,26 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/file-loader": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
"integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==",
"dev": true,
"dependencies": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"webpack": "^4.0.0 || ^5.0.0"
}
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -10583,6 +10622,20 @@
"node": ">=6.11.5"
}
},
"node_modules/loader-utils": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
"dev": true,
"dependencies": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
},
"engines": {
"node": ">=8.9.0"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -13028,6 +13081,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svg-url-loader": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/svg-url-loader/-/svg-url-loader-8.0.0.tgz",
"integrity": "sha512-5doSXvl18hY1fGsRLdhWAU5jgzgxJ06/gc/26cpuDnN0xOz1HmmfhkpL29SSrdIvhtxQ1UwGzmk7wTT/l48mKw==",
"dev": true,
"dependencies": {
"file-loader": "~6.2.0"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"webpack": "^5.0.0"
}
},
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -17509,6 +17577,12 @@
"integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==",
"dev": true
},
"big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
"integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
"dev": true
},
"binary-extensions": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
@@ -18603,6 +18677,12 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true
},
"emojis-list": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
"integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
"dev": true
},
"encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
@@ -19620,6 +19700,16 @@
"flat-cache": "^3.0.4"
}
},
"file-loader": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz",
"integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==",
"dev": true,
"requires": {
"loader-utils": "^2.0.0",
"schema-utils": "^3.0.0"
}
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -22387,6 +22477,17 @@
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
"dev": true
},
"loader-utils": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz",
"integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==",
"dev": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
"json5": "^2.1.2"
}
},
"locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -24223,6 +24324,15 @@
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="
},
"svg-url-loader": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/svg-url-loader/-/svg-url-loader-8.0.0.tgz",
"integrity": "sha512-5doSXvl18hY1fGsRLdhWAU5jgzgxJ06/gc/26cpuDnN0xOz1HmmfhkpL29SSrdIvhtxQ1UwGzmk7wTT/l48mKw==",
"dev": true,
"requires": {
"file-loader": "~6.2.0"
}
},
"symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",

View File

@@ -9,7 +9,7 @@
"url": "https://github.com/markmanx/isoflow.git"
},
"main": "./dist/index.js",
"types": "./dist/App.d.ts",
"types": "./dist/Isoflow.d.ts",
"scripts": {
"start": "webpack serve --config ./webpack/dev.config.js",
"dev": "nodemon --watch ./src/ -e ts,tsx --exec npm run build",
@@ -51,6 +51,7 @@
"react-dom": "^18.2.0",
"recharts": "^2.7.2",
"style-loader": "^3.3.3",
"svg-url-loader": "^8.0.0",
"ts-jest": "^29.0.5",
"ts-loader": "^9.4.2",
"tsconfig-paths-webpack-plugin": "^4.1.0",

View File

@@ -8,13 +8,14 @@ import {
IconInput,
NodeInput,
ConnectorInput,
GroupInput
GroupInput,
Scene
} from 'src/types';
import { useSceneStore, Scene } from 'src/stores/useSceneStore';
import { useSceneStore } from 'src/stores/useSceneStore';
import { GlobalStyles } from 'src/styles/GlobalStyles';
import { Renderer } from 'src/renderer/Renderer';
import { Renderer } from 'src/components/Renderer/Renderer';
import { sceneInputtoScene, sceneToSceneInput } from 'src/utils';
import { DefaultLabelContainer } from 'src/renderer/components/Node/DefaultLabelContainer';
import { LabelContainer } from 'src/components/Node/LabelContainer';
import { ItemControlsManager } from './components/ItemControls/ItemControlsManager';
interface Props {
@@ -88,7 +89,7 @@ export {
GroupInput,
ConnectorInput,
useIsoflow,
DefaultLabelContainer
LabelContainer
};
export default Isoflow;

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 141.38828 163.26061">
<g stroke="#cecece" stroke-width="1" stroke-alignment="center">
<polygon points="70.69436 122.44546 .00022 81.63018 70.69392 40.81515 141.38806 81.63043 70.69436 122.44546" fill="none"/>
<line x1="70.69414" y1="40.81503" x2="141.38784" />
<line y1="0" x2="70.69414" y2="40.81528" />
<line x1="70.69414" y1="122.44559" x2=".00044" y2="163.26061" />
<line x1="141.38828" y1="163.26061" x2="70.69414" y2="122.44533" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -0,0 +1,79 @@
import React, { useMemo } from 'react';
import { Box } from '@mui/material';
import { Connector as ConnectorI, Node, Coords, Scroll, Size } from 'src/types';
import { Svg } from 'src/components/Svg/Svg';
import { UNPROJECTED_TILE_SIZE } from 'src/config';
import {
pathfinder,
getBoundingBox,
getBoundingBoxSize,
getTilePosition
} from 'src/utils';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
interface Props {
connector: ConnectorI;
fromNode: Node;
toNode: Node;
scroll: Scroll;
zoom: number;
}
// How far a connector can be outside the grid area that covers two nodes
const BOUNDS_OFFSET: Coords = { x: 3, y: 3 };
export const Connector = ({
connector,
fromNode,
toNode,
zoom,
scroll
}: Props) => {
const connectorParams = useMemo(() => {
const searchArea = getBoundingBox(
[fromNode.position, toNode.position],
BOUNDS_OFFSET
);
const searchAreaSize = getBoundingBoxSize(searchArea);
const { findPath } = pathfinder(searchAreaSize);
const connectorRoute = findPath([fromNode.position, toNode.position]);
const connectorAreaSize = getBoundingBoxSize(connectorRoute);
const unprojectedTileSize = UNPROJECTED_TILE_SIZE * zoom;
const path = connectorRoute.reduce((acc, tile) => {
return `${acc} ${tile.x * unprojectedTileSize},${
tile.y * unprojectedTileSize
}`;
}, '');
const position = getTilePosition({
tile: fromNode.position,
zoom,
scroll
});
return { path, connectorAreaSize, position };
}, [fromNode.position, toNode.position, zoom, scroll]);
return (
<Box
sx={{
position: 'absolute',
left: connectorParams.position.x,
top: connectorParams.position.y
}}
>
<IsoTileArea
tileArea={connectorParams.connectorAreaSize}
zoom={zoom}
fill="none"
>
<polyline
points={connectorParams.path}
stroke="black"
strokeWidth={10 * zoom}
fill="none"
/>
</IsoTileArea>
</Box>
);
};

View File

@@ -1,10 +1,10 @@
import React, { useRef, useEffect, useState } from 'react';
import gsap from 'gsap';
import { List, Box, Card } from '@mui/material';
import { Coords } from 'src/types';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { getTileScreenPosition } from 'src/renderer/utils/gridHelpers';
import { useSceneStore } from 'src/stores/useSceneStore';
import gsap from 'gsap';
// import { List, Box, Card } from '@mui/material';
// import { useUiStateStore } from 'src/stores/useUiStateStore';
// import { getTileScreenPosition } from 'src/renderer/utils/gridHelpers';
// import { useSceneStore } from 'src/stores/useSceneStore';
interface Props {
children: React.ReactNode;

View File

@@ -0,0 +1,75 @@
import React, { useEffect, useRef, useCallback, useState } from 'react';
import { Box, useTheme } from '@mui/material';
import gsap from 'gsap';
import { getTilePosition } from 'src/utils';
import { Coords, TileOriginEnum, Scroll } from 'src/types';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
interface Props {
tile: Coords;
scroll: Scroll;
zoom: number;
}
export const Cursor = ({ tile, zoom, scroll }: Props) => {
const theme = useTheme();
const [isReady, setIsReady] = useState(false);
const containerRef = useRef<HTMLDivElement>();
const setPosition = useCallback(
({
tile: _tile,
animationDuration = 0.15
}: {
tile: Coords;
animationDuration?: number;
}) => {
if (!containerRef.current) return;
const position = getTilePosition({
tile: _tile,
origin: TileOriginEnum.TOP,
scroll,
zoom
});
gsap.to(containerRef.current, {
duration: animationDuration,
left: position.x,
top: position.y
});
},
[zoom, scroll]
);
useEffect(() => {
if (!containerRef.current || !isReady) return;
setPosition({ tile });
}, [tile, setPosition, isReady]);
useEffect(() => {
if (!containerRef.current || isReady) return;
gsap.killTweensOf(containerRef.current);
setPosition({ tile, animationDuration: 0 });
containerRef.current.style.opacity = '1';
setIsReady(true);
}, [tile, setPosition, isReady]);
return (
<Box
ref={containerRef}
sx={{
position: 'absolute'
}}
>
<IsoTileArea
fill={theme.palette.primary.main}
tileArea={{ width: 1, height: 1 }}
zoom={zoom}
cornerRadius={10 * zoom}
/>
</Box>
);
};

View File

@@ -0,0 +1,45 @@
import React, { useRef, useMemo } from 'react';
import { Box } from '@mui/material';
import gridTileSvg from 'src/assets/grid-tile-bg.svg';
import { Scroll } from 'src/types';
import { getProjectedTileSize } from 'src/utils';
interface Props {
scroll: Scroll;
zoom: number;
}
export const Grid = ({ zoom, scroll }: Props) => {
const containerRef = useRef<HTMLDivElement>();
const projectedTileSize = useMemo(() => {
return getProjectedTileSize({ zoom });
}, [zoom]);
return (
<Box
ref={containerRef}
sx={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
overflow: 'hidden',
pointerEvents: 'none'
}}
>
<Box
sx={{
position: 'absolute',
width: '100%',
height: '100%',
background: `repeat url("${gridTileSvg}")`,
backgroundSize: `${projectedTileSize.width}px`,
backgroundPosition: `calc(50% + ${
scroll.position.x % projectedTileSize.width
}px) calc(50% + ${scroll.position.y % projectedTileSize.height}px)`
}}
/>
</Box>
);
};

View File

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

View File

@@ -0,0 +1,88 @@
import React, { useMemo } from 'react';
import { Box } from '@mui/material';
import { UNPROJECTED_TILE_SIZE } from 'src/config';
import { Size, Coords } from 'src/types';
import { getIsoMatrixCSS, getProjectedTileSize } from 'src/utils';
import { Svg } from 'src/components/Svg/Svg';
interface Props {
tileArea: Size;
fill: string;
cornerRadius?: number;
stroke?: {
width: number;
color: string;
};
zoom: number;
children?: React.ReactNode;
}
export const IsoTileArea = ({
tileArea,
fill,
cornerRadius = 0,
stroke,
zoom,
children
}: Props) => {
const projectedTileSize = useMemo(() => {
return getProjectedTileSize({ zoom });
}, [zoom]);
const viewbox = useMemo<Size>(() => {
return {
width:
(tileArea.width / 2 + tileArea.height / 2) * projectedTileSize.width,
height:
(tileArea.width / 2 + tileArea.height / 2) * projectedTileSize.height
};
}, [tileArea, projectedTileSize]);
const translate = useMemo<Coords>(() => {
return { x: tileArea.width * (projectedTileSize.width / 2), y: 0 };
}, [tileArea, projectedTileSize]);
const strokeParams = useMemo(() => {
if (!stroke) return {};
return {
stroke: stroke.color,
strokeWidth: stroke.width,
strokeAlignment: 'center',
strokeLineJoin: 'round',
strokeLineCap: 'round'
};
}, [stroke]);
const marginLeft = useMemo(() => {
return -(tileArea.width * projectedTileSize.width * 0.5);
}, [projectedTileSize.width, tileArea.width]);
return (
<Box
sx={{
position: 'absolute',
marginLeft: `${marginLeft}px`
}}
>
<Svg
viewBox={`0 0 ${viewbox.width} ${viewbox.height}`}
width={`${viewbox.width}px`}
height={`${viewbox.height}px`}
>
<g transform={`translate(${translate.x}, ${translate.y})`}>
<g transform={getIsoMatrixCSS()}>
<rect
width={tileArea.width * UNPROJECTED_TILE_SIZE * zoom}
height={tileArea.height * UNPROJECTED_TILE_SIZE * zoom}
fill={fill}
rx={cornerRadius}
{...strokeParams}
/>
{children}
</g>
</g>
</Svg>
</Box>
);
};

View File

@@ -2,25 +2,31 @@ import React from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import { Button, Typography } from '@mui/material';
import { Icon as IconInterface } from 'src/stores/useSceneStore';
import { Icon as IconI } from 'src/types';
interface Props {
icon: IconInterface;
icon: IconI;
onClick: () => void;
}
export const Icon = ({ icon, onClick }: Props) => (
<Button variant="text" onClick={onClick}>
<Stack justifyContent="center" alignItems="center" sx={{ height: '100%' }}>
<Box
component="img"
src={icon.url}
alt={`Icon ${icon.name}`}
sx={{ width: '100%', height: 80 }}
/>
<Typography variant="body2" color="text.secondary">
{icon.name}
</Typography>
</Stack>
</Button>
);
export const Icon = ({ icon, onClick }: Props) => {
return (
<Button variant="text" onClick={onClick}>
<Stack
justifyContent="center"
alignItems="center"
sx={{ height: '100%' }}
>
<Box
component="img"
src={icon.url}
alt={`Icon ${icon.name}`}
sx={{ width: '100%', height: 80 }}
/>
<Typography variant="body2" color="text.secondary">
{icon.name}
</Typography>
</Stack>
</Button>
);
};

View File

@@ -1,23 +1,32 @@
import React from 'react';
import Grid from '@mui/material/Grid';
import { Icon as IconInterface } from 'src/stores/useSceneStore';
import { Icon as IconI } from 'src/types';
import { Icon } from './Icon';
import { Section } from '../../components/Section';
interface Props {
name?: string;
icons: IconInterface[];
onClick: (icon: IconInterface) => void;
icons: IconI[];
onClick: (icon: IconI) => void;
}
export const IconCategory = ({ name, icons, onClick }: Props) => (
<Section title={name}>
<Grid container spacing={2}>
{icons.map((icon) => (
<Grid item xs={3} key={icon.id}>
<Icon icon={icon} onClick={() => onClick(icon)} />
</Grid>
))}
</Grid>
</Section>
);
export const IconCategory = ({ name, icons, onClick }: Props) => {
return (
<Section title={name}>
<Grid container spacing={2}>
{icons.map((icon) => {
return (
<Grid item xs={3} key={icon.id}>
<Icon
icon={icon}
onClick={() => {
return onClick(icon);
}}
/>
</Grid>
);
})}
</Grid>
</Section>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useMemo } from 'react';
import Grid from '@mui/material/Grid';
import { Icon } from 'src/stores/useSceneStore';
import { Icon } from 'src/types';
import { IconCategory } from './IconCategory';
interface Props {
@@ -13,7 +13,9 @@ export const Icons = ({ icons, onClick }: Props) => {
const cats: { name?: string; icons: Icon[] }[] = [];
icons.forEach((icon) => {
const category = cats.find((cat) => cat.name === icon.category);
const category = cats.find((cat) => {
return cat.name === icon.category;
});
if (!category) {
cats.push({ name: icon.category, icons: [icon] });
@@ -37,11 +39,13 @@ export const Icons = ({ icons, onClick }: Props) => {
return (
<Grid container spacing={4}>
{categorisedIcons.map((cat) => (
<Grid item xs={12} key={`icon-category-${cat.name}`}>
<IconCategory {...cat} onClick={onClick} />
</Grid>
))}
{categorisedIcons.map((cat) => {
return (
<Grid item xs={12} key={`icon-category-${cat.name}`}>
<IconCategory {...cat} onClick={onClick} />
</Grid>
);
})}
</Grid>
);
};

View File

@@ -1,6 +1,7 @@
import React, { useState, useCallback } from 'react';
import { Tabs, Tab, Box } from '@mui/material';
import { useSceneStore, useNodeHooks, Node } from 'src/stores/useSceneStore';
import { Node } from 'src/types';
import { useSceneStore, useNodeHooks } from 'src/stores/useSceneStore';
import { ControlsContainer } from '../components/ControlsContainer';
import { Icons } from './IconSelection/IconSelection';
import { Header } from '../components/Header';
@@ -12,8 +13,12 @@ interface Props {
export const NodeControls = ({ nodeId }: Props) => {
const [tab, setTab] = useState(0);
const icons = useSceneStore((state) => state.icons);
const sceneActions = useSceneStore((state) => state.actions);
const icons = useSceneStore((state) => {
return state.icons;
});
const sceneActions = useSceneStore((state) => {
return state.actions;
});
const { useGetNodeById } = useNodeHooks();
const node = useGetNodeById(nodeId);
@@ -53,7 +58,9 @@ export const NodeControls = ({ nodeId }: Props) => {
{tab === 1 && (
<Icons
icons={icons}
onClick={(icon) => onNodeUpdated({ iconId: icon.id })}
onClick={(icon) => {
return onNodeUpdated({ iconId: icon.id });
}}
/>
)}
</ControlsContainer>

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Slider, useTheme } from '@mui/material';
import { Node } from 'src/stores/useSceneStore';
import { Node } from 'src/types';
import { ColorSelector } from 'src/components/ColorSelector/ColorSelector';
import { MarkdownEditor } from '../../../MarkdownEditor/MarkdownEditor';
@@ -26,7 +26,9 @@ export const NodeSettings = ({
<Section title="Label">
<MarkdownEditor
value={label}
onChange={(text) => onUpdate({ label: text })}
onChange={(text) => {
return onUpdate({ label: text });
}}
/>
</Section>
<Section title="Label height">
@@ -36,16 +38,18 @@ export const NodeSettings = ({
min={0}
max={200}
value={labelHeight}
onChange={(e, newHeight) =>
onUpdate({ labelHeight: newHeight as number })
}
onChange={(e, newHeight) => {
return onUpdate({ labelHeight: newHeight as number });
}}
/>
</Section>
<Section title="Color">
<ColorSelector
activeColor={color}
colors={Object.values(theme.customVars.diagramPalette)}
onChange={(col) => onUpdate({ color: col })}
onChange={(col) => {
return onUpdate({ color: col });
}}
/>
</Section>
</>

View File

@@ -0,0 +1,83 @@
// import { useRef, useCallback } from 'react';
// import { Group, Shape } from 'paper';
// import gsap from 'gsap';
// import { Coords } from 'src/types';
// import { UNPROJECTED_TILE_SIZE, PIXEL_UNIT } from 'src/renderer/utils/constants';
// import {
// getBoundingBox,
// sortByPosition,
// getTileBounds
// } from 'src/renderer/utils/gridHelpers';
// import { applyProjectionMatrix } from 'src/renderer/utils/projection';
// export const useLasso = () => {
// const containerRef = useRef(new Group());
// const shapeRef = useRef<paper.Shape.Rectangle>();
// const setSelection = useCallback((startTile: Coords, endTile: Coords) => {
// if (!shapeRef.current) return;
// const boundingBox = getBoundingBox([startTile, endTile]);
// // TODO: Enforce at least one node being passed to this getBoundingBox() to prevent null returns
// if (!boundingBox) return;
// const lassoStartTile = boundingBox[3];
// const lassoScreenPosition = getTileBounds(lassoStartTile).left;
// const sorted = sortByPosition(boundingBox);
// const position = { x: sorted.lowX, y: sorted.highY };
// const size = {
// x: sorted.highX - sorted.lowX,
// y: sorted.highY - sorted.lowY
// };
// shapeRef.current.set({
// position,
// size: [
// (size.x + 1) * (UNPROJECTED_TILE_SIZE - PIXEL_UNIT * 3),
// (size.y + 1) * (UNPROJECTED_TILE_SIZE - PIXEL_UNIT * 3)
// ]
// });
// containerRef.current.set({
// pivot: shapeRef.current.bounds.bottomLeft,
// position: lassoScreenPosition
// });
// }, []);
// const init = useCallback(() => {
// containerRef.current.removeChildren();
// containerRef.current.set({ pivot: [0, 0] });
// shapeRef.current = new Shape.Rectangle({
// strokeCap: 'round',
// fillColor: 'lightBlue',
// size: [UNPROJECTED_TILE_SIZE, UNPROJECTED_TILE_SIZE],
// opacity: 0.5,
// radius: PIXEL_UNIT * 8,
// strokeWidth: PIXEL_UNIT * 3,
// strokeColor: 'blue',
// dashArray: [5, 10],
// pivot: [0, 0]
// });
// gsap
// .fromTo(
// shapeRef.current,
// { dashOffset: 0 },
// { dashOffset: PIXEL_UNIT * 10, ease: 'none', duration: 0.25 }
// )
// .repeat(-1);
// containerRef.current.addChild(shapeRef.current);
// applyProjectionMatrix(containerRef.current);
// return containerRef.current;
// }, []);
// return {
// init,
// containerRef,
// setSelection
// };
// };

View File

@@ -0,0 +1,83 @@
import React, { useEffect, useRef } from 'react';
import gsap from 'gsap';
import { Box } from '@mui/material';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { Size } from 'src/types';
interface Props {
labelHeight: number;
tileSize: Size;
children: React.ReactNode;
connectorDotSize: number;
}
export const LabelContainer = ({
children,
labelHeight,
tileSize,
connectorDotSize
}: Props) => {
const contentRef = useRef<HTMLDivElement>();
const { observe, size: contentSize } = useResizeObserver();
useEffect(() => {
if (!contentRef.current) return;
observe(contentRef.current);
}, [observe]);
useEffect(() => {
if (!contentRef.current) return;
gsap.to(contentRef.current, {
duration: 0,
x: -contentSize.width * 0.5,
y: -(contentSize.height + labelHeight)
});
}, [contentSize, labelHeight]);
return (
<Box
sx={{
position: 'absolute',
width: 10,
height: 10
}}
>
<Box
component="svg"
sx={{
position: 'absolute',
top: -(labelHeight + tileSize.height / 2),
left: -connectorDotSize / 2
}}
>
<line
x1={connectorDotSize / 2}
y1={tileSize.height / 2}
x2={connectorDotSize / 2}
y2={labelHeight}
strokeDasharray={`0, ${connectorDotSize * 2}`}
stroke="black"
strokeWidth={connectorDotSize}
strokeLinecap="round"
/>
</Box>
<Box
ref={contentRef}
sx={{
position: 'absolute',
bgcolor: 'common.white',
border: '1px solid',
borderColor: 'grey.500',
borderRadius: 2,
overflow: 'hidden',
py: 1,
px: 1.5
}}
>
{children}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,136 @@
import React, { useEffect, useRef, useCallback, useMemo } from 'react';
import { Box } from '@mui/material';
import gsap from 'gsap';
import { Size, Coords, TileOriginEnum, Node as NodeI, Scroll } from 'src/types';
import {
getTilePosition,
getProjectedTileSize,
getColorVariant
} from 'src/utils';
import { useResizeObserver } from 'src/hooks/useResizeObserver';
import { IsoTileArea } from 'src/components/IsoTileArea/IsoTileArea';
import { LabelContainer } from './LabelContainer';
import { MarkdownLabel } from './LabelTypes/MarkdownLabel';
interface Props {
node: NodeI;
iconUrl?: string;
zoom: number;
scroll: Scroll;
}
export const Node = ({ node, iconUrl, zoom, scroll }: Props) => {
const nodeRef = useRef<HTMLDivElement>();
const iconRef = useRef<HTMLImageElement>();
const { observe, size: iconSize } = useResizeObserver();
useEffect(() => {
if (!iconRef.current) return;
observe(iconRef.current);
}, [observe]);
const projectedTileSize = useMemo<Size>(() => {
return getProjectedTileSize({ zoom });
}, [zoom]);
const moveToTile = useCallback(
({
tile,
animationDuration = 0.15
}: {
tile: Coords;
animationDuration?: number;
}) => {
if (!nodeRef.current || !iconRef.current) return;
const position = getTilePosition({
tile,
zoom,
scroll,
origin: TileOriginEnum.BOTTOM
});
gsap.to(iconRef.current, {
duration: animationDuration,
x: -iconRef.current.width * 0.5,
y: -iconRef.current.height
});
gsap.to(nodeRef.current, {
duration: animationDuration,
x: position.x,
y: position.y
});
},
[zoom, scroll]
);
const onImageLoaded = useCallback(() => {
if (!nodeRef.current || !iconRef.current) return;
gsap.killTweensOf(nodeRef.current);
moveToTile({ tile: node.position, animationDuration: 0 });
nodeRef.current.style.opacity = '1';
}, [node.position, moveToTile]);
useEffect(() => {
moveToTile({ tile: node.position, animationDuration: 0 });
}, [node.position, moveToTile]);
return (
<Box
ref={nodeRef}
sx={{
position: 'absolute',
opacity: 0
}}
>
<Box
sx={{
position: 'absolute'
}}
>
<Box
sx={{
position: 'absolute',
top: -projectedTileSize.height
}}
>
<IsoTileArea
tileArea={{
width: 1,
height: 1
}}
fill={node.color}
cornerRadius={15 * zoom}
stroke={{
width: 1 * zoom,
color: getColorVariant(node.color, 'dark', { grade: 1.5 })
}}
zoom={zoom}
/>
</Box>
<LabelContainer
labelHeight={node.labelHeight + iconSize.height}
tileSize={projectedTileSize}
connectorDotSize={5 * zoom}
>
{node.label && <MarkdownLabel label={node.label} />}
{node.labelComponent}
</LabelContainer>
</Box>
<Box
component="img"
ref={iconRef}
onLoad={onImageLoaded}
src={iconUrl}
sx={{
position: 'absolute',
width: projectedTileSize.width,
pointerEvents: 'none'
}}
/>
</Box>
);
};

View File

@@ -1,14 +1,14 @@
import React from 'react';
import React, { useCallback } from 'react';
import { Box } from '@mui/material';
import { Node as NodeI } from 'src/types';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { OriginEnum, getTilePosition } from 'src/utils';
import { useSceneStore } from 'src/stores/useSceneStore';
import { useInteractionManager } from 'src/interaction/useInteractionManager';
import { TILE_SIZE } from './utils/constants';
// import { ContextMenuLayer } from './components/ContextMenuLayer/ContextMenuLayer';
import { Grid } from './components/Grid/Grid';
import { Cursor } from './components/Cursor/Cursor';
import { NodeV2 } from './components/Node/NodeV2';
import { Grid } from 'src/components/Grid/Grid';
import { Cursor } from 'src/components/Cursor/Cursor';
import { Node } from 'src/components/Node/Node';
import { Group } from 'src/components/Group/Group';
import { Connector } from 'src/components/Connector/Connector';
export const Renderer = () => {
const scene = useSceneStore(({ nodes, connectors, groups }) => {
@@ -31,6 +31,21 @@ export const Renderer = () => {
});
useInteractionManager();
const getNodesFromIds = useCallback(
(nodeIds: string[]) => {
return nodeIds
.map((nodeId) => {
return scene.nodes.find((node) => {
return node.id === nodeId;
});
})
.filter((node) => {
return node !== undefined;
}) as NodeI[];
},
[scene.nodes]
);
return (
<Box
sx={{
@@ -38,60 +53,51 @@ export const Renderer = () => {
height: '100%'
}}
>
<Grid tileSize={TILE_SIZE * zoom} scroll={scroll.position} />
{mode.showCursor && (
<Cursor
position={getTilePosition(mouse.position.tile, OriginEnum.TOP)}
tileSize={TILE_SIZE * zoom}
/>
)}
{scene.nodes.map((node) => {
return (
<NodeV2
key={node.id}
position={getTilePosition(node.position, OriginEnum.BOTTOM)}
iconUrl={
icons.find((icon) => {
return icon.id === node.iconId;
})?.url
}
/>
);
})}
{/* {mode.type === 'LASSO' && (
<Lasso
parentContainer={renderer.lassoContainer.current as paper.Group}
startTile={mode.selection.startTile}
endTile={mode.selection.endTile}
/>
)}
{scene.connectors.map((connector) => {
return (
<Connector
key={connector.id}
connector={connector}
parentContainer={renderer.connectorManager.container as paper.Group}
/>
);
})}
<Grid scroll={scroll} zoom={zoom} />
{scene.groups.map((group) => {
const nodes = getNodesFromIds(group.nodeIds);
return (
<Group
key={group.id}
parentContainer={renderer.groupManager.container as paper.Group}
group={group}
nodes={nodes}
zoom={zoom}
scroll={scroll}
/>
);
})}
{mode.showCursor && (
<Cursor tile={mouse.position.tile} zoom={zoom} scroll={scroll} />
)}
{/* {scene.connectors.map((connector) => {
const nodes = getNodesFromIds([connector.from, connector.to]);
return (
<Connector
connector={connector}
fromNode={nodes[0]}
toNode={nodes[1]}
scroll={scroll}
zoom={zoom}
/>
);
})} */}
{scene.nodes.map((node) => {
return (
<Node
key={node.id}
node={node}
parentContainer={renderer.nodeManager.container as paper.Group}
iconUrl={
icons.find((icon) => {
return icon.id === node.iconId;
})?.url
}
zoom={zoom}
scroll={scroll}
/>
);
})} */}
})}
</Box>
);
};

View File

@@ -0,0 +1,13 @@
import React from 'react';
type Props = React.SVGProps<SVGSVGElement> & {
children: React.ReactNode;
};
export const Svg = ({ children, ...rest }: Props) => {
return (
<svg xmlns="http://www.w3.org/2000/svg" {...rest}>
{children}
</svg>
);
};

View File

@@ -1,18 +1,15 @@
import { customVars } from '../styles/theme';
import { Size } from 'src/types';
import { customVars } from './styles/theme';
export const UNPROJECTED_TILE_SIZE = 100;
export const TILE_PROJECTION_MULTIPLIERS: Size = {
width: 1.415,
height: 0.819
};
export const DEFAULT_COLOR = customVars.diagramPalette.blue;
export const CONNECTOR_DEFAULTS = {
width: 4
};
export const GRID_DEFAULTS = {
size: {
x: 51,
y: 51
}
};
export const NODE_DEFAULTS = {
label: '',
labelHeight: 100,

View File

@@ -53,18 +53,43 @@ export const CustomNode = () => {
<Isoflow
initialScene={{
icons,
connectors: [],
groups: [],
connectors: [
{
id: 'connector1',
from: 'database',
to: 'server',
label: 'connection'
}
],
groups: [
{
id: 'group1',
label: 'Group 1',
nodeIds: ['server', 'database']
}
],
nodes: [
{
id: 'Node1',
id: 'server',
label: 'Requests per minute',
labelElement: <CustomLabel />,
labelComponent: <CustomLabel />,
labelHeight: 40,
iconId: 'server',
position: {
x: 0,
y: 0
}
},
{
id: 'database',
label: 'Transactions',
labelComponent: <CustomLabel />,
labelHeight: 40,
iconId: 'server',
position: {
x: 0,
y: 3
}
}
]
}}

4
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.svg' {
const content: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
export default content;
}

View File

@@ -0,0 +1,35 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Size } from 'src/types';
export const useResizeObserver = () => {
const resizeObserverRef = useRef<ResizeObserver>();
const [size, setSize] = useState<Size>({ width: 0, height: 0 });
const disconnect = useCallback(() => {
resizeObserverRef.current?.disconnect();
}, []);
const observe = useCallback((element: HTMLElement) => {
disconnect();
resizeObserverRef.current = new ResizeObserver(() => {
setSize({
width: element.clientWidth,
height: element.clientHeight
});
});
resizeObserverRef.current.observe(element);
}, []);
useEffect(() => {
return () => {
disconnect();
};
}, [disconnect]);
return {
observe,
size
};
};

View File

@@ -1,25 +0,0 @@
import { useState, useEffect } from 'react';
export const useWindowSize = () => {
const [windowSize, setWindowSize] = useState({
width: window.innerWidth,
height: window.innerHeight
});
useEffect(() => {
const onResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
};
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
};
}, []);
return windowSize;
};

View File

@@ -1,7 +1,5 @@
import { SidebarTypeEnum } from 'src/stores/useUiStateStore';
import { CoordsUtils } from 'src/utils';
import { InteractionReducer } from '../types';
import { getItemsByTile } from '../../renderer/utils/gridHelpers';
import { SidebarTypeEnum, InteractionReducer } from 'src/types';
import { CoordsUtils, filterNodesByTile } from 'src/utils';
export const Cursor: InteractionReducer = {
mousemove: (draftState) => {
@@ -16,7 +14,7 @@ export const Cursor: InteractionReducer = {
if (draftState.mode.mousedown) {
// User is in mousedown mode
if (draftState.mode.mousedown.items.nodes.length > 0) {
if (draftState.mode.mousedown.items.length > 0) {
// User's last mousedown action was on a node
draftState.mode = {
type: 'DRAG_ITEMS',
@@ -42,9 +40,9 @@ export const Cursor: InteractionReducer = {
mousedown: (draftState) => {
if (draftState.mode.type !== 'CURSOR') return;
const itemsAtTile = getItemsByTile({
const itemsAtTile = filterNodesByTile({
tile: draftState.mouse.position.tile,
sortedSceneItems: draftState.scene
nodes: draftState.scene.nodes
});
draftState.mode.mousedown = {
@@ -64,7 +62,7 @@ export const Cursor: InteractionReducer = {
if (draftState.mode.mousedown !== null) {
// User's last mousedown action was on a scene item
const mousedownNode = draftState.mode.mousedown.items.nodes[0];
const mousedownNode = draftState.mode.mousedown.items[0];
if (mousedownNode) {
// The user's last mousedown action was on a node

View File

@@ -1,5 +1,5 @@
import { CoordsUtils } from 'src/utils';
import { InteractionReducer } from '../types';
import { InteractionReducer } from 'src/types';
export const DragItems: InteractionReducer = {
mousemove: (draftState) => {
@@ -10,7 +10,7 @@ export const DragItems: InteractionReducer = {
!CoordsUtils.isEqual(draftState.mouse.delta.tile, CoordsUtils.zero())
) {
// User has moved tile since the last mouse event
draftState.mode.items.nodes.forEach((node) => {
draftState.mode.items.forEach((node) => {
const nodeIndex = draftState.scene.nodes.findIndex((sceneNode) => {
return sceneNode.id === node.id;
});

View File

@@ -1,6 +1,5 @@
import { CoordsUtils } from 'src/utils';
import { isWithinBounds } from 'src/renderer/utils/gridHelpers';
import { InteractionReducer } from '../types';
import { CoordsUtils, isWithinBounds } from 'src/utils';
import { InteractionReducer } from 'src/types';
export const Lasso: InteractionReducer = {
mousemove: (draftState) => {

View File

@@ -1,5 +1,5 @@
import { CoordsUtils } from 'src/utils';
import { InteractionReducer } from '../types';
import { InteractionReducer } from 'src/types';
export const Pan: InteractionReducer = {
mousemove: (draftState) => {

View File

@@ -1,13 +1,13 @@
import { useCallback, useEffect } from 'react';
import { produce } from 'immer';
import { useSceneStore } from 'src/stores/useSceneStore';
import { useUiStateStore, Mouse } from 'src/stores/useUiStateStore';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { CoordsUtils, screenToIso } from 'src/utils';
import { InteractionReducer, Mouse, State } from 'src/types';
import { DragItems } from './reducers/DragItems';
import { Pan } from './reducers/Pan';
import { Cursor } from './reducers/Cursor';
import { Lasso } from './reducers/Lasso';
import type { InteractionReducer } from './types';
const reducers: { [k in string]: InteractionReducer } = {
CURSOR: Cursor,
@@ -23,6 +23,9 @@ export const useInteractionManager = () => {
const scroll = useUiStateStore((state) => {
return state.scroll;
});
const zoom = useUiStateStore((state) => {
return state.zoom;
});
const mouse = useUiStateStore((state) => {
return state.mouse;
});
@@ -35,8 +38,8 @@ export const useInteractionManager = () => {
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const scene = useSceneStore(({ nodes, connectors, groups }) => {
return { nodes, connectors, groups };
const scene = useSceneStore(({ nodes, connectors, groups, icons }) => {
return { nodes, connectors, groups, icons };
});
const sceneActions = useSceneStore((state) => {
return state.actions;
@@ -60,7 +63,11 @@ export const useInteractionManager = () => {
const newPosition: Mouse['position'] = {
screen: { x: e.clientX, y: e.clientY },
tile: screenToIso({ x: e.clientX, y: e.clientY })
tile: screenToIso({
mouse: { x: e.clientX, y: e.clientY },
zoom,
scroll
})
};
const newDelta: Mouse['delta'] = {
@@ -85,19 +92,18 @@ export const useInteractionManager = () => {
mousedown: getMousedown()
};
const newState = produce(
{
scene,
mouse: nextMouse,
mode,
scroll,
contextMenu,
itemControls
},
(draft) => {
return reducerAction(draft);
}
);
const baseState: State = {
scene,
mouse: nextMouse,
mode,
scroll,
contextMenu,
itemControls
};
const newState = produce(baseState, (draft) => {
return reducerAction(draft);
});
uiStateActions.setMouse(nextMouse);
uiStateActions.setScroll(newState.scroll);
@@ -108,12 +114,16 @@ export const useInteractionManager = () => {
},
[
mode,
mouse.position.screen,
mouse.position.tile,
mouse.mousedown,
scroll,
itemControls,
uiStateActions,
sceneActions,
scene,
contextMenu
contextMenu,
zoom
]
);

View File

@@ -1,74 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import Paper from 'paper';
import { Box } from '@mui/material';
interface Props {
children: React.ReactNode;
}
const render = () => {
if (Paper.view) {
if (global.requestAnimationFrame) {
const raf = global.requestAnimationFrame(render);
return raf;
}
Paper.view.update();
}
};
export const Initialiser = ({ children }: Props) => {
const [isReady, setIsReady] = useState(false);
const containerRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
setIsReady(false);
if (!containerRef.current) return;
Paper.settings = {
insertItems: false,
applyMatrix: false
};
Paper.setup(containerRef.current);
const rafId = render();
setIsReady(true);
return () => {
setIsReady(false);
if (rafId) cancelAnimationFrame(rafId);
Paper.projects.forEach((project) => {
return project.remove();
});
};
}, []);
return (
<Box
sx={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%'
}}
>
<canvas
ref={containerRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%'
}}
/>
{isReady && children}
</Box>
);
};

View File

@@ -1,44 +0,0 @@
import { useEffect } from 'react';
import {
Connector as ConnectorInterface,
useSceneStore
} from 'src/stores/useSceneStore';
import { useConnector } from './useConnector';
interface ConnectorProps {
connector: ConnectorInterface;
parentContainer: paper.Group;
}
export const Connector = ({ parentContainer, connector }: ConnectorProps) => {
const { init, updateFromTo, updateColor } = useConnector();
const nodes = useSceneStore((state) => {
return state.nodes;
});
useEffect(() => {
const container = init();
parentContainer.addChild(container);
}, [parentContainer, init]);
useEffect(() => {
updateColor(connector.color);
}, [connector, updateColor]);
useEffect(() => {
const fromNode = nodes.find((node) => {
return node.id === connector.from;
});
const toNode = nodes.find((node) => {
return node.id === connector.to;
});
if (!fromNode || !toNode) return;
updateFromTo({ x: 100, y: 100 }, fromNode.position, toNode.position);
}, [nodes, connector, updateFromTo]);
return null;
};

View File

@@ -1,54 +0,0 @@
import { useCallback, useRef } from 'react';
import { Group, Path } from 'paper';
import { pathfinder } from 'src/renderer/utils/pathfinder';
import { Coords } from 'src/types';
import { getTileBounds } from 'src/renderer/utils/gridHelpers';
export const useConnector = () => {
const containerRef = useRef(new Group());
const pathRef = useRef<paper.Path>();
const updateColor = useCallback((color: string) => {
if (!pathRef.current) return;
pathRef.current.set({
strokeColor: color
});
}, []);
const updateFromTo = useCallback(
(gridSize: Coords, from: Coords, to: Coords) => {
if (!pathRef.current) return;
const { findPath } = pathfinder(gridSize);
const path = findPath([from, to]);
const points = path.map((tile) => {
return getTileBounds(tile).center;
});
pathRef.current.set({
segments: points
});
},
[]
);
const init = useCallback(() => {
containerRef.current.removeChildren();
pathRef.current = new Path({
strokeWidth: 5
});
containerRef.current.addChild(pathRef.current);
return containerRef.current;
}, []);
return {
init,
updateColor,
updateFromTo
};
};

View File

@@ -1,72 +0,0 @@
import React, { useEffect, useRef, useCallback, useState } from 'react';
import gsap from 'gsap';
import { Box, useTheme } from '@mui/material';
import { getCSSMatrix } from 'src/renderer/utils/projection';
interface Props {
position: { x: number; y: number };
tileSize: number;
}
// TODO: Remove tilesize
export const Cursor = ({ position, tileSize }: Props) => {
const theme = useTheme();
const [isReady, setIsReady] = useState(false);
const ref = useRef<SVGElement>();
const setPosition = useCallback(
({
position: _position,
animationDuration = 0.15
}: {
position: { x: number; y: number };
animationDuration?: number;
}) => {
if (!ref.current) return;
gsap.to(ref.current, {
duration: animationDuration,
left: _position.x,
top: _position.y
});
},
[]
);
useEffect(() => {
if (!ref.current || !isReady) return;
setPosition({ position });
}, [position, setPosition, isReady]);
useEffect(() => {
if (!ref.current || isReady) return;
gsap.killTweensOf(ref.current);
setPosition({ position, animationDuration: 0 });
ref.current.style.opacity = '1';
setIsReady(true);
}, [position, setPosition, isReady]);
return (
<Box
ref={ref}
component="svg"
sx={{
position: 'absolute',
transform: getCSSMatrix({ x: -(tileSize / 2), y: -(tileSize / 2) }),
opacity: 0
}}
width={tileSize}
height={tileSize}
>
<rect
width={tileSize}
height={tileSize}
fill={theme.palette.primary.main}
opacity={0.7}
rx={10}
/>
</Box>
);
};

View File

@@ -1,61 +0,0 @@
import { useRef, useCallback } from 'react';
import { Group, Shape } from 'paper';
import gsap from 'gsap';
import { Coords } from 'src/types';
import { TILE_SIZE, PIXEL_UNIT } from '../../utils/constants';
import { applyProjectionMatrix } from '../../utils/projection';
export const useCursor = () => {
const container = useRef(new Group());
const init = useCallback(() => {
container.current.removeChildren();
container.current.set({ pivot: [0, 0] });
const rectangle = new Shape.Rectangle({
strokeCap: 'round',
fillColor: 'blue',
size: [TILE_SIZE, TILE_SIZE],
opacity: 0.5,
radius: PIXEL_UNIT * 8,
strokeWidth: 0,
strokeColor: 'transparent',
pivot: [0, 0],
position: [0, 0],
dashArray: null
});
container.current.addChild(rectangle);
applyProjectionMatrix(container.current);
return container.current;
}, []);
const moveTo = useCallback(
(position: Coords, opts?: { animationDuration?: number }) => {
const tweenProxy = {
x: container.current.position.x,
y: container.current.position.y
};
gsap.to(tweenProxy, {
duration: opts?.animationDuration || 0.1,
...position,
onUpdate: () => {
container.current.position.set(tweenProxy);
}
});
},
[]
);
const setVisible = useCallback((state: boolean) => {
container.current.visible = state;
}, []);
return {
init,
moveTo,
setVisible
};
};

View File

@@ -1,78 +0,0 @@
import React from 'react';
import { Box } from '@mui/material';
import { PROJECTED_TILE_DIMENSIONS } from 'src/renderer/utils/constants';
import { getCSSMatrix } from 'src/renderer/utils/projection';
import { useWindowSize } from 'src/hooks/useWindowSize';
interface Props {
tileSize: number;
scroll: { x: number; y: number };
}
export const Grid = ({ tileSize: _tileSize, scroll }: Props) => {
const windowSize = useWindowSize();
const tileSize = _tileSize;
return (
<Box
sx={{
position: 'absolute',
left: 0,
top: 0,
width: '100%',
height: '100%',
overflow: 'hidden',
pointerEvents: 'none'
}}
>
<Box
sx={{
position: 'absolute',
width: '300%',
height: '300%',
left: '-100%',
top: '-100%',
transform: `${getCSSMatrix()}`
}}
>
<Box component="svg" width="100%" height="100%">
{/* <pattern
id="dotpattern"
x={`calc(50% - ${tileSize * 0.5}px)`}
y={`calc(50% - ${tileSize * 0.5}px)`}
width={tileSize}
height={tileSize}
patternUnits="userSpaceOnUse"
>
<circle cx="0" cy="0" r={2} fill="rgba(0, 0, 0, 0.3)" />
</pattern> */}
<pattern
id="gridpattern"
x={`${windowSize.width * 1.5 - tileSize * 0.5}px`}
y={`${windowSize.height * 1.5 - tileSize * 0.5}px`}
width={tileSize}
height={tileSize}
patternUnits="userSpaceOnUse"
>
<rect
x="0"
y="0"
width={tileSize}
height={tileSize}
strokeWidth={1}
stroke="rgba(0, 0, 0, 0.3)"
fill="none"
/>
</pattern>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="url(#gridpattern)"
/>
</Box>
</Box>
</Box>
);
};

View File

@@ -1,51 +0,0 @@
import { useEffect, useMemo } from 'react';
import {
Group as GroupInterface,
useSceneStore,
Node
} from 'src/stores/useSceneStore';
import { DEFAULT_COLOR } from 'src/utils/config';
import { getColorVariant } from 'src/utils';
import { useGroup } from './useGroup';
interface GroupProps {
group: GroupInterface;
parentContainer: paper.Group;
}
export const Group = ({ group, parentContainer }: GroupProps) => {
const { init, setTiles, setColor } = useGroup();
const allNodes = useSceneStore((state) => {
return state.nodes;
});
const groupNodes = useMemo(() => {
const nodes = group.nodeIds.map((nodeId) => {
return allNodes.find((node) => {
return node.id === nodeId;
});
});
return nodes.filter((node) => {
return node !== undefined;
}) as Node[];
}, [allNodes, group.nodeIds]);
useEffect(() => {
const container = init();
parentContainer.addChild(container);
}, [init, parentContainer]);
useEffect(() => {
setTiles(
groupNodes.map((node) => {
return node.position;
})
);
}, [groupNodes, setTiles]);
useEffect(() => {
setColor(DEFAULT_COLOR);
}, [setColor]);
return null;
};

View File

@@ -1,77 +0,0 @@
import { useCallback, useRef } from 'react';
import { Group, Shape } from 'paper';
import { Coords } from 'src/types';
import { PIXEL_UNIT, TILE_SIZE } from 'src/renderer/utils/constants';
import { getColorVariant } from 'src/utils';
import {
getBoundingBox,
sortByPosition,
getTileBounds
} from '../../utils/gridHelpers';
import { applyProjectionMatrix } from '../../utils/projection';
export const useGroup = () => {
// TODO: Make sure consistent naming for all containers among all scene components
const containerRef = useRef(new Group());
const pathRef = useRef<paper.Shape.Rectangle>();
const setColor = useCallback((color: string) => {
if (!pathRef.current) return;
const fillColor = getColorVariant(color, 'light', { alpha: 0.5 });
pathRef.current.set({ fillColor, strokeColor: color });
}, []);
const setTiles = useCallback((tiles: Coords[]) => {
if (!pathRef.current) return;
const corners = getBoundingBox(tiles, { x: 1, y: 1 });
if (corners === null) {
containerRef.current.removeChildren();
throw new Error('Group has no nodes');
}
const sorted = sortByPosition(corners);
const size = {
x: sorted.highX - sorted.lowX,
y: sorted.highY - sorted.lowY
};
pathRef.current.set({
position: [0, 0],
radius: PIXEL_UNIT * 17,
size: [
(size.x + 1) * (TILE_SIZE - PIXEL_UNIT * 3),
(size.y + 1) * (TILE_SIZE - PIXEL_UNIT * 3)
]
});
containerRef.current.set({
pivot: pathRef.current.bounds.bottomLeft,
position: getTileBounds(corners[3]).left
});
}, []);
// TODO: Do we really need init an init function on each component hook? Does any of them take arguments?
const init = useCallback(() => {
containerRef.current.removeChildren();
pathRef.current = new Shape.Rectangle({
strokeCap: 'round',
strokeWidth: PIXEL_UNIT
});
containerRef.current.addChild(pathRef.current);
applyProjectionMatrix(containerRef.current);
return containerRef.current;
}, []);
return {
init,
setTiles,
setColor
};
};

View File

@@ -1,29 +0,0 @@
import { useEffect } from 'react';
import { Coords } from 'src/types';
import { useLasso } from './useLasso';
interface Props {
startTile: Coords;
endTile: Coords;
parentContainer: paper.Group;
}
export const Lasso = ({ startTile, endTile, parentContainer }: Props) => {
const lasso = useLasso();
const { init: initLasso, setSelection } = lasso;
useEffect(() => {
const container = initLasso();
parentContainer.addChild(container);
return () => {
container.remove();
};
}, [initLasso, parentContainer]);
useEffect(() => {
setSelection(startTile, endTile);
}, [setSelection, startTile, endTile]);
return null;
};

View File

@@ -1,83 +0,0 @@
import { useRef, useCallback } from 'react';
import { Group, Shape } from 'paper';
import gsap from 'gsap';
import { Coords } from 'src/types';
import { TILE_SIZE, PIXEL_UNIT } from 'src/renderer/utils/constants';
import {
getBoundingBox,
sortByPosition,
getTileBounds
} from 'src/renderer/utils/gridHelpers';
import { applyProjectionMatrix } from 'src/renderer/utils/projection';
export const useLasso = () => {
const containerRef = useRef(new Group());
const shapeRef = useRef<paper.Shape.Rectangle>();
const setSelection = useCallback((startTile: Coords, endTile: Coords) => {
if (!shapeRef.current) return;
const boundingBox = getBoundingBox([startTile, endTile]);
// TODO: Enforce at least one node being passed to this getBoundingBox() to prevent null returns
if (!boundingBox) return;
const lassoStartTile = boundingBox[3];
const lassoScreenPosition = getTileBounds(lassoStartTile).left;
const sorted = sortByPosition(boundingBox);
const position = { x: sorted.lowX, y: sorted.highY };
const size = {
x: sorted.highX - sorted.lowX,
y: sorted.highY - sorted.lowY
};
shapeRef.current.set({
position,
size: [
(size.x + 1) * (TILE_SIZE - PIXEL_UNIT * 3),
(size.y + 1) * (TILE_SIZE - PIXEL_UNIT * 3)
]
});
containerRef.current.set({
pivot: shapeRef.current.bounds.bottomLeft,
position: lassoScreenPosition
});
}, []);
const init = useCallback(() => {
containerRef.current.removeChildren();
containerRef.current.set({ pivot: [0, 0] });
shapeRef.current = new Shape.Rectangle({
strokeCap: 'round',
fillColor: 'lightBlue',
size: [TILE_SIZE, TILE_SIZE],
opacity: 0.5,
radius: PIXEL_UNIT * 8,
strokeWidth: PIXEL_UNIT * 3,
strokeColor: 'blue',
dashArray: [5, 10],
pivot: [0, 0]
});
gsap
.fromTo(
shapeRef.current,
{ dashOffset: 0 },
{ dashOffset: PIXEL_UNIT * 10, ease: 'none', duration: 0.25 }
)
.repeat(-1);
containerRef.current.addChild(shapeRef.current);
applyProjectionMatrix(containerRef.current);
return containerRef.current;
}, []);
return {
init,
containerRef,
setSelection
};
};

View File

@@ -1,52 +0,0 @@
import React, { useEffect } from 'react';
import { Box } from '@mui/material';
import { useLabelConnector } from './useLabelConnector';
interface Props {
labelHeight: number;
children: React.ReactNode;
parentContainer: paper.Group;
}
// TODO: Rename all `parentContainer` to `groupContainer`
export const DefaultLabelContainer = ({
children,
labelHeight,
parentContainer
}: Props) => {
const labelConnector = useLabelConnector();
const {
init: initLabelConnector,
updateHeight: updateLabelHeight,
destroy: destroyLabelConnector
} = labelConnector;
useEffect(() => {
const labelConnectorContainer = initLabelConnector();
parentContainer.addChild(labelConnectorContainer);
return () => {
destroyLabelConnector();
};
}, [initLabelConnector, destroyLabelConnector, parentContainer]);
useEffect(() => {
updateLabelHeight(labelHeight);
}, [labelHeight, updateLabelHeight]);
return (
<Box
sx={{
bgcolor: 'common.white',
border: '1px solid',
borderColor: 'grey.500',
borderRadius: 2,
overflow: 'hidden',
py: 1,
px: 1.5
}}
>
{children}
</Box>
);
};

View File

@@ -1,133 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { Group } from 'paper';
import { Box } from '@mui/material';
import gsap from 'gsap';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { Node as NodeInterface } from 'src/stores/useSceneStore';
import { useNodeIcon } from './useNodeIcon';
import { DefaultLabelContainer } from './DefaultLabelContainer';
import { useNodeTile } from './useNodeTile';
import { MarkdownLabel } from './LabelTypes/MarkdownLabel';
import {
getTilePosition,
getTileScreenPosition
} from '../../utils/gridHelpers';
export interface NodeProps {
node: NodeInterface;
parentContainer: paper.Group;
}
export const Node = ({ node, parentContainer }: NodeProps) => {
const [isFirstDisplay, setIsFirstDisplay] = useState(true);
const groupRef = useRef(new Group());
const labelRef = useRef<HTMLDivElement>();
const labelConnectorContainer = useRef(new Group());
const nodeIcon = useNodeIcon();
const nodeTile = useNodeTile();
const scroll = useUiStateStore((state) => {
return state.scroll;
});
const zoom = useUiStateStore((state) => {
return state.zoom;
});
const mode = useUiStateStore((state) => {
return state.mode;
});
const [labelSize, setLabelSize] = useState({ width: 0, height: 0 });
const {
init: initNodeIcon,
update: updateNodeIcon,
isLoaded: isIconLoaded
} = nodeIcon;
const { init: initNodeTile, updateColor, setActive } = nodeTile;
useEffect(() => {
const nodeIconContainer = initNodeIcon();
const nodeTileContainer = initNodeTile();
groupRef.current.removeChildren();
groupRef.current.addChild(nodeTileContainer);
groupRef.current.addChild(labelConnectorContainer.current);
groupRef.current.addChild(nodeIconContainer);
groupRef.current.pivot = nodeIconContainer.bounds.bottomCenter;
parentContainer.addChild(groupRef.current);
}, [initNodeIcon, parentContainer, initNodeTile]);
useEffect(() => {
updateNodeIcon(node.iconId);
}, [node.iconId, updateNodeIcon]);
useEffect(() => {
if (!isIconLoaded) return;
const tweenValues = groupRef.current.position;
const endState = getTilePosition(node.position);
gsap.to(tweenValues, {
duration: isFirstDisplay ? 0 : 0.1,
...endState,
onUpdate: () => {
groupRef.current.position.set(tweenValues);
}
});
if (isFirstDisplay) setIsFirstDisplay(false);
}, [node.position, isFirstDisplay, isIconLoaded]);
useEffect(() => {
if (!labelRef.current) return;
const screenPosition = getTileScreenPosition({
position: node.position,
scrollPosition: scroll.position,
zoom,
origin: 'top'
});
gsap.to(labelRef.current, {
duration: mode.type === 'PAN' ? 0 : 0.1,
left: screenPosition.x - labelSize.width * 0.5,
top: screenPosition.y - labelSize.height - node.labelHeight * zoom,
scale: zoom
});
}, [node.position, node.labelHeight, zoom, scroll.position, mode, labelSize]);
useEffect(() => {
if (!labelRef.current) return;
setLabelSize({
width: labelRef.current.clientWidth ?? 0,
height: labelRef.current.clientHeight ?? 0
});
}, [node.label, node.labelElement]);
useEffect(() => {
updateColor(node.color);
}, [node.color, updateColor]);
useEffect(() => {
setActive(node.isSelected);
}, [setActive, node.isSelected]);
return (
<Box
ref={labelRef}
sx={{
position: 'absolute',
transformOrigin: 'bottom center'
}}
>
{(node.labelElement || node.label) && (
<DefaultLabelContainer
labelHeight={node.labelHeight}
parentContainer={labelConnectorContainer.current}
>
{node.label && <MarkdownLabel label={node.label} />}
{node.labelElement !== undefined && node.labelElement}
</DefaultLabelContainer>
)}
</Box>
);
};

View File

@@ -1,64 +0,0 @@
import React, { useEffect, useRef, useCallback } from 'react';
import { Box } from '@mui/material';
import gsap from 'gsap';
import { PROJECTED_TILE_DIMENSIONS } from 'src/renderer/utils/constants';
interface Props {
iconUrl?: string;
position: { x: number; y: number };
}
export const NodeV2 = ({ iconUrl, position }: Props) => {
const ref = useRef<HTMLImageElement>();
const setPosition = useCallback(
({
position: _position,
animationDuration = 0.15
}: {
position: { x: number; y: number };
animationDuration?: number;
}) => {
if (!ref.current) return;
gsap.to(ref.current, {
duration: animationDuration,
x: _position.x - PROJECTED_TILE_DIMENSIONS.width / 2,
y:
_position.y -
PROJECTED_TILE_DIMENSIONS.height / 2 -
ref.current.height
});
},
[]
);
useEffect(() => {
if (!ref.current) return;
setPosition({ position });
}, [position, setPosition]);
const onImageLoaded = useCallback(() => {
if (!ref.current) return;
gsap.killTweensOf(ref.current);
setPosition({ position, animationDuration: 0 });
ref.current.style.opacity = '1';
}, [position, setPosition]);
return (
<Box
ref={ref}
onLoad={onImageLoaded}
component="img"
src={iconUrl}
sx={{
position: 'absolute',
width: PROJECTED_TILE_DIMENSIONS.width,
pointerEvents: 'none',
opacity: 0
}}
/>
);
};

View File

@@ -1,51 +0,0 @@
import { useRef, useCallback } from 'react';
import { Group, Path, Point } from 'paper';
import { useTheme } from '@mui/material';
import {
PIXEL_UNIT,
PROJECTED_TILE_DIMENSIONS
} from 'src/renderer/utils/constants';
export const useLabelConnector = () => {
const theme = useTheme();
const containerRef = useRef(new Group());
const pathRef = useRef<paper.Path.Line>();
const updateHeight = useCallback((labelHeight: number) => {
if (!pathRef.current) return;
pathRef.current.segments[1].point.y = -(
labelHeight +
PROJECTED_TILE_DIMENSIONS.height * 0.5
);
}, []);
const init = useCallback(() => {
containerRef.current.removeChildren();
pathRef.current = new Path.Line({
strokeColor: theme.palette.grey[800],
strokeWidth: PIXEL_UNIT * 2.5,
dashArray: [0, PIXEL_UNIT * 6],
strokeJoin: 'round',
strokeCap: 'round',
dashOffset: PIXEL_UNIT * 4,
from: new Point(0, 0)
});
containerRef.current.addChild(pathRef.current);
return containerRef.current;
}, [theme.palette.grey]);
const destroy = useCallback(() => {
return containerRef.current.remove();
}, []);
return {
containerRef,
init,
updateHeight,
destroy
};
};

View File

@@ -1,69 +0,0 @@
import { useRef, useCallback, useState } from 'react';
import { Group, Raster, Point } from 'paper';
import { useSceneStore } from 'src/stores/useSceneStore';
import { PROJECTED_TILE_DIMENSIONS } from '../../utils/constants';
const NODE_IMG_PADDING = 0;
export const useNodeIcon = () => {
const [isLoaded, setIsLoaded] = useState(false);
const container = useRef(new Group());
const icons = useSceneStore((state) => {
return state.icons;
});
const update = useCallback(
async (iconId: string) => {
setIsLoaded(false);
container.current.removeChildren();
const icon = icons.find((_icon) => {
return _icon.id === iconId;
});
if (!icon) return;
await new Promise((resolve) => {
const iconRaster = new Raster();
iconRaster.onLoad = () => {
if (!container.current) return;
iconRaster.scale(
(PROJECTED_TILE_DIMENSIONS.width - NODE_IMG_PADDING) /
iconRaster.bounds.width
);
const raster = iconRaster.rasterize();
container.current.addChild(raster);
container.current.pivot = iconRaster.bounds.bottomCenter;
container.current.position = new Point(
0,
PROJECTED_TILE_DIMENSIONS.height * 0.5
);
resolve(null);
};
iconRaster.source = icon.url;
setIsLoaded(true);
});
},
[icons]
);
const init = useCallback(() => {
container.current.removeChildren();
container.current = new Group();
return container.current;
}, []);
return {
container: container.current,
update,
init,
isLoaded
};
};

View File

@@ -1,90 +0,0 @@
import { useCallback, useRef } from 'react';
import { Group, Shape } from 'paper';
import gsap from 'gsap';
import { TILE_SIZE, PIXEL_UNIT } from 'src/renderer/utils/constants';
import { applyProjectionMatrix } from 'src/renderer/utils/projection';
import { getColorVariant } from 'src/utils';
export const useNodeTile = () => {
const containerRef = useRef(new Group());
const tileRef = useRef<paper.Shape.Rectangle>();
const highlightRef = useRef<paper.Shape.Rectangle>();
const updateColor = useCallback((color: string) => {
if (!tileRef.current || !highlightRef.current) return;
tileRef.current.set({
fillColor: color,
strokeColor: getColorVariant(color, 'dark', { grade: 2 })
});
highlightRef.current.set({
strokeColor: getColorVariant(color, 'dark', { grade: 2 })
});
}, []);
const setActive = useCallback((isActive: boolean) => {
if (!highlightRef.current) return;
if (isActive) {
highlightRef.current.set({ visible: true });
gsap
.fromTo(
highlightRef.current,
{ dashOffset: 0 },
{
dashOffset: PIXEL_UNIT * 12,
duration: 0.25,
ease: 'none'
}
)
.repeat(-1);
} else {
highlightRef.current.set({ visible: false });
gsap.killTweensOf(highlightRef.current);
}
}, []);
const init = useCallback(() => {
containerRef.current.removeChildren();
containerRef.current.set({ pivot: [0, 0] });
tileRef.current = new Shape.Rectangle({
strokeCap: 'round',
size: [TILE_SIZE * 1.2, TILE_SIZE * 1.2],
radius: PIXEL_UNIT * 8,
strokeWidth: 1,
strokeColor: 'black',
pivot: [0, 0],
position: [0, 0],
dashArray: null
});
highlightRef.current = tileRef.current.clone().set({
radius: PIXEL_UNIT * 12,
strokeWidth: PIXEL_UNIT * 3,
pivot: [0, 0],
dashArray: [PIXEL_UNIT * 6, PIXEL_UNIT * 6],
scaling: 1.2,
visible: false
});
containerRef.current.addChild(highlightRef.current);
containerRef.current.addChild(tileRef.current);
applyProjectionMatrix(containerRef.current);
tileRef.current.position.set(0, 0);
highlightRef.current.position.set(0, 0);
tileRef.current.applyMatrix = true;
highlightRef.current.applyMatrix = true;
return containerRef.current;
}, []);
return {
init,
updateColor,
setActive
};
};

View File

@@ -1,10 +0,0 @@
import { useRef } from 'react';
import { Group } from 'paper';
export const useConnectorManager = () => {
const containerRef = useRef(new Group());
return {
container: containerRef.current
};
};

View File

@@ -1,10 +0,0 @@
import { useRef } from 'react';
import { Group } from 'paper';
export const useGroupManager = () => {
const containerRef = useRef(new Group());
return {
container: containerRef.current
};
};

View File

@@ -1,10 +0,0 @@
import { useRef } from 'react';
import { Group } from 'paper';
export const useNodeManager = () => {
const container = useRef(new Group());
return {
container: container.current
};
};

View File

@@ -1,67 +0,0 @@
import { useCallback, useRef } from 'react';
import Paper, { Group } from 'paper';
import { Coords } from 'src/types';
import { useUiStateStore } from 'src/stores/useUiStateStore';
import { CoordsUtils } from 'src/utils';
import { useNodeManager } from './useNodeManager';
import { useGroupManager } from './useGroupManager';
import { useConnectorManager } from './useConnectorManager';
export const useRenderer = () => {
const container = useRef(new Group());
const innerContainer = useRef(new Group());
// TODO: Store layers in a giant ref object called layers? layers = { lasso: new Group(), grid: new Group() etc }
const lassoContainer = useRef(new Group());
const nodeManager = useNodeManager();
const connectorManager = useConnectorManager();
const groupManager = useGroupManager();
const uiStateActions = useUiStateStore((state) => {
return state.actions;
});
const { setScroll } = uiStateActions;
const zoomTo = useCallback((zoom: number) => {
Paper.project.activeLayer.view.zoom = zoom;
}, []);
const init = useCallback(() => {
// TODO: Grid and Cursor should be initialised in their JSX components (create if they don't exist)
// to be inline with other initialisation patterns
// innerContainer.current.addChild(gridContainer);
innerContainer.current.addChild(groupManager.container);
innerContainer.current.addChild(lassoContainer.current);
innerContainer.current.addChild(connectorManager.container);
innerContainer.current.addChild(nodeManager.container);
container.current.addChild(innerContainer.current);
container.current.set({ position: [0, 0] });
Paper.project.activeLayer.addChild(container.current);
setScroll({
position: CoordsUtils.zero(),
offset: CoordsUtils.zero()
});
}, [setScroll, nodeManager.container, groupManager.container]);
const scrollTo = useCallback((to: Coords) => {
const { center: viewCenter } = Paper.project.view.bounds;
const newPosition = {
x: to.x + viewCenter.x,
y: to.y + viewCenter.y
};
container.current.position.set(newPosition);
}, []);
return {
init,
container,
zoomTo,
scrollTo,
nodeManager,
groupManager,
lassoContainer,
connectorManager
};
};

View File

@@ -1,9 +0,0 @@
import { Size } from 'src/types';
export const TILE_SIZE = 100;
export const PROJECTED_TILE_DIMENSIONS: Size = {
width: TILE_SIZE * 1.415,
height: TILE_SIZE * 0.819
};
export const PIXEL_UNIT = TILE_SIZE * 0.02;
export const SCALING_CONST = 0.9425;

View File

@@ -1,232 +0,0 @@
import Paper from 'paper';
import { PROJECTED_TILE_DIMENSIONS } from 'src/renderer/utils/constants';
import { Coords } from 'src/types';
import { clamp, CoordsUtils } from 'src/utils';
import { SortedSceneItems, Node } from 'src/stores/useSceneStore';
import { Scroll } from 'src/stores/useUiStateStore';
const halfW = PROJECTED_TILE_DIMENSIONS.width * 0.5;
const halfH = PROJECTED_TILE_DIMENSIONS.height * 0.5;
interface GetTileFromMouse {
mousePosition: Coords;
scroll: Scroll;
gridSize: Coords;
}
export const getTileFromMouse = ({
mousePosition,
scroll,
gridSize
}: GetTileFromMouse) => {
const canvasPosition = {
x: mousePosition.x - (scroll.position.x + Paper.view.bounds.center.x),
y:
mousePosition.y - (scroll.position.y + Paper.view.bounds.center.y) + halfH
};
const row = Math.floor(
((mousePosition.x - scroll.position.x) / halfW + canvasPosition.y / halfH) /
2
);
const col = Math.floor(
(canvasPosition.y / halfH - canvasPosition.x / halfW) / 2
);
const halfRowNum = Math.floor(gridSize.x * 0.5);
const halfColNum = Math.floor(gridSize.y * 0.5);
return {
x: clamp(row, -halfRowNum, halfRowNum),
y: clamp(col, -halfColNum, halfColNum)
};
};
export const getTilePosition = ({ x, y }: Coords) => {
return { x: x * halfW - y * halfW, y: x * halfH + y * halfH };
};
export const getTileBounds = (coords: Coords) => {
const position = getTilePosition(coords);
return {
left: {
x: position.x - PROJECTED_TILE_DIMENSIONS.width * 0.5,
y: position.y
},
right: {
x: position.x + PROJECTED_TILE_DIMENSIONS.width * 0.5,
y: position.y
},
top: {
x: position.x,
y: position.y - PROJECTED_TILE_DIMENSIONS.height * 0.5
},
bottom: {
x: position.x,
y: position.y + PROJECTED_TILE_DIMENSIONS.height * 0.5
},
center: { x: position.x, y: position.y }
};
};
interface GetItemsByTile {
tile: Coords;
sortedSceneItems: SortedSceneItems;
}
// TODO: Acheive better performance with more granular functions e.g. getNodesByTile, or even getFirstNodeByTile
export const getItemsByTile = ({
tile,
sortedSceneItems
}: GetItemsByTile): { nodes: Node[] } => {
const nodes = sortedSceneItems.nodes.filter((node) => {
return CoordsUtils.isEqual(node.position, tile);
});
return { nodes };
};
interface GetItemsByTileV2 {
tile: Coords;
sceneItems: Node[];
}
export const getItemsByTileV2 = ({ tile, sceneItems }: GetItemsByTileV2) => {
return sceneItems.filter((item) => {
return CoordsUtils.isEqual(item.position, tile);
});
};
interface CanvasCoordsToScreenCoords {
position: Coords;
scrollPosition: Coords;
zoom: number;
}
export const canvasCoordsToScreenCoords = ({
position,
scrollPosition,
zoom
}: CanvasCoordsToScreenCoords) => {
const { width: viewW, height: viewH } = Paper.view.bounds;
const { offsetLeft: offsetX, offsetTop: offsetY } =
Paper.project.view.element;
const container = Paper.project.activeLayer.children[0];
const globalItemsGroupPosition = container.globalToLocal([0, 0]);
const onScreenPosition = {
x:
(position.x +
scrollPosition.x +
globalItemsGroupPosition.x +
container.position.x +
viewW * 0.5) *
zoom +
offsetX,
y:
(position.y +
scrollPosition.y +
globalItemsGroupPosition.y +
container.position.y +
viewH * 0.5) *
zoom +
offsetY
};
return onScreenPosition;
};
type GetTileScreenPosition = CanvasCoordsToScreenCoords & {
origin?: 'center' | 'top' | 'bottom' | 'left' | 'right';
};
export const getTileScreenPosition = ({
position,
scrollPosition,
zoom,
origin = 'center'
}: GetTileScreenPosition) => {
const tilePosition = getTileBounds(position)[origin];
const onScreenPosition = canvasCoordsToScreenCoords({
position: tilePosition,
scrollPosition,
zoom
});
return onScreenPosition;
};
export const sortByPosition = (tiles: Coords[]) => {
const xSorted = [...tiles];
const ySorted = [...tiles];
xSorted.sort((a, b) => {
return a.x - b.x;
});
ySorted.sort((a, b) => {
return a.y - b.y;
});
const highest = {
byX: xSorted[xSorted.length - 1],
byY: ySorted[ySorted.length - 1]
};
const lowest = { byX: xSorted[0], byY: ySorted[0] };
const lowX = lowest.byX.x;
const highX = highest.byX.x;
const lowY = lowest.byY.y;
const highY = highest.byY.y;
return {
byX: xSorted,
byY: ySorted,
highest,
lowest,
lowX,
lowY,
highX,
highY
};
};
// Returns a complete set of tiles that form a grid area (takes in any number of tiles to use points to encapsulate)
export const getGridSubset = (tiles: Coords[]) => {
const { lowX, lowY, highX, highY } = sortByPosition(tiles);
const subset = [];
for (let x = lowX; x < highX + 1; x += 1) {
for (let y = lowY; y < highY + 1; y += 1) {
subset.push({ x, y });
}
}
return subset;
};
export const isWithinBounds = (tile: Coords, bounds: Coords[]) => {
const { lowX, lowY, highX, highY } = sortByPosition(bounds);
return tile.x >= lowX && tile.x <= highX && tile.y >= lowY && tile.y <= highY;
};
// Returns the four corners of a grid that encapsulates all tiles
// passed in (at least 1 tile needed)
export const getBoundingBox = (
tiles: Coords[],
offset: Coords = CoordsUtils.zero()
) => {
if (tiles.length === 0) {
return null;
}
const { lowX, lowY, highX, highY } = sortByPosition(tiles);
return [
{ x: lowX - offset.x, y: lowY - offset.y },
{ x: highX + offset.x, y: lowY - offset.y },
{ x: highX + offset.x, y: highY + offset.y },
{ x: lowX - offset.x, y: highY + offset.y }
];
};

View File

@@ -1,31 +0,0 @@
import { Matrix, Point } from 'paper';
export const getProjectionMatrix = (x: number, y: number) => {
return new Matrix([
Math.sqrt(2) / 2,
Math.sqrt(6) / 6,
-(Math.sqrt(2) / 2),
Math.sqrt(6) / 6,
x - (Math.sqrt(2) / 2) * (x - y),
y - (Math.sqrt(6) / 6) * (x + y - 2)
]);
};
export const applyProjectionMatrix = (
item: paper.Item,
pivot?: paper.Point,
rotation?: number
) => {
const matrix = getProjectionMatrix(0, 0);
matrix.rotate(rotation ?? 0, new Point(0, 0));
item.pivot = pivot ?? new Point(0, 0);
item.matrix = matrix;
return matrix;
};
export const getCSSMatrix = (
translate: { x: number; y: number } = { x: 0, y: 0 }
) => {
return `translate(${translate.x}px, ${translate.y}px) matrix(0.707, 0.409, -0.707, 0.409, 0, -0.816)`;
};

View File

@@ -2,63 +2,8 @@ import { useCallback } from 'react';
import { create } from 'zustand';
import { v4 as uuid } from 'uuid';
import { produce } from 'immer';
import { NODE_DEFAULTS } from 'src/utils/config';
import { IconInput, SceneItemTypeEnum, Coords } from 'src/types';
// TODO: Move all types into a types file for easier access and less mental load over where to look
export interface Node {
type: SceneItemTypeEnum.NODE;
id: string;
iconId: string;
color: string;
label: string;
labelHeight: number;
position: Coords;
isSelected: boolean;
labelElement?: React.ReactNode;
}
export interface Connector {
type: SceneItemTypeEnum.CONNECTOR;
id: string;
color: string;
from: string;
to: string;
}
export interface Group {
type: SceneItemTypeEnum.GROUP;
id: string;
nodeIds: string[];
}
export type Icon = IconInput;
export interface SceneItem {
id: string;
type: SceneItemTypeEnum;
}
// TODO: Is this needed, or do we just expost a getNodesFromTile() function?
export interface SortedSceneItems {
// TODO: Decide on whether to make a Map instead of an array for easier lookup
nodes: Node[];
groups: Group[];
connectors: Connector[];
}
// TODO: This typing is super confusing to work with
export type Scene = SortedSceneItems & {
icons: IconInput[];
};
export interface SceneActions {
set: (scene: Scene) => void;
setItems: (elements: SortedSceneItems) => void;
updateNode: (id: string, updates: Partial<Node>) => void;
createNode: (position: Coords) => void;
}
import { NODE_DEFAULTS } from 'src/config';
import { Scene, SceneActions, Node, SceneItemTypeEnum } from 'src/types';
export type UseSceneStore = Scene & {
actions: SceneActions;
@@ -75,7 +20,7 @@ export const useSceneStore = create<UseSceneStore>((set, get) => {
set: (scene) => {
set(scene);
},
setItems: (items: SortedSceneItems) => {
setItems: (items) => {
set({ nodes: items.nodes });
},
updateNode: (id, updates) => {

View File

@@ -1,109 +1,12 @@
import { create } from 'zustand';
import { clamp, roundToOneDecimalPlace, CoordsUtils } from 'src/utils';
import { Coords } from 'src/types';
import { SceneItem, Node } from 'src/stores/useSceneStore';
import { UiState, UiStateActions } from 'src/types';
// TODO: Move into the defaults file
const ZOOM_INCREMENT = 0.2;
export const MIN_ZOOM = 0.2;
export const MAX_ZOOM = 1;
export enum SidebarTypeEnum {
SINGLE_NODE = 'SINGLE_NODE',
PROJECT_SETTINGS = 'PROJECT_SETTINGS'
}
export type ItemControls =
| {
type: SidebarTypeEnum.SINGLE_NODE;
nodeId: string;
}
| {
type: SidebarTypeEnum.PROJECT_SETTINGS;
}
| null;
export interface Mouse {
position: {
screen: Coords;
tile: Coords;
};
mousedown: {
screen: Coords;
tile: Coords;
} | null;
delta: {
screen: Coords;
tile: Coords;
} | null;
}
// TODO: Extract modes into own file for simplicity
export interface CursorMode {
type: 'CURSOR';
showCursor: boolean;
mousedown: {
items: { nodes: Node[] };
tile: Coords;
} | null;
}
export interface PanMode {
type: 'PAN';
showCursor: boolean;
}
export interface LassoMode {
type: 'LASSO'; // TODO: Put these into an enum
showCursor: boolean;
selection: {
startTile: Coords;
endTile: Coords;
items: Node[];
};
isDragging: boolean;
}
export interface DragItemsMode {
type: 'DRAG_ITEMS';
showCursor: boolean;
items: { nodes: Node[] };
}
export type Mode = CursorMode | PanMode | DragItemsMode | LassoMode;
export type ContextMenu =
| SceneItem
| {
type: 'EMPTY_TILE';
position: Coords;
}
| null;
export interface Scroll {
position: Coords;
offset: Coords;
}
export interface UiState {
mode: Mode;
itemControls: ItemControls;
contextMenu: ContextMenu;
zoom: number;
scroll: Scroll;
mouse: Mouse;
}
export interface UiStateActions {
setMode: (mode: Mode) => void;
incrementZoom: () => void;
decrementZoom: () => void;
setScroll: (scroll: Scroll) => void;
setSidebar: (itemControls: ItemControls) => void;
setContextMenu: (contextMenu: ContextMenu) => void;
setMouse: (mouse: Mouse) => void;
}
export type UseUiStateStore = UiState & {
actions: UiStateActions;
};

View File

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

View File

@@ -8,7 +8,7 @@ import {
export type IconInput = z.infer<typeof iconInput>;
export type NodeInput = z.infer<typeof nodeInput> & {
labelElement?: React.ReactNode;
labelComponent?: React.ReactNode;
};
export type ConnectorInput = z.infer<typeof connectorInput>;
export type GroupInput = z.infer<typeof groupInput>;

View File

@@ -4,15 +4,15 @@ import {
Scroll,
ContextMenu,
ItemControls,
Mouse
} from 'src/stores/useUiStateStore';
import { SortedSceneItems } from 'src/stores/useSceneStore';
Mouse,
Scene
} from 'src/types';
export interface State {
mode: Mode;
mouse: Mouse;
scroll: Scroll;
scene: SortedSceneItems;
scene: Scene;
contextMenu: ContextMenu;
itemControls: ItemControls;
}

View File

@@ -1,5 +1,65 @@
import { Coords } from './common';
import { IconInput } from './inputs';
export enum TileOriginEnum {
CENTER = 'CENTER',
TOP = 'TOP',
BOTTOM = 'BOTTOM',
LEFT = 'LEFT',
RIGHT = 'RIGHT'
}
export enum SceneItemTypeEnum {
NODE = 'NODE',
CONNECTOR = 'CONNECTOR',
GROUP = 'GROUP'
}
export interface Node {
id: string;
type: SceneItemTypeEnum.NODE;
iconId: string;
color: string;
label: string;
labelHeight: number;
position: Coords;
isSelected: boolean;
labelComponent?: React.ReactNode;
}
export interface Connector {
type: SceneItemTypeEnum.CONNECTOR;
id: string;
color: string;
from: string;
to: string;
}
export interface Group {
type: SceneItemTypeEnum.GROUP;
id: string;
nodeIds: string[];
color: string;
}
export type SceneItem = Node | Connector | Group;
export type Icon = IconInput;
export type Scene = {
nodes: Node[];
connectors: Connector[];
groups: Group[];
icons: IconInput[];
};
export interface SceneActions {
set: (scene: Scene) => void;
setItems: (elements: {
nodes: Node[];
connectors: Connector[];
groups: Group[];
}) => void;
updateNode: (id: string, updates: Partial<Node>) => void;
createNode: (position: Coords) => void;
}

99
src/types/ui.ts Normal file
View File

@@ -0,0 +1,99 @@
import { Coords } from './common';
import { SceneItem } from './scene';
export enum SidebarTypeEnum {
SINGLE_NODE = 'SINGLE_NODE',
PROJECT_SETTINGS = 'PROJECT_SETTINGS'
}
export type ItemControls =
| {
type: SidebarTypeEnum.SINGLE_NODE;
nodeId: string;
}
| {
type: SidebarTypeEnum.PROJECT_SETTINGS;
}
| null;
export interface Mouse {
position: {
screen: Coords;
tile: Coords;
};
mousedown: {
screen: Coords;
tile: Coords;
} | null;
delta: {
screen: Coords;
tile: Coords;
} | null;
}
// Begin mode types
export interface CursorMode {
type: 'CURSOR';
showCursor: boolean;
mousedown: {
items: SceneItem[];
tile: Coords;
} | null;
}
export interface PanMode {
type: 'PAN';
showCursor: boolean;
}
export interface LassoMode {
type: 'LASSO'; // TODO: Put these into an enum
showCursor: boolean;
selection: {
startTile: Coords;
endTile: Coords;
items: SceneItem[];
};
isDragging: boolean;
}
export interface DragItemsMode {
type: 'DRAG_ITEMS';
showCursor: boolean;
items: SceneItem[];
}
export type Mode = CursorMode | PanMode | DragItemsMode | LassoMode;
// End mode types
export type ContextMenu =
| SceneItem
| {
type: 'EMPTY_TILE';
position: Coords;
}
| null;
export interface Scroll {
position: Coords;
offset: Coords;
}
export interface UiState {
mode: Mode;
itemControls: ItemControls;
contextMenu: ContextMenu;
zoom: number;
scroll: Scroll;
mouse: Mouse;
}
export interface UiStateActions {
setMode: (mode: Mode) => void;
incrementZoom: () => void;
decrementZoom: () => void;
setScroll: (scroll: Scroll) => void;
setSidebar: (itemControls: ItemControls) => void;
setContextMenu: (contextMenu: ContextMenu) => void;
setMouse: (mouse: Mouse) => void;
}

View File

@@ -1,118 +1,19 @@
import gsap from 'gsap';
import {
Coords,
SceneInput,
NodeInput,
ConnectorInput,
GroupInput,
SceneItemTypeEnum
} from 'src/types';
import { customVars } from 'src/styles/theme';
import chroma from 'chroma-js';
import { PROJECTED_TILE_DIMENSIONS } from 'src/renderer/utils/constants';
import { Scene, Node, Connector, Group } from 'src/stores/useSceneStore';
import { NODE_DEFAULTS } from 'src/utils/config';
import { CoordsUtils } from 'src/utils';
import { SceneInput, Scene } from 'src/types';
export const clamp = (num: number, min: number, max: number) => {
// eslint-disable-next-line no-nested-ternary
return num <= min ? min : num >= max ? max : num;
};
export const nonZeroCoords = (coords: Coords) => {
// For some reason, gsap doesn't like to tween x and y both to 0, so we force 0 to be just above 0.
return {
x: coords.x === 0 ? 0.000001 : coords.x,
y: coords.y === 0 ? 0.000001 : coords.y
};
};
export const getRandom = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min) + min);
};
export const tweenPosition = (
item: paper.Item,
{ x, y, duration }: { x: number; y: number; duration: number }
) => {
// paperjs doesn't like it when you try to tween the position of an item directly,
// so we have to use a proxy object
const currPosition = {
x: item.position.x,
y: item.position.y
};
gsap.to(currPosition, {
duration,
overwrite: 'auto',
x,
y,
onUpdate: () => {
item.set({ position: currPosition });
}
});
};
export const roundToOneDecimalPlace = (num: number) => {
return Math.round(num * 10) / 10;
};
export const nodeInputToNode = (nodeInput: NodeInput): Node => {
return {
type: SceneItemTypeEnum.NODE,
id: nodeInput.id,
label: nodeInput.label ?? NODE_DEFAULTS.label,
labelElement: nodeInput.labelElement,
labelHeight: nodeInput.labelHeight ?? NODE_DEFAULTS.labelHeight,
color: nodeInput.color ?? NODE_DEFAULTS.color,
iconId: nodeInput.iconId,
position: nodeInput.position,
isSelected: false
};
};
export const groupInputToGroup = (groupInput: GroupInput): Group => {
return {
type: SceneItemTypeEnum.GROUP,
id: groupInput.id,
nodeIds: groupInput.nodeIds
};
};
export const connectorInputToConnector = (
connectorInput: ConnectorInput
): Connector => {
return {
type: SceneItemTypeEnum.CONNECTOR,
id: connectorInput.id,
color: connectorInput.color ?? customVars.diagramPalette.blue,
from: connectorInput.from,
to: connectorInput.to
};
};
export const sceneInputtoScene = (sceneInput: SceneInput): Scene => {
const nodes = sceneInput.nodes.map((nodeInput) => {
return nodeInputToNode(nodeInput);
});
const groups = sceneInput.groups.map((groupInput) => {
return groupInputToGroup(groupInput);
});
const connectors = sceneInput.connectors.map((connectorInput) => {
return connectorInputToConnector(connectorInput);
});
return {
...sceneInput,
nodes,
groups,
connectors,
icons: sceneInput.icons
} as Scene;
};
export const sceneToSceneInput = (scene: Scene): SceneInput => {
const nodes: SceneInput['nodes'] = scene.nodes.map((node) => {
return {
@@ -158,66 +59,3 @@ export const getColorVariant = (
return chroma(color).alpha(alpha).css();
}
};
export const screenToIso = ({ x, y }: { x: number; y: number }) => {
const editorWidth = window.innerWidth;
const editorHeight = window.innerHeight;
const halfW = PROJECTED_TILE_DIMENSIONS.width / 2;
const halfH = PROJECTED_TILE_DIMENSIONS.height / 2;
// The origin is the center of the project.
const projectPosition = {
x: x - editorWidth * 0.5,
y: y - editorHeight * 0.5
};
const tile = {
x: Math.floor(
(projectPosition.x + halfW) / PROJECTED_TILE_DIMENSIONS.width -
projectPosition.y / PROJECTED_TILE_DIMENSIONS.height
),
y: -Math.floor(
(projectPosition.y + halfH) / PROJECTED_TILE_DIMENSIONS.height +
projectPosition.x / PROJECTED_TILE_DIMENSIONS.width
)
};
return tile;
};
export enum OriginEnum {
CENTER = 'CENTER',
TOP = 'TOP',
BOTTOM = 'BOTTOM',
LEFT = 'LEFT',
RIGHT = 'RIGHT'
}
export const getTilePosition = (
{ x, y }: { x: number; y: number },
origin: OriginEnum = OriginEnum.CENTER
) => {
const editorWidth = window.innerWidth;
const editorHeight = window.innerHeight;
const halfW = PROJECTED_TILE_DIMENSIONS.width / 2;
const halfH = PROJECTED_TILE_DIMENSIONS.height / 2;
const position: Coords = {
x: editorWidth * 0.5 + (halfW * x - halfW * y),
y: editorHeight * 0.5 - (halfH * x + halfH * y) + halfH
};
switch (origin) {
case OriginEnum.TOP:
return CoordsUtils.add(position, { x: 0, y: -halfH });
case OriginEnum.BOTTOM:
return CoordsUtils.add(position, { x: 0, y: halfH });
case OriginEnum.LEFT:
return CoordsUtils.add(position, { x: -halfW, y: 0 });
case OriginEnum.RIGHT:
return CoordsUtils.add(position, { x: halfW, y: 0 });
case OriginEnum.CENTER:
default:
return position;
}
};

View File

@@ -1,3 +1,5 @@
export * from './CoordsUtils';
export * from './common';
export * from './config';
export * from './inputs';
export * from './pathfinder';
export * from './renderer';

70
src/utils/inputs.ts Normal file
View File

@@ -0,0 +1,70 @@
import {
SceneInput,
NodeInput,
ConnectorInput,
GroupInput,
SceneItemTypeEnum,
Scene,
Node,
Connector,
Group
} from 'src/types';
import { NODE_DEFAULTS, DEFAULT_COLOR } from 'src/config';
import { customVars } from 'src/styles/theme';
export const nodeInputToNode = (nodeInput: NodeInput): Node => {
return {
type: SceneItemTypeEnum.NODE,
id: nodeInput.id,
label: nodeInput.label ?? NODE_DEFAULTS.label,
labelComponent: nodeInput.labelComponent,
labelHeight: nodeInput.labelHeight ?? NODE_DEFAULTS.labelHeight,
color: nodeInput.color ?? NODE_DEFAULTS.color,
iconId: nodeInput.iconId,
position: nodeInput.position,
isSelected: false
};
};
export const groupInputToGroup = (groupInput: GroupInput): Group => {
return {
type: SceneItemTypeEnum.GROUP,
id: groupInput.id,
nodeIds: groupInput.nodeIds,
color: groupInput.color ?? DEFAULT_COLOR
};
};
export const connectorInputToConnector = (
connectorInput: ConnectorInput
): Connector => {
return {
type: SceneItemTypeEnum.CONNECTOR,
id: connectorInput.id,
color: connectorInput.color ?? DEFAULT_COLOR,
from: connectorInput.from,
to: connectorInput.to
};
};
export const sceneInputtoScene = (sceneInput: SceneInput): Scene => {
const nodes = sceneInput.nodes.map((nodeInput) => {
return nodeInputToNode(nodeInput);
});
const groups = sceneInput.groups.map((groupInput) => {
return groupInputToGroup(groupInput);
});
const connectors = sceneInput.connectors.map((connectorInput) => {
return connectorInputToConnector(connectorInput);
});
return {
...sceneInput,
nodes,
groups,
connectors,
icons: sceneInput.icons
} as Scene;
};

View File

@@ -1,10 +1,10 @@
import PF from 'pathfinding';
import { Coords } from 'src/types';
import { Size, Coords } from 'src/types';
// TODO1: This file is a mess, refactor it
// TODO: Have one single place for utils
export const pathfinder = (gridSize: Coords) => {
const grid = new PF.Grid(gridSize.x, gridSize.y);
export const pathfinder = (gridSize: Size) => {
const grid = new PF.Grid(gridSize.width, gridSize.height);
const finder = new PF.AStarFinder({
heuristic: PF.Heuristic.manhattan,
diagonalMovement: PF.DiagonalMovement.Always
@@ -12,15 +12,15 @@ export const pathfinder = (gridSize: Coords) => {
const convertToGridXY = ({ x, y }: Coords): Coords => {
return {
x: x + Math.floor(gridSize.x * 0.5),
y: y + Math.floor(gridSize.y * 0.5)
x: x + Math.floor(gridSize.width * 0.5),
y: y + Math.floor(gridSize.height * 0.5)
};
};
const convertToSceneXY = ({ x, y }: Coords): Coords => {
return {
x: x - Math.floor(gridSize.x * 0.5),
y: y - Math.floor(gridSize.y * 0.5)
x: x - Math.floor(gridSize.width * 0.5),
y: y - Math.floor(gridSize.height * 0.5)
};
};

189
src/utils/renderer.ts Normal file
View File

@@ -0,0 +1,189 @@
import { TILE_PROJECTION_MULTIPLIERS, UNPROJECTED_TILE_SIZE } from 'src/config';
import { Coords, TileOriginEnum, Node, Size, Scroll } from 'src/types';
import { CoordsUtils } from 'src/utils';
interface GetProjectedTileSize {
zoom: number;
}
// Gets the size of a tile at a given zoom level
export const getProjectedTileSize = ({ zoom }: GetProjectedTileSize): Size => {
return {
width: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.width * zoom,
height: UNPROJECTED_TILE_SIZE * TILE_PROJECTION_MULTIPLIERS.height * zoom
};
};
interface ScreenToIso {
mouse: Coords;
zoom: number;
scroll: Scroll;
}
// converts a mouse position to a tile position
export const screenToIso = ({ mouse, zoom, scroll }: ScreenToIso) => {
const editorWidth = window.innerWidth;
const editorHeight = window.innerHeight;
const projectedTileSize = getProjectedTileSize({ zoom });
const halfW = projectedTileSize.width / 2;
const halfH = projectedTileSize.height / 2;
// The origin is the center of the project.
const projectPosition = {
x: mouse.x - scroll.position.x - editorWidth * 0.5,
y: mouse.y - scroll.position.y - editorHeight * 0.5
};
const tile = {
x: Math.floor(
(projectPosition.x + halfW) / projectedTileSize.width -
projectPosition.y / projectedTileSize.height
),
y: -Math.floor(
(projectPosition.y + halfH) / projectedTileSize.height +
projectPosition.x / projectedTileSize.width
)
};
return tile;
};
interface GetTilePosition {
tile: Coords;
scroll: Scroll;
zoom: number;
origin?: TileOriginEnum;
}
export const getTilePosition = ({
tile,
scroll,
zoom,
origin = TileOriginEnum.CENTER
}: GetTilePosition) => {
// TODO: Refactor editorWidth to not use window width
const editorWidth = window.innerWidth;
const editorHeight = window.innerHeight;
const projectedTileSize = getProjectedTileSize({ zoom });
const halfW = projectedTileSize.width / 2;
const halfH = projectedTileSize.height / 2;
const position: Coords = {
x:
editorWidth * 0.5 + (halfW * tile.x - halfW * tile.y) + scroll.position.x,
y:
editorHeight * 0.5 - (halfH * tile.x + halfH * tile.y) + scroll.position.y
};
switch (origin) {
case TileOriginEnum.TOP:
return CoordsUtils.add(position, { x: 0, y: -halfH });
case TileOriginEnum.BOTTOM:
return CoordsUtils.add(position, { x: 0, y: halfH });
case TileOriginEnum.LEFT:
return CoordsUtils.add(position, { x: -halfW, y: 0 });
case TileOriginEnum.RIGHT:
return CoordsUtils.add(position, { x: halfW, y: 0 });
case TileOriginEnum.CENTER:
default:
return position;
}
};
export const sortByPosition = (tiles: Coords[]) => {
const xSorted = [...tiles];
const ySorted = [...tiles];
xSorted.sort((a, b) => {
return a.x - b.x;
});
ySorted.sort((a, b) => {
return a.y - b.y;
});
const highest = {
byX: xSorted[xSorted.length - 1],
byY: ySorted[ySorted.length - 1]
};
const lowest = { byX: xSorted[0], byY: ySorted[0] };
const lowX = lowest.byX.x;
const highX = highest.byX.x;
const lowY = lowest.byY.y;
const highY = highest.byY.y;
return {
byX: xSorted,
byY: ySorted,
highest,
lowest,
lowX,
lowY,
highX,
highY
};
};
// Returns a complete set of tiles that form a grid area (takes in any number of tiles to use points to encapsulate)
export const getGridSubset = (tiles: Coords[]) => {
const { lowX, lowY, highX, highY } = sortByPosition(tiles);
const subset = [];
for (let x = lowX; x < highX + 1; x += 1) {
for (let y = lowY; y < highY + 1; y += 1) {
subset.push({ x, y });
}
}
return subset;
};
export const isWithinBounds = (tile: Coords, bounds: Coords[]) => {
const { lowX, lowY, highX, highY } = sortByPosition(bounds);
return tile.x >= lowX && tile.x <= highX && tile.y >= lowY && tile.y <= highY;
};
// Returns the four corners of a grid that encapsulates all tiles
// passed in (at least 1 tile needed)
export const getBoundingBox = (
tiles: Coords[],
offset: Coords = CoordsUtils.zero()
): Coords[] => {
const { lowX, lowY, highX, highY } = sortByPosition(tiles);
return [
{ x: lowX - offset.x, y: lowY - offset.y },
{ x: highX + offset.x, y: lowY - offset.y },
{ x: highX + offset.x, y: highY + offset.y },
{ x: lowX - offset.x, y: highY + offset.y }
];
};
export const getBoundingBoxSize = (boundingBox: Coords[]): Size => {
const { lowX, lowY, highX, highY } = sortByPosition(boundingBox);
return {
width: highX - lowX + 1,
height: highY - lowY + 1
};
};
export const getIsoMatrixCSS = () => {
return `matrix(-0.707, 0.409, 0.707, 0.409, 0, -0.816)`;
};
export const getTranslateCSS = (translate: Coords = { x: 0, y: 0 }) => {
return `translate(${translate.x}px, ${translate.y}px)`;
};
interface GetNodesByTile {
tile: Coords;
nodes: Node[];
}
export const filterNodesByTile = ({ tile, nodes }: GetNodesByTile): Node[] => {
return nodes.filter((node) => {
return CoordsUtils.isEqual(node.position, tile);
});
};

View File

@@ -1,11 +1,7 @@
import { Coords } from 'src/types';
import { getGridSubset, isWithinBounds } from '../gridHelpers';
import { getGridSubset, isWithinBounds } from '../renderer';
jest.mock('paper', () => {
return {};
});
describe('Tests gridhelpers', () => {
describe('Tests renderer utils', () => {
test('getGridSubset() works correctly', () => {
const gridSubset = getGridSubset([
{ x: 5, y: 5 },

View File

@@ -30,5 +30,6 @@ export const connectorInput = z.object({
export const groupInput = z.object({
id: z.string(),
label: z.string().nullable(),
color: z.string().optional(),
nodeIds: z.array(z.string())
});

View File

@@ -17,6 +17,7 @@
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
"src/**/*.tsx",
"src/global.d.ts"
],
}

View File

@@ -28,6 +28,14 @@ module.exports = {
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
{
test: /\.svg$/,
use: [
{
loader: 'svg-url-loader'
},
],
},
],
},
resolve: {

View File

@@ -3,7 +3,7 @@ const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin');
module.exports = {
mode: "production",
entry: "./src/App.tsx",
entry: "./src/Isoflow.tsx",
output: {
path: path.resolve(__dirname, "../dist"),
filename: "index.js",
@@ -34,6 +34,14 @@ module.exports = {
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
{
test: /\.svg$/,
use: [
{
loader: 'svg-url-loader'
},
],
},
],
},
resolve: {