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.
- checksums.yaml +7 -0
- data/LICENSE +17 -0
- data/README.md +47 -0
- data/lib/glance_widgets/box.rb +117 -0
- data/lib/glance_widgets/color.rb +48 -0
- data/lib/glance_widgets/components/bar_chart.rb +65 -0
- data/lib/glance_widgets/components/battery.rb +77 -0
- data/lib/glance_widgets/components/charts.rb +5 -0
- data/lib/glance_widgets/components/heatmap.rb +184 -0
- data/lib/glance_widgets/components/line_chart.rb +213 -0
- data/lib/glance_widgets/components/modern.rb +59 -0
- data/lib/glance_widgets/components/pixel_bar_chart.rb +106 -0
- data/lib/glance_widgets/components/pixel_progress.rb +151 -0
- data/lib/glance_widgets/components/pixel_text.rb +66 -0
- data/lib/glance_widgets/components/progress.rb +62 -0
- data/lib/glance_widgets/components/ring_progress.rb +89 -0
- data/lib/glance_widgets/components/shared.rb +131 -0
- data/lib/glance_widgets/components/signal.rb +41 -0
- data/lib/glance_widgets/components/stat.rb +171 -0
- data/lib/glance_widgets/components/status.rb +102 -0
- data/lib/glance_widgets/data/fonts/font-3x5.json +120 -0
- data/lib/glance_widgets/data/fonts/font-5x7.json +118 -0
- data/lib/glance_widgets/data/icons.json +5293 -0
- data/lib/glance_widgets/data/schemas.json +418 -0
- data/lib/glance_widgets/data/text-advances.json +120 -0
- data/lib/glance_widgets/font.rb +165 -0
- data/lib/glance_widgets/geometry.rb +108 -0
- data/lib/glance_widgets/layers.rb +152 -0
- data/lib/glance_widgets/matrix.rb +277 -0
- data/lib/glance_widgets/options.rb +31 -0
- data/lib/glance_widgets/output.rb +91 -0
- data/lib/glance_widgets/pixelate.rb +46 -0
- data/lib/glance_widgets/protocol.rb +217 -0
- data/lib/glance_widgets/raster.rb +130 -0
- data/lib/glance_widgets/signature.rb +30 -0
- data/lib/glance_widgets/support.rb +134 -0
- data/lib/glance_widgets/themes.rb +34 -0
- data/lib/glance_widgets.rb +38 -0
- 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
|