reladraw 0.15.1 → 0.17.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.
package/dist/size.d.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A stated size: `height: 0.5 group height`, `width: a + b + 2`,
3
+ * `height: a top to c bottom`, `width: larger of a and b`, `height: 3 lines`.
4
+ *
5
+ * Read here once, by the parser, which checks it and stores it back as text
6
+ * with every word spaced out; the placing pass reads that text again and works
7
+ * the number out. A size ends at the first word that cannot continue it, so a
8
+ * placement can follow it on the same line: `height: a right of b`.
9
+ */
10
+ import type { Token } from './lexer.js';
11
+ export type Dimension = 'width' | 'height';
12
+ export type SpanSide = 'top' | 'bottom' | 'left' | 'right' | 'center';
13
+ export type SizeExpr = {
14
+ kind: 'number';
15
+ value: number;
16
+ unit: 'unit' | 'px' | 'lines' | 'chars';
17
+ }
18
+ /** Another box's measurement; no dimension means the one being stated. */
19
+ | {
20
+ kind: 'measure';
21
+ name: string;
22
+ dimension?: Dimension;
23
+ }
24
+ /** The box's own other measurement: `height: 0.75 width`. */
25
+ | {
26
+ kind: 'own';
27
+ dimension: Dimension;
28
+ } | {
29
+ kind: 'span';
30
+ from: {
31
+ name: string;
32
+ side: SpanSide;
33
+ };
34
+ to: {
35
+ name: string;
36
+ side: SpanSide;
37
+ };
38
+ } | {
39
+ kind: 'scale';
40
+ by: number;
41
+ of: SizeExpr;
42
+ } | {
43
+ kind: 'sum';
44
+ terms: SizeExpr[];
45
+ } | {
46
+ kind: 'pick';
47
+ which: 'larger' | 'smaller';
48
+ first: SizeExpr;
49
+ second: SizeExpr;
50
+ };
51
+ /** The keys that state a size, and the dimension each measures. */
52
+ export declare const SIZE_KEYS: Record<string, Dimension>;
53
+ /**
54
+ * Read a size starting at `at`. Returns it and the index of the first token
55
+ * after it.
56
+ */
57
+ export declare function readSize(tokens: Token[], at: number, key: string, subject: string, line: number, stops: (token: Token) => boolean): {
58
+ size: SizeExpr;
59
+ next: number;
60
+ };
61
+ /** A size as text, every word spaced, which `readSize` reads back the same. */
62
+ export declare function writeSize(size: SizeExpr): string;
63
+ /** Read a stored size back. */
64
+ export declare function storedSize(text: string, key: string, subject: string, line: number): SizeExpr;
package/dist/size.js ADDED
@@ -0,0 +1,222 @@
1
+ import { isAttrKey } from './lexer.js';
2
+ import { SourceError } from './errors.js';
3
+ /** The keys that state a size, and the dimension each measures. */
4
+ export const SIZE_KEYS = {
5
+ width: 'width',
6
+ height: 'height',
7
+ 'min-width': 'width',
8
+ 'max-width': 'width',
9
+ 'min-height': 'height',
10
+ 'max-height': 'height',
11
+ };
12
+ const SPAN_SIDES = ['top', 'bottom', 'left', 'right', 'center'];
13
+ const RESERVED = new Set(['and', 'of', 'to', '+', '(', ')', 'larger', 'smaller']);
14
+ const NUMBER = /^(\d*\.?\d+)(?:\/(\d*\.?\d+))?(px)?$/;
15
+ /**
16
+ * Read a size starting at `at`. Returns it and the index of the first token
17
+ * after it.
18
+ */
19
+ export function readSize(tokens, at, key, subject, line, stops) {
20
+ // `a+b` is `a + b`: how much whitespace there is never matters.
21
+ const spaced = [];
22
+ for (const [index, token] of tokens.entries()) {
23
+ if (index < at || token.quoted || token.text === '+' || !token.text.includes('+')) {
24
+ spaced.push(token);
25
+ continue;
26
+ }
27
+ token.text.split(/(\+)/).filter((part) => part.length > 0).forEach((part) => spaced.push({ text: part, quoted: false }));
28
+ }
29
+ const reading = { tokens: spaced, at, key, subject, line, stops };
30
+ if (!startsOperand(reading)) {
31
+ throw new SourceError(`${subject}: ${key}: takes a size, such as ${key}: 4, ${key}: 120px or ${key}: group ${SIZE_KEYS[key]}`, line);
32
+ }
33
+ const size = sum(reading);
34
+ refuseBracket(reading);
35
+ // Count back to the caller's tokens, which had `a+b` as one.
36
+ let next = at;
37
+ let consumed = 0;
38
+ for (let index = at; index < tokens.length && consumed < reading.at - at; index += 1) {
39
+ const token = tokens[index];
40
+ consumed += token.quoted || token.text === '+' || !token.text.includes('+')
41
+ ? 1
42
+ : token.text.split(/(\+)/).filter((part) => part.length > 0).length;
43
+ next = index + 1;
44
+ }
45
+ return { size, next };
46
+ }
47
+ /** A size as text, every word spaced, which `readSize` reads back the same. */
48
+ export function writeSize(size) {
49
+ switch (size.kind) {
50
+ case 'number':
51
+ return size.unit === 'px' ? `${size.value}px` : size.unit === 'unit' ? `${size.value}` : `${size.value} ${size.unit}`;
52
+ case 'measure':
53
+ return size.dimension ? `${size.name} ${size.dimension}` : size.name;
54
+ case 'own':
55
+ return size.dimension;
56
+ case 'span':
57
+ return `${size.from.name} ${size.from.side} to ${size.to.name} ${size.to.side}`;
58
+ case 'scale': {
59
+ const inner = writeSize(size.of);
60
+ return `${size.by} ${size.of.kind === 'sum' ? `( ${inner} )` : inner}`;
61
+ }
62
+ case 'sum':
63
+ return size.terms.map(writeSize).join(' + ');
64
+ case 'pick':
65
+ return `${size.which} of ( ${writeSize(size.first)} ) and ( ${writeSize(size.second)} )`;
66
+ }
67
+ }
68
+ /** Read a stored size back. */
69
+ export function storedSize(text, key, subject, line) {
70
+ const tokens = text.split(' ').map((word) => ({ text: word, quoted: false }));
71
+ return readSize(tokens, 0, key, subject, line, () => false).size;
72
+ }
73
+ function peek(reading, ahead = 0) {
74
+ return reading.tokens[reading.at + ahead];
75
+ }
76
+ function isWord(reading, word, ahead = 0) {
77
+ const token = peek(reading, ahead);
78
+ return token !== undefined && !token.quoted && token.text === word;
79
+ }
80
+ /** A word that could be a box's name here. */
81
+ function isName(reading, token) {
82
+ if (!token || token.quoted || isAttrKey(token))
83
+ return false;
84
+ if (RESERVED.has(token.text) || reading.stops(token))
85
+ return false;
86
+ return /^[A-Za-z_][\w.-]*$/.test(token.text);
87
+ }
88
+ function startsOperand(reading) {
89
+ const token = peek(reading);
90
+ if (!token || token.quoted || isAttrKey(token))
91
+ return false;
92
+ return (NUMBER.test(token.text) ||
93
+ token.text === '(' ||
94
+ token.text === 'larger' ||
95
+ token.text === 'smaller' ||
96
+ token.text === 'width' ||
97
+ token.text === 'height' ||
98
+ isName(reading, token) ||
99
+ token.text.startsWith('-'));
100
+ }
101
+ function sum(reading) {
102
+ const terms = [scaled(reading)];
103
+ while (isWord(reading, '+')) {
104
+ reading.at += 1;
105
+ if (!startsOperand(reading)) {
106
+ throw new SourceError(`${reading.subject}: ${reading.key}: ends with a +, which has nothing to add`, reading.line);
107
+ }
108
+ terms.push(scaled(reading));
109
+ }
110
+ refuseStar(reading);
111
+ return terms.length === 1 ? terms[0] : { kind: 'sum', terms };
112
+ }
113
+ function refuseStar(reading) {
114
+ const token = peek(reading);
115
+ if (token && !token.quoted && token.text.includes('*')) {
116
+ throw new SourceError(`${reading.subject}: ${reading.key}: scales by writing the number in front — ${reading.key}: 0.5 group, not 0.5 * group`, reading.line);
117
+ }
118
+ }
119
+ /** `height: group (at most: 30)`: a bracket of settings, which a size never takes. */
120
+ function refuseBracket(reading) {
121
+ if (!isWord(reading, '('))
122
+ return;
123
+ let settings = false;
124
+ for (let ahead = 1; peek(reading, ahead) && !isWord(reading, ')', ahead); ahead += 1) {
125
+ if (isAttrKey(peek(reading, ahead)))
126
+ settings = true;
127
+ }
128
+ if (!settings)
129
+ return;
130
+ const dimension = SIZE_KEYS[reading.key];
131
+ throw new SourceError(`${reading.subject}: ${reading.key}: takes no bracket — a limit is its own key, min-${dimension}: or max-${dimension}:, ` +
132
+ 'since a bracket reads as belonging to whatever it sits beside', reading.line);
133
+ }
134
+ function scaled(reading) {
135
+ const token = peek(reading);
136
+ if (token.text.startsWith('-')) {
137
+ throw new SourceError(`${reading.subject}: ${reading.key}: a size is never negative, so ${token.text} is not one`, reading.line);
138
+ }
139
+ const number = NUMBER.exec(token.text);
140
+ if (!number)
141
+ return atom(reading);
142
+ reading.at += 1;
143
+ const value = number[2] === undefined ? Number(number[1]) : Number(number[1]) / Number(number[2]);
144
+ if (number[3])
145
+ return { kind: 'number', value, unit: 'px' };
146
+ if (isWord(reading, 'lines') || isWord(reading, 'chars')) {
147
+ const unit = peek(reading).text;
148
+ reading.at += 1;
149
+ return { kind: 'number', value, unit };
150
+ }
151
+ refuseStar(reading);
152
+ const next = peek(reading);
153
+ const scales = next !== undefined &&
154
+ !next.quoted &&
155
+ (next.text === '(' || next.text === 'larger' || next.text === 'smaller' || next.text === 'width' ||
156
+ next.text === 'height' || isName(reading, next));
157
+ if (!scales)
158
+ return { kind: 'number', value, unit: 'unit' };
159
+ return { kind: 'scale', by: value, of: atom(reading) };
160
+ }
161
+ function atom(reading) {
162
+ const token = peek(reading);
163
+ const { subject, key, line } = reading;
164
+ if (token.text === '(') {
165
+ refuseBracket(reading);
166
+ reading.at += 1;
167
+ const inner = sum(reading);
168
+ if (!isWord(reading, ')'))
169
+ throw new SourceError(`${subject}: ${key}: opens a bracket it never closes`, line);
170
+ reading.at += 1;
171
+ return inner;
172
+ }
173
+ if (token.text === 'larger' || token.text === 'smaller') {
174
+ const which = token.text;
175
+ if (!isWord(reading, 'of', 1)) {
176
+ throw new SourceError(`${subject}: ${key}: ${which} chooses between two — ${which} of a and b`, line);
177
+ }
178
+ reading.at += 2;
179
+ const first = sum(reading);
180
+ if (!isWord(reading, 'and')) {
181
+ throw new SourceError(`${subject}: ${key}: ${which} of names two sizes, joined by and — ${which} of a and b`, line);
182
+ }
183
+ reading.at += 1;
184
+ if (!startsOperand(reading)) {
185
+ throw new SourceError(`${subject}: ${key}: ${which} of a and … names nothing after and`, line);
186
+ }
187
+ const second = sum(reading);
188
+ return { kind: 'pick', which, first, second };
189
+ }
190
+ if (token.text === 'width' || token.text === 'height') {
191
+ reading.at += 1;
192
+ return { kind: 'own', dimension: token.text };
193
+ }
194
+ if (!isName(reading, token)) {
195
+ throw new SourceError(`${subject}: ${key}: "${token.text}" is not a size or the name of a box`, line);
196
+ }
197
+ reading.at += 1;
198
+ const name = token.text;
199
+ const after = peek(reading);
200
+ if (after && !after.quoted && SPAN_SIDES.includes(after.text) && isWord(reading, 'to', 1)) {
201
+ reading.at += 2;
202
+ const far = peek(reading);
203
+ const side = peek(reading, 1);
204
+ // `a top to c right of b`: the far side is missing, and a placement follows.
205
+ const placed = isWord(reading, 'of', 2);
206
+ if (!isName(reading, far) || !side || side.quoted || !SPAN_SIDES.includes(side.text) || placed) {
207
+ throw new SourceError(`${subject}: ${key}: a span names a side at each end — ${name} ${after.text} to c ${after.text === 'top' ? 'bottom' : after.text}`, line);
208
+ }
209
+ reading.at += 2;
210
+ return {
211
+ kind: 'span',
212
+ from: { name, side: after.text },
213
+ to: { name: far.text, side: side.text },
214
+ };
215
+ }
216
+ if (isWord(reading, 'width') || isWord(reading, 'height')) {
217
+ const dimension = peek(reading).text;
218
+ reading.at += 1;
219
+ return { kind: 'measure', name, dimension };
220
+ }
221
+ return { kind: 'measure', name };
222
+ }
package/dist/themes.d.ts CHANGED
@@ -45,6 +45,23 @@ export interface Accent {
45
45
  /** Text on a solid fill of `color`: `theme-on-primary`. */
46
46
  on: string;
47
47
  }
