Tldraw/packages/tldraw/src/lib/shapes/arrow/arrowLabel.ts

354 wiersze
11 KiB
TypeScript

import {
Arc2d,
Box,
Circle2d,
Edge2d,
Editor,
Geometry2d,
Polygon2d,
TLArrowInfo,
TLArrowShape,
Vec,
VecLike,
angleDistance,
clamp,
getPointOnCircle,
intersectCirclePolygon,
intersectLineSegmentPolygon,
} from '@tldraw/editor'
import {
ARROW_LABEL_FONT_SIZES,
ARROW_LABEL_PADDING,
FONT_FAMILIES,
LABEL_TO_ARROW_PADDING,
STROKE_SIZES,
TEXT_PROPS,
} from '../shared/default-shape-constants'
const labelSizeCache = new WeakMap<TLArrowShape, Vec>()
function getArrowLabelSize(editor: Editor, shape: TLArrowShape) {
const cachedSize = labelSizeCache.get(shape)
if (cachedSize) return cachedSize
const info = editor.getArrowInfo(shape)!
let width = 0
let height = 0
const bodyGeom = info.isStraight
? new Edge2d({
start: Vec.From(info.start.point),
end: Vec.From(info.end.point),
})
: new Arc2d({
center: Vec.Cast(info.handleArc.center),
radius: info.handleArc.radius,
start: Vec.Cast(info.start.point),
end: Vec.Cast(info.end.point),
sweepFlag: info.bodyArc.sweepFlag,
largeArcFlag: info.bodyArc.largeArcFlag,
})
if (shape.props.text.trim()) {
const bodyBounds = bodyGeom.bounds
const { w, h } = editor.textMeasure.measureText(shape.props.text, {
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: ARROW_LABEL_FONT_SIZES[shape.props.size],
maxWidth: null,
})
width = w
height = h
if (bodyBounds.width > bodyBounds.height) {
width = Math.max(Math.min(w, 64), Math.min(bodyBounds.width - 64, w))
const { w: squishedWidth, h: squishedHeight } = editor.textMeasure.measureText(
shape.props.text,
{
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: ARROW_LABEL_FONT_SIZES[shape.props.size],
maxWidth: width,
}
)
width = squishedWidth
height = squishedHeight
}
if (width > 16 * ARROW_LABEL_FONT_SIZES[shape.props.size]) {
width = 16 * ARROW_LABEL_FONT_SIZES[shape.props.size]
const { w: squishedWidth, h: squishedHeight } = editor.textMeasure.measureText(
shape.props.text,
{
...TEXT_PROPS,
fontFamily: FONT_FAMILIES[shape.props.font],
fontSize: ARROW_LABEL_FONT_SIZES[shape.props.size],
maxWidth: width,
}
)
width = squishedWidth
height = squishedHeight
}
}
const size = new Vec(width, height).addScalar(ARROW_LABEL_PADDING * 2)
labelSizeCache.set(shape, size)
return size
}
function getLabelToArrowPadding(editor: Editor, shape: TLArrowShape) {
const strokeWidth = STROKE_SIZES[shape.props.size]
const labelToArrowPadding =
LABEL_TO_ARROW_PADDING +
(strokeWidth - STROKE_SIZES.s) * 2 +
(strokeWidth === STROKE_SIZES.xl ? 20 : 0)
return labelToArrowPadding
}
/**
* Return the range of possible label positions for a straight arrow. The full possible range is 0
* to 1, but as the label itself takes up space the usable range is smaller.
*/
function getStraightArrowLabelRange(
editor: Editor,
shape: TLArrowShape,
info: Extract<TLArrowInfo, { isStraight: true }>
): { start: number; end: number } {
const labelSize = getArrowLabelSize(editor, shape)
const labelToArrowPadding = getLabelToArrowPadding(editor, shape)
// take the start and end points of the arrow, and nudge them in a bit to give some spare space:
const startOffset = Vec.Nudge(info.start.point, info.end.point, labelToArrowPadding)
const endOffset = Vec.Nudge(info.end.point, info.start.point, labelToArrowPadding)
// assuming we just stick the label in the middle of the shape, where does the arrow intersect the label?
const intersectionPoints = intersectLineSegmentPolygon(
startOffset,
endOffset,
Box.FromCenter(info.middle, labelSize).corners
)
if (!intersectionPoints || intersectionPoints.length !== 2) {
return { start: 0.5, end: 0.5 }
}
// there should be two intersection points - one near the start, and one near the end
let [startIntersect, endIntersect] = intersectionPoints
if (Vec.Dist2(startIntersect, startOffset) > Vec.Dist2(endIntersect, startOffset)) {
;[endIntersect, startIntersect] = intersectionPoints
}
// take our nudged start and end points and scooch them in even further to give us the possible
// range for the position of the _center_ of the label
const startConstrained = startOffset.add(Vec.Sub(info.middle, startIntersect))
const endConstrained = endOffset.add(Vec.Sub(info.middle, endIntersect))
// now we can work out the range of possible label positions
const start = Vec.Dist(info.start.point, startConstrained) / info.length
const end = Vec.Dist(info.start.point, endConstrained) / info.length
return { start, end }
}
/**
* Return the range of possible label positions for a curved arrow. The full possible range is 0
* to 1, but as the label itself takes up space the usable range is smaller.
*/
function getCurvedArrowLabelRange(
editor: Editor,
shape: TLArrowShape,
info: Extract<TLArrowInfo, { isStraight: false }>
): { start: number; end: number; dbg?: Geometry2d[] } {
const labelSize = getArrowLabelSize(editor, shape)
const labelToArrowPadding = getLabelToArrowPadding(editor, shape)
const direction = Math.sign(shape.props.bend)
// take the start and end points of the arrow, and nudge them in a bit to give some spare space:
const labelToArrowPaddingRad = (labelToArrowPadding / info.handleArc.radius) * direction
const startOffsetAngle = Vec.Angle(info.bodyArc.center, info.start.point) - labelToArrowPaddingRad
const endOffsetAngle = Vec.Angle(info.bodyArc.center, info.end.point) + labelToArrowPaddingRad
const startOffset = getPointOnCircle(info.bodyArc.center, info.bodyArc.radius, startOffsetAngle)
const endOffset = getPointOnCircle(info.bodyArc.center, info.bodyArc.radius, endOffsetAngle)
const dbg: Geometry2d[] = []
// unlike the straight arrow, we can't just stick the label in the middle of the shape when
// we're working out the range. this is because as the label moves along the curve, the place
// where the arrow intersects with label changes. instead, we have to stick the label center on
// the `startOffset` (the start-most place where it can go), then find where it intersects with
// the arc. because of the symmetry of the label rectangle, we can move the label to that new
// center and take that as the start-most possible point.
const startIntersections = intersectArcPolygon(
info.bodyArc.center,
info.bodyArc.radius,
startOffsetAngle,
endOffsetAngle,
direction,
Box.FromCenter(startOffset, labelSize).corners
)
dbg.push(
new Polygon2d({
points: Box.FromCenter(startOffset, labelSize).corners,
debugColor: 'lime',
isFilled: false,
ignore: true,
})
)
const endIntersections = intersectArcPolygon(
info.bodyArc.center,
info.bodyArc.radius,
startOffsetAngle,
endOffsetAngle,
direction,
Box.FromCenter(endOffset, labelSize).corners
)
dbg.push(
new Polygon2d({
points: Box.FromCenter(endOffset, labelSize).corners,
debugColor: 'lime',
isFilled: false,
ignore: true,
})
)
for (const pt of [
...(startIntersections ?? []),
...(endIntersections ?? []),
startOffset,
endOffset,
]) {
dbg.push(
new Circle2d({
x: pt.x - 3,
y: pt.y - 3,
radius: 3,
isFilled: false,
debugColor: 'magenta',
ignore: true,
})
)
}
// if we have one or more intersections (we shouldn't have more than two) then the one we need
// is the one furthest from the arrow terminal
const startConstrained =
(startIntersections && furthest(info.start.point, startIntersections)) ?? info.middle
const endConstrained =
(endIntersections && furthest(info.end.point, endIntersections)) ?? info.middle
const startAngle = Vec.Angle(info.bodyArc.center, info.start.point)
const endAngle = Vec.Angle(info.bodyArc.center, info.end.point)
const constrainedStartAngle = Vec.Angle(info.bodyArc.center, startConstrained)
const constrainedEndAngle = Vec.Angle(info.bodyArc.center, endConstrained)
// if the arc is small enough that there's no room for the label to move, we constrain it to the middle.
if (
angleDistance(startAngle, constrainedStartAngle, direction) >
angleDistance(startAngle, constrainedEndAngle, direction)
) {
return { start: 0.5, end: 0.5, dbg }
}
// now we can work out the range of possible label positions
const fullDistance = angleDistance(startAngle, endAngle, direction)
const start = angleDistance(startAngle, constrainedStartAngle, direction) / fullDistance
const end = angleDistance(startAngle, constrainedEndAngle, direction) / fullDistance
return { start, end, dbg }
}
export function getArrowLabelPosition(editor: Editor, shape: TLArrowShape) {
let labelCenter
const debugGeom: Geometry2d[] = []
const info = editor.getArrowInfo(shape)!
const hasStartBinding = shape.props.start.type === 'binding'
const hasEndBinding = shape.props.end.type === 'binding'
const hasStartArrowhead = info.start.arrowhead !== 'none'
const hasEndArrowhead = info.end.arrowhead !== 'none'
if (info.isStraight) {
const range = getStraightArrowLabelRange(editor, shape, info)
let clampedPosition = clamp(
shape.props.labelPosition,
hasStartArrowhead || hasStartBinding ? range.start : 0,
hasEndArrowhead || hasEndBinding ? range.end : 1
)
// This makes the position snap in the middle.
clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition
labelCenter = Vec.Lrp(info.start.point, info.end.point, clampedPosition)
} else {
const range = getCurvedArrowLabelRange(editor, shape, info)
if (range.dbg) debugGeom.push(...range.dbg)
let clampedPosition = clamp(
shape.props.labelPosition,
hasStartArrowhead || hasStartBinding ? range.start : 0,
hasEndArrowhead || hasEndBinding ? range.end : 1
)
// This makes the position snap in the middle.
clampedPosition = clampedPosition >= 0.48 && clampedPosition <= 0.52 ? 0.5 : clampedPosition
const labelAngle = interpolateArcAngles(
Vec.Angle(info.bodyArc.center, info.start.point),
Vec.Angle(info.bodyArc.center, info.end.point),
Math.sign(shape.props.bend),
clampedPosition
)
labelCenter = getPointOnCircle(info.bodyArc.center, info.bodyArc.radius, labelAngle)
}
const labelSize = getArrowLabelSize(editor, shape)
return { box: Box.FromCenter(labelCenter, labelSize), debugGeom }
}
function intersectArcPolygon(
center: VecLike,
radius: number,
angleStart: number,
angleEnd: number,
direction: number,
polygon: VecLike[]
) {
const intersections = intersectCirclePolygon(center, radius, polygon)
// filter the circle intersections to just the ones from the arc
const fullArcDistance = angleDistance(angleStart, angleEnd, direction)
return intersections?.filter((pt) => {
const pDistance = angleDistance(angleStart, Vec.Angle(center, pt), direction)
return pDistance >= 0 && pDistance <= fullArcDistance
})
}
function furthest(from: VecLike, candidates: VecLike[]): VecLike | null {
let furthest: VecLike | null = null
let furthestDist = -Infinity
for (const candidate of candidates) {
const dist = Vec.Dist2(from, candidate)
if (dist > furthestDist) {
furthest = candidate
furthestDist = dist
}
}
return furthest
}
/**
*
* @param angleStart - The angle of the start of the arc
* @param angleEnd - The angle of the end of the arc
* @param direction - The direction of the arc (1 = counter-clockwise, -1 = clockwise)
* @param t - A number between 0 and 1 representing the position along the arc
* @returns
*/
function interpolateArcAngles(angleStart: number, angleEnd: number, direction: number, t: number) {
const dist = angleDistance(angleStart, angleEnd, direction)
return angleStart + dist * t * direction * -1
}