reladraw 0.20.0 → 0.22.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/render.js CHANGED
@@ -3,7 +3,7 @@ import { SourceError } from './errors.js';
3
3
  import { ICON_STROKE } from './icons.js';
4
4
  import { insideOutline, isBoxy, isRound, placePath, shapeOf, SUBPROCESS_INSET } from './outline.js';
5
5
  import { monospaceMeasurer } from './measure.js';
6
- import { joinedToText } from './model.js';
6
+ import { joinedToText, outlineStated } from './model.js';
7
7
  import { DARK_THEME, THEMES, overlapFill, regionTint, textOnFill, themeColor } from './themes.js';
8
8
  import { crossingPoints, planLines, traceRoute, visibleRuns } from './lines.js';
9
9
  import { patchPadding, placeLineTexts } from './linetexts.js';
@@ -11,22 +11,21 @@ import { plain } from './text.js';
11
11
  const CORNER = 8;
12
12
  /** Turn solved geometry into a standalone SVG document. */
13
13
  export function render(layout, options = {}) {
14
+ return drawing(layout, options)(false);
15
+ }
16
+ /**
17
+ * The SVG as `render` gives it, and the same drawing with each thing wrapped in
18
+ * a group naming it and its statement's line, for a page that points at things
19
+ * (`--page`). Drawn once, so the two can't disagree.
20
+ */
21
+ export function renderTagged(layout, options = {}) {
22
+ const assemble = drawing(layout, options);
23
+ return { svg: assemble(false), tagged: assemble(true) };
24
+ }
25
+ function drawing(layout, options) {
14
26
  const measurer = options.measurer ?? monospaceMeasurer();
15
27
  const fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
16
- // A theme passed in — the command line's `--theme` — beats the one the file
17
- // names, so one source renders in either. `diagram background:` and `text:`
18
- // are the author overruling a color of whichever theme that is, and a color
19
- // written by hand wins over any theme, so they are folded in afterwards and
20
- // everything downstream sees one theme.
21
- const named = layout.diagram['theme'];
22
- const base = options.theme ?? (named === undefined ? undefined : THEMES[named]) ?? DARK_THEME;
23
- const page = layout.diagram['background'];
24
- const words = layout.diagram['text.color'];
25
- const theme = {
26
- ...base,
27
- ...(page !== undefined && { background: themeColor(page, base) }),
28
- ...(words !== undefined && { text: themeColor(words, base) }),
29
- };
28
+ const theme = themeOf(layout, options);
30
29
  // Guides are shown by the viewer (`--guides`), or by a file that explains
31
30
  // its own construction (`diagram guides: show`).
32
31
  const guides = {
@@ -110,8 +109,8 @@ export function render(layout, options = {}) {
110
109
  }
111
110
  const lineSvg = new Map(lines.map((line) => [line.key, line.svg]));
112
111
  const body = sequence
113
- .map((item) => lineSvg.get(item.key) ?? item.svg)
114
- .filter((svg) => svg.length > 0);
112
+ .map((item) => ({ item, svg: lineSvg.get(item.key) ?? item.svg }))
113
+ .filter(({ svg }) => svg.length > 0);
115
114
  // An edge's geometry is measured rather than solved for, so the resolver sized
116
115
  // the canvas from the boxes alone. A curve out of a `top` side, or a text
117
116
  // riding above one, lands outside that — so the page grows to hold it and the
@@ -135,8 +134,11 @@ export function render(layout, options = {}) {
135
134
  markers.set(markerId(mark, color, free), [mark, color, free]);
136
135
  }
137
136
  }
138
- const svg = [
137
+ return (tagged) => ownIds([
139
138
  `<svg xmlns="http://www.w3.org/2000/svg" width="${canvas.width}" height="${canvas.height}" viewBox="${canvas.x} ${canvas.y} ${canvas.width} ${canvas.height}" font-family=${quote(measurer.fontFamily)} font-size="${fontSize}px">`,
139
+ // `diagram title:` — what a browser shows on the tab when the SVG is opened
140
+ // on its own, and what a screen reader announces.
141
+ ...(layout.diagram['title'] === undefined ? [] : [` <title>${escapeXml(layout.diagram['title'])}</title>`]),
140
142
  ' <defs>',
141
143
  ...[...markers.values()].map(([mark, color, free]) => markMarker(mark, color, theme.background, free)),
142
144
  // Each mask covers the whole page, in page units: the default region is the
@@ -150,11 +152,51 @@ export function render(layout, options = {}) {
150
152
  ].join('\n')),
151
153
  ' </defs>',
152
154
  ` <rect x="${canvas.x}" y="${canvas.y}" width="${canvas.width}" height="${canvas.height}" fill="${theme.background}"/>`,
153
- ...body,
155
+ ...body.map(({ item, svg }) => (tagged ? tag(layout, item, svg) : svg)),
154
156
  '</svg>',
155
157
  '',
156
- ].join('\n');
157
- return ownIds(svg);
158
+ ].join('\n'));
159
+ }
160
+ /**
161
+ * The colors a drawing is made in. A theme passed in — the command line's
162
+ * `--theme` — beats the one the file names, so one source renders in either.
163
+ * `diagram background:` and `text:` are the author overruling a color of
164
+ * whichever theme that is, and a color written by hand wins over any theme, so
165
+ * they are folded in afterwards and everything downstream sees one theme.
166
+ */
167
+ export function themeOf(layout, options = {}) {
168
+ const named = layout.diagram['theme'];
169
+ const base = options.theme ?? (named === undefined ? undefined : THEMES[named]) ?? DARK_THEME;
170
+ const page = layout.diagram['background'];
171
+ const words = layout.diagram['text.color'];
172
+ return {
173
+ ...base,
174
+ ...(page !== undefined && { background: themeColor(page, base) }),
175
+ ...(words !== undefined && { text: themeColor(words, base) }),
176
+ };
177
+ }
178
+ /**
179
+ * `<g data-name="api" data-line="12">` round each thing drawn: what a page
180
+ * shows on hover, and what a finding about that line lights up.
181
+ */
182
+ function tag(layout, item, svg) {
183
+ let name;
184
+ let line;
185
+ if (item.node) {
186
+ ({ name, line } = item.node);
187
+ }
188
+ else if (item.key.startsWith('edge ')) {
189
+ const edge = layout.edges[Number(item.key.slice('edge '.length))];
190
+ name = edge.name ?? `${edge.from.name} ${edge.arrow} ${edge.to.name}`;
191
+ line = edge.line;
192
+ }
193
+ else {
194
+ const mark = layout.marks.find((one) => one.name === item.key);
195
+ if (!mark)
196
+ return svg;
197
+ ({ name, line } = mark);
198
+ }
199
+ return `<g data-name="${escapeXml(name).replace(/"/g, '&quot;')}" data-line="${line}">\n${svg}\n</g>`;
158
200
  }
