import type {
PossibleVector2,
SignalValue,
SimpleSignal,
ThreadGenerator,
TimingFunction,
} from '@revideo/core';
import {
BBox,
createSignal,
threadable,
transformVectorAsPoint,
tween,
unwrap,
useLogger,
Vector2,
} from '@revideo/core';
import type {CurveProfile} from '../curves';
import {getPolylineProfile} from '../curves';
import {
calculateLerpDistance,
polygonLength,
polygonPointsLerp,
} from '../curves/createCurveProfileLerp';
import {computed, initial, nodeName, signal} from '../decorators';
import {arc, drawLine, drawPivot, lineTo, moveTo} from '../utils';
import type {CurveProps} from './Curve';
import {Curve} from './Curve';
import {Layout} from './Layout';
const lineWithoutPoints = `
The line won't be visible unless you specify at least two points:
\`\`\`tsx
\`\`\`
Alternatively, you can define the points using the children:
\`\`\`tsx
\`\`\`
If you did this intentionally, and want to disable this message, set the
\`points\` property to \`null\`:
\`\`\`tsx
\`\`\`
`;
export interface LineProps extends CurveProps {
/**
* {@inheritDoc Line.radius}
*/
radius?: SignalValue;
/**
* {@inheritDoc Line.points}
*/
points?: SignalValue[]>;
}
/**
* A node for drawing lines and polygons.
*
* @remarks
* This node can be used to render any polygonal shape defined by a set of
* points.
*
* @preview
* ```tsx editor
* // snippet Simple line
* import {makeScene2D, Line} from '@revideo/2d';
*
* export default makeScene2D(function* (view) {
* view.add(
* ,
* );
* });
*
* // snippet Polygon
* import {makeScene2D, Line} from '@revideo/2d';
*
* export default makeScene2D(function* (view) {
* view.add(
* ,
* );
* });
*
* // snippet Using signals
* import {makeScene2D, Line} from '@revideo/2d';
* import {createSignal} from '@revideo/core';
*
* export default makeScene2D(function* (view) {
* const tip = createSignal(-150);
* view.add(
* [tip(), -70],
* ]}
* stroke={'lightseagreen'}
* lineWidth={8}
* closed
* />,
* );
*
* yield* tip(150, 1).back(1);
* });
*
* // snippet Tweening points
* import {makeScene2D, Line} from '@revideo/2d';
* import {createRef} from '@revideo/core';
*
* export default makeScene2D(function* (view) {
* const line = createRef();
* view.add(
* ,
* );
*
* yield* line()
* .points(
* [
* [-150, 0],
* [0, 100],
* [150, 0],
* [150, -70],
* [-150, -70],
* ],
* 2,
* )
* .back(2);
* });
* ```
*/
@nodeName('Line')
export class Line extends Curve {
/**
* Rotate the points to minimize the overall distance traveled when tweening.
*
* @param points - The points to rotate.
* @param reference - The reference points to which the distance is measured.
* @param closed - Whether the points form a closed polygon.
*/
private static rotatePoints(
points: Vector2[],
reference: Vector2[],
closed: boolean,
) {
if (closed) {
let minDistance = Infinity;
let bestOffset = 0;
for (let offset = 0; offset < points.length; offset += 1) {
const distance = calculateLerpDistance(points, reference, offset);
if (distance < minDistance) {
minDistance = distance;
bestOffset = offset;
}
}
if (bestOffset) {
const spliced = points.splice(0, bestOffset);
points.splice(points.length, 0, ...spliced);
}
} else {
const originalDistance = calculateLerpDistance(points, reference, 0);
const reversedPoints = [...points].reverse();
const reversedDistance = calculateLerpDistance(
reversedPoints,
reference,
0,
);
if (reversedDistance < originalDistance) {
points.reverse();
}
}
}
/**
* Distribute additional points along the polyline.
*
* @param points - The points of a polyline along which new points should be
* distributed.
* @param count - The number of points to add.
*/
private static distributePoints(points: Vector2[], count: number) {
if (points.length === 0) {
for (let j = 0; j < count; j++) {
points.push(Vector2.zero);
}
return;
}
if (points.length === 1) {
const point = points[0];
for (let j = 0; j < count; j++) {
points.push(point);
}
return;
}
const desiredLength = points.length + count;
const arcLength = polygonLength(points);
let density = count / arcLength;
let i = 0;
while (points.length < desiredLength) {
const pointsLeft = desiredLength - points.length;
if (i + 1 >= points.length) {
density = pointsLeft / arcLength;
i = 0;
continue;
}
const a = points[i];
const b = points[i + 1];
const length = a.sub(b).magnitude;
const pointCount = Math.min(Math.round(length * density), pointsLeft) + 1;
for (let j = 1; j < pointCount; j++) {
points.splice(++i, 0, Vector2.lerp(a, b, j / pointCount));
}
i++;
}
}
/**
* The radius of the line's corners.
*/
@initial(0)
@signal()
public declare readonly radius: SimpleSignal;
/**
* The points of the line.
*
* @remarks
* When set to `null`, the Line will use the positions of its children as
* points.
*/
@initial(null)
@signal()
public declare readonly points: SimpleSignal<
SignalValue[] | null,
this
>;
@threadable()
protected *tweenPoints(
value: SignalValue[] | null>,
time: number,
timingFunction: TimingFunction,
): ThreadGenerator {
const fromPoints = [...this.parsedPoints()];
const toPoints = this.parsePoints(unwrap(value));
const closed = this.closed();
const diff = fromPoints.length - toPoints.length;
Line.distributePoints(diff < 0 ? fromPoints : toPoints, Math.abs(diff));
Line.rotatePoints(toPoints, fromPoints, closed);
this.tweenedPoints(fromPoints);
yield* tween(
time,
value => {
const progress = timingFunction(value);
this.tweenedPoints(polygonPointsLerp(fromPoints, toPoints, progress));
},
() => {
this.tweenedPoints(null);
this.points(value);
},
);
}
private tweenedPoints = createSignal(null);
public constructor(props: LineProps) {
super(props);
if (props.children === undefined && props.points === undefined) {
useLogger().warn({
message: 'No points specified for the line',
remarks: lineWithoutPoints,
inspect: this.key,
});
}
}
@computed()
protected childrenBBox() {
let points = this.tweenedPoints();
if (!points) {
const custom = this.points();
points = custom
? custom.map(signal => new Vector2(unwrap(signal)))
: this.children()
.filter(child => !(child instanceof Layout) || child.isLayoutRoot())
.map(child => child.position());
}
return BBox.fromPoints(...points);
}
@computed()
public parsedPoints(): Vector2[] {
return this.parsePoints(this.points());
}
@computed()
public override profile(): CurveProfile {
return getPolylineProfile(
this.tweenedPoints() ?? this.parsedPoints(),
this.radius(),
this.closed(),
);
}
protected override lineWidthCoefficient(): number {
const radius = this.radius();
const join = this.lineJoin();
let coefficient = super.lineWidthCoefficient();
if (radius === 0 && join === 'miter') {
const {minSin} = this.profile();
if (minSin > 0) {
coefficient = Math.max(coefficient, 0.5 / minSin);
}
}
return coefficient;
}
public override drawOverlay(
context: CanvasRenderingContext2D,
matrix: DOMMatrix,
) {
const box = this.childrenBBox().transformCorners(matrix);
const size = this.computedSize();
const offsetVector = size.mul(this.offset()).scale(0.5);
const offset = transformVectorAsPoint(offsetVector, matrix);
context.fillStyle = 'white';
context.strokeStyle = 'black';
context.lineWidth = 1;
const path = new Path2D();
const pointsPreTransformation = this.tweenedPoints() ?? this.parsedPoints();
const points = pointsPreTransformation.map(p =>
transformVectorAsPoint(p, matrix),
);
if (points.length > 0) {
moveTo(path, points[0]);
for (const point of points) {
lineTo(path, point);
context.beginPath();
arc(context, point, 4);
context.closePath();
context.fill();
context.stroke();
}
}
context.strokeStyle = 'white';
context.stroke(path);
context.beginPath();
drawPivot(context, offset);
context.stroke();
context.beginPath();
drawLine(context, box);
context.closePath();
context.stroke();
}
private parsePoints(points: SignalValue[] | null) {
return points
? points.map(signal => new Vector2(unwrap(signal)))
: this.children().map(child => child.position());
}
}