glance-widgets-sdk 1.0.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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +17 -0
  3. data/README.md +47 -0
  4. data/lib/glance_widgets/box.rb +117 -0
  5. data/lib/glance_widgets/color.rb +48 -0
  6. data/lib/glance_widgets/components/bar_chart.rb +65 -0
  7. data/lib/glance_widgets/components/battery.rb +77 -0
  8. data/lib/glance_widgets/components/charts.rb +5 -0
  9. data/lib/glance_widgets/components/heatmap.rb +184 -0
  10. data/lib/glance_widgets/components/line_chart.rb +213 -0
  11. data/lib/glance_widgets/components/modern.rb +59 -0
  12. data/lib/glance_widgets/components/pixel_bar_chart.rb +106 -0
  13. data/lib/glance_widgets/components/pixel_progress.rb +151 -0
  14. data/lib/glance_widgets/components/pixel_text.rb +66 -0
  15. data/lib/glance_widgets/components/progress.rb +62 -0
  16. data/lib/glance_widgets/components/ring_progress.rb +89 -0
  17. data/lib/glance_widgets/components/shared.rb +131 -0
  18. data/lib/glance_widgets/components/signal.rb +41 -0
  19. data/lib/glance_widgets/components/stat.rb +171 -0
  20. data/lib/glance_widgets/components/status.rb +102 -0
  21. data/lib/glance_widgets/data/fonts/font-3x5.json +120 -0
  22. data/lib/glance_widgets/data/fonts/font-5x7.json +118 -0
  23. data/lib/glance_widgets/data/icons.json +5293 -0
  24. data/lib/glance_widgets/data/schemas.json +418 -0
  25. data/lib/glance_widgets/data/text-advances.json +120 -0
  26. data/lib/glance_widgets/font.rb +165 -0
  27. data/lib/glance_widgets/geometry.rb +108 -0
  28. data/lib/glance_widgets/layers.rb +152 -0
  29. data/lib/glance_widgets/matrix.rb +277 -0
  30. data/lib/glance_widgets/options.rb +31 -0
  31. data/lib/glance_widgets/output.rb +91 -0
  32. data/lib/glance_widgets/pixelate.rb +46 -0
  33. data/lib/glance_widgets/protocol.rb +217 -0
  34. data/lib/glance_widgets/raster.rb +130 -0
  35. data/lib/glance_widgets/signature.rb +30 -0
  36. data/lib/glance_widgets/support.rb +134 -0
  37. data/lib/glance_widgets/themes.rb +34 -0
  38. data/lib/glance_widgets.rb +38 -0
  39. metadata +79 -0
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ LINE_CHART_DEFAULTS = {
5
+ 'id' => 'lineChart', 'interpolation' => 'monotone', 'strokeWidth' => 2, 'fillOpacity' => 0,
6
+ 'areaFillOpacity' => 0.3, 'markerRadius' => 3, 'fontSizeRatio' => 0.06, 'axisWidth' => 1,
7
+ 'gridCellSize' => 8, 'zIndex' => 0, 'axisColor' => '#FFFFFF', 'labelColor' => '#FFFFFF',
8
+ 'axisOpacity' => 0.5, 'labelOpacity' => 0.72,
9
+ 'seriesColors' => %w[#6BA7FF #FF9F0A #34C759 #FF453A #BF5AF2 #5AC8FA]
10
+ }.freeze
11
+
12
+ module Components
13
+ def series_points(entry)
14
+ spec = entry.is_a?(Array) ? { 'values' => entry } : entry
15
+ raise ArgumentError, 'Series needs exactly one of values or points' if DataValues.set?(spec, 'values') == DataValues.set?(spec, 'points')
16
+ raw = spec['points'] || spec.fetch('values').each_with_index.map { |value, i| [i, value] }
17
+ raise ArgumentError, 'Series needs 2..1024 points' unless raw.length.between?(2, 1024)
18
+ unless raw.all? { |point| point.is_a?(Array) && point.length == 2 && point.all? { |v| Numbers.finite?(v) } }
19
+ raise ArgumentError, 'Series points must be finite number pairs'
20
+ end
21
+ points = DataValues.copy(raw).sort_by(&:first)
22
+ raise ArgumentError, 'Series cannot repeat x coordinates' if points.each_cons(2).any? { |a, b| a[0] == b[0] }
23
+ points
24
+ end
25
+
26
+ def chart_markers(points, color, markers, decimals, radius, label_color)
27
+ ys = points.map(&:last)
28
+ wanted = []
29
+ wanted << [points.length - 1, 'last'] if markers['last']
30
+ wanted << [ys.index(ys.max), 'max'] if markers['max']
31
+ wanted << [ys.index(ys.min), 'min'] if markers['min']
32
+ seen = {}
33
+ wanted.each_with_object([]) do |(index, kind), result|
34
+ next if seen[index]
35
+ seen[index] = true
36
+ x, y = points[index]
37
+ marker = { 'at' => { 'x' => x }, 'symbol' => { 'radiusPt' => radius, 'fill' => color } }
38
+ if markers['labels']
39
+ below = kind == 'min'
40
+ marker['label'] = Numbers.format_number(y, decimals)
41
+ marker['labelStyle'] = { 'color' => label_color, 'fontSizePt' => 10, 'position' => below ? 'bottom' : 'top' }
42
+ marker['at'] = { 'x' => x, 'offsetPt' => [0, below ? radius + 2 : -(radius + 2)] }
43
+ end
44
+ result << marker
45
+ end
46
+ end
47
+
48
+ def plot_plan(canvas, options, ticks, padding)
49
+ ratio = DataValues.fetch_or(options, 'fontSizeRatio', 0.06)
50
+ pt = font_pt(ratio, canvas)
51
+ metrics = { 'ratio' => ratio, 'fontPt' => pt, 'lineHeightPt' => pt * 1.2, 'gapPt' => pt * 0.4 }
52
+ axis_width = DataValues.fetch_or(options, 'axisWidth', 1)
53
+ axis_pt = options['showAxes'] ? axis_width : 0
54
+ label_width = ticks.map { |tick| text_width(tick, pt) }.max
55
+ half_label = options['showValueLabels'] ? metrics['lineHeightPt'] * 1.5 / 2 : 0
56
+ x_row = DataValues.fetch_or(options, 'xLabels', []).empty? ? 0 : metrics['gapPt'] + text_height(pt)
57
+ need = { 'left' => (options['showValueLabels'] ? label_width + metrics['gapPt'] : 0) + axis_pt,
58
+ 'top' => half_label, 'right' => 0, 'bottom' => [half_label, axis_pt + x_row].max }
59
+ area_box = area(options['frame'], canvas)
60
+ insets = need.transform_values { |value| [value, padding].max - padding }
61
+ curve_region = emitted(area_box.inset_pt(insets), canvas)
62
+ rect = curve_region['rect']
63
+ plot = { 'x' => rect['x'] + padding, 'y' => rect['y'] + padding,
64
+ 'width' => rect['width'] - 2 * padding, 'height' => rect['height'] - 2 * padding }
65
+ raise ArgumentError, 'Chart area too small for labels' unless plot['width'] > 0 && plot['height'] > 0
66
+ { 'canvas' => canvas, 'area' => area_box.to_pt, 'curveFrame' => curve_region['frame'],
67
+ 'plot' => plot, 'text' => metrics, 'axisX' => plot['x'] - axis_width / 2.0 }
68
+ end
69
+
70
+ def chart_grid(id, path, z, grid, layout)
71
+ selected_theme = theme(grid['theme'])
72
+ canvas = layout['canvas']
73
+ region = emitted(Box.from_pt(layout['plot'], canvas), canvas)
74
+ solved = GlanceWidgets.solve_grid('region' => region['rect'], 'cellSize' => DataValues.fetch_or(grid, 'cellSize', 8),
75
+ 'gapMode' => selected_theme['layout']['gapMode'])
76
+ matrix = Matrix.new(solved['columns'], solved['rows'], GlanceWidgets.theme_options(
77
+ selected_theme, 'default' => selected_theme['default'], 'layout' => solved['layout']
78
+ ))
79
+ GlanceWidgets.place(matrix.to_part, 'id' => id + ':grid', 'frame' => region['frame'],
80
+ 'path' => path, 'zIndex' => z).merge('plot' => region['rect'])
81
+ end
82
+
83
+ def chart_overlays(id, z, options, layout, ticks)
84
+ canvas, area, plot, metrics = layout.values_at('canvas', 'area', 'plot', 'text')
85
+ axis_x, axis_width = layout['axisX'], DataValues.fetch_or(options, 'axisWidth', 1)
86
+ bottom = plot['y'] + plot['height']
87
+ colors = DataValues.fetch_or(options, 'colors', {})
88
+ layers = []
89
+ if options['showAxes']
90
+ style = { 'color' => DataValues.fetch_or(colors, 'axis', '#FFFFFF'), 'strokeWidth' => axis_width, 'opacity' => 0.5 }
91
+ layers << GlanceWidgets.vertical_line('id' => id + ':axis-value', 'at' => axis_x / canvas['width'],
92
+ 'from' => plot['y'] / canvas['height'], 'to' => bottom / canvas['height'],
93
+ 'zIndex' => z, 'style' => style)
94
+ layers << GlanceWidgets.horizontal_line('id' => id + ':axis-base',
95
+ 'at' => (bottom + axis_width / 2.0) / canvas['height'],
96
+ 'from' => (axis_x - axis_width / 2.0) / canvas['width'],
97
+ 'to' => (plot['x'] + plot['width']) / canvas['width'],
98
+ 'zIndex' => z, 'style' => style)
99
+ end
100
+ add = lambda do |role, rect, content, horizontal, vertical|
101
+ layers << label(id + ':' + role, Box.from_pt(rect, canvas), content, horizontal, vertical,
102
+ metrics['ratio'], DataValues.fetch_or(colors, 'label', '#FFFFFF'), 0.72, z)
103
+ end
104
+ if options['showValueLabels']
105
+ height = metrics['lineHeightPt'] * 1.5
106
+ right = axis_x - axis_width / 2.0 - metrics['gapPt']
107
+ ticks.each_with_index do |content, i|
108
+ center = i.zero? ? plot['y'] : bottom
109
+ rect = { 'x' => area['x'], 'y' => center - height / 2,
110
+ 'width' => [0, right - area['x']].max, 'height' => height }
111
+ add.call("value-#{i}", rect, content, 'right', 'center')
112
+ end
113
+ end
114
+ labels = DataValues.fetch_or(options, 'xLabels', [])
115
+ axis_pt = options['showAxes'] ? axis_width : 0
116
+ labels.each_with_index do |content, i|
117
+ fraction = labels.length == 1 ? 0.5 : i.fdiv(labels.length - 1)
118
+ width = text_width(content, metrics['fontPt'])
119
+ centered = [area['x'], plot['x'] + fraction * plot['width'] - width / 2].max
120
+ left = [area['x'] + area['width'] - width, centered].min
121
+ add.call("x-#{i}", { 'x' => left, 'y' => bottom + axis_pt + metrics['gapPt'],
122
+ 'width' => width, 'height' => text_height(metrics['fontPt']) }, content, 'center', 'top')
123
+ end
124
+ layers
125
+ end
126
+
127
+ def chart(what, input_series, options)
128
+ raise ArgumentError, 'Chart needs at least one series' if input_series.empty?
129
+ id, path = identity(options, 'lineChart', what)
130
+ z, stroke = DataValues.fetch_or(options, 'zIndex', 0), DataValues.fetch_or(options, 'strokeWidth', 2)
131
+ markers = DataValues.fetch_or(options, 'markers', {})
132
+ radius = DataValues.fetch_or(markers, 'radius', 3)
133
+ has_markers = %w[last min max].any? { |key| markers[key] == true }
134
+ colors = DataValues.fetch_or(options, 'colors', {})
135
+ palette = DataValues.fetch_or(colors, 'series', LINE_CHART_DEFAULTS['seriesColors'])
136
+ series = input_series.each_with_index.map do |entry, i|
137
+ color = entry.is_a?(Hash) ? entry['color'] : nil
138
+ { 'points' => series_points(entry),
139
+ 'color' => GlanceWidgets.normalize_color(color || palette[i % palette.length]) }
140
+ end
141
+ every = series.flat_map { |entry| entry['points'] }
142
+ domain = every.map(&:first).minmax
143
+ low, high = DataValues.fetch_or(options, 'min', every.map(&:last).min), DataValues.fetch_or(options, 'max', every.map(&:last).max)
144
+ if low == high && !DataValues.set?(options, 'min') && !DataValues.set?(options, 'max')
145
+ pad = low.abs < 0.001 ? 1 : low.abs * 0.1
146
+ low, high = low - pad, high + pad
147
+ end
148
+ raise ArgumentError, 'Chart max must exceed min' unless high > low
149
+ decimals = DataValues.fetch_or(options, 'decimals', decimals(low, high))
150
+ series.each { |entry| entry['points'] = entry['points'].map { |x, y| [x, Numbers.clamp(y, low, high)] } }
151
+ marker_lists = series.map do |entry|
152
+ chart_markers(entry['points'], entry['color'], markers, decimals, radius, DataValues.fetch_or(colors, 'label', '#FFFFFF'))
153
+ end
154
+ reaches = marker_lists.flatten.select { |marker| DataValues.set?(marker, 'label') }.map do |marker|
155
+ [radius + 2 + text_height(10), text_width(marker['label'], 10) / 2].max
156
+ end
157
+ padding = [48, [stroke / 2.0, has_markers ? radius : 0, *reaches].max].min
158
+ grid = options['grid'] == true ? {} : (options['grid'] == false ? nil : options['grid'])
159
+ ticks = [high, low].map { |value| Numbers.format_number(value, decimals) }
160
+ needs_canvas = options['showAxes'] || options['showValueLabels'] ||
161
+ !DataValues.fetch_or(options, 'xLabels', []).empty? || !grid.nil?
162
+ layout = if needs_canvas
163
+ plot_plan(GlanceWidgets.require_canvas(options['canvas'], what), options, ticks, padding)
164
+ end
165
+ area = options['frame']
166
+ frame = if layout
167
+ layout['curveFrame']
168
+ elsif area.nil?
169
+ { 'left' => 0, 'top' => 0, 'width' => 1, 'height' => 1 }
170
+ else
171
+ area.is_a?(Box) ? area.to_frame : DataValues.copy(area)
172
+ end
173
+ layers = []
174
+ if layout && grid
175
+ background = chart_grid(id, path, z, grid, layout)
176
+ layers << background
177
+ layout['plot'] = background['plot']
178
+ end
179
+ series.each_with_index do |entry, i|
180
+ layers << GlanceWidgets.curve(
181
+ 'id' => "#{id}:series-#{i}", 'frame' => frame, 'zIndex' => z + 1,
182
+ 'data' => { 'mode' => 'samples', 'points' => entry['points'], 'domain' => domain, 'yDomain' => [low, high],
183
+ 'interpolation' => DataValues.fetch_or(options, 'interpolation', 'monotone'), 'markers' => marker_lists[i] },
184
+ 'style' => { 'color' => entry['color'], 'fillColor' => entry['color'], 'opacity' => 1,
185
+ 'fillOpacity' => DataValues.fetch_or(options, 'fillOpacity', 0),
186
+ 'strokeWidth' => stroke, 'plotPadding' => padding }
187
+ )
188
+ end
189
+ layers += chart_overlays(id, z + 2, options, layout, ticks) if layout
190
+ Components.combine(layers)
191
+ end
192
+ end
193
+
194
+ def line_chart(series, options = {})
195
+ Components.chart('line_chart', series, { 'showAxes' => true, 'showValueLabels' => true }.merge(options) { |_, default, user| user.nil? ? default : user })
196
+ end
197
+
198
+ def area_chart(series, options = {})
199
+ Components.chart('area_chart', series,
200
+ { 'fillOpacity' => 0.3, 'showAxes' => true, 'showValueLabels' => true }.merge(options) { |_, default, user| user.nil? ? default : user })
201
+ end
202
+
203
+ def sparkline(values, options = {})
204
+ series = { 'values' => values }
205
+ series['color'] = options['color'] if DataValues.set?(options, 'color')
206
+ rest = options.reject { |key, _| %w[color fill fillOpacity].include?(key) }
207
+ Components.chart('sparkline', [series], rest.merge(
208
+ 'id' => DataValues.fetch_or(options, 'id', 'sparkline'), 'markers' => DataValues.fetch_or(options, 'markers', 'last' => true),
209
+ 'fillOpacity' => options['fill'] == true ? DataValues.fetch_or(options, 'fillOpacity', 0.3) : 0,
210
+ 'showAxes' => false, 'showValueLabels' => false
211
+ ))
212
+ end
213
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ # Shared pieces of the modern (layer-built, not matrix) components. Thin or partial pieces are
5
+ # `line` layers (stroke 0.5-8 pt) and `curve` layers (stroke 0.25-12 pt), both with round caps.
6
+ MODERN_COLORS = { 'fill' => '#0A84FF', 'track' => '#8E8E93', 'label' => '#FFFFFF' }.freeze
7
+ TRACK_OPACITY = 0.3
8
+ LINE_STROKE_MAX_PT = 8
9
+ CURVE_STROKE_MAX_PT = 12
10
+ CURVE_POINT_DECIMALS = 1e3
11
+
12
+ module Modern
13
+ extend self
14
+
15
+ def require_stroke(what, name, value, max)
16
+ return if Numbers.finite?(value) && value >= 0.5 && value <= max
17
+ raise ArgumentError, "#{what}: #{name} must be between 0.5 and #{max} pt, got #{value}"
18
+ end
19
+
20
+ def min_capsule_length(canvas, direction, width)
21
+ width + FRAME_SIZE_MIN * (direction == 'horizontal' ? canvas['width'] : canvas['height'])
22
+ end
23
+
24
+ # A round-capped line whose visible extent is exactly [from, to].
25
+ def capsule(spec)
26
+ canvas, direction, width = spec['canvas'], spec['direction'], spec['width']
27
+ horizontal = direction == 'horizontal'
28
+ at = spec['at']
29
+ start = spec['from'] + width / 2.0
30
+ stop = [spec['to'] - width / 2.0, start + FRAME_SIZE_MIN * (horizontal ? canvas['width'] : canvas['height'])].max
31
+ frame = if horizontal
32
+ Box.new(start / canvas['width'], at / canvas['height'], (stop - start) / canvas['width'], 0)
33
+ else
34
+ Box.new(at / canvas['width'], start / canvas['height'], 0, (stop - start) / canvas['height'])
35
+ end
36
+ GlanceWidgets.line('id' => spec['id'], 'frame' => frame, 'direction' => direction, 'zIndex' => spec['zIndex'],
37
+ 'style' => { 'color' => spec['color'], 'opacity' => spec['opacity'], 'strokeWidth' => width, 'cap' => 'round' })
38
+ end
39
+
40
+ # A horizontal round-capped bar up to 12 pt thick: a 2-point curve whose visible extent is exactly [from, to].
41
+ def curve_capsule(spec)
42
+ canvas, width = spec['canvas'], spec['width']
43
+ half = width / 2.0
44
+ round = ->(v) { Numbers.round(v * CURVE_POINT_DECIMALS) / CURVE_POINT_DECIMALS }
45
+ frame_width = [spec['to'] - spec['from'], width + 0.05, FRAME_SIZE_MIN * canvas['width']].max
46
+ frame_height = [width, FRAME_SIZE_MIN * canvas['height']].max
47
+ GlanceWidgets.curve('id' => spec['id'], 'zIndex' => spec['zIndex'],
48
+ 'frame' => Box.from_pt({ 'x' => spec['from'], 'y' => spec['at'] - frame_height / 2.0, 'width' => frame_width, 'height' => frame_height }, canvas),
49
+ 'data' => { 'mode' => 'samples',
50
+ 'points' => [[round.call(half), 0.5], [round.call(frame_width - half), 0.5]],
51
+ 'domain' => [0, round.call(frame_width)], 'yDomain' => [0, 1], 'interpolation' => 'linear' },
52
+ 'style' => { 'color' => spec['color'], 'opacity' => spec['opacity'], 'strokeWidth' => width, 'lineCap' => 'round' })
53
+ end
54
+
55
+ def fill_length(fraction, full, minimum)
56
+ fraction <= 0 ? 0 : [full, [minimum, fraction * full].max].min
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ PIXEL_BAR_CHART_DEFAULTS = {
5
+ 'id' => 'pixelBarChart', 'rows' => 10, 'gap' => 0.16, 'barWidth' => 1, 'barSpacing' => 1,
6
+ 'cornerRadius' => 0.15, 'fontSizeRatio' => 0.06, 'axisWidth' => 1, 'zIndex' => 0,
7
+ 'colors' => { 'positive' => '#5EA2FF', 'negative' => '#FF6B6B', 'track' => nil,
8
+ 'axis' => '#FFFFFF', 'label' => '#FFFFFF' }, 'axisOpacity' => 0.5, 'labelOpacity' => 0.72
9
+ }.freeze
10
+
11
+ def pixel_bar_chart(values, options)
12
+ unless !values.empty? && values.all? { |v| Numbers.finite?(v) }
13
+ raise ArgumentError, 'pixel_bar_chart needs finite values'
14
+ end
15
+ canvas = require_canvas(options['canvas'], 'pixel_bar_chart')
16
+ id, path = Components.identity(options, 'pixelBarChart', 'pixel_bar_chart')
17
+ width, spacing, z = DataValues.fetch_or(options, 'barWidth', 1), DataValues.fetch_or(options, 'barSpacing', 1), DataValues.fetch_or(options, 'zIndex', 0)
18
+ colors = PIXEL_BAR_CHART_DEFAULTS['colors'].merge(DataValues.fetch_or(options, 'colors', {}))
19
+ categories = DataValues.fetch_or(options, 'categories', [])
20
+ show_categories = DataValues.fetch_or(options, 'showCategoryLabels', !categories.empty?)
21
+ show_values, show_axes = DataValues.fetch_or(options, 'showValueLabels', true), DataValues.fetch_or(options, 'showAxes', true)
22
+ raise ArgumentError, 'Categories need one entry per value' if show_categories && categories.length != values.length
23
+ low, high = DataValues.fetch_or(options, 'min', [0, *values].min), DataValues.fetch_or(options, 'max', [0, *values].max)
24
+ high = low + 1 if !DataValues.set?(options, 'min') && !DataValues.set?(options, 'max') && low == high
25
+ raise ArgumentError, 'pixel_bar_chart max must exceed min' unless high > low
26
+ zero = Numbers.clamp(0, low, high)
27
+ decimals = DataValues.fetch_or(options, 'decimals', Components.decimals(low, high))
28
+ ratio = DataValues.fetch_or(options, 'fontSizeRatio', 0.06)
29
+ pt = Components.font_pt(ratio, canvas)
30
+ line_height, gap_pt, axis_width = pt * 1.2, pt * 0.4, DataValues.fetch_or(options, 'axisWidth', 1)
31
+ area = Components.area(options['frame'], canvas)
32
+ rect = area.to_pt
33
+ rows, gap = DataValues.fetch_or(options, 'rows', 10), DataValues.fetch_or(options, 'gap', 0.16)
34
+ half_gap = show_axes || show_values || show_categories ? gap * rect['height'] / rows / 2 : 0
35
+ ticks = [high, zero, low].uniq { |v| v.zero? ? 0.0 : v.to_f }.map { |v| [v, Numbers.format_number(v, decimals)] }
36
+ label_width = ticks.map { |_, label| Components.text_width(label, pt) }.max
37
+ half_label = show_values ? line_height * 1.5 / 2 : 0
38
+ category_row = show_categories ? gap_pt + Components.text_height(pt) : 0
39
+ plot_box = area.inset_pt(
40
+ 'left' => (show_values ? label_width + gap_pt : 0) + (show_axes || show_values ? axis_width + half_gap : 0),
41
+ 'top' => half_label + half_gap, 'bottom' => [half_label, category_row].max + half_gap
42
+ )
43
+ frame = plot_box.to_frame
44
+ region = resolve_frame(frame, canvas)
45
+ columns = values.length * width + (values.length - 1) * spacing
46
+ grid = solve_grid('region' => region, 'columns' => columns, 'rows' => rows, 'gap' => gap)
47
+ geometry = fit_matrix(region, grid, grid['layout'])
48
+ baseline = Numbers.round((grid['rows'] * (high - zero)).fdiv(high - low))
49
+ matrix = Matrix.new(columns, grid['rows'],
50
+ 'default' => colors['track'].nil? ? EMPTY : normalize_color(colors['track']),
51
+ 'base' => { 'opacity' => 1, 'cornerRadius' => DataValues.fetch_or(options, 'cornerRadius', 0.15) },
52
+ 'layout' => grid['layout'])
53
+ values.each_with_index do |value, i|
54
+ x = i * (width + spacing)
55
+ value = Numbers.clamp(value, low, high)
56
+ if value > zero
57
+ cells = Numbers.round((value - zero).fdiv(high - zero) * baseline)
58
+ matrix.rect(x, baseline - cells, width, cells, colors['positive'])
59
+ elsif value < zero
60
+ cells = Numbers.round((zero - value).fdiv(zero - low) * (grid['rows'] - baseline))
61
+ matrix.rect(x, baseline, width, cells, colors['negative'])
62
+ end
63
+ end
64
+ placed = place(matrix.to_part, 'id' => id + ':plot', 'frame' => frame, 'path' => path, 'zIndex' => z)
65
+ layers = [placed]
66
+ axis_x = [column_boundary(geometry, 0), geometry['originX'] - axis_width / 2.0].min
67
+ if show_axes
68
+ style = { 'color' => colors['axis'], 'strokeWidth' => axis_width, 'opacity' => 0.5 }
69
+ layers << vertical_line('id' => id + ':axis-value', 'at' => axis_x / canvas['width'],
70
+ 'from' => row_boundary(geometry, 0) / canvas['height'],
71
+ 'to' => row_boundary(geometry, grid['rows']) / canvas['height'],
72
+ 'zIndex' => z + 1, 'style' => style)
73
+ layers << horizontal_line('id' => id + ':axis-base', 'at' => row_boundary(geometry, baseline) / canvas['height'],
74
+ 'from' => (axis_x - axis_width / 2.0) / canvas['width'],
75
+ 'to' => column_boundary(geometry, grid['columns']) / canvas['width'],
76
+ 'zIndex' => z + 1, 'style' => style)
77
+ end
78
+ add = lambda do |role, box, content, horizontal, vertical|
79
+ layers << Components.label(id + ':' + role, box, content, horizontal, vertical, ratio,
80
+ colors['label'], 0.72, z + 1)
81
+ end
82
+ if show_values
83
+ height = line_height * 1.5
84
+ right = axis_x - axis_width / 2.0 - gap_pt
85
+ ticks.each_with_index do |(value, content), i|
86
+ boundary = value == high ? 0 : (value == low ? grid['rows'] : baseline)
87
+ center = row_boundary(geometry, boundary)
88
+ box = Box.from_pt({ 'x' => rect['x'], 'y' => center - height / 2,
89
+ 'width' => [0, right - rect['x']].max, 'height' => height }, canvas)
90
+ add.call("value-#{i}", box, content, 'right', 'center')
91
+ end
92
+ end
93
+ if show_categories
94
+ pitch = (width + spacing) * (geometry['cellWidth'] + geometry['gapX'])
95
+ categories.each_with_index do |category, i|
96
+ start = i * (width + spacing)
97
+ center = (column_boundary(geometry, start) + column_boundary(geometry, start + width)) / 2
98
+ label_width = [pitch, 2 * (center - rect['x']), 2 * (rect['x'] + rect['width'] - center)].min
99
+ box = Box.from_pt({ 'x' => center - label_width / 2, 'y' => row_boundary(geometry, grid['rows']) + gap_pt,
100
+ 'width' => label_width, 'height' => Components.text_height(pt) }, canvas)
101
+ add.call("category-#{i}", box, category, 'center', 'top')
102
+ end
103
+ end
104
+ Components.combine(layers)
105
+ end
106
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ PIXEL_PROGRESS_BAR_DEFAULTS = {
5
+ 'min' => 0, 'max' => 100, 'orientation' => 'horizontal', 'segments' => 10,
6
+ 'segmentSize' => 3, 'segmentGap' => 1, 'thickness' => 2
7
+ }.freeze
8
+ LABELED_PIXEL_PROGRESS_BAR_DEFAULTS = {
9
+ 'id' => 'progress', 'labelPosition' => 'end', 'fontSizeRatio' => 0.07, 'labelOpacity' => 1, 'zIndex' => 0
10
+ }.freeze
11
+ PIXEL_RING_PROGRESS_DEFAULTS = { 'min' => 0, 'max' => 100, 'diameter' => 33, 'thickness' => 4, 'startAngle' => 0 }.freeze
12
+ LABELED_PIXEL_RING_PROGRESS_DEFAULTS = { 'id' => 'ring', 'labelOpacity' => 1, 'zIndex' => 0 }.freeze
13
+
14
+ def bar_matrix(value, options)
15
+ minimum = options['min'].nil? ? 0 : options['min']
16
+ maximum = options['max'].nil? ? 100 : options['max']
17
+ Components.range('pixel_progress_bar', minimum, maximum)
18
+ segments, size, gap = DataValues.fetch_or(options, 'segments', 10), DataValues.fetch_or(options, 'segmentSize', 3), DataValues.fetch_or(options, 'segmentGap', 1)
19
+ Components.positive('pixel_progress_bar', 'segments', segments)
20
+ Components.positive('pixel_progress_bar', 'segmentSize', size)
21
+ raise ArgumentError, 'segmentGap must be non-negative integer' unless Numbers.integer?(gap) && gap >= 0
22
+ Components.positive('pixel_progress_bar', 'thickness', options['thickness']) if DataValues.set?(options, 'thickness')
23
+ segments, size, gap = segments.to_i, size.to_i, gap.to_i
24
+ vertical, reverse = DataValues.fetch_or(options, 'orientation', 'horizontal') == 'vertical', DataValues.fetch_or(options, 'reverse', false)
25
+ theme = Components.theme(options['theme'])
26
+ region = Components.region(options, 'pixel_progress_bar')
27
+ length = segments * size + (segments - 1) * gap
28
+ across = DataValues.fetch_or(options, 'thickness', region ? nil : 2)
29
+ dimensions = vertical ? { 'rows' => length, 'columns' => across } : { 'columns' => length, 'rows' => across }
30
+ plan = Components.grid(region, dimensions, theme, 'pixel_progress_bar')
31
+ thickness = plan[vertical ? 'columns' : 'rows']
32
+ looks = Components.looks(theme, DataValues.fetch_or(options, 'colors', {}))
33
+ matrix_options = theme_options(theme, 'default' => EMPTY, 'layout' => plan['layout'])
34
+ matrix = Matrix.new(plan['columns'], plan['rows'], matrix_options)
35
+ lit = Numbers.round(Components.fraction(value, minimum, maximum) * segments)
36
+ segments.times do |index|
37
+ slot = vertical != reverse ? segments - 1 - index : index
38
+ offset = slot * (size + gap)
39
+ style = looks[index < lit ? 'on' : 'off']
40
+ vertical ? matrix.rect(0, offset, thickness, size, style) : matrix.rect(offset, 0, size, thickness, style)
41
+ end
42
+ matrix
43
+ end
44
+ private :bar_matrix
45
+
46
+ def pixel_progress_bar(value, options = {})
47
+ bar_matrix(value, options).to_part
48
+ end
49
+
50
+ def labeled_pixel_progress_bar(value, options)
51
+ canvas = require_canvas(options['canvas'], 'labeled_pixel_progress_bar')
52
+ id, path = Components.identity(options, 'progress', 'labeled_pixel_progress_bar')
53
+ minimum = options['min'].nil? ? 0 : options['min']
54
+ maximum = options['max'].nil? ? 100 : options['max']
55
+ Components.range('labeled_pixel_progress_bar', minimum, maximum)
56
+ theme = Components.theme(options['theme'])
57
+ vertical = DataValues.fetch_or(options, 'orientation', 'horizontal') == 'vertical'
58
+ at_end = DataValues.fetch_or(options, 'labelPosition', 'end') == 'end'
59
+ content = DataValues.fetch_or(options, 'label', percent_label(Components.fraction(value, minimum, maximum)))
60
+ z = DataValues.fetch_or(options, 'zIndex', 0)
61
+ area = Components.area(options['frame'], canvas)
62
+ rect = area.to_pt
63
+ across = vertical ? Components.font_for_width(content, rect['width']) : rect['height'] / (TEXT_LINE_HEIGHT * LABEL_BOX_LINES)
64
+ along = vertical ? rect['height'] / (TEXT_LINE_HEIGHT * LABEL_BOX_LINES * LABEL_MAX_SHARE_DIVISOR) : Components.font_for_width(content, rect['width'].fdiv(LABEL_MAX_SHARE_DIVISOR))
65
+ requested = [Components.font_pt(DataValues.fetch_or(options, 'fontSizeRatio', 0.07), canvas), across, along].min
66
+ pt = Components.font_pt(Components.font_ratio(requested, canvas), canvas)
67
+ slot = vertical ? pt * (TEXT_LINE_HEIGHT * LABEL_BOX_LINES) : Components.text_width(content, pt)
68
+ side = vertical ? (at_end ? 'bottom' : 'top') : (at_end ? 'right' : 'left')
69
+ bar_box = area.inset_pt(side => slot + pt * 0.4)
70
+ label_rect = if vertical
71
+ { 'x' => rect['x'], 'y' => at_end ? rect['y'] + rect['height'] - slot : rect['y'],
72
+ 'width' => rect['width'], 'height' => slot }
73
+ else
74
+ { 'x' => at_end ? rect['x'] + rect['width'] - slot : rect['x'], 'y' => rect['y'],
75
+ 'width' => slot, 'height' => rect['height'] }
76
+ end
77
+ part = bar_matrix(value, options.merge('frame' => bar_box, 'canvas' => canvas)).to_part
78
+ placed = place(part, 'id' => id + ':bar', 'frame' => Components.emitted(bar_box, canvas)['frame'],
79
+ 'path' => path, 'zIndex' => z)
80
+ horizontal = vertical ? 'center' : (at_end ? 'right' : 'left')
81
+ label = Components.label(id + ':label', Box.from_pt(label_rect, canvas), content, horizontal, 'center',
82
+ Components.font_ratio(pt, canvas),
83
+ DataValues.fetch_or(DataValues.fetch_or(options, 'colors', {}), 'label', theme['palette']['on']), 1, z + 1)
84
+ Components.combine([placed, label])
85
+ end
86
+
87
+ def ring_matrix(value, options)
88
+ minimum = options['min'].nil? ? 0 : options['min']
89
+ maximum = options['max'].nil? ? 100 : options['max']
90
+ Components.range('pixel_ring_progress', minimum, maximum)
91
+ diameter, thickness = DataValues.fetch_or(options, 'diameter', 33), DataValues.fetch_or(options, 'thickness', 4)
92
+ start = DataValues.fetch_or(options, 'startAngle', 0)
93
+ Components.positive('pixel_ring_progress', 'diameter', diameter)
94
+ Components.positive('pixel_ring_progress', 'thickness', thickness)
95
+ diameter, thickness = diameter.to_i, thickness.to_i
96
+ raise ArgumentError, 'Ring diameter must be odd and at least 3' unless diameter >= 3 && diameter.odd?
97
+ theme = Components.theme(options['theme'])
98
+ looks = Components.looks(theme, DataValues.fetch_or(options, 'colors', {}))
99
+ radius = (diameter - 1) / 2
100
+ matrix = Matrix.new(diameter, diameter, theme_options(theme, 'default' => EMPTY))
101
+ sweep = Components.fraction(value, minimum, maximum) * 360
102
+ matrix.arc(radius, radius, radius, 0, 360, looks['off'], 'thickness' => thickness)
103
+ if sweep > 0
104
+ a, b = options['counterClockwise'] ? [start - sweep, start] : [start, start + sweep]
105
+ matrix.arc(radius, radius, radius, a, b, looks['on'], 'thickness' => thickness)
106
+ end
107
+ matrix
108
+ end
109
+ private :ring_matrix
110
+
111
+ def pixel_ring_progress(value, options = {})
112
+ ring_matrix(value, options).to_part
113
+ end
114
+
115
+ def labeled_pixel_ring_progress(value, options)
116
+ canvas = require_canvas(options['canvas'], 'labeled_pixel_ring_progress')
117
+ id, path = Components.identity(options, 'ring', 'labeled_pixel_ring_progress')
118
+ theme, z = Components.theme(options['theme']), DataValues.fetch_or(options, 'zIndex', 0)
119
+ minimum = options['min'].nil? ? 0 : options['min']
120
+ maximum = options['max'].nil? ? 100 : options['max']
121
+ fraction = Components.fraction(value, minimum, maximum)
122
+ content = DataValues.fetch_or(options, 'label', percent_label(fraction))
123
+ area = Components.area(options['frame'], canvas).to_pt
124
+ side = [area['width'], area['height']].min
125
+ box = Box.from_pt({ 'x' => area['x'] + (area['width'] - side) / 2.0,
126
+ 'y' => area['y'] + (area['height'] - side) / 2.0,
127
+ 'width' => side, 'height' => side }, canvas)
128
+ ring = Components.emitted(box, canvas)
129
+ part = ring_matrix(value, options).to_part
130
+ placed = place(part, 'id' => id + ':ring', 'frame' => ring['frame'], 'path' => path, 'zIndex' => z)
131
+ diameter = part['content']['pixels'][0].length
132
+ geometry = fit_matrix(ring['rect'], { 'columns' => diameter, 'rows' => diameter }, part['content']['layout'])
133
+ center = cell_rect(geometry, (diameter - 1) / 2.0, (diameter - 1) / 2.0)
134
+ hole = [1, (diameter - 1) / 2.0 - DataValues.fetch_or(options, 'thickness', 4)].max
135
+ radius = hole * (geometry['cellWidth'] + geometry['gapX'])
136
+ width, height = 1.6 * radius, 1.2 * radius
137
+ requested = if DataValues.set?(options, 'fontSizeRatio')
138
+ Components.font_pt(options['fontSizeRatio'], canvas)
139
+ else
140
+ [height / (TEXT_LINE_HEIGHT * LABEL_BOX_LINES), Components.font_for_width(content, width)].min
141
+ end
142
+ pt = Components.font_pt(Components.font_ratio(requested, canvas), canvas)
143
+ bw, bh = [width, Components.text_width(content, pt)].max, [height, Components.text_height(pt)].max
144
+ label_box = Box.from_pt({ 'x' => center['x'] + center['width'] / 2.0 - bw / 2.0,
145
+ 'y' => center['y'] + center['height'] / 2.0 - bh / 2.0,
146
+ 'width' => bw, 'height' => bh }, canvas)
147
+ label = Components.label(id + ':label', label_box, content, 'center', 'center', Components.font_ratio(pt, canvas),
148
+ DataValues.fetch_or(DataValues.fetch_or(options, 'colors', {}), 'label', theme['palette']['on']), 1, z + 1)
149
+ Components.combine([placed, label])
150
+ end
151
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ PIXEL_TEXT_DEFAULTS = { 'font' => '5x7', 'align' => 'center', 'valign' => 'center', 'unlit' => true }.freeze
5
+
6
+ module Components
7
+ def content_matrix(columns, rows, theme)
8
+ Matrix.new([1, columns].max, [1, rows].max, GlanceWidgets.theme_options(theme, 'default' => EMPTY))
9
+ end
10
+
11
+ def fit_pixel(content, region, options)
12
+ theme = options.fetch('theme')
13
+ gap = GAP_MODE_RATIOS.fetch(theme['layout']['gapMode'])
14
+ cell = [region['width'].fdiv(content.columns + (content.columns - 1) * gap),
15
+ region['height'].fdiv(content.rows + (content.rows - 1) * gap)].min
16
+ if DataValues.set?(options, 'maxCellSize')
17
+ raise ArgumentError, 'maxCellSize must be positive' unless options['maxCellSize'] > 0
18
+ cell = [cell, options['maxCellSize'] / (1 + gap)].min
19
+ end
20
+ count = ->(extent) { Numbers.floor((extent / cell + gap) / (1 + gap) + 1e-9) }
21
+ columns = [content.columns, count.call(region['width'])].max
22
+ rows = [content.rows, count.call(region['height'])].max
23
+ raise ArgumentError, 'Pixel content exceeds 4096 cells' if columns * rows > MAX_MATRIX_CELLS
24
+ solved = GlanceWidgets.solve_grid('region' => region, 'columns' => columns, 'rows' => rows,
25
+ 'gapMode' => theme['layout']['gapMode'])
26
+ matrix = Matrix.new(columns, rows, GlanceWidgets.theme_options(
27
+ theme, 'default' => DataValues.fetch_or(options, 'unlit', false) ? theme['default'] : EMPTY, 'layout' => solved['layout']
28
+ ))
29
+ offset = lambda do |slack, position, start, finish|
30
+ position == start ? 0 : (position == finish ? slack : slack.div(2))
31
+ end
32
+ matrix.blit(content, offset.call(columns - content.columns, DataValues.fetch_or(options, 'align', 'center'), 'left', 'right'),
33
+ offset.call(rows - content.rows, DataValues.fetch_or(options, 'valign', 'center'), 'top', 'bottom'))
34
+ end
35
+
36
+ def pixel_content(content, options)
37
+ raise ArgumentError, 'Pixel text must not be empty' if content.empty?
38
+ selected_theme = theme(options['theme'])
39
+ font = DataValues.fetch_or(options, 'font', '5x7')
40
+ spacing = options.select { |key, _| %w[monospace letterSpacing lineSpacing].include?(key) }
41
+ metrics = GlanceWidgets.measure_text(content, font, spacing)
42
+ matrix = content_matrix(metrics['width'], metrics['height'], selected_theme)
43
+ drawing = spacing.merge('font' => font, 'boxWidth' => metrics['width'],
44
+ 'align' => DataValues.fetch_or(options, 'lineAlign', DataValues.fetch_or(options, 'align', 'center')))
45
+ drawing['style'] = options['color'] if DataValues.set?(options, 'color')
46
+ matrix.text(content, 0, 0, drawing)
47
+ end
48
+
49
+ def fitted_text(content, region, options)
50
+ fit_pixel(pixel_content(content, options), region,
51
+ options.merge('theme' => theme(options['theme']), 'unlit' => DataValues.fetch_or(options, 'unlit', true)))
52
+ end
53
+ end
54
+
55
+ def pixel_text_panel(content, options)
56
+ canvas = require_canvas(options['canvas'], 'pixel_text_panel')
57
+ region = Components.emitted(Components.area(options['frame'], canvas), canvas)['rect']
58
+ Components.fitted_text(content, region, options).to_part
59
+ end
60
+
61
+ def pixel_text_root(content, options)
62
+ canvas = require_canvas(options['canvas'], 'pixel_text_root')
63
+ region = { 'x' => 0, 'y' => 0, 'width' => canvas['width'], 'height' => canvas['height'] }
64
+ Components.fitted_text(content, region, options).to_root
65
+ end
66
+ end