feat: connections

This commit is contained in:
aarthificial
2022-02-16 18:16:18 +01:00
parent 6cb5589390
commit 49254fc36c
21 changed files with 444 additions and 39 deletions

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules
output
dist
test
.idea

53
package-lock.json generated
View File

@@ -1,18 +1,19 @@
{
"name": "@aarthificial/motion-canvas",
"version": "1.0.14",
"version": "1.2.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@aarthificial/motion-canvas",
"version": "1.0.14",
"version": "1.2.0",
"license": "ISC",
"dependencies": {
"canvas": "^2.9.0",
"konva": "^8.3.2",
"ts-loader": "^9.2.6",
"typescript": "^4.5.5",
"url-loader": "^4.1.1",
"webpack": "^5.68.0",
"webpack-dev-server": "^4.7.4"
},
@@ -627,7 +628,6 @@
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
"integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
"dev": true,
"engines": {
"node": "*"
}
@@ -1207,7 +1207,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
"integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
"dev": true,
"engines": {
"node": ">= 4"
}
@@ -2165,7 +2164,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
"integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==",
"dev": true,
"dependencies": {
"minimist": "^1.2.5"
},
@@ -2216,7 +2214,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
"integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
"dev": true,
"dependencies": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
@@ -3683,6 +3680,32 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-loader": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz",
"integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==",
"dependencies": {
"loader-utils": "^2.0.0",
"mime-types": "^2.1.27",
"schema-utils": "^3.0.0"
},
"engines": {
"node": ">= 10.13.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
},
"peerDependencies": {
"file-loader": "*",
"webpack": "^4.0.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"file-loader": {
"optional": true
}
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -4657,8 +4680,7 @@
"big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
"integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
"dev": true
"integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ=="
},
"binary-extensions": {
"version": "2.2.0",
@@ -5104,8 +5126,7 @@
"emojis-list": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz",
"integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==",
"dev": true
"integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q=="
},
"encodeurl": {
"version": "1.0.2",
@@ -5814,7 +5835,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
"integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==",
"dev": true,
"requires": {
"minimist": "^1.2.5"
}
@@ -5839,7 +5859,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.2.tgz",
"integrity": "sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==",
"dev": true,
"requires": {
"big.js": "^5.2.2",
"emojis-list": "^3.0.0",
@@ -6861,6 +6880,16 @@
"punycode": "^2.1.0"
}
},
"url-loader": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz",
"integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==",
"requires": {
"loader-utils": "^2.0.0",
"mime-types": "^2.1.27",
"schema-utils": "^3.0.0"
}
},
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@@ -7,13 +7,16 @@
"license": "ISC",
"scripts": {
"prepare": "npm run build",
"build": "tsc"
"build": "tsc",
"test:serve": "node ./tools/serve.mjs ./test/player.ts",
"test:render": "node ./tools/serve.mjs ./test/render.ts"
},
"dependencies": {
"canvas": "^2.9.0",
"konva": "^8.3.2",
"ts-loader": "^9.2.6",
"typescript": "^4.5.5",
"url-loader": "^4.1.1",
"webpack": "^5.68.0",
"webpack-dev-server": "^4.7.4"
},

View File

@@ -2,9 +2,12 @@ import {Project} from './Project';
export function Player(factory: () => Project) {
const project = factory();
project.start();
const interval = setInterval(() => {
if (project.next()) {
clearInterval(interval);
project.start();
project.next();
// clearInterval(interval);
}
}, 1000 / project.framesPerSeconds);
}

View File

