conceptql 0.6.0 → 0.7.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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +176 -0
  3. data/README.md +4 -2
  4. data/assets/diagram/diagram.css +331 -0
  5. data/assets/diagram/fonts/OFL.txt +93 -0
  6. data/assets/diagram/fonts/Roboto-400.ttf +0 -0
  7. data/assets/diagram/fonts/Roboto-700.ttf +0 -0
  8. data/assets/diagram/fonts/Roboto-Italic-400.ttf +0 -0
  9. data/assets/diagram/fonts/Roboto-Italic-Latin.woff2 +0 -0
  10. data/assets/diagram/fonts/Roboto-Normal-Latin.woff2 +0 -0
  11. data/assets/diagram/operators.svg +224 -0
  12. data/config/vocabularies.csv +85 -85
  13. data/docs/spec/README.md +2624 -0
  14. data/docs/spec/examples/after-operator.json +562 -0
  15. data/docs/spec/examples/any-overlap-operator.json +106 -0
  16. data/docs/spec/examples/before-operator.json +437 -0
  17. data/docs/spec/examples/co-reported-operator.json +168 -0
  18. data/docs/spec/examples/contains-operator.json +97 -0
  19. data/docs/spec/examples/date-range-operator.json +275 -0
  20. data/docs/spec/examples/death-operator.json +103 -0
  21. data/docs/spec/examples/during-operator.json +178 -0
  22. data/docs/spec/examples/episode-operator.json +80 -0
  23. data/docs/spec/examples/equal-operator.json +79 -0
  24. data/docs/spec/examples/ethnicity-operator.json +86 -0
  25. data/docs/spec/examples/except-operator.json +536 -0
  26. data/docs/spec/examples/filter-operator.json +218 -0
  27. data/docs/spec/examples/first-operator.json +148 -0
  28. data/docs/spec/examples/gender-operator.json +257 -0
  29. data/docs/spec/examples/hospice-operator.json +40 -0
  30. data/docs/spec/examples/hospitalization-operator.json +131 -0
  31. data/docs/spec/examples/information-periods-operator.json +131 -0
  32. data/docs/spec/examples/last-operator.json +148 -0
  33. data/docs/spec/examples/match-operator.json +186 -0
  34. data/docs/spec/examples/nth-occurrence-operator.json +300 -0
  35. data/docs/spec/examples/numeric-filter-operator.json +77 -0
  36. data/docs/spec/examples/one-in-two-out-operator.json +158 -0
  37. data/docs/spec/examples/person-filter-operator.json +919 -0
  38. data/docs/spec/examples/person-operator.json +274 -0
  39. data/docs/spec/examples/place-of-service-filter-operator.json +57 -0
  40. data/docs/spec/examples/provenance-operator.json +148 -0
  41. data/docs/spec/examples/provider-filter-operator.json +77 -0
  42. data/docs/spec/examples/race-operator.json +131 -0
  43. data/docs/spec/examples/recall-operator.json +289 -0
  44. data/docs/spec/examples/snf-operator.json +40 -0
  45. data/docs/spec/examples/time-window-operator.json +736 -0
  46. data/docs/spec/examples/trim-date-end-operator.json +201 -0
  47. data/docs/spec/examples/trim-date-start-operator.json +201 -0
  48. data/docs/spec/examples/union-operator.json +547 -0
  49. data/docs/spec/examples/vocabulary-operator.json +232 -0
  50. data/lib/conceptql/behaviors/metadatable.rb +68 -2
  51. data/lib/conceptql/behaviors/provenanceable.rb +4 -1
  52. data/lib/conceptql/behaviors/utilizable.rb +3 -0
  53. data/lib/conceptql/code_case_mismatch_error.rb +50 -0
  54. data/lib/conceptql/data_model/gdm.rb +23 -2
  55. data/lib/conceptql/diagram_renderer/app.rb +59 -0
  56. data/lib/conceptql/diagram_renderer/configuration.rb +87 -0
  57. data/lib/conceptql/diagram_renderer/diagram_data.rb +80 -0
  58. data/lib/conceptql/diagram_renderer/drawing.rb +255 -0
  59. data/lib/conceptql/diagram_renderer/geometry.rb +380 -0
  60. data/lib/conceptql/diagram_renderer/input_error.rb +11 -0
  61. data/lib/conceptql/diagram_renderer/js_format.rb +112 -0
  62. data/lib/conceptql/diagram_renderer/resvg.rb +120 -0
  63. data/lib/conceptql/diagram_renderer/sprite.rb +73 -0
  64. data/lib/conceptql/diagram_renderer/static.rb +70 -0
  65. data/lib/conceptql/diagram_renderer/svg.rb +73 -0
  66. data/lib/conceptql/diagram_renderer/tree_layout.rb +287 -0
  67. data/lib/conceptql/diagram_renderer.rb +82 -0
  68. data/lib/conceptql/lexicon/lexicon_strategy.rb +16 -0
  69. data/lib/conceptql/lexicon.rb +63 -11
  70. data/lib/conceptql/nodifier.rb +5 -3
  71. data/lib/conceptql/operators/after.rb +15 -1
  72. data/lib/conceptql/operators/any_overlap.rb +6 -1
  73. data/lib/conceptql/operators/before.rb +11 -1
  74. data/lib/conceptql/operators/binary_operator_operator.rb +12 -2
  75. data/lib/conceptql/operators/co_reported.rb +9 -4
  76. data/lib/conceptql/operators/contains.rb +6 -1
  77. data/lib/conceptql/operators/count.rb +2 -0
  78. data/lib/conceptql/operators/date_range.rb +15 -3
  79. data/lib/conceptql/operators/death.rb +5 -1
  80. data/lib/conceptql/operators/dedup.rb +2 -0
  81. data/lib/conceptql/operators/during.rb +6 -1
  82. data/lib/conceptql/operators/episode.rb +13 -2
  83. data/lib/conceptql/operators/equal.rb +11 -1
  84. data/lib/conceptql/operators/ethnicity.rb +19 -7
  85. data/lib/conceptql/operators/except.rb +10 -1
  86. data/lib/conceptql/operators/filter.rb +6 -1
  87. data/lib/conceptql/operators/first.rb +19 -9
  88. data/lib/conceptql/operators/from.rb +3 -0
  89. data/lib/conceptql/operators/gender.rb +10 -2
  90. data/lib/conceptql/operators/hospice.rb +7 -1
  91. data/lib/conceptql/operators/hospitalization.rb +6 -1
  92. data/lib/conceptql/operators/information_periods.rb +26 -7
  93. data/lib/conceptql/operators/invalid.rb +4 -0
  94. data/lib/conceptql/operators/last.rb +19 -9
  95. data/lib/conceptql/operators/match.rb +6 -1
  96. data/lib/conceptql/operators/multiple_vocabularies.rb +17 -3
  97. data/lib/conceptql/operators/numeric.rb +4 -0
  98. data/lib/conceptql/operators/numeric_filter.rb +13 -3
  99. data/lib/conceptql/operators/occurrence.rb +33 -12
  100. data/lib/conceptql/operators/one_in_two_out.rb +22 -5
  101. data/lib/conceptql/operators/operator.rb +8 -4
  102. data/lib/conceptql/operators/person.rb +5 -1
  103. data/lib/conceptql/operators/person_filter.rb +14 -1
  104. data/lib/conceptql/operators/place_of_service_filter.rb +14 -3
  105. data/lib/conceptql/operators/provenance.rb +17 -8
  106. data/lib/conceptql/operators/provider_filter.rb +18 -6
  107. data/lib/conceptql/operators/race.rb +14 -9
  108. data/lib/conceptql/operators/read.rb +11 -1
  109. data/lib/conceptql/operators/recall.rb +34 -2
  110. data/lib/conceptql/operators/snf.rb +7 -1
  111. data/lib/conceptql/operators/sum.rb +4 -0
  112. data/lib/conceptql/operators/temporal_operator.rb +4 -2
  113. data/lib/conceptql/operators/time_window.rb +27 -4
  114. data/lib/conceptql/operators/trim_date.rb +6 -14
  115. data/lib/conceptql/operators/trim_date_end.rb +9 -3
  116. data/lib/conceptql/operators/trim_date_start.rb +9 -3
  117. data/lib/conceptql/operators/union.rb +13 -1
  118. data/lib/conceptql/operators/vocabulary.rb +80 -6
  119. data/lib/conceptql/paths.rb +9 -1
  120. data/lib/conceptql/version.rb +1 -1
  121. data/lib/conceptql/vocabularies/drg_systems.rb +3 -2
  122. data/lib/conceptql/vocabularies/entry.rb +14 -11
  123. data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +2 -1
  124. data/lib/conceptql.rb +3 -0
  125. metadata +75 -2
  126. data/doc/spec.md +0 -2091
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ConceptQL
6
+ module DiagramRenderer
7
+ # The drawing that frontend/lib/conceptql-diagram/src/renderer.js makes on
8
+ # its first update(), at duration 0: the stream gradients for <defs> and the
9
+ # <g class="vis-algorithm"> group with the links, the nodes and the recall
10
+ # arcs. Elements, attributes and their order are the renderer's. Each
11
+ # method names the renderer.js method it ports. lockNodes changes only the
12
+ # class of the root svg, which the caller writes.
13
+ #
14
+ # data is a diagram tree with ids (DiagramData.from makes one).
15
+ #
16
+ # Two options serve the in-app SVG (App). They write what renderer.js does
17
+ # with event handlers and transitions:
18
+ #
19
+ # - hooks: each node group gets data-node-id (the element that onNodeClick
20
+ # listens on), and each "+" insert button gets data-insert-before (the
21
+ # element that onInsertBefore listens on) unless lock_nodes is true. Each
22
+ # element that a transition moves (MOVED) gets data-diagram-key, made
23
+ # from node ids as the D3 joins key them, so it is the same in every
24
+ # render of the same node. Each recall arc (.vis-connect) gets
25
+ # data-recall-node-ids, a JSON array of the ids of the nodes whose
26
+ # mouseenter shows the arcs (see recall_node_ids).
27
+ # - css_transforms: each MOVED element's position is written as
28
+ # style="transform: translate(Xpx, Ypx)" and not as the transform
29
+ # attribute, because CSS transitions cannot animate the attribute.
30
+ class Drawing
31
+ # The classes of the elements whose transform renderer.js transitions.
32
+ MOVED = %w[vis-link vis-link-new vis-link-path vis-node].freeze
33
+
34
+ attr_reader :data, :show_counts, :show_options
35
+
36
+ def initialize(data, show_counts: false, show_options: false, hooks: false, lock_nodes: true, css_transforms: false)
37
+ @data = data
38
+ @show_counts = show_counts
39
+ @show_options = show_options
40
+ @hooks = hooks
41
+ @lock_nodes = lock_nodes
42
+ @css_transforms = css_transforms
43
+ root = Geometry.layout(data)
44
+ @nodes = root.descendants
45
+ @links = root.links
46
+ end
47
+
48
+ # updateLinearGradients
49
+ def gradients
50
+ Geometry::LINEAR_GRADIENTS.map do |gradient|
51
+ stops = [ [ "5%", 0 ], [ "95%", 1 ] ].map do |offset, opacity|
52
+ Svg.element("stop", { "offset" => offset, "stop-color" => gradient[:color], "stop-opacity" => opacity })
53
+ end
54
+ Svg.element("linearGradient", { "x1" => 0, "x2" => 0, "y1" => 0, "y2" => 1, "id" => "gradient-#{gradient[:name]}" }, stops)
55
+ end
56
+ end
57
+
58
+ # updateTree: the links, then the nodes, then the recall arcs.
59
+ def algorithm
60
+ children = @links.map { link(_1) } + @nodes.map { node(_1) } + recall_connections.map { recall(_1) }
61
+ Svg.element("g", { "class" => "vis-algorithm", "data-test-diagram-group" => "" }, children)
62
+ end
63
+
64
+ private
65
+
66
+ # updateLinks, for one link.
67
+ def link(link)
68
+ target = link.target.data
69
+ streams = target["outputTypes"].each_with_index.map { |type, i| stream(link, type, i) }
70
+ attributes = { "class" => "vis-link", **place(Geometry.link_offset(target["outputTypes"]), 0), **key("link:#{link_key(link)}") }
71
+ Svg.element("g", attributes, streams + [ insert_button(link) ])
72
+ end
73
+
74
+ # The "+" insert button of a link (.vis-link-new).
75
+ def insert_button(link)
76
+ classes = [ "vis-link-new" ]
77
+ classes << "is-selectable" if JsFormat.truthy?(link.target.data["isAttachable"])
78
+ position = Geometry.insert_button_position(link)
79
+ text = link.source.data["name"] == "root" && link.target.data["name"] == "hidden" ? [ "New Stream" ] : []
80
+ attributes = { "class" => classes.join(" "), **place(position[:x], position[:y]), **key("link-new:#{link_key(link)}") }
81
+ attributes["data-insert-before"] = link.target.data["id"] if @hooks && !@lock_nodes
82
+ Svg.element("g", attributes, [
83
+ Svg.element("circle", { "r" => 15 }),
84
+ Svg.element("use", { "xlink:href" => "#operator-new", "height" => 20, "width" => 20, "x" => -10, "y" => -10 }),
85
+ Svg.element("text", { "class" => "vis-link-text", "x" => 30, "y" => 1 }, text)
86
+ ])
87
+ end
88
+
89
+ # One stream of a link (.vis-link-path): the target and source paths and,
90
+ # with show_counts, the count label.
91
+ def stream(link, type, index)
92
+ path = Geometry.connector_path(link)
93
+ color = link.target.data["name"] == "hidden" ? "placeholder" : type
94
+ children = [
95
+ Svg.element("path", { "class" => "vis-link-path-target", "data-color" => color, "d" => path }),
96
+ Svg.element("path", { "class" => "vis-link-path-source", "stroke" => Geometry.source_gradient(link, index), "d" => path })
97
+ ]
98
+ children << count_label(link, type, index) if show_counts
99
+ attributes = { "class" => "vis-link-path", **place(Geometry.stream_offset(index), 0), **key("link-path:#{link_key(link)}:#{index}") }
100
+ Svg.element("g", attributes, children)
101
+ end
102
+
103
+ def count_label(link, type, index)
104
+ target = link.target.data
105
+ lines = Geometry.count_label_lines(target["counts"], type) || []
106
+ position = Geometry.count_label_position(link, index, target["outputTypes"].length)
107
+ tspans = lines.each_with_index.map do |line, j|
108
+ Svg.element("tspan", { "x" => 0, "dy" => j.zero? ? 0 : Geometry::COUNT_LABEL[:line_height] }, [ line ])
109
+ end
110
+ Svg.element("text", {
111
+ "class" => "vis-link-count",
112
+ "transform" => translate(position[:x], position[:y]),
113
+ "text-anchor" => position[:anchor],
114
+ "fill" => Geometry.count_label_color(type),
115
+ "font-size" => Geometry::COUNT_LABEL[:font_size]
116
+ }, tspans)
117
+ end
118
+
119
+ # updateNodes, for one node.
120
+ def node(node)
121
+ d = node.data
122
+ label = label?(d)
123
+ classes = [ "vis-node" ]
124
+ classes << "is-active" if JsFormat.truthy?(d["isSelected"])
125
+ classes << "is-error" unless empty?(d["errors"])
126
+ classes << "is-warning" unless empty?(d["warnings"])
127
+ classes << "is-label" if label
128
+ position = Geometry.node_position(node)
129
+
130
+ attributes = {
131
+ "operator-type" => "#{d['name']}-#{node.data_index}",
132
+ "class" => classes.join(" "),
133
+ "data-test-node" => "",
134
+ **place(position[:x], position[:y])
135
+ }
136
+ attributes.merge!("data-node-id" => d["id"], **key("node:#{JsFormat.string(d['id'])}")) if @hooks
137
+ Svg.element("g", attributes, [
138
+ Svg.element("use", { "class" => "vis-node-point", "height" => 40, "width" => 40, "data-color" => Geometry.point_color(d["outputTypes"]), "xlink:href" => "#operator-base-#{template(d, 'base')}" }),
139
+ Svg.element("use", { "class" => "vis-node-icon", "height" => 26, "width" => 26, "transform" => "translate(7,7)", "xlink:href" => "#operator-#{d['name']}" }),
140
+ Svg.element("use", { "class" => "vis-node-label", "xlink:href" => "#operator-label", "height" => 8, "width" => 10, "transform" => "translate(45, 32)" }),
141
+ badge("vis-node-warning", "#operator-warning"),
142
+ badge("vis-node-error", "#operator-error"),
143
+ description(d, label)
144
+ ])
145
+ end
146
+
147
+ # The warning and error badges, which CSS shows on .is-warning and
148
+ # .is-error nodes.
149
+ def badge(name, href)
150
+ Svg.element("g", { "class" => name }, [
151
+ Svg.element("circle", { "r" => 12 }),
152
+ Svg.element("use", { "xlink:href" => href, "height" => 22, "width" => 22, "transform" => "translate(-11,-11)" })
153
+ ])
154
+ end
155
+
156
+ # The title and the description lines. A labelled node's first value
157
+ # line is indented past the label icon.
158
+ def description(data, label)
159
+ title = Geometry.node_title(data)
160
+ heading = Svg.element("tspan", { "class" => "vis-node-description-heading", "style" => "font-size: 12px;" }, text(title))
161
+ values = Geometry.description_lines(data, show_options:).each_with_index.map do |line, i|
162
+ Svg.element("tspan", {
163
+ "class" => "vis-node-description-value",
164
+ "style" => "font-size: 10px;",
165
+ "x" => label && i.zero? ? 15 : 0,
166
+ "y" => "#{JsFormat.number(((i + 1) * 1.4) + 0.2)}em"
167
+ }, text(line))
168
+ end
169
+ Svg.element("text", { "class" => "vis-node-description", "transform" => "translate(45,21)" }, [ heading, *values ])
170
+ end
171
+
172
+ # updateRecalls, for one recall arc.
173
+ def recall(connection)
174
+ attributes = { "class" => "vis-connect", "d" => Geometry.recall_path(connection) }
175
+ attributes["data-recall-node-ids"] = JSON.generate(recall_node_ids(connection)) if @hooks
176
+ Svg.element("path", attributes)
177
+ end
178
+
179
+ # The ids of the nodes for which isRecalled finds this arc. renderer.js
180
+ # shows every arc on the mouseenter of such a node. A recall node
181
+ # compares its first value, and any other node its label when that is
182
+ # truthy, with the first value of the arc's recall.
183
+ def recall_node_ids(connection)
184
+ value = connection[0].data["values"][0]
185
+ nodes = @nodes.select do |node|
186
+ d = node.data
187
+ if d["name"] == "recall"
188
+ values = d["values"]
189
+ (values.is_a?(Array) || values.is_a?(String)) && !values.empty? && same?(values[0], value)
190
+ else
191
+ label?(d) && same?(d["parameters"]["label"], value)
192
+ end
193
+ end
194
+ nodes.map { JsFormat.string(_1.data["id"]) }
195
+ end
196
+
197
+ # recallConnections: each recall node with the first node (in
198
+ # descendants order) whose label is the recall's first value.
199
+ def recall_connections
200
+ @nodes.filter_map do |recall|
201
+ values = recall.data["values"]
202
+ next unless recall.data["name"] == "recall" && !empty?(values)
203
+
204
+ recalled = @nodes.find do |node|
205
+ parameters = node.data["parameters"]
206
+ parameters.is_a?(Hash) && parameters.key?("label") && same?(parameters["label"], values[0])
207
+ end
208
+ [ recall, recalled ] if recalled
209
+ end
210
+ end
211
+
212
+ # The is-label class: `params && params.label`.
213
+ def label?(data)
214
+ parameters = data["parameters"]
215
+ parameters.is_a?(Hash) && JsFormat.truthy?(parameters["label"])
216
+ end
217
+
218
+ # isEmpty in renderer.js: nil, an empty string or array, or an object
219
+ # whose length is 0.
220
+ def empty?(value)
221
+ case value
222
+ when nil then true
223
+ when String, Array then value.empty?
224
+ when Hash then value["length"].is_a?(Numeric) && value["length"].zero?
225
+ else false
226
+ end
227
+ end
228
+
229
+ # JavaScript ===: arrays and objects are equal only to themselves.
230
+ def same?(a, b) = a.is_a?(Array) || a.is_a?(Hash) ? a.equal?(b) : a == b
231
+
232
+ # A key in a template literal: a missing key is "undefined".
233
+ def template(data, key) = data.key?(key) ? JsFormat.string(data[key]) : "undefined"
234
+
235
+ # d3's text(value): nil is no text.
236
+ def text(value) = value.nil? ? [] : [ JsFormat.string(value) ].reject(&:empty?)
237
+
238
+ def translate(x, y) = "translate(#{JsFormat.number(x)},#{JsFormat.number(y)})"
239
+
240
+ # The position of a MOVED element: the transform attribute or, with
241
+ # css_transforms, the same translation as a CSS transform.
242
+ def place(x, y)
243
+ return { "transform" => translate(x, y) } unless @css_transforms
244
+
245
+ { "style" => "transform: translate(#{JsFormat.number(x)}px, #{JsFormat.number(y)}px)" }
246
+ end
247
+
248
+ # The data-diagram-key attribute of a MOVED element, with hooks.
249
+ def key(value) = @hooks ? { "data-diagram-key" => value } : {}
250
+
251
+ # The D3 join key of a link (updateTree): "<source id>-<target id>".
252
+ def link_key(link) = "#{JsFormat.string(link.source.data['id'])}-#{JsFormat.string(link.target.data['id'])}"
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,380 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConceptQL
4
+ module DiagramRenderer
5
+ # The geometry and presentation rules of the diagram: layout, connector
6
+ # paths, offsets, colours and label packing. A port of
7
+ # frontend/lib/conceptql-diagram/src/geometry.js. The JavaScript file is the
8
+ # reference: every fixture's reference/geometry.json comes from it, and
9
+ # test/diagram_renderer/geometry_test.rb compares the two. Each method
10
+ # names the JavaScript function it ports.
11
+ #
12
+ # Input is a diagram tree as hashes with string keys ("id", "name", "base",
13
+ # "humanName", "outputTypes", "values", "parameters", "children", "counts").
14
+ # A node, as `layout` returns it and the link methods take it, has `x`, `y`
15
+ # and `data` (a TreeLayout::Node); a link has `source` (the parent) and
16
+ # `target` (the child). Positions are hashes with symbol keys.
17
+ #
18
+ # Coordinates: the tree layout puts depth along +y. The diagram draws the
19
+ # root at the bottom and grows upward, so every y is negated when drawn.
20
+ module Geometry
21
+ extend self
22
+
23
+ # NODE_SPACING
24
+ NODE_SPACING = { x: 200, y: 140 }.freeze
25
+
26
+ # LINEAR_GRADIENTS: the colour of each stream type, for link source
27
+ # gradients.
28
+ LINEAR_GRADIENTS = [
29
+ { name: "condition_occurrence", color: "#d6758a" },
30
+ { name: "death", color: "#a7736f" },
31
+ { name: "drug_exposure", color: "#cf92d2" },
32
+ { name: "person", color: "#a9c9e7" },
33
+ { name: "placeholder", color: "#f6f3f5" },
34
+ { name: "procedure_cost", color: "#e0d67f" },
35
+ { name: "procedure_occurrence", color: "#a7e2ac" },
36
+ { name: "unknown", color: "#958c93" },
37
+ { name: "visit_occurrence", color: "#e8d4b3" }
38
+ ].map(&:freeze).freeze
39
+
40
+ CHARACTERS_PER_LINE = 20
41
+ # Option lines ("key: value") may run wider than values: a key cut at 20
42
+ # characters loses the value readers need (Ryan, 2026-09-24).
43
+ OPTION_CHARACTERS_PER_LINE = 40
44
+ MAX_DESCRIPTION_LINES = 3
45
+ ELLIPSIS = "…"
46
+
47
+ # MAX_TITLE_LENGTH: the longest node title (12px bold) that stays clear of
48
+ # the next node's title.
49
+ MAX_TITLE_LENGTH = 22
50
+
51
+ # Parameters that are never shown as option lines: the label already
52
+ # heads the description, and the id is bookkeeping.
53
+ HIDDEN_OPTIONS = %w[label id].freeze
54
+
55
+ # COUNT_LABEL: the styling of edge-count labels, the grey of the spec's
56
+ # GraphViz diagrams.
57
+ COUNT_LABEL = { fill: "#817980", font_size: 10, line_height: 12, ascent: 8, gap: 8 }.freeze
58
+
59
+ # Each stream's count label is coloured like its domain's node glyph (the
60
+ # colours of diagram_styles.css .vis-node-point[data-color]).
61
+ DOMAIN_LABEL_COLORS = {
62
+ "condition_occurrence" => "#ad4a5b",
63
+ "death" => "#704a4a",
64
+ "drug_exposure" => "#9f57a5",
65
+ "observation_period" => "#6fbbba",
66
+ "person" => "#7193cd",
67
+ "procedure_occurrence" => "#90d796",
68
+ "visit_occurrence" => "#d1a776"
69
+ }.freeze
70
+
71
+ # layout: lays the tree out with d3.tree() (TreeLayout). Returns the root
72
+ # TreeLayout::Node with x and y on every node, and a depth-first pre-order
73
+ # data_index in input order.
74
+ def layout(data)
75
+ root = TreeLayout.hierarchy(data) { _1["children"] }
76
+
77
+ index = 0
78
+ root.each_before do |node|
79
+ node.data_index = index
80
+ index += 1
81
+ end
82
+
83
+ TreeLayout.new(node_size: [ NODE_SPACING[:x], NODE_SPACING[:y] ], separation: ->(_a, _b) { 1 }).call(root)
84
+ end
85
+
86
+ # diagonalPath: the connector from a child (target) down to its parent
87
+ # (source). The +0.01 keeps the path from being perfectly vertical: a
88
+ # zero-width bounding box would make the objectBoundingBox gradient on the
89
+ # source path render as nothing.
90
+ def diagonal_path(link)
91
+ path("M", link.target.x, ",", -link.target.y, "v30L", link.source.x + 0.01, ",", -link.source.y - 20, "v20")
92
+ end
93
+
94
+ # elbowPath: the right-hand input of a filter enters the filter's side.
95
+ def elbow_path(link)
96
+ path("M", link.target.x, ",", -link.target.y + 20, "v10L", link.source.x + 30, ",", -link.source.y - 15, "h-10")
97
+ end
98
+
99
+ # straightPath: streams under the (invisible) root run straight down.
100
+ def straight_path(link)
101
+ path("M", link.target.x, ",", -link.target.y + 20, "v10L", link.target.x + 0.01, ",", -link.source.y)
102
+ end
103
+
104
+ # connectorPath: the connector for a link, by the shape of the source
105
+ # (parent) operator.
106
+ def connector_path(link)
107
+ if root?(link.source.data)
108
+ straight_path(link)
109
+ elsif second_filter_input?(link)
110
+ elbow_path(link)
111
+ else
112
+ diagonal_path(link)
113
+ end
114
+ end
115
+
116
+ # recallPath: the arc from a recall node to the node it recalls.
117
+ def recall_path((from, to))
118
+ dx = to.x - from.x
119
+ dy = to.y - from.y
120
+ dr = Math.sqrt((dx * dx) + (dy * dy)) * 0.8
121
+ path("M", from.x, ",", -from.y, "A", dr, ",", dr, " 0 0,1 ", to.x, ",", -to.y)
122
+ end
123
+
124
+ # linkOffset: more streams make a wider link bundle, so shift it left to
125
+ # stay centred.
126
+ def link_offset(target_output_types)
127
+ (target_output_types.length - 1) * -2.5
128
+ end
129
+
130
+ # streamOffset: each stream in a bundle sits 5px right of the previous one.
131
+ def stream_offset(index)
132
+ index * 5
133
+ end
134
+
135
+ # insertButtonPosition: the position of a link's "+" insert button,
136
+ # relative to the link group.
137
+ def insert_button_position(link)
138
+ source = link.source.data
139
+ output_types = link.target.data["outputTypes"]
140
+ x = link.source.x + ((output_types.length - 1) * 2.5)
141
+ y = -link.source.y - 66
142
+
143
+ if root?(source)
144
+ x = link.target.x
145
+ else
146
+ x += (link.target.x - link.source.x) / 2.0
147
+ end
148
+
149
+ x += 17 if source["base"] == "filter" && link.target.x > link.source.x
150
+
151
+ { x:, y: }
152
+ end
153
+
154
+ # nodePosition: the top-left of a node's 40x40 glyph box, centred on its
155
+ # layout point.
156
+ def node_position(node)
157
+ { x: node.x - 20, y: -node.y - 20 }
158
+ end
159
+
160
+ # sourceGradient: the stroke of a link's source-side path. Placeholder
161
+ # under the root, unknown into a hidden node, the cast's own type for
162
+ # casts, otherwise nil (the CSS data-color on the target path supplies the
163
+ # colour).
164
+ def source_gradient(link, stream_index)
165
+ source = link.source.data
166
+ target = link.target.data
167
+
168
+ if root?(source)
169
+ "url(#gradient-placeholder)"
170
+ elsif target["name"] == "hidden"
171
+ "url(#gradient-unknown)"
172
+ elsif source["base"] == "cast"
173
+ type = source["outputTypes"][stream_index]
174
+ "url(#gradient-#{JsFormat.truthy?(type) ? JsFormat.string(type) : 'unknown'})"
175
+ end
176
+ end
177
+
178
+ # pointColor: a node's glyph colour key, its single stream type or
179
+ # "mixed".
180
+ def point_color(output_types)
181
+ output_types.length == 1 ? output_types[0] : "mixed"
182
+ end
183
+
184
+ # nodeTitle: a node's title. Its humanName, unless that is longer than
185
+ # MAX_TITLE_LENGTH and the node is a vocabulary operator: then its
186
+ # vocabulary ID (e.g. "LOINC" rather than "Logical Observation Identifiers
187
+ # Names and Codes (Regenstrief Institute)").
188
+ def node_title(data)
189
+ title = data["humanName"]
190
+ vocabulary_id = data["vocabularyId"]
191
+ return vocabulary_id if title.is_a?(String) && title.length > MAX_TITLE_LENGTH && JsFormat.truthy?(vocabulary_id)
192
+
193
+ title
194
+ end
195
+
196
+ # descriptionLines: the lines under a node's heading. The label (if any)
197
+ # first, cut at 20 characters, then the values packed 20 characters to a
198
+ # line, 3 lines in all. A single over-long value is cut to 19 characters
199
+ # plus an ellipsis, and when values overflow the last line the ellipsis
200
+ # marks the cut.
201
+ #
202
+ # With show_options, the node's options follow as "key: value" lines, each
203
+ # cut at 40 characters, within the same 3-line limit. If they do not all
204
+ # fit, the last shown line ends in an ellipsis.
205
+ def description_lines(data, show_options: false)
206
+ display, overflowed = value_lines(data)
207
+ return display if !show_options || overflowed
208
+
209
+ parameters = data["parameters"]
210
+ parameters = {} unless JsFormat.truthy?(parameters)
211
+ options = JsFormat.entries(parameters)
212
+ .reject { |key, value| HIDDEN_OPTIONS.include?(key) || blank?(value) }
213
+ .map { |key, value| truncate_line("#{key}: #{value.is_a?(Array) ? JsFormat.join(value, ', ') : JsFormat.string(value)}", OPTION_CHARACTERS_PER_LINE) }
214
+
215
+ options.each do |option|
216
+ if display.length == MAX_DESCRIPTION_LINES
217
+ display[-1] += ELLIPSIS unless display[-1].end_with?(ELLIPSIS)
218
+ break
219
+ end
220
+ display.push(option)
221
+ end
222
+ display
223
+ end
224
+
225
+ # countLabelColor: the text colour of a stream's count label, grey for
226
+ # other domains.
227
+ def count_label_color(stream_type)
228
+ DOMAIN_LABEL_COLORS.fetch(stream_type, COUNT_LABEL[:fill])
229
+ end
230
+
231
+ # formatCount: 1234567 becomes "1,234,567".
232
+ def format_count(n)
233
+ JsFormat.string(n).gsub(/\B(?=(\d{3})+(?!\d))/, ",")
234
+ end
235
+
236
+ # countLabelLines: the two label lines for one stream, from the child
237
+ # node's counts[<stream type>] = { "rows", "n" }; nil when there is
238
+ # nothing to show.
239
+ def count_label_lines(counts, stream_type)
240
+ c = JsFormat.truthy?(counts) ? counts[stream_type] : nil
241
+ return nil unless c.is_a?(Hash) && c.key?("rows") && c.key?("n")
242
+
243
+ [ "rows=#{format_count(c['rows'])}", "n=#{format_count(c['n'])}" ]
244
+ end
245
+
246
+ # countLabelPosition: where a stream's count label sits, in its stream
247
+ # group's coordinates, beside the midpoint of the connector's sloped (or,
248
+ # under the root, vertical) run.
249
+ #
250
+ # One stream: on the outside of the slope, to the left (anchored at its
251
+ # end) when the child is left of its parent, otherwise to the right.
252
+ #
253
+ # Several streams (drawn side by side, stream 0 leftmost): each label goes
254
+ # on the side of the bundle nearest its own stream, so the left half of
255
+ # the streams label on the left and the rest on the right, stacking
256
+ # outward from the midpoint.
257
+ #
258
+ # On the outer side of the slope the connector rises away from the label,
259
+ # so the label hangs one ascent below the midpoint. On the inner side the
260
+ # connector falls away beneath it, so the label sits just above.
261
+ def count_label_position(link, stream_index = 0, stream_count = 1)
262
+ source = link.source.data
263
+ from = [ link.target.x, -link.target.y + 30 ]
264
+ to =
265
+ if root?(source)
266
+ [ link.target.x, -link.source.y ]
267
+ elsif second_filter_input?(link)
268
+ [ link.source.x + 30, -link.source.y - 15 ]
269
+ else
270
+ [ link.source.x, -link.source.y - 20 ]
271
+ end
272
+ mid_x = (from[0] + to[0]) / 2.0
273
+ mid_y = (from[1] + to[1]) / 2.0
274
+
275
+ sloped = !root?(source) && link.target.x != link.source.x
276
+ outer = link.target.x < link.source.x ? "left" : "right"
277
+
278
+ if stream_count <= 1
279
+ side = sloped ? outer : "right"
280
+ stack = 0
281
+ else
282
+ left_count = (stream_count / 2.0).ceil
283
+ side = stream_index < left_count ? "left" : "right"
284
+ stack = side == "left" ? stream_index : stream_index - left_count
285
+ end
286
+ inner = sloped && side != outer
287
+
288
+ # Each label is drawn inside its own stream's group, already shifted
289
+ # stream_offset(i) right. Cancel that so labels line up just outside the
290
+ # bundle's left or right edge.
291
+ own_shift = stream_offset(stream_index)
292
+ bundle_width = stream_offset(stream_count - 1)
293
+ label_height = 2 * COUNT_LABEL[:line_height]
294
+
295
+ {
296
+ x: side == "left" ? mid_x - COUNT_LABEL[:gap] - own_shift : mid_x + COUNT_LABEL[:gap] + bundle_width - own_shift,
297
+ y: inner ? mid_y - 2 - COUNT_LABEL[:line_height] - (stack * label_height) : mid_y + COUNT_LABEL[:ascent] + (stack * label_height),
298
+ anchor: side == "left" ? "end" : "start"
299
+ }
300
+ end
301
+
302
+ private
303
+
304
+ # A path string: numbers written as JavaScript writes them.
305
+ def path(*parts)
306
+ parts.map { _1.is_a?(String) ? _1 : JsFormat.number(_1) }.join
307
+ end
308
+
309
+ def root?(data) = data["name"] == "root"
310
+
311
+ # The link enters a filter as the filter's second (right-hand) input. The
312
+ # JavaScript compares the child object itself (===), so this compares
313
+ # identity.
314
+ def second_filter_input?(link)
315
+ source = link.source.data
316
+ source["base"] == "filter" && JsFormat.truthy?(source["children"]) && source["children"][1].equal?(link.target.data)
317
+ end
318
+
319
+ # isBlank
320
+ def blank?(value)
321
+ value.nil? || value == "" || value == []
322
+ end
323
+
324
+ # truncateLine: cut a line to `limit` characters, ellipsis included,
325
+ # without leaving a space before the ellipsis.
326
+ def truncate_line(text, limit)
327
+ text.length > limit ? JsFormat.trim_end(text[0, limit - 1]) + ELLIPSIS : text
328
+ end
329
+
330
+ # valueLines: the label and value packing JDE has always drawn. Returns
331
+ # [lines, overflowed]; overflowed means values were cut off because the
332
+ # lines ran out.
333
+ def value_lines(data)
334
+ lines = MAX_DESCRIPTION_LINES
335
+ overflowed = false
336
+ characters_remaining = CHARACTERS_PER_LINE
337
+ display = []
338
+ line = []
339
+ values = data["values"]
340
+ values = JsFormat.truthy?(values) ? values.dup : []
341
+
342
+ # If there is a label, cut it and make it the first line.
343
+ parameters = data["parameters"]
344
+ if JsFormat.truthy?(parameters) && JsFormat.truthy?(parameters["label"])
345
+ label_text = parameters["label"]
346
+ label_text = label_text[0, CHARACTERS_PER_LINE] + ELLIPSIS if JsFormat.length(label_text) > CHARACTERS_PER_LINE
347
+ display.push(label_text)
348
+ lines -= 1
349
+ end
350
+
351
+ # Now split the values into lines by the space available. The loop ends
352
+ # at the first falsy value, as `while ((value = values.pop()))` does.
353
+ values.reverse!
354
+ while JsFormat.truthy?(value = values.pop)
355
+ if JsFormat.length(value) > CHARACTERS_PER_LINE
356
+ value = value[0, CHARACTERS_PER_LINE - 1] + ELLIPSIS
357
+ elsif JsFormat.length(value) > characters_remaining
358
+ display.push(JsFormat.join(line, ", "))
359
+ lines -= 1
360
+ if lines.positive?
361
+ characters_remaining = CHARACTERS_PER_LINE
362
+ line = []
363
+ else
364
+ display[-1] += ELLIPSIS
365
+ overflowed = true
366
+ break
367
+ end
368
+ end
369
+
370
+ line.push(value)
371
+ characters_remaining -= JsFormat.length(value)
372
+
373
+ display.push(JsFormat.join(line, ", ")) if values.empty?
374
+ end
375
+
376
+ [ display, overflowed ]
377
+ end
378
+ end
379
+ end
380
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConceptQL
4
+ module DiagramRenderer
5
+ # The input is not a diagram tree that DiagramData accepts (InputError in
6
+ # frontend/lib/conceptql-diagram/cli/src/render-static.js). The message says
7
+ # what is wrong, with the same text as the JavaScript.
8
+ class InputError < StandardError
9
+ end
10
+ end
11
+ end