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,232 @@
1
+ {
2
+ "spec_id": "vocabulary-operator",
3
+ "examples": [
4
+ {
5
+ "title": "All records with the ICD-9-CM diagnosis code 412 (old myocardial infarction)",
6
+ "statement": ["icd9cm", "412"],
7
+ "help": true,
8
+ "columns": [
9
+ "person_id",
10
+ "criterion_id",
11
+ "criterion_table",
12
+ "criterion_domain",
13
+ "start_date",
14
+ "end_date",
15
+ "source_value"
16
+ ],
17
+ "rows": [
18
+ [
19
+ 11,
20
+ 82614,
21
+ "observations",
22
+ "condition_occurrence",
23
+ "2008-10-29",
24
+ "2008-10-29",
25
+ "412"
26
+ ],
27
+ [
28
+ 17,
29
+ 27269,
30
+ "observations",
31
+ "condition_occurrence",
32
+ "2008-08-25",
33
+ "2008-08-25",
34
+ "412"
35
+ ],
36
+ [
37
+ 17,
38
+ 46165,
39
+ "observations",
40
+ "condition_occurrence",
41
+ "2009-04-30",
42
+ "2009-04-30",
43
+ "412"
44
+ ],
45
+ [
46
+ 38,
47
+ 40556,
48
+ "observations",
49
+ "condition_occurrence",
50
+ "2010-02-12",
51
+ "2010-02-12",
52
+ "412"
53
+ ],
54
+ [
55
+ 54,
56
+ 74606,
57
+ "observations",
58
+ "condition_occurrence",
59
+ "2008-06-05",
60
+ "2008-06-05",
61
+ "412"
62
+ ],
63
+ [
64
+ 60,
65
+ 986,
66
+ "observations",
67
+ "condition_occurrence",
68
+ "2009-07-19",
69
+ "2009-07-22",
70
+ "412"
71
+ ],
72
+ [
73
+ 66,
74
+ 16171,
75
+ "observations",
76
+ "condition_occurrence",
77
+ "2009-07-25",
78
+ "2009-07-25",
79
+ "412"
80
+ ],
81
+ [
82
+ 73,
83
+ 52843,
84
+ "observations",
85
+ "condition_occurrence",
86
+ "2008-11-16",
87
+ "2008-11-16",
88
+ "412"
89
+ ],
90
+ [
91
+ 77,
92
+ 58165,
93
+ "observations",
94
+ "condition_occurrence",
95
+ "2010-10-06",
96
+ "2010-10-06",
97
+ "412"
98
+ ],
99
+ [
100
+ 81,
101
+ 1405,
102
+ "observations",
103
+ "condition_occurrence",
104
+ "2009-01-28",
105
+ "2009-01-30",
106
+ "412"
107
+ ]
108
+ ],
109
+ "tree": {
110
+ "format": "conceptql-diagram/v1",
111
+ "statements": [
112
+ {
113
+ "name": "icd9cm",
114
+ "base": "selection",
115
+ "humanName": "ICD-9 CM",
116
+ "vocabularyId": "ICD9CM",
117
+ "outputTypes": ["condition_occurrence"],
118
+ "parameters": {},
119
+ "values": ["412"],
120
+ "children": [],
121
+ "counts": {
122
+ "condition_occurrence": {
123
+ "rows": 48,
124
+ "n": 37
125
+ }
126
+ }
127
+ }
128
+ ]
129
+ }
130
+ },
131
+ {
132
+ "title": "All MS-DRG 205 records: other respiratory system diagnoses with a major complication or comorbidity",
133
+ "statement": ["ms_drg", "205"],
134
+ "help": true,
135
+ "columns": [
136
+ "person_id",
137
+ "criterion_id",
138
+ "criterion_table",
139
+ "criterion_domain",
140
+ "start_date",
141
+ "end_date",
142
+ "source_value"
143
+ ],
144
+ "rows": [
145
+ [
146
+ 17,
147
+ 1365,
148
+ "observations",
149
+ "procedure_occurrence",
150
+ "2010-06-02",
151
+ "2010-06-06",
152
+ "205"
153
+ ],
154
+ [
155
+ 68,
156
+ 18,
157
+ "observations",
158
+ "procedure_occurrence",
159
+ "2008-04-19",
160
+ "2008-04-23",
161
+ "205"
162
+ ],
163
+ [
164
+ 95,
165
+ 603,
166
+ "observations",
167
+ "procedure_occurrence",
168
+ "2008-08-27",
169
+ "2008-09-01",
170
+ "205"
171
+ ]
172
+ ],
173
+ "tree": {
174
+ "format": "conceptql-diagram/v1",
175
+ "statements": [
176
+ {
177
+ "name": "ms_drg",
178
+ "base": "selection",
179
+ "humanName": "MS-DRG",
180
+ "vocabularyId": "MS-DRG",
181
+ "outputTypes": ["procedure_occurrence"],
182
+ "parameters": {},
183
+ "values": ["205"],
184
+ "children": [],
185
+ "counts": {
186
+ "procedure_occurrence": {
187
+ "rows": 3,
188
+ "n": 3
189
+ }
190
+ }
191
+ }
192
+ ]
193
+ }
194
+ },
195
+ {
196
+ "title": "All CMS-DRG 205 records (a liver disorder)",
197
+ "statement": ["cms_drg", "205"],
198
+ "help": false,
199
+ "columns": [
200
+ "person_id",
201
+ "criterion_id",
202
+ "criterion_table",
203
+ "criterion_domain",
204
+ "start_date",
205
+ "end_date",
206
+ "source_value"
207
+ ],
208
+ "rows": [],
209
+ "tree": {
210
+ "format": "conceptql-diagram/v1",
211
+ "statements": [
212
+ {
213
+ "name": "cms_drg",
214
+ "base": "selection",
215
+ "humanName": "CMS-DRG",
216
+ "vocabularyId": "CMS-DRG",
217
+ "outputTypes": ["procedure_occurrence"],
218
+ "parameters": {},
219
+ "values": ["205"],
220
+ "children": [],
221
+ "counts": {
222
+ "procedure_occurrence": {
223
+ "rows": 0,
224
+ "n": 0
225
+ }
226
+ }
227
+ }
228
+ ]
229
+ }
230
+ }
231
+ ]
232
+ }
@@ -47,22 +47,71 @@ module ConceptQL
47
47
  @deprecated = message
