aontu 0.54.0 → 0.55.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/src/view.ts CHANGED
@@ -38,10 +38,10 @@
38
38
  // other code makes it `lossy`, which `--strict` refuses: a figure that
39
39
  // quietly omits things is the failure this capability exists to avoid.
40
40
 
41
- import { basename, dirname, isAbsolute, relative, resolve } from 'path'
41
+ import { basename, dirname, isAbsolute, relative, resolve } from 'node:path'
42
42
 
43
43
  import { Aontu } from './aontu'
44
- import { failureFinding, anchorAt } from './vet'
44
+ import { failureFinding, anchorAt, throughResidue } from './vet'
45
45
  import type { VetFinding } from './vet'
46
46
  import type { TrustOptions } from './type'
47
47
  import { graphOf } from './graph'
@@ -57,7 +57,8 @@ import type { SubsumeProfile } from './subsume'
57
57
  export type ViewVerdict = 'rendered' | 'lossy' | 'error'
58
58
 
59
59
  export type ViewKind =
60
- 'tree' | 'matrix' | 'graph' | 'layer' | 'sets' | 'layers' | 'ladder' | 'poset'
60
+ 'tree' | 'matrix' | 'graph' | 'layer' | 'sets' | 'layers' | 'ladder'
61
+ | 'poset' | 'doc'
61
62
 
62
63
  // The target grammars. Each kind declares the profiles it can render
63
64
  // into, and the first is its default (PROFILES below).
@@ -75,6 +76,68 @@ export type ViewOrder = 'canon' | 'partition'
75
76
  // existed.
76
77
  export type ViewEdges = 'upward' | 'all' | 'none'
77
78
 
79
+ // STYLING (VIEWS.0.md, "7. Styling"), which amends that note's colour
80
+ // boundary. Every mark a figure makes already has a reason the
81
+ // extractor established -- a cell is `direct` because the edge is
82
+ // declared, an arrow is `upward` because it runs against the bands --
83
+ // and the SVG profile has published those reasons as classes since it
84
+ // landed, because an SVG cannot be drawn without saying what each
85
+ // shape is. This declares the same vocabulary for the text profile and
86
+ // adds the one thing missing: a way to turn it on at the call.
87
+ //
88
+ // NEITHER MECHANISM STATES A COLOUR, which is what keeps the boundary
89
+ // intact. SGR 31 does not mean red; it means the colour the reader's
90
+ // terminal calls red, which the reader chose. A CSS class states
91
+ // nothing at all, and the stylesheet reads `var(--av-closure, ...)` so
92
+ // a host page's palette wins. A hex triple is the thing that cannot
93
+ // follow a theme, and it stays refused -- no truecolour escape, no
94
+ // 256-colour escape, no `classDef`.
95
+ export type ViewRole =
96
+ 'label' | 'muted' | 'rule' | 'direct' | 'closure' | 'unmirrored'
97
+ | 'upward' | 'repeat' | 'bar' | 'hole'
98
+
99
+ // `none` is plain characters, and an SVG carrying its classes but not
100
+ // the embedded stylesheet -- what a host page wants once it has bound
101
+ // the variables and is embedding eight figures. `ansi` is the text
102
+ // profile's mechanism and `css` the SVG's; asking for either on the
103
+ // wrong profile is a usage error.
104
+ //
105
+ // `auto` IS NOT HERE ON PURPOSE. Resolving it means knowing whether
106
+ // the destination is a terminal, which err.ts already settles for the
107
+ // error frames: a library cannot see its destination and a caller who
108
+ // can is the only one who may decide. The CLI maps `auto`; `viewOf`
109
+ // takes a resolved value, so every shared-spec row is deterministic.
110
+ export type ViewStyle = 'none' | 'ansi' | 'css'
111
+
112
+ // The text profile's mechanism: the eight named colours, `bold` and
113
+ // `dim`, and nothing else. `label` is unstyled -- an entity's own name
114
+ // is the figure's content, not a mark about it.
115
+ const SGR: Record<ViewRole, string> = {
116
+ label: '', muted: '2', rule: '2', direct: '1', closure: '36',
117
+ unmirrored: '33', upward: '31', repeat: '2', bar: '36', hole: '2',
118
+ }
119
+
120
+ // A painter wraps a run of text in its role's mechanism. It NEVER
121
+ // changes the run's length in characters, so every width the renderers
122
+ // computed from the unpainted strings still holds.
123
+ type Paint = (role: ViewRole, text: string) => string
124
+
125
+ const PLAIN: Paint = (_role, text) => text
126
+
127
+ const ANSI: Paint = (role, text) =>
128
+ '' === SGR[role] || '' === text
129
+ ? text : `\x1b[${SGR[role]}m${text}\x1b[0m`
130
+
131
+ const painter = (style: ViewStyle): Paint => 'ansi' === style ? ANSI : PLAIN
132
+
133
+ // The style a figure gets when the caller named none. An SVG carries
134
+ // its stylesheet, which is what makes it standalone and what every
135
+ // pinned golden holds; everything else carries no mechanism, since a
136
+ // library cannot see whether its output is a terminal.
137
+ const styleOf = (
138
+ style: ViewStyle | undefined, as: ViewProfile
139
+ ): ViewStyle => style ?? ('svg' === as ? 'css' : 'none')
140
+
78
141
  // One row of the loss report.
