reladraw 0.0.1 → 0.2.0

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.
@@ -0,0 +1,189 @@
1
+ import { SourceError } from './errors.js';
2
+ /** Inside a box, between its border and its contents. */
3
+ export const PAD = 14;
4
+ /** Between stacked children of one container. */
5
+ export const CHILD_GAP = 10;
6
+ /** Between a container's own label and its first child. */
7
+ export const HEADER_GAP = 10;
8
+ /**
9
+ * How far each deck copy is offset behind the front face. It has to clear a
10
+ * whole line of text plus the padding above it, or a copy's label is drawn and
11
+ * then immediately covered by the copy in front of it.
12
+ */
13
+ export const DECK_STEP = 34;
14
+ /**
15
+ * The named gaps a placement may ask for, each a *minimum* distance rather than a
16
+ * fixed one. Anything the author puts between two things widens the space
17
+ * between them on its own, so no gap here ever has to be chosen large enough
18
+ * to leave room for something else.
19
+ */
20
+ export const GAPS = {
21
+ // A zero gap turns an offset into edge-to-edge contact, so "my top edge
22
+ // against Docker's bottom edge" needs no vocabulary of its own.
23
+ none: 0,
24
+ tight: 24,
25
+ normal: 56,
26
+ wide: 110,
27
+ };
28
+ /**
29
+ * How far apart two boxes are pushed when they would otherwise overlap. Small
30
+ * on purpose: this is the tool enforcing something the author did not write, so
31
+ * the space it leaves should read as "these are not the same box" and never as
32
+ * a relationship someone stated. Say `gap:` if you want breathing room.
33
+ */
34
+ export const SEPARATION_GAP = GAPS['tight'];
35
+ /**
36
+ * Between two links meeting the same side of the same box. An author names a
37
+ * side, never a point on it, so this is the tool keeping two attachments apart
38
+ * rather than a distance anyone asked for — small, like `SEPARATION_GAP`, and
39
+ * squeezed further if the side is too short to hold the whole group.
40
+ */
41
+ export const ATTACH_STEP = 16;
42
+ /** Kept clear at each end of a side, so an attachment never sits on a corner. */
43
+ export const ATTACH_MARGIN = 10;
44
+ /** How wide a link's line is drawn. */
45
+ export const LINE_WIDTH = 1.6;
46
+ /**
47
+ * The arrowhead's length, in the `markerUnits="strokeWidth"` the marker is
48
+ * declared in, so its drawn length is this times `LINE_WIDTH`.
49
+ */
50
+ export const ARROW_MARKER_WIDTH = 7;
51
+ /**
52
+ * How much of the line an arrowhead covers. Derived rather than written down,
53
+ * because the resolver reserves it and the renderer draws it, and a number
54
+ * agreed by coincidence is a number that drifts.
55
+ */
56
+ export const ARROW_LENGTH = ARROW_MARKER_WIDTH * LINE_WIDTH;
57
+ /**
58
+ * Line left showing between a link's label and the box at that end of the
59
+ * corridor it crosses.
60
+ *
61
+ * Unlike `SEPARATION_GAP` and `ATTACH_MARGIN` this is not "small on purpose".
62
+ * Those two keep two things from touching, and the least distance that reads as
63
+ * "not touching" is the right one. This one has something to show: a label sits
64
+ * in a knockout that erases the line behind it, so whatever is left either side
65
+ * is the entire evidence that the label belongs to a link at all. At ten pixels
66
+ * it did not read as a line — the seed diagram in the playground drew as a word
67
+ * with a dash beside it — so it is the length of a run of line, not a margin.
68
+ *
69
+ * Say `gap:` if you want the corridor wider than its contents.
70
+ */
71
+ export const LABEL_CLEARANCE = 20;
72
+ /**
73
+ * How much room a link's label takes along one axis.
74
+ *
75
+ * The knockout rectangle drawn behind a label is the text plus five either side,
76
+ * so that rectangle, not the glyphs, is what must not overlap anything.
77
+ *
78
+ * Shared by the resolver, which widens a corridor to hold a label, and the
79
+ * renderer, which spaces the lanes of a channel by it, so the two cannot
80
+ * disagree about how much room a label needs. The two ask different questions of
81
+ * it and both are right: the resolver measures *along* the run, so a link
82
+ * traveling horizontally needs the label's width; the renderer measures *across*
83
+ * the channel, so a link traveling horizontally down one needs its height.
84
+ */
85
+ export function labelExtent(label, appearance, axis, measurer, fontSize, line) {
86
+ const size = fontSizeFor('link', appearance, fontSize, line);
87
+ const { width, lines } = measurer.measure(label, size);
88
+ return axis === 'x' ? width + 10 : lines.length * measurer.lineHeight(size);
89
+ }
90
+ /**
91
+ * An icon is two lines of the label tall, and that ratio is what makes it a
92
+ * *label-sized* ornament rather than a picture with a size of its own. It is
93
+ * read off the reference, where the title lines run 25 pixels baseline to
94
+ * baseline and the drive and machine glyphs are close to 50 tall. Deriving it
95
+ * from the text also means an icon on a `size: small` node shrinks with it,
96
+ * which is what anyone would expect and what a fixed pixel count would not do.
97
+ */
98
+ export const ICON_LINES = 2;
99
+ /** Between the label column and the icon column beside it. */
100
+ export const ICON_GAP = 10;
101
+ export const DEFAULT_FONT_SIZE = 14;
102
+ /**
103
+ * The named text sizes, each a multiple of the document's own size. Named
104
+ * rather than numeric for the reason gaps are: a number here is typography by
105
+ * coordinate. It goes stale the moment the document is set at another size, and
106
+ * it says nothing about why one piece of text is smaller than another.
107
+ *
108
+ * `small` is sampled rather than chosen. In
109
+ * `examples/reference/arch.png` the box and container labels run 25
110
+ * pixels baseline to baseline and every annotation runs 21, which is this
111
+ * ratio; `./dev.sh textrows` is how that was read off. `large` is the same step
112
+ * taken the other way, so the scale is symmetric about the document size.
113
+ */
114
+ export const TEXT_SIZES = {
115
+ small: 21 / 25,
116
+ normal: 1,
117
+ large: 25 / 21,
118
+ };
119
+ /**
120
+ * What each kind of text is set at when the file says nothing. A note annotates
121
+ * the diagram rather than being part of it, and at the size of a box label an
122
+ * aside reads as a statement — so `note` starts small and says so by being a
123
+ * note. This is a default and not a ceiling: `size:` overrides it, the same way
124
+ * `fill:` overrides the theme's color.
125
+ */
126
+ const DEFAULT_TEXT_SIZE = { note: 'small' };
127
+ /**
128
+ * The size a piece of text is set at. Shared by the resolver, which reserves
129
+ * the room, and the renderer, which fills it, so the two cannot disagree about
130
+ * how much room there is.
131
+ */
132
+ export function fontSizeFor(kind, appearance, fontSize, line) {
133
+ const named = appearance['size'] ?? DEFAULT_TEXT_SIZE[kind] ?? 'normal';
134
+ const scale = TEXT_SIZES[named];
135
+ if (scale === undefined) {
136
+ throw new SourceError(`size takes ${Object.keys(TEXT_SIZES).join(', ')}, not "${named}"`, line);
137
+ }
138
+ return Math.round(fontSize * scale);
139
+ }
140
+ export const DEFAULT_MARGIN = 40;
141
+ /**
142
+ * Where a container's own label sits. Every container reserves a band for its
143
+ * label and its icon; `at` says which end of the box that band is, and the
144
+ * contents take what is left. `align` says how the text sits across it.
145
+ *
146
+ * The two are independent and neither implies the other. A label at the bottom
147
+ * is an ordinary label that happens to be at the bottom — there is no kind of
148
+ * label being named here and no second thing quietly coming along with the
149
+ * first. An earlier version bundled them as `label: heading | caption`, which
150
+ * read a position as though it were a meaning; a folded corner means "artifact
151
+ * rather than process" and a reader decodes it, while "lower down" means only
152
+ * lower down.
153
+ *
154
+ * A leaf has no band, so `at` says nothing about one and is refused there. But
155
+ * `align` is not about the band: a label of more than one line has lines of
156
+ * unequal length whatever kind of box it is in, and how those sit across each
157
+ * other is a real question anywhere. A leaf's default is centered rather than
158
+ * ranged left, which is why the fallback is a parameter.
159
+ */
160
+ export const LABEL_ENDS = ['top', 'bottom'];
161
+ /**
162
+ * Author's word to the SVG's. One spelling of each, per the rule that refuses
163
+ * synonyms for `box` and `link`: an alias is a variant a reader has to learn,
164
+ * and every document and example has to pick one of them anyway.
165
+ */
166
+ const LABEL_ALIGNMENTS = {
167
+ left: 'start',
168
+ center: 'middle',
169
+ right: 'end',
170
+ };
171
+ /**
172
+ * Read a label's bracketed modifiers. Shared by the resolver, which offsets the
173
+ * contents away from the band, and the renderer, which draws into it, so the two
174
+ * cannot disagree about which end the band is at.
175
+ */
176
+ export function labelStyleFor(label, line, fallbackAlign = 'start') {
177
+ const at = label['at'];
178
+ if (at !== undefined && !LABEL_ENDS.includes(at)) {
179
+ throw new SourceError(`a label's at takes ${LABEL_ENDS.join(' or ')}, not "${at}"`, line);
180
+ }
181
+ const align = label['align'];
182
+ if (align !== undefined && LABEL_ALIGNMENTS[align] === undefined) {
183
+ throw new SourceError(`a label's align takes left, center or right, not "${align}"`, line);
184
+ }
185
+ return {
186
+ at: at ?? 'top',
187
+ align: align === undefined ? fallbackAlign : LABEL_ALIGNMENTS[align],
188
+ };
189
+ }
@@ -0,0 +1,56 @@
1
+ import type { Placement } from './ast.js';
2
+ /**
3
+ * The tightest arrangement satisfying a set of relative distances.
4
+ *
5
+ * Every placement in the language turns into the same shape of fact: one thing sits
6
+ * at least so far along an axis from another. Written as `pos[to] >= pos[from]
7
+ * + weight`, a whole diagram is a system of those, and the arrangement the
8
+ * author meant is the one where nothing is further apart than it has to be.
9
+ *
10
+ * That arrangement is unique and is found by longest paths, so no search
11
+ * happens and no alternative is ever weighed. Distances come out of the
12
+ * arithmetic; which side of what a thing sits on came from the author.
13
+ */
14
+ export interface Constraint {
15
+ from: number;
16
+ to: number;
17
+ weight: number;
18
+ /** The placement this came from, so a failure can be reported in the author's words. */
19
+ placement?: Placement;
20
+ }
21
+ /** An exact distance, which is two inequalities pointing opposite ways. */
22
+ export declare function fix(from: number, to: number, distance: number, placement?: Placement): Constraint[];
23
+ export interface Contradiction {
24
+ /** The placements that cannot all hold. Empty if the loop is entirely implicit. */
25
+ placements: Placement[];
26
+ }
27
+ /**
28
+ * Solve, or report the placements that fight.
29
+ *
30
+ * Longest path from a virtual source joined to everything at zero, which is
31
+ * Bellman-Ford with the comparison flipped. A distance that keeps growing after
32
+ * one pass per node means the constraints run in a circle that demands ever
33
+ * more room, and that circle is exactly the set of placements to quote back.
34
+ */
35
+ export declare function tightest(count: number, constraints: Constraint[]): {
36
+ positions: number[];
37
+ } | {
38
+ contradiction: Contradiction;
39
+ };
40
+ /**
41
+ * Which members can be driven apart from which, along one axis.
42
+ *
43
+ * A constraint says `pos[to] >= pos[from] + weight`, so it bounds `to` from
44
+ * below and leaves it free to move further away. Follow those edges and the
45
+ * question "may this one end up beyond that one?" becomes plain reachability:
46
+ * if `j` is reachable from `i` and `i` is not reachable from `j`, then the file
47
+ * lets the distance from `i` to `j` grow without limit and never lets it be
48
+ * closed from the other side. Pushing `j` past `i` is then the one separation
49
+ * the file allows, and no choice was made.
50
+ *
51
+ * Reachable both ways means the two are pinned at a fixed distance, so they
52
+ * cannot be separated along this axis at all. Reachable neither way means the
53
+ * file said nothing that orders them, which is the case the caller must refuse
54
+ * rather than guess at.
55
+ */
56
+ export declare function reachability(count: number, constraints: Constraint[]): boolean[][];
@@ -0,0 +1,95 @@
1
+ /** An exact distance, which is two inequalities pointing opposite ways. */
2
+ export function fix(from, to, distance, placement) {
3
+ return [
4
+ { from, to, weight: distance, ...(placement ? { placement } : {}) },
5
+ { from: to, to: from, weight: -distance, ...(placement ? { placement } : {}) },
6
+ ];
7
+ }
8
+ /**
9
+ * Solve, or report the placements that fight.
10
+ *
11
+ * Longest path from a virtual source joined to everything at zero, which is
12
+ * Bellman-Ford with the comparison flipped. A distance that keeps growing after
13
+ * one pass per node means the constraints run in a circle that demands ever
14
+ * more room, and that circle is exactly the set of placements to quote back.
15
+ */
16
+ export function tightest(count, constraints) {
17
+ const positions = new Array(count).fill(0);
18
+ const cameFrom = new Array(count).fill(undefined);
19
+ for (let pass = 0; pass < count; pass += 1) {
20
+ let moved = false;
21
+ for (const constraint of constraints) {
22
+ const candidate = positions[constraint.from] + constraint.weight;
23
+ if (candidate > positions[constraint.to] + 1e-9) {
24
+ positions[constraint.to] = candidate;
25
+ cameFrom[constraint.to] = constraint;
26
+ moved = true;
27
+ }
28
+ }
29
+ if (!moved)
30
+ return { positions };
31
+ }
32
+ // Still moving after a full pass per node, so some loop demands more room
33
+ // every time round it. Walk backwards to find it.
34
+ for (const constraint of constraints) {
35
+ if (positions[constraint.from] + constraint.weight > positions[constraint.to] + 1e-9) {
36
+ return { contradiction: { placements: loopFrom(constraint.to, cameFrom, count) } };
37
+ }
38
+ }
39
+ return { contradiction: { placements: [] } };
40
+ }
41
+ /**
42
+ * Which members can be driven apart from which, along one axis.
43
+ *
44
+ * A constraint says `pos[to] >= pos[from] + weight`, so it bounds `to` from
45
+ * below and leaves it free to move further away. Follow those edges and the
46
+ * question "may this one end up beyond that one?" becomes plain reachability:
47
+ * if `j` is reachable from `i` and `i` is not reachable from `j`, then the file
48
+ * lets the distance from `i` to `j` grow without limit and never lets it be
49
+ * closed from the other side. Pushing `j` past `i` is then the one separation
50
+ * the file allows, and no choice was made.
51
+ *
52
+ * Reachable both ways means the two are pinned at a fixed distance, so they
53
+ * cannot be separated along this axis at all. Reachable neither way means the
54
+ * file said nothing that orders them, which is the case the caller must refuse
55
+ * rather than guess at.
56
+ */
57
+ export function reachability(count, constraints) {
58
+ const reach = Array.from({ length: count }, () => new Array(count).fill(false));
59
+ for (const constraint of constraints)
60
+ reach[constraint.from][constraint.to] = true;
61
+ for (let via = 0; via < count; via += 1) {
62
+ for (let from = 0; from < count; from += 1) {
63
+ if (!reach[from][via])
64
+ continue;
65
+ for (let to = 0; to < count; to += 1) {
66
+ if (reach[via][to])
67
+ reach[from][to] = true;
68
+ }
69
+ }
70
+ }
71
+ return reach;
72
+ }
73
+ /** The placements on the loop reached by following each position back to what set it. */
74
+ function loopFrom(start, cameFrom, count) {
75
+ let at = start;
76
+ for (let step = 0; step < count; step += 1) {
77
+ const previous = cameFrom[at];
78
+ if (previous === undefined)
79
+ break;
80
+ at = previous.from;
81
+ }
82
+ const placements = [];
83
+ const seen = new Set();
84
+ let cursor = at;
85
+ while (!seen.has(cursor)) {
86
+ seen.add(cursor);
87
+ const previous = cameFrom[cursor];
88
+ if (previous === undefined)
89
+ break;
90
+ if (previous.placement)
91
+ placements.push(previous.placement);
92
+ cursor = previous.from;
93
+ }
94
+ return placements.reverse();
95
+ }
@@ -0,0 +1,7 @@
1
+ /** A problem in the source, reported in the vocabulary of the source. */
2
+ export declare class SourceError extends Error {
3
+ readonly line: number;
4
+ constructor(message: string, line: number);
5
+ /** `12: two placements for "server"` — the form the command-line tool prints. */
6
+ format(file?: string): string;
7
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,14 @@
1
+ /** A problem in the source, reported in the vocabulary of the source. */
2
+ export class SourceError extends Error {
3
+ line;
4
+ constructor(message, line) {
5
+ super(message);
6
+ this.line = line;
7
+ this.name = 'SourceError';
8
+ }
9
+ /** `12: two placements for "server"` — the form the command-line tool prints. */
10
+ format(file) {
11
+ const where = file ? `${file}:${this.line}` : `line ${this.line}`;
12
+ return `${where}: ${this.message}`;
13
+ }
14
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The language's *lexical* vocabulary, and a scanner that classifies one line of
3
+ * source into colored spans.
4
+ *
5
+ * This is deliberately separate from `parser.ts`, and it is not a second parser.
6
+ * The parser answers "what does this file mean" and refuses anything it cannot
7
+ * answer for; a highlighter has to color a half-typed line without complaint,
8
+ * so it answers only "what kind of word is this" and never fails. Every rule
9
+ * below is a regex applied to a single line, in priority order, with one bit of
10
+ * carried state (whether anything has been seen on the line yet, and whether the
11
+ * previous token was an attribute key).
12
+ *
13
+ * **That restriction is the point, and it is what makes this reusable.** An
14
+ * Emacs `font-lock-keywords` list, a Vim `syntax match` file and a TextMate
15
+ * grammar are all exactly this: an ordered list of single-line regexes with a
16
+ * face attached. So the vocabulary here — which is the half that goes stale, and
17
+ * which is imported from `ast.ts`, `constants.ts` and `icons.ts` rather than
18
+ * retyped — can be emitted into any of those without this file being ported.
19
+ * The scanner is the JavaScript consumer of the same spec, and the playground is
20
+ * its only caller.
21
+ *
22
+ * Nothing in the language spans lines: `//` runs to the end of one, a string
23
+ * closes on one, and there are no blocks. That is the property those editor
24
+ * formats need and the reason a `.reladraw` grammar is small in all of them.
25
+ */
26
+ /** What a span of source is, for coloring. */
27
+ export type TokenKind =
28
+ /** `// to the end of the line` */
29
+ 'comment'
30
+ /** A quoted string, quotes included. Unterminated ones count, so typing is quiet. */
31
+ | 'string'
32
+ /** The word a statement opens with: `box`, `link`, … */
33
+ | 'keyword'
34
+ /** The name a statement declares, right after its keyword. */
35
+ | 'name'
36
+ /** `->` and `<->`. */
37
+ | 'arrow'
38
+ /** Placement and link vocabulary: `right`, `of`, `level`, `with`, `and`, `between`, … */
39
+ | 'relation'
40
+ /** A `key:` opening an attribute or a bracketed modifier. */
41
+ | 'attribute'
42
+ /** The single word an attribute takes. */
43
+ | 'value'
44
+ /** `#14532d`, wherever it appears. */
45
+ | 'color'
46
+ /** `(` and `)` around a placement's or a label's modifiers. */
47
+ | 'bracket'
48
+ /** Everything else: node names being referred to, and whitespace. */
49
+ | 'plain';
50
+ export interface Span {
51
+ kind: TokenKind;
52
+ /** Half-open, in characters, into the line it came from. */
53
+ start: number;
54
+ end: number;
55
+ }
56
+ /**
57
+ * The words a statement may open with. `parseStatement` in `parser.ts` is the
58
+ * authority — its switch is what actually accepts them — and this list mirrors
59
+ * it. A word missing here is a word that draws in the plain color, which is a
60
+ * dull page rather than a wrong one.
61
+ */
62
+ export declare const STATEMENT_KEYWORDS: readonly ["box", "note", "link", "deck", "style", "diagram"];
63
+ /**
64
+ * Every word that says something about where a thing goes. Assembled from the
65
+ * lists the parser itself reads, so a direction or a passage axis added there
66
+ * colors here without anybody remembering to come back.
67
+ */
68
+ export declare const RELATION_WORDS: string[];
69
+ /**
70
+ * The patterns, as source strings, so a generator can emit them into another
71
+ * editor's grammar without reaching into a compiled RegExp. Each is written to
72
+ * match at the point the scanner has reached; the scanner adds the sticky flag.
73
+ *
74
+ * A name may contain dots (containment) and hyphens, which is why the word
75
+ * pattern is what it is rather than `\w+`.
76
+ */
77
+ export declare const PATTERNS: {
78
+ readonly comment: "\\/\\/.*";
79
+ readonly string: "\"(?:\\\\.|[^\"\\\\])*\"?";
80
+ readonly keyword: `(?:${string})\\b`;
81
+ readonly arrow: "<->|->|<-";
82
+ readonly attribute: "[A-Za-z][A-Za-z0-9_-]*:";
83
+ readonly color: "#[0-9A-Fa-f]{3,8}\\b";
84
+ readonly relation: `(?:${string})\\b`;
85
+ readonly bracket: "[()]";
86
+ readonly word: "(?:[^\\s()\"\\/]|\\/(?!\\/))(?:[^\\s\"\\/]|\\/(?!\\/))*";
87
+ /**
88
+ * The same, inside an open bracket, where `)` closes the group instead of
89
+ * being an ordinary character. An editor grammar that cannot count brackets
90
+ * should use this one throughout: mistaking `rgb(20,20,20)` for three tokens
91
+ * is a smaller wrong than swallowing the `)` that ends `(gap: tight)`.
92
+ */
93
+ readonly wordInGroup: "(?:[^\\s()\"\\/]|\\/(?!\\/))(?:[^\\s)\"\\/]|\\/(?!\\/))*";
94
+ };
95
+ /**
96
+ * Classify one line. Always returns spans covering it end to end, in order, so a
97
+ * caller can rebuild the text by concatenation and can trust that nothing was
98
+ * dropped — which is what a highlighter drawn *behind* a textarea needs, since
99
+ * a lost character would slide every following one out of register.
100
+ */
101
+ export declare function highlightLine(line: string): Span[];
102
+ /** Every line of a source file, classified. Line endings are not included. */
103
+ export declare function highlight(source: string): Span[][];