48
48
  end
49
49
 
50
+ # Marks an operator as hidden. It still runs, but ConceptQL.metadata
51
+ # (Nodifier#to_metadata) leaves it out, so an editor does not offer it.
52
+ # An operator is not hidden unless it says so. A subclass does not
53
+ # inherit the mark.
54
+ def hidden
55
+ @hidden = true
56
+ end
57
+
58
+ def hidden?
59
+ @hidden || false
60
+ end
61
+
50
62
  def predominant_domains(*values)
51
63
  return @predominant_domains if values.empty?
52
64
 
53
65
  @predominant_domains = values
54
66
  end
55
67
 
68
+ # An argument or option can carry two texts. instructions: is the form
69
+ # hint for typing a value. meaning: says what the argument or option
70
+ # does. Both are plain text, because the diagram editor escapes them.
56
71
  def argument(name, options = {})
57
72
  (@arguments ||= [])
58
73
  @arguments << [ name, auto_label(name, options) ]
59
74
  end
60
75
 
76
+ # The options that one argument was declared with
77
+ def argument_options(name)
78
+ (@arguments || []).to_h.fetch(name)
79
+ end
80
+
61
81
  def option(name, options = {})
62
82
  @options ||= {}
63
83
  @options[name] = auto_label(name, options)
64
84
  end
65
85
 
