added massive overhaul of connector labels, now you can add as many as you like! as long as its below 256, you can do per line, in double connector mode, Fixes #107

This commit is contained in:
stan-smith
2025-10-05 15:22:04 +01:00
parent 3cbcadac7f
commit 2a53437ad0
9 changed files with 468 additions and 197 deletions

View File

@@ -1,5 +1,10 @@
import React, { useState } from 'react';
import { Connector, connectorStyleOptions, connectorLineTypeOptions } from 'src/types';
import React, { useState, useMemo } from 'react';
import {
Connector,
ConnectorLabel,
connectorStyleOptions,
connectorLineTypeOptions
} from 'src/types';
import {
Box,
Slider,
@@ -9,14 +14,21 @@ import {
IconButton as MUIIconButton,
FormControlLabel,
Switch,
Typography
Typography,
Button,
Paper
} 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';
import {
Close as CloseIcon,
Add as AddIcon,
Delete as DeleteIcon
} from '@mui/icons-material';
import { getConnectorLabels, generateId } from 'src/utils';
import { ControlsContainer } from '../components/ControlsContainer';
import { Section } from '../components/Section';
import { DeleteButton } from '../components/DeleteButton';
@@ -31,16 +43,91 @@ export const ConnectorControls = ({ id }: Props) => {
});
const connector = useConnector(id);
const { updateConnector, deleteConnector } = useScene();
const [useCustomColor, setUseCustomColor] = useState(!!connector?.customColor);
const [useCustomColor, setUseCustomColor] = useState(
!!connector?.customColor
);
// Get all labels (including migrated legacy labels)
const labels = useMemo(() => {
if (!connector) return [];
return getConnectorLabels(connector);
}, [connector]);
// If connector doesn't exist, return null
if (!connector) {
return null;
}
const isDoubleLineType =
connector.lineType === 'DOUBLE' ||
connector.lineType === 'DOUBLE_WITH_CIRCLE';
const handleAddLabel = () => {
if (labels.length >= 256) return;
const newLabel: ConnectorLabel = {
id: generateId(),
text: '',
position: 50,
height: 0,
line: '1'
};
// Migrate legacy labels if needed and add new label
const updatedLabels = [...labels, newLabel];
updateConnector(connector.id, {
labels: updatedLabels,
// Clear legacy fields on first new label addition
description: undefined,
startLabel: undefined,
endLabel: undefined,
startLabelHeight: undefined,
centerLabelHeight: undefined,
endLabelHeight: undefined
});
};
const handleUpdateLabel = (
labelId: string,
updates: Partial<ConnectorLabel>
) => {
const updatedLabels = labels.map((label) => {
return label.id === labelId ? { ...label, ...updates } : label;
});
updateConnector(connector.id, {
labels: updatedLabels,
// Clear legacy fields
description: undefined,
startLabel: undefined,
endLabel: undefined,
startLabelHeight: undefined,
centerLabelHeight: undefined,
endLabelHeight: undefined
});
};
const handleDeleteLabel = (labelId: string) => {
const updatedLabels = labels.filter((label) => {
return label.id !== labelId;
});
updateConnector(connector.id, {
labels: updatedLabels,
// Clear legacy fields
description: undefined,
startLabel: undefined,
endLabel: undefined,
startLabelHeight: undefined,
centerLabelHeight: undefined,
endLabelHeight: undefined
});
};
return (
<ControlsContainer>
<Box sx={{ position: 'relative', paddingTop: '24px', paddingBottom: '24px' }}>
<Box
sx={{ position: 'relative', paddingTop: '24px', paddingBottom: '24px' }}
>
{/* Close button */}
<MUIIconButton
aria-label="Close"
@@ -57,79 +144,160 @@ export const ConnectorControls = ({ id }: Props) => {
>
<CloseIcon />
</MUIIconButton>
<Section>
<TextField
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 title="Label Heights">
<Section title="Labels">
<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
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2
}}
/>
</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 });
}}
/>
>
<Typography variant="body2" color="text.secondary">
{labels.length} / 256 labels
</Typography>
<Button
startIcon={<AddIcon />}
onClick={handleAddLabel}
disabled={labels.length >= 256}
size="small"
variant="outlined"
>
Add Label
</Button>
</Box>
{labels.length === 0 && (
<Typography
variant="body2"
color="text.secondary"
sx={{ textAlign: 'center', py: 2 }}
>
No labels. Click &quot;Add Label&quot; to create one.
</Typography>
)}
{labels.map((label, index) => {
return (
<Paper key={label.id} variant="outlined" sx={{ p: 2, mb: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1
}}
>
<Typography variant="caption" color="text.secondary">
Label {index + 1}
</Typography>
<MUIIconButton
size="small"
onClick={() => {
return handleDeleteLabel(label.id);
}}
color="error"
>
<DeleteIcon fontSize="small" />
</MUIIconButton>
</Box>
<TextField
label="Text"
value={label.text}
onChange={(e) => {
return handleUpdateLabel(label.id, {
text: e.target.value
});
}}
fullWidth
sx={{ mb: 2 }}
/>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<TextField
label="Position (%)"
type="number"
value={label.position}
onChange={(e) => {
const inputValue = e.target.value;
// Allow empty input
if (inputValue === '') {
handleUpdateLabel(label.id, { position: 0 });
return;
}
const value = parseInt(inputValue, 10);
if (!Number.isNaN(value)) {
handleUpdateLabel(label.id, {
position: Math.max(0, Math.min(100, value))
});
}
}}
onBlur={(e) => {
// On blur, ensure we have a valid value
if (e.target.value === '') {
handleUpdateLabel(label.id, { position: 0 });
}
}}
inputProps={{ min: 0, max: 100 }}
sx={{ flex: 1 }}
/>
{isDoubleLineType && (
<Select
value={label.line || '1'}
onChange={(e) => {
return handleUpdateLabel(label.id, {
line: e.target.value as '1' | '2'
});
}}
sx={{ flex: 1 }}
>
<MenuItem value="1">Line 1</MenuItem>
<MenuItem value="2">Line 2</MenuItem>
</Select>
)}
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Height Offset
</Typography>
<Slider
marks
step={10}
min={-100}
max={100}
value={label.height || 0}
onChange={(e, value) => {
return handleUpdateLabel(label.id, {
height: value as number
});
}}
/>
</Box>
<Box>
<FormControlLabel
control={
<Switch
checked={label.showLine !== false}
onChange={(e) => {
return handleUpdateLabel(label.id, {
showLine: e.target.checked
});
}}
/>
}
label="Show Dotted Line"
/>
</Box>
</Paper>
);
})}
</Box>
</Section>
<Section title="Color">
@@ -163,7 +331,10 @@ export const ConnectorControls = ({ id }: Props) => {
) : (
<ColorSelector
onChange={(color) => {
return updateConnector(connector.id, { color, customColor: '' });
return updateConnector(connector.id, {
color,
customColor: ''
});
}}
activeColor={connector.color}
/>
@@ -193,7 +364,11 @@ export const ConnectorControls = ({ id }: Props) => {
sx={{ mb: 2 }}
>
{Object.values(connectorStyleOptions).map((style) => {
return <MenuItem key={style} value={style}>{style}</MenuItem>;
return (
<MenuItem key={style} value={style}>
{style}
</MenuItem>
);
})}
</Select>
</Section>
@@ -208,10 +383,17 @@ export const ConnectorControls = ({ id }: Props) => {
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>;
let displayName = 'Double Line with Circle';
if (type === 'SINGLE') {
displayName = 'Single Line';
} else if (type === 'DOUBLE') {
displayName = 'Double Line';
}
return (
<MenuItem key={type} value={type}>
{displayName}
</MenuItem>
);
})}
</Select>
</Section>

