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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1537f14e4b5207a0780552a0977c7e937b902f6e305996c8fc3887332569f234
|
|
4
|
+
data.tar.gz: 52d07498b2a230f9bc45e3a9a19fbb7b83b77d39f4fa407ca9374516f8e28ac4
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: de779835c49be6a9d0c3419946ef7e61b05555d0ec22bb3b16a09259eb98c952cb56393ca2539b3da1defe0f78d6ddcac1d510ccbc6b61b3dbd274367e03b015
|
|
7
|
+
data.tar.gz: bcf67db6eee7043ba6464ab7d30400be9b1d6c66ee533d8fc161a62d3048a61a4309bc73a587e7ef7f237cf3fb3d0fa842c906dcc761162fa2f442c38b81a074
|
data/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Glance Widgets
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
data/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Glance Widgets Ruby SDK
|
|
2
|
+
|
|
3
|
+
SDK for generating Glance Widgets API responses
|
|
4
|
+
|
|
5
|
+
Install from RubyGems:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
gem install glance-widgets-sdk --version 1.0.0
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Layer constructors return a `Composite`: `{layers, data}`. Declarations contain only id, type and dataPath; the addressed objects contain layout, content and style. Merge layer results and chart components with the same merge function, then finalize with toResponse (to_response in snake_case APIs). An explicit dataPath chooses the object location; otherwise it is derived from id.
|
|
12
|
+
|
|
13
|
+
`Matrix.to_part()` returns `{content: MatrixData}`. Place it with `place` / `Place` to give its node a frame and data path. The node's `content` holds `pixels`, `default`, `batch` and `layout`; matrix `style` is empty. Image and image-backed icon nodes use `content.url`. Bind an editor layer to a partial node in `data` without adding a remote declaration. Omitted or null values keep the saved editor value; objects merge field by field, arrays replace, and a supplied frame replaces the whole frame and must include width and height. Each response starts from the saved configuration.
|
|
14
|
+
|
|
15
|
+
Ruby 2.6+ implementation of the full protocol v1 SDK, using standard libraries. Install from this directory with `gem build glance_widgets.gemspec` and `gem install ./glance-widgets-sdk-1.0.0.gem`, or add `lib` to Ruby's load path.
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require 'glance_widgets'
|
|
19
|
+
g = GlanceWidgets
|
|
20
|
+
canvas = { 'width' => 338, 'height' => 158 }
|
|
21
|
+
frame = g::Box.canvas(canvas).sub(0.1, 0.6, 0.8, 0.2).to_frame
|
|
22
|
+
bar = g.pixel_progress_bar(65, 'canvas' => canvas, 'frame' => frame)
|
|
23
|
+
response = g.to_response(g.place(bar, {
|
|
24
|
+
'id' => 'progress', 'frame' => frame, 'path' => 'data.progress'
|
|
25
|
+
}))
|
|
26
|
+
puts JSON.generate(response)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Use string keys for options and returned JSON objects. Protocol keys keep their camelCase spelling; Ruby methods use snake_case (`to_response`, `round_rect`, `flip_x`, `to_root`, `to_part`). Hash, Array and nil preserve JSON object, array and null values. Missing keys remain absent. Output hashes have deterministic insertion order.
|
|
30
|
+
|
|
31
|
+
| TypeScript API | Ruby API |
|
|
32
|
+
|---|---|
|
|
33
|
+
| `Matrix`, drawing, text, transforms | `Matrix.new`, `round_rect`, `flood_fill`, `flip_x`, `rotate90`, etc. |
|
|
34
|
+
| `Box.canvas`, `Box.fromFrame`, `Box.fromPt` | `Box.canvas`, `Box.from_frame`, `Box.from_pt` |
|
|
35
|
+
| `merge`, `place`, `toResponse` | `merge`, `place`, `to_response` |
|
|
36
|
+
| colors, fonts, themes, icons | `normalize_color`, `measure_text`, `get_theme`, `icon_glyph`, etc. |
|
|
37
|
+
| geometry, pixelate, layer builders | `fit_matrix`, `solve_grid`, `pixelate`, `text`, `curve`, etc. |
|
|
38
|
+
| validation and signatures | `validate`, `resolve_data_path`, `sign`, `verify_signature` |
|
|
39
|
+
| semantic components | `bar_chart`, `line_chart`, `area_chart`, `sparkline`, `stat`, `progress_bar`, `ring_progress`, `battery`, `signal`, `pixel_bar_chart`, `pixel_progress_bar`, `pixel_ring_progress`, `status_strip`, `heatmap`, `calendar_heatmap`, `pixel_text_panel`, `pixel_text_root`, and `labeled_*` variants (`labeled_pixel_progress_bar`, `labeled_pixel_ring_progress`, ...) |
|
|
40
|
+
|
|
41
|
+
Argument order and JSON options match TypeScript. Matrix drawing mutates the receiver; transforms return independent matrices. Builders return fragments, while `to_response` composes, completes and validates a response. `validate` returns nil or raises `GlanceValidationError` with a `path` reader. See the [guide](../../docs/guide.md) for the protocol and composition rules.
|
|
42
|
+
|
|
43
|
+
Fonts, 5,287 icons, text advances and validator tables are bundled; loading the gem never reads repository files. Font and icon sources are `spec/fonts/*.json` and `spec/icons.json`. SF Pro widths come from `spec/tools/measure-text-advances.py`. The data-only `sdks/python/scripts/generate_data.py` extracts validation schema tables from TypeScript. Refresh these shared tables there when needed, then run `ruby sdks/ruby/scripts/sync-data.rb` to copy data into the gem. All Ruby algorithms are maintained directly in the modules under `lib/glance_widgets`.
|
|
44
|
+
|
|
45
|
+
Run `bash integration/run-sdk-tests.sh ruby` from the repository. It checks every shared golden fixture plus native API tests for exact numeric labels, Unicode, validation, composition purity, clipping, and installation outside the repository. Golden numeric comparisons use relative tolerance `1e-9`; exact strings use exact comparison.
|
|
46
|
+
|
|
47
|
+
Use `layer_path(id)` for the derived node path, and `validate_layer_node(node, type)` to validate a partial editor node before returning it.
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GlanceWidgets
|
|
4
|
+
class Box
|
|
5
|
+
attr_reader :left, :top, :width, :height
|
|
6
|
+
|
|
7
|
+
def initialize(left, top, width, height, canvas = nil)
|
|
8
|
+
unless [left, top, width, height].all? { |v| Numbers.finite?(v) } && width >= 0 && height >= 0
|
|
9
|
+
raise ArgumentError, 'Invalid Box geometry'
|
|
10
|
+
end
|
|
11
|
+
@left, @top, @width, @height, @canvas = left, top, width, height, DataValues.copy(canvas)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.canvas(canvas = nil)
|
|
15
|
+
new(0, 0, 1, 1, canvas)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.from_frame(frame, canvas)
|
|
19
|
+
from_pt(GlanceWidgets.resolve_frame(frame, canvas), canvas)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.from_pt(rect, canvas)
|
|
23
|
+
ratio = GlanceWidgets.to_ratio_rect(rect, canvas)
|
|
24
|
+
new(ratio['x'], ratio['y'], ratio['width'], ratio['height'], canvas)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def right
|
|
28
|
+
left + width
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def bottom
|
|
32
|
+
top + height
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def sub(x, y, w, h)
|
|
36
|
+
Box.new(left + x * width, top + y * height, w * width, h * height, @canvas)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def inset(value)
|
|
40
|
+
sides = insets(value)
|
|
41
|
+
sub(sides['left'], sides['top'], 1 - sides['left'] - sides['right'], 1 - sides['top'] - sides['bottom'])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inset_pt(value)
|
|
45
|
+
rect, sides = to_pt, insets(value)
|
|
46
|
+
Box.from_pt({ 'x' => rect['x'] + sides['left'], 'y' => rect['y'] + sides['top'],
|
|
47
|
+
'width' => [0, rect['width'] - sides['left'] - sides['right']].max,
|
|
48
|
+
'height' => [0, rect['height'] - sides['top'] - sides['bottom']].max }, @canvas)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def rows(count_or_weights, options = {})
|
|
52
|
+
split('rows', count_or_weights, options)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def columns(count_or_weights, options = {})
|
|
56
|
+
split('columns', count_or_weights, options)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def split(direction, count_or_weights, options = {})
|
|
60
|
+
if count_or_weights.is_a?(Numeric)
|
|
61
|
+
unless Numbers.integer?(count_or_weights) && count_or_weights >= 0
|
|
62
|
+
raise ArgumentError, 'Invalid split count'
|
|
63
|
+
end
|
|
64
|
+
weights = Array.new(count_or_weights.to_i, 1)
|
|
65
|
+
else
|
|
66
|
+
weights = count_or_weights
|
|
67
|
+
end
|
|
68
|
+
unless weights.is_a?(Array) && !weights.empty? &&
|
|
69
|
+
weights.all? { |v| Numbers.finite?(v) && v >= 0 } && weights.inject(0) { |s, v| s + v } > 0
|
|
70
|
+
raise ArgumentError, 'Box.split needs non-negative weights with positive total'
|
|
71
|
+
end
|
|
72
|
+
vertical = direction == 'rows'
|
|
73
|
+
extent = vertical ? height : width
|
|
74
|
+
gap = DataValues.fetch_or(options, 'gap', 0) * extent
|
|
75
|
+
if DataValues.set?(options, 'gapPt')
|
|
76
|
+
canvas = GlanceWidgets.require_canvas(@canvas)
|
|
77
|
+
gap = options['gapPt'].fdiv(canvas[vertical ? 'height' : 'width'])
|
|
78
|
+
end
|
|
79
|
+
available = extent - gap * (weights.length - 1)
|
|
80
|
+
raise ArgumentError, 'Gaps are larger than the box' if available < 0
|
|
81
|
+
offset = vertical ? top : left
|
|
82
|
+
total = weights.inject(0) { |sum, weight| sum + weight }
|
|
83
|
+
weights.map do |weight|
|
|
84
|
+
size = (available * weight).fdiv(total)
|
|
85
|
+
box = vertical ? Box.new(left, offset, width, size, @canvas) : Box.new(offset, top, size, height, @canvas)
|
|
86
|
+
offset += size + gap
|
|
87
|
+
box
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def to_pt
|
|
92
|
+
canvas = GlanceWidgets.require_canvas(@canvas)
|
|
93
|
+
{ 'x' => left * canvas['width'], 'y' => top * canvas['height'],
|
|
94
|
+
'width' => width * canvas['width'], 'height' => height * canvas['height'] }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def to_frame
|
|
98
|
+
x, w = frame_axis(left, width)
|
|
99
|
+
y, h = frame_axis(top, height)
|
|
100
|
+
{ 'left' => x, 'top' => y, 'width' => w, 'height' => h }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def insets(value)
|
|
106
|
+
%w[left top right bottom].each_with_object({}) do |key, sides|
|
|
107
|
+
sides[key] = value.is_a?(Hash) ? value.fetch(key, 0) : value
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def frame_axis(start, size)
|
|
112
|
+
raise ArgumentError, 'Frame size exceeds 1.5' if size > 1.5
|
|
113
|
+
start, size = start + size / 2.0 - 0.01, 0.02 if size < 0.02
|
|
114
|
+
[start, size].map { |v| Numbers.round(v * 1e6) / 1e6 }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GlanceWidgets
|
|
4
|
+
COLOR_SPACE_CLASS = '[\u0009-\u000d\u0020\u00a0\u1680\u2000-\u200a' \
|
|
5
|
+
'\u2028\u2029\u202f\u205f\u3000\ufeff]'
|
|
6
|
+
COLOR_WHITESPACE = Regexp.new("\\A#{COLOR_SPACE_CLASS}+|#{COLOR_SPACE_CLASS}+\\z")
|
|
7
|
+
|
|
8
|
+
def parse_color(color)
|
|
9
|
+
code = color.is_a?(String) ? color.gsub(COLOR_WHITESPACE, '') : nil
|
|
10
|
+
unless code && /\A#?(?:[\da-f]{3}|[\da-f]{6}|[\da-f]{8})\z/i.match(code)
|
|
11
|
+
raise ArgumentError, 'Expected #RGB, #RRGGBB or #AARRGGBB'
|
|
12
|
+
end
|
|
13
|
+
code = code.sub(/\A#/, '')
|
|
14
|
+
code = code.chars.map { |char| char * 2 }.join if code.length == 3
|
|
15
|
+
code = 'FF' + code if code.length == 6
|
|
16
|
+
%w[a r g b].zip(code.scan(/../).map { |pair| pair.to_i(16) }).to_h
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def format_color(color)
|
|
20
|
+
channels = %w[a r g b].map do |key|
|
|
21
|
+
Numbers.require_finite(color.fetch(key), 'Color channel')
|
|
22
|
+
'%02X' % Numbers.clamp(Numbers.round(color[key]), 0, 255)
|
|
23
|
+
end
|
|
24
|
+
'#' + (channels[0] == 'FF' ? channels[1..-1] : channels).join
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def normalize_color(color)
|
|
28
|
+
format_color(parse_color(color))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def is_app_color(color)
|
|
32
|
+
color.is_a?(String) && !!/\A#?(?:[\da-f]{6}|[\da-f]{8})\z/i.match(color)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def rgb(red, green, blue, alpha = 1)
|
|
36
|
+
format_color('r' => red, 'g' => green, 'b' => blue, 'a' => alpha * 255)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def with_alpha(color, alpha)
|
|
40
|
+
format_color(parse_color(color).merge('a' => alpha * 255))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def mix_colors(start, finish, fraction)
|
|
44
|
+
a, b = parse_color(start), parse_color(finish)
|
|
45
|
+
fraction = Numbers.clamp(fraction, 0, 1)
|
|
46
|
+
format_color(a.each_with_object({}) { |(key, value), out| out[key] = value + (b[key] - value) * fraction })
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GlanceWidgets
|
|
4
|
+
BAR_CHART_DEFAULTS = {
|
|
5
|
+
'id' => 'barChart', 'fontSizeRatio' => 0.06, 'barShareOfSlot' => 0.5, 'baselineWidth' => 1,
|
|
6
|
+
'baselineOpacity' => 0.3, 'labelOpacity' => 0.72, 'zIndex' => 0,
|
|
7
|
+
'colors' => MODERN_COLORS.merge('positive' => MODERN_COLORS['fill'], 'negative' => '#FF453A', 'baseline' => '#FFFFFF')
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
# Modern bar chart: one round-capped line layer per bar, a faint zero baseline and optional category labels.
|
|
11
|
+
def bar_chart(values, options)
|
|
12
|
+
raise ArgumentError, 'bar_chart: needs at least one value' if values.empty?
|
|
13
|
+
raise ArgumentError, 'bar_chart: values must be finite' unless values.all? { |v| Numbers.finite?(v) }
|
|
14
|
+
canvas = require_canvas(options['canvas'], 'bar_chart')
|
|
15
|
+
id, = Components.identity(options, 'barChart', 'bar_chart')
|
|
16
|
+
colors = BAR_CHART_DEFAULTS['colors'].merge(DataValues.fetch_or(options, 'colors', {}))
|
|
17
|
+
z = DataValues.fetch_or(options, 'zIndex', 0)
|
|
18
|
+
categories = DataValues.fetch_or(options, 'categories', [])
|
|
19
|
+
if !categories.empty? && categories.length != values.length
|
|
20
|
+
raise ArgumentError, 'bar_chart: categories must have one entry per value'
|
|
21
|
+
end
|
|
22
|
+
low = DataValues.fetch_or(options, 'min', [0, *values].min)
|
|
23
|
+
high = 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, "bar_chart: max (#{high}) must be greater than min (#{low})" unless high > low
|
|
26
|
+
|
|
27
|
+
ratio = DataValues.fetch_or(options, 'fontSizeRatio', 0.06)
|
|
28
|
+
pt = ratio * [canvas['width'], canvas['height']].min
|
|
29
|
+
area = Components.area(options['frame'], canvas).to_pt
|
|
30
|
+
label_row = categories.empty? ? 0 : pt * 0.4 + Components.text_height(pt)
|
|
31
|
+
plot_height = area['height'] - label_row
|
|
32
|
+
slot = area['width'].fdiv(values.length)
|
|
33
|
+
bar_width = DataValues.fetch_or(options, 'barWidth', [LINE_STROKE_MAX_PT, slot * 0.5].min)
|
|
34
|
+
Modern.require_stroke('bar_chart', 'barWidth', bar_width, LINE_STROKE_MAX_PT)
|
|
35
|
+
minimum = Modern.min_capsule_length(canvas, 'vertical', bar_width)
|
|
36
|
+
raise ArgumentError, 'bar_chart: the area is too short' unless plot_height > minimum
|
|
37
|
+
|
|
38
|
+
y_of = ->(v) { area['y'] + (1 - Components.fraction(v, low, high)) * plot_height }
|
|
39
|
+
zero = [high, [low, 0].max].min
|
|
40
|
+
base_y = y_of.call(zero)
|
|
41
|
+
layers = []
|
|
42
|
+
values.each_with_index do |value, index|
|
|
43
|
+
clamped = [high, [low, value].max].min
|
|
44
|
+
next if clamped == zero
|
|
45
|
+
value_y = y_of.call(clamped)
|
|
46
|
+
positive = clamped > zero
|
|
47
|
+
from, to = positive ? [[value_y, base_y - minimum].min, base_y] : [base_y, [value_y, base_y + minimum].max]
|
|
48
|
+
layers << Modern.capsule('id' => "#{id}:bar-#{index}", 'canvas' => canvas, 'direction' => 'vertical',
|
|
49
|
+
'at' => area['x'] + (index + 0.5) * slot, 'from' => from, 'to' => to, 'width' => bar_width,
|
|
50
|
+
'color' => positive ? colors['positive'] : colors['negative'], 'opacity' => 1, 'zIndex' => z)
|
|
51
|
+
end
|
|
52
|
+
if DataValues.fetch_or(options, 'showBaseline', true)
|
|
53
|
+
layers << horizontal_line('id' => id + ':baseline', 'at' => base_y / canvas['height'],
|
|
54
|
+
'from' => area['x'] / canvas['width'], 'to' => (area['x'] + area['width']) / canvas['width'],
|
|
55
|
+
'zIndex' => z,
|
|
56
|
+
'style' => { 'color' => colors['baseline'], 'strokeWidth' => 1, 'opacity' => 0.3 })
|
|
57
|
+
end
|
|
58
|
+
categories.each_with_index do |category, index|
|
|
59
|
+
box = Box.from_pt({ 'x' => area['x'] + index * slot, 'y' => area['y'] + plot_height + pt * 0.4,
|
|
60
|
+
'width' => slot, 'height' => Components.text_height(pt) }, canvas)
|
|
61
|
+
layers << Components.label("#{id}:category-#{index}", box, category, 'center', 'top', ratio, colors['label'], 0.72, z)
|
|
62
|
+
end
|
|
63
|
+
Components.combine(layers)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GlanceWidgets
|
|
4
|
+
BATTERY_DEFAULTS = { 'id' => 'battery', 'min' => 0, 'max' => 100, 'height' => 12, 'widthRatio' => 2.3,
|
|
5
|
+
'fontSizeRatio' => 0.07, 'zIndex' => 0 }.freeze
|
|
6
|
+
LABEL_GAP_EM = 0.4
|
|
7
|
+
BAD_LEVEL = 0.2
|
|
8
|
+
WARN_LEVEL = 0.4
|
|
9
|
+
BATTERY_HEIGHT_MIN_PT = 5
|
|
10
|
+
BATTERY_INSET_PT = 2
|
|
11
|
+
BATTERY_TAB_HEIGHT_RATIO = 0.5
|
|
12
|
+
BATTERY_TAB_WIDTH_RATIO = 0.25
|
|
13
|
+
BATTERY_TAB_GAP_PT = 1
|
|
14
|
+
|
|
15
|
+
# Modern battery: translucent capsule body, vertical capsule tab and a rounded level bar colored by charge.
|
|
16
|
+
def battery(level, options)
|
|
17
|
+
canvas = require_canvas(options['canvas'], 'battery')
|
|
18
|
+
id, = Components.identity(options, 'battery', 'battery')
|
|
19
|
+
minimum = options['min'].nil? ? 0 : options['min']
|
|
20
|
+
maximum = options['max'].nil? ? 100 : options['max']
|
|
21
|
+
Components.range('battery', minimum, maximum)
|
|
22
|
+
height = DataValues.fetch_or(options, 'height', BATTERY_DEFAULTS['height'])
|
|
23
|
+
unless height >= BATTERY_HEIGHT_MIN_PT && height <= CURVE_STROKE_MAX_PT
|
|
24
|
+
raise ArgumentError, "battery: height must be between #{BATTERY_HEIGHT_MIN_PT} and #{CURVE_STROKE_MAX_PT} pt, got #{height}"
|
|
25
|
+
end
|
|
26
|
+
colors = DataValues.fetch_or(options, 'colors', {})
|
|
27
|
+
z = DataValues.fetch_or(options, 'zIndex', 0)
|
|
28
|
+
fraction = Components.fraction(level, minimum, maximum)
|
|
29
|
+
track = DataValues.fetch_or(colors, 'track', MODERN_COLORS['track'])
|
|
30
|
+
status = fraction <= BAD_LEVEL ? 'bad' : (fraction <= WARN_LEVEL ? 'warn' : 'good')
|
|
31
|
+
level_color = DataValues.fetch_or(colors, 'fill', DataValues.fetch_or(colors, status, STATUS_COLORS[status]))
|
|
32
|
+
area = Components.area(options['frame'], canvas).to_pt
|
|
33
|
+
if area['height'] < height
|
|
34
|
+
raise ArgumentError, "battery: the area must be at least #{height} pt high, got #{area['height']}"
|
|
35
|
+
end
|
|
36
|
+
tab_width = height * BATTERY_TAB_WIDTH_RATIO
|
|
37
|
+
middle = area['y'] + area['height'] / 2.0
|
|
38
|
+
|
|
39
|
+
reserved = 0
|
|
40
|
+
label_layers = []
|
|
41
|
+
label = options['label']
|
|
42
|
+
unless label.nil? || label == false
|
|
43
|
+
content = label.is_a?(String) ? label : percent_label(fraction)
|
|
44
|
+
requested = Components.font_pt(DataValues.fetch_or(options, 'fontSizeRatio', 0.07), canvas)
|
|
45
|
+
pt = Components.font_pt(Components.font_ratio([requested, height / (TEXT_LINE_HEIGHT * LABEL_BOX_LINES)].min, canvas), canvas)
|
|
46
|
+
slot = Components.text_width(content, pt)
|
|
47
|
+
reserved = slot + pt * LABEL_GAP_EM
|
|
48
|
+
box = Box.from_pt({ 'x' => area['x'] + area['width'] - slot, 'y' => area['y'], 'width' => slot, 'height' => area['height'] }, canvas)
|
|
49
|
+
label_layers = [Components.label(id + ':label', box, content, 'right', 'center', Components.font_ratio(pt, canvas),
|
|
50
|
+
DataValues.fetch_or(colors, 'label', MODERN_COLORS['label']), 1, z + 1)]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
available = area['width'] - reserved - BATTERY_TAB_GAP_PT - tab_width
|
|
54
|
+
body_width = [available, DataValues.fetch_or(options, 'width', height * BATTERY_DEFAULTS['widthRatio'])].min
|
|
55
|
+
unless body_width >= height + 0.1
|
|
56
|
+
raise ArgumentError, "battery: the area is too narrow (body #{body_width} pt)"
|
|
57
|
+
end
|
|
58
|
+
left = area['x']
|
|
59
|
+
spec = { 'canvas' => canvas, 'at' => middle, 'color' => track, 'opacity' => TRACK_OPACITY, 'zIndex' => z }
|
|
60
|
+
layers = [
|
|
61
|
+
Modern.curve_capsule(spec.merge('id' => id + ':body', 'from' => left, 'to' => left + body_width, 'width' => height)),
|
|
62
|
+
Modern.capsule(spec.merge('id' => id + ':tab', 'direction' => 'vertical',
|
|
63
|
+
'at' => left + body_width + BATTERY_TAB_GAP_PT + tab_width / 2.0,
|
|
64
|
+
'from' => middle - (height * BATTERY_TAB_HEIGHT_RATIO) / 2.0,
|
|
65
|
+
'to' => middle + (height * BATTERY_TAB_HEIGHT_RATIO) / 2.0, 'width' => tab_width))
|
|
66
|
+
]
|
|
67
|
+
thickness = height - 2 * BATTERY_INSET_PT
|
|
68
|
+
length = Modern.fill_length(fraction, body_width - 2 * BATTERY_INSET_PT, thickness)
|
|
69
|
+
if length > 0
|
|
70
|
+
layers << Modern.curve_capsule('canvas' => canvas, 'id' => id + ':level', 'from' => left + BATTERY_INSET_PT,
|
|
71
|
+
'to' => left + BATTERY_INSET_PT + length, 'at' => middle, 'width' => thickness,
|
|
72
|
+
'color' => level_color, 'opacity' => 1, 'zIndex' => z + 1)
|
|
73
|
+
end
|
|
74
|
+
layers.concat(label_layers)
|
|
75
|
+
Components.combine(layers)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GlanceWidgets
|
|
4
|
+
HEATMAP_DEFAULTS = { 'missing' => 'empty', 'calendarLevels' => 5 }.freeze
|
|
5
|
+
CALENDAR_HEATMAP_DEFAULTS = {
|
|
6
|
+
'startWeekday' => 0, 'orientation' => 'weekColumns',
|
|
7
|
+
'weekdayLabels' => %w[Sun Mon Tue Wed Thu Fri Sat],
|
|
8
|
+
'monthLabels' => %w[Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec]
|
|
9
|
+
}.freeze
|
|
10
|
+
LABELED_CALENDAR_HEATMAP_DEFAULTS = {
|
|
11
|
+
'id' => 'calendar', 'fontSizeRatio' => 0.05, 'labelOpacity' => 0.72, 'zIndex' => 0
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
module Components
|
|
15
|
+
def heat_scale(values, theme, options, default_levels = nil)
|
|
16
|
+
stops = DataValues.fetch_or(options, 'scale', [theme['palette']['off'], theme['palette']['on']])
|
|
17
|
+
raise ArgumentError, 'Heat scale needs at least two colors' if stops.length < 2
|
|
18
|
+
levels = DataValues.fetch_or(options, 'levels', default_levels)
|
|
19
|
+
raise ArgumentError, 'Levels must be integer of at least 2' if levels && (!Numbers.integer?(levels) || levels < 2)
|
|
20
|
+
present = values.compact
|
|
21
|
+
present.each { |v| Numbers.require_finite(v, 'Heat value') }
|
|
22
|
+
minimum, maximum = DataValues.fetch_or(options, 'min', present.min || 0), DataValues.fetch_or(options, 'max', present.max || 0)
|
|
23
|
+
raise ArgumentError, 'Heat max must not be below min' if maximum < minimum
|
|
24
|
+
lambda do |value|
|
|
25
|
+
next nil if value.nil?
|
|
26
|
+
fraction = maximum > minimum ? Numbers.clamp((value - minimum).fdiv(maximum - minimum), 0, 1) : 0
|
|
27
|
+
fraction = [levels - 1, (fraction * levels).floor].min.fdiv(levels - 1) if levels
|
|
28
|
+
position = fraction * (stops.length - 1)
|
|
29
|
+
index = [stops.length - 2, position.floor].min
|
|
30
|
+
GlanceWidgets.mix_colors(stops[index], stops[index + 1], position - index)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def paint_heat(matrix, x, y, value, scale, missing)
|
|
35
|
+
color = scale.call(value)
|
|
36
|
+
matrix.set(x, y, color) if color
|
|
37
|
+
matrix.set(x, y, missing) if color.nil? && missing != EMPTY
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def calendar_layout(count, options)
|
|
41
|
+
raise ArgumentError, 'Calendar needs at least one day' if count.zero?
|
|
42
|
+
weekday = DataValues.fetch_or(options, 'startWeekday', 0)
|
|
43
|
+
unless Numbers.integer?(weekday) && weekday.between?(0, 6)
|
|
44
|
+
raise ArgumentError, 'startWeekday must be 0..6'
|
|
45
|
+
end
|
|
46
|
+
weekday = weekday.to_i
|
|
47
|
+
orientation = DataValues.fetch_or(options, 'orientation', 'weekColumns')
|
|
48
|
+
raise ArgumentError, 'Invalid calendar orientation' unless %w[weekColumns weekRows].include?(orientation)
|
|
49
|
+
start = options['start']
|
|
50
|
+
unless start.is_a?(String) && /\A[0-9]{4}-[0-9]{2}-[0-9]{2}\z/.match(start)
|
|
51
|
+
raise ArgumentError, 'Calendar start must be YYYY-MM-DD'
|
|
52
|
+
end
|
|
53
|
+
year, month, day = start.split('-').map(&:to_i)
|
|
54
|
+
raise ArgumentError, 'Calendar year must be at least 100' if year < 100
|
|
55
|
+
beginning = Date.new(year, month, day, Date::GREGORIAN)
|
|
56
|
+
days = Array.new(count) { |i| beginning + i }
|
|
57
|
+
leading = (beginning.wday - weekday + 7) % 7
|
|
58
|
+
slots = Array.new(count) { |i| { 'week' => (leading + i) / 7, 'day' => (leading + i) % 7 } }
|
|
59
|
+
{ 'days' => days, 'slots' => slots, 'weeks' => slots.last['week'] + 1,
|
|
60
|
+
'startWeekday' => weekday, 'vertical' => orientation == 'weekRows' }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def calendar_matrix(values, layout, options)
|
|
64
|
+
selected_theme = theme(options['theme'])
|
|
65
|
+
scale = heat_scale(values, selected_theme, options, 5)
|
|
66
|
+
vertical = layout['vertical']
|
|
67
|
+
size = { 'columns' => vertical ? 7 : layout['weeks'], 'rows' => vertical ? layout['weeks'] : 7 }
|
|
68
|
+
plan = grid(region(options, 'calendar_heatmap'), size, selected_theme, 'calendar_heatmap')
|
|
69
|
+
matrix = Matrix.new(plan['columns'], plan['rows'], GlanceWidgets.theme_options(
|
|
70
|
+
selected_theme, 'default' => EMPTY, 'layout' => plan['layout']
|
|
71
|
+
))
|
|
72
|
+
values.zip(layout['slots']).each do |value, slot|
|
|
73
|
+
x, y = vertical ? [slot['day'], slot['week']] : [slot['week'], slot['day']]
|
|
74
|
+
paint_heat(matrix, x, y, value, scale, DataValues.fetch_or(options, 'missing', EMPTY))
|
|
75
|
+
end
|
|
76
|
+
matrix
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def month_weeks(layout, minimum_gap)
|
|
80
|
+
labels = []
|
|
81
|
+
layout['days'].zip(layout['slots']).each do |day, slot|
|
|
82
|
+
if day.day == 1 && (labels.empty? || slot['week'] - labels.last['week'] >= minimum_gap)
|
|
83
|
+
labels << { 'week' => slot['week'], 'month' => day.month - 1 }
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
labels
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def heatmap(values, options = {})
|
|
91
|
+
rows, columns = values.length, values.empty? ? 0 : values.first.length
|
|
92
|
+
unless rows > 0 && columns > 0 && values.all? { |row| row.length == columns }
|
|
93
|
+
raise ArgumentError, 'Heatmap needs non-empty rectangular grid'
|
|
94
|
+
end
|
|
95
|
+
theme = Components.theme(options['theme'])
|
|
96
|
+
scale = Components.heat_scale(values.flatten, theme, options)
|
|
97
|
+
size = { 'columns' => columns, 'rows' => rows }
|
|
98
|
+
plan = Components.grid(Components.region(options, 'heatmap'), size, theme, 'heatmap')
|
|
99
|
+
matrix_options = theme_options(theme, 'default' => EMPTY, 'layout' => plan['layout'])
|
|
100
|
+
matrix = Matrix.new(plan['columns'], plan['rows'], matrix_options)
|
|
101
|
+
values.each_with_index do |row, y|
|
|
102
|
+
row.each_with_index do |value, x|
|
|
103
|
+
Components.paint_heat(matrix, x, y, value, scale, DataValues.fetch_or(options, 'missing', EMPTY))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
matrix.to_part
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def calendar_heatmap(days, options)
|
|
110
|
+
Components.calendar_matrix(days, Components.calendar_layout(days.length, options), options).to_part
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def labeled_calendar_heatmap(days, options)
|
|
114
|
+
canvas = require_canvas(options['canvas'], 'labeled_calendar_heatmap')
|
|
115
|
+
id, path = Components.identity(options, 'calendar', 'labeled_calendar_heatmap')
|
|
116
|
+
theme = Components.theme(options['theme'])
|
|
117
|
+
layout = Components.calendar_layout(days.length, options)
|
|
118
|
+
weekdays = DataValues.fetch_or(options, 'weekdayLabels', CALENDAR_HEATMAP_DEFAULTS['weekdayLabels'])
|
|
119
|
+
months = DataValues.fetch_or(options, 'monthLabels', CALENDAR_HEATMAP_DEFAULTS['monthLabels'])
|
|
120
|
+
unless weekdays.length == 7 && months.length == 12
|
|
121
|
+
raise ArgumentError, 'Calendar requires 7 weekday and 12 month names'
|
|
122
|
+
end
|
|
123
|
+
show_days, show_months = DataValues.fetch_or(options, 'showWeekdayLabels', true), DataValues.fetch_or(options, 'showMonthLabels', true)
|
|
124
|
+
z, ratio = DataValues.fetch_or(options, 'zIndex', 0), DataValues.fetch_or(options, 'fontSizeRatio', 0.05)
|
|
125
|
+
pt = Components.font_pt(ratio, canvas)
|
|
126
|
+
row_pt, gap_pt = pt * 1.2 * 1.3, pt * 0.4
|
|
127
|
+
widest = ->(names) { names.map { |name| Components.text_width(name, pt) }.max }
|
|
128
|
+
vertical = layout['vertical']
|
|
129
|
+
side_reserve = (show_days && !vertical ? widest.call(weekdays) : 0) +
|
|
130
|
+
(show_months && vertical ? widest.call(months) : 0)
|
|
131
|
+
top_reserve = (show_days && vertical) || (show_months && !vertical) ? row_pt : 0
|
|
132
|
+
area = Components.area(options['frame'], canvas)
|
|
133
|
+
rect = area.to_pt
|
|
134
|
+
grid_box = area.inset_pt('left' => side_reserve > 0 ? side_reserve + gap_pt : 0,
|
|
135
|
+
'top' => top_reserve > 0 ? top_reserve + gap_pt : 0)
|
|
136
|
+
grid = Components.emitted(grid_box, canvas)
|
|
137
|
+
matrix = Components.calendar_matrix(days, layout, options.merge('frame' => grid_box, 'canvas' => canvas))
|
|
138
|
+
placed = place(matrix.to_part, 'id' => id + ':grid', 'frame' => grid['frame'], 'path' => path, 'zIndex' => z)
|
|
139
|
+
geometry = fit_matrix(grid['rect'], { 'columns' => matrix.columns, 'rows' => matrix.rows }, matrix.layout)
|
|
140
|
+
layers = [placed]
|
|
141
|
+
label_color = DataValues.fetch_or(DataValues.fetch_or(options, 'colors', {}), 'label', theme['palette']['on'])
|
|
142
|
+
add = lambda do |role, label_rect, content, horizontal|
|
|
143
|
+
layers << Components.label(id + ':' + role, Box.from_pt(label_rect, canvas), content, horizontal, 'center',
|
|
144
|
+
ratio, label_color, 0.72, z + 1)
|
|
145
|
+
end
|
|
146
|
+
left = ->(x, width) { [rect['x'], [x, rect['x'] + rect['width'] - width].min].max }
|
|
147
|
+
top = ->(y, height) { [rect['y'], [y, rect['y'] + rect['height'] - height].min].max }
|
|
148
|
+
if show_days
|
|
149
|
+
[1, 3, 5].each do |day|
|
|
150
|
+
name = weekdays[(layout['startWeekday'] + day) % 7]
|
|
151
|
+
width = Components.text_width(name, pt)
|
|
152
|
+
cell = vertical ? cell_rect(geometry, day, 0) : cell_rect(geometry, 0, day)
|
|
153
|
+
label_rect = if vertical
|
|
154
|
+
{ 'x' => left.call(cell['x'] + cell['width'] / 2 - width / 2, width), 'y' => rect['y'],
|
|
155
|
+
'width' => width, 'height' => row_pt }
|
|
156
|
+
else
|
|
157
|
+
{ 'x' => rect['x'], 'y' => top.call(cell['y'] + cell['height'] / 2 - row_pt / 2, row_pt),
|
|
158
|
+
'width' => widest.call(weekdays), 'height' => row_pt }
|
|
159
|
+
end
|
|
160
|
+
add.call("weekday-#{day}", label_rect, name, vertical ? 'center' : 'right')
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
if show_months
|
|
164
|
+
pitch = vertical ? geometry['cellHeight'] + geometry['gapY'] : geometry['cellWidth'] + geometry['gapX']
|
|
165
|
+
minimum_gap = [1, Numbers.ceil((vertical ? row_pt : widest.call(months)) / pitch)].max
|
|
166
|
+
labels = Components.month_weeks(layout, minimum_gap)
|
|
167
|
+
labels.each do |label|
|
|
168
|
+
week, month = label['week'], label['month']
|
|
169
|
+
name = months[month]
|
|
170
|
+
if vertical
|
|
171
|
+
cell = cell_rect(geometry, 0, week)
|
|
172
|
+
label_rect = { 'x' => rect['x'], 'y' => top.call(cell['y'] + cell['height'] / 2 - row_pt / 2, row_pt),
|
|
173
|
+
'width' => widest.call(months), 'height' => row_pt }
|
|
174
|
+
else
|
|
175
|
+
cell = cell_rect(geometry, week, 0)
|
|
176
|
+
width = Components.text_width(name, pt)
|
|
177
|
+
label_rect = { 'x' => left.call(cell['x'], width), 'y' => rect['y'], 'width' => width, 'height' => row_pt }
|
|
178
|
+
end
|
|
179
|
+
add.call("month-#{week}", label_rect, name, vertical ? 'right' : 'left')
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
Components.combine(layers)
|
|
183
|
+
end
|
|
184
|
+
end
|