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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +176 -0
- data/README.md +4 -2
- data/assets/diagram/diagram.css +331 -0
- data/assets/diagram/fonts/OFL.txt +93 -0
- data/assets/diagram/fonts/Roboto-400.ttf +0 -0
- data/assets/diagram/fonts/Roboto-700.ttf +0 -0
- data/assets/diagram/fonts/Roboto-Italic-400.ttf +0 -0
- data/assets/diagram/fonts/Roboto-Italic-Latin.woff2 +0 -0
- data/assets/diagram/fonts/Roboto-Normal-Latin.woff2 +0 -0
- data/assets/diagram/operators.svg +224 -0
- data/config/vocabularies.csv +85 -85
- data/docs/spec/README.md +2624 -0
- data/docs/spec/examples/after-operator.json +562 -0
- data/docs/spec/examples/any-overlap-operator.json +106 -0
- data/docs/spec/examples/before-operator.json +437 -0
- data/docs/spec/examples/co-reported-operator.json +168 -0
- data/docs/spec/examples/contains-operator.json +97 -0
- data/docs/spec/examples/date-range-operator.json +275 -0
- data/docs/spec/examples/death-operator.json +103 -0
- data/docs/spec/examples/during-operator.json +178 -0
- data/docs/spec/examples/episode-operator.json +80 -0
- data/docs/spec/examples/equal-operator.json +79 -0
- data/docs/spec/examples/ethnicity-operator.json +86 -0
- data/docs/spec/examples/except-operator.json +536 -0
- data/docs/spec/examples/filter-operator.json +218 -0
- data/docs/spec/examples/first-operator.json +148 -0
- data/docs/spec/examples/gender-operator.json +257 -0
- data/docs/spec/examples/hospice-operator.json +40 -0
- data/docs/spec/examples/hospitalization-operator.json +131 -0
- data/docs/spec/examples/information-periods-operator.json +131 -0
- data/docs/spec/examples/last-operator.json +148 -0
- data/docs/spec/examples/match-operator.json +186 -0
- data/docs/spec/examples/nth-occurrence-operator.json +300 -0
- data/docs/spec/examples/numeric-filter-operator.json +77 -0
- data/docs/spec/examples/one-in-two-out-operator.json +158 -0
- data/docs/spec/examples/person-filter-operator.json +919 -0
- data/docs/spec/examples/person-operator.json +274 -0
- data/docs/spec/examples/place-of-service-filter-operator.json +57 -0
- data/docs/spec/examples/provenance-operator.json +148 -0
- data/docs/spec/examples/provider-filter-operator.json +77 -0
- data/docs/spec/examples/race-operator.json +131 -0
- data/docs/spec/examples/recall-operator.json +289 -0
- data/docs/spec/examples/snf-operator.json +40 -0
- data/docs/spec/examples/time-window-operator.json +736 -0
- data/docs/spec/examples/trim-date-end-operator.json +201 -0
- data/docs/spec/examples/trim-date-start-operator.json +201 -0
- data/docs/spec/examples/union-operator.json +547 -0
- data/docs/spec/examples/vocabulary-operator.json +232 -0
- data/lib/conceptql/behaviors/metadatable.rb +68 -2
- data/lib/conceptql/behaviors/provenanceable.rb +4 -1
- data/lib/conceptql/behaviors/utilizable.rb +3 -0
- data/lib/conceptql/code_case_mismatch_error.rb +50 -0
- data/lib/conceptql/data_model/gdm.rb +23 -2
- data/lib/conceptql/diagram_renderer/app.rb +59 -0
- data/lib/conceptql/diagram_renderer/configuration.rb +87 -0
- data/lib/conceptql/diagram_renderer/diagram_data.rb +80 -0
- data/lib/conceptql/diagram_renderer/drawing.rb +255 -0
- data/lib/conceptql/diagram_renderer/geometry.rb +380 -0
- data/lib/conceptql/diagram_renderer/input_error.rb +11 -0
- data/lib/conceptql/diagram_renderer/js_format.rb +112 -0
- data/lib/conceptql/diagram_renderer/resvg.rb +120 -0
- data/lib/conceptql/diagram_renderer/sprite.rb +73 -0
- data/lib/conceptql/diagram_renderer/static.rb +70 -0
- data/lib/conceptql/diagram_renderer/svg.rb +73 -0
- data/lib/conceptql/diagram_renderer/tree_layout.rb +287 -0
- data/lib/conceptql/diagram_renderer.rb +82 -0
- data/lib/conceptql/lexicon/lexicon_strategy.rb +16 -0
- data/lib/conceptql/lexicon.rb +63 -11
- data/lib/conceptql/nodifier.rb +5 -3
- data/lib/conceptql/operators/after.rb +15 -1
- data/lib/conceptql/operators/any_overlap.rb +6 -1
- data/lib/conceptql/operators/before.rb +11 -1
- data/lib/conceptql/operators/binary_operator_operator.rb +12 -2
- data/lib/conceptql/operators/co_reported.rb +9 -4
- data/lib/conceptql/operators/contains.rb +6 -1
- data/lib/conceptql/operators/count.rb +2 -0
- data/lib/conceptql/operators/date_range.rb +15 -3
- data/lib/conceptql/operators/death.rb +5 -1
- data/lib/conceptql/operators/dedup.rb +2 -0
- data/lib/conceptql/operators/during.rb +6 -1
- data/lib/conceptql/operators/episode.rb +13 -2
- data/lib/conceptql/operators/equal.rb +11 -1
- data/lib/conceptql/operators/ethnicity.rb +19 -7
- data/lib/conceptql/operators/except.rb +10 -1
- data/lib/conceptql/operators/filter.rb +6 -1
- data/lib/conceptql/operators/first.rb +19 -9
- data/lib/conceptql/operators/from.rb +3 -0
- data/lib/conceptql/operators/gender.rb +10 -2
- data/lib/conceptql/operators/hospice.rb +7 -1
- data/lib/conceptql/operators/hospitalization.rb +6 -1
- data/lib/conceptql/operators/information_periods.rb +26 -7
- data/lib/conceptql/operators/invalid.rb +4 -0
- data/lib/conceptql/operators/last.rb +19 -9
- data/lib/conceptql/operators/match.rb +6 -1
- data/lib/conceptql/operators/multiple_vocabularies.rb +17 -3
- data/lib/conceptql/operators/numeric.rb +4 -0
- data/lib/conceptql/operators/numeric_filter.rb +13 -3
- data/lib/conceptql/operators/occurrence.rb +33 -12
- data/lib/conceptql/operators/one_in_two_out.rb +22 -5
- data/lib/conceptql/operators/operator.rb +8 -4
- data/lib/conceptql/operators/person.rb +5 -1
- data/lib/conceptql/operators/person_filter.rb +14 -1
- data/lib/conceptql/operators/place_of_service_filter.rb +14 -3
- data/lib/conceptql/operators/provenance.rb +17 -8
- data/lib/conceptql/operators/provider_filter.rb +18 -6
- data/lib/conceptql/operators/race.rb +14 -9
- data/lib/conceptql/operators/read.rb +11 -1
- data/lib/conceptql/operators/recall.rb +34 -2
- data/lib/conceptql/operators/snf.rb +7 -1
- data/lib/conceptql/operators/sum.rb +4 -0
- data/lib/conceptql/operators/temporal_operator.rb +4 -2
- data/lib/conceptql/operators/time_window.rb +27 -4
- data/lib/conceptql/operators/trim_date.rb +6 -14
- data/lib/conceptql/operators/trim_date_end.rb +9 -3
- data/lib/conceptql/operators/trim_date_start.rb +9 -3
- data/lib/conceptql/operators/union.rb +13 -1
- data/lib/conceptql/operators/vocabulary.rb +80 -6
- data/lib/conceptql/paths.rb +9 -1
- data/lib/conceptql/version.rb +1 -1
- data/lib/conceptql/vocabularies/drg_systems.rb +3 -2
- data/lib/conceptql/vocabularies/entry.rb +14 -11
- data/lib/conceptql/vocabularies/vocabulary_class_factory.rb +2 -1
- data/lib/conceptql.rb +3 -0
- metadata +75 -2
- 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
|