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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ PROGRESS_BAR_DEFAULTS = {
5
+ 'id' => 'progress', 'min' => 0, 'max' => 100, 'orientation' => 'horizontal', 'thickness' => 6,
6
+ 'labelPosition' => 'end', 'fontSizeRatio' => 0.07, 'labelOpacity' => 1, 'zIndex' => 0
7
+ }.freeze
8
+
9
+ # Modern progress bar: a rounded track and a rounded fill (2 line layers, + a label).
10
+ def progress_bar(value, options)
11
+ canvas = require_canvas(options['canvas'], 'progress_bar')
12
+ id, = Components.identity(options, 'progress', 'progress_bar')
13
+ minimum = options['min'].nil? ? 0 : options['min']
14
+ maximum = options['max'].nil? ? 100 : options['max']
15
+ Components.range('progress_bar', minimum, maximum)
16
+ thickness = DataValues.fetch_or(options, 'thickness', 6)
17
+ Modern.require_stroke('progress_bar', 'thickness', thickness, LINE_STROKE_MAX_PT)
18
+ direction = DataValues.fetch_or(options, 'orientation', 'horizontal')
19
+ vertical = direction == 'vertical'
20
+ colors = MODERN_COLORS.merge(DataValues.fetch_or(options, 'colors', {}))
21
+ z = DataValues.fetch_or(options, 'zIndex', 0)
22
+ fraction = Components.fraction(value, minimum, maximum)
23
+ area = Components.area(options['frame'], canvas).to_pt
24
+ from, to = vertical ? [area['y'], area['y'] + area['height']] : [area['x'], area['x'] + area['width']]
25
+ layers = []
26
+ label = options['label']
27
+ unless label.nil? || label == false
28
+ content = label.is_a?(String) ? label : percent_label(fraction)
29
+ at_end = DataValues.fetch_or(options, 'labelPosition', 'end') == 'end'
30
+ line_box = TEXT_LINE_HEIGHT * LABEL_BOX_LINES
31
+ fit_across = vertical ? Components.font_for_width(content, area['width']) : area['height'] / line_box
32
+ fit_along = vertical ? area['height'] / (line_box * LABEL_MAX_SHARE_DIVISOR) : Components.font_for_width(content, area['width'] / LABEL_MAX_SHARE_DIVISOR)
33
+ requested = Components.font_pt(DataValues.fetch_or(options, 'fontSizeRatio', 0.07), canvas)
34
+ pt = Components.font_pt(Components.font_ratio([requested, fit_across, fit_along].min, canvas), canvas)
35
+ slot = vertical ? pt * line_box : Components.text_width(content, pt)
36
+ reserved = slot + pt * 0.4
37
+ rect = if vertical
38
+ { 'x' => area['x'], 'y' => at_end ? to - slot : from, 'width' => area['width'], 'height' => slot }
39
+ else
40
+ { 'x' => at_end ? to - slot : from, 'y' => area['y'], 'width' => slot, 'height' => area['height'] }
41
+ end
42
+ at_end ? to -= reserved : from += reserved
43
+ layers << Components.label(id + ':label', Box.from_pt(rect, canvas), content,
44
+ vertical ? 'center' : (at_end ? 'right' : 'left'), 'center',
45
+ Components.font_ratio(pt, canvas), colors['label'], 1, z + 1)
46
+ end
47
+ at = vertical ? area['x'] + area['width'] / 2.0 : area['y'] + area['height'] / 2.0
48
+ minimum_length = Modern.min_capsule_length(canvas, direction, thickness)
49
+ raise ArgumentError, "progress_bar: the area is too short for a #{thickness} pt bar" unless to - from >= minimum_length
50
+ spec = { 'canvas' => canvas, 'direction' => direction, 'at' => at, 'width' => thickness, 'zIndex' => z }
51
+ layers.unshift(Modern.capsule(spec.merge('id' => id + ':track', 'from' => from, 'to' => to,
52
+ 'color' => colors['track'], 'opacity' => TRACK_OPACITY)))
53
+ length = Modern.fill_length(fraction, to - from, minimum_length)
54
+ if length > 0
55
+ from_start = vertical == (options['reverse'] ? true : false)
56
+ start, stop = from_start ? [from, from + length] : [to - length, to]
57
+ layers.insert(1, Modern.capsule(spec.merge('id' => id + ':fill', 'from' => start, 'to' => stop,
58
+ 'color' => colors['fill'], 'opacity' => 1, 'zIndex' => z + 1)))
59
+ end
60
+ Components.combine(layers)
61
+ end
62
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ RING_PROGRESS_DEFAULTS = {
5
+ 'id' => 'ring', 'min' => 0, 'max' => 100, 'startAngle' => 0, 'thicknessRatio' => 0.1, 'thicknessMin' => 3,
6
+ 'labelOpacity' => 1, 'zIndex' => 0
7
+ }.freeze
8
+ RING_SAMPLE_STEP_DEGREES = 2
9
+ RING_POINT_DECIMALS = 1e4
10
+ RING_X_EXTREME = 90
11
+ RING_HALF_TURN = 180
12
+
13
+ # The arc from `from` to `to` (degrees clockwise from 12 o'clock) as x-ascending polylines on the unit circle.
14
+ def arc_pieces(from, to)
15
+ unit = ->(v) { Numbers.round([1, [-1, v].max].min * RING_POINT_DECIMALS) / RING_POINT_DECIMALS }
16
+ bounds = [from]
17
+ angle = ((from - RING_X_EXTREME).fdiv(RING_HALF_TURN)).ceil * RING_HALF_TURN + RING_X_EXTREME
18
+ while angle < to
19
+ bounds << angle if angle > from
20
+ angle += RING_HALF_TURN
21
+ end
22
+ bounds << to
23
+ pieces = []
24
+ (0...bounds.length - 1).each do |index|
25
+ a, b = bounds[index], bounds[index + 1]
26
+ next unless b - a > 1e-9
27
+ count = [2, ((b - a).fdiv(RING_SAMPLE_STEP_DEGREES)).ceil + 1].max
28
+ points = Array.new(count) do |step|
29
+ radians = ((a + ((b - a) * step).fdiv(count - 1)) * Math::PI) / RING_HALF_TURN
30
+ [unit.call(Math.sin(radians)), unit.call(Math.cos(radians))]
31
+ end
32
+ points.reverse! if points.first[0] > points.last[0]
33
+ pieces << points
34
+ end
35
+ pieces
36
+ end
37
+
38
+ def ring_progress(value, options)
39
+ canvas = require_canvas(options['canvas'], 'ring_progress')
40
+ id, = Components.identity(options, 'ring', 'ring_progress')
41
+ minimum = options['min'].nil? ? 0 : options['min']
42
+ maximum = options['max'].nil? ? 100 : options['max']
43
+ Components.range('ring_progress', minimum, maximum)
44
+ start = DataValues.fetch_or(options, 'startAngle', 0)
45
+ raise ArgumentError, "ring_progress: startAngle must be finite, got #{start}" unless Numbers.finite?(start)
46
+ colors = MODERN_COLORS.merge(DataValues.fetch_or(options, 'colors', {}))
47
+ z = DataValues.fetch_or(options, 'zIndex', 0)
48
+ fraction = Components.fraction(value, minimum, maximum)
49
+ area = Components.area(options['frame'], canvas).to_pt
50
+ side = [area['width'], area['height']].min
51
+ ring_pt = { 'x' => area['x'] + (area['width'] - side) / 2.0, 'y' => area['y'] + (area['height'] - side) / 2.0,
52
+ 'width' => side, 'height' => side }
53
+ ring_box = Box.from_pt(ring_pt, canvas)
54
+ thickness = DataValues.fetch_or(options, 'thickness', [CURVE_STROKE_MAX_PT, [3, Numbers.round(side * 0.1)].max].min)
55
+ Modern.require_stroke('ring_progress', 'thickness', thickness, CURVE_STROKE_MAX_PT)
56
+ raise ArgumentError, "ring_progress: a #{thickness} pt ring does not fit #{side} pt" unless thickness < side / 2.0
57
+ pieces = lambda do |from, to, role, color, opacity, zi|
58
+ arc_pieces(from, to).each_with_index.map do |points, index|
59
+ curve('id' => "#{id}:#{role}-#{index}", 'frame' => ring_box, 'zIndex' => zi,
60
+ 'data' => { 'mode' => 'samples', 'points' => points, 'domain' => [-1, 1], 'yDomain' => [-1, 1], 'interpolation' => 'linear' },
61
+ 'style' => { 'color' => color, 'opacity' => opacity, 'strokeWidth' => thickness, 'lineCap' => 'round',
62
+ 'plotPadding' => thickness / 2.0 })
63
+ end
64
+ end
65
+ layers = pieces.call(-RING_X_EXTREME, 360 - RING_X_EXTREME, 'track', colors['track'], TRACK_OPACITY, z)
66
+ if fraction > 0
67
+ sweep = fraction * 360
68
+ from, to = options['counterClockwise'] ? [start - sweep, start] : [start, start + sweep]
69
+ layers.concat(pieces.call(from, to, 'arc', colors['fill'], 1, z + 1))
70
+ end
71
+ if options['label'] != false
72
+ label = options['label']
73
+ content = label.is_a?(String) ? label : percent_label(fraction)
74
+ hole = [1, side / 2.0 - thickness].max
75
+ hole_width, hole_height = 1.6 * hole, 1.2 * hole
76
+ requested = if DataValues.set?(options, 'fontSizeRatio')
77
+ Components.font_pt(options['fontSizeRatio'], canvas)
78
+ else
79
+ [hole_height / (TEXT_LINE_HEIGHT * LABEL_BOX_LINES), Components.font_for_width(content, hole_width)].min
80
+ end
81
+ pt = Components.font_pt(Components.font_ratio(requested, canvas), canvas)
82
+ bw, bh = [hole_width, Components.text_width(content, pt)].max, [hole_height, Components.text_height(pt)].max
83
+ cx, cy = ring_pt['x'] + side / 2.0, ring_pt['y'] + side / 2.0
84
+ layers << Components.label(id + ':label', Box.from_pt({ 'x' => cx - bw / 2.0, 'y' => cy - bh / 2.0, 'width' => bw, 'height' => bh }, canvas),
85
+ content, 'center', 'center', Components.font_ratio(pt, canvas), colors['label'], 1, z + 2)
86
+ end
87
+ Components.combine(layers)
88
+ end
89
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ DEFAULT_THEME_NAME = 'lcd'
5
+ STATUS_COLORS = { 'good' => '#34C759', 'warn' => '#FF9F0A', 'bad' => '#FF453A', 'neutral' => '#8E8E93' }.freeze
6
+
7
+ # Shared component measurements are in points until a layer frame is emitted.
8
+ TEXT_LINE_HEIGHT = 1.2
9
+ LABEL_BOX_LINES = 1.5
10
+ LABEL_MAX_SHARE_DIVISOR = 3
11
+
12
+ def percent_label(fraction)
13
+ "#{Numbers.round(fraction * 100)}%"
14
+ end
15
+
16
+ module Components
17
+ extend self
18
+ def combine(parts)
19
+ merged = GlanceWidgets.merge(*parts)
20
+ { 'layers' => merged.fetch('layers', []), 'data' => merged.fetch('data', {}) }
21
+ end
22
+
23
+ ADVANCES = DataValues.load('text-advances.json')
24
+
25
+ def theme(input = nil)
26
+ input.nil? || input.is_a?(String) ? GlanceWidgets.get_theme(input || DEFAULT_THEME_NAME) : input
27
+ end
28
+
29
+ def area(frame, canvas)
30
+ return Box.canvas(canvas) if frame.nil?
31
+ return Box.new(frame.left, frame.top, frame.width, frame.height, canvas) if frame.is_a?(Box)
32
+ Box.from_frame(frame, canvas)
33
+ end
34
+
35
+ def emitted(box, canvas)
36
+ frame = box.to_frame
37
+ { 'frame' => frame, 'rect' => GlanceWidgets.resolve_frame(frame, canvas) }
38
+ end
39
+
40
+ def region(options, what)
41
+ canvas, frame = options['canvas'], options['frame']
42
+ unless canvas
43
+ return frame.to_pt if frame.is_a?(Box) && frame.instance_variable_get(:@canvas)
44
+ raise ArgumentError, "#{what}: frame needs canvas" if frame
45
+ return nil
46
+ end
47
+ emitted(area(frame, GlanceWidgets.require_canvas(canvas, what)), canvas)['rect']
48
+ end
49
+
50
+ def grid(region, size, theme, what)
51
+ unless region
52
+ raise ArgumentError, "#{what}: grid needs both counts without canvas" unless size['columns'] && size['rows']
53
+ return size.merge('layout' => DataValues.copy(theme['layout']))
54
+ end
55
+ GlanceWidgets.solve_grid(size.reject { |_, value| value.nil? }.merge(
56
+ 'region' => region, 'gapMode' => theme['layout']['gapMode'], 'clampAspect' => true
57
+ ))
58
+ end
59
+
60
+ def looks(theme, colors = {})
61
+ off = !colors['off'].nil? && colors['off'] != '' ? theme['default'].merge('color' => colors['off']) : theme['default']
62
+ { 'on' => DataValues.fetch_or(colors, 'on', theme['palette']['on']),
63
+ 'off' => colors.key?('off') && colors['off'].nil? ? EMPTY : off }
64
+ end
65
+
66
+ def advance_em(content)
67
+ content.each_char.sum do |char|
68
+ code = char.ord
69
+ (code.between?(32, 126) ? ADVANCES['ascii'][code - 32] : ADVANCES['extra'].fetch(char, 1000)) + 40
70
+ end / 1000.0
71
+ end
72
+
73
+ def advance_pt(content, pt)
74
+ advance_em(content) * pt
75
+ end
76
+
77
+ def text_width(content, pt)
78
+ advance_em(content) * 1.05 * pt + 1
79
+ end
80
+
81
+ def font_for_width(content, width)
82
+ [0, width - 1].max / [1e-9, advance_em(content) * 1.05].max
83
+ end
84
+
85
+ def text_height(pt, lines = 1)
86
+ lines * TEXT_LINE_HEIGHT * pt + 1
87
+ end
88
+
89
+ def font_pt(ratio, canvas)
90
+ ratio * [canvas['width'], canvas['height']].min
91
+ end
92
+
93
+ def font_ratio(pt, canvas)
94
+ Numbers.clamp(pt.fdiv([canvas['width'], canvas['height']].min), 0.02, 0.28)
95
+ end
96
+
97
+ def identity(options, fallback, what)
98
+ id = DataValues.fetch_or(options, 'id', fallback)
99
+ unless DataValues.set?(options, 'path') || /\A[A-Za-z_][A-Za-z0-9_]*\z/.match(id)
100
+ raise ArgumentError, "#{what}: non-identifier id needs an explicit path"
101
+ end
102
+ [id, DataValues.fetch_or(options, 'path', 'data.' + id)]
103
+ end
104
+
105
+ def decimals(*values)
106
+ values.all? { |v| Numbers.integer?(v) } ? 0 : 1
107
+ end
108
+
109
+ def fraction(value, minimum, maximum)
110
+ Numbers.require_finite(value, 'Value')
111
+ maximum > minimum ? Numbers.clamp((value - minimum).fdiv(maximum - minimum), 0, 1).to_f : 0.0
112
+ end
113
+
114
+ def range(what, minimum, maximum)
115
+ unless Numbers.finite?(minimum) && Numbers.finite?(maximum) && maximum > minimum
116
+ raise ArgumentError, "#{what}: max must exceed min"
117
+ end
118
+ end
119
+
120
+ def positive(what, name, value)
121
+ raise ArgumentError, "#{what}: #{name} must be positive integer" unless Numbers.integer?(value) && value >= 1
122
+ end
123
+
124
+ def label(id, box, content, horizontal, vertical, ratio, color, opacity, z, weight = nil)
125
+ style = { 'color' => color, 'textOpacity' => opacity, 'fontSizeRatio' => ratio,
126
+ 'horizontalAlignment' => horizontal, 'verticalAlignment' => vertical }
127
+ style['fontWeight'] = weight if weight
128
+ GlanceWidgets.text('id' => id, 'frame' => box, 'text' => content, 'zIndex' => z, 'style' => style)
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ SIGNAL_DEFAULTS = { 'id' => 'signal', 'min' => 0, 'max' => 100, 'bars' => 4, 'maxHeight' => 14,
5
+ 'barWidthRatio' => 0.26, 'gapRatio' => 0.5, 'shortestRatio' => 0.35, 'zIndex' => 0 }.freeze
6
+
7
+ # Modern reception indicator: ascending round-capped bars, lit up to the level.
8
+ def signal(level, options)
9
+ canvas = require_canvas(options['canvas'], 'signal')
10
+ id, = Components.identity(options, 'signal', 'signal')
11
+ minimum = options['min'].nil? ? 0 : options['min']
12
+ maximum = options['max'].nil? ? 100 : options['max']
13
+ Components.range('signal', minimum, maximum)
14
+ bars = DataValues.fetch_or(options, 'bars', 4)
15
+ Components.positive('signal', 'bars', bars)
16
+ area = Components.area(options['frame'], canvas).to_pt
17
+ height = DataValues.fetch_or(options, 'height', [SIGNAL_DEFAULTS['maxHeight'], area['height']].min)
18
+ width = DataValues.fetch_or(options, 'barWidth', height * SIGNAL_DEFAULTS['barWidthRatio'])
19
+ Modern.require_stroke('signal', 'barWidth', width, LINE_STROKE_MAX_PT)
20
+ gap = width * SIGNAL_DEFAULTS['gapRatio']
21
+ minimum_length = Modern.min_capsule_length(canvas, 'vertical', width)
22
+ unless height >= minimum_length && height <= area['height']
23
+ raise ArgumentError, "signal: height #{height} pt does not fit the area"
24
+ end
25
+ raise ArgumentError, 'signal: the area is too narrow' if bars * width + (bars - 1) * gap > area['width']
26
+ colors = DataValues.fetch_or(options, 'colors', {})
27
+ fill = DataValues.fetch_or(colors, 'fill', MODERN_COLORS['label'])
28
+ track = DataValues.fetch_or(colors, 'track', fill)
29
+ lit = Numbers.round(Components.fraction(level, minimum, maximum) * bars)
30
+ bottom = area['y'] + (area['height'] + height) / 2.0
31
+ layers = Array.new(bars.to_i) do |index|
32
+ share = bars == 1 ? 1 : SIGNAL_DEFAULTS['shortestRatio'] + ((1 - SIGNAL_DEFAULTS['shortestRatio']) * index).fdiv(bars - 1)
33
+ Modern.capsule('id' => "#{id}:bar-#{index}", 'canvas' => canvas, 'direction' => 'vertical',
34
+ 'at' => area['x'] + width / 2.0 + index * (width + gap),
35
+ 'from' => bottom - [minimum_length, height * share].max, 'to' => bottom,
36
+ 'width' => width, 'color' => index < lit ? fill : track, 'opacity' => index < lit ? 1 : TRACK_OPACITY,
37
+ 'zIndex' => DataValues.fetch_or(options, 'zIndex', 0))
38
+ end
39
+ Components.combine(layers)
40
+ end
41
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ STAT_DEFAULTS = {
5
+ 'id' => 'stat', 'deltaDecimals' => 1, 'arrow' => 'icon', 'valueStyle' => 'text', 'align' => 'left',
6
+ 'fontSizeRatio' => 0.08, 'pixelFont' => '5x7', 'pixelUnitFont' => '3x5', 'zIndex' => 0,
7
+ 'labelOpacity' => 0.72, 'valueColor' => '#FFFFFF', 'labelColor' => '#FFFFFF',
8
+ 'upIcon' => 'ph_arrowUp', 'downIcon' => 'ph_arrowDown', 'flatIcon' => 'ph_minus'
9
+ }.freeze
10
+
11
+ # A card keeps its measurements together rather than passing implicit state
12
+ # through a generic evaluator. Each row produces ordinary protocol layers.
13
+ class StatCard
14
+ def initialize(value, options)
15
+ @options = options
16
+ @canvas = GlanceWidgets.require_canvas(options['canvas'], 'stat')
17
+ @id, @path = Components.identity(options, 'stat', 'stat')
18
+ unless value.is_a?(String) || Numbers.finite?(value)
19
+ raise ArgumentError, 'Stat value must be finite number or string'
20
+ end
21
+ @value = if value.is_a?(String)
22
+ value
23
+ else
24
+ Numbers.format_number(value, DataValues.fetch_or(options, 'decimals', Components.decimals(value)))
25
+ end
26
+ @theme = Components.theme(options['theme'])
27
+ @pixel = DataValues.fetch_or(options, 'valueStyle', 'text') == 'pixel'
28
+ @center = DataValues.fetch_or(options, 'align', 'left') == 'center'
29
+ @z = DataValues.fetch_or(options, 'zIndex', 0)
30
+ @colors = DataValues.fetch_or(options, 'colors', {})
31
+ @value_color = DataValues.fetch_or(@colors, 'value', @pixel ? @theme['palette']['on'] : '#FFFFFF')
32
+ delta = options['delta']
33
+ @direction = delta.nil? ? nil : (delta > 0 ? 'up' : (delta < 0 ? 'down' : 'flat'))
34
+ @delta = options['deltaText']
35
+ if @delta.nil? && !delta.nil?
36
+ @delta = (delta > 0 ? '+' : '') + Numbers.format_number(delta, DataValues.fetch_or(options, 'deltaDecimals', 1)) +
37
+ DataValues.fetch_or(options, 'deltaSuffix', '')
38
+ end
39
+ present = (DataValues.set?(options, 'label') ? ['label'] : []) + ['value'] + (@delta.nil? ? [] : ['delta'])
40
+ weights = { 'label' => 2, 'value' => 5, 'delta' => 2 }
41
+ boxes = Components.area(options['frame'], @canvas).rows(present.map { |row| weights[row] })
42
+ @rows = present.zip(boxes.map(&:to_pt)).to_h
43
+ @layers = []
44
+ end
45
+
46
+ def build
47
+ add_label if DataValues.set?(@options, 'label')
48
+ @pixel ? add_pixel_value : add_text_value
49
+ add_delta unless @delta.nil?
50
+ Components.combine(@layers)
51
+ end
52
+
53
+ private
54
+
55
+ def used_pt(value)
56
+ Components.font_pt(Components.font_ratio(value, @canvas), @canvas)
57
+ end
58
+
59
+ def fit_font(height, width, content)
60
+ used_pt([height / (TEXT_LINE_HEIGHT * LABEL_BOX_LINES), Components.font_for_width(content, width),
61
+ Components.font_pt(DataValues.fetch_or(@options, 'fontSizeRatio', 0.08), @canvas)].min)
62
+ end
63
+
64
+ def text_layer(role, rect, content, horizontal, pt, color, opacity, weight)
65
+ @layers << Components.label(@id + ':' + role, Box.from_pt(rect, @canvas), content, horizontal, 'center',
66
+ Components.font_ratio(pt, @canvas), color, opacity, @z, weight)
67
+ end
68
+
69
+ def add_label
70
+ rect = @rows['label']
71
+ pt = fit_font(rect['height'], rect['width'], @options['label'])
72
+ text_layer('label', rect, @options['label'], @center ? 'center' : 'left', pt,
73
+ DataValues.fetch_or(@colors, 'label', '#FFFFFF'), 0.72, '400')
74
+ end
75
+
76
+ def add_pixel_value
77
+ font = DataValues.fetch_or(@options, 'pixelFont', '5x7')
78
+ main = GlanceWidgets.measure_text(@value, font)
79
+ small = if DataValues.set?(@options, 'unit')
80
+ GlanceWidgets.measure_text(@options['unit'], DataValues.fetch_or(@options, 'pixelUnitFont', '3x5'))
81
+ end
82
+ content = Components.content_matrix(main['width'] + (small ? 1 + small['width'] : 0),
83
+ [main['height'], small ? small['height'] : 0].max, @theme)
84
+ content.text(@value, 0, 0, 'font' => font, 'style' => @value_color)
85
+ if small
86
+ content.text(@options['unit'], main['width'] + 1, [0, main['height'] - small['height']].max,
87
+ 'font' => DataValues.fetch_or(@options, 'pixelUnitFont', '3x5'),
88
+ 'style' => DataValues.fetch_or(@colors, 'value', @theme['palette']['dim']))
89
+ end
90
+ region = Components.emitted(Box.from_pt(@rows['value'], @canvas), @canvas)
91
+ options = @options.select { |key, _| key == 'maxCellSize' }.merge(
92
+ 'theme' => @theme, 'align' => @center ? 'center' : 'left', 'valign' => 'center'
93
+ )
94
+ matrix = Components.fit_pixel(content, region['rect'], options)
95
+ append_matrix(matrix, @id + ':value', region['frame'], @path)
96
+ end
97
+
98
+ def add_text_value
99
+ rect = @rows['value']
100
+ unit = @options['unit']
101
+ value_em = Components.advance_em(@value)
102
+ width_em = unit.nil? ? value_em * 1.05 :
103
+ [value_em * 1.05, value_em + 0.15 + Components.advance_em(unit) * 1.05 * 0.45].max
104
+ fit_width = [0, rect['width'] - 1].max / [1e-9, width_em].max
105
+ pt = used_pt([rect['height'] / 1.32, fit_width].min)
106
+ value_width = Components.advance_pt(@value, pt)
107
+ unit_font, unit_gap = used_pt(pt * 0.45), pt * 0.15
108
+ unit_width = unit.nil? ? 0 : Components.advance_pt(unit, unit_font)
109
+ total = value_width + (unit.nil? ? 0 : unit_gap + unit_width)
110
+ start = @center ? rect['x'] + (rect['width'] - total) / 2 : rect['x']
111
+ box_height = pt * 1.32
112
+ top = rect['y'] + (rect['height'] - box_height) / 2
113
+ value_rect = { 'x' => start, 'y' => top, 'width' => Components.text_width(@value, pt), 'height' => box_height }
114
+ text_layer('value', value_rect, @value, 'left', pt, @value_color, 1, '700')
115
+ return if unit.nil?
116
+ height = unit_font * 1.32
117
+ unit_rect = { 'x' => start + value_width + unit_gap, 'y' => top + box_height - height,
118
+ 'width' => Components.text_width(unit, unit_font), 'height' => height }
119
+ text_layer('unit', unit_rect, unit, 'left', unit_font, @value_color, 0.72, '400')
120
+ end
121
+
122
+ def add_delta
123
+ rect = @rows['delta']
124
+ good = if @direction && @direction != 'flat'
125
+ (@direction == 'up') != DataValues.fetch_or(@options, 'invertDelta', false)
126
+ end
127
+ status = good.nil? ? 'neutral' : (good ? 'good' : 'bad')
128
+ color = DataValues.fetch_or(@colors, status, STATUS_COLORS[status])
129
+ arrow = DataValues.fetch_or(@options, 'arrow', 'icon')
130
+ show_arrow = arrow != 'none' && !@direction.nil?
131
+ pt = fit_font(rect['height'], rect['width'] - (show_arrow ? 1.2 * rect['height'] : 0), @delta)
132
+ arrow_pt, gap = show_arrow ? [pt * 1.2, pt * 0.4] : [0, 0]
133
+ total = arrow_pt + gap + Components.advance_pt(@delta, pt)
134
+ start = @center ? rect['x'] + (rect['width'] - total) / 2 : rect['x']
135
+ if show_arrow
136
+ square = { 'x' => start, 'y' => rect['y'] + (rect['height'] - arrow_pt) / 2,
137
+ 'width' => arrow_pt, 'height' => arrow_pt }
138
+ add_arrow(arrow, square, color)
139
+ end
140
+ text_rect = { 'x' => start + arrow_pt + gap, 'y' => rect['y'],
141
+ 'width' => Components.text_width(@delta, pt), 'height' => rect['height'] }
142
+ text_layer('delta', text_rect, @delta, 'left', pt, color, 1, '600')
143
+ end
144
+
145
+ def add_arrow(kind, square, color)
146
+ if kind == 'icon'
147
+ name = { 'up' => 'ph_arrowUp', 'down' => 'ph_arrowDown', 'flat' => 'ph_minus' }.fetch(@direction)
148
+ @layers << GlanceWidgets.icon('id' => @id + ':arrow', 'name' => name, 'frame' => Box.from_pt(square, @canvas),
149
+ 'zIndex' => @z, 'style' => { 'color' => color })
150
+ else
151
+ rows = @direction == 'flat' ? %w[..... ..... ##### ..... .....] : %w[..#.. .###. ##### ..#.. ..#..]
152
+ rows = rows.reverse if @direction == 'down'
153
+ matrix = Components.content_matrix(5, 5, @theme)
154
+ rows.each_with_index do |row, y|
155
+ row.each_char.with_index { |char, x| matrix.set(x, y, color) if char == '#' }
156
+ end
157
+ region = Components.emitted(Box.from_pt(square, @canvas), @canvas)
158
+ append_matrix(matrix, @id + ':arrow', region['frame'], @path + 'Arrow')
159
+ end
160
+ end
161
+
162
+ def append_matrix(matrix, id, frame, path)
163
+ placed = GlanceWidgets.place(matrix.to_part, 'id' => id, 'frame' => frame, 'path' => path, 'zIndex' => @z)
164
+ @layers << placed
165
+ end
166
+ end
167
+
168
+ def stat(value, options)
169
+ StatCard.new(value, options).build
170
+ end
171
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GlanceWidgets
4
+ STATUS_STRIP_DEFAULTS = {
5
+ 'cellWidth' => 1, 'cellGap' => 1, 'rows' => 5, 'unknownState' => 'unknown',
6
+ 'colors' => { 'ok' => STATUS_COLORS['good'], 'warn' => STATUS_COLORS['warn'], 'down' => STATUS_COLORS['bad'] },
7
+ 'names' => { 'ok' => 'OK', 'warn' => 'Warn', 'down' => 'Down', 'unknown' => 'No data' }
8
+ }.freeze
9
+ LABELED_STATUS_STRIP_DEFAULTS = {
10
+ 'id' => 'statusStrip', 'fontSizeRatio' => 0.05, 'labelOpacity' => 0.72, 'zIndex' => 0
11
+ }.freeze
12
+
13
+ def state_style(state, theme, colors)
14
+ state ||= 'unknown'
15
+ return colors[state] if colors.key?(state)
16
+ return theme['default'] if state == 'unknown'
17
+ raise ArgumentError, "No color for state #{state}"
18
+ end
19
+ private :state_style
20
+
21
+ def strip_matrix(states, options)
22
+ raise ArgumentError, 'Status strip needs at least one state' if states.empty?
23
+ width, gap = DataValues.fetch_or(options, 'cellWidth', 1), DataValues.fetch_or(options, 'cellGap', 1)
24
+ Components.positive('status_strip', 'cellWidth', width)
25
+ raise ArgumentError, 'cellGap must be non-negative integer' unless Numbers.integer?(gap) && gap >= 0
26
+ Components.positive('status_strip', 'rows', options['rows']) if DataValues.set?(options, 'rows')
27
+ theme = Components.theme(options['theme'])
28
+ colors = STATUS_STRIP_DEFAULTS['colors'].merge(DataValues.fetch_or(options, 'colors', {}))
29
+ region = Components.region(options, 'status_strip')
30
+ size = { 'columns' => states.length * width + (states.length - 1) * gap,
31
+ 'rows' => DataValues.fetch_or(options, 'rows', region ? nil : 5) }
32
+ plan = Components.grid(region, size, theme, 'status_strip')
33
+ matrix_options = theme_options(theme, 'default' => EMPTY, 'layout' => plan['layout'])
34
+ matrix = Matrix.new(plan['columns'], plan['rows'], matrix_options)
35
+ states.each_with_index do |state, i|
36
+ matrix.rect(i * (width + gap), 0, width, plan['rows'], state_style(state, theme, colors))
37
+ end
38
+ matrix
39
+ end
40
+ private :strip_matrix
41
+
42
+ def status_strip(states, options = {})
43
+ strip_matrix(states, options).to_part
44
+ end
45
+
46
+ def labeled_status_strip(states, options)
47
+ canvas = require_canvas(options['canvas'], 'labeled_status_strip')
48
+ id, path = Components.identity(options, 'statusStrip', 'labeled_status_strip')
49
+ theme = Components.theme(options['theme'])
50
+ colors = STATUS_STRIP_DEFAULTS['colors'].merge(DataValues.fetch_or(options, 'colors', {}).reject { |key, _| key == 'label' })
51
+ names = STATUS_STRIP_DEFAULTS['names'].merge(DataValues.fetch_or(options, 'names', {}))
52
+ z, ratio = DataValues.fetch_or(options, 'zIndex', 0), DataValues.fetch_or(options, 'fontSizeRatio', 0.05)
53
+ color = DataValues.fetch_or(DataValues.fetch_or(options, 'colors', {}), 'label', theme['palette']['on'])
54
+ legend = DataValues.fetch_or(options, 'legend', false)
55
+ listed = legend == false ? [] : (legend.is_a?(Array) ? legend : states.map { |s| s || 'unknown' }.uniq)
56
+ pt = Components.font_pt(ratio, canvas)
57
+ row_pt, gap_pt = Components.text_height(pt), pt * 0.4
58
+ has_labels = DataValues.set?(options, 'startLabel') || DataValues.set?(options, 'endLabel')
59
+ reserved = (has_labels ? gap_pt + row_pt : 0) + (listed.empty? ? 0 : gap_pt + row_pt)
60
+ area = Components.area(options['frame'], canvas)
61
+ rect = area.to_pt
62
+ raise ArgumentError, 'Area too small for labels' if reserved >= rect['height']
63
+ strip_box = area.inset_pt('bottom' => reserved)
64
+ part = strip_matrix(states, options.merge('frame' => strip_box, 'canvas' => canvas)).to_part
65
+ placed = place(part, 'id' => id + ':strip', 'frame' => Components.emitted(strip_box, canvas)['frame'],
66
+ 'path' => path, 'zIndex' => z)
67
+ layers = [placed]
68
+ row_box = lambda do |top, left, width|
69
+ Box.from_pt({ 'x' => left, 'y' => top, 'width' => width, 'height' => row_pt }, canvas)
70
+ end
71
+ label = lambda do |role, box, content, horizontal, label_color|
72
+ Components.label(id + ':' + role, box, content, horizontal, 'center', ratio, label_color, 0.72, z + 1)
73
+ end
74
+ top = rect['y'] + rect['height'] - reserved
75
+ if has_labels
76
+ top += gap_pt
77
+ half = rect['width'] / 2.0
78
+ if DataValues.set?(options, 'startLabel')
79
+ layers << label.call('start', row_box.call(top, rect['x'], half), options['startLabel'], 'left', color)
80
+ end
81
+ if DataValues.set?(options, 'endLabel')
82
+ layers << label.call('end', row_box.call(top, rect['x'] + half, half), options['endLabel'], 'right', color)
83
+ end
84
+ top += row_pt
85
+ end
86
+ unless listed.empty?
87
+ top += gap_pt
88
+ slot = rect['width'].fdiv(listed.length)
89
+ dot_width = Components.text_width('●', pt)
90
+ name_offset = dot_width + pt * 0.25
91
+ listed.each_with_index do |state, i|
92
+ left = rect['x'] + i * slot
93
+ style = state_style(state, theme, colors)
94
+ swatch = style.is_a?(String) ? style : theme['default']['color']
95
+ layers << label.call("legend-dot-#{i}", row_box.call(top, left, dot_width), '●', 'left', swatch)
96
+ box = row_box.call(top, left + name_offset, [0, slot - name_offset].max)
97
+ layers << label.call("legend-name-#{i}", box, names.fetch(state, state), 'left', color)
98
+ end
99
+ end
100
+ Components.combine(layers)
101
+ end
102
+ end