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,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConceptQL
|
|
4
|
+
module DiagramRenderer
|
|
5
|
+
# A port of d3-hierarchy 3.1.2 (the version frontend/package-lock.json
|
|
6
|
+
# resolves): hierarchy() and tree(), the tidy tree layout of Buchheim, Jünger
|
|
7
|
+
# and Leipert (the linear-time version of Reingold-Tilford), as
|
|
8
|
+
# d3-hierarchy's src/tree.js implements it. Only the nodeSize mode is ported,
|
|
9
|
+
# because geometry.js uses only that mode.
|
|
10
|
+
#
|
|
11
|
+
# The arithmetic is the same as the JavaScript, in the same order and in
|
|
12
|
+
# Float (IEEE double, as JavaScript numbers), so the x values are the same
|
|
13
|
+
# to the last bit.
|
|
14
|
+
#
|
|
15
|
+
# root = TreeLayout.hierarchy(data) { |d| d["children"] }
|
|
16
|
+
# TreeLayout.new(node_size: [200, 140], separation: ->(_a, _b) { 1 }).call(root)
|
|
17
|
+
# root.descendants.map { [it.x, it.y] }
|
|
18
|
+
class TreeLayout
|
|
19
|
+
# eachBefore and eachAfter, for Node and TreeNode (in tree.js, TreeNode
|
|
20
|
+
# inherits them from Node). Both need only `children`.
|
|
21
|
+
module Traversal
|
|
22
|
+
# Pre-order, children left to right (node.eachBefore).
|
|
23
|
+
def each_before
|
|
24
|
+
nodes = [ self ]
|
|
25
|
+
while (node = nodes.pop)
|
|
26
|
+
yield node
|
|
27
|
+
node.children&.reverse_each { nodes.push(_1) }
|
|
28
|
+
end
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Post-order, children left to right (node.eachAfter).
|
|
33
|
+
def each_after
|
|
34
|
+
nodes = [ self ]
|
|
35
|
+
after = []
|
|
36
|
+
while (node = nodes.pop)
|
|
37
|
+
after.push(node)
|
|
38
|
+
node.children&.each { nodes.push(_1) }
|
|
39
|
+
end
|
|
40
|
+
yield after.pop until after.empty?
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A node of the hierarchy (d3-hierarchy Node). `children` is nil for a
|
|
46
|
+
# leaf, as in d3: an empty child list makes no children.
|
|
47
|
+
class Node
|
|
48
|
+
include Traversal
|
|
49
|
+
|
|
50
|
+
attr_accessor :data, :depth, :height, :parent, :children, :x, :y, :data_index
|
|
51
|
+
|
|
52
|
+
def initialize(data)
|
|
53
|
+
@data = data
|
|
54
|
+
@depth = 0
|
|
55
|
+
@height = 0
|
|
56
|
+
@parent = nil
|
|
57
|
+
@children = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Breadth-first, children left to right (node[Symbol.iterator] and
|
|
61
|
+
# node.descendants()).
|
|
62
|
+
def descendants
|
|
63
|
+
found = []
|
|
64
|
+
level = [ self ]
|
|
65
|
+
until level.empty?
|
|
66
|
+
found.concat(level)
|
|
67
|
+
level = level.flat_map { _1.children || [] }
|
|
68
|
+
end
|
|
69
|
+
found
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Every parent-child link, in descendants order (node.links()).
|
|
73
|
+
def links
|
|
74
|
+
descendants.drop(1).map { Link.new(_1.parent, _1) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
Link = Struct.new(:source, :target)
|
|
79
|
+
|
|
80
|
+
# d3.hierarchy(data, children): the block returns a datum's children.
|
|
81
|
+
def self.hierarchy(data, &children)
|
|
82
|
+
root = Node.new(data)
|
|
83
|
+
nodes = [ root ]
|
|
84
|
+
while (node = nodes.pop)
|
|
85
|
+
childs = children.call(node.data)
|
|
86
|
+
next if childs.nil? || childs.empty?
|
|
87
|
+
|
|
88
|
+
node.children = childs.map { Node.new(_1) }
|
|
89
|
+
node.children.reverse_each do |child|
|
|
90
|
+
nodes.push(child)
|
|
91
|
+
child.parent = node
|
|
92
|
+
child.depth = node.depth + 1
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
root.each_before { compute_height(_1) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# computeHeight in hierarchy/index.js.
|
|
99
|
+
def self.compute_height(node)
|
|
100
|
+
height = 0
|
|
101
|
+
loop do
|
|
102
|
+
node.height = height
|
|
103
|
+
node = node.parent
|
|
104
|
+
height += 1
|
|
105
|
+
break unless node && node.height < height
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
private_class_method :compute_height
|
|
109
|
+
|
|
110
|
+
# TreeNode in tree.js: the layout's working state for one node.
|
|
111
|
+
# z prelim, m mod, c change, s shift, t thread, a ancestor,
|
|
112
|
+
# ancestor_default A, i number among its siblings.
|
|
113
|
+
class TreeNode
|
|
114
|
+
include Traversal
|
|
115
|
+
|
|
116
|
+
attr_accessor :node, :parent, :children, :ancestor_default, :a, :z, :m, :c, :s, :t, :i
|
|
117
|
+
|
|
118
|
+
def initialize(node, i)
|
|
119
|
+
@node = node
|
|
120
|
+
@parent = nil
|
|
121
|
+
@children = nil
|
|
122
|
+
@ancestor_default = nil
|
|
123
|
+
@a = self
|
|
124
|
+
@z = 0.0
|
|
125
|
+
@m = 0.0
|
|
126
|
+
@c = 0.0
|
|
127
|
+
@s = 0.0
|
|
128
|
+
@t = nil
|
|
129
|
+
@i = i
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def initialize(node_size:, separation:)
|
|
134
|
+
@dx = Float(node_size[0])
|
|
135
|
+
@dy = Float(node_size[1])
|
|
136
|
+
@separation = separation
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# tree(root): sets x and y on every node of the hierarchy and returns it.
|
|
140
|
+
def call(root)
|
|
141
|
+
t = tree_root(root)
|
|
142
|
+
|
|
143
|
+
t.each_after { first_walk(_1) }
|
|
144
|
+
t.parent.m = -t.z
|
|
145
|
+
t.each_before { second_walk(_1) }
|
|
146
|
+
|
|
147
|
+
root.each_before { size_node(_1) }
|
|
148
|
+
root
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# treeRoot in tree.js.
|
|
154
|
+
def tree_root(root)
|
|
155
|
+
tree = TreeNode.new(root, 0)
|
|
156
|
+
nodes = [ tree ]
|
|
157
|
+
while (node = nodes.pop)
|
|
158
|
+
children = node.node.children
|
|
159
|
+
next unless children
|
|
160
|
+
|
|
161
|
+
node.children = Array.new(children.length)
|
|
162
|
+
(children.length - 1).downto(0) do |i|
|
|
163
|
+
child = node.children[i] = TreeNode.new(children[i], i)
|
|
164
|
+
nodes.push(child)
|
|
165
|
+
child.parent = node
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
tree.parent = TreeNode.new(nil, 0)
|
|
170
|
+
tree.parent.children = [ tree ]
|
|
171
|
+
tree
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# The separation function, called with hierarchy nodes; Float like a
|
|
175
|
+
# JavaScript number.
|
|
176
|
+
def separation(a, b) = Float(@separation.call(a, b))
|
|
177
|
+
|
|
178
|
+
# nextLeft in tree.js.
|
|
179
|
+
def next_left(v) = v.children ? v.children[0] : v.t
|
|
180
|
+
|
|
181
|
+
# nextRight in tree.js.
|
|
182
|
+
def next_right(v) = v.children ? v.children[-1] : v.t
|
|
183
|
+
|
|
184
|
+
# moveSubtree in tree.js.
|
|
185
|
+
def move_subtree(wm, wp, shift)
|
|
186
|
+
change = shift / (wp.i - wm.i)
|
|
187
|
+
wp.c -= change
|
|
188
|
+
wp.s += shift
|
|
189
|
+
wm.c += change
|
|
190
|
+
wp.z += shift
|
|
191
|
+
wp.m += shift
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# executeShifts in tree.js.
|
|
195
|
+
def execute_shifts(v)
|
|
196
|
+
shift = 0.0
|
|
197
|
+
change = 0.0
|
|
198
|
+
v.children.reverse_each do |w|
|
|
199
|
+
w.z += shift
|
|
200
|
+
w.m += shift
|
|
201
|
+
change += w.c
|
|
202
|
+
shift += w.s + change
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# nextAncestor in tree.js.
|
|
207
|
+
def next_ancestor(vim, v, ancestor)
|
|
208
|
+
vim.a.parent.equal?(v.parent) ? vim.a : ancestor
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# firstWalk in tree.js.
|
|
212
|
+
def first_walk(v)
|
|
213
|
+
children = v.children
|
|
214
|
+
siblings = v.parent.children
|
|
215
|
+
w = v.i.zero? ? nil : siblings[v.i - 1]
|
|
216
|
+
if children
|
|
217
|
+
execute_shifts(v)
|
|
218
|
+
midpoint = (children[0].z + children[-1].z) / 2
|
|
219
|
+
if w
|
|
220
|
+
v.z = w.z + separation(v.node, w.node)
|
|
221
|
+
v.m = v.z - midpoint
|
|
222
|
+
else
|
|
223
|
+
v.z = midpoint
|
|
224
|
+
end
|
|
225
|
+
elsif w
|
|
226
|
+
v.z = w.z + separation(v.node, w.node)
|
|
227
|
+
end
|
|
228
|
+
v.parent.ancestor_default = apportion(v, w, v.parent.ancestor_default || siblings[0])
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# secondWalk in tree.js.
|
|
232
|
+
def second_walk(v)
|
|
233
|
+
v.node.x = v.z + v.parent.m
|
|
234
|
+
v.m += v.parent.m
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# apportion in tree.js.
|
|
238
|
+
def apportion(v, w, ancestor)
|
|
239
|
+
return ancestor unless w
|
|
240
|
+
|
|
241
|
+
vip = v
|
|
242
|
+
vop = v
|
|
243
|
+
vim = w
|
|
244
|
+
vom = vip.parent.children[0]
|
|
245
|
+
sip = vip.m
|
|
246
|
+
sop = vop.m
|
|
247
|
+
sim = vim.m
|
|
248
|
+
som = vom.m
|
|
249
|
+
loop do
|
|
250
|
+
vim = next_right(vim)
|
|
251
|
+
vip = next_left(vip)
|
|
252
|
+
break unless vim && vip
|
|
253
|
+
|
|
254
|
+
vom = next_left(vom)
|
|
255
|
+
vop = next_right(vop)
|
|
256
|
+
vop.a = v
|
|
257
|
+
shift = vim.z + sim - vip.z - sip + separation(vim.node, vip.node)
|
|
258
|
+
if shift > 0
|
|
259
|
+
move_subtree(next_ancestor(vim, v, ancestor), v, shift)
|
|
260
|
+
sip += shift
|
|
261
|
+
sop += shift
|
|
262
|
+
end
|
|
263
|
+
sim += vim.m
|
|
264
|
+
sip += vip.m
|
|
265
|
+
som += vom.m
|
|
266
|
+
sop += vop.m
|
|
267
|
+
end
|
|
268
|
+
if vim && !next_right(vop)
|
|
269
|
+
vop.t = vim
|
|
270
|
+
vop.m += sim - sop
|
|
271
|
+
end
|
|
272
|
+
if vip && !next_left(vom)
|
|
273
|
+
vom.t = vip
|
|
274
|
+
vom.m += sip - som
|
|
275
|
+
ancestor = v
|
|
276
|
+
end
|
|
277
|
+
ancestor
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# sizeNode in tree.js.
|
|
281
|
+
def size_node(node)
|
|
282
|
+
node.x *= @dx
|
|
283
|
+
node.y = node.depth * @dy
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "paths"
|
|
4
|
+
|
|
5
|
+
module ConceptQL
|
|
6
|
+
# The ConceptQL diagram renderer: a diagram tree (ConceptQL::Diagram.render)
|
|
7
|
+
# to a standalone static SVG (render_static), a PNG (png) or the in-app SVG
|
|
8
|
+
# (render_app). See docs/diagram_renderer.md. It uses the Ruby standard
|
|
9
|
+
# library and the resvg program only. lib/conceptql.rb autoloads it, so
|
|
10
|
+
# `require "conceptql"` loads none of it, and a plain Ruby process can load
|
|
11
|
+
# it alone:
|
|
12
|
+
#
|
|
13
|
+
# ruby -Ilib -e 'require "conceptql/diagram_renderer"; ConceptQL::DiagramRenderer::Geometry'
|
|
14
|
+
#
|
|
15
|
+
# It is a port of the JavaScript renderer of the Jigsaw diagram editor
|
|
16
|
+
# (frontend/lib/conceptql-diagram in the jigsaw-diagram-editor repository).
|
|
17
|
+
# The comments name the JavaScript files by their paths in that repository.
|
|
18
|
+
module DiagramRenderer
|
|
19
|
+
class << self
|
|
20
|
+
# The asset configuration (Configuration) that render_static and png use.
|
|
21
|
+
def configuration = @configuration ||= Configuration.new
|
|
22
|
+
|
|
23
|
+
# Replace the configuration with a new one that the block sets up.
|
|
24
|
+
def configure
|
|
25
|
+
config = Configuration.new
|
|
26
|
+
yield config
|
|
27
|
+
@configuration = config
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The standalone static SVG of a diagram tree (DiagramData.from takes the
|
|
31
|
+
# input), as text. show_counts labels each stream with its child's
|
|
32
|
+
# counts; show_options lists each node's options under its values.
|
|
33
|
+
def render_static(input, show_counts: false, show_options: false)
|
|
34
|
+
check_resvg_version
|
|
35
|
+
Static.new(configuration).render(input, show_counts:, show_options:)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The static SVG rasterized by resvg, as PNG bytes, at the SVG's width and
|
|
39
|
+
# height.
|
|
40
|
+
def png(input, show_counts: false, show_options: false)
|
|
41
|
+
configuration.resvg.png(render_static(input, show_counts:, show_options:))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The in-app SVG (App) of a diagram tree whose nodes have ids, as text:
|
|
45
|
+
# the svg.vis element of renderer.js, with click hooks and stable keys.
|
|
46
|
+
# Options: styles, icons, lock_nodes, summary, show_counts, show_options
|
|
47
|
+
# and css_transforms (see App#render and docs/diagram_renderer.md).
|
|
48
|
+
def render_app(tree, **options)
|
|
49
|
+
App.new(configuration).render(tree, **options)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# The first call in a process runs `resvg --version`. Another version
|
|
55
|
+
# than Resvg::VERSION can draw wrong diagrams, so it prints one warning
|
|
56
|
+
# to stderr and draws anyway. Later calls do not check again, even after
|
|
57
|
+
# configure. png calls render_static, so png checks too.
|
|
58
|
+
def check_resvg_version
|
|
59
|
+
return if @resvg_version_checked
|
|
60
|
+
|
|
61
|
+
@resvg_version_checked = true
|
|
62
|
+
found = configuration.resvg.version
|
|
63
|
+
return if found == Resvg::VERSION
|
|
64
|
+
|
|
65
|
+
warn "ConceptQL::DiagramRenderer: resvg #{found} is not the required version #{Resvg::VERSION}. The diagrams can be wrong."
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
require_relative "diagram_renderer/js_format"
|
|
72
|
+
require_relative "diagram_renderer/tree_layout"
|
|
73
|
+
require_relative "diagram_renderer/geometry"
|
|
74
|
+
require_relative "diagram_renderer/resvg"
|
|
75
|
+
require_relative "diagram_renderer/input_error"
|
|
76
|
+
require_relative "diagram_renderer/diagram_data"
|
|
77
|
+
require_relative "diagram_renderer/svg"
|
|
78
|
+
require_relative "diagram_renderer/sprite"
|
|
79
|
+
require_relative "diagram_renderer/configuration"
|
|
80
|
+
require_relative "diagram_renderer/drawing"
|
|
81
|
+
require_relative "diagram_renderer/static"
|
|
82
|
+
require_relative "diagram_renderer/app"
|
|
@@ -59,6 +59,22 @@ module ConceptQL
|
|
|
59
59
|
"and #{(ids - declared).inspect} do not"
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# The [vocabulary_id, concept_code] pairs of the vocabularies' codes that
|
|
63
|
+
# equal one of the codes when case is ignored. Lexicon reads them to find
|
|
64
|
+
# a code given in another case than the stored one
|
|
65
|
+
# (CodeCaseMismatchError). A vocabulary that declares (?i:...) already
|
|
66
|
+
# compares without case, so it has none. A lexicon without a database has
|
|
67
|
+
# none either.
|
|
68
|
+
def case_variants(data_db, vocabulary_id, codes)
|
|
69
|
+
return [] if db.nil? || db_is_mock?(data_db) || case_insensitive?(vocabulary_id)
|
|
70
|
+
|
|
71
|
+
concepts_table(db)
|
|
72
|
+
.where(vocabulary_id: vocabulary_id)
|
|
73
|
+
.where(Sequel.function(:lower, :concept_code) => Array(codes).map { |code| code.to_s.downcase })
|
|
74
|
+
.distinct
|
|
75
|
+
.select_map(%i[vocabulary_id concept_code])
|
|
76
|
+
end
|
|
77
|
+
|
|
62
78
|
def concepts_by_name(data_db, names = [])
|
|
63
79
|
ds = concepts_table(data_db)
|
|
64
80
|
|
data/lib/conceptql/lexicon.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "forwardable"
|
|
4
|
+
require_relative "code_case_mismatch_error"
|
|
4
5
|
require_relative "lexicon/lexicon_gdm"
|
|
5
6
|
require_relative "lexicon/lexicon_no_db"
|
|
6
7
|
require_relative "lexicon/lexicon_ohdsi"
|
|
@@ -29,10 +30,8 @@ module ConceptQL
|
|
|
29
30
|
|
|
30
31
|
def_delegators :@strategy,
|
|
31
32
|
:strategy,
|
|
32
|
-
:concepts,
|
|
33
33
|
:concepts_table,
|
|
34
34
|
:concepts_by_name,
|
|
35
|
-
:concepts_ds,
|
|
36
35
|
:vocabularies,
|
|
37
36
|
:vocabularies_query
|
|
38
37
|
|
|
@@ -187,40 +186,77 @@ module ConceptQL
|
|
|
187
186
|
@cache = {} if cache
|
|
188
187
|
end
|
|
189
188
|
|
|
190
|
-
(CACHED_LOOKUPS - %i[code_meanings concepts_to_codes concepts_to_codes_in_class known_codes
|
|
189
|
+
(CACHED_LOOKUPS - %i[code_meanings concept_ids concepts_to_codes concepts_to_codes_in_class known_codes
|
|
190
|
+
table_is_missing?]).each do |name|
|
|
191
191
|
define_method(name) do |*args|
|
|
192
192
|
lookup(name, args) { @strategy.public_send(name, *args) }
|
|
193
193
|
end
|
|
194
194
|
end
|
|
195
195
|
|
|
196
|
+
# The lookups below take codes. Each one raises CodeCaseMismatchError for
|
|
197
|
+
# a code that matches no stored code exactly but matches one in another
|
|
198
|
+
# case (bead conceptql-rz8s). A vocabulary operator passes
|
|
199
|
+
# check_case: false, because a statement warns about such a code instead.
|
|
200
|
+
|
|
201
|
+
def concept_ids(data_db, vocabulary_id, codes = [], check_case: true)
|
|
202
|
+
ids = lookup(:concept_ids, [ data_db, vocabulary_id, codes ]) do
|
|
203
|
+
@strategy.concept_ids(data_db, vocabulary_id, codes)
|
|
204
|
+
end
|
|
205
|
+
check_case!(data_db, vocabulary_id, codes) if check_case
|
|
206
|
+
ids
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The datasets are not cached, but the check runs when they are built.
|
|
210
|
+
def concepts(data_db, vocabulary_id, codes = [], check_case: true)
|
|
211
|
+
ds = @strategy.concepts(data_db, vocabulary_id, codes)
|
|
212
|
+
check_case!(data_db, vocabulary_id, codes) if check_case
|
|
213
|
+
ds
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def concepts_ds(data_db, vocabulary_id, codes, check_case: true)
|
|
217
|
+
ds = @strategy.concepts_ds(data_db, vocabulary_id, codes)
|
|
218
|
+
check_case!(data_db, vocabulary_id, codes) if check_case
|
|
219
|
+
ds
|
|
220
|
+
end
|
|
221
|
+
|
|
196
222
|
# The strategy's known_codes first asks whether the vocabulary is empty.
|
|
197
223
|
# It asks through the block, so that question also reaches the cache.
|
|
198
|
-
def known_codes(data_db, vocabulary_id, codes)
|
|
199
|
-
lookup(:known_codes, [ data_db, vocabulary_id, codes ]) do
|
|
224
|
+
def known_codes(data_db, vocabulary_id, codes, check_case: true)
|
|
225
|
+
known = lookup(:known_codes, [ data_db, vocabulary_id, codes ]) do
|
|
200
226
|
@strategy.known_codes(data_db, vocabulary_id, codes) { vocabulary_is_empty?(data_db, vocabulary_id) }
|
|
201
227
|
end
|
|
228
|
+
check_case!(data_db, vocabulary_id, Array(codes) - known) if check_case
|
|
229
|
+
known
|
|
202
230
|
end
|
|
203
231
|
|
|
204
232
|
# The strategy's concepts_to_codes first asks whether the concept table is
|
|
205
233
|
# missing. It asks through the block, so that question also reaches the cache.
|
|
206
|
-
def concepts_to_codes(data_db, vocabulary_id, codes = [])
|
|
207
|
-
lookup(:concepts_to_codes, [ data_db, vocabulary_id, codes ]) do
|
|
234
|
+
def concepts_to_codes(data_db, vocabulary_id, codes = [], check_case: true)
|
|
235
|
+
found = lookup(:concepts_to_codes, [ data_db, vocabulary_id, codes ]) do
|
|
208
236
|
@strategy.concepts_to_codes(data_db, vocabulary_id, codes) { table_is_missing?(data_db) }
|
|
209
237
|
end
|
|
238
|
+
check_case!(data_db, vocabulary_id, Array(codes) - found.map(&:first)) if check_case
|
|
239
|
+
found
|
|
210
240
|
end
|
|
211
241
|
|
|
212
242
|
# Like concepts_to_codes, it asks about the concept table through the block.
|
|
213
|
-
|
|
214
|
-
|
|
243
|
+
# A code of the vocabulary that is in another class is not in the answer,
|
|
244
|
+
# so the check finds it in its own case and does not raise.
|
|
245
|
+
def concepts_to_codes_in_class(data_db, vocabulary_id, concept_class_id, codes, check_case: true)
|
|
246
|
+
found = lookup(:concepts_to_codes_in_class, [ data_db, vocabulary_id, concept_class_id, codes ]) do
|
|
215
247
|
@strategy.concepts_to_codes_in_class(data_db, vocabulary_id, concept_class_id, codes) { table_is_missing?(data_db) }
|
|
216
248
|
end
|
|
249
|
+
check_case!(data_db, vocabulary_id, Array(codes) - Array(found).map(&:first)) if check_case
|
|
250
|
+
found
|
|
217
251
|
end
|
|
218
252
|
|
|
219
253
|
# Like concepts_to_codes, it asks about the concept table through the block.
|
|
220
|
-
def code_meanings(data_db, vocabulary_id, codes)
|
|
221
|
-
lookup(:code_meanings, [ data_db, vocabulary_id, codes ]) do
|
|
254
|
+
def code_meanings(data_db, vocabulary_id, codes, check_case: true)
|
|
255
|
+
meanings = lookup(:code_meanings, [ data_db, vocabulary_id, codes ]) do
|
|
222
256
|
@strategy.code_meanings(data_db, vocabulary_id, codes) { table_is_missing?(data_db) }
|
|
223
257
|
end
|
|
258
|
+
check_case!(data_db, vocabulary_id, codes) if check_case
|
|
259
|
+
meanings
|
|
224
260
|
end
|
|
225
261
|
|
|
226
262
|
# Only "the table is there" is kept. A missing table (or a database error,
|
|
@@ -232,6 +268,22 @@ module ConceptQL
|
|
|
232
268
|
end
|
|
233
269
|
end
|
|
234
270
|
|
|
271
|
+
# Raises CodeCaseMismatchError when a code matches no stored code of the
|
|
272
|
+
# vocabularies exactly, but matches one in another case. The caller passes
|
|
273
|
+
# only the codes that its lookup did not find, when it can tell. A strategy
|
|
274
|
+
# without case_variants (a test double) cannot check.
|
|
275
|
+
def check_case!(data_db, vocabulary_id, codes)
|
|
276
|
+
codes = Array(codes)
|
|
277
|
+
return if codes.empty? || !@strategy.respond_to?(:case_variants)
|
|
278
|
+
|
|
279
|
+
rows = lookup(:case_variants, [ data_db, vocabulary_id, codes ]) do
|
|
280
|
+
@strategy.case_variants(data_db, vocabulary_id, codes)
|
|
281
|
+
end
|
|
282
|
+
mismatches = CodeCaseMismatchError.mismatches(codes, rows)
|
|
283
|
+
raise CodeCaseMismatchError, mismatches unless mismatches.empty?
|
|
284
|
+
end
|
|
285
|
+
private :check_case!
|
|
286
|
+
|
|
235
287
|
# Runs the lookup, or with caching on returns the result kept for the same
|
|
236
288
|
# method and arguments. A result that keep rejects is returned, not kept.
|
|
237
289
|
def lookup(name, args, keep: nil)
|
data/lib/conceptql/nodifier.rb
CHANGED
|
@@ -42,10 +42,12 @@ module ConceptQL
|
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# The metadata of each operator that is not hidden, sorted by name
|
|
45
46
|
def to_metadata(opts = {})
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
operators.reject { |_name, klass| klass.hidden? }
|
|
48
|
+
.map { |name, klass| [ name, klass.to_metadata(name, opts) ] }
|
|
49
|
+
.sort_by { |_name, metadata| metadata[:name] }
|
|
50
|
+
.to_h
|
|
49
51
|
end
|
|
50
52
|
|
|
51
53
|
# The operators this nodifier can create: its Database's registry for
|
|
@@ -7,9 +7,23 @@ module ConceptQL
|
|
|
7
7
|
class After < TemporalOperator
|
|
8
8
|
register __FILE__
|
|
9
9
|
|
|
10
|
-
desc "For each
|
|
10
|
+
desc "For each patient, keeps the left-hand records whose start_date is after the earliest end_date of the patient's right-hand records. With the within option, each left-hand record is compared with every right-hand record of the patient instead."
|
|
11
|
+
returns "The left-hand records that start after a right-hand record of the same patient ends, each record once. The right-hand records are not returned."
|
|
11
12
|
|
|
12
13
|
allows_at_least_option
|
|
14
|
+
option_meaning :within, "A date adjustment, such as 30d, 2m or 1y-3d. The left-hand record must also start no more than this amount after the end_date of the right-hand record. With this option, each left-hand record is compared with every right-hand record of the patient, not only with the one that ends first. Use a positive amount."
|
|
15
|
+
option_meaning :at_least, "A date adjustment, such as 7d or 1w. The left-hand record must start more than this amount after the end_date of the right-hand record. Use a positive amount."
|
|
16
|
+
|
|
17
|
+
example "All MIs that occurred after a patient's first case of diabetes (250.01)",
|
|
18
|
+
[ "after", { left: [ "icd9", "412" ], right: [ "icd9", "250.01" ] } ]
|
|
19
|
+
example "Prescriptions of interest within three days after an office visit",
|
|
20
|
+
[ "after", { left: [ "ndc", "61392070054", "65084025214", "65726040125", { label: "Prescriptions of Interest" } ],
|
|
21
|
+
right: [ "cpt", "99214", { label: "Office Visit" } ], within: "3d" } ]
|
|
22
|
+
example "Heart attack diagnoses more than 1 week after a diagnosis of diabetes",
|
|
23
|
+
[ "after", { left: [ "icd9", "410.00", "410.01", "410.10", "410.11", { label: "Heart Attack Dx" } ],
|
|
24
|
+
right: [ "icd9", "250.01", { label: "Diabetes Dx" } ], at_least: "1w" } ]
|
|
25
|
+
example "All MIs that occurred after a male patient's 50th birthday",
|
|
26
|
+
[ "after", { left: [ "icd9", "412" ], right: [ "time_window", [ "gender", "Male" ], { start: "50y", end: "50y" } ] } ]
|
|
13
27
|
within_skip :after
|
|
14
28
|
|
|
15
29
|
def right_stream_query(db)
|
|
@@ -7,7 +7,12 @@ module ConceptQL
|
|
|
7
7
|
class AnyOverlap < TemporalOperator
|
|
8
8
|
register __FILE__
|
|
9
9
|
|
|
10
|
-
desc "For each
|
|
10
|
+
desc "For each patient, keeps the left-hand records whose date range shares at least one day with the date range of a right-hand record."
|
|
11
|
+
returns "The left-hand records that overlap the date range of at least one right-hand record of the same patient, each record once. The right-hand records are not returned."
|
|
12
|
+
option_meaning :within, "A date adjustment, such as 30d, 2m or 1y-3d. Before the comparison, the date range of each right-hand record is widened by this amount in each direction: its start_date moves back and its end_date moves forward. Use a positive amount."
|
|
13
|
+
|
|
14
|
+
example "All MIs that overlap with the month of 2010-06",
|
|
15
|
+
[ "any_overlap", { left: [ "icd9", "412" ], right: [ "date_range", { start: "2010-06-01", end: "2010-06-30" } ] } ]
|
|
11
16
|
|
|
12
17
|
def where_clause
|
|
13
18
|
((within_start <= l_start_date) & (l_start_date <= within_end)) | ((l_start_date <= within_start) & (within_start <= l_end_date))
|
|
@@ -7,9 +7,19 @@ module ConceptQL
|
|
|
7
7
|
class Before < TemporalOperator
|
|
8
8
|
register __FILE__
|
|
9
9
|
|
|
10
|
-
desc "For each
|
|
10
|
+
desc "For each patient, keeps the left-hand records whose end_date is before the latest start_date of the patient's right-hand records. With the within option, each left-hand record is compared with every right-hand record of the patient instead."
|
|
11
|
+
returns "The left-hand records that end before a right-hand record of the same patient starts, each record once. The right-hand records are not returned."
|
|
11
12
|
|
|
12
13
|
allows_at_least_option
|
|
14
|
+
option_meaning :within, "A date adjustment, such as 30d, 2m or 1y-3d. The left-hand record must also end no more than this amount before the start_date of the right-hand record. With this option, each left-hand record is compared with every right-hand record of the patient, not only with the one that starts last. Use a positive amount."
|
|
15
|
+
option_meaning :at_least, "A date adjustment, such as 7d or 1w. The left-hand record must end more than this amount before the start_date of the right-hand record. Use a positive amount."
|
|
16
|
+
|
|
17
|
+
example "All MIs that occurred before a patient's last case of diabetes (250.01)",
|
|
18
|
+
[ "before", { left: [ "icd9", "412" ], right: [ "icd9", "250.01" ] } ]
|
|
19
|
+
example "All MIs that occurred before a patient's first case of diabetes (250.01)",
|
|
20
|
+
[ "before", { left: [ "icd9", "412" ], right: [ "first", [ "icd9", "250.01" ] ] } ]
|
|
21
|
+
example "Heart attacks that end no more than 1 year before death",
|
|
22
|
+
[ "before", { left: [ "icd9", "410.00", "410.01", "410.10", "410.11" ], right: [ "death" ], within: "1y" } ]
|
|
13
23
|
within_skip :before
|
|
14
24
|
|
|
15
25
|
def right_stream_query(db)
|
|
@@ -6,8 +6,13 @@ module ConceptQL
|
|
|
6
6
|
module Operators
|
|
7
7
|
# Base class for all operators that take two streams, a left-hand and a right-hand
|
|
8
8
|
class BinaryOperatorOperator < Operator
|
|
9
|
-
option :left, type: :upstream
|
|
10
|
-
|
|
9
|
+
option :left, type: :upstream,
|
|
10
|
+
meaning: "The left-hand stream. The operator compares these records with the right-hand stream and passes on the ones that meet its condition."
|
|
11
|
+
option :right, type: :upstream,
|
|
12
|
+
meaning: "The right-hand stream. The operator compares the left-hand records with these records and does not pass them on."
|
|
13
|
+
# The operands are the left and right options. A positional upstream
|
|
14
|
+
# would be ignored, so it is an error (Ryan ruled 2026-11-03).
|
|
15
|
+
validate_no_upstreams
|
|
11
16
|
validate_no_arguments
|
|
12
17
|
validate_option Array, :left, :right
|
|
13
18
|
validate_required_options :left, :right
|
|
@@ -68,6 +73,11 @@ module ConceptQL
|
|
|
68
73
|
@right = to_op(options[:right]) if options[:right].is_a?(Array)
|
|
69
74
|
end
|
|
70
75
|
|
|
76
|
+
# create_upstreams leaves the positional upstreams as statements
|
|
77
|
+
def upstream_operator_names
|
|
78
|
+
@upstreams.map { |stmt| stmt.is_a?(Operator) ? stmt.op_name : stmt.first }
|
|
79
|
+
end
|
|
80
|
+
|
|
71
81
|
def left_stream(db)
|
|
72
82
|
left_stream_query(db)
|
|
73
83
|
end
|
|
@@ -17,10 +17,15 @@ module ConceptQL
|
|
|
17
17
|
vocabularies.length > 1
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
desc "Passes along the
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
desc "Passes along the records of its upstreams that were reported in the same context as a record of every other upstream. " \
|
|
21
|
+
"In the GDM, a context is the claim or other source record that reported the record."
|
|
22
|
+
|
|
23
|
+
returns "The records of each upstream whose context also has a record of every other upstream. " \
|
|
24
|
+
"The operator does not compare dates. If one upstream has no record in a context, the records of all upstreams " \
|
|
25
|
+
"in that context are not passed along. Person and death records have no context, so they never pass."
|
|
26
|
+
|
|
27
|
+
example "Diabetes diagnoses reported as a reason for an office visit, and those office visits",
|
|
28
|
+
[ "co_reported", [ "icd9", "250.00" ], [ "cpt", "99214" ] ]
|
|
24
29
|
|
|
25
30
|
allows_many_upstreams
|
|
26
31
|
category "Combine Streams"
|
|
@@ -7,7 +7,12 @@ module ConceptQL
|
|
|
7
7
|
class Contains < TemporalOperator
|
|
8
8
|
register __FILE__
|
|
9
9
|
|
|
10
|
-
desc "For each
|
|
10
|
+
desc "For each patient, keeps the left-hand records whose date range encloses the date range of a right-hand record. The left-hand record starts on or before the start_date of the right-hand record and ends on or after its end_date."
|
|
11
|
+
returns "The left-hand records that enclose the date range of at least one right-hand record of the same patient, each record once. The right-hand records are not returned."
|
|
12
|
+
option_meaning :within, "A date adjustment, such as 30d, 2m or 1y-3d. Before the comparison, the date range of each left-hand record is widened by this amount in each direction: its start_date moves back and its end_date moves forward. The returned records keep their own dates. Use a positive amount."
|
|
13
|
+
|
|
14
|
+
example "All MIs occurring on 2010-06-22",
|
|
15
|
+
[ "contains", { left: [ "icd9", "412" ], right: [ "date_range", { start: "2010-06-22", end: "2010-06-22" } ] } ]
|
|
11
16
|
|
|
12
17
|
def where_clause
|
|
13
18
|
(within_start <= r_start_date) & (r_end_date <= within_end)
|