86
+ # Replaces the meaning: text of an option, usually one that this class
87
+ # inherited. The option hash is shared with the parent class (see
88
+ # #inherited), so this stores a copy and leaves the parent's text alone.
89
+ # An option that the class does not have raises KeyError.
90
+ def option_meaning(name, text)
91
+ @options[name] = @options.fetch(name).merge(meaning: text)
92
+ end
93
+
94
+ # The plain text that says which records the operator returns. A
95
+ # subclass does not inherit it.
96
+ def returns(value = nil)
97
+ return @returns unless value
98
+
99
+ @returns = value
100
+ end
101
+
102
+ # Adds an example: a title and a ConceptQL statement. help: false keeps
103
+ # the example out of an editor's help, for example because it returns no
104
+ # rows on the test data. The spec build generates the results and the
105
+ # diagram, so they are never written here. A subclass does not inherit
106
+ # the examples, because they would show the wrong operator.
107
+ def example(title, statement, help: true)
108
+ (@examples ||= []) << { title: title, statement: statement, help: help }
109
+ end
110
+
111
+ def examples
112
+ @examples || []
113
+ end
114
+
66
115
  # Takes an option that a subclass inherited out of its metadata
67
116
  def remove_option(name)
68
117
  @options.delete(name)
@@ -93,11 +142,16 @@ module ConceptQL
93
142
  @basic_type = value
94
143
  end
95
144
 
145
+ # min_upstreams and max_upstreams say how many positional upstreams pass
146
+ # validation (99 means no limit). A validate_*_upstream check sets them
147
+ # again when to_metadata runs (derive_metadata_from_validations).
96
148
  def allows_many_upstreams
149
+ @min_upstreams = 0
97
150
  @max_upstreams = 99
98
151
  end
99
152
 
100
153
  def allows_one_upstream
154
+ @min_upstreams = 0
101
155
  @max_upstreams = 1
102
156
  end
103
157
 
@@ -155,7 +209,7 @@ module ConceptQL
155
209
  short_name: short_name,
156
210
  long_name: long_name,
157
211
  operation: just_class_name.underscore,
158
- min_upstreams: @max_upstreams || 0,
212
+ min_upstreams: @min_upstreams || 0,
159
213
  max_upstreams: @max_upstreams || 0,
160
214
  arguments: @arguments || [],
161
215
  options: process_options(@options),
@@ -167,10 +221,22 @@ module ConceptQL
167
221
  deprecated: (true if deprecated), # nil for an operator that is not deprecated
168
222
  arity: arity,
169
223
  aliases: @aliases,
170
- spec_id: spec_id
224
+ spec_id: spec_id,
225
+ returns: get_returns,
226
+ examples: examples_for(name)
171
227
  }
172
228
  end
173
229
 
230
+ # The returns text that the metadata serves
231
+ def get_returns
232
+ returns
233
+ end
234
+
235
+ # The examples that the metadata of the operator with this name serves
236
+ def examples_for(_name)
237
+ examples
238
+ end
239
+
174
240
  def arity
175
241
  case basic_type
176
242
  when :set
@@ -76,8 +76,11 @@ module ConceptQL
76
76
  [ dm.file_provenance_types_vocab, dm.code_provenance_types_vocab ].flatten
77
77
  end
78
78
 
79
+ # The keywords come from the statement, whose validation reports a bad
80
+ # one (find_bad_keywords), so the lookup does not raise
81
+ # CodeCaseMismatchError.
79
82
  def prov_of(db, vocabularies, ancestors)
80
- dm.descendants_of(db, dm.concept_ids(db, vocabularies, ancestors))
83
+ dm.descendants_of(db, dm.concept_ids(db, vocabularies, ancestors, check_case: false))
81
84
  end
82
85
 
83
86
  def plain_condition(db, codes)
@@ -55,6 +55,9 @@ module ConceptQL
55
55
  db[name].with(name, list, Scope::SHARED_TABLE => true)
56
56
  end
57
57
 