48
+ /**
49
+ * A venn's overlap when it states no fill: the theme's secondary at its subtle
50
+ * strength laid over the left set's fill, so the shared part reads as both
51
+ * sets at once. A left fill that can't be measured is taken as the theme's
52
+ * primary-subtle, the left set's default.
53
+ */
54
+ export declare function overlapFill(under: string, theme: Theme): string;
55
+ /**
56
+ * A region's fill when it states none (design.org 8.3): the theme's primary
57
+ * mixed toward the page, so that drawn at a region's half opacity it reads as
58
+ * the primary at 12% did, the tint regions have always had. A page color that
59
+ * can't be measured keeps the primary itself, at a quarter of the opacity.
60
+ */
61
+ export declare function regionTint(theme: Theme, opacity: number): {
62
+ color: string;
63
+ opacity: number;
64
+ };
48
65
  export declare const THEME_COLORS: readonly string[];
49
66
  /** A color as written, with a `theme-` word replaced by the theme's value. */
50
67
  export declare function themeColor(value: string, theme: Theme): string;
package/dist/themes.js CHANGED
@@ -3,6 +3,30 @@ const SUBTLE = 0.25;
3
3
  function accent(color, page, on, subtle = mix(color, page, SUBTLE)) {
4
4
  return { color, subtle, on };
5
5
  }