159
201
  /**
160
202
  * Gives every id in a drawing a prefix of its own, taken from a hash of the
@@ -305,7 +347,7 @@ function drawNode(node, theme, measurer, fontSize, markup, guides, inherited) {
305
347
  * cards drawn back to front, so the first, the front card, is on top.
306
348
  */
307
349
  function drawDeck(node, theme, measurer, fontSize, markup, guides, url) {
308
- const outlined = node.appearance['fill'] !== undefined || node.appearance['border'] !== undefined;
350
+ const outlined = outlineStated(node.appearance);
309
351
  const drawn = outlined ? node : { ...node, appearance: { ...node.appearance, fill: 'none', border: 'none' } };
310
352
  const cards = node.children.filter((child) => child !== node.heading && !joinedToText(child));
311
353
  // The cards are the same thing, so each looks like the front one unless it says otherwise.
@@ -364,8 +406,29 @@ function borderSvg(node, theme) {
364
406
  if (node.body.kind !== 'shape')
365
407
  return '';
366
408
  const face = faceOf(node);
409
+ const d = outlinePath(node.body.outline, face.x, face.y, face.width, face.height, node.corners, node.body.look);
410
+ const stroke = borderStroke(node.borderWidth, `<path d="${d}"/>`, `rim-${node.name.replace(/[^\w.-]/g, '_')}`);
367
411
  const border = borderOf(node.appearance, theme, theme.containerStroke);
368
- return ` <path d="${outlinePath(node.body.outline, face.x, face.y, face.width, face.height, node.corners, node.body.look)}" fill="none" stroke="${border}"/>`;
412
+ return `${stroke.clip} <path d="${d}" fill="none" stroke="${border}"${stroke.attr}/>`;
413
+ }
414
+ /**
415
+ * How a border is stroked. A thick border lies wholly inside the outline: the
416
+ * outline is stroked twice as wide and clipped to itself, so only the inner
417
+ * half shows, and its outer edge is the outline exactly, along every side and
418
+ * at every corner and point of every shape, where a line ends and a gap is
419
+ * measured from (design.org 9.1). A normal border straddles the outline, as
420
+ * every box's always did, and writes nothing here. `detail` is for the lines
421
+ * drawn inside a shape, such as a cylinder's rim, which are as wide as the
422
+ * border and straddle where they run.
423
+ */
424
+ function borderStroke(width, outline, id) {
425
+ if (width === undefined)
426
+ return { clip: '', attr: '', detail: '' };
427
+ return {
428
+ clip: ` <clipPath id="${id}">${outline}</clipPath>\n`,
429
+ attr: ` stroke-width="${round(width * 2)}" clip-path="url(#${id})"`,
430
+ detail: ` stroke-width="${round(width)}" clip-path="url(#${id})"`,
431
+ };
369
432
  }
370
433
  /** What a node draws of its own, without its children, which are drawn as items of their own. */
371
434
  function nodeSvg(node, theme, measurer, fontSize, markup) {
@@ -431,18 +494,22 @@ function nodeSvg(node, theme, measurer, fontSize, markup) {
431
494
  // and the fill, drawn without a stroke, still shows behind the title's inner
432
495
  // half.
433
496
  const onBorder = node.lines.length > 0 && node.textAttrs['place']?.startsWith('on ') === true;
497
+ const d = outlinePath(outline, face.x, face.y, face.width, face.height, node.corners, look);
498
+ const stroke = borderStroke(node.borderWidth, `<path d="${d}"/>`, `border-${node.name.replace(/[^\w.-]/g, '_')}`);
499
+ if (stroke.clip)
500
+ parts.push(stroke.clip.trimEnd());
434
501
  if (onBorder) {
435
502
  const id = `title-${node.name.replace(/[^\w.-]/g, '_')}`;
436
503
  const room = PAD / 2;
437
504
  const hole = node.textBox;
438
505
  const grown = { x: face.x - PAD, y: face.y - PAD, w: face.width + PAD * 2, h: face.height + PAD * 2 };
439
- parts.push(` <mask id="${id}" maskUnits="userSpaceOnUse" x="${round(grown.x)}" y="${round(grown.y)}" width="${round(grown.w)}" height="${round(grown.h)}">`, ` <rect x="${round(grown.x)}" y="${round(grown.y)}" width="${round(grown.w)}" height="${round(grown.h)}" fill="white"/>`, ` <rect x="${round(node.x + hole.x - room)}" y="${round(node.y + hole.y)}" width="${round(hole.width + room * 2)}" height="${round(hole.height)}" fill="black"/>`, ' </mask>', ` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height, node.corners, look)}" fill="${fill}" stroke="none"/>`, ` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height, node.corners, look)}" fill="none" stroke="${border}" mask="url(#${id})"/>`);
506
+ parts.push(` <mask id="${id}" maskUnits="userSpaceOnUse" x="${round(grown.x)}" y="${round(grown.y)}" width="${round(grown.w)}" height="${round(grown.h)}">`, ` <rect x="${round(grown.x)}" y="${round(grown.y)}" width="${round(grown.w)}" height="${round(grown.h)}" fill="white"/>`, ` <rect x="${round(node.x + hole.x - room)}" y="${round(node.y + hole.y)}" width="${round(hole.width + room * 2)}" height="${round(hole.height)}" fill="black"/>`, ' </mask>', ` <path d="${d}" fill="${fill}" stroke="none"/>`, ` <path d="${d}" fill="none" stroke="${border}"${stroke.attr} mask="url(#${id})"/>`);
440
507
  }
441
508
  else {
442
- parts.push(` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height, node.corners, look)}" fill="${fill}" stroke="${border}"/>`);
509
+ parts.push(` <path d="${d}" fill="${fill}" stroke="${border}"${stroke.attr}/>`);
443
510
  }
444
511
  for (const extra of outlineDetail(outline, face.x, face.y, face.width, face.height, look)) {
445
- parts.push(` <path d="${extra}" fill="none" stroke="${border}"/>`);
512
+ parts.push(` <path d="${extra}" fill="none" stroke="${border}"${stroke.detail}/>`);
446
513
  }
447
514
  // A leaf's text defaults to the middle of its box, a container's to the top
448
515
  // left of the band; both then read `at` for where it really goes. Where the
@@ -493,6 +560,11 @@ function markSvg(mark, theme, guides, markup, fontSize) {
493
560
  ? `d="M${round(mark.x)} ${round(mark.y)}L${round(mark.x + mark.width)} ${round(mark.y + mark.height)}"`
494
561
  : `x="${round(mark.x)}" y="${round(mark.y)}" width="${round(mark.width)}" height="${round(mark.height)}" rx="4"`;
495
562
  const shape = flat ? 'path' : 'rect';
563
+ // A thick border lies inside the region's stated edge, as a box's does; one
564
+ // drawn as a line has no inside, so it is only wider.
565
+ const edge = flat
566
+ ? { clip: '', attr: mark.borderWidth === undefined ? '' : ` stroke-width="${round(mark.borderWidth)}"` }
567
+ : borderStroke(mark.borderWidth, `<rect ${box}/>`, `edge-${mark.name.replace(/[^\w.-]/g, '_')}`);
496
568
  // A text on the border breaks it, as a title on a box's border does.
497
569
  const hole = mark.text && mark.text.textAttrs['place']?.startsWith('on ') ? mark.text.box : undefined;
498
570
  const id = `region-${mark.name.replace(/[^\w.-]/g, '_')}`;
@@ -506,13 +578,13 @@ function markSvg(mark, theme, guides, markup, fontSize) {
506
578
  if (mark.guide) {
507
579
  const fill = color(said('fill')) ?? 'none';
508
580
  const border = color(said('border')) ?? guides.color;
509
- return `${masked} <${shape} ${box} fill="${fill}" stroke="${border}" stroke-dasharray="${GUIDE_DASH}"${mask}/>${label}`;
581
+ return `${edge.clip}${masked} <${shape} ${box} fill="${fill}" stroke="${border}"${edge.attr} stroke-dasharray="${GUIDE_DASH}"${mask}/>${label}`;
510
582
  }
511
583
  const fill = regionFill(mark, theme);
512
584
  const border = color(said('border'));
513
585
  const opacity = fill.color === 'none' || fill.opacity >= 1 ? '' : ` fill-opacity="${round(fill.opacity)}"`;
514
- return (`${masked} <${shape} ${box} fill="${fill.color}"${opacity}` +
515
- ` stroke="${border ?? theme.primary.color}"${border === undefined ? ' stroke-opacity="0.5"' : ''} stroke-width="1"${mask}/>${label}`);
586
+ return (`${edge.clip}${masked} <${shape} ${box} fill="${fill.color}"${opacity}` +
587
+ ` stroke="${border ?? theme.primary.color}"${border === undefined ? ' stroke-opacity="0.5"' : ''}${edge.attr || ' stroke-width="1"'}${mask}/>${label}`);
516
588
  }
517
589
  /**
518
590
  * A region's fill (design.org 8.3): what it states, or the theme's tint, at
package/dist/resolve.d.ts CHANGED
@@ -26,3 +26,17 @@ export interface ResolveOptions {
26
26
  * sits on is ever decided here, because the author wrote it down.
27
27
  */
28
28
  export declare function resolve(doc: Document, options?: ResolveOptions): Layout;
29
+ /**
30
+ * How a node's corners are drawn. A value is checked wherever it came from — a
31
+ * style's `corners: sharpp` is as much a typo as a node's — but a circle refuses
32
+ * `corners:` only when the node itself says it, since a circle has none. From a
33
+ * style or a default it lands on the outlines that have corners, the way a
34
+ * line's `corners: rounded` from a style lands only on lines with corners. A
35
+ * node drawn as a picture or with no body has no outline; `checkAttrs` has
36
+ * already refused the word written on one, and a style's is unused.
37
+ */
38
+ /**
39
+ * A border's thickness in pixels: `thin`, `normal`, `thick`, a multiple of
40
+ * normal, or `px`, read as a line's is (design.org 3.8).
41
+ */
42
+ export declare function borderWidthOf(what: string, value: string | undefined, line: number): number;
package/dist/resolve.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { TEXT_GAP } from './linetexts.js';
2
2
  import { POINT_KEYS, REGION_KEYS, ALL_ATTR_KEYS, ATTR_KEYS, MARKS, COLOR_KEYS, COLOR_PARTS, CHILD_ALIGNMENTS, GATHER_PLACES, CONTENT_WIDTHS, SIZE_KEY_NAMES, LINE_VALUES, NODE_CORNERS, PADDING_SIDES, describePlacement, nameTarget, partWordHint, } from './ast.js';
3
- import { DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, ICON_GAP, ICON_LINES, LINE_WIDTH, PAD, THICKNESS, fontSizeFor, widestLine, } from './constants.js';
3
+ import { DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, ICON_GAP, ICON_LINES, LINE_WIDTH, PAD, THICKNESS, BORDER_THICKNESS, BORDER_WIDTH, fontSizeFor, widestLine, } from './constants.js';
4
4
  import { SourceError } from './errors.js';
5
5
  import { bodyFor, pastedIcon } from './icons.js';
6
6
  import { monospaceMeasurer } from './measure.js';
7
7
  import { isBoxy, middleOf, sizeFor, SUBPROCESS_INSET } from './outline.js';
8
8
  import { markupStyles, parseMarkup, plain, splitRuns, wrapLine } from './text.js';
9
- import { joinedToText } from './model.js';
9
+ import { joinedToText, outlineStated } from './model.js';
10
10
  import { checkOpacity, gapPixels, Placer, regionTextAt } from './place.js';
11
11
  /**
12
12
  * What a node's own contents make of it, before anything is placed: a leaf's
@@ -31,12 +31,15 @@ function factsFor(node, measurer, fontSize) {
31
31
  const venn = vennOf(node);
32
32
  const circle = isCircle(node);
33
33
  // A deck that draws no outline of its own is its cards, with nothing round them.
34
- const undrawnDeck = node.appearance['layout'] === 'deck' && node.appearance['fill'] === undefined && node.appearance['border'] === undefined;
34
+ const undrawnDeck = node.appearance['layout'] === 'deck' && !outlineStated(node.appearance);
35
+ // A thick border is drawn inside the outline, so the padding is measured
36
+ // from its inner edge and the box grows by the extra (design.org 9.1).
37
+ const extra = (node.borderWidth ?? BORDER_WIDTH) - BORDER_WIDTH;
35
38
  const padding = node.body.kind === 'none' || (undrawnDeck && node.appearance['padding'] === undefined)
36
39
  ? 0
37
- : node.appearance['padding'] !== undefined
40
+ : (node.appearance['padding'] !== undefined
38
41
  ? gapPixels(node.appearance['padding'], `"${node.name}" padding:`, node.line)
39
- : PAD;
42
+ : PAD) + extra;
40
43
  // `padding-right: 4`: one side's padding, over `padding:` there.
41
44
  const pad = { top: padding, right: padding, bottom: padding, left: padding };
42
45
  for (const side of PADDING_SIDES) {
@@ -46,7 +49,7 @@ function factsFor(node, measurer, fontSize) {
46
49
  if (circle) {
47
50
  throw new SourceError(`"${node.name}" padding-${side}: a circle has no sides to pad one of — use padding:`, node.line);
48
51
  }
49
- pad[side] = gapPixels(written, `"${node.name}" padding-${side}:`, node.line);
52
+ pad[side] = gapPixels(written, `"${node.name}" padding-${side}:`, node.line) + extra;
50
53
  }
51
54
  // A subprocess's bars stand inside its sides, so its text keeps its padding clear of them.
52
55
  if (node.body.kind === 'shape' && node.body.outline === 'flow-subprocess') {
@@ -486,6 +489,7 @@ function buildMarks(named, placed, styles, texts = new Map()) {
486
489
  else if (text) {
487
490
  box = pointTextBox(at.x, at.y, text);
488
491
  }
492
+ const borderWidth = borderWidthOf(`region "${name}"`, appearance['border.thickness'], stmt.line);
489
493
  marks.push({
490
494
  kind: stmt.kind,
491
495
  name,
@@ -493,6 +497,7 @@ function buildMarks(named, placed, styles, texts = new Map()) {
493
497
  names,
494
498
  appearance,
495
499
  guide: stmt.guide === true,
500
+ ...(borderWidth !== BORDER_WIDTH ? { borderWidth } : {}),
496
501
  ...(text && box ? { text: { lines: text.lines, textAttrs: text.textAttrs, size: text.size, lineHeight: text.lineHeight, box } } : {}),
497
502
  line: stmt.line,
498
503
  });
@@ -781,6 +786,10 @@ function buildTree(statements, styles, defaults, icons) {
781
786
  // the renderer does for its colors; its own words beat both.
782
787
  const guideLook = stmt.guide ? defaults.get('guide node')?.['corners'] : undefined;
783
788
  node.corners = cornersOf(node, stmt.attrs['corners'] ?? guideLook ?? appearance['corners'], stmt.attrs['corners'] !== undefined);
789
+ const guideBorder = stmt.guide ? defaults.get('guide node')?.['border.thickness'] : undefined;
790
+ const borderWidth = borderWidthOf(`"${node.name}"`, stmt.attrs['border.thickness'] ?? guideBorder ?? appearance['border.thickness'], stmt.line);
791
+ if (borderWidth !== BORDER_WIDTH)
792
+ node.borderWidth = borderWidth;
784
793
  const cut = stmt.name.lastIndexOf('.');
785
794
  if (cut === -1) {
786
795
  roots.push(node);
@@ -1097,7 +1106,7 @@ function checkAttrs(kind, name, attrs, line) {
1097
1106
  // Quoted back the way it was written. A bracketed value arrives one dotted
1098
1107
  // key at a time, and `contents: match` is not a line anybody could look for.
1099
1108
  // A deck's cards are stored joined on a line break; quote them back as written.
1100
- const shown = key === 'cards' ? value.split('\n').map((text) => `"${text}"`).join(' ') : value;
1109
+ const shown = key === 'cards' ? value.split('\n').map((text) => `"${text}"`).join(' ') : key === 'title' ? `"${value}"` : value;
1101
1110
  const wrote = written === key ? `${key}: ${shown}` : `${key}: (${written.slice(key.length + 1)}: ${shown})`;
1102
1111
  if (!ALL_ATTR_KEYS.includes(key)) {
1103
1112
  // Nothing anywhere in the language answers to this word, so the only
@@ -1144,6 +1153,23 @@ function checkAttrs(kind, name, attrs, line) {
1144
1153
  * node drawn as a picture or with no body has no outline; `checkAttrs` has
1145
1154
  * already refused the word written on one, and a style's is unused.
1146
1155
  */
1156
+ /**
1157
+ * A border's thickness in pixels: `thin`, `normal`, `thick`, a multiple of
1158
+ * normal, or `px`, read as a line's is (design.org 3.8).
1159
+ */
1160
+ export function borderWidthOf(what, value, line) {
1161
+ if (value === undefined)
1162
+ return BORDER_WIDTH;
1163
+ const named = BORDER_THICKNESS[value];
1164
+ if (named !== undefined)
1165
+ return named;
1166
+ const number = /^(\d*\.?\d+)(px)?$/.exec(value);
1167
+ if (!number || Number(number[1]) <= 0) {
1168
+ throw new SourceError(`${what}: "border: (thickness: ${value})" — a thickness is thin, normal, thick, ` +
1169
+ 'a multiple of normal greater than zero such as `thickness: 2`, or pixels such as `thickness: 3px`', line);
1170
+ }
1171
+ return number[2] ? Number(number[1]) : BORDER_WIDTH * Number(number[1]);
1172
+ }
1147
1173
  function cornersOf(node, value, written) {
1148
1174
  if (node.body.kind !== 'shape' || value === undefined)
1149
1175
  return NODE_CORNERS[0];
package/dist/themes.d.ts CHANGED
@@ -72,6 +72,7 @@ export declare function themeColor(value: string, theme: Theme): string;
72
72
  * A hand-picked fill is left alone — the theme has no say over it.
73
73
  */
74
74
  export declare function textOnFill(fill: string, theme: Theme): string | undefined;
75
+ export declare function luminance(hex: string): number;
75
76
  /**
76
77
  * Sampled out of `examples/reference/arch.png` rather than invented,
77
78
  * so the benchmark render and the drawing it is measured against differ by
package/dist/themes.js CHANGED
@@ -86,7 +86,7 @@ export function textOnFill(fill, theme) {
86
86
  const apart = (color) => Math.abs(luminance(color) - under);
87
87
  return apart(theme.text) >= apart(theme.background) ? theme.text : theme.background;
88
88
  }
89
- function luminance(hex) {
89
+ export function luminance(hex) {
90
90
  const [r, g, b] = [1, 3, 5].map((at) => {
91
91
  const c = parseInt(hex.slice(at, at + 2), 16) / 255;
92
92
  return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The pan and zoom shared by the page `render --page` writes and the
3
+ * playground, so the two look at a diagram the same way: − and +, Fit, 100%,
4
+ * the zoom always shown and saying "Fit" while fitted, the wheel, a pinch, a
5
+ * drag, a double-click, and the keys + - 0 1.
6
+ *
7
+ * It is browser code kept as a string, since it runs in a page, not here: the
8
+ * page writes it into its script, and the playground's build writes it into
9
+ * its own. Calling `panZoom(view, stage, label, buttons)` returns the controls
10
+ * the page and the playground need beyond it.
11
+ *
12
+ * `view` clips and takes the pointer; `stage` holds the SVG and is moved
13
+ * inside it; `label` shows the zoom; `buttons` holds the elements for out, in,
14
+ * fit and 100%, any of which may be missing.
15
+ */
16
+ export declare const PAN_ZOOM: string;
package/dist/viewer.js ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The pan and zoom shared by the page `render --page` writes and the
3
+ * playground, so the two look at a diagram the same way: − and +, Fit, 100%,
4
+ * the zoom always shown and saying "Fit" while fitted, the wheel, a pinch, a
5
+ * drag, a double-click, and the keys + - 0 1.
6
+ *
7
+ * It is browser code kept as a string, since it runs in a page, not here: the
8
+ * page writes it into its script, and the playground's build writes it into
9
+ * its own. Calling `panZoom(view, stage, label, buttons)` returns the controls
10
+ * the page and the playground need beyond it.
11
+ *
12
+ * `view` clips and takes the pointer; `stage` holds the SVG and is moved
13
+ * inside it; `label` shows the zoom; `buttons` holds the elements for out, in,
14
+ * fit and 100%, any of which may be missing.
15
+ */
16
+ export const PAN_ZOOM = String.raw `
17
+ function panZoom(view, stage, label, buttons) {
18
+ var MIN = 0.05;
19
+ var MAX = 20;
20
+ var svg = null;
21
+ var natural = { w: 1, h: 1 };
22
+ // The drawing is resized rather than scaled with a transform, so the
23
+ // browser draws it afresh at every size and it stays sharp.
24
+ var at = { x: 0, y: 0, s: 1 };
25
+ // Until the reader pans or zooms, a change of size redraws the view the way
26
+ // it was asked for: as it opened, or fitted.
27
+ var fitted = 'open';
28
+
29
+ function apply() {
30
+ if (!svg) {
31
+ label.textContent = '';
32
+ return;
33
+ }
34
+ svg.setAttribute('width', natural.w * at.s);
35
+ svg.setAttribute('height', natural.h * at.s);
36
+ stage.style.transform = 'translate(' + at.x + 'px,' + at.y + 'px)';
37
+ // The zoom always shows. Fitted, it says so too, since 62% alone doesn't
38
+ // say whether that is the whole diagram or a zoom that happens to be 62%.
39
+ // A large diagram opens fitted; a small one opens at 100%, not fitted.
40
+ var isFit = fitted === 'fit' || (fitted === 'open' && at.s < 1);
41
+ label.innerHTML = (isFit ? '<span class="fitted">Fit</span> · ' : '') + Math.round(at.s * 100) + '%';
42
+ }
43
+
44
+ function size() {
45
+ return { w: view.clientWidth, h: view.clientHeight };
46
+ }
47
+
48
+ function center(s) {
49
+ var room = size();
50
+ at = { s: s, x: (room.w - natural.w * s) / 2, y: (room.h - natural.h * s) / 2 };
51
+ apply();
52
+ }
53
+
54
+ function fitScale() {
55
+ var room = size();
56
+ return Math.max(MIN, Math.min((room.w - 32) / natural.w, (room.h - 32) / natural.h));
57
+ }
58
+
59
+ function fit() {
60
+ fitted = 'fit';
61
+ center(fitScale());
62
+ }
63
+
64
+ function actual() {
65
+ fitted = false;
66
+ center(1);
67
+ }
68
+
69
+ // A diagram smaller than the view opens at its own size; a larger one
70
+ // opens fitted.
71
+ function open() {
72
+ fitted = 'open';
73
+ center(Math.min(1, fitScale()));
74
+ }
75
+
76
+ function refit() {
77
+ if (!svg) return;
78
+ if (fitted === 'fit') fit();
79
+ else if (fitted === 'open') open();
80
+ else apply();
81
+ }
82
+
83
+ // Zoom about a point of the view, which stays where it is on screen.
84
+ function zoomAt(factor, px, py) {
85
+ if (!svg) return;
86
+ var s = Math.min(MAX, Math.max(MIN, at.s * factor));
87
+ var k = s / at.s;
88
+ at = { s: s, x: px - (px - at.x) * k, y: py - (py - at.y) * k };
89
+ fitted = false;
90
+ apply();
91
+ }
92
+
93
+ function zoomMiddle(factor) {
94
+ var room = size();
95
+ zoomAt(factor, room.w / 2, room.h / 2);
96
+ }
97
+
98
+ function pan(dx, dy) {
99
+ at.x += dx;
100
+ at.y += dy;
101
+ fitted = false;
102
+ apply();
103
+ }
104
+
105
+ // A new drawing in the stage. The view stays as the reader left it: fitted
106
+ // stays fitted to the new drawing, and a zoom keeps its scale and place.
107
+ function show() {
108
+ svg = stage.querySelector('svg');
109
+ if (!svg) return apply();
110
+ var box = svg.viewBox.baseVal;
111
+ natural = box && box.width ? { w: box.width, h: box.height } : { w: parseFloat(svg.getAttribute('width')) || 1, h: parseFloat(svg.getAttribute('height')) || 1 };
112
+ refit();
113
+ }
114
+
115
+ if (buttons.out) buttons.out.onclick = function () { zoomMiddle(0.8); };
116
+ if (buttons.in) buttons.in.onclick = function () { zoomMiddle(1.25); };
117
+ if (buttons.fit) buttons.fit.onclick = fit;
118
+ if (buttons.actual) buttons.actual.onclick = actual;
119
+
120
+ view.addEventListener('wheel', function (event) {
121
+ event.preventDefault();
122
+ var rect = view.getBoundingClientRect();
123
+ // A pinch on a trackpad arrives as a wheel with ctrl held, in small steps.
124
+ var step = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
125
+ zoomAt(Math.exp(-step * (event.ctrlKey ? 0.01 : 0.0015)), event.clientX - rect.left, event.clientY - rect.top);
126
+ }, { passive: false });
127
+
128
+ // Dragging pans; two fingers pinch.
129
+ var pointers = new Map();
130
+ var pinch = null;
131
+ view.addEventListener('pointerdown', function (event) {
132
+ if (event.button !== 0) return;
133
+ if (event.target.closest('a')) return;
134
+ view.setPointerCapture(event.pointerId);
135
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
136
+ if (pointers.size === 2) {
137
+ var two = Array.from(pointers.values());
138
+ pinch = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
139
+ }
140
+ view.classList.add('dragging');
141
+ });
142
+ view.addEventListener('pointermove', function (event) {
143
+ var last = pointers.get(event.pointerId);
144
+ if (!last) return;
145
+ var now = { x: event.clientX, y: event.clientY };
146
+ pointers.set(event.pointerId, now);
147
+ if (pointers.size === 2 && pinch) {
148
+ var two = Array.from(pointers.values());
149
+ var apart = Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y);
150
+ var rect = view.getBoundingClientRect();
151
+ zoomAt(apart / pinch, (two[0].x + two[1].x) / 2 - rect.left, (two[0].y + two[1].y) / 2 - rect.top);
152
+ pinch = apart;
153
+ return;
154
+ }
155
+ pan(now.x - last.x, now.y - last.y);
156
+ });
157
+ function release(event) {
158
+ pointers.delete(event.pointerId);
159
+ if (pointers.size < 2) pinch = null;
160
+ if (pointers.size === 0) view.classList.remove('dragging');
161
+ }
162
+ view.addEventListener('pointerup', release);
163
+ view.addEventListener('pointercancel', release);
164
+ view.addEventListener('dblclick', function (event) {
165
+ var rect = view.getBoundingClientRect();
166
+ zoomAt(event.shiftKey ? 0.5 : 2, event.clientX - rect.left, event.clientY - rect.top);
167
+ });
168
+
169
+ // The view's own size, not the window's: a dragged divider changes it too.
170
+ new ResizeObserver(refit).observe(view);
171
+
172
+ document.addEventListener('keydown', function (event) {
173
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
174
+ if (event.target.closest && event.target.closest('input, textarea, select, [contenteditable]')) return;
175
+ if (event.key === '+' || event.key === '=') zoomMiddle(1.25);
176
+ else if (event.key === '-' || event.key === '_') zoomMiddle(0.8);
177
+ else if (event.key === '0') fit();
178
+ else if (event.key === '1') actual();
179
+ else return;
180
+ event.preventDefault();
181
+ });
182
+
183
+ return { show: show, open: open, fit: fit, pan: pan };
184
+ }
185
+ `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "main": "./dist/index.js",
10
10
  "types": "./dist/index.d.ts",
11
11
  "files": [
12
+ ".claude/skills",
12
13
  "dist",
13
14
  "NOTICE",
14
15
  "SYNTAX.md"