58
+ # collection_type and "primary" are ConceptQL's own codes, so their
59
+ # lookups check case: a wrong case raises CodeCaseMismatchError instead
60
+ # of selecting nothing (conceptql-9okk, bead conceptql-rz8s).
58
61
  def gdm_it(db)
59
62
  source_type_id = dm.concept_ids(db, dm.file_provenance_types_vocab, collection_type)
60
63
  all_source_type_ids = dm.descendants_of(db, source_type_id)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConceptQL
4
+ # A code lookup got codes that match no stored code exactly, but match a
5
+ # stored code of the same vocabulary when case is ignored (bead
6
+ # conceptql-rz8s). ConceptQL compares codes case-exactly (seed conceptql-oji
7
+ # ruling "b"), so without this error the lookup returns no concept for those
8
+ # codes, and the caller loses rows without a word. t_shank asked GDM_PROV for
9
+ # "line" when the vocabulary stores "LINE".
10
+ #
11
+ # The public lookups of ConceptQL::Lexicon (and of the DataModel, which
12
+ # delegates to it) raise it. A vocabulary operator in a statement does not:
13
+ # it passes check_case: false, and warns about the code instead.
14
+ class CodeCaseMismatchError < ArgumentError
15
+ # One code of one vocabulary. +stored+ is every spelling of the code that
16
+ # the vocabulary stores, sorted.
17
+ Mismatch = Data.define(:vocabulary_id, :code, :stored) do
18
+ def to_s
19
+ "The vocabulary #{vocabulary_id} stores #{code.inspect} as #{stored.map(&:inspect).join(" or ")}."
20
+ end
21
+ end
22
+
23
+ attr_reader :mismatches
24
+
25
+ def initialize(mismatches)
26
+ @mismatches = mismatches
27
+ super([
28
+ "A code lookup got codes that their vocabulary stores only in another case.",
29
+ "ConceptQL compares codes case-exactly, so these codes would match no concept:",
30
+ *mismatches.map { |mismatch| " - #{mismatch}" },
31
+ "Pass each code as its vocabulary stores it."
32
+ ].join("\n"))
33
+ end
34
+
35
+ # The mismatches among +codes+, given +rows+: the [vocabulary_id,
36
+ # concept_code] pairs of the vocabularies' codes that equal one of the
37
+ # codes when case is ignored. A code that some vocabulary stores exactly
38
+ # is no mismatch. Neither is a code that no vocabulary stores in any case.
39
+ def self.mismatches(codes, rows)
40
+ codes.map(&:to_s).uniq.flat_map do |code|
41
+ same = rows.select { |_, stored| stored.casecmp?(code) }
42
+ next [] if same.empty? || same.any? { |_, stored| stored == code }
43
+
44
+ same.group_by(&:first).sort.map do |vocabulary_id, pairs|
45
+ Mismatch.new(vocabulary_id, code, pairs.map(&:last).uniq.sort)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -127,10 +127,31 @@ module ConceptQL
127
127
  end
128
128
  end
129
129
 
130
- def information_period_where_clause(arguments)
130
+ # Each argument is an information type concept ID or a concept code of
131
+ # information_types_vocab. A statement may mix both. A code that names
132
+ # no concept adds no ID, so it matches no period: it does not become
133
+ # concept ID 0. With only unknown codes, the clause matches nothing.
134
+ # Validation warns about an unknown code, so the lookup does not raise
135
+ # CodeCaseMismatchError.
136
+ def information_period_where_clause(db, arguments)
131
137
  return if arguments.empty?
132
138
 
133
- { information_type_concept_id: arguments.map(&:to_i) }
139
+ codes = information_type_codes(arguments)
140
+ ids = (arguments - codes).map(&:to_i)
141
+ ids += concept_ids(db, information_types_vocab, codes, check_case: false) unless codes.empty?
142
+ { information_type_concept_id: ids }
143
+ end
144
+
145
+ # The arguments that are not concept IDs, as strings. A concept ID is
146
+ # an Integer or a string of digits.
147
+ def information_type_codes(arguments)
148
+ arguments.reject { |arg| arg.is_a?(Integer) || arg.to_s.match?(/\A\d+\z/) }.map(&:to_s)
149
+ end
150
+
151
+ # The vocabulary of the information type concepts, such as PARTD
152
+ # (Medicare Part D). The GDM and the OHDSI vocabulary tables both name it so.
153
+ def information_types_vocab
154
+ "GDM_INFORMATION_TYPE"
134
155
  end