View File

@@ -10,6 +10,7 @@ export interface Props {
expandDirection?: 'CENTER' | 'BOTTOM';
children: React.ReactNode;
sx?: SxProps;
showLine?: boolean;
}
export const Label = ({
@@ -18,7 +19,8 @@ export const Label = ({
maxHeight,
expandDirection = 'CENTER',
labelHeight = 0,
sx
sx,
showLine = true
}: Props) => {
const contentRef = useRef<HTMLDivElement>();
@@ -29,7 +31,7 @@ export const Label = ({
width: maxWidth
}}
>
{labelHeight > 0 && (
{labelHeight > 0 && showLine && (
<Box
component="svg"
viewBox={`0 0 ${CONNECTOR_DOT_SIZE} ${labelHeight}`}

View File

@@ -1,131 +1,126 @@
import React, { useMemo } from 'react';
import { Box, Typography } from '@mui/material';
import { useScene } from 'src/hooks/useScene';
import { connectorPathTileToGlobal, getTilePosition } from 'src/utils';
import { PROJECTED_TILE_SIZE } from 'src/config';
import { useConnector } from 'src/hooks/useConnector';
import {
connectorPathTileToGlobal,
getTilePosition,
getConnectorLabels,
getLabelTileIndex
} from 'src/utils';
import { PROJECTED_TILE_SIZE, UNPROJECTED_TILE_SIZE } from 'src/config';
import { Label } from 'src/components/Label/Label';
import { ConnectorLabel as ConnectorLabelType } from 'src/types';
interface Props {
connector: ReturnType<typeof useScene>['connectors'][0];
}
export const ConnectorLabel = ({ connector }: Props) => {
const centerLabelPosition = useMemo(() => {
const tileIndex = Math.floor(connector.path.tiles.length / 2);
const tile = connector.path.tiles[tileIndex];
export const ConnectorLabel = ({ connector: sceneConnector }: Props) => {
const connector = useConnector(sceneConnector.id);
return getTilePosition({
tile: connectorPathTileToGlobal(tile, connector.path.rectangle.from)
});
}, [connector.path]);
const labels = useMemo(() => {
if (!connector) return [];
return getConnectorLabels(connector);
}, [connector]);
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]);
// Calculate label positions based on percentage and line assignment
const labelPositions = useMemo(() => {
if (!connector) return [];
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 labels
.map((label) => {
const tileIndex = getLabelTileIndex(
sceneConnector.path.tiles.length,
label.position
);
const tile = sceneConnector.path.tiles[tileIndex];
if (!tile) return null;
let position = getTilePosition({
tile: connectorPathTileToGlobal(
tile,
sceneConnector.path.rectangle.from
)
});
// For double line types, offset labels based on line assignment
const lineType = connector.lineType || 'SINGLE';
if (
(lineType === 'DOUBLE' || lineType === 'DOUBLE_WITH_CIRCLE') &&
label.line === '2'
) {
// Calculate offset perpendicular to line direction
const { tiles } = sceneConnector.path;
if (tileIndex > 0 && tileIndex < tiles.length - 1) {
const prev = tiles[tileIndex - 1];
const next = tiles[tileIndex + 1];
const dx = next.x - prev.x;
const dy = next.y - prev.y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
// Perpendicular offset (matches the offset in Connector.tsx)
const connectorWidthPx =
(UNPROJECTED_TILE_SIZE / 100) * (connector.width || 15);
const offset = connectorWidthPx * 3;
const perpX = -dy / len;
const perpY = dx / len;
position = {
x: position.x - perpX * offset,
y: position.y - perpY * offset
};
}
}
return { label, position };
})
.filter(
(
item
): item is {
label: ConnectorLabelType;
position: { x: number; y: number };
} => {
return item !== null;
}
);
}, [labels, sceneConnector.path, connector?.lineType, connector?.width]);
return (
<>
{/* 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
{labelPositions.map(({ label, position }) => {
return (
<Box
key={label.id}
sx={{ position: 'absolute', pointerEvents: 'none' }}
style={{
maxWidth: PROJECTED_TILE_SIZE.width,
left: position.x,
top: position.y
}}
>
<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>
)}
<Label
maxWidth={150}
labelHeight={label.height || 0}
showLine={label.showLine !== false}
sx={{
py: 0.75,
px: 1,
borderRadius: 2,
backgroundColor: 'background.paper',
opacity: 0.95
}}
>
<Typography color="text.secondary" variant="body2">
{label.text}
</Typography>
</Label>
</Box>
);
})}
</>
);
};

View File

@@ -11,7 +11,12 @@ export const ConnectorLabels = ({ connectors }: Props) => {
<>
{connectors
.filter((connector) => {
return Boolean(connector.description || connector.startLabel || connector.endLabel);
return Boolean(
connector.description ||
connector.startLabel ||
connector.endLabel ||
(connector.labels && connector.labels.length > 0)
);
})
.map((connector) => {
return <ConnectorLabel key={connector.id} connector={connector} />;

View File

@@ -53,6 +53,7 @@ export const CONNECTOR_DEFAULTS: Required<Omit<Connector, 'id' | 'color'>> = {
startLabelHeight: 0,
centerLabelHeight: 0,
endLabelHeight: 0,
labels: [],
customColor: '',
anchors: [],
style: 'SOLID',

View File

@@ -4,6 +4,15 @@ 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 connectorLabelSchema = z.object({
id,
text: constrainedStrings.description,
position: z.number().min(0).max(100), // Percentage along the path (0-100)
height: z.number().optional(), // Vertical offset
line: z.enum(['1', '2']).optional(), // Which line for double line types (defaults to '1')
showLine: z.boolean().optional() // Show the dotted line connecting label to connector (defaults to true)
});
export const anchorSchema = z.object({
id,
ref: z
@@ -17,12 +26,15 @@ export const anchorSchema = z.object({
export const connectorSchema = z.object({
id,
// Legacy label fields (for backward compatibility)
description: constrainedStrings.description.optional(),
startLabel: constrainedStrings.description.optional(),
endLabel: constrainedStrings.description.optional(),
startLabelHeight: z.number().optional(),
centerLabelHeight: z.number().optional(),
endLabelHeight: z.number().optional(),
// New flexible labels array
labels: z.array(connectorLabelSchema).max(256).optional(),
color: id.optional(),
customColor: z.string().optional(), // For custom RGB colors
width: z.number().optional(),

View File

@@ -8,6 +8,7 @@ import {
viewSchema,
viewItemSchema,
connectorSchema,
connectorLabelSchema,
iconsSchema,
colorsSchema,
anchorSchema,
@@ -31,6 +32,7 @@ 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 ConnectorLabel = z.infer<typeof connectorLabelSchema>;
export type Connector = z.infer<typeof connectorSchema>;
export type TextBox = z.infer<typeof textBoxSchema>;
export type Rectangle = z.infer<typeof rectangleSchema>;

View File

@@ -0,0 +1,71 @@
import { Connector, ConnectorLabel } from 'src/types';
import { generateId } from './common';
/**
* Migrates legacy connector labels (description, startLabel, endLabel)
* to the new flexible labels array format
*/
export const migrateLegacyLabels = (connector: Connector): ConnectorLabel[] => {
const labels: ConnectorLabel[] = [];
// Convert startLabel to 10% position
if (connector.startLabel) {
labels.push({
id: generateId(),
text: connector.startLabel,
position: 10,
height: connector.startLabelHeight,
line: '1'
});
}
// Convert description (center label) to 50% position
if (connector.description) {
labels.push({
id: generateId(),
text: connector.description,
position: 50,
height: connector.centerLabelHeight,
line: '1'
});
}
// Convert endLabel to 90% position
if (connector.endLabel) {
labels.push({
id: generateId(),
text: connector.endLabel,
position: 90,
height: connector.endLabelHeight,
line: '1'
});
}
return labels;
};
/**
* Gets all labels for a connector, migrating legacy labels if needed
*/
export const getConnectorLabels = (connector: Connector): ConnectorLabel[] => {
// If connector already has new-style labels, use them
if (connector.labels && connector.labels.length > 0) {
return connector.labels;
}
// Otherwise, migrate legacy labels
return migrateLegacyLabels(connector);
};
/**
* Calculates the actual tile position along the connector path for a given percentage
*/
export const getLabelTileIndex = (
pathLength: number,
position: number
): number => {
if (pathLength === 0) return 0;
const index = Math.round((position / 100) * (pathLength - 1));
return Math.max(0, Math.min(index, pathLength - 1));
};

View File

@@ -7,3 +7,4 @@ export * from './exportOptions';
export * from './model';
export * from './findNearestUnoccupiedTile';
export * from './pointInPolygon';
export * from './connectorLabels';