6
+ /**
7
+ * A venn's overlap when it states no fill: the theme's secondary at its subtle
8
+ * strength laid over the left set's fill, so the shared part reads as both
9
+ * sets at once. A left fill that can't be measured is taken as the theme's
10
+ * primary-subtle, the left set's default.
11
+ */
12
+ export function overlapFill(under, theme) {
13
+ const base = /^#[0-9a-f]{6}$/i.test(under) ? under : theme.primary.subtle;
14
+ return mix(theme.secondary.color, base, SUBTLE);
15
+ }
16
+ /**
17
+ * A region's fill when it states none (design.org 8.3): the theme's primary
18
+ * mixed toward the page, so that drawn at a region's half opacity it reads as
19
+ * the primary at 12% did, the tint regions have always had. A page color that
20
+ * can't be measured keeps the primary itself, at a quarter of the opacity.
21
+ */
22
+ export function regionTint(theme, opacity) {
23
+ const hex = /^#[0-9a-f]{6}$/i;
24
+ if (!hex.test(theme.primary.color) || !hex.test(theme.background))
25
+ return { color: theme.primary.color, opacity: opacity * REGION_TINT };
26
+ return { color: mix(theme.primary.color, theme.background, REGION_TINT), opacity };
27
+ }
28
+ /** How much of the primary a region's own tint keeps, so that at half opacity it is 12% of it. */
29
+ const REGION_TINT = 0.24;
6
30
  /** `amount` of `color` over `page`, both `#rrggbb`. */