79
142
  export type ViewLoss = {
80
143
  code: string
@@ -190,6 +253,10 @@ export type ViewOptions = {
190
253
  sets?: string
191
254
  member?: string
192
255
  universe?: string
256
+ // doc: how many levels of key below the anchor to draw. Absent
257
+ // means three, which is the depth at which a model's shape is
258
+ // legible and its data is not yet enumerated.
259
+ depth?: number
193
260
  // sets: drop intersections below this degree.
194
261
  minDegree?: number
195
262
  // sets, layers: elide columns beyond this many, counted in the loss
@@ -208,6 +275,14 @@ export type ViewOptions = {
208
275
  // document, only once every figure of the set rendered.
209
276
  out?: string
210
277
 
278
+ // How the figure is styled (VIEWS.0.md, "7. Styling"). Absent means
279
+ // `none`: plain characters, and an SVG carrying its classes without
280
+ // the embedded stylesheet. THE CALLER RESOLVES `auto` -- see
281
+ // ViewStyle. A figure written to a file is written plain whatever
282
+ // this says, which the CLI enforces: a pinned golden with terminal
283
+ // escapes in it is not a golden anybody can read.
284
+ style?: ViewStyle
285
+
211
286
  // The VIEW DOCUMENT (VIEWS.0.md, "6. The view document"): the path of
212
287
  // a map whose values declare figures. `viewSet` reads it; `view`
213
288
  // ignores it, because one call draws one figure.
@@ -219,6 +294,7 @@ export type ViewOptions = {
219
294
  // global default, because there is no sensible text form of a
220
295
  // node-link drawing and no sensible Mermaid form of a matrix.
221
296
  const PROFILES: Record<ViewKind, ViewProfile[]> = {
297
+ doc: ['text', 'svg'],
222
298
  tree: ['text', 'svg'],
223
299
  matrix: ['text', 'svg'],
224
300
  graph: ['mermaid', 'dot', 'er'],
@@ -229,6 +305,14 @@ const PROFILES: Record<ViewKind, ViewProfile[]> = {
229
305
  poset: ['mermaid', 'dot'],
230
306
  }
231
307
 
308
+ // The profile a kind draws into when none is asked for. The CLI needs
309
+ // it to resolve `--style auto` BEFORE the library runs, since the
310
+ // mechanism is the profile's.
311
+ export function viewDefaultProfile(kind: ViewKind): ViewProfile | undefined {
312
+ return PROFILES[kind]?.[0]
313
+ }
314
+
315
+
232
316
  // Loss codes that describe the drawing rather than a gap in it.
233
317
  const INFORMATIONAL = ['edges_deduped', 'inverse_suppressed', 'crossings']
234
318
 
@@ -564,11 +648,18 @@ const svgEsc = (s: string): string => escape(s, SVG_ESC)
564
648
 
565
649
  // The document: a viewBox the size of the figure, the style, and the
566
650
  // parts, one per line, so the bytes read as a figure and diff as one.
567
- function svgDoc(w: number, h: number, about: string, parts: string[]): string {
651
+ function svgDoc(
652
+ w: number, h: number, about: string, parts: string[], style: ViewStyle
653
+ ): string {
654
+ // The CLASSES are structure and are always written -- a rect that
655
+ // does not say whether it is a direct cell or a closure cell is not
656
+ // a figure. What `--style none` drops is the STYLESHEET, for a host
657
+ // page that has already bound the variables and would otherwise
658
+ // carry one copy of these rules per embedded figure.
568
659
  return [
569
660
  `<svg xmlns="http://www.w3.org/2000/svg" class="av" viewBox="0 0 ${w} ${h}" ` +
570
661
  `width="${w}" height="${h}" role="img" aria-label="${svgEsc(about)}">`,
571
- SVG_STYLE,
662
+ ...('css' === style ? [SVG_STYLE] : []),
572
663
  ...parts,
573
664
  '</svg>',
574
665
  ].join('\n')
@@ -688,8 +779,9 @@ type TreeRow = { depth: number, text: string, mark: string, parent: number }
688
779
 
689
780
  function drawTree(
690
781
  all: Drawn[], relation: string | undefined, roots: string[], max: number,
691
- as: ViewProfile
782
+ as: ViewProfile, style: ViewStyle
692
783
  ): Figure {
784
+ const paint = painter(style)
693
785
  // With a relation named, the tree is OVER THAT RELATION. A node-link
694
786
  // diagram can label each edge and so draw every relation at once; a
695
787
  // tree cannot without becoming unreadable, and walking two relations
@@ -785,7 +877,8 @@ function drawTree(
785
877
  const grown = 0 < (kids.get(edge.to) as Kid[]).length
786
878
  const text = label(edge.to) + (many ? ' (' + edge.label + ')' : '')
787
879
  const mark = loop ? ' (cycle)' : (seen && grown ? ' (*)' : '')
788
- out.push(frame.prefix + (last ? '└── ' : '├── ') + text + mark)
880
+ out.push(paint('rule', frame.prefix + (last ? '└── ' : '├── '))
881
+ + text + paint('repeat', mark))
789
882
  rows.push({ depth: stack.length, text, mark, parent: frame.row })
790
883
  if (loop || seen) {
791
884
  continue
@@ -820,7 +913,11 @@ function drawTree(
820
913
  }
821
914
  }
822
915
 
823
- return { text: 'svg' === as ? treeSvg(rows, nodes.length) : out.join('\n') }
916
+ return {
917
+ text: 'svg' === as
918
+ ? treeSvg(rows, `Dependency tree: ${nodes.length} nodes`, style)
919
+ : out.join('\n'),
920
+ }
824
921
  }
825
922
 
826
923
 
@@ -828,7 +925,9 @@ function drawTree(
828
925
  // depth, joined to its parent by a path that drops from the parent's
829
926
  // row and turns in to the child. The marks are muted text after the
830
927
  // label.
831
- function treeSvg(rows: (TreeRow | null)[], count: number): string {
928
+ function treeSvg(
929
+ rows: (TreeRow | null)[], about: string, style: ViewStyle
930
+ ): string {
832
931
  const U = 24
833
932
  const parts: string[] = []
834
933
  let width = 0
@@ -848,11 +947,162 @@ function treeSvg(rows: (TreeRow | null)[], count: number): string {
848
947
  `</tspan><tspan class="av-m">${svgEsc(r.mark)}</tspan></text>`)
849
948
  width = Math.max(width, x + (r.text.length + r.mark.length) * CH)
850
949
  })
851
- return svgDoc(width + PAD, rows.length * LH + PAD,
852
- `Dependency tree: ${count} nodes`, parts)
950
+ return svgDoc(width + PAD, rows.length * LH + PAD, about, parts, style)
853
951
  }
854
952
 
855
953
 
954
+ // ---------------------------------------------------------------------
955
+ // The document tree
956
+
957
+ // THE SHAPE OF THE MODEL ITSELF, which no other kind draws. Every
958
+ // other figure here reads a REPORT -- the edge set, the provenance
959
+ // record, the subsumption order -- and so can only draw a document
960
+ // that has links, contributions or peers. A reader meeting a model for
961
+ // the first time wants the plainer thing first: what is in it, and how
962
+ // it is arranged.
963
+ //
964
+ // This is `get --keys --types` as a picture, and it reads the same
965
+ // walk: map keys in code-point order, list indices in order, and a
966
+ // leaf's KIND rather than its value -- the canon of a scalar's type,
967
+ // not the scalar. Values are what the document is for; the shape is
968
+ // what a reader needs before any of them mean anything.
969
+ //
970
+ // DEPTH IS A BOUND, NOT AN ELISION MARK. Below it the subtree is not
971
+ // drawn and the row says how many keys were not drawn, because a tree
972
+ // that stops without saying so is the one thing a structural drawing
973
+ // must not be.
974
+
975
+ const DEFAULT_DOC_DEPTH = 3
976
+
977
+ // A node's own children, as the anchor walk sees them: map keys sorted
978
+ // by code point, list indices in order, and nothing for a leaf.
979
+ function docKids(v: any): string[] {
980
+ const node: any = throughDoc(v)
981
+ if (true === node?.isMap) {
982
+ // AN ALIAS DECLARATION IS NOT PART OF THE DOCUMENT
983
+ // (docs/reference-language.md, "Aliases"): it does not generate
984
+ // and it does not appear in canon. It IS a key of the root map in
985
+ // the value tree, which `get --keys` reports and this does not --
986
+ // a figure of the document's shape that showed `%Cents` beside
987
+ // `customers` would be drawing the declaration as data
988
+ // (use-cases/BUGS.md 74).
989
+ return Object.keys(node.peg)
990
+ .filter((k) => !k.startsWith('%')).sort(cmpCodePoint)
991
+ }
992
+ if (true === node?.isList) {
993
+ return Object.keys(node.peg).filter((k) => /^[0-9]+$/.test(k))
994
+ }
995
+ return []
996
+ }
997
+
998
+ // A preference wraps its value without being a level of its own, and
999
+ // `anchorAt` already steps through a sizing residue; this is the same
1000
+ // unwrapping, for the shape walk.
1001
+ function throughDoc(v: any): any {
1002
+ // Every caller reaches this with a Val the anchor walk handed over,
1003
+ // so the node is never absent and the optional chain that would say
1004
+ // otherwise is an arm no test can take.
1005
+ const node = throughResidue(v)
1006
+ return true === node.isPref ? throughDoc(node.peg) : node
1007
+ }
1008
+
1009
+ // What a leaf IS, in one short word: its canon, which for a constraint
1010
+ // is the constraint and for a scalar its value. Long canons are cut,
1011
+ // since the figure is the shape and not the data.
1012
+ function docLeaf(v: any): string {
1013
+ // A CONTAINER WITH NOTHING IN IT IS NOT A LEAF, and calling it one
1014
+ // by writing nothing after the key would make it read as a value the
1015
+ // figure declined to describe. Its canon says what it is -- `{}`,
1016
+ // `[]`, or a template a spread wrote and no member filled.
1017
+ //
1018
+ // `canon` is a string on every Val, so there is no other-type arm to
1019
+ // take; the cut is the only decision here.
1020
+ const canon: string = throughDoc(v).canon
1021
+ return 32 < canon.length ? canon.slice(0, 29) + '...' : canon
1022
+ }
1023
+
1024
+
1025
+ function drawDoc(
1026
+ root: any,
1027
+ o: { at?: string, depth?: number, as: ViewProfile, style: ViewStyle },
1028
+ max: number, loss: ViewLoss[]
1029
+ ): Figure {
1030
+ const paint = painter(o.style)
1031
+ const at = o.at ?? '$'
1032
+ const anchor = anchorAt(root, at)
1033
+ if (null == anchor) {
1034
+ return {
1035
+ // The same code and the same sentence `get` answers with: the
1036
+ // question is identical, so a caller that already handles one
1037
+ // handles the other.
1038
+ errors: [finding('no_path', 'reference', at,
1039
+ `The path ${at} names nothing in this document.`)],
1040
+ }
1041
+ }
1042
+ const depth = o.depth ?? DEFAULT_DOC_DEPTH
1043
+ const out: string[] = []
1044
+ const rows: (TreeRow | null)[] = []
1045
+ let elided = 0
1046
+
1047
+ out.push(at)
1048
+ rows.push({ depth: 0, text: at, mark: '', parent: 0 })
1049
+
1050
+ // ITERATIVE, like the dependency tree's walk and for the same
1051
+ // reason: a deep model is a real shape, and the drawing of one must
1052
+ // not depend on how deep the interpreter lets a recursion go.
1053
+ type Frame = { node: any, kids: string[], at: number, prefix: string, row: number }
1054
+ const stack: Frame[] = [
1055
+ { node: anchor, kids: docKids(anchor), at: 0, prefix: '', row: 0 },
1056
+ ]
1057
+ while (0 < stack.length) {
1058
+ const frame = stack[stack.length - 1]
1059
+ if (frame.at >= frame.kids.length) {
1060
+ stack.pop()
1061
+ continue
1062
+ }
1063
+ const key = frame.kids[frame.at++]
1064
+ const last = frame.at === frame.kids.length
1065
+ const child = throughDoc(throughDoc(frame.node).peg[key])
1066
+ const kids = docKids(child)
1067
+ const under = stack.length < depth
1068
+ // A container the depth bound stops at says how many keys are not
1069
+ // drawn; a leaf says what it is.
1070
+ // A leaf says what it is and a stopped container says how many
1071
+ // keys it holds; both are written after the key with one space,
1072
+ // and neither is ever empty (a canon has at least one character).
1073
+ const mark = 0 === kids.length ? ' ' + docLeaf(child)
1074
+ : under ? '' : ` (${kids.length})`
1075
+ if (0 < kids.length && !under) {
1076
+ elided += kids.length
1077
+ }
1078
+ out.push(paint('rule', frame.prefix + (last ? '└── ' : '├── ')) + key +
1079
+ paint('muted', mark))
1080
+ rows.push({ depth: stack.length, text: key, mark, parent: frame.row })
1081
+ if (max < rows.length) {
1082
+ return {
1083
+ errors: [rowsFinding(rows.length, max, '--at or --depth')],
1084
+ }
1085
+ }
1086
+ if (0 < kids.length && under) {
1087
+ stack.push({
1088
+ node: child, kids, at: 0,
1089
+ prefix: frame.prefix + (last ? ' ' : '│ '),
1090
+ row: rows.length - 1,
1091
+ })
1092
+ }
1093
+ }
1094
+ if (0 < elided) {
1095
+ loss.push({ code: 'depth_elided', count: elided })
1096
+ }
1097
+ return {
1098
+ text: 'svg' === o.as
1099
+ ? treeSvg(rows,
1100
+ `Document tree at ${at}: ${rows.length - 1} keys to depth ${depth}`,
1101
+ o.style)
1102
+ : out.join('\n'),
1103
+ }
1104
+ }
1105
+
856
1106
  // ---------------------------------------------------------------------
857
1107
  // The matrix (Ghoniem et al. 2004; Sangal et al. 2005)
858
1108
 
@@ -925,9 +1175,13 @@ function pickRelation(
925
1175
 
926
1176
  function drawMatrix(
927
1177
  triples: Triple[], decls: RelDecls,
928
- o: { relation?: string, order: ViewOrder, closure: boolean, as: ViewProfile },
1178
+ o: {
1179
+ relation?: string, order: ViewOrder, closure: boolean, as: ViewProfile,
1180
+ style: ViewStyle,
1181
+ },
929
1182
  max: number, loss: ViewLoss[]
930
1183
  ): Figure {
1184
+ const paint = painter(o.style)
931
1185
  const picked = pickRelation(o.relation, keysOf(triples))
932
1186
  if (undefined !== picked.error) {
933
1187
  return { errors: [picked.error] }
@@ -972,7 +1226,7 @@ function drawMatrix(
972
1226
  // column stays one character wide.
973
1227
  for (let d = 0; d < iw; d++) {
974
1228
  lines.push(' '.repeat(w + 1 + iw + 1) +
975
- idx.map((s) => lpad(s, iw)[d]).join(' '))
1229
+ paint('muted', idx.map((s) => lpad(s, iw)[d]).join(' ')))
976
1230
  }
977
1231
 
978
1232
  let above = 0
@@ -992,15 +1246,16 @@ function drawMatrix(
992
1246
  })
993
1247
  grid.push(cells)
994
1248
  lines.push(
995
- pad(label(r), w) + ' ' + lpad(idx[ri], iw) + ' ' + cells.join(' '))
1249
+ pad(label(r), w) + ' ' + paint('muted', lpad(idx[ri], iw)) + ' ' +
1250
+ cells.map((g) => paint(CELL_ROLE[g], g)).join(' '))
996
1251
  })
997
1252
  const footer = `# above-diagonal direct cells: ${above}`
998
- lines.push(footer)
1253
+ lines.push(paint('muted', footer))
999
1254
  if ('svg' === o.as) {
1000
1255
  return {
1001
1256
  text: matrixSvg(order.map(label), idx, grid, footer,
1002
1257
  `Dependency matrix${over(relation)}: ${order.length} rows, ` +
1003
- `${above} direct cells above the diagonal`),
1258
+ `${above} direct cells above the diagonal`, o.style),
1004
1259
  }
1005
1260
  }
1006
1261
  return { text: lines.join('\n') }
@@ -1014,9 +1269,19 @@ const CELL_CLASS: Record<string, string> = {
1014
1269
  '.': 'av-cell', '\\': 'av-cell',
1015
1270
  }
1016
1271
 
1272
+ // The same five states as ROLES, for the text profile. One table per
1273
+ // mechanism rather than one shared one, because the two vocabularies
1274
+ // are not in step: SVG needs a class for the empty cell (it draws a
1275
+ // rect there) and the text profile has nothing to say about a `.`
1276
+ // beyond that it is not a mark.
1277
+ const CELL_ROLE: Record<string, ViewRole> = {
1278
+ X: 'direct', '!': 'unmirrored', '+': 'closure',
1279
+ '.': 'muted', '\\': 'rule',
1280
+ }
1281
+
1017
1282
  function matrixSvg(
1018
1283
  labels: string[], idx: string[], grid: string[][], footer: string,
1019
- about: string
1284
+ about: string, style: ViewStyle
1020
1285
  ): string {
1021
1286
  const S = 20
1022
1287
  const w = widest(labels)
@@ -1042,7 +1307,7 @@ function matrixSvg(
1042
1307
  const n = labels.length
1043
1308
  parts.push(svgText(4, y0 + n * S + 16, 'av-m', footer))
1044
1309
  const width = Math.max(gutter + n * S, 4 + footer.length * CH) + PAD
1045
- return svgDoc(width, y0 + n * S + LH + PAD, about, parts)
1310
+ return svgDoc(width, y0 + n * S + LH + PAD, about, parts, style)
1046
1311
  }
1047
1312
 
1048
1313
 
@@ -1272,7 +1537,7 @@ function drawLayer(
1272
1537
  triples: Triple[], root: any,
1273
1538
  o: {
1274
1539
  relation?: string, groupBy?: string, layers: string[],
1275
- edges?: ViewEdges, as: ViewProfile,
1540
+ edges?: ViewEdges, as: ViewProfile, style: ViewStyle,
1276
1541
  },
1277
1542
  max: number, loss: ViewLoss[]
1278
1543
  ): Figure {
@@ -1400,20 +1665,35 @@ function drawLayer(
1400
1665
  : `${upward} upward edges`
1401
1666
  return {
1402
1667
  text: layerSvg(bands, shown, footer,
1403
- `Architecture layers${over(relation)}: ${bands.length} bands, ${drew}`),
1668
+ `Architecture layers${over(relation)}: ${bands.length} bands, ${drew}`,
1669
+ o.style),
1404
1670
  }
1405
1671
  }
1406
1672
  if ('text' === o.as) {
1673
+ const paint = painter(o.style)
1407
1674
  const w = widest(bands.map((b) => b.name))
1408
1675
  const rows = bands.map((b) =>
1409
- pad(b.name, w) + ' ' + b.nodes.map((n) => n.label).join(' '))
1410
- const inner = widest(rows)
1411
- const rule = '+' + '-'.repeat(inner + 2) + '+'
1676
+ paint('muted', pad(b.name, w)) + ' ' +
1677
+ b.nodes.map((n) => n.label).join(' '))
1678
+ const inner = widest(bands.map((b) =>
1679
+ pad(b.name, w) + ' ' + b.nodes.map((n) => n.label).join(' ')))
1680
+ const rule = paint('rule', '+' + '-'.repeat(inner + 2) + '+')
1412
1681
  out.push(rule)
1413
- for (const row of rows) {
1414
- out.push('| ' + pad(row, inner) + ' |', rule)
1415
- }
1416
- out.push(...footer)
1682
+ rows.forEach((row, i) => {
1683
+ // The row was padded from its UNPAINTED width, which the band
1684
+ // name's escapes do not change; `pad` would count them, so the
1685
+ // padding is computed here and appended.
1686
+ const bare = pad(bands[i].name, w) + ' ' +
1687
+ bands[i].nodes.map((n) => n.label).join(' ')
1688
+ out.push(paint('rule', '|') + ' ' + row +
1689
+ ' '.repeat(inner - bare.length) + ' ' + paint('rule', '|'), rule)
1690
+ })
1691
+ // The first footer line counts; the rest name one edge each, and
1692
+ // an upward edge is the violation the bands cannot show.
1693
+ out.push(paint('muted', footer[0]))
1694
+ footer.slice(1).forEach((f, i) => {
1695
+ out.push(paint('upward' === shown[i].way ? 'upward' : 'muted', f))
1696
+ })
1417
1697
  }
1418
1698
  else {
1419
1699
  const esc = (s: string): string => escape(s, MERMAID_ESC)
@@ -1448,7 +1728,8 @@ type Drawing = { edge: GEdge, way: 'downward' | 'sideways' | 'upward' }
1448
1728
  // same line and a straight edge between them would cross whatever
1449
1729
  // stands between.
1450
1730
  function layerSvg(
1451
- bands: Band[], shown: Drawing[], footer: string[], about: string
1731
+ bands: Band[], shown: Drawing[], footer: string[], about: string,
1732
+ style: ViewStyle
1452
1733
  ): string {
1453
1734
  const BH = 44
1454
1735
  const gutter = widest(bands.map((b) => b.name)) * CH + 16
@@ -1512,7 +1793,7 @@ function layerSvg(
1512
1793
  footer.forEach((f, i) => {
1513
1794
  parts.push(svgText(4, y1 + i * LH + 14, 'av-m', f))
1514
1795
  })
1515
- return svgDoc(width, y1 + footer.length * LH + PAD, about, parts)
1796
+ return svgDoc(width, y1 + footer.length * LH + PAD, about, parts, style)
1516
1797
  }
1517
1798
 
1518
1799
 
@@ -1566,38 +1847,47 @@ function columnsOf(
1566
1847
  }
1567
1848
 
1568
1849
 
1569
- function renderPanel(p: Panel): string {
1850
+ function renderPanel(p: Panel, style: ViewStyle): string {
1851
+ const paint = painter(style)
1570
1852
  const w = widest(p.names)
1571
- const out: string[] = [p.header, '']
1853
+ const out: string[] = [paint('muted', p.header), '']
1572
1854
  const most = p.sizes.reduce((m, n) => Math.max(m, n), 0)
1573
1855
  p.names.forEach((n, i) => {
1856
+ // The bar is padded to `most` from its own length, so the pad is
1857
+ // written outside the painted run rather than counted inside it.
1858
+ const bar = '#'.repeat(p.sizes[i])
1574
1859
  out.push(pad(n, w) + ' ' +
1575
- (p.bars ? pad('#'.repeat(p.sizes[i]), most) + ' ' : '') +
1576
- String(p.sizes[i]))
1860
+ (p.bars ? paint('bar', bar) + ' '.repeat(most - bar.length) + ' ' : '') +
1861
+ paint('muted', String(p.sizes[i])))
1577
1862
  })
1578
1863
  out.push('')
1579
1864
  p.names.forEach((n, i) => {
1580
- out.push(pad(n, w) + ' | ' +
1581
- p.cols.map((c) => c.sig[i] ? '*' : '.').join(' '))
1865
+ out.push(pad(n, w) + ' ' + paint('rule', '|') + ' ' +
1866
+ p.cols.map((c) => c.sig[i] ? paint('direct', '*') : paint('hole', '.'))
1867
+ .join(' '))
1582
1868
  })
1583
- out.push(pad('', w) + ' +' + '-'.repeat(2 * p.cols.length))
1869
+ out.push(pad('', w) + ' ' + paint('rule', '+' + '-'.repeat(2 * p.cols.length)))
1584
1870
  if (p.bars) {
1585
1871
  const tallest = p.cols.reduce((m, c) => Math.max(m, c.items.length), 0)
1586
- // The bars, tallest column first; a line ends at its last bar.
1872
+ // The bars, tallest column first; a line ends at its last bar. The
1873
+ // trailing blanks are trimmed BEFORE painting, so an escape can
1874
+ // never be what the trim leaves behind.
1587
1875
  for (let h = tallest; 0 < h; h--) {
1588
- out.push((pad('', w) + ' |' +
1589
- p.cols.map((c) => h <= c.items.length ? ' #' : ' ').join(''))
1590
- .replace(/ +$/, ''))
1876
+ const cells = p.cols.map((c) => h <= c.items.length ? ' #' : ' ')
1877
+ .join('').replace(/ +$/, '')
1878
+ out.push(pad('', w) + ' ' + paint('rule', '|') +
1879
+ cells.replace(/#/g, () => paint('bar', '#')))
1591
1880
  }
1592
1881
  }
1593
1882
  out.push(pad('', w) + ' ' +
1594
- p.cols.map((c) => String(c.items.length)).join(' '))
1883
+ paint('muted', p.cols.map((c) => String(c.items.length)).join(' ')))
1595
1884
  out.push('')
1596
1885
  p.cols.forEach((c, i) => {
1597
1886
  const shown = 4 < c.items.length && !p.bars
1598
1887
  ? c.items.slice(0, 3).join(' ') + ' ...' : c.items.join(' ')
1599
- out.push(` col ${i + 1}${p.bars ? '' : ` (${c.items.length})`}: ${shown}` +
1600
- (c.sig.some((b) => b) ? '' : p.none))
1888
+ out.push(paint('muted',
1889
+ ` col ${i + 1}${p.bars ? '' : ` (${c.items.length})`}:`) +
1890
+ ` ${shown}` + (c.sig.some((b) => b) ? '' : paint('muted', p.none)))
1601
1891
  })
1602
1892
  return out.join('\n')
1603
1893
  }
@@ -1606,7 +1896,7 @@ function renderPanel(p: Panel): string {
1606
1896
  // The panel as SVG: the set sizes as bars, the intersections as a dot
1607
1897
  // matrix (a filled dot where the set lies in the column), the column
1608
1898
  // cardinalities as bars under it, and the columns' elements as text.
1609
- function panelSvg(p: Panel, about: string): string {
1899
+ function panelSvg(p: Panel, about: string, style: ViewStyle): string {
1610
1900
  const w = widest(p.names)
1611
1901
  const most = p.sizes.reduce((m, n) => Math.max(m, n), 0)
1612
1902
  const parts: string[] = [svgText(4, 14, 'av-m', p.header)]
@@ -1651,7 +1941,7 @@ function panelSvg(p: Panel, about: string): string {
1651
1941
  const width = Math.max(gx + p.cols.length * 20,
1652
1942
  gx + (p.bars ? most * 10 + 8 : 0) + 3 * CH,
1653
1943
  4 + widest(lines) * CH, 4 + p.header.length * CH) + PAD
1654
- return svgDoc(width, yI + lines.length * LH + PAD, about, parts)
1944
+ return svgDoc(width, yI + lines.length * LH + PAD, about, parts, style)
1655
1945
  }
1656
1946
 
1657
1947
 
@@ -1695,7 +1985,7 @@ function drawSets(
1695
1985
  gen: any,
1696
1986
  o: {
1697
1987
  sets: string, member: string, universe?: string,
1698
- minDegree?: number, maxCols?: number, as: ViewProfile,
1988
+ minDegree?: number, maxCols?: number, as: ViewProfile, style: ViewStyle,
1699
1989
  },
1700
1990
  max: number, loss: ViewLoss[]
1701
1991
  ): Figure {
@@ -1772,8 +2062,8 @@ function drawSets(
1772
2062
  return {
1773
2063
  text: 'svg' === o.as
1774
2064
  ? panelSvg(panel, `Set panel over ${o.sets}: ${names.length} sets, ` +
1775
- `${elements.size} elements, ${cols.length} intersections`)
1776
- : renderPanel(panel),
2065
+ `${elements.size} elements, ${cols.length} intersections`, o.style)
2066
+ : renderPanel(panel, o.style),
1777
2067
  }
1778
2068
  }
1779
2069
 
@@ -1791,7 +2081,10 @@ function docName(file: string, entry: string | undefined): string {
1791
2081
 
1792
2082
  function drawLayers(
1793
2083
  prov: Provenance, root: any, entry: string | undefined,
1794
- o: { at?: string, minSize?: number, maxCols?: number, as: ViewProfile },
2084
+ o: {
2085
+ at?: string, minSize?: number, maxCols?: number, as: ViewProfile,
2086
+ style: ViewStyle,
2087
+ },
1795
2088
  max: number, loss: ViewLoss[]
1796
2089
  ): Figure {
1797
2090
  // Every path something met at AND THE DOCUMENT HAS A VALUE AT,
@@ -1849,8 +2142,8 @@ function drawLayers(
1849
2142
  return {
1850
2143
  text: 'svg' === o.as
1851
2144
  ? panelSvg(panel, `Document layers: ${names.length} documents, ` +
1852
- `${paths.length} paths, ${cols.length} intersections`)
1853
- : renderPanel(panel),
2145
+ `${paths.length} paths, ${cols.length} intersections`, o.style)
2146
+ : renderPanel(panel, o.style),
1854
2147
  }
1855
2148
  }
1856
2149
 
@@ -2209,6 +2502,28 @@ export function view(
2209
2502
  `profiles: ${profiles.join(', ')}`)],
2210
2503
  })
2211
2504
  }
2505
+ // ONE MECHANISM PER PROFILE (VIEWS.0.md, "7. Styling"). `ansi` is
2506
+ // the text profile's and `css` the SVG's; asking for one on a
2507
+ // profile that has no way to carry it is a usage error rather than a
2508
+ // silent no-op, so a script that asks for colour and gets none is
2509
+ // told why. `none` is always available -- it is the absence of a
2510
+ // mechanism.
2511
+ const style: ViewStyle = styleOf(options.style, as)
2512
+ const carrier: Record<string, ViewProfile> = { ansi: 'text', css: 'svg' }
2513
+ if (undefined !== carrier[style] && carrier[style] !== as) {
2514
+ return done({
2515
+ errors: [finding('view_style_profile', 'reference', '$',
2516
+ `The ${as} profile cannot carry --style ${style}.`,
2517
+ `${style} is the ${carrier[style]} profile's mechanism`)],
2518
+ })
2519
+ }
2520
+ if (undefined === carrier[style] && 'none' !== style) {
2521
+ return done({
2522
+ errors: [finding('view_style_unknown', 'reference', '$',
2523
+ `${style} is not a style.`, 'styles: auto, none, ansi, css')],
2524
+ })
2525
+ }
2526
+
2212
2527
  // Zero means the default, in both ports.
2213
2528
  const max = options.maxRows || DEFAULT_MAX_ROWS
2214
2529
 
@@ -2249,9 +2564,13 @@ function drawLoaded(
2249
2564
  kind: ViewKind, as: ViewProfile, options: ViewOptions,
2250
2565
  max: number, loss: ViewLoss[]
2251
2566
  ): Figure {
2567
+ const style = styleOf(options.style, as)
2568
+ if ('doc' === kind) {
2569
+ return drawDoc(root, { ...options, as, style }, max, loss)
2570
+ }
2252
2571
  if ('layers' === kind) {
2253
2572
  return drawLayers(prov as Provenance, root, options.path,
2254
- { ...options, as }, max, loss)
2573
+ { ...options, as, style }, max, loss)
2255
2574
  }
2256
2575
  if ('sets' === kind) {
2257
2576
  if (undefined === options.sets || undefined === options.member) {
@@ -2277,7 +2596,7 @@ function drawLoaded(
2277
2596
  }
2278
2597
  return drawSets(value, {
2279
2598
  sets: options.sets, member: options.member, universe: options.universe,
2280
- minDegree: options.minDegree, maxCols: options.maxCols, as,
2599
+ minDegree: options.minDegree, maxCols: options.maxCols, as, style,
2281
2600
  }, max, loss)
2282
2601
  }
2283
2602
 
@@ -2289,7 +2608,7 @@ function drawLoaded(
2289
2608
  if ('matrix' === kind) {
2290
2609
  return drawMatrix(triples, decls, {
2291
2610
  relation, order: options.order ?? 'canon', closure: true === options.closure,
2292
- as,
2611
+ as, style,
2293
2612
  }, max, loss)
2294
2613
  }
2295
2614
  if ('graph' === kind) {
@@ -2301,11 +2620,11 @@ function drawLoaded(
2301
2620
  if ('layer' === kind) {
2302
2621
  return drawLayer(triples, root, {
2303
2622
  relation, groupBy: options.groupBy, layers: options.layers ?? [],
2304
- edges: options.edges, as,
2623
+ edges: options.edges, as, style,
2305
2624
  }, max, loss)
2306
2625
  }
2307
2626
  return drawTree(
2308
- collapse(triples, relation), relation, options.roots ?? [], max, as)
2627
+ collapse(triples, relation), relation, options.roots ?? [], max, as, style)
2309
2628
  }
2310
2629
 
2311
2630
 
@@ -2341,7 +2660,7 @@ const DECL_ENUM: Record<string, string[]> = {
2341
2660
  order: ['canon', 'partition'],
2342
2661
  edges: ['upward', 'all', 'none'],
2343
2662
  }
2344
- const DECL_COUNT = ['maxRows', 'maxCols', 'minDegree', 'minSize']
2663
+ const DECL_COUNT = ['maxRows', 'maxCols', 'minDegree', 'minSize', 'depth']
2345
2664
  const DECL_FLAG = ['closure']
2346
2665
  const DECL_LIST = ['roots', 'relations', 'layers']
2347
2666