labimotion 2.4.0.rc1 → 2.4.0.rc2
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 +21 -0
- data/lib/labimotion/apis/element_variation_api.rb +58 -3
- data/lib/labimotion/apis/generic_klass_api.rb +25 -3
- data/lib/labimotion/apis/labimotion_api.rb +1 -0
- data/lib/labimotion/apis/user_klass_settings_api.rb +70 -0
- data/lib/labimotion/entities/generic_klass_entity.rb +1 -0
- data/lib/labimotion/entities/segment_entity.rb +8 -0
- data/lib/labimotion/entities/user_klass_setting_entity.rb +10 -0
- data/lib/labimotion/helpers/generic_helpers.rb +9 -0
- data/lib/labimotion/helpers/param_helpers.rb +14 -0
- data/lib/labimotion/libs/element_variation_column_set.rb +426 -0
- data/lib/labimotion/libs/element_variation_header.rb +119 -0
- data/lib/labimotion/libs/export_element_variations.rb +245 -0
- data/lib/labimotion/libs/import_element_variations.rb +411 -0
- data/lib/labimotion/libs/linked_element.rb +10 -3
- data/lib/labimotion/libs/sample_association.rb +5 -0
- data/lib/labimotion/models/cellline.rb +47 -0
- data/lib/labimotion/models/concerns/element_fetchable.rb +3 -2
- data/lib/labimotion/models/concerns/segmentable.rb +2 -0
- data/lib/labimotion/models/element.rb +3 -2
- data/lib/labimotion/models/user_klass_setting.rb +12 -0
- data/lib/labimotion/version.rb +1 -1
- data/lib/labimotion.rb +8 -0
- metadata +24 -2
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'labimotion/utils/units'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
## ElementVariationColumnSet
|
|
7
|
+
# Resolves the ordered set of columns for an element's variations grid.
|
|
8
|
+
#
|
|
9
|
+
# Both ExportElementVariations and ImportElementVariations go through this
|
|
10
|
+
# class, so a workbook written by the exporter is always readable by the
|
|
11
|
+
# importer. The ordering mirrors the React grid
|
|
12
|
+
# (GenericElementVariationsColumnDefs.js): driven by the stored layout when
|
|
13
|
+
# there is one, otherwise inferred from the variation data itself -- the same
|
|
14
|
+
# fallback the frontend applies when it loads a record with an empty layout.
|
|
15
|
+
class ElementVariationColumnSet
|
|
16
|
+
UUID_KEY = '__uuid'
|
|
17
|
+
NAME_KEY = '__variation'
|
|
18
|
+
ANALYSES_FIELD = '__analyses__'
|
|
19
|
+
|
|
20
|
+
# Mirrors Labimotion::Prop::LAYERS / ::FIELDS. Held locally because
|
|
21
|
+
# requiring `labimotion/utils/prop` here would race the gem's autoload.
|
|
22
|
+
LAYERS = 'layers'
|
|
23
|
+
FIELDS = 'fields'
|
|
24
|
+
|
|
25
|
+
GROUPS = %w[properties metadata segments].freeze
|
|
26
|
+
GROUP_LABELS = {
|
|
27
|
+
'properties' => 'Properties',
|
|
28
|
+
'metadata' => 'Metadata',
|
|
29
|
+
'segments' => 'Segments'
|
|
30
|
+
}.freeze
|
|
31
|
+
METADATA_FIELDS = %w[notes analyses group].freeze
|
|
32
|
+
METADATA_LABELS = {
|
|
33
|
+
'notes' => 'Notes',
|
|
34
|
+
'analyses' => 'Analyses (IDs)',
|
|
35
|
+
'group' => 'Group'
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Mirrors the field-type allowlist chem-generic-ui applies when it offers
|
|
39
|
+
# columns. `select-multi` deliberately has no Labimotion::FieldType constant.
|
|
40
|
+
COLUMN_FIELD_TYPES = %w[
|
|
41
|
+
integer number select system-defined text date datetime select-multi
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
# `label` is the leaf header only. `group` / `sub_group` carry the two header
|
|
45
|
+
# levels above it, so the exporter can rebuild the grid's banded header.
|
|
46
|
+
# `sub_group_key` disambiguates two sub-groups that happen to share a label.
|
|
47
|
+
Column = Struct.new(
|
|
48
|
+
:key, :label, :unit, :kind, :field_type, :group, :sub_group, :sub_group_key,
|
|
49
|
+
keyword_init: true
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
def build(element, variation, segment_klasses: nil)
|
|
54
|
+
new(element, variation, segment_klasses: segment_klasses).columns
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def encode_property_key(layer_key, field_key)
|
|
58
|
+
"layer#{layer_key}field#{field_key}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def decode_property_key(key)
|
|
62
|
+
match = key.to_s.match(/\Alayer(.+?)field(.+)\z/m)
|
|
63
|
+
return nil unless match
|
|
64
|
+
|
|
65
|
+
{ layer_key: match[1], field_key: match[2] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def analyses_property_key?(key)
|
|
69
|
+
decoded = decode_property_key(key)
|
|
70
|
+
!decoded.nil? && decoded[:field_key] == ANALYSES_FIELD
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initialize(element, variation, segment_klasses: nil)
|
|
75
|
+
@element = element
|
|
76
|
+
@variations = variation.respond_to?(:variations_hash) ? variation.variations_hash : {}
|
|
77
|
+
@layout = variation.respond_to?(:layout_hash) ? variation.layout_hash : {}
|
|
78
|
+
@segment_klasses = segment_klasses
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
attr_reader :element, :variations, :layout
|
|
82
|
+
|
|
83
|
+
def columns
|
|
84
|
+
@columns ||= begin
|
|
85
|
+
cols = [
|
|
86
|
+
Column.new(key: UUID_KEY, label: 'Row ID', unit: '', kind: :uuid, field_type: nil),
|
|
87
|
+
Column.new(key: NAME_KEY, label: 'Variation', unit: '', kind: :name, field_type: nil)
|
|
88
|
+
]
|
|
89
|
+
group_order.each { |group| cols.concat(columns_for_group(group)) }
|
|
90
|
+
cols
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def lookup(key)
|
|
95
|
+
@lookup ||= columns.each_with_object({}) { |col, acc| acc[col.key] = col }
|
|
96
|
+
@lookup[key]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Variation rows in the grid's display order: layout rowOrder first, then
|
|
100
|
+
# anything the layout does not mention, uuid-sorted (as `sortedRows` does).
|
|
101
|
+
def rows
|
|
102
|
+
seen = {}
|
|
103
|
+
ordered = []
|
|
104
|
+
Array(layout['rowOrder']).each do |uuid|
|
|
105
|
+
row = variations[uuid.to_s]
|
|
106
|
+
next unless row.is_a?(Hash)
|
|
107
|
+
|
|
108
|
+
ordered << row
|
|
109
|
+
seen[uuid.to_s] = true
|
|
110
|
+
end
|
|
111
|
+
remaining = variations.reject { |uuid, row| seen[uuid.to_s] || !row.is_a?(Hash) }
|
|
112
|
+
ordered + remaining.values.sort_by { |row| row['uuid'].to_s }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def segment_klasses
|
|
116
|
+
@segment_klasses ||= Labimotion::SegmentKlass.where(element_klass_id: element.element_klass_id).to_a
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def segment_klass(klass_id)
|
|
120
|
+
@segment_klass_index ||= segment_klasses.each_with_object({}) do |klass, acc|
|
|
121
|
+
acc[klass.id.to_s] = klass
|
|
122
|
+
end
|
|
123
|
+
@segment_klass_index[klass_id.to_s]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def columns_for_group(group)
|
|
129
|
+
case group
|
|
130
|
+
when 'properties' then property_columns
|
|
131
|
+
when 'metadata' then metadata_columns
|
|
132
|
+
when 'segments' then segment_columns
|
|
133
|
+
else []
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def group_order
|
|
138
|
+
stored = Array(layout['groupOrder']).select { |group| GROUPS.include?(group) }
|
|
139
|
+
stored + (GROUPS - stored)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# ---- properties -------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
# Grouped by layer, each layer's fields followed by its "Link Analyses"
|
|
145
|
+
# column -- the leaf order buildColumnDefs produces.
|
|
146
|
+
def property_columns
|
|
147
|
+
analysis_layers = selected_analysis_layers
|
|
148
|
+
property_layers.flat_map do |layer_key, layer|
|
|
149
|
+
next layer[:columns] unless analysis_layers.include?(layer_key)
|
|
150
|
+
|
|
151
|
+
layer[:columns] + [analyses_layer_column(layer_key, layer[:label])]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# layer_key => { label:, columns: }, in the grid's layer order: layers
|
|
156
|
+
# holding a selected field first, then layers selected for analyses only.
|
|
157
|
+
def property_layers
|
|
158
|
+
layers = {}
|
|
159
|
+
selected_property_keys.each do |property_key|
|
|
160
|
+
option = property_options[property_key]
|
|
161
|
+
next if option.nil?
|
|
162
|
+
|
|
163
|
+
layer = ensure_property_layer(layers, option['layerKey'], option['layerLabel'])
|
|
164
|
+
layer[:columns] << property_column(property_key, option, layer[:label])
|
|
165
|
+
end
|
|
166
|
+
selected_analysis_layers.each do |layer_key|
|
|
167
|
+
ensure_property_layer(layers, layer_key, layer_labels[layer_key.to_s])
|
|
168
|
+
end
|
|
169
|
+
layers
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def ensure_property_layer(layers, layer_key, layer_label)
|
|
173
|
+
key = layer_key.to_s
|
|
174
|
+
layers[key] ||= { label: presence(layer_label) || key, columns: [] }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def property_column(property_key, option, layer_label)
|
|
178
|
+
unit = unit_for("prop:#{property_key}", option, inferred_property_unit(property_key))
|
|
179
|
+
Column.new(
|
|
180
|
+
key: "prop:#{property_key}",
|
|
181
|
+
label: with_unit(option['fieldLabel'], unit),
|
|
182
|
+
unit: unit,
|
|
183
|
+
kind: :property,
|
|
184
|
+
field_type: option['type'],
|
|
185
|
+
group: GROUP_LABELS['properties'],
|
|
186
|
+
sub_group: layer_label,
|
|
187
|
+
sub_group_key: option['layerKey'].to_s
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def analyses_layer_column(layer_key, layer_label)
|
|
192
|
+
Column.new(
|
|
193
|
+
key: "prop:#{self.class.encode_property_key(layer_key, ANALYSES_FIELD)}",
|
|
194
|
+
label: 'Link Analyses (IDs)',
|
|
195
|
+
unit: '',
|
|
196
|
+
kind: :property,
|
|
197
|
+
field_type: ANALYSES_FIELD,
|
|
198
|
+
group: GROUP_LABELS['properties'],
|
|
199
|
+
sub_group: layer_label,
|
|
200
|
+
sub_group_key: layer_key.to_s
|
|
201
|
+
)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def selected_property_keys
|
|
205
|
+
stored = Array(layout['selectedPropertyKeys']).map(&:to_s)
|
|
206
|
+
return stored unless stored.empty?
|
|
207
|
+
|
|
208
|
+
keys_from_data.reject { |key| self.class.analyses_property_key?(key) }
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def selected_analysis_layers
|
|
212
|
+
stored = Array(layout['selectedAnalysisLayers']).map(&:to_s)
|
|
213
|
+
return stored unless stored.empty?
|
|
214
|
+
|
|
215
|
+
keys_from_data.filter_map do |key|
|
|
216
|
+
decoded = self.class.decode_property_key(key)
|
|
217
|
+
decoded[:layer_key] if decoded && decoded[:field_key] == ANALYSES_FIELD
|
|
218
|
+
end.uniq
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def keys_from_data
|
|
222
|
+
@keys_from_data ||= variations.values.flat_map do |row|
|
|
223
|
+
row.is_a?(Hash) ? (row['properties'] || {}).keys : []
|
|
224
|
+
end.uniq
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def inferred_property_unit(key)
|
|
228
|
+
variations.each_value do |row|
|
|
229
|
+
next unless row.is_a?(Hash)
|
|
230
|
+
|
|
231
|
+
cell = (row['properties'] || {})[key]
|
|
232
|
+
return cell['unit'].to_s if cell.is_a?(Hash) && !cell['unit'].to_s.empty?
|
|
233
|
+
end
|
|
234
|
+
nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# ---- metadata ---------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
# Metadata leaves hang straight off the group, as they do in the grid: no
|
|
240
|
+
# sub_group, so the exporter spans their header down a row.
|
|
241
|
+
def metadata_columns
|
|
242
|
+
selected_metadata_keys.map do |field|
|
|
243
|
+
Column.new(
|
|
244
|
+
key: "meta:#{field}",
|
|
245
|
+
label: METADATA_LABELS[field] || field,
|
|
246
|
+
unit: '',
|
|
247
|
+
kind: :metadata,
|
|
248
|
+
field_type: field,
|
|
249
|
+
group: GROUP_LABELS['metadata']
|
|
250
|
+
)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def selected_metadata_keys
|
|
255
|
+
stored = Array(layout['selectedMetadataKeys']).map(&:to_s)
|
|
256
|
+
stored.empty? ? METADATA_FIELDS.dup : stored
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# ---- segments ---------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
def segment_columns
|
|
262
|
+
selected_segment_ids.flat_map do |klass_id|
|
|
263
|
+
klass = segment_klass(klass_id)
|
|
264
|
+
segment_label = klass ? (klass.label || "Segment #{klass_id}") : "Segment #{klass_id}"
|
|
265
|
+
options = segment_field_options(klass_id)
|
|
266
|
+
|
|
267
|
+
selected_segment_fields(klass_id).filter_map do |field_key|
|
|
268
|
+
option = options[field_key]
|
|
269
|
+
next if option.nil?
|
|
270
|
+
|
|
271
|
+
unit = unit_for("seg:#{klass_id}:#{field_key}", option, nil)
|
|
272
|
+
Column.new(
|
|
273
|
+
key: "seg:#{klass_id}:#{field_key}",
|
|
274
|
+
label: with_unit(option['fieldLabel'], unit),
|
|
275
|
+
unit: unit,
|
|
276
|
+
kind: :segment,
|
|
277
|
+
field_type: option['type'],
|
|
278
|
+
group: GROUP_LABELS['segments'],
|
|
279
|
+
sub_group: segment_label,
|
|
280
|
+
sub_group_key: klass_id.to_s
|
|
281
|
+
)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def selected_segment_ids
|
|
287
|
+
stored = Array(layout['selectedSegmentIds']).map(&:to_s)
|
|
288
|
+
return stored unless stored.empty?
|
|
289
|
+
|
|
290
|
+
variations.values.flat_map do |row|
|
|
291
|
+
row.is_a?(Hash) ? (row['segments'] || {}).keys.map(&:to_s) : []
|
|
292
|
+
end.uniq
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def selected_segment_fields(klass_id)
|
|
296
|
+
stored = layout['selectedSegmentFields']
|
|
297
|
+
if stored.is_a?(Hash)
|
|
298
|
+
keys = stored[klass_id.to_s] || stored[klass_id.to_i]
|
|
299
|
+
return Array(keys).map(&:to_s) unless keys.nil?
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
variations.values.flat_map do |row|
|
|
303
|
+
next [] unless row.is_a?(Hash)
|
|
304
|
+
|
|
305
|
+
(row.dig('segments', klass_id.to_s) || {}).keys.map(&:to_s)
|
|
306
|
+
end.uniq
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def segment_field_options(klass_id)
|
|
310
|
+
@segment_field_options ||= {}
|
|
311
|
+
@segment_field_options[klass_id.to_s] ||= begin
|
|
312
|
+
klass = segment_klass(klass_id)
|
|
313
|
+
options = {}
|
|
314
|
+
each_layer_field(release_of(klass)) do |_layer_key, _layer_label, field|
|
|
315
|
+
options[field['field'].to_s] ||= field_option(field)
|
|
316
|
+
end
|
|
317
|
+
options
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# ---- shared schema walking -------------------------------------------
|
|
322
|
+
|
|
323
|
+
def property_options
|
|
324
|
+
@property_options ||= begin
|
|
325
|
+
options = {}
|
|
326
|
+
each_layer_field(element_klass_release) do |layer_key, layer_label, field|
|
|
327
|
+
key = self.class.encode_property_key(layer_key, field['field'])
|
|
328
|
+
options[key] = field_option(field).merge('layerKey' => layer_key, 'layerLabel' => layer_label)
|
|
329
|
+
end
|
|
330
|
+
options
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def layer_labels
|
|
335
|
+
@layer_labels ||= begin
|
|
336
|
+
layers = element_klass_release[LAYERS] || {}
|
|
337
|
+
layers.each_with_object({}) do |(layer_key, layer), acc|
|
|
338
|
+
acc[layer_key.to_s] = layer['label'] || layer_key.to_s
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def element_klass_release
|
|
344
|
+
@element_klass_release ||= begin
|
|
345
|
+
klass = element.respond_to?(:element_klass) ? element.element_klass : nil
|
|
346
|
+
release = release_of(klass)
|
|
347
|
+
release.empty? ? release_of(element) : release
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def release_of(record)
|
|
352
|
+
return {} if record.nil? || !record.respond_to?(:properties_release)
|
|
353
|
+
|
|
354
|
+
release = record.properties_release
|
|
355
|
+
release.is_a?(Hash) ? release : {}
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def each_layer_field(release)
|
|
359
|
+
layers = release[LAYERS] || {}
|
|
360
|
+
layers.each do |layer_key, layer|
|
|
361
|
+
next unless layer.is_a?(Hash)
|
|
362
|
+
|
|
363
|
+
layer_label = layer['label'] || layer_key.to_s
|
|
364
|
+
Array(layer[FIELDS]).each do |field|
|
|
365
|
+
next unless field.is_a?(Hash) && !field['field'].to_s.empty?
|
|
366
|
+
next unless COLUMN_FIELD_TYPES.include?(field['type'].to_s)
|
|
367
|
+
|
|
368
|
+
yield(layer_key.to_s, layer_label, field)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def field_option(field)
|
|
374
|
+
label = field['label'].to_s.empty? ? field['field'].to_s : field['label'].to_s
|
|
375
|
+
{
|
|
376
|
+
'fieldKey' => field['field'].to_s,
|
|
377
|
+
'fieldLabel' => label,
|
|
378
|
+
'type' => field['type'].to_s,
|
|
379
|
+
'defaultUnit' => default_unit(field)
|
|
380
|
+
}
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# ---- units ------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
# The grid stores unit *keys* (`C`, `mm_s2`), never the display labels --
|
|
386
|
+
# some of which carry HTML (`mm/s<sup>2</sup>`). Keep the key: it is what a
|
|
387
|
+
# round-trip must preserve.
|
|
388
|
+
def field_units(field)
|
|
389
|
+
quantity = field['option_layers'] || field['generic_quantity']
|
|
390
|
+
return [] if quantity.to_s.empty?
|
|
391
|
+
|
|
392
|
+
config = Labimotion::Units::FIELDS.find { |entry| entry[:field] == quantity }
|
|
393
|
+
return [] unless config
|
|
394
|
+
|
|
395
|
+
Array(config[:units]).filter_map { |unit| unit[:key]&.to_s }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def default_unit(field)
|
|
399
|
+
units = field_units(field)
|
|
400
|
+
return '' if units.empty?
|
|
401
|
+
|
|
402
|
+
value_system = field['value_system'].to_s
|
|
403
|
+
value_system.empty? ? units.first : value_system
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def unit_for(column_key, option, inferred)
|
|
407
|
+
stored = column_units[column_key]
|
|
408
|
+
return stored.to_s unless stored.to_s.empty?
|
|
409
|
+
return inferred.to_s unless inferred.to_s.empty?
|
|
410
|
+
|
|
411
|
+
option['defaultUnit'].to_s
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
def column_units
|
|
415
|
+
@column_units ||= layout['columnUnits'].is_a?(Hash) ? layout['columnUnits'] : {}
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def with_unit(label, unit)
|
|
419
|
+
unit.to_s.empty? ? label : "#{label} [#{unit}]"
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def presence(value)
|
|
423
|
+
value.to_s.empty? ? nil : value.to_s
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'caxlsx'
|
|
4
|
+
|
|
5
|
+
module Labimotion
|
|
6
|
+
## ElementVariationHeader
|
|
7
|
+
# Turns an ordered column set into the banded header the React grid draws:
|
|
8
|
+
#
|
|
9
|
+
# row 1 group Properties | Metadata | Segments
|
|
10
|
+
# row 2 sub-group Conditions | Notes | Group | My Segment
|
|
11
|
+
# row 3 leaf Temperature [C] | Solvent | | | Mass [g]
|
|
12
|
+
#
|
|
13
|
+
# Adjacent columns sharing a header are merged across, and a header with no
|
|
14
|
+
# level beneath it -- the identity columns, and the metadata leaves, which hang
|
|
15
|
+
# straight off their group -- is merged down to the leaf row. That is exactly
|
|
16
|
+
# what ag-grid renders for the same columns.
|
|
17
|
+
#
|
|
18
|
+
# `rows` are the three cell arrays; `merges` the ranges to hand to Axlsx.
|
|
19
|
+
class ElementVariationHeader
|
|
20
|
+
ROWS = 3
|
|
21
|
+
|
|
22
|
+
def initialize(columns)
|
|
23
|
+
@columns = columns
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
attr_reader :columns
|
|
27
|
+
|
|
28
|
+
def rows
|
|
29
|
+
plan[:rows]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def merges
|
|
33
|
+
plan[:merges]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def plan
|
|
39
|
+
@plan ||= build
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def build
|
|
43
|
+
@rows = Array.new(ROWS) { Array.new(columns.length, '') }
|
|
44
|
+
@merges = []
|
|
45
|
+
|
|
46
|
+
runs(columns.map(&:group)).each { |run| place_group(run) }
|
|
47
|
+
runs(columns.map { |column| sub_group_key(column) }).each { |run| place_sub_group(run) }
|
|
48
|
+
|
|
49
|
+
{ rows: @rows, merges: @merges }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def place_group(run)
|
|
53
|
+
# No group at all: an identity column, spanning the whole header.
|
|
54
|
+
return span_down(run, 0) if blank?(run[:key])
|
|
55
|
+
|
|
56
|
+
span_across(run, 0, run[:key])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def place_sub_group(run)
|
|
60
|
+
return if run[:key].nil?
|
|
61
|
+
|
|
62
|
+
sub_group = columns[run[:first]].sub_group
|
|
63
|
+
# No sub-group: a metadata leaf, spanning the rows below its group.
|
|
64
|
+
return span_down(run, 1) if blank?(sub_group)
|
|
65
|
+
|
|
66
|
+
span_across(run, 1, sub_group)
|
|
67
|
+
fill(run, 2)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# One cell per column, each merged down to the leaf row.
|
|
71
|
+
def span_down(run, row)
|
|
72
|
+
fill(run, row)
|
|
73
|
+
column_range(run).each { |index| merge(index, row, index, ROWS - 1) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A single cell carrying `text` across the whole run.
|
|
77
|
+
def span_across(run, row, text)
|
|
78
|
+
@rows[row][run[:first]] = text
|
|
79
|
+
merge(run[:first], row, run[:last], row) unless run[:last] == run[:first]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fill(run, row)
|
|
83
|
+
column_range(run).each { |index| @rows[row][index] = columns[index].label }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def column_range(run)
|
|
87
|
+
(run[:first]..run[:last])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def merge(from_column, from_row, to_column, to_row)
|
|
91
|
+
@merges << [cell_ref(from_column, from_row), cell_ref(to_column, to_row)]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def cell_ref(column_index, row_index)
|
|
95
|
+
"#{Axlsx.col_ref(column_index)}#{row_index + 1}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Keyed on the sub-group *key*, not its label: two layers may share a label.
|
|
99
|
+
def sub_group_key(column)
|
|
100
|
+
blank?(column.group) ? nil : [column.group, column.sub_group_key]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Contiguous columns sharing a header key.
|
|
104
|
+
def runs(keys)
|
|
105
|
+
keys.each_with_index.with_object([]) do |(key, index), acc|
|
|
106
|
+
last = acc.last
|
|
107
|
+
if last && last[:key] == key
|
|
108
|
+
last[:last] = index
|
|
109
|
+
else
|
|
110
|
+
acc << { key: key, first: index, last: index }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def blank?(value)
|
|
116
|
+
value.to_s.empty?
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|