7
31
  function mix(color, page, amount) {
8
32
  const channel = (hex, at) => parseInt(hex.slice(at, at + 2), 16);
@@ -92,10 +116,11 @@ export const DARK_THEME = {
92
116
  iconInk: '#8d8d8e',
93
117
  iconShade: '#3e3d58',
94
118
  // The reference's two colored boxes: `synced` is the green, `dump` the red.
95
- // Their fills are sampled rather than mixed, so the benchmark written in
96
- // theme colors renders exactly as it did in hex.
119
+ // The green's fill is sampled rather than mixed, so the benchmark written in
120
+ // theme colors renders as it did in hex. The red's is mixed, as every other
121
+ // theme's is: the sampled #460000 glared across a venn's whole set.
97
122
  primary: accent('#486544', '#111111', '#d9d9d9', '#142814'),
98
- secondary: accent('#8f3a3a', '#111111', '#d9d9d9', '#460000'),
123
+ secondary: accent('#8f3a3a', '#111111', '#d9d9d9'),
99
124
  };
100
125
  /** The dark theme's counterpart: the same roles, on a white page. */
101
126
  const LIGHT_THEME = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.15.1",
3
+ "version": "0.17.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,7 @@
36
36
  "prepublishOnly": "npm run build",
37
37
  "preversion": "./dev.sh check-version \"$npm_new_version\"",
38
38
  "dev": "tsc --watch",
39
- "example": "npm run build && node dist/cli.js examples/arch.reladraw -o examples/out/arch.svg"
39
+ "example": "npm run build && node dist/cli.js render examples/arch.reladraw -o examples/out/arch.svg"
40
40
  },
41
41
  "engines": {
42
42
  "node": ">=18"
@@ -1,56 +0,0 @@
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[][];
package/dist/constrain.js DELETED
@@ -1,95 +0,0 @@
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
- }