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.
@@ -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