完善端口标号与MECMAS21动态图标

This commit is contained in:
ljz committed 2026-08-04 12:55:50 +08:00
1 parent df1d676131
commit 09778972a6
7 files changed
+780 -60

No files matched your search

+227 -2
View File
@@ -456,6 +456,13 @@ const NODE_SYMBOL_HEIGHT = 84;
const SYMBOL_VIEWBOX_WIDTH = 64;
const SYMBOL_VIEWBOX_HEIGHT = 48;
const SYMBOL_ENVELOPE_PADDING_PX = 2;
const PORT_INDEX_OUTSET_PX = 18;
const PORT_INDEX_TANGENT_OFFSET_PX = 12;
const PORT_INDEX_LABEL_HEIGHT_PX = 20;
const PORT_INDEX_LABEL_MIN_WIDTH_PX = 20;
const PORT_INDEX_LABEL_CHARACTER_WIDTH_PX = 10;
const PORT_INDEX_LABEL_GAP_PX = 1;
const PORT_INDEX_LABEL_LANE_GAP_PX = 2;
const CONTACT_SNAP_RADIUS_PX = 12;
const CONTACT_DISCONNECT_DISTANCE_PX = 8;
const CONTACT_GEOMETRY_TOLERANCE = 0.5;
@@ -1585,6 +1592,13 @@ function SimulationComponentNode({ id, data, selected }: NodeProps<SimulationNod
mirrored,
portAnchors,
);
const portIndexLabels = selected
? portIndexLabelPlacements(data.ports, portPlacements, rotation)
: [];
const symbolVariantKey =
symbol === "amesim_mecmas21"
? `${data.parameters.useFriction ?? ""}:${data.parameters.stoptype ?? ""}`
: "";
useLayoutEffect(() => {
if (!hasDedicatedSymbol) {
@@ -1605,7 +1619,7 @@ function SimulationComponentNode({ id, data, selected }: NodeProps<SimulationNod
? currentEnvelope
: nextEnvelope,
);
}, [hasDedicatedSymbol, symbol]);
}, [hasDedicatedSymbol, symbol, symbolVariantKey]);
useEffect(() => {
updateNodeInternals(id);
@@ -1669,10 +1683,26 @@ function SimulationComponentNode({ id, data, selected }: NodeProps<SimulationNod
height: symbolEnvelope.height,
}}
/>
<ComponentSymbol symbol={symbol} surface="canvas" />
<ComponentSymbol
parameters={data.parameters}
symbol={symbol}
surface="canvas"
/>
</div>
</div>
) : null}
{portIndexLabels.map((label) => (
<span
aria-hidden="true"
className="port-index-label"
data-port-index={label.text}
data-port-name={label.portName}
key={label.portName}
style={{ left: label.x, top: label.y }}
>
{label.text}
</span>
))}
<div className="node-label">{data.label}</div>
</div>
);
@@ -1760,6 +1790,199 @@ function transformedPortPlacements(
});
}
type TransformedPortPlacement = ReturnType<
typeof transformedPortPlacements
>[number];
type PortIndexLabelPlacement = {
order: number;
portName: string;
position: Position;
text: string;
x: number;
y: number;
};
function portIndexText(port: PortDefinition, index: number) {
const numericSuffix = port.name.match(/^port_([1-9]\d*)$/)?.[1];
if (numericSuffix !== undefined) {
const parsed = Number(numericSuffix);
if (Number.isSafeInteger(parsed)) {
return String(parsed);
}
}
return String(index + 1);
}
function portIndexLabelWidth(text: string) {
return Math.max(
PORT_INDEX_LABEL_MIN_WIDTH_PX,
text.length * PORT_INDEX_LABEL_CHARACTER_WIDTH_PX + 4,
);
}
function packPortIndexLabelLane(
labels: PortIndexLabelPlacement[],
position: Position,
axisLength: number,
) {
const verticalEdge = position === Position.Left || position === Position.Right;
const coordinate = (label: PortIndexLabelPlacement) =>
verticalEdge ? label.y : label.x;
const extent = (label: PortIndexLabelPlacement) =>
verticalEdge ? PORT_INDEX_LABEL_HEIGHT_PX : portIndexLabelWidth(label.text);
const sorted = [...labels].sort(
(first, second) =>
coordinate(first) - coordinate(second) || first.order - second.order,
);
if (sorted.length < 2) {
return sorted;
}
const packedCoordinates: number[] = [];
for (const [index, label] of sorted.entries()) {
const target = coordinate(label);
if (index === 0) {
packedCoordinates.push(target);
continue;
}
const previous = sorted[index - 1];
const minimumCoordinate =
packedCoordinates[index - 1] +
extent(previous) / 2 +
extent(label) / 2 +
PORT_INDEX_LABEL_GAP_PX;
packedCoordinates.push(Math.max(target, minimumCoordinate));
}
const idealShift =
sorted.reduce(
(sum, label, index) => sum + coordinate(label) - packedCoordinates[index],
0,
) / sorted.length;
const minimumShift = extent(sorted[0]) / 2 - packedCoordinates[0];
const maximumShift =
axisLength -
extent(sorted[sorted.length - 1]) / 2 -
packedCoordinates[packedCoordinates.length - 1];
const shift =
minimumShift <= maximumShift
? Math.min(Math.max(idealShift, minimumShift), maximumShift)
: idealShift;
return sorted.map((label, index) => {
const adjustedCoordinate = packedCoordinates[index] + shift;
return verticalEdge
? { ...label, y: adjustedCoordinate }
: { ...label, x: adjustedCoordinate };
});
}
function offsetPortIndexLabelOutward(
label: PortIndexLabelPlacement,
distance: number,
) {
if (label.position === Position.Left) {
return { ...label, x: label.x - distance };
}
if (label.position === Position.Right) {
return { ...label, x: label.x + distance };
}
if (label.position === Position.Top) {
return { ...label, y: label.y - distance };
}
return { ...label, y: label.y + distance };
}
function spreadPortIndexLabels(
labels: PortIndexLabelPlacement[],
position: Position,
axisLength: number,
) {
if (labels.length === 0) {
return labels;
}
const verticalEdge = position === Position.Left || position === Position.Right;
const coordinate = (label: PortIndexLabelPlacement) =>
verticalEdge ? label.y : label.x;
const extent = (label: PortIndexLabelPlacement) =>
verticalEdge ? PORT_INDEX_LABEL_HEIGHT_PX : portIndexLabelWidth(label.text);
const normalExtent = (label: PortIndexLabelPlacement) =>
verticalEdge ? portIndexLabelWidth(label.text) : PORT_INDEX_LABEL_HEIGHT_PX;
const sorted = [...labels].sort(
(first, second) =>
coordinate(first) - coordinate(second) || first.order - second.order,
);
const requiredSpan =
sorted.reduce((sum, label) => sum + extent(label), 0) +
PORT_INDEX_LABEL_GAP_PX * Math.max(0, sorted.length - 1);
const laneCount = Math.min(
sorted.length,
Math.max(1, Math.ceil(requiredSpan / axisLength)),
);
const lanes = Array.from(
{ length: laneCount },
() => [] as PortIndexLabelPlacement[],
);
sorted.forEach((label, index) => lanes[index % laneCount].push(label));
const laneStep =
Math.max(...sorted.map(normalExtent)) + PORT_INDEX_LABEL_LANE_GAP_PX;
return lanes.flatMap((lane, laneIndex) =>
packPortIndexLabelLane(lane, position, axisLength).map((label) =>
offsetPortIndexLabelOutward(
label,
PORT_INDEX_OUTSET_PX + laneIndex * laneStep,
),
),
);
}
function portIndexLabelPlacements(
ports: PortDefinition[],
placements: TransformedPortPlacement[],
rotation: NodeRotation,
) {
const indexByPortName = new Map(
ports.map((port, index) => [port.name, index] as const),
);
const labels = placements.map(({ port, position, x, y }, order) => {
const index = indexByPortName.get(port.name) ?? order;
const label: PortIndexLabelPlacement = {
order,
portName: port.name,
position,
text: portIndexText(port, index),
x,
y,
};
if (position === Position.Left || position === Position.Right) {
label.y -= PORT_INDEX_TANGENT_OFFSET_PX;
} else {
label.x += PORT_INDEX_TANGENT_OFFSET_PX;
}
return label;
});
const renderedSize = nodeFrameDimensions(rotation);
const spreadByOrder = new Map<number, PortIndexLabelPlacement>();
for (const position of [
Position.Left,
Position.Right,
Position.Top,
Position.Bottom,
]) {
const group = labels.filter((label) => label.position === position);
const axisLength =
position === Position.Left || position === Position.Right
? renderedSize.height
: renderedSize.width;
for (const label of spreadPortIndexLabels(group, position, axisLength)) {
spreadByOrder.set(label.order, label);
}
}
return labels.map((label) => spreadByOrder.get(label.order) ?? label);
}
function rotatePoint(x: number, y: number, rotation: NodeRotation): [number, number] {
if (rotation === 90) {
return [-y, x];
@@ -5749,6 +5972,7 @@ function FlowWorkbench() {
>
<span className="palette-icon">
<ComponentSymbol
parameters={defaultParameters(definition)}
surface="palette"
symbol={definition.symbol}
/>
@@ -6038,6 +6262,7 @@ function FlowWorkbench() {
>
<span className="palette-icon-preview-symbol">
<ComponentSymbol
parameters={defaultParameters(paletteIconPreview.definition)}
surface="palette"
symbol={paletteIconPreview.definition.symbol}
/>
+5 -1
View File
@@ -26,6 +26,7 @@ import { SymbolSvg } from "./componentSymbols/primitives";
import { AmesimStep0Symbol, AmesimUd00Symbol } from "./componentSymbols/signals";
import { AmesimPnch012Symbol, AmesimPnch023Symbol } from "./componentSymbols/storage";
import type {
ComponentSymbolParameters,
ComponentSymbolPortAnchors,
ComponentSymbolPresentation,
SymbolDefinition,
@@ -38,6 +39,7 @@ type ComponentSymbolProps = {
symbol: string;
surface: ComponentSymbolSurface;
className?: string;
parameters?: ComponentSymbolParameters;
};
function CylinderSymbol() {
@@ -361,11 +363,13 @@ export function ComponentSymbol({
symbol,
surface,
className,
parameters,
}: ComponentSymbolProps) {
const resolvedSymbol: RegisteredComponentSymbol = isRegisteredComponentSymbol(symbol)
? symbol
: "generic";
const definition = symbolRegistry[resolvedSymbol];
const SymbolRenderer = definition.render;
const rootClassName = [
"component-symbol",
`component-symbol-${surface}`,
@@ -382,7 +386,7 @@ export function ComponentSymbol({
data-component-symbol={resolvedSymbol}
data-symbol-presentation={definition.presentation}
>
{definition.render()}
<SymbolRenderer parameters={parameters} />
</span>
);
}
+149 -54
View File
@@ -1,12 +1,17 @@
import { useId } from "react";
import {
SYMBOL_ACCENT,
SYMBOL_FILL,
SYMBOL_MUTED,
SYMBOL_STROKE,
SymbolSvg,
} from "./primitives";
import type { ComponentSymbolRendererProps } from "./types";
const MECHANICAL_LINE_WIDTH = 1.8;
const MECMAS21_STROKE = "#00af00";
const MECMAS21_HATCH = "#66cf66";
const MECMAS21_FILL = "#ffffff";
const PNRP17_STROKE = "#8b134f";
const PNRP17_FILL = "#cf9db6";
const PNRP17_LIGHT_FILL = "#f5ebf0";
@@ -22,23 +27,24 @@ const mechanicalStroke = {
function MechanicalSocket({ side }: { side: "left" | "right" }) {
const facingLeft = side === "left";
return (
<g {...mechanicalStroke}>
<path d={facingLeft ? "M2 24h7" : "M55 24h7"} />
<path d={facingLeft ? "M15 19H9v10h6" : "M49 19h6v10h-6"} />
</g>
);
}
function GuideHatching({ y, upward }: { y: number; upward: boolean }) {
const direction = upward ? -1 : 1;
return (
<g stroke={SYMBOL_MUTED} strokeWidth="1">
{[14, 22, 30, 38, 46].map((x) => (
<path
d={`M${x - 3} ${y + direction * 3}L${x + 3} ${y - direction * 3}`}
key={x}
/>
))}
<g
fill="none"
stroke={MECMAS21_STROKE}
strokeLinecap="butt"
strokeLinejoin="miter"
>
<path
d={facingLeft ? "M2 24h3.2" : "M58.8 24H62"}
strokeWidth="0.9"
/>
<path
d={
facingLeft
? "M15.1 20.9H6.2v6.2h8.9"
: "M48.9 20.9h8.9v6.2h-8.9"
}
strokeWidth="2"
/>
</g>
);
}
@@ -100,44 +106,133 @@ export function AmesimForcSymbol() {
);
}
export function AmesimMecmas21Symbol() {
export function AmesimMecmas21Symbol({
parameters,
}: ComponentSymbolRendererProps) {
const frictionEnabled = Number(parameters?.useFriction) === 2;
const stopType = Number(parameters?.stoptype);
const limitsEnabled = stopType === 1 || stopType === 2 || stopType === 3;
const showGuides = frictionEnabled || limitsEnabled;
const variant = frictionEnabled
? limitsEnabled
? "friction-limited"
: "friction-unlimited"
: limitsEnabled
? "plain-limited"
: "plain-unlimited";
const hatchingId = `mecmas21-hatching-${useId().replace(
/[^a-zA-Z0-9_-]/g,
"",
)}`;
return (
<SymbolSvg className="component-symbol-amesim-mecmas21" domain="mechanical">
<GuideHatching upward y={5} />
<GuideHatching upward={false} y={43} />
<path d="M10 8h44M10 40h44" stroke={SYMBOL_MUTED} strokeWidth="1.3" />
<MechanicalSocket side="left" />
<MechanicalSocket side="right" />
<rect
fill={SYMBOL_FILL}
height="26"
rx="1"
stroke={SYMBOL_STROKE}
strokeWidth="2"
width="34"
x="15"
y="11"
/>
<text
dominantBaseline="central"
fill={SYMBOL_STROKE}
fontFamily="Arial, sans-serif"
fontSize="12"
fontWeight="600"
textAnchor="middle"
x="31"
y="28"
<SymbolSvg
className="component-symbol-amesim-mecmas21"
domain="mechanical"
>
<defs>
<pattern
height="5.05"
id={hatchingId}
patternUnits="userSpaceOnUse"
width="5.05"
>
<rect fill={MECMAS21_FILL} height="5.05" width="5.05" />
<path
d="M-1 1 1-1M0 5.05 5.05 0M4.05 6.05 6.05 4.05"
stroke={MECMAS21_HATCH}
strokeWidth="1.55"
/>
</pattern>
</defs>
<g
data-mecmas-friction={frictionEnabled ? "enabled" : "disabled"}
data-mecmas-limits={limitsEnabled ? "finite" : "unlimited"}
data-mecmas-variant={variant}
>
M
</text>
<path
d="M22 18h15m-5-4 5 4-5 4"
fill="none"
stroke={SYMBOL_ACCENT}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
/>
{showGuides ? (
<g data-mecmas-part="guides">
<rect
data-mecmas-part="upper-guide"
fill={`url(#${hatchingId})`}
height="5.25"
stroke={MECMAS21_STROKE}
strokeWidth="1.2"
width="60"
x="2"
y="2.6"
/>
{limitsEnabled ? (
<g data-mecmas-part="finite-lower-guide">
<path
d="M2 32.6H10V39.6H54V32.6H62V45.4H2Z"
fill={`url(#${hatchingId})`}
stroke={MECMAS21_STROKE}
strokeLinejoin="miter"
strokeWidth="1.2"
/>
</g>
) : (
<rect
data-mecmas-part="unlimited-lower-guide"
fill={`url(#${hatchingId})`}
height="5.25"
stroke={MECMAS21_STROKE}
strokeWidth="1.2"
width="60"
x="2"
y="40.23"
/>
)}
</g>
) : null}
{frictionEnabled ? (
<path
d="M16.49 9.93h4.9m3.81 0h4.9m3.93 0h4.9m3.79 0h4.9M16.49 38.07h4.9m3.81 0h4.9m3.93 0h4.9m3.79 0h4.9"
data-mecmas-part="friction-markers"
stroke={MECMAS21_STROKE}
strokeLinecap="butt"
strokeWidth="0.92"
/>
) : null}
<g data-mecmas-part="core">
<MechanicalSocket side="left" />
<MechanicalSocket side="right" />
<rect
data-mecmas-part="mass"
fill={MECMAS21_FILL}
height="23.1"
stroke={MECMAS21_STROKE}
strokeWidth="2"
width="34"
x="15"
y="12.45"
/>
<text
dominantBaseline="central"
fill={MECMAS21_STROKE}
fontFamily="Arial, sans-serif"
fontSize="10"
fontWeight="500"
textAnchor="middle"
x="32"
y="29.2"
>
M
</text>
<path
d="M18.94 18.8h5.88M21.9 16.6V21"
fill="none"
stroke={MECMAS21_STROKE}
strokeLinecap="square"
strokeWidth="1.05"
/>
<path
d="M45.92 18.81 39.66 15.31 39.54 17.98H26.55v1.74h12.99l.12 2.93Z"
fill={MECMAS21_STROKE}
/>
</g>
</g>
</SymbolSvg>
);
}
+10 -2
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import type { ComponentType } from "react";
export type ComponentSymbolPresentation = "framed" | "bare";
@@ -11,7 +11,15 @@ export type ComponentSymbolPortAnchors = Partial<
Record<"left" | "right", readonly ComponentSymbolPortAnchor[]>
>;
export type ComponentSymbolParameters = Readonly<
Record<string, number | string>
>;
export type ComponentSymbolRendererProps = {
parameters?: ComponentSymbolParameters;
};
export type SymbolDefinition = {
presentation: ComponentSymbolPresentation;
render: () => ReactNode;
render: ComponentType<ComponentSymbolRendererProps>;
};
+22
View File
@@ -1637,6 +1637,28 @@ textarea {
opacity: 0.18;
}
.port-index-label {
position: absolute;
z-index: 4;
min-width: 20px;
height: 20px;
box-sizing: border-box;
padding: 0 2px;
border: 0;
color: #111827;
background: #ffffff;
box-shadow: none;
font-size: 18px;
font-variant-numeric: tabular-nums;
font-weight: 700;
line-height: 20px;
pointer-events: none;
text-align: center;
transform: translate(-50%, -50%);
user-select: none;
white-space: nowrap;
}
.flow-canvas .react-flow__edge-path,
.flow-canvas .react-flow__connection-path {
stroke: #64748b;