glance-widgets-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +17 -0
  3. data/README.md +47 -0
  4. data/lib/glance_widgets/box.rb +117 -0
  5. data/lib/glance_widgets/color.rb +48 -0
  6. data/lib/glance_widgets/components/bar_chart.rb +65 -0
  7. data/lib/glance_widgets/components/battery.rb +77 -0
  8. data/lib/glance_widgets/components/charts.rb +5 -0
  9. data/lib/glance_widgets/components/heatmap.rb +184 -0
  10. data/lib/glance_widgets/components/line_chart.rb +213 -0
  11. data/lib/glance_widgets/components/modern.rb +59 -0
  12. data/lib/glance_widgets/components/pixel_bar_chart.rb +106 -0
  13. data/lib/glance_widgets/components/pixel_progress.rb +151 -0
  14. data/lib/glance_widgets/components/pixel_text.rb +66 -0
  15. data/lib/glance_widgets/components/progress.rb +62 -0
  16. data/lib/glance_widgets/components/ring_progress.rb +89 -0
  17. data/lib/glance_widgets/components/shared.rb +131 -0
  18. data/lib/glance_widgets/components/signal.rb +41 -0
  19. data/lib/glance_widgets/components/stat.rb +171 -0
  20. data/lib/glance_widgets/components/status.rb +102 -0
  21. data/lib/glance_widgets/data/fonts/font-3x5.json +120 -0
  22. data/lib/glance_widgets/data/fonts/font-5x7.json +118 -0
  23. data/lib/glance_widgets/data/icons.json +5293 -0
  24. data/lib/glance_widgets/data/schemas.json +418 -0
  25. data/lib/glance_widgets/data/text-advances.json +120 -0
  26. data/lib/glance_widgets/font.rb +165 -0
  27. data/lib/glance_widgets/geometry.rb +108 -0
  28. data/lib/glance_widgets/layers.rb +152 -0
  29. data/lib/glance_widgets/matrix.rb +277 -0
  30. data/lib/glance_widgets/options.rb +31 -0
  31. data/lib/glance_widgets/output.rb +91 -0
  32. data/lib/glance_widgets/pixelate.rb +46 -0
  33. data/lib/glance_widgets/protocol.rb +217 -0
  34. data/lib/glance_widgets/raster.rb +130 -0
  35. data/lib/glance_widgets/signature.rb +30 -0
  36. data/lib/glance_widgets/support.rb +134 -0
  37. data/lib/glance_widgets/themes.rb +34 -0
  38. data/lib/glance_widgets.rb +38 -0
  39. metadata +79 -0
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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'pixel_bar_chart'
4
+ require_relative 'bar_chart'
5
+ require_relative 'line_chart'
@@ -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