mirror of
https://github.com/stan-smith/FossFLOW.git
synced 2025-12-24 06:58:48 -05:00
feat: enhance connector functionality with multiple labels and line types (#128)
- Add support for three line types: single, double, and double with circle - Implement start, center, and end labels with independent height controls - Add custom RGB color picker for connectors and rectangles - Increase spacing between double lines for better visibility - Offset start/end labels by one tile to prevent node overlap - Fix label filtering to show connectors with any label type - Improve slider UI spacing to prevent interaction issues Closes #107 Closes #113 Co-authored-by: Stan <stanleylsmith@pm.me>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Connector, connectorStyleOptions } from 'src/types';
|
||||
import React, { useState } from 'react';
|
||||
import { Connector, connectorStyleOptions, connectorLineTypeOptions } from 'src/types';
|
||||
import {
|
||||
Box,
|
||||
Slider,
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
TextField,
|
||||
IconButton as MUIIconButton,
|
||||
FormControlLabel,
|
||||
Switch
|
||||
Switch,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useConnector } from 'src/hooks/useConnector';
|
||||
import { ColorSelector } from 'src/components/ColorSelector/ColorSelector';
|
||||
import { ColorPicker } from 'src/components/ColorSelector/ColorPicker';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useScene } from 'src/hooks/useScene';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
@@ -29,6 +31,7 @@ export const ConnectorControls = ({ id }: Props) => {
|
||||
});
|
||||
const connector = useConnector(id);
|
||||
const { updateConnector, deleteConnector } = useScene();
|
||||
const [useCustomColor, setUseCustomColor] = useState(!!connector?.customColor);
|
||||
|
||||
// If connector doesn't exist, return null
|
||||
if (!connector) {
|
||||
@@ -37,7 +40,7 @@ export const ConnectorControls = ({ id }: Props) => {
|
||||
|
||||
return (
|
||||
<ControlsContainer>
|
||||
<Box sx={{ position: 'relative', paddingTop: '24px' }}>
|
||||
<Box sx={{ position: 'relative', paddingTop: '24px', paddingBottom: '24px' }}>
|
||||
{/* Close button */}
|
||||
<MUIIconButton
|
||||
aria-label="Close"
|
||||
@@ -56,22 +59,115 @@ export const ConnectorControls = ({ id }: Props) => {
|
||||
</MUIIconButton>
|
||||
<Section>
|
||||
<TextField
|
||||
label="Description"
|
||||
label="Center Label (Description)"
|
||||
value={connector.description}
|
||||
onChange={(e) => {
|
||||
updateConnector(connector.id, {
|
||||
description: e.target.value as string
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
label="Start Label"
|
||||
value={connector.startLabel || ''}
|
||||
onChange={(e) => {
|
||||
updateConnector(connector.id, {
|
||||
startLabel: e.target.value as string
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
<TextField
|
||||
label="End Label"
|
||||
value={connector.endLabel || ''}
|
||||
onChange={(e) => {
|
||||
updateConnector(connector.id, {
|
||||
endLabel: e.target.value as string
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<ColorSelector
|
||||
onChange={(color) => {
|
||||
return updateConnector(connector.id, { color });
|
||||
}}
|
||||
activeColor={connector.color}
|
||||
<Section title="Label Heights">
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary">Start Label Height</Typography>
|
||||
<Slider
|
||||
marks
|
||||
step={10}
|
||||
min={-100}
|
||||
max={100}
|
||||
value={connector.startLabelHeight || 0}
|
||||
onChange={(e, value) => {
|
||||
updateConnector(connector.id, { startLabelHeight: value as number });
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary">Center Label Height</Typography>
|
||||
<Slider
|
||||
marks
|
||||
step={10}
|
||||
min={-100}
|
||||
max={100}
|
||||
value={connector.centerLabelHeight || 0}
|
||||
onChange={(e, value) => {
|
||||
updateConnector(connector.id, { centerLabelHeight: value as number });
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary">End Label Height</Typography>
|
||||
<Slider
|
||||
marks
|
||||
step={10}
|
||||
min={-100}
|
||||
max={100}
|
||||
value={connector.endLabelHeight || 0}
|
||||
onChange={(e, value) => {
|
||||
updateConnector(connector.id, { endLabelHeight: value as number });
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Section>
|
||||
<Section title="Color">
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={useCustomColor}
|
||||
onChange={(e) => {
|
||||
setUseCustomColor(e.target.checked);
|
||||
if (!e.target.checked) {
|
||||
updateConnector(connector.id, { customColor: '' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Use Custom Color"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
{useCustomColor ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<ColorPicker
|
||||
value={connector.customColor || '#000000'}
|
||||
onChange={(color) => {
|
||||
updateConnector(connector.id, { customColor: color });
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{connector.customColor || '#000000'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<ColorSelector
|
||||
onChange={(color) => {
|
||||
return updateConnector(connector.id, { color, customColor: '' });
|
||||
}}
|
||||
activeColor={connector.color}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Section title="Width">
|
||||
<Slider
|
||||
@@ -85,17 +181,37 @@ export const ConnectorControls = ({ id }: Props) => {
|
||||
}}
|
||||
/>
|
||||
</Section>
|
||||
<Section title="Style">
|
||||
<Section title="Line Style">
|
||||
<Select
|
||||
value={connector.style}
|
||||
value={connector.style || 'SOLID'}
|
||||
onChange={(e) => {
|
||||
updateConnector(connector.id, {
|
||||
style: e.target.value as Connector['style']
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
{Object.values(connectorStyleOptions).map((style) => {
|
||||
return <MenuItem value={style}>{style}</MenuItem>;
|
||||
return <MenuItem key={style} value={style}>{style}</MenuItem>;
|
||||
})}
|
||||
</Select>
|
||||
</Section>
|
||||
<Section title="Line Type">
|
||||
<Select
|
||||
value={connector.lineType || 'SINGLE'}
|
||||
onChange={(e) => {
|
||||
updateConnector(connector.id, {
|
||||
lineType: e.target.value as Connector['lineType']
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
>
|
||||
{Object.values(connectorLineTypeOptions).map((type) => {
|
||||
const displayName = type === 'SINGLE' ? 'Single Line' :
|
||||
type === 'DOUBLE' ? 'Double Line' :
|
||||
'Double Line with Circle';
|
||||
return <MenuItem key={type} value={type}>{displayName}</MenuItem>;
|
||||
})}
|
||||
</Select>
|
||||
</Section>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Box, IconButton as MUIIconButton } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { Box, IconButton as MUIIconButton, FormControlLabel, Switch, Typography } from '@mui/material';
|
||||
import { useRectangle } from 'src/hooks/useRectangle';
|
||||
import { ColorSelector } from 'src/components/ColorSelector/ColorSelector';
|
||||
import { ColorPicker } from 'src/components/ColorSelector/ColorPicker';
|
||||
import { useUiStateStore } from 'src/stores/uiStateStore';
|
||||
import { useScene } from 'src/hooks/useScene';
|
||||
import { Close as CloseIcon } from '@mui/icons-material';
|
||||
@@ -19,6 +20,7 @@ export const RectangleControls = ({ id }: Props) => {
|
||||
});
|
||||
const rectangle = useRectangle(id);
|
||||
const { updateRectangle, deleteRectangle } = useScene();
|
||||
const [useCustomColor, setUseCustomColor] = useState(!!rectangle?.customColor);
|
||||
|
||||
// If rectangle doesn't exist, return null
|
||||
if (!rectangle) {
|
||||
@@ -44,13 +46,42 @@ export const RectangleControls = ({ id }: Props) => {
|
||||
>
|
||||
<CloseIcon />
|
||||
</MUIIconButton>
|
||||
<Section>
|
||||
<ColorSelector
|
||||
onChange={(color) => {
|
||||
updateRectangle(rectangle.id, { color });
|
||||
}}
|
||||
activeColor={rectangle.color}
|
||||
<Section title="Color">
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={useCustomColor}
|
||||
onChange={(e) => {
|
||||
setUseCustomColor(e.target.checked);
|
||||
if (!e.target.checked) {
|
||||
updateRectangle(rectangle.id, { customColor: '' });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Use Custom Color"
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
{useCustomColor ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<ColorPicker
|
||||
value={rectangle.customColor || '#000000'}
|
||||
onChange={(color) => {
|
||||
updateRectangle(rectangle.id, { customColor: color });
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{rectangle.customColor || '#000000'}
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<ColorSelector
|
||||
onChange={(color) => {
|
||||
updateRectangle(rectangle.id, { color, customColor: '' });
|
||||
}}
|
||||
activeColor={rectangle.color}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
<Section>
|
||||
<Box>
|
||||
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const ConnectorLabel = ({ connector }: Props) => {
|
||||
const labelPosition = useMemo(() => {
|
||||
const centerLabelPosition = useMemo(() => {
|
||||
const tileIndex = Math.floor(connector.path.tiles.length / 2);
|
||||
const tile = connector.path.tiles[tileIndex];
|
||||
|
||||
@@ -19,28 +19,113 @@ export const ConnectorLabel = ({ connector }: Props) => {
|
||||
});
|
||||
}, [connector.path]);
|
||||
|
||||
const startLabelPosition = useMemo(() => {
|
||||
if (!connector.startLabel) return null;
|
||||
const tiles = connector.path.tiles;
|
||||
if (tiles.length < 2) return null;
|
||||
|
||||
// Use second tile position for start label to offset from node
|
||||
const tile = tiles[Math.min(1, tiles.length - 1)];
|
||||
return getTilePosition({
|
||||
tile: connectorPathTileToGlobal(tile, connector.path.rectangle.from)
|
||||
});
|
||||
}, [connector.path, connector.startLabel]);
|
||||
|
||||
const endLabelPosition = useMemo(() => {
|
||||
if (!connector.endLabel) return null;
|
||||
const tiles = connector.path.tiles;
|
||||
if (tiles.length < 2) return null;
|
||||
|
||||
// Use second-to-last tile position for end label to offset from node
|
||||
const tile = tiles[Math.max(tiles.length - 2, 0)];
|
||||
return getTilePosition({
|
||||
tile: connectorPathTileToGlobal(tile, connector.path.rectangle.from)
|
||||
});
|
||||
}, [connector.path, connector.endLabel]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ position: 'absolute', pointerEvents: 'none' }}
|
||||
style={{
|
||||
maxWidth: PROJECTED_TILE_SIZE.width,
|
||||
left: labelPosition.x,
|
||||
top: labelPosition.y
|
||||
}}
|
||||
>
|
||||
<Label
|
||||
maxWidth={150}
|
||||
labelHeight={0}
|
||||
sx={{
|
||||
py: 0.75,
|
||||
px: 1,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{connector.description}
|
||||
</Typography>
|
||||
</Label>
|
||||
</Box>
|
||||
<>
|
||||
{/* Center label (existing description) */}
|
||||
{connector.description && (
|
||||
<Box
|
||||
sx={{ position: 'absolute', pointerEvents: 'none' }}
|
||||
style={{
|
||||
maxWidth: PROJECTED_TILE_SIZE.width,
|
||||
left: centerLabelPosition.x,
|
||||
top: centerLabelPosition.y
|
||||
}}
|
||||
>
|
||||
<Label
|
||||
maxWidth={150}
|
||||
labelHeight={connector.centerLabelHeight || 0}
|
||||
sx={{
|
||||
py: 0.75,
|
||||
px: 1,
|
||||
borderRadius: 2
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary" variant="body2">
|
||||
{connector.description}
|
||||
</Typography>
|
||||
</Label>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Start label */}
|
||||
{connector.startLabel && startLabelPosition && (
|
||||
<Box
|
||||
sx={{ position: 'absolute', pointerEvents: 'none' }}
|
||||
style={{
|
||||
maxWidth: PROJECTED_TILE_SIZE.width,
|
||||
left: startLabelPosition.x,
|
||||
top: startLabelPosition.y
|
||||
}}
|
||||
>
|
||||
<Label
|
||||
maxWidth={100}
|
||||
labelHeight={connector.startLabelHeight || 0}
|
||||
sx={{
|
||||
py: 0.5,
|
||||
px: 0.75,
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'background.paper',
|
||||
opacity: 0.9
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
{connector.startLabel}
|
||||
</Typography>
|
||||
</Label>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* End label */}
|
||||
{connector.endLabel && endLabelPosition && (
|
||||
<Box
|
||||
sx={{ position: 'absolute', pointerEvents: 'none' }}
|
||||
style={{
|
||||
maxWidth: PROJECTED_TILE_SIZE.width,
|
||||
left: endLabelPosition.x,
|
||||
top: endLabelPosition.y
|
||||
}}
|
||||
>
|
||||
<Label
|
||||
maxWidth={100}
|
||||
labelHeight={connector.endLabelHeight || 0}
|
||||
sx={{
|
||||
py: 0.5,
|
||||
px: 0.75,
|
||||
borderRadius: 1,
|
||||
backgroundColor: 'background.paper',
|
||||
opacity: 0.9
|
||||
}}
|
||||
>
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
{connector.endLabel}
|
||||
</Typography>
|
||||
</Label>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ export const ConnectorLabels = ({ connectors }: Props) => {
|
||||
<>
|
||||
{connectors
|
||||
.filter((connector) => {
|
||||
return Boolean(connector.description);
|
||||
return Boolean(connector.description || connector.startLabel || connector.endLabel);
|
||||
})
|
||||
.map((connector) => {
|
||||
return <ConnectorLabel key={connector.id} connector={connector} />;
|
||||
|
||||
@@ -20,11 +20,20 @@ interface Props {
|
||||
|
||||
export const Connector = ({ connector: _connector, isSelected }: Props) => {
|
||||
const theme = useTheme();
|
||||
const color = useColor(_connector.color);
|
||||
const predefinedColor = useColor(_connector.color);
|
||||
const { currentView } = useScene();
|
||||
const connector = useConnector(_connector.id);
|
||||
|
||||
if (!connector || !color) {
|
||||
if (!connector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use custom color if provided, otherwise use predefined color
|
||||
const color = connector.customColor
|
||||
? { value: connector.customColor }
|
||||
: predefinedColor;
|
||||
|
||||
if (!color) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -39,6 +48,10 @@ export const Connector = ({ connector: _connector, isSelected }: Props) => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const connectorWidthPx = useMemo(() => {
|
||||
return (UNPROJECTED_TILE_SIZE / 100) * connector.width;
|
||||
}, [connector.width]);
|
||||
|
||||
const pathString = useMemo(() => {
|
||||
return connector.path.tiles.reduce((acc, tile) => {
|
||||
return `${acc} ${tile.x * UNPROJECTED_TILE_SIZE + drawOffset.x},${
|
||||
@@ -47,6 +60,69 @@ export const Connector = ({ connector: _connector, isSelected }: Props) => {
|
||||
}, '');
|
||||
}, [connector.path.tiles, drawOffset]);
|
||||
|
||||
// Create offset paths for double lines
|
||||
const offsetPaths = useMemo(() => {
|
||||
if (!connector.lineType || connector.lineType === 'SINGLE') return null;
|
||||
|
||||
const tiles = connector.path.tiles;
|
||||
if (tiles.length < 2) return null;
|
||||
|
||||
const offset = connectorWidthPx * 3; // Larger spacing between double lines for visibility
|
||||
const path1Points: string[] = [];
|
||||
const path2Points: string[] = [];
|
||||
|
||||
for (let i = 0; i < tiles.length; i++) {
|
||||
const curr = tiles[i];
|
||||
let dx = 0, dy = 0;
|
||||
|
||||
// Calculate perpendicular offset based on line direction
|
||||
if (i > 0 && i < tiles.length - 1) {
|
||||
const prev = tiles[i - 1];
|
||||
const next = tiles[i + 1];
|
||||
const dx1 = curr.x - prev.x;
|
||||
const dy1 = curr.y - prev.y;
|
||||
const dx2 = next.x - curr.x;
|
||||
const dy2 = next.y - curr.y;
|
||||
|
||||
// Average direction for smooth corners
|
||||
const avgDx = (dx1 + dx2) / 2;
|
||||
const avgDy = (dy1 + dy2) / 2;
|
||||
const len = Math.sqrt(avgDx * avgDx + avgDy * avgDy) || 1;
|
||||
|
||||
// Perpendicular vector
|
||||
dx = -avgDy / len;
|
||||
dy = avgDx / len;
|
||||
} else if (i === 0 && tiles.length > 1) {
|
||||
// Start point
|
||||
const next = tiles[1];
|
||||
const dirX = next.x - curr.x;
|
||||
const dirY = next.y - curr.y;
|
||||
const len = Math.sqrt(dirX * dirX + dirY * dirY) || 1;
|
||||
dx = -dirY / len;
|
||||
dy = dirX / len;
|
||||
} else if (i === tiles.length - 1 && tiles.length > 1) {
|
||||
// End point
|
||||
const prev = tiles[i - 1];
|
||||
const dirX = curr.x - prev.x;
|
||||
const dirY = curr.y - prev.y;
|
||||
const len = Math.sqrt(dirX * dirX + dirY * dirY) || 1;
|
||||
dx = -dirY / len;
|
||||
dy = dirX / len;
|
||||
}
|
||||
|
||||
const x = curr.x * UNPROJECTED_TILE_SIZE + drawOffset.x;
|
||||
const y = curr.y * UNPROJECTED_TILE_SIZE + drawOffset.y;
|
||||
|
||||
path1Points.push(`${x + dx * offset},${y + dy * offset}`);
|
||||
path2Points.push(`${x - dx * offset},${y - dy * offset}`);
|
||||
}
|
||||
|
||||
return {
|
||||
path1: path1Points.join(' '),
|
||||
path2: path2Points.join(' ')
|
||||
};
|
||||
}, [connector.path.tiles, connector.lineType, connectorWidthPx, drawOffset]);
|
||||
|
||||
const anchorPositions = useMemo(() => {
|
||||
if (!isSelected) return [];
|
||||
|
||||
@@ -77,10 +153,6 @@ export const Connector = ({ connector: _connector, isSelected }: Props) => {
|
||||
return getConnectorDirectionIcon(connector.path.tiles);
|
||||
}, [connector.path.tiles]);
|
||||
|
||||
const connectorWidthPx = useMemo(() => {
|
||||
return (UNPROJECTED_TILE_SIZE / 100) * connector.width;
|
||||
}, [connector.width]);
|
||||
|
||||
const strokeDashArray = useMemo(() => {
|
||||
switch (connector.style) {
|
||||
case 'DASHED':
|
||||
@@ -93,6 +165,8 @@ export const Connector = ({ connector: _connector, isSelected }: Props) => {
|
||||
}
|
||||
}, [connector.style, connectorWidthPx]);
|
||||
|
||||
const lineType = connector.lineType || 'SINGLE';
|
||||
|
||||
return (
|
||||
<Box style={css}>
|
||||
<Svg
|
||||
@@ -104,25 +178,118 @@ export const Connector = ({ connector: _connector, isSelected }: Props) => {
|
||||
}}
|
||||
viewboxSize={pxSize}
|
||||
>
|
||||
<polyline
|
||||
points={pathString}
|
||||
stroke={theme.palette.common.white}
|
||||
strokeWidth={connectorWidthPx * 1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeOpacity={0.7}
|
||||
strokeDasharray={strokeDashArray}
|
||||
fill="none"
|
||||
/>
|
||||
<polyline
|
||||
points={pathString}
|
||||
stroke={getColorVariant(color.value, 'dark', { grade: 1 })}
|
||||
strokeWidth={connectorWidthPx}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={strokeDashArray}
|
||||
fill="none"
|
||||
/>
|
||||
{lineType === 'SINGLE' ? (
|
||||
<>
|
||||
<polyline
|
||||
points={pathString}
|
||||
stroke={theme.palette.common.white}
|
||||
strokeWidth={connectorWidthPx * 1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeOpacity={0.7}
|
||||
strokeDasharray={strokeDashArray}
|
||||
fill="none"
|
||||
/>
|
||||
<polyline
|
||||
points={pathString}
|
||||
stroke={getColorVariant(color.value, 'dark', { grade: 1 })}
|
||||
strokeWidth={connectorWidthPx}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={strokeDashArray}
|
||||
fill="none"
|
||||
/>
|
||||
</>
|
||||
) : offsetPaths ? (
|
||||
<>
|
||||
{/* First line of double */}
|
||||
<polyline
|
||||
points={offsetPaths.path1}
|
||||
stroke={theme.palette.common.white}
|
||||
strokeWidth={connectorWidthPx * 1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeOpacity={0.7}
|
||||
strokeDasharray={strokeDashArray}
|
||||
fill="none"
|
||||
/>
|
||||
<polyline
|
||||
points={offsetPaths.path1}
|
||||
stroke={getColorVariant(color.value, 'dark', { grade: 1 })}
|
||||
strokeWidth={connectorWidthPx}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={strokeDashArray}
|
||||
fill="none"
|
||||
/>
|
||||
{/* Second line of double */}
|
||||
<polyline
|
||||
points={offsetPaths.path2}
|
||||
stroke={theme.palette.common.white}
|
||||
strokeWidth={connectorWidthPx * 1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeOpacity={0.7}
|
||||
strokeDasharray={strokeDashArray}
|
||||
fill="none"
|
||||
/>
|
||||
<polyline
|
||||
points={offsetPaths.path2}
|
||||
stroke={getColorVariant(color.value, 'dark', { grade: 1 })}
|
||||
strokeWidth={connectorWidthPx}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeDasharray={strokeDashArray}
|
||||
fill="none"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{/* Circle for port-channel representation */}
|
||||
{lineType === 'DOUBLE_WITH_CIRCLE' && connector.path.tiles.length >= 2 && (() => {
|
||||
const midIndex = Math.floor(connector.path.tiles.length / 2);
|
||||
const midTile = connector.path.tiles[midIndex];
|
||||
const x = midTile.x * UNPROJECTED_TILE_SIZE + drawOffset.x;
|
||||
const y = midTile.y * UNPROJECTED_TILE_SIZE + drawOffset.y;
|
||||
|
||||
// Calculate rotation based on line direction at middle point
|
||||
let rotation = 0;
|
||||
if (midIndex > 0 && midIndex < connector.path.tiles.length - 1) {
|
||||
const prevTile = connector.path.tiles[midIndex - 1];
|
||||
const nextTile = connector.path.tiles[midIndex + 1];
|
||||
const dx = nextTile.x - prevTile.x;
|
||||
const dy = nextTile.y - prevTile.y;
|
||||
rotation = Math.atan2(dy, dx) * (180 / Math.PI);
|
||||
}
|
||||
|
||||
// Increased size to encompass both lines with the spacing
|
||||
const circleRadiusX = connectorWidthPx * 5; // Wider to cover both lines
|
||||
const circleRadiusY = connectorWidthPx * 4; // Height to encompass both lines
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x}, ${y}) rotate(${rotation})`}>
|
||||
<ellipse
|
||||
cx={0}
|
||||
cy={0}
|
||||
rx={circleRadiusX}
|
||||
ry={circleRadiusY}
|
||||
fill="none"
|
||||
stroke={getColorVariant(color.value, 'dark', { grade: 1 })}
|
||||
strokeWidth={connectorWidthPx * 0.8}
|
||||
/>
|
||||
<ellipse
|
||||
cx={0}
|
||||
cy={0}
|
||||
rx={circleRadiusX}
|
||||
ry={circleRadiusY}
|
||||
fill="none"
|
||||
stroke={theme.palette.common.white}
|
||||
strokeWidth={connectorWidthPx * 1.2}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
|
||||
{anchorPositions.map((anchor) => {
|
||||
return (
|
||||
|
||||
@@ -6,8 +6,13 @@ import { useColor } from 'src/hooks/useColor';
|
||||
|
||||
type Props = ReturnType<typeof useScene>['rectangles'][0];
|
||||
|
||||
export const Rectangle = ({ from, to, color: colorId }: Props) => {
|
||||
const color = useColor(colorId);
|
||||
export const Rectangle = ({ from, to, color: colorId, customColor }: Props) => {
|
||||
const predefinedColor = useColor(colorId);
|
||||
|
||||
// Use custom color if provided, otherwise use predefined color
|
||||
const color = customColor
|
||||
? { value: customColor }
|
||||
: predefinedColor;
|
||||
|
||||
if (!color) {
|
||||
return null;
|
||||
|
||||
@@ -48,8 +48,15 @@ export const VIEW_ITEM_DEFAULTS: Required<Omit<ViewItem, 'id' | 'tile'>> = {
|
||||
export const CONNECTOR_DEFAULTS: Required<Omit<Connector, 'id' | 'color'>> = {
|
||||
width: 10,
|
||||
description: '',
|
||||
startLabel: '',
|
||||
endLabel: '',
|
||||
startLabelHeight: 0,
|
||||
centerLabelHeight: 0,
|
||||
endLabelHeight: 0,
|
||||
customColor: '',
|
||||
anchors: [],
|
||||
style: 'SOLID',
|
||||
lineType: 'SINGLE',
|
||||
showArrow: true
|
||||
};
|
||||
|
||||
@@ -68,7 +75,9 @@ export const TEXTBOX_FONT_WEIGHT = 'bold';
|
||||
|
||||
export const RECTANGLE_DEFAULTS: Required<
|
||||
Omit<Rectangle, 'id' | 'from' | 'to' | 'color'>
|
||||
> = {};
|
||||
> = {
|
||||
customColor: ''
|
||||
};
|
||||
|
||||
export const ZOOM_INCREMENT = 0.2;
|
||||
export const MIN_ZOOM = 0.2;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from 'zod';
|
||||
import { coords, id, constrainedStrings } from './common';
|
||||
|
||||
export const connectorStyleOptions = ['SOLID', 'DOTTED', 'DASHED'] as const;
|
||||
export const connectorLineTypeOptions = ['SINGLE', 'DOUBLE', 'DOUBLE_WITH_CIRCLE'] as const;
|
||||
|
||||
export const anchorSchema = z.object({
|
||||
id,
|
||||
@@ -17,9 +18,16 @@ export const anchorSchema = z.object({
|
||||
export const connectorSchema = z.object({
|
||||
id,
|
||||
description: constrainedStrings.description.optional(),
|
||||
startLabel: constrainedStrings.description.optional(),
|
||||
endLabel: constrainedStrings.description.optional(),
|
||||
startLabelHeight: z.number().optional(),
|
||||
centerLabelHeight: z.number().optional(),
|
||||
endLabelHeight: z.number().optional(),
|
||||
color: id.optional(),
|
||||
customColor: z.string().optional(), // For custom RGB colors
|
||||
width: z.number().optional(),
|
||||
style: z.enum(connectorStyleOptions).optional(),
|
||||
lineType: z.enum(connectorLineTypeOptions).optional(),
|
||||
showArrow: z.boolean().optional(),
|
||||
anchors: z.array(anchorSchema)
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { id, coords } from './common';
|
||||
export const rectangleSchema = z.object({
|
||||
id,
|
||||
color: id.optional(),
|
||||
customColor: z.string().optional(), // For custom RGB colors
|
||||
from: coords,
|
||||
to: coords
|
||||
});
|
||||
|
||||
@@ -13,11 +13,12 @@ import {
|
||||
anchorSchema,
|
||||
textBoxSchema,
|
||||
rectangleSchema,
|
||||
connectorStyleOptions
|
||||
connectorStyleOptions,
|
||||
connectorLineTypeOptions
|
||||
} from 'src/schemas';
|
||||
import { StoreApi } from 'zustand';
|
||||
|
||||
export { connectorStyleOptions } from 'src/schemas';
|
||||
export { connectorStyleOptions, connectorLineTypeOptions } from 'src/schemas';
|
||||
export type Model = z.infer<typeof modelSchema>;
|
||||
export type ModelItems = z.infer<typeof modelItemsSchema>;
|
||||
export type Icon = z.infer<typeof iconSchema>;
|
||||
@@ -28,6 +29,7 @@ export type Views = z.infer<typeof viewsSchema>;
|
||||
export type View = z.infer<typeof viewSchema>;
|
||||
export type ViewItem = z.infer<typeof viewItemSchema>;
|
||||
export type ConnectorStyle = keyof typeof connectorStyleOptions;
|
||||
export type ConnectorLineType = keyof typeof connectorLineTypeOptions;
|
||||
export type ConnectorAnchor = z.infer<typeof anchorSchema>;
|
||||
export type Connector = z.infer<typeof connectorSchema>;
|
||||
export type TextBox = z.infer<typeof textBoxSchema>;
|
||||
|
||||
Reference in New Issue
Block a user