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:
Stan
2025-09-06 12:55:58 +00:00
committed by GitHub
parent 2091aa0cca
commit d5e02ea303
10 changed files with 500 additions and 76 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}
</>
);
};

View File

@@ -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} />;

View File

@@ -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 (

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)
});

View File

@@ -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
});

View File

@@ -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>;