@@ -2,6 +2,10 @@ import {Stage, StageConfig} from 'konva/lib/Stage';
import {Rect} from 'konva/lib/shapes/Rect';
import {Layer} from 'konva/lib/Layer';
import {Scene, SceneRunner} from './Scene';
import {Vector2d} from 'konva/lib/types';
import {Konva} from "konva/lib/Global";
Konva.autoDrawEnabled = false;
export enum ProjectSize {
FullHD,
@@ -13,11 +17,15 @@ const Sizes: Record<ProjectSize, [number, number]> = {
export class Project extends Stage {
public readonly background: Rect;
public readonly center: Vector2d;
public framesPerSeconds = 60;
public frame: number = 0;
private runner: Generator;
private scenes: Scene[] = [];
public constructor(
private runnerFactory: (project: Project) => Generator,
size: ProjectSize = ProjectSize.FullHD,
config: Partial<StageConfig> = {},
) {
@@ -28,6 +36,11 @@ export class Project extends Stage {
...config,
});
this.center = {
x: Sizes[size][0] / 2,
y: Sizes[size][1] / 2,
};
this.background = new Rect({
x: 0,
y: 0,
@@ -42,19 +55,23 @@ export class Project extends Stage {
}
public createScene(runner: SceneRunner) {
const layer = new Layer();
this.add(layer);
const scene = new Scene(this, new Layer(), runner);
this.add(scene.layer);
this.scenes.push(scene);
return new Scene(this, layer, runner);
return scene;
}
public setRunner(factory: (project: Project) => Generator) {
public start() {
this.scenes.forEach(scene => scene.layer.destroy());
this.scenes = [];
this.frame = 0;
this.runner = factory(this);
this.runner = this.runnerFactory(this);
}
public next(): boolean {
const result = this.runner.next();
this.draw();
this.frame++;
return result.done;

View File

@@ -7,8 +7,8 @@ export interface SceneRunner {
export class Scene {
public constructor(
private project: Project,
private layer: Layer,
public readonly project: Project,
public readonly layer: Layer,
private runner: SceneRunner,
) {}

2
src/animations/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './move';
export * from './show';

View File

@@ -1,6 +1,28 @@
import {Node} from "konva/lib/Node";
import {Vector2d} from "konva/lib/types";
import {Node} from 'konva/lib/Node';
import {Vector2d} from 'konva/lib/types';
import {tween} from '../tweening';
export function move(node: Node, positionTo: Vector2d) {
const positionFrom = node.position();
}
export function move(node: Node, position: Vector2d): Generator;
export function move(
node: Node,
positionX: number,
positionY: number,
): Generator;
export function move(
node: Node,
positionX: number | Vector2d,
positionY?: number,
): Generator {
const positionFrom = node.position();
const positionTo =
typeof positionX === 'number' ? {x: positionX, y: positionY} : positionX;
const distance = Math.sqrt(
Math.pow(positionFrom.x - positionTo.x, 2) +
Math.pow(positionFrom.y - positionTo.y, 2),
);
return tween(distance / 1000, value =>
node.position(value.vector2d(positionFrom, positionTo)),
);
}

View File

@@ -88,6 +88,7 @@ class LineSegment extends Segment {
class CircleSegment extends Segment {
private readonly length: number;
private readonly delta: number;
public constructor(
private center: Vector2d,
@@ -97,7 +98,10 @@ class CircleSegment extends Segment {
private counter: boolean,
) {
super();
this.length = Math.abs(deltaAngle * radius);
this.delta = this.counter
? this.deltaAngle
: this.deltaAngle + Math.PI * 2;
this.length = Math.abs(this.delta * radius);
}
get arcLength(): number {
@@ -110,12 +114,8 @@ class CircleSegment extends Segment {
to: number,
move: boolean,
): [Vector2d, Vector2d, Vector2d, Vector2d] {
const delta = this.counter
? this.deltaAngle
: this.deltaAngle + Math.PI * 2;
const startAngle = this.startAngle + delta * from;
const endAngle = this.startAngle + delta * to;
const startAngle = this.startAngle + this.delta * from;
const endAngle = this.startAngle + this.delta * to;
context.arc(
this.center.x,
@@ -160,14 +160,16 @@ class CircleSegment extends Segment {
}
public getOffset(from: number): number {
return this.counter ? 0 : -from * 1.045 * this.deltaAngle * this.radius / 2;
// May wanna go back to (-from * 1.045 * this.delta * this.radius) / 2
return 0;
}
}
export class Arrow extends Shape<ArrowConfig> {
protected dirty = true;
private segments: Segment[] = [];
private arcLength: number = 0;
private dirty = true;
_sceneFunc(context: Context) {
if (this.dirty) {

View File

@@ -0,0 +1,259 @@
import {Arrow, ArrowConfig} from './Arrow';
import {Node} from 'konva/lib/Node';
import {_registerNode} from 'konva/lib/Global';
import {Factory} from 'konva/lib/Factory';
import {GetSet, Vector2d} from 'konva/lib/types';
import {Direction} from '../types/Origin';
import {Context} from 'konva/lib/Context';
import {TimeTween} from '../tweening';
export interface ConnectionPoint {
node: Node;
direction: Direction;
offset: number;
vertical?: boolean;
}
export interface ConnectionConfig extends ArrowConfig {
from: ConnectionPoint;
to: ConnectionPoint;
crossing: Vector2d;
}
interface Measurement {
direction: -1 | 0 | 1;
range: [number, number];
rangeOffset: [number, number];
from: number;
to: number;
}
function clamp(value: number, min: number, max: number): number {
if (min > max) [min, max] = [max, min];
return value < min ? min : value > max ? max : value;
}
export class Connection extends Arrow {
constructor(config?: ConnectionConfig) {
super(config);
}
private measurePosition(
positionA: number,
sizeA: number,
positionB: number,
sizeB: number,
): Measurement {
let direction: -1 | 0 | 1;
let range: [number, number];
let rangeOffset: [number, number];
if (positionA - sizeA - 80 > positionB + sizeB) {
direction = -1;
range = [positionA - sizeA - 20, positionB + sizeB + 20];
rangeOffset = [range[0] - 20, range[1] + 20];
} else if (positionA + sizeA + 80 < positionB - sizeB) {
direction = 1;
range = [positionA + sizeA + 20, positionB - sizeB - 20];
rangeOffset = [range[0] + 20, range[1] - 20];
} else {
direction = 0;
range = [positionA - sizeA - 20, positionB - sizeB - 20];
rangeOffset = [range[0] - 20, range[1] - 20];
}
return {direction, range, rangeOffset, from: positionA, to: positionB};
}
private calculateCrossing(
crossing: number,
measurement: Measurement,
): number {
const fractionX = crossing >= 0 && crossing <= 1;
let clampedCrossing = crossing + measurement.from;
if (measurement.direction !== 0 && !fractionX) {
clampedCrossing = clamp(
clampedCrossing,
measurement.rangeOffset[0],
measurement.rangeOffset[1],
);
}
return measurement.direction === 0
? Math.min(
measurement.rangeOffset[0],
measurement.rangeOffset[1],
clampedCrossing,
)
: fractionX
? TimeTween.map(
measurement.rangeOffset[0],
measurement.rangeOffset[1],
crossing,
)
: clampedCrossing;
}
_sceneFunc(context: Context) {
if (this.dirty) {
if (!this.attrs.from || !this.attrs.to) {
this.attrs.points = [];
} else {
const from: ConnectionPoint = this.attrs.from;
const to: ConnectionPoint = this.attrs.to;
const fromPosition = from.node.absolutePosition();
const fromSize = from.node.size();
fromSize.width /= 2;
fromSize.height /= 2;
const toPosition = to.node.absolutePosition();
const toSize = to.node.size();
toSize.width /= 2;
toSize.height /= 2;
this.attrs.points = [];
const horizontal = this.measurePosition(
fromPosition.x,
fromSize.width,
toPosition.x,
toSize.width,
);
const vertical = this.measurePosition(
fromPosition.y,
fromSize.height,
toPosition.y,
toSize.height,
);
if (from.vertical) {
this.attrs.points.push(fromPosition.x, vertical.range[0]);
} else {
this.attrs.points.push(horizontal.range[0], fromPosition.y);
}
let toVertical = to.vertical;
if (
from.vertical !== toVertical &&
(horizontal.direction === 0 || vertical.direction === 0)
) {
toVertical = from.vertical;
}
let distance = 100;
if (from.vertical === toVertical) {
if (from.vertical) {
distance = Math.abs(toPosition.x - fromPosition.x);
if (distance > 0) {
const y = this.calculateCrossing(this.attrs.crossing.y, vertical);
this.attrs.points.push(fromPosition.x, y);
this.attrs.points.push(toPosition.x, y);
}
} else {
distance = Math.abs(toPosition.y - fromPosition.y);
if (distance > 0) {
const x = this.calculateCrossing(
this.attrs.crossing.x,
horizontal,
);
this.attrs.points.push(x, fromPosition.y);
this.attrs.points.push(x, toPosition.y);
}
}
} else {
if (from.vertical) {
this.attrs.points.push(fromPosition.x, toPosition.y);
} else {
this.attrs.points.push(toPosition.x, fromPosition.y);
}
}
if (toVertical) {
this.attrs.points.push(toPosition.x, vertical.range[1]);
} else {
this.attrs.points.push(horizontal.range[1], toPosition.y);
}
this.attrs.radius = Math.min(8, distance / 2);
}
}
super._sceneFunc(context);
}
private previousFromNode: Node | null = null;
public fromChanged() {
if (this.previousFromNode === this.attrs.from?.node) return;
this.markAsDirtyCallback ??= () => this.markAsDirty();
this.previousFromNode?.off(
'absoluteTransformChange',
this.markAsDirtyCallback,
);
this.previousFromNode = this.attrs.from?.node;
this.previousFromNode?.on(
'absoluteTransformChange',
this.markAsDirtyCallback,
);
this.markAsDirty();
}
private previousToNode: Node | null = null;
public toChanged() {
if (this.previousToNode === this.attrs.to?.node) return;
this.markAsDirtyCallback ??= () => this.markAsDirty();
this.previousToNode?.off(
'absoluteTransformChange',
this.markAsDirtyCallback,
);
this.previousToNode = this.attrs.to?.node;
this.previousToNode?.on(
'absoluteTransformChange',
this.markAsDirtyCallback,
);
this.markAsDirty();
}
private markAsDirtyCallback = () => {
this.markAsDirty();
};
from: GetSet<ConnectionPoint, this>;
to: GetSet<ConnectionPoint, this>;
crossing: GetSet<Vector2d, this>;
}
Connection.prototype.className = 'Connection';
Connection.prototype._attrsAffectingSize = ['from', 'to'];
_registerNode(Arrow);
Factory.addGetterSetter(
Connection,
'from',
{
node: null,
direction: Direction.Right,
offset: 0,
},
undefined,
Connection.prototype.fromChanged,
);
Factory.addGetterSetter(
Connection,
'to',
{
node: null,
direction: Direction.Right,
offset: 0,
},
undefined,
Connection.prototype.toChanged,
);
Factory.addGetterSetter(
Connection,
'crossing',
{x: 0, y: 0},
undefined,
Connection.prototype.markAsDirty,
);

View File

@@ -2,6 +2,7 @@ import {Group} from 'konva/lib/Group';
import {Rect} from 'konva/lib/shapes/Rect';
import {Text} from 'konva/lib/shapes/Text';
import {Vector2d} from 'konva/lib/types';
import {ContainerConfig} from 'konva/lib/Container';
import {Origin, Direction} from '../types/Origin';
import {tween} from '../tweening';
@@ -10,8 +11,8 @@ export class ObjectNode extends Group {
public readonly text: Text;
private _origin: Origin = Origin.Middle;
public constructor() {
super();
public constructor(config?: ContainerConfig) {
super(config);
this.box = new Rect({
x: 0,
@@ -82,7 +83,7 @@ export class ObjectNode extends Group {
const previousWidth = this.text.width();
this.text.width(null);
const width = this.text.getTextWidth();
const height = this.text.getTextHeight();
const height = this.text.height();
const boxWidth = Math.ceil((width + 80) / 20) * 20;
this.text.width(previousWidth);

22
src/components/Sprite.ts Normal file
View File

@@ -0,0 +1,22 @@
import {Shape, ShapeConfig} from 'konva/lib/Shape';
import {Context} from 'konva/lib/Context';
import {Util} from 'konva/lib/Util';
export interface SpriteConfig extends ShapeConfig {
image: string;
}
export class Sprite extends Shape {
private image: HTMLImageElement;
constructor(config?: SpriteConfig) {
super(config);
this.image = Util.createImageElement();
this.image.src = config?.image;
}
_sceneFunc(context: Context) {
context._context.imageSmoothingEnabled = false;
context.drawImage(this.image, -24 * 20, -24 * 20, 24 * 40, 24 * 40);
}
}

View File

@@ -1,2 +1,3 @@
export * from './Arrow';
export * from './Connection';
export * from './ObjectNode';

12
src/flow/any.ts Normal file
View File

@@ -0,0 +1,12 @@
export function* any(...sequences: Generator[]): Generator {
while (sequences.length > 0) {
for (let i = sequences.length - 1; i >= 0; i--) {
const result = sequences[i].next();
if (result.done) {
return;
}
}
yield;
}
}

View File

@@ -1,2 +1,4 @@
export * from './all';
export * from './any';
export * from './loop';
export * from './sequence';

5
src/flow/loop.ts Normal file
View File

@@ -0,0 +1,5 @@
export function* loop(iterations: number, factory: () => Generator) {
for (let i = 0; i < iterations; i++) {
yield* factory();
}
}

4
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module "*.png" {
const value: any;
export = value;
}

2
src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export {Sprite} from 'konva/lib/shapes/Sprite';
export {Util} from 'konva/lib/Util';

View File

@@ -70,6 +70,7 @@ function build(entry) {
let totalSize = 0;
const project = setup.default.default(createCanvas, Image);
project.start();
while (!project.next()) {
const name = String(project.frame).padStart(6, '0');
const content = project.toDataURL().replace(/^data:image\/png;base64,/, '');

View File

@@ -23,6 +23,17 @@ const compiler = webpack({
allowTsInNodeModules: true,
}
},
{
test: /\.(png|jpg|gif)$/i,
use: [
{
loader: 'url-loader',
options: {
limit: 8192,
},
},
],
},
],
},
resolve: {

View File

@@ -8,6 +8,12 @@
"target": "es6",
"declaration": true,
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"paths": {
"MC/*": [
"../src/*",
"../dist/*"
]
}
}
}