feat: Pin移动连线也跟着移动

This commit is contained in:
alivender
2025-04-27 14:08:05 +08:00
parent b3a5342d6b
commit 10db7c67bf
6 changed files with 348 additions and 467 deletions

View File

@@ -5,71 +5,63 @@
:height="height"
:viewBox="'0 0 ' + viewBoxWidth + ' ' + viewBoxHeight"
class="pin-component"
:data-pin-id="props.label"
>
<g :transform="`translate(${viewBoxWidth/2}, ${viewBoxHeight/2})`"> <g v-if="props.appearance === 'None'">
<g transform="translate(-12.5, -12.5)" class="interactive"> <circle
:data-component-id="componentId"
:data-pin-label="props.label"
> <g :transform="`translate(${viewBoxWidth/2}, ${viewBoxHeight/2})`">
<g v-if="props.appearance === 'None'">
<g transform="translate(-12.5, -12.5)">
<circle
style="fill:#909090"
cx="12.5"
cy="12.5"
r="3.75"
@mouseenter="showPinTooltip"
@mouseleave="hidePinTooltip"
class="interactive"
@click.stop="handlePinClick"
:data-pin-id="`${props.label}-${props.constraint}`" />
:data-pin-element="`${props.componentId}-${props.label}`" />
</g>
</g>
<g v-else-if="props.appearance === 'Dip'">
<!-- 使用inkscape创建的SVG替代原有Dip样式 -->
<g transform="translate(-12.5, -12.5)" class="interactive">
<g transform="translate(-12.5, -12.5)">
<rect
:style="`fill:${props.type === 'analog' ? '#2a6099' : '#000000'};fill-opacity:0.772973`"
width="25"
height="25"
x="0"
y="0"
rx="2.5" /> <circle
rx="2.5" />
<circle
style="fill:#ecececc5;fill-opacity:0.772973"
cx="12.5"
cy="12.5"
r="3.75"
@mouseenter="showPinTooltip"
@mouseleave="hidePinTooltip"
class="interactive"
@click.stop="handlePinClick"
:data-pin-id="`${props.label}-${props.constraint}`" />
:data-pin-element="`${props.componentId}-${props.label}`" />
<text
style="font-size:6.85px;text-align:start;fill:#ffffff;fill-opacity:0.772973"
x="7.3"
y="7"
xml:space="preserve">{{ props.label }}</text>
</g>
</g> <g v-else-if="props.appearance === 'SMT'">
</g>
<g v-else-if="props.appearance === 'SMT'">
<rect x="-20" y="-10" width="40" height="20" fill="#aaa" rx="2" ry="2" />
<rect x="-18" y="-8" width="36" height="16" :fill="getColorByType" rx="1" ry="1" />
<rect x="-16" y="-6" width="26" height="12" :fill="getColorByType" rx="1" ry="1" />
<text text-anchor="middle" dominant-baseline="middle" font-size="8" fill="white" x="-3">{{ props.label }}</text>
<!-- SMT样式的针脚 --> <circle
<circle
fill="#ecececc5"
cx="10"
cy="0"
r="3.75"
class="interactive"
@mouseenter="showPinTooltip"
@mouseleave="hidePinTooltip"
@click.stop="handlePinClick"
:data-pin-id="`${props.label}-${props.constraint}`" />
</g> </g>
:data-pin-element="`${props.componentId}-${props.label}`"
/>
</g>
</g>
</svg>
<!-- 提示框 - 在SVG外部 -->
<div v-if="showTooltip" class="pin-tooltip" :style="{
position: 'absolute',
top: tooltipPosition.top + 'px',
left: tooltipPosition.left + 'px',
transform: 'translate(-50%, -100%)',
marginTop: '-5px'
}">
{{ tooltipText }}
</div>
</template>
<script setup lang="ts">
@@ -82,6 +74,7 @@ interface Props {
direction?: 'input' | 'output' | 'inout';
type?: 'digital' | 'analog';
appearance?: 'None' | 'Dip' | 'SMT';
componentId?: string; // 添加组件ID属性用于唯一标识
}
const props = withDefaults(defineProps<Props>(), {
@@ -90,7 +83,8 @@ const props = withDefaults(defineProps<Props>(), {
constraint: '',
direction: 'input',
type: 'digital',
appearance: 'Dip'
appearance: 'Dip',
componentId: 'pin-default' // 默认ID
});
const emit = defineEmits([
@@ -100,58 +94,6 @@ const emit = defineEmits([
// 内部状态
const analogValue = ref(0);
const showTooltip = ref(false);
const tooltipText = ref('');
const tooltipPosition = reactive({
top: 0,
left: 0
});
// 显示针脚提示
function showPinTooltip(event: MouseEvent) {
showTooltip.value = true;
const target = event.target as SVGElement;
const rect = target.getBoundingClientRect();
// 更新提示位置
tooltipPosition.top = rect.top;
tooltipPosition.left = rect.left + rect.width / 2;
// 更新提示文本
tooltipText.value = generateTooltipText();
}
// 隐藏针脚提示
function hidePinTooltip() {
showTooltip.value = false;
}
// 生成提示文本
function generateTooltipText() {
const parts = [];
parts.push(`标签: ${props.label}`);
if (props.constraint) {
parts.push(`约束: ${props.constraint}`);
} else {
parts.push('约束: 未定义');
}
parts.push(`方向: ${getDirectionText()}`);
parts.push(`类型: ${props.type === 'digital' ? '数字' : '模拟'}`);
return parts.join(' | ');
}
// 获取方向文本
function getDirectionText() {
switch (props.direction) {
case 'input': return '输入';
case 'output': return '输出';
case 'inout': return '双向';
default: return '未知';
}
}
// 处理针脚点击
function handlePinClick(event: MouseEvent) {
@@ -222,10 +164,10 @@ defineExpose({
// 在 Pin 组件中,只有一个针脚,所以直接检查标签是否匹配
if (pinLabel !== props.label) return null;
// 使用自身作为一个唯一标识符确保针脚位置是基于实际的DOM位置
// 这样可以避免document.querySelector获取到非预期的元素
const pinElement = document.querySelector(`[data-pin-id="${props.label}-${props.constraint}"]`) as SVGElement;
// 使用组件ID和针脚标签的组合作为唯一标识符
const selector = `[data-pin-element="${props.componentId}-${props.label}"]`;
const pinElement = document.querySelector(selector) as SVGElement;
if (pinElement) {
// 获取针脚元素的位置
const rect = pinElement.getBoundingClientRect();
@@ -235,13 +177,11 @@ defineExpose({
};
}
// 如果找不到特定元素,使用计算出的位置
// 这种情况下我们需要找到父SVG元素位置
const svgElement = document.querySelector(`.pin-component[data-pin-id="${props.label}"]`) as SVGElement;
if (!svgElement) {
console.error(`找不到针脚 ${props.label} 的SVG元素`);
return null;
}
// 如果找不到特定元素,使用SVG元素位置
const svgSelector = `svg.pin-component[data-component-id="${props.componentId}"][data-pin-label="${props.label}"]`;
const svgElement = document.querySelector(svgSelector) as SVGElement;
if (!svgElement) return null;
const svgRect = svgElement.getBoundingClientRect();
@@ -249,12 +189,6 @@ defineExpose({
let pinX = svgRect.left + svgRect.width / 2;
let pinY = svgRect.top + svgRect.height / 2;
// 添加一个小的随机偏移,确保不同针脚返回不同位置
// 仅在测试时启用,生产环境应使用更精确的算法
const randomOffset = 0.1;
pinX += Math.random() * randomOffset;
pinY += Math.random() * randomOffset;
return { x: pinX, y: pinY };
}
});
@@ -273,15 +207,4 @@ defineExpose({
.interactive:hover {
filter: brightness(1.2);
}
.pin-tooltip {
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
z-index: 1000;
pointer-events: none;
white-space: nowrap;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
</style>

View File

@@ -0,0 +1,149 @@
<template>
<g :data-wire-id="props.id">
<path
:d="pathData"
fill="none"
:stroke="computedStroke"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
:class="{ 'wire-active': props.isActive }"
@click="handleClick"
/>
<!-- 可选添加连线标签或状态指示器 -->
<text
v-if="showLabel"
:x="labelPosition.x"
:y="labelPosition.y"
class="wire-label"
>{{ props.constraint || '' }}</text>
</g>
</template>
<script setup lang="ts">
import { computed, defineEmits, reactive } from 'vue';
interface Props {
id: string;
startX: number;
startY: number;
endX: number;
endY: number;
strokeColor?: string;
strokeWidth?: number;
isActive?: boolean;
routingMode?: 'auto' | 'orthogonal' | 'direct';
// 针脚引用属性
startComponentId?: string;
startPinLabel?: string;
endComponentId?: string;
endPinLabel?: string;
// 添加约束属性
constraint?: string;
// 显示标签
showLabel?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
strokeColor: '#4a5568',
strokeWidth: 2,
isActive: false,
routingMode: 'orthogonal',
showLabel: false,
constraint: ''
});
const computedStroke = computed(() => props.isActive ? '#ff9800' : props.strokeColor);
const emit = defineEmits(['click', 'update:active', 'update:position']);
function handleClick(event: MouseEvent) {
emit('click', { id: props.id, event });
}
// 计算标签位置 - 放在连线中间
const labelPosition = computed(() => {
return {
x: (props.startX + props.endX) / 2,
y: (props.startY + props.endY) / 2 - 5
};
});
const pathData = computed(() => {
return calculateOrthogonalPath(props.startX, props.startY, props.endX, props.endY);
});
function calculateOrthogonalPath(startX: number, startY: number, endX: number, endY: number) {
// 计算两点之间的水平和垂直距离
const dx = endX - startX;
const dy = endY - startY;
// 如果在同一水平或垂直线上,直接连线
if (dx === 0 || dy === 0) {
return `M ${startX} ${startY} L ${endX} ${endY}`;
}
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
if (absDx > absDy) {
// 先水平移动,然后垂直移动
const middleX = startX + dx * 0.5;
return `M ${startX} ${startY} L ${middleX} ${startY} L ${middleX} ${endY} L ${endX} ${endY}`;
} else {
// 先垂直移动,然后水平移动
const middleY = startY + dy * 0.5;
return `M ${startX} ${startY} L ${startX} ${middleY} L ${endX} ${middleY} L ${endX} ${endY}`;
}
}
// 暴露方法,用于获取这条连线的信息
defineExpose({ id: props.id,
getInfo: () => ({
id: props.id,
startComponentId: props.startComponentId,
startPinLabel: props.startPinLabel,
endComponentId: props.endComponentId,
endPinLabel: props.endPinLabel,
constraint: props.constraint
}),
// 更新连线位置
updatePosition: (newStartX: number, newStartY: number, newEndX: number, newEndY: number) => {
// 由于 props 是只读的,我们只能通过事件通知父组件更新
emit('update:position', {
id: props.id,
startX: newStartX,
startY: newStartY,
endX: newEndX,
endY: newEndY
});
},
// 获取连线的针脚情况
getPinPosition: () => null, // 为了与其他组件接口一致但Wire不是针脚组件
// 获取连线的路由模式
getRoutingMode: () => props.routingMode,
// 设置连线状态(如高亮等)
setActive: (active: boolean) => {
emit('update:active', active);
}
});
</script>
<style scoped>
.wire-active {
stroke-dasharray: 5;
animation: dash 0.5s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: 10;
}
}
.wire-label {
font-size: 10px;
fill: #666;
text-anchor: middle;
pointer-events: none;
user-select: none;
}
</style>

View File

@@ -318,7 +318,55 @@ const componentConfigs: Record<string, ComponentConfig> = {
description: '相同约束字符串的组件将被视为有电气连接'
}
]
}
},
// 线缆配置
Wire: {
props: [
{
name: 'routingMode',
type: 'select',
label: '路由方式',
default: 'orthogonal',
options: [
{ value: 'orthogonal', label: '直角' },
{ value: 'direct', label: '直线' },
{ value: 'auto', label: '自动' }
],
description: '线路连接方式'
},
{
name: 'strokeColor',
type: 'string',
label: '线条颜色',
default: '#4a5568',
description: '线条颜色使用CSS颜色值'
},
{
name: 'strokeWidth',
type: 'number',
label: '线条宽度',
default: 2,
min: 1,
max: 10,
step: 0.5,
description: '线条宽度'
},
{
name: 'constraint',
type: 'string',
label: '约束名称',
default: '',
description: '线路约束名称,用于标识连接关系'
},
{
name: 'showLabel',
type: 'boolean',
label: '显示标签',
default: false,
description: '是否显示连线上的约束标签'
}
]
},
};
// 获取组件配置的函数