做流程图编辑器的同学大多碰到过这个需求:两个节点之间拉一条连线,连线不能穿过其他节点,还要走横平竖直的正交路径,并且与节点之间留出一定的间距。实现这个功能的核心之一,就是给每个节点计算一个膨胀边界——把节点的包围盒向外扩出一个安全距离,路由算法把这个膨胀后的矩形当作真正的障碍物来避让。这篇文章就来聊聊,如何在TypeScript里为这套机制设计类型定义,让算法代码在编译期就得到足够的约束。

一、基础边界类型:从矩形包围盒开始
任何讨论都得先有一个坐标系约定。流程图编辑器通常使用左上角为原点、y轴向下的屏幕坐标系,一个节点的包围盒用x、y、width、height四个数字就能描述。我们先定义最基础的类型:
// 平面坐标点
interface Point {
x: number;
y: number;
}
// 节点的基础包围盒
interface Rect {
x: number; // 左上角横坐标
y: number; // 左上角纵坐标
width: number; // 宽度
height: number; // 高度
}
// 尺寸信息
interface Size {
width: number;
height: number;
}有了Rect之后,膨胀边界其实就是在它基础上扩出一个间距。这里有一个容易踩的坑:间距必须是非负数,否则膨胀就变成了收缩,障碍物反而比节点本身还小,连线会直接压在节点上。普通number类型表达不了这个约束,我们可以用品牌类型(branded type)来强化它:
// 用品牌类型标记非负数
type NonNegative = number & { readonly __brand: 'NonNegative' };
function asNonNegative(n: number): NonNegative {
if (n < 0 || Number.isNaN(n)) {
throw new RangeError(`间距必须是非负数,收到的是 ${n}`);
}
return n as NonNegative;
}品牌类型的好处是,调用方没办法直接传一个普通number进来,必须经过asNonNegative这个校验函数,等于把运行时检查和编译期类型绑定在了一起。对于路由这种对参数敏感的算法,这种写法能避免大量莫名其妙的布局错乱。
二、带膨胀间距的边界模型与分侧膨胀
最简单的膨胀是对四个方向统一扩出一个margin值,但实际产品里往往需要分侧控制。比如节点下方有文字标签,连线避让时需要留出更大的空间;而节点上方是工具栏,间距可以小一些。因此我们把膨胀模型设计成分侧结构:
// 单个膨胀间距,允许 undefined 表示该侧使用默认值
type SideMargin = NonNegative | undefined;
// 四个方向的膨胀配置
interface Margins {
top: SideMargin;
right: SideMargin;
bottom: SideMargin;
left: SideMargin;
}
// 完整的膨胀边界:原始矩形 + 膨胀配置 + 计算后的包围盒
interface InflatedBoundary {
readonly source: Rect; // 原始节点包围盒
readonly margins: Required<Margins>; // 归一化后的四侧间距
readonly bounds: Rect; // 膨胀后的实际障碍物矩形
}注意Required<Margins>这个写法,它把所有可选属性变成了必填。内部计算时,我们先用一个默认值填充所有undefined的边距,得到归一化的Required<Margins>,再据此算出膨胀后的bounds。这样对外可以接受宽松的输入,对内则始终处理完整的结构,代码不用到处写判空逻辑。
计算函数可以这样写,同时利用类型保证结果的一致性:
const DEFAULT_MARGIN = 10;
function inflate(rect: Rect, margins?: Partial<Margins>): InflatedBoundary {
const m: Required<Margins> = {
top: margins?.top ?? DEFAULT_MARGIN,
right: margins?.right ?? DEFAULT_MARGIN,
bottom: margins?.bottom ?? DEFAULT_MARGIN,
left: margins?.left ?? DEFAULT_MARGIN,
};
return {
source: rect,
margins: m,
bounds: {
x: rect.x - m.left,
y: rect.y - m.top,
width: rect.width + m.left + m.right,
height: rect.height + m.top + m.bottom,
},
};
}这里用Partial<Margins>作为参数类型,意味着调用方可以只传关心的那一侧,其余自动落到默认值。配合前面的NonNegative品牌类型,margins里传入的每个值都经过了校验,整个膨胀过程在类型层面是封闭的。
三、为路由算法设计交互类型:相交检测与方向感知
正交路由算法的核心操作是判断一条候选线段是否穿过某个膨胀边界。线段要么水平要么垂直,所以类型上可以做一个可辨识联合,让分支处理更精确:
// 正交线段:要么水平要么垂直
type OrthSegment =
| { readonly orientation: 'h'; readonly y: number; readonly x1: number; readonly x2: number }
| { readonly orientation: 'v'; readonly x: number; readonly y1: number; readonly y2: number };
// 相交测试接口
interface Obstacle {
readonly boundary: InflatedBoundary;
intersects(segment: OrthSegment): boolean;
}可辨识联合的妙处在于,当orientation为'h'时TypeScript自动知道存在y、x1、x2属性,不存在x和y1,写switch分支时能获得完整的类型提示和检查。
另外,连线从节点出发时需要知道连接点在膨胀边界上的哪个方向,这决定了第一段线段往哪个方向走。可以定义一个方向类型和锚点解析函数:
// 四个基本方向
type Direction = 'top' | 'right' | 'bottom' | 'left';
// 锚点:连接点及其出边方向
interface Anchor {
readonly point: Point; // 锚点坐标(在膨胀边界上)
readonly direction: Direction; // 连线离开边界的方向
}
function resolveAnchor(boundary: InflatedBoundary, dir: Direction): Anchor {
const b = boundary.bounds;
const map: Record<Direction, Anchor> = {
top: { point: { x: b.x + b.width / 2, y: b.y }, direction: 'top' },
right: { point: { x: b.x + b.width, y: b.y + b.height / 2 }, direction: 'right' },
bottom: { point: { x: b.x + b.width / 2, y: b.y + b.height }, direction: 'bottom' },
left: { point: { x: b.x, y: b.y + b.height / 2 }, direction: 'left' },
};
return map[dir];
}用Record<Direction, Anchor>构建映射表是个实用技巧,如果将来新增方向,编译器会强制你在映射表里补齐对应项,避免遗漏分支。锚点落在bounds而不是原始source上,这样第一段线段天然就在膨胀边界之外,与节点保持间距。
四、类型层面的边界工具与扩展建议
除了上述核心类型,还建议补充几个工具类型提升整体可用性。比如用ReadonlyArray<Obstacle>表示路由器的障碍物列表,明确告知算法内部不会修改它;再比如为可视化调试导出膨胀矩形的顶点序列:
// 路由请求与结果
interface RouteRequest {
from: Anchor;
to: Anchor;
obstacles: ReadonlyArray<Obstacle>;
}
interface RouteResult {
path: ReadonlyArray<Point>; // 折线顶点
cost: number; // 路径代价,可用于多方案择优
}
type RouteHandler = (req: RouteRequest) => RouteResult;最后总结几个设计要点。第一,膨胀间距用品牌类型约束非负性,把运行时校验收敛到一个入口函数。第二,膨胀配置用Partial接收、Required存储,对外宽松对内严格。第三,正交线段用可辨识联合建模,方向用Record映射表穷举,让编译器帮你查漏。第四,障碍物列表和路径结果都用只读类型,明确所有权边界。这套类型体系不算复杂,但每一层都有明确的约束目的,落地到具体的A星或扫描线正交路由实现时,基本不需要额外的防御性代码,可以直接专注于算法本身。
TypeScript类型定义正交路由障碍物膨胀修改时间:2026-09-11 20:32:49