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,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
|