@elicdavis/node-flow 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mocharc.json +5 -0
- package/dist/index.d.mts +62 -1
- package/dist/index.d.ts +62 -1
- package/dist/index.js +3197 -3337
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3189 -3337
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -2
- package/src/graph.ts +7 -0
- package/src/index.ts +66 -40
- package/src/markdown/entry.ts +21 -2
- package/src/markdown/lexicalParser.ts +22 -32
- package/src/markdown/syntaxParser.ts +93 -68
- package/src/node.ts +61 -25
- package/src/nodes/subsystem.ts +7 -7
- package/src/organize.ts +276 -181
- package/src/port.ts +130 -7
- package/src/styles/text.ts +8 -0
- package/src/types/text.ts +6 -4
- package/src/utils/string.ts +7 -1
- package/src/widgets/color.ts +5 -6
- package/src/widgets/factory.ts +1 -1
- package/src/widgets/number.ts +2 -2
- package/src/widgets/slider.ts +2 -2
- package/src/widgets/string.ts +2 -2
- package/src/widgets/toggle.ts +2 -2
- package/src/widgets/widget.ts +22 -0
- package/test/markdown.test.ts +165 -0
- package/test/organize.test.ts +156 -0
- package/test/port.test.ts +125 -0
- package/tsconfig.json +6 -2
- package/dist/web/NodeFlow.js +0 -8
- package/dist/web/NodeFlow.js.map +0 -7
package/src/styles/text.ts
CHANGED
|
@@ -65,6 +65,14 @@ export class TextStyle {
|
|
|
65
65
|
return this.#font;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
getWeight(): FontWeight {
|
|
69
|
+
return this.#weight;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getStyle(): FontStyle {
|
|
73
|
+
return this.#fontStyle;
|
|
74
|
+
}
|
|
75
|
+
|
|
68
76
|
measure(ctx: CanvasRenderingContext2D, scale: number, text: string, out: Vector2): void {
|
|
69
77
|
this.setupStyle(ctx, scale);
|
|
70
78
|
const measurements = ctx.measureText(text)
|
package/src/types/text.ts
CHANGED
|
@@ -32,10 +32,12 @@ export class Text {
|
|
|
32
32
|
this.#lineSpacing = config?.LineSpacing ? config?.LineSpacing : 5;
|
|
33
33
|
this.#textToRender = [value];
|
|
34
34
|
|
|
35
|
-
if (
|
|
36
|
-
document.fonts.
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
if (typeof document !== "undefined" && document.fonts !== undefined) {
|
|
36
|
+
if (!document.fonts.check(`16px "${this.#style.getFont()}"`)) {
|
|
37
|
+
document.fonts.addEventListener("loadingdone", () => {
|
|
38
|
+
this.#measured = false;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
|
package/src/utils/string.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
interface BinarySearch {
|
|
2
|
+
max: number;
|
|
3
|
+
getValue: (guess: number) => number;
|
|
4
|
+
match: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const binarySearch = ({ max, getValue, match }: BinarySearch): number => {
|
|
2
8
|
let min = 0;
|
|
3
9
|
|
|
4
10
|
while (min <= max) {
|
package/src/widgets/color.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { HexToColor } from "../utils/color";
|
|
|
2
2
|
import { TextStyleConfig } from "../styles/text";
|
|
3
3
|
import { Box } from "../types/box";
|
|
4
4
|
import { CopyVector2, Vector2 } from "../types/vector2";
|
|
5
|
-
import { height, width } from "./widget";
|
|
5
|
+
import { height, startingWidgetValue, width } from "./widget";
|
|
6
6
|
import { Popup } from "../popup";
|
|
7
7
|
import { TextBoxStyle } from '../styles/textBox';
|
|
8
8
|
import { Theme } from "../theme";
|
|
@@ -46,12 +46,12 @@ export class ColorWidget {
|
|
|
46
46
|
#callback?: (newColor: string) => void;
|
|
47
47
|
|
|
48
48
|
constructor(node: FlowNode, config?: ColorWidgetConfig) {
|
|
49
|
-
this.#value = "#000000"
|
|
50
|
-
this.#contrast = contrastColor(this.#value);
|
|
51
|
-
this.Set(config?.value === undefined ? "#000000" : config?.value);
|
|
52
|
-
|
|
53
49
|
this.#node = node;
|
|
54
50
|
this.#nodeProperty = config?.property;
|
|
51
|
+
this.#callback = config?.callback;
|
|
52
|
+
|
|
53
|
+
this.#value = startingWidgetValue(node, this.#nodeProperty, config?.value, "#000000");
|
|
54
|
+
this.#contrast = contrastColor(this.#value);
|
|
55
55
|
this.#textBoxStyle = new TextBoxStyle({
|
|
56
56
|
box: {
|
|
57
57
|
color: this.#value,
|
|
@@ -62,7 +62,6 @@ export class ColorWidget {
|
|
|
62
62
|
},
|
|
63
63
|
text: config?.textStyle
|
|
64
64
|
});
|
|
65
|
-
this.#callback = config?.callback;
|
|
66
65
|
|
|
67
66
|
if (this.#nodeProperty !== undefined) {
|
|
68
67
|
this.#node.addPropertyChangeListener(this.#nodeProperty, (oldVal, newVal) => {
|
package/src/widgets/factory.ts
CHANGED
package/src/widgets/number.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Popup } from "../popup";
|
|
|
3
3
|
import { TextBoxStyle, TextBoxStyleConfig, TextBoxStyleWithFallback } from "../styles/textBox";
|
|
4
4
|
import { Box, InBox } from '../types/box';
|
|
5
5
|
import { CopyVector2, Vector2 } from "../types/vector2";
|
|
6
|
-
import { height, width } from "./widget";
|
|
6
|
+
import { height, startingWidgetValue, width } from "./widget";
|
|
7
7
|
import { FlowNode } from "../node";
|
|
8
8
|
|
|
9
9
|
export interface NumberWidgetConfig {
|
|
@@ -62,8 +62,8 @@ export class NumberWidget {
|
|
|
62
62
|
},
|
|
63
63
|
text: { color: Theme.Widget.FontColor },
|
|
64
64
|
}));
|
|
65
|
-
this.Set(config?.value === undefined ? 0 : config?.value);
|
|
66
65
|
this.#callback = config?.callback;
|
|
66
|
+
this.Set(startingWidgetValue(node, this.#nodeProperty, config?.value, 0));
|
|
67
67
|
|
|
68
68
|
if (this.#nodeProperty !== undefined) {
|
|
69
69
|
this.#node.addPropertyChangeListener(this.#nodeProperty, (oldVal, newVal) => {
|
package/src/widgets/slider.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { TextStyle, TextStyleConfig, TextStyleFallback } from "../styles/text";
|
|
|
4
4
|
import { Box } from '../types/box';
|
|
5
5
|
import { CopyVector2, Vector2, Zero } from "../types/vector2";
|
|
6
6
|
import { Clamp, Clamp01 } from "../utils/math";
|
|
7
|
-
import { height, width } from "./widget";
|
|
7
|
+
import { height, startingWidgetValue, width } from "./widget";
|
|
8
8
|
import { TextBaseline } from "../styles/canvasTextBaseline";
|
|
9
9
|
import { FlowNode } from "../node";
|
|
10
10
|
|
|
@@ -104,7 +104,7 @@ export class SliderWidget {
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
this.SetValue(config?.value
|
|
107
|
+
this.SetValue(startingWidgetValue(node, this.#nodeProperty, config?.value, 0));
|
|
108
108
|
|
|
109
109
|
// Setup change callback after we set the initial value to prevent the callback from being
|
|
110
110
|
this.#change = config?.change;
|
package/src/widgets/string.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { TextBoxStyle, TextBoxStyleConfig, TextBoxStyleWithFallback } from "../s
|
|
|
3
3
|
import { Box, InBox } from "../types/box";
|
|
4
4
|
import { CopyVector2, Vector2 } from "../types/vector2";
|
|
5
5
|
import { fitString } from "../utils/string";
|
|
6
|
-
import { height, width } from "./widget";
|
|
6
|
+
import { height, startingWidgetValue, width } from "./widget";
|
|
7
7
|
import { FlowNode } from '../node';
|
|
8
8
|
import { SetStringPopup } from "../popups/string";
|
|
9
9
|
|
|
@@ -60,8 +60,8 @@ export class StringWidget {
|
|
|
60
60
|
text: { color: Theme.Widget.FontColor },
|
|
61
61
|
}));
|
|
62
62
|
|
|
63
|
-
this.Set(config?.value === undefined ? "" : config?.value);
|
|
64
63
|
this.#callback = config?.callback;
|
|
64
|
+
this.Set(startingWidgetValue(node, this.#nodeProperty, config?.value, ""));
|
|
65
65
|
if (this.#nodeProperty !== undefined) {
|
|
66
66
|
this.#node.addPropertyChangeListener(this.#nodeProperty, (oldVal, newVal) => {
|
|
67
67
|
this.Set(newVal);
|
package/src/widgets/toggle.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Theme } from "../theme";
|
|
2
2
|
import { Box, InBox } from "../types/box";
|
|
3
3
|
import { CopyVector2, Vector2 } from "../types/vector2";
|
|
4
|
-
import { height, width } from "./widget";
|
|
4
|
+
import { height, startingWidgetValue, width } from "./widget";
|
|
5
5
|
import { TextBoxStyle, TextBoxStyleConfig, TextBoxStyleWithFallback } from "../styles/textBox";
|
|
6
6
|
import { FlowNode } from "../node";
|
|
7
7
|
|
|
@@ -159,8 +159,8 @@ export class ToggleWidget {
|
|
|
159
159
|
lightColor: config?.disabledStyle?.lightColor === undefined ? "#004400" : config?.enabledStyle?.lightColor,
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
-
this.Set(config?.value === undefined ? false : config?.value);
|
|
163
162
|
this.#callback = config?.callback;
|
|
163
|
+
this.Set(startingWidgetValue(node, this.#nodeProperty, config?.value, false));
|
|
164
164
|
if (this.#nodeProperty !== undefined) {
|
|
165
165
|
this.#node.addPropertyChangeListener(this.#nodeProperty, (oldVal, newVal) => {
|
|
166
166
|
this.Set(newVal);
|
package/src/widgets/widget.ts
CHANGED
|
@@ -9,4 +9,26 @@ export interface Widget {
|
|
|
9
9
|
Draw(ctx: CanvasRenderingContext2D, position: Vector2, scale: number, mousePosition: Vector2 | undefined): Box
|
|
10
10
|
ClickStart(): void
|
|
11
11
|
ClickEnd(): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface PropertyReader {
|
|
15
|
+
getProperty(name: string): any;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function startingWidgetValue<T>(
|
|
19
|
+
node: PropertyReader,
|
|
20
|
+
property: string | undefined,
|
|
21
|
+
configured: T | undefined,
|
|
22
|
+
fallback: T,
|
|
23
|
+
): T {
|
|
24
|
+
if (configured !== undefined) {
|
|
25
|
+
return configured;
|
|
26
|
+
}
|
|
27
|
+
if (property !== undefined && property !== null) {
|
|
28
|
+
const existing = node.getProperty(property);
|
|
29
|
+
if (existing !== undefined && existing !== null) {
|
|
30
|
+
return existing as T;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return fallback;
|
|
12
34
|
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import * as assert from 'assert';
|
|
2
|
+
import { BuildMarkdown } from '../src/markdown/markdown';
|
|
3
|
+
import { BasicMarkdownEntry, CodeBlockEntry, UnorderedListMarkdownEntry } from '../src/markdown/entry';
|
|
4
|
+
import { FontStyle, FontWeight } from '../src/styles/text';
|
|
5
|
+
import { Text } from '../src/types/text';
|
|
6
|
+
|
|
7
|
+
/** The runs of text in an entry, as [content, weight, style] triples. */
|
|
8
|
+
function runs(entry: any): Array<[string, FontWeight, FontStyle]> {
|
|
9
|
+
let lines: ReadonlyArray<Text>;
|
|
10
|
+
if (entry.entries) {
|
|
11
|
+
lines = entry.entries().flatMap((item: any) => item.lines());
|
|
12
|
+
} else if (entry.lines) {
|
|
13
|
+
lines = entry.lines();
|
|
14
|
+
} else {
|
|
15
|
+
lines = [entry.text()];
|
|
16
|
+
}
|
|
17
|
+
return lines.map(line => [
|
|
18
|
+
line.get(),
|
|
19
|
+
line.style().getWeight(),
|
|
20
|
+
line.style().getStyle(),
|
|
21
|
+
] as [string, FontWeight, FontStyle]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function contentOf(entry: any): string {
|
|
25
|
+
return runs(entry).map(r => r[0]).join('');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('markdown: paragraphs and headers', () => {
|
|
29
|
+
it('reads a plain line', () => {
|
|
30
|
+
const entries = BuildMarkdown('hello world');
|
|
31
|
+
assert.strictEqual(entries.length, 1);
|
|
32
|
+
assert.strictEqual(contentOf(entries[0]), 'hello world');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('reads each header level', () => {
|
|
36
|
+
const entries = BuildMarkdown('# one\n## two\n### three');
|
|
37
|
+
assert.strictEqual(entries.length, 3);
|
|
38
|
+
assert.strictEqual(contentOf(entries[0]), 'one');
|
|
39
|
+
assert.strictEqual(contentOf(entries[1]), 'two');
|
|
40
|
+
assert.strictEqual(contentOf(entries[2]), 'three');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('keeps a # that is not at the start of a line as text', () => {
|
|
44
|
+
const entries = BuildMarkdown('issue #42 filed');
|
|
45
|
+
assert.strictEqual(contentOf(entries[0]), 'issue #42 filed');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('markdown: emphasis', () => {
|
|
50
|
+
it('italicises a single-starred run', () => {
|
|
51
|
+
const entries = BuildMarkdown('*hello*');
|
|
52
|
+
assert.deepStrictEqual(runs(entries[0]), [['hello', FontWeight.Normal, FontStyle.Italic]]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('bolds a double-starred run', () => {
|
|
56
|
+
const entries = BuildMarkdown('**hello**');
|
|
57
|
+
assert.deepStrictEqual(runs(entries[0]), [['hello', FontWeight.Bold, FontStyle.Normal]]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('emphasises a run in the middle of a line', () => {
|
|
61
|
+
const entries = BuildMarkdown('a *b* c');
|
|
62
|
+
assert.deepStrictEqual(runs(entries[0]), [
|
|
63
|
+
['a ', FontWeight.Normal, FontStyle.Normal],
|
|
64
|
+
['b', FontWeight.Normal, FontStyle.Italic],
|
|
65
|
+
[' c', FontWeight.Normal, FontStyle.Normal],
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('leaves a star surrounded by spaces alone', () => {
|
|
70
|
+
const entries = BuildMarkdown('2 * 3 * 4');
|
|
71
|
+
assert.strictEqual(contentOf(entries[0]), '2 * 3 * 4');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('keeps stars that never matched as text', () => {
|
|
75
|
+
assert.strictEqual(contentOf(BuildMarkdown('*unclosed')[0]), '*unclosed');
|
|
76
|
+
assert.strictEqual(contentOf(BuildMarkdown('a * b')[0]), 'a * b');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('keeps the stars left over when the runs are uneven', () => {
|
|
80
|
+
// Two opened, one closed: the extra opener is just a character.
|
|
81
|
+
assert.deepStrictEqual(runs(BuildMarkdown('**a*')[0]), [
|
|
82
|
+
['*', FontWeight.Normal, FontStyle.Normal],
|
|
83
|
+
['a', FontWeight.Normal, FontStyle.Italic],
|
|
84
|
+
]);
|
|
85
|
+
assert.deepStrictEqual(runs(BuildMarkdown('*a**')[0]), [
|
|
86
|
+
['a', FontWeight.Normal, FontStyle.Italic],
|
|
87
|
+
['*', FontWeight.Normal, FontStyle.Normal],
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('does not emphasise across a space held against the stars', () => {
|
|
92
|
+
assert.strictEqual(contentOf(BuildMarkdown('** a **')[0]), '** a **');
|
|
93
|
+
assert.strictEqual(contentOf(BuildMarkdown('**a **')[0]), '**a **');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('emphasises a run holding a space in the middle', () => {
|
|
97
|
+
assert.deepStrictEqual(runs(BuildMarkdown('**a f**')[0]), [
|
|
98
|
+
['a f', FontWeight.Bold, FontStyle.Normal],
|
|
99
|
+
]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('never produces an empty run', () => {
|
|
103
|
+
for (const source of ['*a*', '**a**', '*a**', '**a*', '* a *', 'a `b` c']) {
|
|
104
|
+
for (const [content] of runs(BuildMarkdown(source)[0])) {
|
|
105
|
+
assert.notStrictEqual(content, '', `${source} produced an empty run`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('emphasises more than one run on a line', () => {
|
|
111
|
+
const entries = BuildMarkdown('*a* and *b*');
|
|
112
|
+
assert.deepStrictEqual(runs(entries[0]), [
|
|
113
|
+
['a', FontWeight.Normal, FontStyle.Italic],
|
|
114
|
+
[' and ', FontWeight.Normal, FontStyle.Normal],
|
|
115
|
+
['b', FontWeight.Normal, FontStyle.Italic],
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('markdown: lists', () => {
|
|
121
|
+
it('collects consecutive star lines into one list', () => {
|
|
122
|
+
const entries = BuildMarkdown('* one\n* two\n* three');
|
|
123
|
+
assert.strictEqual(entries.length, 1);
|
|
124
|
+
assert.ok(entries[0] instanceof UnorderedListMarkdownEntry);
|
|
125
|
+
assert.strictEqual((entries[0] as any).entries().length, 3);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('ends the list at the first line that is not one', () => {
|
|
129
|
+
const entries = BuildMarkdown('* one\n* two\nafter');
|
|
130
|
+
assert.strictEqual(entries.length, 2);
|
|
131
|
+
assert.ok(entries[0] instanceof UnorderedListMarkdownEntry);
|
|
132
|
+
assert.strictEqual(contentOf(entries[1]), 'after');
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('markdown: code', () => {
|
|
137
|
+
it('sets inline code apart without keeping the backticks', () => {
|
|
138
|
+
const entry = BuildMarkdown('a `b` c')[0] as any;
|
|
139
|
+
assert.deepStrictEqual(runs(entry).map(r => r[0]), ['a ', 'b', ' c']);
|
|
140
|
+
assert.strictEqual(entry.lines()[1].style().getFont(), 'monospace');
|
|
141
|
+
assert.notStrictEqual(entry.lines()[0].style().getFont(), 'monospace');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('keeps a lone backtick as text', () => {
|
|
145
|
+
assert.strictEqual(contentOf(BuildMarkdown('a ` b')[0]), 'a ` b');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('keeps a fenced block verbatim', () => {
|
|
149
|
+
const entries = BuildMarkdown('```\nlet a = *b*;\n```');
|
|
150
|
+
assert.strictEqual(entries.length, 1);
|
|
151
|
+
assert.ok(entries[0] instanceof CodeBlockEntry, `got ${entries[0].constructor.name}`);
|
|
152
|
+
assert.strictEqual(contentOf(entries[0]), 'let a = *b*;\n');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('keeps more than one line of a fenced block', () => {
|
|
156
|
+
const entries = BuildMarkdown('```\none\ntwo\n```');
|
|
157
|
+
assert.strictEqual(contentOf(entries[0]), 'one\ntwo\n');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('carries on after a fenced block', () => {
|
|
161
|
+
const entries = BuildMarkdown('```\ncode\n```\nafter');
|
|
162
|
+
assert.strictEqual(entries.length, 2);
|
|
163
|
+
assert.strictEqual(contentOf(entries[1]), 'after');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import * as assert from 'assert';
|
|
2
|
+
import { assignLayers, orderWithinLayers, topologicalOrder } from '../src/organize';
|
|
3
|
+
|
|
4
|
+
/** Builds the upstream/downstream maps Organize works from. */
|
|
5
|
+
function graph(edges: Array<[number, number]>, nodes: Array<number>) {
|
|
6
|
+
const upstream = new Map<number, Array<number>>();
|
|
7
|
+
const downstream = new Map<number, Array<number>>();
|
|
8
|
+
for (const node of nodes) {
|
|
9
|
+
upstream.set(node, []);
|
|
10
|
+
downstream.set(node, []);
|
|
11
|
+
}
|
|
12
|
+
for (const [from, to] of edges) {
|
|
13
|
+
(upstream.get(to) as Array<number>).push(from);
|
|
14
|
+
(downstream.get(from) as Array<number>).push(to);
|
|
15
|
+
}
|
|
16
|
+
return { upstream, downstream, nodes };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('assignLayers', () => {
|
|
20
|
+
it('puts a chain in one node per layer, in order', () => {
|
|
21
|
+
const { upstream } = graph([[0, 1], [1, 2], [2, 3]], [0, 1, 2, 3]);
|
|
22
|
+
const layout = assignLayers([0, 1, 2, 3], upstream);
|
|
23
|
+
|
|
24
|
+
assert.deepStrictEqual(layout.layers, [[0], [1], [2], [3]]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('puts a node to the right of the furthest thing feeding it, not the nearest', () => {
|
|
28
|
+
// 0 feeds both 1 and a longer path 2 -> 3, and 4 consumes 1 and 3.
|
|
29
|
+
// Taking the nearest feeder would put 4 beside 3 instead of after it.
|
|
30
|
+
const { upstream } = graph([[0, 1], [0, 2], [2, 3], [1, 4], [3, 4]], [0, 1, 2, 3, 4]);
|
|
31
|
+
const layout = assignLayers([0, 1, 2, 3, 4], upstream);
|
|
32
|
+
|
|
33
|
+
assert.strictEqual(layout.layerOf.get(0), 0);
|
|
34
|
+
assert.strictEqual(layout.layerOf.get(3), 2);
|
|
35
|
+
assert.strictEqual(layout.layerOf.get(4), 3, 'the join waits for the long branch');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('never puts a node left of something it consumes', () => {
|
|
39
|
+
const edges: Array<[number, number]> = [[0, 2], [1, 2], [2, 3], [1, 3], [3, 4], [0, 4]];
|
|
40
|
+
const { upstream } = graph(edges, [0, 1, 2, 3, 4]);
|
|
41
|
+
const layout = assignLayers([0, 1, 2, 3, 4], upstream);
|
|
42
|
+
|
|
43
|
+
for (const [from, to] of edges) {
|
|
44
|
+
assert.ok(
|
|
45
|
+
(layout.layerOf.get(from) as number) < (layout.layerOf.get(to) as number),
|
|
46
|
+
`${from} -> ${to} points backwards`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('terminates on a cycle instead of hanging', () => {
|
|
52
|
+
const { upstream } = graph([[0, 1], [1, 2], [2, 0], [2, 3]], [0, 1, 2, 3]);
|
|
53
|
+
const layout = assignLayers([0, 1, 2, 3], upstream);
|
|
54
|
+
|
|
55
|
+
assert.strictEqual(layout.layerOf.size, 4);
|
|
56
|
+
// The cycle is spread across layers rather than collapsed into one.
|
|
57
|
+
assert.ok(layout.layers.length > 1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('starts every disconnected node at the first layer', () => {
|
|
61
|
+
const { upstream } = graph([], [0, 1, 2]);
|
|
62
|
+
const layout = assignLayers([0, 1, 2], upstream);
|
|
63
|
+
|
|
64
|
+
assert.deepStrictEqual(layout.layers.length, 1);
|
|
65
|
+
assert.deepStrictEqual(layout.layers[0].sort(), [0, 1, 2]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('handles a graph deep enough to overflow a recursive walk', () => {
|
|
69
|
+
const edges: Array<[number, number]> = [];
|
|
70
|
+
const nodes: Array<number> = [];
|
|
71
|
+
for (let i = 0; i < 20000; i++) {
|
|
72
|
+
nodes.push(i);
|
|
73
|
+
if (i > 0) {
|
|
74
|
+
edges.push([i - 1, i]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const { upstream } = graph(edges, nodes);
|
|
78
|
+
const layout = assignLayers(nodes, upstream);
|
|
79
|
+
|
|
80
|
+
assert.strictEqual(layout.layers.length, 20000);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('orderWithinLayers', () => {
|
|
85
|
+
/** Counts pairs of edges between two layers that cross each other. */
|
|
86
|
+
function crossings(layout: ReturnType<typeof assignLayers>, edges: Array<[number, number]>): number {
|
|
87
|
+
const slot = new Map<number, number>();
|
|
88
|
+
for (const layer of layout.layers) {
|
|
89
|
+
layer.forEach((node, index) => slot.set(node, index));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let total = 0;
|
|
93
|
+
for (let i = 0; i < edges.length; i++) {
|
|
94
|
+
for (let j = i + 1; j < edges.length; j++) {
|
|
95
|
+
const [a1, a2] = edges[i];
|
|
96
|
+
const [b1, b2] = edges[j];
|
|
97
|
+
if (layout.layerOf.get(a1) !== layout.layerOf.get(b1)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (layout.layerOf.get(a2) !== layout.layerOf.get(b2)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const left = (slot.get(a1) as number) - (slot.get(b1) as number);
|
|
104
|
+
const right = (slot.get(a2) as number) - (slot.get(b2) as number);
|
|
105
|
+
if (left * right < 0) {
|
|
106
|
+
total++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return total;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
it('untangles edges that start out crossed', () => {
|
|
114
|
+
// Three sources feeding three sinks in reverse order: laid out in
|
|
115
|
+
// declaration order every edge crosses every other.
|
|
116
|
+
const edges: Array<[number, number]> = [[0, 5], [1, 4], [2, 3]];
|
|
117
|
+
const nodes = [0, 1, 2, 3, 4, 5];
|
|
118
|
+
const { upstream, downstream } = graph(edges, nodes);
|
|
119
|
+
|
|
120
|
+
const layout = assignLayers(nodes, upstream);
|
|
121
|
+
const before = crossings(layout, edges);
|
|
122
|
+
orderWithinLayers(layout, upstream, downstream);
|
|
123
|
+
const after = crossings(layout, edges);
|
|
124
|
+
|
|
125
|
+
assert.ok(before > 0, 'the fixture should start tangled');
|
|
126
|
+
assert.strictEqual(after, 0, `expected no crossings, got ${after}`);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('keeps every node exactly once', () => {
|
|
130
|
+
const nodes = [0, 1, 2, 3, 4, 5];
|
|
131
|
+
const { upstream, downstream } = graph([[0, 3], [1, 3], [2, 4], [3, 5], [4, 5]], nodes);
|
|
132
|
+
|
|
133
|
+
const layout = assignLayers(nodes, upstream);
|
|
134
|
+
orderWithinLayers(layout, upstream, downstream);
|
|
135
|
+
|
|
136
|
+
const seen = layout.layers.flat().sort((a, b) => a - b);
|
|
137
|
+
assert.deepStrictEqual(seen, nodes);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('topologicalOrder', () => {
|
|
142
|
+
it('lists every node once', () => {
|
|
143
|
+
const { upstream } = graph([[0, 1], [1, 2], [0, 2]], [0, 1, 2]);
|
|
144
|
+
const order = topologicalOrder([0, 1, 2], upstream);
|
|
145
|
+
|
|
146
|
+
assert.deepStrictEqual(order.slice().sort(), [0, 1, 2]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('puts feeders before the nodes that consume them', () => {
|
|
150
|
+
const { upstream } = graph([[0, 1], [1, 2]], [0, 1, 2]);
|
|
151
|
+
const order = topologicalOrder([0, 1, 2], upstream);
|
|
152
|
+
|
|
153
|
+
assert.ok(order.indexOf(0) < order.indexOf(1));
|
|
154
|
+
assert.ok(order.indexOf(1) < order.indexOf(2));
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import * as assert from 'assert';
|
|
2
|
+
import { Port, PortConfig, PortType, portsCompatible } from '../src/port';
|
|
3
|
+
import { FlowNode } from '../src/node';
|
|
4
|
+
|
|
5
|
+
function port(config: PortConfig): Port {
|
|
6
|
+
// A port only reaches back to its node when it is drawn or connected,
|
|
7
|
+
// neither of which the type rules touch.
|
|
8
|
+
return new Port(undefined as unknown as FlowNode, PortType.Output, config);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('portsCompatible', () => {
|
|
12
|
+
it('joins two ports declaring the same type', () => {
|
|
13
|
+
assert.ok(portsCompatible(port({ type: 'mesh' }), port({ type: 'mesh' })));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('refuses two ports declaring different types', () => {
|
|
17
|
+
assert.ok(!portsCompatible(port({ type: 'mesh' }), port({ type: 'float64' })));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('joins an any-type port to anything, from either side', () => {
|
|
21
|
+
const anything = port({ type: 'T', anyType: true });
|
|
22
|
+
assert.ok(portsCompatible(anything, port({ type: 'mesh' })));
|
|
23
|
+
assert.ok(portsCompatible(port({ type: 'mesh' }), anything));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('joins two any-type ports', () => {
|
|
27
|
+
assert.ok(portsCompatible(
|
|
28
|
+
port({ type: 'T', anyType: true }),
|
|
29
|
+
port({ type: '[]T', anyType: true }),
|
|
30
|
+
));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('still refuses a port that is missing', () => {
|
|
34
|
+
assert.ok(!portsCompatible(port({ type: 'T', anyType: true }), undefined));
|
|
35
|
+
assert.ok(!portsCompatible(null, port({ type: 'mesh' })));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('does not treat an untyped port as accepting anything', () => {
|
|
39
|
+
assert.ok(!portsCompatible(port({}), port({ type: 'mesh' })));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('shows a type of its own before falling back to "any"', () => {
|
|
43
|
+
assert.strictEqual(port({ type: 'T', anyType: true }).getDataType(), 'T');
|
|
44
|
+
assert.ok(port({ type: 'T', anyType: true }).acceptsAnyType());
|
|
45
|
+
assert.ok(!port({ type: 'T' }).acceptsAnyType());
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('a port that takes more than one type', () => {
|
|
50
|
+
it('joins a port declaring either of them', () => {
|
|
51
|
+
const lifted = port({ type: 'float64', acceptedTypes: ['float64', '[]float64'] });
|
|
52
|
+
|
|
53
|
+
assert.ok(portsCompatible(lifted, port({ type: 'float64' })));
|
|
54
|
+
assert.ok(portsCompatible(lifted, port({ type: '[]float64' })));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('refuses a type outside its set', () => {
|
|
58
|
+
const lifted = port({ type: 'float64', acceptedTypes: ['float64', '[]float64'] });
|
|
59
|
+
|
|
60
|
+
assert.ok(!portsCompatible(lifted, port({ type: 'string' })),
|
|
61
|
+
'this is the whole point: the drag is refused rather than accepted then errored');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('joins another multi-type port when the sets overlap', () => {
|
|
65
|
+
const a = port({ type: 'float64', acceptedTypes: ['float64', '[]float64'] });
|
|
66
|
+
const b = port({ type: 'int', acceptedTypes: ['int', '[]float64'] });
|
|
67
|
+
const c = port({ type: 'int', acceptedTypes: ['int', '[]int'] });
|
|
68
|
+
|
|
69
|
+
assert.ok(portsCompatible(a, b));
|
|
70
|
+
assert.ok(!portsCompatible(a, c));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('still lets an any-type port through', () => {
|
|
74
|
+
const lifted = port({ type: 'float64', acceptedTypes: ['float64', '[]float64'] });
|
|
75
|
+
assert.ok(portsCompatible(lifted, port({ type: 'T', anyType: true })));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('narrows to one type once it settles', () => {
|
|
79
|
+
const lifted = port({ type: 'float64', acceptedTypes: ['float64', '[]float64'] });
|
|
80
|
+
lifted.settleOn('[]float64');
|
|
81
|
+
|
|
82
|
+
assert.strictEqual(lifted.getDataType(), '[]float64');
|
|
83
|
+
assert.deepStrictEqual(lifted.acceptedTypes(), ['[]float64']);
|
|
84
|
+
assert.ok(portsCompatible(lifted, port({ type: '[]float64' })));
|
|
85
|
+
assert.ok(!portsCompatible(lifted, port({ type: 'float64' })));
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('settling a port on a type', () => {
|
|
90
|
+
it('enforces the new type once it stops accepting anything', () => {
|
|
91
|
+
const settled = port({ type: 'T', anyType: true });
|
|
92
|
+
settled.setDataType('mesh');
|
|
93
|
+
settled.setAnyType(false);
|
|
94
|
+
|
|
95
|
+
assert.strictEqual(settled.getDataType(), 'mesh');
|
|
96
|
+
assert.ok(portsCompatible(settled, port({ type: 'mesh' })));
|
|
97
|
+
assert.ok(!portsCompatible(settled, port({ type: 'float64' })),
|
|
98
|
+
'the whole point: a settled port refuses the drag the Go side would reject');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('recolors a port whose fill came from its type', () => {
|
|
102
|
+
const settled = port({ type: 'T', anyType: true });
|
|
103
|
+
const before = settled.filledStyleColor();
|
|
104
|
+
settled.setDataType('mesh');
|
|
105
|
+
|
|
106
|
+
assert.notStrictEqual(settled.filledStyleColor(), before);
|
|
107
|
+
assert.strictEqual(settled.filledStyleColor(), port({ type: 'mesh' }).filledStyleColor(),
|
|
108
|
+
'and lands on the same color a port declaring that type gets');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('leaves a fill the caller picked alone', () => {
|
|
112
|
+
const settled = port({ type: 'T', anyType: true, filledStyle: { fillColor: '#abcdef' } });
|
|
113
|
+
settled.setDataType('mesh');
|
|
114
|
+
|
|
115
|
+
assert.strictEqual(settled.filledStyleColor(), '#abcdef');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('can be handed back to accepting anything', () => {
|
|
119
|
+
const settled = port({ type: 'T', anyType: true });
|
|
120
|
+
settled.setAnyType(false);
|
|
121
|
+
settled.setAnyType(true);
|
|
122
|
+
|
|
123
|
+
assert.ok(portsCompatible(settled, port({ type: 'float64' })));
|
|
124
|
+
});
|
|
125
|
+
});
|
package/tsconfig.json
CHANGED
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
"sourceMap": true, // Generate source maps (optional)
|
|
5
5
|
"strict": true, // Enable strict type checking (optional)
|
|
6
6
|
"outDir": "./dist/js",
|
|
7
|
-
|
|
7
|
+
// The source already uses #private class fields, which are ES2022,
|
|
8
|
+
// and the bundlers emit esnext. ES2015 only made tsc downlevel
|
|
9
|
+
// those fields to WeakMaps in an output nothing ships.
|
|
10
|
+
"target": "ES2022",
|
|
8
11
|
},
|
|
9
12
|
"include": [
|
|
10
|
-
"src/**/*.ts"
|
|
13
|
+
"src/**/*.ts",
|
|
14
|
+
"test/**/*.ts"
|
|
11
15
|
],
|
|
12
16
|
"exclude": [
|
|
13
17
|
"node_modules"
|