135
156
 
136
157
  def code_provenance_type(_query, _domain)
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConceptQL
4
+ module DiagramRenderer
5
+ # The in-app SVG of a diagram: the <svg class="vis"> element that
6
+ # frontend/lib/conceptql-diagram/src/renderer.js builds (its constructor,
7
+ # then update() at duration 0), for a page to show and script. The root
8
+ # svg, the injected <style>, the hidden copy of the icon sprite, the
9
+ # gradients and the Drawing with hooks (data-node-id, data-insert-before,
10
+ # data-diagram-key; see Drawing).
11
+ #
12
+ # Fit-to-view stays in the browser: renderer.js measures the drawing with
13
+ # getBBox and sets the .vis-algorithm transform, so this SVG has none.
14
+ #
15
+ # ConceptQL::DiagramRenderer::App.new(configuration).render(tree, lock_nodes: true) # => SVG text
16
+ class App
17
+ # XMLSerializer leaves out a namespace declaration that the element
18
+ # already inherits. The sprite <div> is in the SVG namespace (d3 appends
19
+ # it to the svg), so the browser's copy of the sprite has no
20
+ # xmlns="http://www.w3.org/2000/svg".
21
+ INHERITED_XMLNS = /\s+xmlns\s*=\s*(?:"#{Regexp.escape(Static::SVG_NS)}"|'#{Regexp.escape(Static::SVG_NS)}')/
22
+ START_TAG = /<[^\s!?\/>][^>]*>/
23
+
24
+ def initialize(configuration)
25
+ @configuration = configuration
26
+ end
27
+
28
+ # tree is a diagram tree whose nodes have ids, isSelected and
29
+ # isAttachable (what Ember passes to the renderer, or DiagramData.from's
30
+ # output). It is drawn as it is. styles and icons are the renderer's
31
+ # options of those names: the CSS and the sprite markup (default: the
32
+ # configuration's).
33
+ def render(tree, styles: @configuration.styles, icons: @configuration.icons, lock_nodes: false, summary: false,
34
+ show_counts: false, show_options: false, css_transforms: false)
35
+ drawing = Drawing.new(tree, show_counts:, show_options:, hooks: true, lock_nodes:, css_transforms:)
36
+ Svg.element("svg", {
37
+ "xmlns" => Static::SVG_NS,
38
+ "xmlns:xlink" => Static::XLINK_NS,
39
+ "class" => lock_nodes ? "vis is-new" : "vis",
40
+ "height" => "100%",
41
+ "width" => "100%",
42
+ "viewBox" => summary ? "0 0 800 500" : nil
43
+ }, [
44
+ Svg.element("style", {}, styles.nil? ? [] : [ styles ]),
45
+ Svg.element("div", { "style" => "display: none;" }, icons.nil? || icons.empty? ? [] : [ Svg::Raw.new(serialized(icons)) ]),
46
+ Svg.element("defs", {}, drawing.gradients),
47
+ drawing.algorithm
48
+ ]).to_s
49
+ end
50
+
51
+ private
52
+
53
+ # The sprite markup as the browser serializes the <div> after its
54
+ # innerHTML is set. The markup must otherwise be in serialized form, as
55
+ # the sprite in frontend/index.html is.
56
+ def serialized(icons) = icons.gsub(START_TAG) { _1.gsub(INHERITED_XMLNS, "") }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConceptQL
4
+ module DiagramRenderer
5
+ # Where the renderer's assets are. Each path defaults to the copy that the
6
+ # gem ships under assets/diagram/ (ASSETS), so a host needs no configure
7
+ # call. A host that has its own copy of an asset sets that path:
8
+ #
9
+ # ConceptQL::DiagramRenderer.configure do |config|
10
+ # config.styles_path = "my_diagram.css"
11
+ # end
12
+ #
13
+ # - sprite_path: a file that holds the operator icon <symbol>s (Sprite):
14
+ # assets/diagram/operators.svg.
15
+ # - styles_path: the diagram CSS: assets/diagram/diagram.css.
16
+ # - webfonts_dir: the WEBFONTS woff2 files, embedded in the SVG's <style>:
17
+ # assets/diagram/fonts.
18
+ # - resvg_fonts_dir: the Resvg::FONT_FILES TTF files for resvg:
19
+ # assets/diagram/fonts.
20
+ # - resvg_program: the resvg executable, "resvg" (on PATH) by default.
21
+ #
22
+ # The files are read once, when first needed.
23
+ class Configuration
24
+ # The gem's asset directory.
25
+ ASSETS = ConceptQL.root.join("assets", "diagram")
26
+
27
+ # The Roboto webfonts and their font-style (loadAssets in
28
+ # cli/src/render-static.js).
29
+ WEBFONTS = [ [ "Roboto-Normal-Latin.woff2", "normal" ], [ "Roboto-Italic-Latin.woff2", "italic" ] ].freeze
30
+
31
+ # The rule for the root svg after the diagram CSS. animation: none is
32
+ # required: the diagram CSS fades .vis in after a delay, and an SVG shown
33
+ # as an image runs its CSS animations, so anything that snapshots the
34
+ # file early (thumbnailers, PDF converters) gets a blank.
35
+ ROOT_RULE = %(svg.vis { font: normal 15px/1.5 "Roboto", sans-serif; animation: none; cursor: default; }\n)
36
+
37
+ # An asset path is not set.
38
+ class Missing < StandardError; end
39
+
40
+ attr_accessor :sprite_path, :styles_path, :webfonts_dir, :resvg_fonts_dir, :resvg_program
41
+
42
+ def initialize
43
+ @sprite_path = ASSETS.join("operators.svg").to_s
44
+ @styles_path = ASSETS.join("diagram.css").to_s
45
+ @webfonts_dir = ASSETS.join("fonts").to_s
46
+ @resvg_fonts_dir = ASSETS.join("fonts").to_s
47
+ @resvg_program = "resvg"
48
+ end
49
+
50
+ # The sprite block in an HTML page (loadAssets in cli/src/render-static.js).
51
+ OPERATORS = %r{<div class="operators"[^>]*>(.*?)</div>\s*(?:<|\z)}m
52
+
53
+ def sprite = @sprite ||= Sprite.new(sprite_markup)
54
+
55
+ def styles = @styles ||= File.read(path(:styles_path))
56
+
57
+ # The icon markup that the in-app SVG copies (App): the content of the
58
+ # <div class="operators"> block when the sprite file has one, else the
59
+ # whole file.
60
+ def icons = @icons ||= sprite_markup[OPERATORS, 1] || sprite_markup
61
+
62
+ # The text of the standalone SVG's <style>: the embedded Roboto faces, the
63
+ # diagram CSS and ROOT_RULE (standaloneStyleText in
64
+ # cli/src/render-static.js).
65
+ def standalone_style_text
66
+ @standalone_style_text ||= "#{font_faces}\n#{styles}\n#{ROOT_RULE}"
67
+ end
68
+
69
+ def resvg = Resvg.new(fonts_dir: path(:resvg_fonts_dir), program: resvg_program)
70
+
71
+ private
72
+
73
+ def sprite_markup = @sprite_markup ||= File.read(path(:sprite_path))
74
+
75
+ def font_faces
76
+ WEBFONTS.map do |file, style|
77
+ data = [ File.binread(File.join(path(:webfonts_dir), file)) ].pack("m0")
78
+ %(@font-face { font-family: "Roboto"; font-style: #{style}; font-weight: 100 900; src: url(data:font/woff2;base64,#{data}) format("woff2"); })
79
+ end.join("\n")
80
+ end
81
+
82
+ def path(name)
83
+ public_send(name)&.to_s or raise Missing, "ConceptQL::DiagramRenderer: #{name} is not set (see ConceptQL::DiagramRenderer.configure)"
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module ConceptQL
6
+ module DiagramRenderer
7
+ # The input handling of the renderer: toDiagramData in
8
+ # frontend/lib/conceptql-diagram/cli/src/render-static.js.
9
+ #
10
+ # The input is parsed JSON (hashes with string keys) in one of three shapes:
11
+ #
12
+ # - a bare node: {"name" => "icd9", ...}
13
+ # - an array of statements: [{"name" => ...}, ...]
14
+ # - {"format" => "conceptql-diagram/v1", "statements" => [...]}, which is
15
+ # what `conceptql render_json` prints
16
+ #
17
+ # The statements go under a synthetic root with path ids (n0, n0.0, ...).
18
+ # Each node gets outputTypes ["unknown"] when it has none, parameters {}
19
+ # when it has none, and isSelected and isAttachable false. Other keys stay
20
+ # as they are. A bad input raises InputError.
21
+ #
22
+ # The input may also have symbol keys, as ConceptQL::Diagram.render's
23
+ # output has: from writes the input as JSON and reads it back first, so
24
+ # it reads every input as parsed JSON.
25
+ module DiagramData
26
+ FORMAT = "conceptql-diagram/v1"
27
+
28
+ module_function
29
+
30
+ def from(input)
31
+ statements = statements_of(JSON.parse(JSON.generate(input)))
32
+ raise InputError, "no statements to render" unless statements.is_a?(Array) && !statements.empty?
33
+
34
+ children = statements.each_with_index.map { |statement, i| assign(statement, "n#{i}") }
35
+ {
36
+ "name" => "root",
37
+ "id" => "root",
38
+ "isSelected" => false,
39
+ "isAttachable" => false,
40
+ "outputTypes" => children.flat_map { _1["outputTypes"] }.uniq,
41
+ "children" => children
42
+ }
43
+ end
44
+
45
+ def statements_of(input)
46
+ if input.is_a?(Array)
47
+ input
48
+ elsif input.is_a?(Hash) && input.key?("statements")
49
+ unless input["format"] == FORMAT
50
+ format = input.key?("format") ? JSON.generate(input["format"]) : "undefined"
51
+ raise InputError, "unsupported format #{format}; expected \"#{FORMAT}\""
52
+ end
53
+ input["statements"]
54
+ elsif input.is_a?(Hash) && input["name"].is_a?(String)
55
+ [ input ]
56
+ else
57
+ raise InputError, 'input must be a diagram node, an array of nodes, or {"format": "conceptql-diagram/v1", "statements": [...]}'
58
+ end
59
+ end
60
+
61
+ def assign(node, id)
62
+ raise InputError, "node #{id} has no \"name\"" unless node.is_a?(Hash) && node["name"].is_a?(String)
63
+
64
+ output_types = node["outputTypes"]
65
+ parameters = node["parameters"]
66
+ children = node["children"]
67
+ node.merge(
68
+ "id" => id,
69
+ "outputTypes" => JsFormat.truthy?(output_types) && JsFormat.truthy?(JsFormat.length(output_types)) ? output_types : [ "unknown" ],
70
+ "parameters" => JsFormat.truthy?(parameters) ? parameters : {},
71
+ "isSelected" => false,
72
+ "isAttachable" => false,
73
+ "children" => (JsFormat.truthy?(children) ? children : []).each_with_index.map { |child, i| assign(child, "#{id}.#{i}") }
74
+ )
75
+ end
76
+
77
+ private_class_method :statements_of, :assign
78
+ end
79
+ end
80
+ end