inkplot 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +2 -2
- data/LICENSE.txt +1 -1
- data/README.md +70 -22
- data/bench/inkplot.rb +3 -0
- data/examples/gallery/09-signed-stack.svg +1 -0
- data/examples/gallery/12-histogram.svg +1 -0
- data/examples/gallery/13-log-scale.svg +1 -0
- data/examples/gallery/14-time-axis.svg +1 -0
- data/examples/gallery/15-category-order.svg +1 -0
- data/examples/gallery/16-dark-theme.svg +1 -0
- data/examples/gallery/17-annotations.svg +1 -0
- data/examples/gallery/18-combo.svg +1 -0
- data/examples/gallery/19-legend-position.svg +1 -0
- data/examples/gallery/20-long-categories.svg +1 -0
- data/examples/gallery/README.md +7 -2
- data/examples/gallery.rb +52 -2
- data/lib/inkplot/core.rb +177 -7
- data/lib/inkplot/renderers/raster.rb +322 -0
- data/lib/inkplot/version.rb +1 -1
- data/lib/inkplot.rb +8 -0
- data/sig/inkplot.rbs +7 -1
- metadata +20 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 90a3ec94d835a8c52be1645190c450486b74e3d69e69f9c7a32759c52cd9fbad
|
|
4
|
+
data.tar.gz: 670616432fd769febb36b31b338d506e58abb41fbdc32820fd0b97b0df9c2ec3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 23eef7bbe84575c0b7d32626a36d880de2a7e5c408791df60b9b0675a8a561bf9c58aa2aea0d28dd0653c126ce9746672bfd83ea734480a84cfbf74af334a861
|
|
7
|
+
data.tar.gz: c80647ebc946d60e8ec623010735aea0f71e922c296d2777265251ad8766a2fef0d3848326ed735d48ca72c0d797cf8eda00468a2dc76cad36c22fa85e9c358c
|
data/CHANGELOG.md
CHANGED
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<h1 align="center">Inkplot</h1>
|
|
2
2
|
|
|
3
|
-
<p align="center">
|
|
3
|
+
<p align="center">Ruby charts, drawn as portable SVG or PNG.</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
6
|
<a href="https://github.com/rbgfx/inkplot/actions/workflows/main.yml"><img src="https://github.com/rbgfx/inkplot/actions/workflows/main.yml/badge.svg" alt="CI"></a>
|
|
@@ -8,65 +8,113 @@
|
|
|
8
8
|
<a href="LICENSE.txt"><img src="https://img.shields.io/badge/license-MIT-750014.svg" alt="MIT license"></a>
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Line, bar, scatter, area, step, and histogram charts with clean defaults. SVG runs on Ruby alone; optional pure Ruby image gems add PNG output and REPL display.
|
|
12
|
+
|
|
13
|
+
## At a glance
|
|
14
|
+
|
|
15
|
+
- **No runtime dependencies for SVG** and no native graphics libraries.
|
|
16
|
+
- **Useful defaults:** automatic linear, logarithmic, category, and time scales; readable ticks, legends, and light or dark themes.
|
|
17
|
+
- **Ruby data in:** arrays, hashes, CSV tables, and records with string or symbol keys.
|
|
18
|
+
- **Flexible output:** SVG strings, PNG bytes, `Tessel::Image`, files, and terminal sparklines.
|
|
19
|
+
- **Optional adapters:** Tessel and Glyphic for PNG; Inlay for IRB, Pry, and IRuby.
|
|
12
20
|
|
|
13
21
|
## Install
|
|
14
22
|
|
|
15
|
-
Until the first RubyGems release, add
|
|
23
|
+
Until the first RubyGems release, add Inkplot from GitHub:
|
|
16
24
|
|
|
17
25
|
```ruby
|
|
18
26
|
gem "inkplot", github: "rbgfx/inkplot"
|
|
19
27
|
```
|
|
20
28
|
|
|
29
|
+
SVG needs only Inkplot. For PNG output, add the optional render dependencies:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
gem "tessel", github: "rbgfx/tessel"
|
|
33
|
+
gem "glyphic", github: "rbgfx/glyphic"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Glyphic's built-in font covers ASCII. For other scripts, set a TrueType font:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
Inkplot.config.font = "/path/to/font.ttf"
|
|
40
|
+
```
|
|
41
|
+
|
|
21
42
|
## Quick start
|
|
22
43
|
|
|
23
44
|
```ruby
|
|
24
45
|
require "inkplot"
|
|
25
46
|
|
|
26
|
-
chart = Inkplot.line([3, 1, 4, 1, 5, 9], title: "A small series")
|
|
47
|
+
chart = Inkplot.line([3, 1, 4, 1, 5, 9, 2, 6], title: "A small series")
|
|
27
48
|
chart.save("series.svg")
|
|
49
|
+
chart.save("series.png", width: 800, height: 450, scale: 2)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use CSV or records directly. Keys can be symbols or strings; `color:` splits records into labeled series.
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
require "csv"
|
|
56
|
+
|
|
57
|
+
rows = CSV.parse(File.read("sales.csv"), headers: true)
|
|
58
|
+
chart = Inkplot.line(rows, x: "month", y: "sales", color: "region", title: "Monthly sales")
|
|
59
|
+
chart.to_svg # => standalone SVG string
|
|
28
60
|
```
|
|
29
61
|
|
|
30
|
-
|
|
62
|
+
The builder API combines marks, scales, and annotations:
|
|
31
63
|
|
|
32
64
|
```ruby
|
|
33
65
|
chart = Inkplot.plot(width: 720, height: 420, theme: :dark) do |plot|
|
|
34
|
-
plot.title "
|
|
35
|
-
plot.x_axis
|
|
36
|
-
plot.y_axis
|
|
37
|
-
plot.line
|
|
38
|
-
plot.line
|
|
39
|
-
plot.hline
|
|
40
|
-
plot.legend
|
|
66
|
+
plot.title "Request latency"
|
|
67
|
+
plot.x_axis label: "Time", type: :time
|
|
68
|
+
plot.y_axis label: "Milliseconds", scale: :log, min: 1
|
|
69
|
+
plot.line times, p50, label: "p50"
|
|
70
|
+
plot.line times, p99, label: "p99", dash: true
|
|
71
|
+
plot.hline 200, label: "SLO", color: :red
|
|
72
|
+
plot.legend position: :top_right
|
|
41
73
|
end
|
|
42
|
-
|
|
43
|
-
svg = chart.to_svg
|
|
44
74
|
```
|
|
45
75
|
|
|
46
|
-
|
|
76
|
+
`Inkplot.bar(data, horizontal: true)` draws horizontal bars. `Inkplot.histogram(values, bins: :auto)` chooses bins with Freedman–Diaconis, falling back to Sturges when the interquartile range is zero. Builder methods also provide grouped and signed stacked bars, areas, steps, sized scatter points, and text or line annotations.
|
|
47
77
|
|
|
48
|
-
|
|
78
|
+
## Gallery
|
|
49
79
|
|
|
50
|
-
|
|
80
|
+
Twenty runnable chart examples and SVG previews are in the [gallery](examples/gallery/README.md). Rebuild the SVGs with:
|
|
51
81
|
|
|
52
82
|
```sh
|
|
53
83
|
ruby examples/gallery.rb
|
|
54
84
|
```
|
|
55
85
|
|
|
86
|
+
## REPL and terminal
|
|
87
|
+
|
|
88
|
+
When [Inlay](https://github.com/rbgfx/inlay) is loaded, Inkplot's `to_inlay` adapter returns SVG plus a lazy PNG fallback. A terminal without an inline image protocol can use:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
puts Inkplot.sparkline([3, 1, 4, 1, 5, 9, 2, 6]) # => "▃▁▄▁▅█▂▅"
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Performance
|
|
95
|
+
|
|
96
|
+
Reference run on Ruby 4.0.6 with YJIT, at 640×360: a 1,000-point line rendered to SVG in 3 ms, the same chart to PNG in 235 ms, and a 10,000-point scatter plot to PNG in 668 ms. These single-run measurements met the design targets of 50 ms, 300 ms, and 1 second respectively; see `bench/inkplot.rb` to rerun them.
|
|
97
|
+
|
|
56
98
|
## API contracts
|
|
57
99
|
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
- Explicit axis limits must be ordered. Log
|
|
61
|
-
- Invalid scales, colors, dimensions, and
|
|
100
|
+
- Chart dimensions must be at least 120×100 pixels. Paired x/y vectors must have equal lengths.
|
|
101
|
+
- Non-finite y values are treated as gaps. `gaps: :connect` connects line segments across them.
|
|
102
|
+
- Explicit axis limits must be valid and ordered. Log axes omit non-positive values with a warning; mixed time-zone offsets raise `ArgumentError`.
|
|
103
|
+
- Invalid scales, colors, dimensions, bin definitions, and output extensions raise `ArgumentError`.
|
|
104
|
+
- `to_svg` needs no optional gems. PNG methods raise `LoadError` with the missing gem's install command.
|
|
105
|
+
- SVG text is escaped; color input accepts a hexadecimal value or a supported CSS color name.
|
|
106
|
+
- PNG rendering uses a built-in 2× coverage pass before downsampling. Pure Ruby rendering is intended for charts and reports, not huge photo workloads.
|
|
62
107
|
|
|
63
108
|
## Development
|
|
64
109
|
|
|
65
110
|
```sh
|
|
66
111
|
bundle install
|
|
67
112
|
bundle exec rake verify
|
|
113
|
+
bundle exec rbs validate
|
|
68
114
|
```
|
|
69
115
|
|
|
116
|
+
The test suite compares all gallery PNGs against Lookalike snapshots. Update them intentionally with `bundle exec ruby examples/gallery.rb --update-snapshots`.
|
|
117
|
+
|
|
70
118
|
## License
|
|
71
119
|
|
|
72
|
-
MIT
|
|
120
|
+
[MIT](LICENSE.txt)
|
data/bench/inkplot.rb
CHANGED
|
@@ -5,8 +5,11 @@ require "inkplot"
|
|
|
5
5
|
|
|
6
6
|
points = 1_000.times.map { |index| Math.sin(index / 20.0) }
|
|
7
7
|
line = Inkplot.line(points)
|
|
8
|
+
scatter = Inkplot.scatter(10_000.times.map { |index| [index % 1_000, Math.sin(index / 100.0)] })
|
|
8
9
|
|
|
9
10
|
puts "YJIT=#{RubyVM::YJIT.enabled?} Ruby=#{RUBY_VERSION}"
|
|
10
11
|
Benchmark.bm(22) do |bench|
|
|
11
12
|
bench.report("1k line SVG") { line.to_svg }
|
|
13
|
+
bench.report("1k line PNG") { line.to_png }
|
|
14
|
+
bench.report("10k scatter PNG") { scatter.to_png }
|
|
12
15
|
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" width="640" height="360" role="img" class="inkplot"><defs><clipPath id="plot-clip"><rect x="42" y="40" width="460" height="286"/></clipPath></defs><rect x="0" y="0" width="640" height="360" fill="#FFFFFF"/><path class="grid-line" d="M 42 326 L 502 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="330" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">-10</text><path class="grid-line" d="M 42 230.67 L 502 230.67" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="234.67" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">0</text><path class="grid-line" d="M 42 135.33 L 502 135.33" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="139.33" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">10</text><path class="grid-line" d="M 88 40 L 88 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="88" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Mon</text><path class="grid-line" d="M 180 40 L 180 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="180" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Tue</text><path class="grid-line" d="M 272 40 L 272 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="272" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Wed</text><path class="grid-line" d="M 364 40 L 364 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="364" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Thu</text><path class="grid-line" d="M 456 40 L 456 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="456" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Fri</text><path class="axis-line" d="M 42 40 L 42 326 L 502 326" fill="none" stroke="#626B78" stroke-width="1"/><text x="320" y="22" fill="#20242B" text-anchor="middle" font-size="15" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Daily net change</text><path class="mark-line" d="M 390 54 L 406 54" fill="none" stroke="#0072B2" stroke-width="2"/><text x="412" y="58" fill="#20242B" text-anchor="start" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">New</text><path class="mark-line" d="M 390 72 L 406 72" fill="none" stroke="#E69F00" stroke-width="2"/><text x="412" y="76" fill="#20242B" text-anchor="start" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Removed</text><g class="series-marks" clip-path="url(#plot-clip)"><g class="series series-0" data-series="0"><rect class="bar-mark" x="52.12" y="154.4" width="70.76" height="76.27" fill="#0072B2"/><rect class="bar-mark" x="144.12" y="230.67" width="70.76" height="38.13" fill="#0072B2"/><rect class="bar-mark" x="236.12" y="183" width="70.76" height="47.67" fill="#0072B2"/><rect class="bar-mark" x="328.12" y="230.67" width="70.76" height="19.07" fill="#0072B2"/><rect class="bar-mark" x="420.12" y="144.87" width="70.76" height="85.8" fill="#0072B2"/></g><g class="series series-1" data-series="1"><rect class="bar-mark" x="52.12" y="125.8" width="70.76" height="28.6" fill="#E69F00"/><rect class="bar-mark" x="144.12" y="268.8" width="70.76" height="57.2" fill="#E69F00"/><rect class="bar-mark" x="236.12" y="163.93" width="70.76" height="19.07" fill="#E69F00"/><rect class="bar-mark" x="328.12" y="249.73" width="70.76" height="47.67" fill="#E69F00"/><rect class="bar-mark" x="420.12" y="106.73" width="70.76" height="38.13" fill="#E69F00"/></g></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" width="640" height="360" role="img" class="inkplot"><defs><clipPath id="plot-clip"><rect x="60" y="40" width="562" height="266"/></clipPath></defs><rect x="0" y="0" width="640" height="360" fill="#FFFFFF"/><path class="grid-line" d="M 60 306 L 622 306" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="52" y="310" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">0</text><path class="grid-line" d="M 60 173 L 622 173" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="52" y="177" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">5</text><path class="grid-line" d="M 60 40 L 622 40" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="52" y="44" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">10</text><text x="200.5" y="324" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">10</text><text x="341" y="324" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">20</text><text x="481.5" y="324" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">30</text><path class="axis-line" d="M 60 40 L 60 306 L 622 306" fill="none" stroke="#626B78" stroke-width="1"/><text x="320" y="22" fill="#20242B" text-anchor="middle" font-size="15" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Response time</text><text x="341" y="355" fill="#20242B" text-anchor="middle" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Milliseconds</text><text x="14" y="180" fill="#20242B" text-anchor="middle" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'" transform="rotate(-90 14 180)">Count</text><g class="series-marks" clip-path="url(#plot-clip)"><g class="series series-0" data-series="0"><rect class="bar-mark" x="172.4" y="40" width="116.08" height="266" fill="#0072B2"/><rect class="bar-mark" x="289.48" y="226.2" width="116.08" height="79.8" fill="#0072B2"/><rect class="bar-mark" x="406.57" y="226.2" width="116.08" height="79.8" fill="#0072B2"/></g></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" width="640" height="360" role="img" class="inkplot"><defs><clipPath id="plot-clip"><rect x="42" y="40" width="580" height="286"/></clipPath></defs><rect x="0" y="0" width="640" height="360" fill="#FFFFFF"/><path class="grid-line" d="M 42 326 L 622 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="330" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">1</text><path class="grid-line" d="M 42 206.11 L 622 206.11" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="210.11" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">10</text><path class="grid-line" d="M 42 86.23 L 622 86.23" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="90.23" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">100</text><text x="42" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">0</text><text x="235.33" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">2</text><text x="428.67" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">4</text><path class="axis-line" d="M 42 40 L 42 326 L 622 326" fill="none" stroke="#626B78" stroke-width="1"/><text x="320" y="22" fill="#20242B" text-anchor="middle" font-size="15" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Growth on a log scale</text><g class="series-marks" clip-path="url(#plot-clip)"><g class="series series-0" data-series="0"><path class="mark-line" d="M 42 326 L 138.67 268.8 L 235.33 211.6 L 332 154.4 L 428.67 97.2 L 525.33 40" fill="none" stroke="#0072B2" stroke-width="2"/></g></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" width="640" height="360" role="img" class="inkplot"><defs><clipPath id="plot-clip"><rect x="42" y="40" width="580" height="266"/></clipPath></defs><rect x="0" y="0" width="640" height="360" fill="#FFFFFF"/><path class="grid-line" d="M 42 217.33 L 622 217.33" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="221.33" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">20</text><path class="grid-line" d="M 42 128.67 L 622 128.67" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="132.67" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">30</text><text x="42" y="324" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">2026-09-24</text><text x="187" y="324" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">2026-09-24</text><text x="332" y="324" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">2026-09-24</text><text x="477" y="324" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">2026-09-24</text><text x="622" y="324" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">2026-09-25</text><path class="axis-line" d="M 42 40 L 42 306 L 622 306" fill="none" stroke="#626B78" stroke-width="1"/><text x="320" y="22" fill="#20242B" text-anchor="middle" font-size="15" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Hourly requests</text><text x="332" y="355" fill="#20242B" text-anchor="middle" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">UTC</text><g class="series-marks" clip-path="url(#plot-clip)"><g class="series series-0" data-series="0"><path class="mark-line" d="M 42 288.27 L 138.67 243.93 L 235.33 261.67 L 332 181.87 L 428.67 119.8 L 525.33 155.27 L 622 57.73" fill="none" stroke="#0072B2" stroke-width="2"/></g></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" width="640" height="360" role="img" class="inkplot"><defs><clipPath id="plot-clip"><rect x="42" y="40" width="580" height="286"/></clipPath></defs><rect x="0" y="0" width="640" height="360" fill="#FFFFFF"/><path class="grid-line" d="M 42 326 L 622 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="330" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">98</text><path class="grid-line" d="M 42 254.5 L 622 254.5" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="258.5" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">98.5</text><path class="grid-line" d="M 42 183 L 622 183" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="187" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">99</text><path class="grid-line" d="M 42 111.5 L 622 111.5" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="115.5" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">99.5</text><path class="grid-line" d="M 42 40 L 622 40" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="44" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">100</text><path class="grid-line" d="M 138.67 40 L 138.67 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="138.67" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">dev</text><path class="grid-line" d="M 332 40 L 332 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="332" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">staging</text><path class="grid-line" d="M 525.33 40 L 525.33 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="525.33" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">production</text><path class="axis-line" d="M 42 40 L 42 326 L 622 326" fill="none" stroke="#626B78" stroke-width="1"/><text x="320" y="22" fill="#20242B" text-anchor="middle" font-size="15" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Deployment health</text><g class="series-marks" clip-path="url(#plot-clip)"><g class="series series-0" data-series="0"><path class="mark-line" d="M 525.33 326 L 138.67 40 L 332 183" fill="none" stroke="#0072B2" stroke-width="2"/></g></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" width="640" height="360" role="img" class="inkplot"><defs><clipPath id="plot-clip"><rect x="42" y="40" width="580" height="286"/></clipPath></defs><rect x="0" y="0" width="640" height="360" fill="#171B22"/><path class="grid-line" d="M 42 230.67 L 622 230.67" fill="none" stroke="#303744" stroke-width="1"/><text x="34" y="234.67" fill="#A9B2C0" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">20</text><path class="grid-line" d="M 42 135.33 L 622 135.33" fill="none" stroke="#303744" stroke-width="1"/><text x="34" y="139.33" fill="#A9B2C0" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">30</text><text x="187" y="344" fill="#A9B2C0" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">2</text><text x="332" y="344" fill="#A9B2C0" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">4</text><text x="477" y="344" fill="#A9B2C0" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">6</text><path class="axis-line" d="M 42 40 L 42 326 L 622 326" fill="none" stroke="#A9B2C0" stroke-width="1"/><text x="320" y="22" fill="#E8ECF2" text-anchor="middle" font-size="15" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Dark dashboard</text><g class="series-marks" clip-path="url(#plot-clip)"><g class="series series-0" data-series="0"><path class="mark-line" d="M 114.5 306.93 L 187 249.73 L 259.5 268.8 L 332 192.53 L 404.5 211.6 L 477 135.33 L 549.5 87.67" fill="none" stroke="#0072B2" stroke-width="2"/></g><path class="rule-line" d="M 42 230.67 L 622 230.67" fill="none" stroke="orange" stroke-width="1.5" stroke-dasharray="5 4"/></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" width="640" height="360" role="img" class="inkplot"><defs><clipPath id="plot-clip"><rect x="42" y="40" width="580" height="286"/></clipPath></defs><rect x="0" y="0" width="640" height="360" fill="#FFFFFF"/><path class="grid-line" d="M 42 268.8 L 622 268.8" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="272.8" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">25</text><path class="grid-line" d="M 42 211.6 L 622 211.6" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="215.6" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">30</text><path class="grid-line" d="M 42 154.4 L 622 154.4" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="158.4" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">35</text><path class="grid-line" d="M 42 97.2 L 622 97.2" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="101.2" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">40</text><text x="187" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">2</text><text x="332" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">4</text><text x="477" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">6</text><path class="axis-line" d="M 42 40 L 42 326 L 622 326" fill="none" stroke="#626B78" stroke-width="1"/><text x="320" y="22" fill="#20242B" text-anchor="middle" font-size="15" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Service level objective</text><g class="series-marks" clip-path="url(#plot-clip)"><g class="series series-0" data-series="0"><path class="mark-line" d="M 114.5 188.72 L 187 234.48 L 259.5 85.76 L 332 142.96 L 404.5 211.6 L 477 280.24 L 549.5 245.92" fill="none" stroke="#0072B2" stroke-width="2"/></g><path class="rule-line" d="M 42 154.4 L 622 154.4" fill="none" stroke="red" stroke-width="1.5" stroke-dasharray="5 4"/><path class="rule-line" d="M 332 40 L 332 326" fill="none" stroke="blue" stroke-width="1.5" stroke-dasharray="5 4"/><text x="339.25" y="74.32" fill="#626B78" text-anchor="start" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">deploy</text></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" width="640" height="360" role="img" class="inkplot"><defs><clipPath id="plot-clip"><rect x="42" y="40" width="460" height="286"/></clipPath></defs><rect x="0" y="0" width="640" height="360" fill="#FFFFFF"/><path class="grid-line" d="M 42 326 L 502 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="330" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">0</text><path class="grid-line" d="M 42 230.67 L 502 230.67" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="234.67" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">20</text><path class="grid-line" d="M 42 135.33 L 502 135.33" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="139.33" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">40</text><path class="grid-line" d="M 99.5 40 L 99.5 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="99.5" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Jan</text><path class="grid-line" d="M 214.5 40 L 214.5 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="214.5" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Feb</text><path class="grid-line" d="M 329.5 40 L 329.5 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="329.5" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Mar</text><path class="grid-line" d="M 444.5 40 L 444.5 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="444.5" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Apr</text><path class="axis-line" d="M 42 40 L 42 326 L 502 326" fill="none" stroke="#626B78" stroke-width="1"/><text x="320" y="22" fill="#20242B" text-anchor="middle" font-size="15" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Sales and conversion</text><path class="mark-line" d="M 390 54 L 406 54" fill="none" stroke="#0072B2" stroke-width="2"/><text x="412" y="58" fill="#20242B" text-anchor="start" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Orders</text><path class="mark-line" d="M 390 72 L 406 72" fill="none" stroke="red" stroke-width="2"/><text x="412" y="76" fill="#20242B" text-anchor="start" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Conversion %</text><g class="series-marks" clip-path="url(#plot-clip)"><g class="series series-0" data-series="0"><rect class="bar-mark" x="52.35" y="211.6" width="93.3" height="114.4" fill="#0072B2"/><rect class="bar-mark" x="167.35" y="183" width="93.3" height="143" fill="#0072B2"/><rect class="bar-mark" x="282.35" y="197.3" width="93.3" height="128.7" fill="#0072B2"/><rect class="bar-mark" x="397.35" y="125.8" width="93.3" height="200.2" fill="#0072B2"/></g><g class="series series-1" data-series="1"><path class="mark-line" d="M 99.5 312.65 L 214.5 311.22 L 329.5 311.7 L 444.5 307.89" fill="none" stroke="red" stroke-width="2"/></g></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 380" width="640" height="380" role="img" class="inkplot"><defs><clipPath id="plot-clip"><rect x="42" y="40" width="580" height="306"/></clipPath></defs><rect x="0" y="0" width="640" height="380" fill="#FFFFFF"/><path class="grid-line" d="M 42 346 L 622 346" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="350" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">2</text><path class="grid-line" d="M 42 244 L 622 244" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="248" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">4</text><path class="grid-line" d="M 42 142 L 622 142" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="146" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">6</text><text x="42" y="364" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">1</text><text x="187" y="364" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">2</text><text x="332" y="364" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">3</text><text x="477" y="364" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">4</text><text x="622" y="364" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">5</text><path class="axis-line" d="M 42 40 L 42 346 L 622 346" fill="none" stroke="#626B78" stroke-width="1"/><text x="320" y="22" fill="#20242B" text-anchor="middle" font-size="15" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Legend below</text><path class="mark-line" d="M 50 306 L 66 306" fill="none" stroke="#0072B2" stroke-width="2"/><text x="72" y="310" fill="#20242B" text-anchor="start" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Actual</text><path class="mark-line" d="M 50 324 L 66 324" fill="none" stroke="#E69F00" stroke-width="2"/><text x="72" y="328" fill="#20242B" text-anchor="start" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Plan</text><g class="series-marks" clip-path="url(#plot-clip)"><g class="series series-0" data-series="0"><path class="mark-line" d="M 42 295 L 187 193 L 332 244 L 477 91 L 622 142" fill="none" stroke="#0072B2" stroke-width="2"/></g><g class="series series-1" data-series="1"><path class="mark-line" d="M 42 346 L 187 295 L 332 244 L 477 193 L 622 142" fill="none" stroke="#E69F00" stroke-width="2"/></g></g></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" width="640" height="360" role="img" class="inkplot"><defs><clipPath id="plot-clip"><rect x="42" y="40" width="580" height="286"/></clipPath></defs><rect x="0" y="0" width="640" height="360" fill="#FFFFFF"/><path class="grid-line" d="M 42 326 L 622 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="330" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">0</text><path class="grid-line" d="M 42 254.5 L 622 254.5" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="258.5" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">10</text><path class="grid-line" d="M 42 183 L 622 183" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="187" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">20</text><path class="grid-line" d="M 42 111.5 L 622 111.5" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="34" y="115.5" fill="#626B78" text-anchor="end" font-size="11" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">30</text><path class="grid-line" d="M 114.5 40 L 114.5 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="114.5" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">North America</text><path class="grid-line" d="M 259.5 40 L 259.5 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="259.5" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">South America</text><path class="grid-line" d="M 404.5 40 L 404.5 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="404.5" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Europe</text><path class="grid-line" d="M 549.5 40 L 549.5 326" fill="none" stroke="#E7EAF0" stroke-width="1"/><text x="549.5" y="344" fill="#626B78" text-anchor="middle" font-size="10" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Asia Pacific</text><path class="axis-line" d="M 42 40 L 42 326 L 622 326" fill="none" stroke="#626B78" stroke-width="1"/><text x="320" y="22" fill="#20242B" text-anchor="middle" font-size="15" font-family="system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif, 'Noto Sans CJK JP'">Labels rotate when needed</text><g class="series-marks" clip-path="url(#plot-clip)"><g class="series series-0" data-series="0"><rect class="bar-mark" x="55.05" y="168.7" width="117.9" height="157.3" fill="#0072B2"/><rect class="bar-mark" x="200.05" y="225.9" width="117.9" height="100.1" fill="#0072B2"/><rect class="bar-mark" x="345.05" y="104.35" width="117.9" height="221.65" fill="#0072B2"/><rect class="bar-mark" x="490.05" y="132.95" width="117.9" height="193.05" fill="#0072B2"/></g></g></svg>
|
data/examples/gallery/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Inkplot gallery
|
|
2
2
|
|
|
3
|
-
Generated by [`../gallery.rb`](../gallery.rb). Run `ruby examples/gallery.rb` from the repository root to refresh the SVG
|
|
3
|
+
Generated by [`../gallery.rb`](../gallery.rb). Run `ruby examples/gallery.rb` from the repository root to refresh the SVG files.
|
|
4
4
|
|
|
5
5
|
| Example | Preview | Example | Preview |
|
|
6
6
|
|---|---|---|---|
|
|
@@ -8,4 +8,9 @@ Generated by [`../gallery.rb`](../gallery.rb). Run `ruby examples/gallery.rb` fr
|
|
|
8
8
|
| Series |  | Scatter |  |
|
|
9
9
|
| Bars |  | Horizontal bars |  |
|
|
10
10
|
| Grouped bars |  | Stacked bars |  |
|
|
11
|
-
|
|
|
11
|
+
| Signed stacks |  | Area |  |
|
|
12
|
+
| Step |  | Histogram |  |
|
|
13
|
+
| Log scale |  | Time scale |  |
|
|
14
|
+
| Category order |  | Dark theme |  |
|
|
15
|
+
| Annotations |  | Mixed marks |  |
|
|
16
|
+
| Legend position |  | Long labels |  |
|
data/examples/gallery.rb
CHANGED
|
@@ -31,19 +31,69 @@ module InkplotGallery
|
|
|
31
31
|
p.bar(%w[Mon Tue Wed Thu Fri], [12, 16, 11, 19, 22], label: "Direct", stacked: true)
|
|
32
32
|
p.bar(%w[Mon Tue Wed Thu Fri], [8, 9, 13, 10, 14], label: "Search", stacked: true)
|
|
33
33
|
end],
|
|
34
|
+
["09-signed-stack", Inkplot.plot(title: "Daily net change") do |p|
|
|
35
|
+
p.bar(%w[Mon Tue Wed Thu Fri], [8, -4, 5, -2, 9], label: "New", stacked: true)
|
|
36
|
+
p.bar(%w[Mon Tue Wed Thu Fri], [3, -6, 2, -5, 4], label: "Removed", stacked: true)
|
|
37
|
+
end],
|
|
34
38
|
["10-area", Inkplot.plot(title: "Build time over a release") { |p| p.area((1..8).to_a, [25, 29, 27, 38, 36, 44, 40, 51], label: "Seconds") }],
|
|
35
|
-
["11-step", Inkplot.plot(title: "Queue depth") { |p| p.step((0..7).to_a, [3, 3, 5, 5, 4, 8, 8, 2], label: "Jobs") }]
|
|
39
|
+
["11-step", Inkplot.plot(title: "Queue depth") { |p| p.step((0..7).to_a, [3, 3, 5, 5, 4, 8, 8, 2], label: "Jobs") }],
|
|
40
|
+
["12-histogram", Inkplot.histogram([8, 9, 10, 10, 11, 12, 12, 13, 14, 15, 17, 18, 22, 25, 31, 33], bins: :auto, title: "Response time", x_label: "Milliseconds")],
|
|
41
|
+
["13-log-scale", Inkplot.plot(title: "Growth on a log scale") do |p|
|
|
42
|
+
p.y_axis(scale: :log, min: 1)
|
|
43
|
+
p.line((0..5).to_a, [1, 3, 9, 27, 81, 243])
|
|
44
|
+
end],
|
|
45
|
+
["14-time-axis", Inkplot.plot(title: "Hourly requests") do |p|
|
|
46
|
+
p.x_axis(label: "UTC", type: :time)
|
|
47
|
+
p.line((0..6).map do |hour|
|
|
48
|
+
Time.utc(2026, 9, 24, hour * 4)
|
|
49
|
+
end, [12, 17, 15, 24, 31, 27, 38])
|
|
50
|
+
end],
|
|
51
|
+
["15-category-order", Inkplot.plot(title: "Deployment health") do |p|
|
|
52
|
+
p.x_axis(order: %w[dev staging production])
|
|
53
|
+
p.line(%w[production dev staging], [98, 100, 99], label: "Success %")
|
|
54
|
+
end],
|
|
55
|
+
["16-dark-theme", Inkplot.plot(title: "Dark dashboard", theme: :dark) do |p|
|
|
56
|
+
p.line((1..7).to_a, [12, 18, 16, 24, 22, 30, 35], label: "Throughput")
|
|
57
|
+
p.hline(20, label: "Target", color: :orange)
|
|
58
|
+
end],
|
|
59
|
+
["17-annotations", Inkplot.plot(title: "Service level objective") do |p|
|
|
60
|
+
p.line((1..7).to_a, [32, 28, 41, 36, 30, 24, 27])
|
|
61
|
+
p.hline(35, label: "SLO", color: :red)
|
|
62
|
+
p.vline(4, color: :blue)
|
|
63
|
+
p.text(4.1, 42, "deploy")
|
|
64
|
+
end],
|
|
65
|
+
["18-combo", Inkplot.plot(title: "Sales and conversion") do |p|
|
|
66
|
+
p.bar(%w[Jan Feb Mar Apr], [24, 30, 27, 42], label: "Orders")
|
|
67
|
+
p.line(%w[Jan Feb Mar Apr], [2.8, 3.1, 3.0, 3.8], label: "Conversion %", color: :red)
|
|
68
|
+
end],
|
|
69
|
+
["19-legend-position", Inkplot.plot(title: "Legend below", width: 640, height: 380) do |p|
|
|
70
|
+
p.line((1..5).to_a, [3, 5, 4, 7, 6], label: "Actual")
|
|
71
|
+
p.line((1..5).to_a, [2, 3, 4, 5, 6], label: "Plan")
|
|
72
|
+
p.legend(position: :bottom_left)
|
|
73
|
+
end],
|
|
74
|
+
["20-long-categories", Inkplot.plot(title: "Labels rotate when needed") do |p|
|
|
75
|
+
p.x_axis(order: ["North America", "South America", "Europe", "Asia Pacific"])
|
|
76
|
+
p.bar(["North America", "South America", "Europe", "Asia Pacific"], [22, 14, 31, 27])
|
|
77
|
+
end]
|
|
36
78
|
]
|
|
37
79
|
end
|
|
38
80
|
end
|
|
39
81
|
|
|
40
82
|
if $PROGRAM_NAME == __FILE__
|
|
41
|
-
|
|
83
|
+
update_snapshots = ARGV.delete("--update-snapshots")
|
|
84
|
+
raise ArgumentError, "usage: ruby examples/gallery.rb [--update-snapshots]" unless ARGV.empty?
|
|
42
85
|
|
|
43
86
|
svg_dir = File.expand_path("gallery", __dir__)
|
|
44
87
|
FileUtils.mkdir_p(svg_dir)
|
|
45
88
|
|
|
46
89
|
InkplotGallery.charts.each do |name, chart|
|
|
47
90
|
File.write(File.join(svg_dir, "#{name}.svg"), chart.to_svg)
|
|
91
|
+
next unless update_snapshots
|
|
92
|
+
|
|
93
|
+
require "tessel"
|
|
94
|
+
require "glyphic"
|
|
95
|
+
png_dir = File.expand_path("../spec/snapshots", __dir__)
|
|
96
|
+
FileUtils.mkdir_p(png_dir)
|
|
97
|
+
File.binwrite(File.join(png_dir, "#{name}.png"), Tessel::PNG.encode(chart.to_image))
|
|
48
98
|
end
|
|
49
99
|
end
|
data/lib/inkplot/core.rb
CHANGED
|
@@ -58,6 +58,13 @@ module Inkplot
|
|
|
58
58
|
rescue NoMethodError
|
|
59
59
|
nil
|
|
60
60
|
end
|
|
61
|
+
|
|
62
|
+
def time(value)
|
|
63
|
+
case value
|
|
64
|
+
when Time then value
|
|
65
|
+
when Date then value.to_time
|
|
66
|
+
end
|
|
67
|
+
end
|
|
61
68
|
end
|
|
62
69
|
|
|
63
70
|
module Ticks
|
|
@@ -104,6 +111,41 @@ module Inkplot
|
|
|
104
111
|
end
|
|
105
112
|
end
|
|
106
113
|
|
|
114
|
+
def time(minimum, maximum, offset: 0)
|
|
115
|
+
span = maximum - minimum
|
|
116
|
+
intervals = [1, 5, 15, 30, 60, 300, 900, 1800, 3600, 10_800, 21_600, 43_200, 86_400, 604_800, 2_592_000, 7_776_000, 15_552_000, 31_536_000]
|
|
117
|
+
step = intervals.find { |value| span / value <= 6 } || 31_536_000
|
|
118
|
+
ticks = if step < 2_592_000
|
|
119
|
+
first = (minimum / step).ceil * step
|
|
120
|
+
(0..10).map { |index| first + (index * step) }.take_while { |value| value <= maximum }
|
|
121
|
+
elsif step < 31_536_000
|
|
122
|
+
time = Time.at(minimum).getlocal(offset)
|
|
123
|
+
month = time.month
|
|
124
|
+
month += 1 while Time.new(time.year, month, 1, 0, 0, 0, offset).to_f < minimum
|
|
125
|
+
month_ticks = Array.new(8) do |index|
|
|
126
|
+
absolute = (time.year * 12) + month - 1 + (index * (step / 2_592_000).round)
|
|
127
|
+
Time.new(absolute / 12, (absolute % 12) + 1, 1, 0, 0, 0, offset).to_f
|
|
128
|
+
end
|
|
129
|
+
month_ticks.take_while { |value| value <= maximum }
|
|
130
|
+
else
|
|
131
|
+
time = Time.at(minimum).getlocal(offset)
|
|
132
|
+
year = time.year
|
|
133
|
+
year += 1 while Time.new(year, 1, 1, 0, 0, 0, offset).to_f < minimum
|
|
134
|
+
Array.new(8) { |index| Time.new(year + (index * (step / 31_536_000).round), 1, 1, 0, 0, 0, offset).to_f }.take_while { |value| value <= maximum }
|
|
135
|
+
end
|
|
136
|
+
ticks.empty? ? [minimum, maximum].uniq : ticks
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def time_label(value, span, offset: 0)
|
|
140
|
+
time = Time.at(value).getlocal(offset)
|
|
141
|
+
return time.strftime("%Y") if span >= 31_536_000
|
|
142
|
+
return time.strftime("%Y-%m") if span >= 2_592_000
|
|
143
|
+
return time.strftime("%Y-%m-%d") if span >= 86_400
|
|
144
|
+
return time.strftime("%H:%M") if span >= 60
|
|
145
|
+
|
|
146
|
+
time.strftime("%H:%M:%S")
|
|
147
|
+
end
|
|
148
|
+
|
|
107
149
|
def number_label(value)
|
|
108
150
|
value.to_i == value ? value.to_i.to_s : format("%.8f", value).sub(/0+\z/, "").sub(/\.\z/, "")
|
|
109
151
|
end
|
|
@@ -223,6 +265,51 @@ module Inkplot
|
|
|
223
265
|
def bandwidth = (@range[1] - @range[0]) / [@domain.length, 1].max
|
|
224
266
|
def format(value) = value.to_s
|
|
225
267
|
end
|
|
268
|
+
|
|
269
|
+
class TimeScale
|
|
270
|
+
attr_reader :domain, :ticks, :offset
|
|
271
|
+
attr_accessor :range
|
|
272
|
+
|
|
273
|
+
def initialize(values, options = {})
|
|
274
|
+
times = values.filter_map { |value| Data.time(value) }
|
|
275
|
+
offsets = times.map(&:utc_offset).uniq
|
|
276
|
+
raise ArgumentError, "time axis contains mixed UTC offsets" if offsets.length > 1
|
|
277
|
+
|
|
278
|
+
@offset = offsets.first || 0
|
|
279
|
+
numbers = times.map(&:to_f)
|
|
280
|
+
low, high = numbers.minmax
|
|
281
|
+
low ||= 0.0
|
|
282
|
+
high ||= low + 86_400
|
|
283
|
+
low = time_limit(options[:min], low, "minimum")
|
|
284
|
+
high = time_limit(options[:max], high, "maximum")
|
|
285
|
+
raise ArgumentError, "time axis minimum must be less than maximum" unless low < high
|
|
286
|
+
|
|
287
|
+
@ticks = Ticks.time(low, high, offset: @offset)
|
|
288
|
+
@domain = [low, high]
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def map(value)
|
|
292
|
+
time = value.is_a?(Numeric) ? value.to_f : Data.time(value)&.to_f
|
|
293
|
+
return nil unless time
|
|
294
|
+
raise ArgumentError, "time axis contains mixed UTC offsets" if !value.is_a?(Numeric) && Data.time(value).utc_offset != @offset
|
|
295
|
+
|
|
296
|
+
@range[0] + ((time - @domain[0]) * (@range[1] - @range[0]) / (@domain[1] - @domain[0]))
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def format(value) = Ticks.time_label(value, @domain[1] - @domain[0], offset: @offset)
|
|
300
|
+
|
|
301
|
+
private
|
|
302
|
+
|
|
303
|
+
def time_limit(value, fallback, name)
|
|
304
|
+
return fallback if value.nil?
|
|
305
|
+
|
|
306
|
+
time = Data.time(value)
|
|
307
|
+
raise ArgumentError, "time axis #{name} must be a Time or Date" unless time
|
|
308
|
+
raise ArgumentError, "time axis contains mixed UTC offsets" unless time.utc_offset == @offset
|
|
309
|
+
|
|
310
|
+
time.to_f
|
|
311
|
+
end
|
|
312
|
+
end
|
|
226
313
|
end
|
|
227
314
|
|
|
228
315
|
module TextMetrics
|
|
@@ -247,7 +334,7 @@ module Inkplot
|
|
|
247
334
|
|
|
248
335
|
Glyphic.default
|
|
249
336
|
rescue LoadError => e
|
|
250
|
-
raise LoadError, "
|
|
337
|
+
raise LoadError, "PNG output needs glyphic; install it with `gem install glyphic` (#{e.message})"
|
|
251
338
|
end
|
|
252
339
|
end
|
|
253
340
|
|
|
@@ -340,6 +427,21 @@ module Inkplot
|
|
|
340
427
|
add(:bar, points, label:, color:, horizontal:, stacked:, **)
|
|
341
428
|
end
|
|
342
429
|
|
|
430
|
+
def histogram(values, bins: :auto, label: nil, color: nil, **)
|
|
431
|
+
numbers = Array(values).filter_map { |value| Data.number(value) }
|
|
432
|
+
raise ArgumentError, "histogram needs at least one numeric value" if numbers.empty?
|
|
433
|
+
|
|
434
|
+
numbers.sort!
|
|
435
|
+
edges = histogram_edges(numbers, bins)
|
|
436
|
+
counts = Array.new(edges.length - 1, 0)
|
|
437
|
+
numbers.each do |number|
|
|
438
|
+
index = edges.each_cons(2).find_index { |left, right| number >= left && (number < right || right == edges.last) }
|
|
439
|
+
counts[index] += 1 if index
|
|
440
|
+
end
|
|
441
|
+
points = counts.each_index.map { |index| { x: edges[index], x2: edges[index + 1], y: counts[index] } }
|
|
442
|
+
add(:bar, points, label:, color:, histogram: true, **)
|
|
443
|
+
end
|
|
444
|
+
|
|
343
445
|
def hline(value, label: nil, color: nil, dash: true)
|
|
344
446
|
number = Data.number(value)
|
|
345
447
|
raise ArgumentError, "horizontal rule value must be finite and numeric" unless number
|
|
@@ -351,6 +453,10 @@ module Inkplot
|
|
|
351
453
|
@notes << { type: :vline, value:, label:, color: Theme.validate_color(color), dash: }
|
|
352
454
|
end
|
|
353
455
|
|
|
456
|
+
def text(x, y, value, color: nil)
|
|
457
|
+
@notes << { type: :text, x:, y: Data.number(y), value: value.to_s, color: Theme.validate_color(color) }
|
|
458
|
+
end
|
|
459
|
+
|
|
354
460
|
def add(type, points, label: nil, color: nil, **options)
|
|
355
461
|
raise ArgumentError, "unsupported chart mark: #{type}" unless %i[line scatter bar area step].include?(type.to_sym)
|
|
356
462
|
raise ArgumentError, "gaps must be :break or :connect" if options[:gaps] && !%i[break connect].include?(options[:gaps])
|
|
@@ -358,6 +464,28 @@ module Inkplot
|
|
|
358
464
|
@series << Series.new(type: type.to_sym, points:, label: label&.to_s, color: Theme.validate_color(color), options:)
|
|
359
465
|
self
|
|
360
466
|
end
|
|
467
|
+
|
|
468
|
+
private
|
|
469
|
+
|
|
470
|
+
def histogram_edges(numbers, bins)
|
|
471
|
+
low, high = numbers.minmax
|
|
472
|
+
return [low - 0.5, high + 0.5] if low == high
|
|
473
|
+
|
|
474
|
+
if bins == :auto
|
|
475
|
+
q1 = numbers[(numbers.length * 0.25).floor]
|
|
476
|
+
q3 = numbers[(numbers.length * 0.75).floor]
|
|
477
|
+
width = 2 * (q3 - q1) / (numbers.length**(1.0 / 3))
|
|
478
|
+
count = width.positive? ? ((high - low) / width).ceil : (1 + Math.log2(numbers.length)).ceil
|
|
479
|
+
count = count.clamp(1, 512)
|
|
480
|
+
Array.new(count + 1) { |index| low + ((high - low) * index / count.to_f) }
|
|
481
|
+
elsif bins.is_a?(Integer) && bins.positive?
|
|
482
|
+
Array.new(bins + 1) { |index| low + ((high - low) * index / bins.to_f) }
|
|
483
|
+
elsif bins.is_a?(Array) && bins.length >= 2 && bins.all? { |value| Data.number(value) } && bins.each_cons(2).all? { |left, right| left < right }
|
|
484
|
+
bins.map(&:to_f)
|
|
485
|
+
else
|
|
486
|
+
raise ArgumentError, "bins must be :auto, a positive integer, or increasing edges"
|
|
487
|
+
end
|
|
488
|
+
end
|
|
361
489
|
end
|
|
362
490
|
|
|
363
491
|
class Chart
|
|
@@ -374,14 +502,34 @@ module Inkplot
|
|
|
374
502
|
Renderers::SVG.render(SceneBuilder.call(self, width: width, height: height))
|
|
375
503
|
end
|
|
376
504
|
|
|
377
|
-
def
|
|
505
|
+
def to_image(scale: 1)
|
|
506
|
+
require_relative "renderers/raster"
|
|
507
|
+
Renderers::Raster.render(SceneBuilder.call(self, width: width, height: height), scale: scale)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def to_png(width: self.width, height: self.height, scale: 1)
|
|
511
|
+
begin
|
|
512
|
+
require "tessel"
|
|
513
|
+
rescue LoadError => e
|
|
514
|
+
raise LoadError, "PNG output needs tessel; install it with `gem install tessel` (#{e.message})"
|
|
515
|
+
end
|
|
516
|
+
require_relative "renderers/raster"
|
|
517
|
+
Tessel::PNG.encode(Renderers::Raster.render(SceneBuilder.call(self, width:, height:), scale: scale))
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def save(path, width: self.width, height: self.height, scale: 1)
|
|
378
521
|
case File.extname(String(path)).downcase
|
|
379
522
|
when ".svg" then File.write(path, to_svg(width:, height:))
|
|
380
|
-
|
|
523
|
+
when ".png" then File.binwrite(path, to_png(width:, height:, scale:))
|
|
524
|
+
else raise ArgumentError, "output path must end in .svg or .png"
|
|
381
525
|
end
|
|
382
526
|
path
|
|
383
527
|
end
|
|
384
528
|
|
|
529
|
+
def to_inlay
|
|
530
|
+
{ svg: to_svg, png: -> { to_png } }
|
|
531
|
+
end
|
|
532
|
+
|
|
385
533
|
private
|
|
386
534
|
|
|
387
535
|
def series = builder.series
|
|
@@ -421,6 +569,14 @@ module Inkplot
|
|
|
421
569
|
y_ticks = ticks(y_scale)
|
|
422
570
|
left = (y_ticks.map { |tick| TextMetrics.width(y_scale.format(tick), 11) }.max.to_f.ceil + 14).clamp(42, 120)
|
|
423
571
|
bottom = 34
|
|
572
|
+
rotate = x_scale.is_a?(Scales::Band) && x_ticks.any? && x_ticks.map { |tick| TextMetrics.width(x_scale.format(tick), 10) }.max > (width - left - 20) / x_ticks.length
|
|
573
|
+
if rotate
|
|
574
|
+
visible_count = [(width - left - 20) / 72, 1].max
|
|
575
|
+
stride = (x_ticks.length.to_f / visible_count).ceil
|
|
576
|
+
last_index = x_ticks.length - 1
|
|
577
|
+
x_ticks = x_ticks.each_with_index.filter_map { |tick, index| tick if (index % stride).zero? || index == last_index }
|
|
578
|
+
end
|
|
579
|
+
bottom += rotate ? 43 : 0
|
|
424
580
|
bottom += 20 if builder.x_label_text
|
|
425
581
|
left += 18 if builder.y_label_text
|
|
426
582
|
top = builder.title_text ? 40 : 18
|
|
@@ -447,7 +603,7 @@ module Inkplot
|
|
|
447
603
|
|
|
448
604
|
label = x_scale.format(tick)
|
|
449
605
|
elements << { type: :line, class: "grid-line", points: [[x, top], [x, height - bottom]], stroke: colors[:grid], width: 1 } if x_scale.is_a?(Scales::Band)
|
|
450
|
-
elements << { type: :text, x:, y: height - bottom + 18, text: label, fill: colors[:axis], anchor: :middle, size: 10 }
|
|
606
|
+
elements << { type: :text, x:, y: height - bottom + (rotate ? 14 : 18), text: label, fill: colors[:axis], anchor: :middle, size: 10, rotate: (rotate ? -45 : nil) }
|
|
451
607
|
end
|
|
452
608
|
elements << { type: :line, class: "axis-line", points: [[left, top], [left, height - bottom], [width - right, height - bottom]], stroke: colors[:axis], width: 1 }
|
|
453
609
|
elements << { type: :text, x: width / 2.0, y: 22, text: builder.title_text, fill: colors[:foreground], anchor: :middle, size: 15 } if builder.title_text
|
|
@@ -467,11 +623,11 @@ module Inkplot
|
|
|
467
623
|
if item.type == :bar
|
|
468
624
|
current_bar_index = bar_index
|
|
469
625
|
bar_index += 1
|
|
470
|
-
total = [bars.length, 1].max
|
|
471
626
|
points.each do |point|
|
|
472
627
|
next unless point[:y] && point[:x]
|
|
473
628
|
|
|
474
629
|
if item.options[:horizontal]
|
|
630
|
+
total = [bars.length, 1].max
|
|
475
631
|
band = y_scale.bandwidth * 0.72
|
|
476
632
|
value = point[:y]
|
|
477
633
|
base = if item.options[:stacked]
|
|
@@ -491,7 +647,15 @@ module Inkplot
|
|
|
491
647
|
offset = item.options[:stacked] ? 0 : (current_bar_index - ((total - 1) / 2.0)) * band
|
|
492
648
|
marks << { type: :rect, class: "bar-mark", series_index: index, x: [value, zero].min, y: category_y - (band / 2) + offset, width: (value - zero).abs, height: band,
|
|
493
649
|
fill: color }
|
|
650
|
+
elsif item.options[:histogram]
|
|
651
|
+
left_edge = x_scale.map(point[:x])
|
|
652
|
+
right_edge = x_scale.map(point[:x2])
|
|
653
|
+
zero = y_scale.map(0)
|
|
654
|
+
value = y_scale.map(point[:y])
|
|
655
|
+
marks << { type: :rect, class: "bar-mark", series_index: index, x: left_edge, y: [zero, value].min, width: [right_edge - left_edge - 1, 1].max,
|
|
656
|
+
height: (zero - value).abs, fill: color }
|
|
494
657
|
else
|
|
658
|
+
total = [bars.length, 1].max
|
|
495
659
|
band = x_scale.bandwidth * (item.options[:stacked] ? 0.78 : 0.82 / total)
|
|
496
660
|
x = x_scale.map(point[:x])
|
|
497
661
|
value = point[:y]
|
|
@@ -554,6 +718,10 @@ module Inkplot
|
|
|
554
718
|
when :vline
|
|
555
719
|
x = x_scale.map(note[:value])
|
|
556
720
|
marks << { type: :line, class: "rule-line", points: [[x, top], [x, height - bottom]], stroke: color, width: 1.5, dash: note[:dash] } if x
|
|
721
|
+
when :text
|
|
722
|
+
x = x_scale.map(note[:x])
|
|
723
|
+
y = y_scale.map(note[:y])
|
|
724
|
+
marks << { type: :text, x:, y:, text: note[:value], fill: color, anchor: :start, size: 11 } if x && y
|
|
557
725
|
end
|
|
558
726
|
end
|
|
559
727
|
|
|
@@ -571,8 +739,10 @@ module Inkplot
|
|
|
571
739
|
def build_scale(values, options, axis, series)
|
|
572
740
|
explicit = options[:scale] || options[:type]
|
|
573
741
|
horizontal_bar = series.any? { |item| item.type == :bar && item.options[:horizontal] }
|
|
574
|
-
bar_band = series.any? { |item| item.type == :bar && ((item.options[:horizontal] && axis == :y) || (!item.options[:horizontal] && axis == :x)) }
|
|
575
|
-
if explicit == :
|
|
742
|
+
bar_band = series.any? { |item| item.type == :bar && !item.options[:histogram] && ((item.options[:horizontal] && axis == :y) || (!item.options[:horizontal] && axis == :x)) }
|
|
743
|
+
if explicit == :time || (explicit.nil? && values.any? { |value| Data.time(value) })
|
|
744
|
+
Scales::TimeScale.new(values, options)
|
|
745
|
+
elsif explicit == :band || (explicit.nil? && (bar_band || values.any? { |value| !Data.number(value) && !Data.time(value) }))
|
|
576
746
|
Scales::Band.new(values, options)
|
|
577
747
|
elsif explicit == :log
|
|
578
748
|
warn "Inkplot ignores zero and negative values on a log axis." if values.any? { |value| (number = Data.number(value)) && !number.positive? }
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Inkplot
|
|
4
|
+
module Renderers
|
|
5
|
+
module Raster
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def render(scene, scale: 1)
|
|
9
|
+
scale = Integer(scale)
|
|
10
|
+
raise ArgumentError, "scale must be a positive integer" unless scale.positive?
|
|
11
|
+
|
|
12
|
+
begin
|
|
13
|
+
require "tessel"
|
|
14
|
+
rescue LoadError => e
|
|
15
|
+
raise LoadError, "PNG output needs tessel; install it with `gem install tessel` (#{e.message})"
|
|
16
|
+
end
|
|
17
|
+
begin
|
|
18
|
+
require "glyphic"
|
|
19
|
+
rescue LoadError => e
|
|
20
|
+
raise LoadError, "PNG output needs glyphic; install it with `gem install glyphic` (#{e.message})"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
factor = scale * 2 # ponytail: 2× coverage sampling; raise this if raster diffs show visible edge artifacts.
|
|
24
|
+
width = scene.width * scale
|
|
25
|
+
height = scene.height * scale
|
|
26
|
+
canvas = Tessel::Image.new(width * 2, height * 2, fill: color(scene.background))
|
|
27
|
+
scene.elements.each { |element| draw_shape(canvas, element, factor) }
|
|
28
|
+
clip = scene.clip.map { |value| value * factor }
|
|
29
|
+
seen = {}
|
|
30
|
+
scene.marks.each do |mark|
|
|
31
|
+
if mark[:type] == :circle
|
|
32
|
+
key = [mark[:series_index], (mark[:cx] * scale).round, (mark[:cy] * scale).round]
|
|
33
|
+
next if seen[key]
|
|
34
|
+
|
|
35
|
+
seen[key] = true
|
|
36
|
+
end
|
|
37
|
+
draw_shape(canvas, mark, factor, clip: clip)
|
|
38
|
+
end
|
|
39
|
+
image = downsample(canvas, width, height)
|
|
40
|
+
draw_labels(image, scene.elements + scene.marks.select { |mark| mark[:type] == :text }, scale)
|
|
41
|
+
image
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def draw_shape(image, shape, factor, clip: nil)
|
|
45
|
+
case shape[:type]
|
|
46
|
+
when :rect
|
|
47
|
+
rectangle(image, shape, factor, clip)
|
|
48
|
+
when :line
|
|
49
|
+
Stroker.stroke(image, shape[:points], color(shape[:stroke]), factor, (shape[:width] || 1) * factor, dash: shape[:dash], clip: clip)
|
|
50
|
+
when :polygon
|
|
51
|
+
rgba = color(shape[:fill])
|
|
52
|
+
rgba[3] = (rgba[3] * (shape[:opacity] || 1)).round
|
|
53
|
+
PathFiller.fill(image, shape[:points].map { |x, y| [x * factor, y * factor] }, rgba, clip: clip)
|
|
54
|
+
when :circle
|
|
55
|
+
cx, cy, radius = shape.values_at(:cx, :cy, :r).map { |value| value * factor }
|
|
56
|
+
Circle.fill(image, cx, cy, radius, color(shape[:fill]), clip: clip)
|
|
57
|
+
Circle.stroke(image, cx, cy, radius, color(shape[:stroke]), [shape[:stroke_width].to_f * factor, 1].max, clip: clip) if shape[:stroke]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
private_class_method :draw_shape
|
|
61
|
+
|
|
62
|
+
def rectangle(image, shape, factor, clip)
|
|
63
|
+
x, y, width, height = shape.values_at(:x, :y, :width, :height).map { |value| (value * factor).round }
|
|
64
|
+
x0 = x
|
|
65
|
+
y0 = y
|
|
66
|
+
x1 = x + width
|
|
67
|
+
y1 = y + height
|
|
68
|
+
if clip
|
|
69
|
+
x0 = [x0, clip[0].floor].max
|
|
70
|
+
y0 = [y0, clip[1].floor].max
|
|
71
|
+
x1 = [x1, clip[2].ceil].min
|
|
72
|
+
y1 = [y1, clip[3].ceil].min
|
|
73
|
+
end
|
|
74
|
+
image.fill_rect(x0, y0, [x1 - x0, 0].max, [y1 - y0, 0].max, color(shape[:fill]), blend: :alpha)
|
|
75
|
+
end
|
|
76
|
+
private_class_method :rectangle
|
|
77
|
+
|
|
78
|
+
def color(value)
|
|
79
|
+
named = {
|
|
80
|
+
"red" => [220, 40, 40], "blue" => [30, 90, 210], "green" => [0, 140, 80], "black" => [0, 0, 0], "white" => [255, 255, 255],
|
|
81
|
+
"orange" => [230, 145, 0], "gray" => [128, 128, 128], "grey" => [128, 128, 128], "purple" => [128, 0, 128], "yellow" => [255, 255, 0],
|
|
82
|
+
"cyan" => [0, 255, 255], "aqua" => [0, 255, 255], "magenta" => [255, 0, 255], "fuchsia" => [255, 0, 255], "pink" => [255, 192, 203],
|
|
83
|
+
"brown" => [165, 42, 42], "navy" => [0, 0, 128], "teal" => [0, 128, 128], "lime" => [0, 255, 0], "maroon" => [128, 0, 0],
|
|
84
|
+
"olive" => [128, 128, 0], "silver" => [192, 192, 192], "rebeccapurple" => [102, 51, 153], "transparent" => [0, 0, 0, 0]
|
|
85
|
+
}
|
|
86
|
+
return named.fetch(value.to_s.downcase) { [32, 36, 43] }.then { |channels| channels.length == 3 ? channels + [255] : channels } unless value.to_s.start_with?("#")
|
|
87
|
+
|
|
88
|
+
hex = value.delete_prefix("#")
|
|
89
|
+
hex = hex.chars.map { |digit| digit * 2 }.join if [3, 4].include?(hex.length)
|
|
90
|
+
channels = hex.scan(/../).map { |channel| channel.to_i(16) }
|
|
91
|
+
channels << 255 if channels.length == 3
|
|
92
|
+
channels
|
|
93
|
+
end
|
|
94
|
+
private_class_method :color
|
|
95
|
+
|
|
96
|
+
def downsample(source, width, height)
|
|
97
|
+
input = source.bytes
|
|
98
|
+
output = String.new(capacity: width * height * 4, encoding: Encoding::BINARY)
|
|
99
|
+
height.times do |y|
|
|
100
|
+
width.times do |x|
|
|
101
|
+
4.times do |channel|
|
|
102
|
+
total = 0
|
|
103
|
+
2.times do |dy|
|
|
104
|
+
2.times do |dx|
|
|
105
|
+
total += input.getbyte((((((y * 2) + dy) * source.width) + (x * 2) + dx) * 4) + channel)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
output << (total / 4).round
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
Tessel::Image.from_rgba(width, height, output)
|
|
113
|
+
end
|
|
114
|
+
private_class_method :downsample
|
|
115
|
+
|
|
116
|
+
def draw_labels(image, elements, scale)
|
|
117
|
+
font = TextMetrics.font_for(12 * scale)
|
|
118
|
+
bitmap = Inkplot.config.font.nil?
|
|
119
|
+
warned = false
|
|
120
|
+
elements.each do |element|
|
|
121
|
+
next unless element[:type] == :text
|
|
122
|
+
|
|
123
|
+
text = element[:text].to_s
|
|
124
|
+
if bitmap && !text.ascii_only? && !warned
|
|
125
|
+
warn "Inkplot's built-in bitmap font is ASCII-only; set Inkplot.config.font to a TrueType font for Unicode labels."
|
|
126
|
+
warned = true
|
|
127
|
+
end
|
|
128
|
+
label = text.encode("ASCII", invalid: :replace, undef: :replace, replace: "?") if bitmap
|
|
129
|
+
label ||= text
|
|
130
|
+
draw_text(image, font, label, element, scale)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
private_class_method :draw_labels
|
|
134
|
+
|
|
135
|
+
def draw_text(image, font, text, element, scale)
|
|
136
|
+
fill = color(element[:fill])
|
|
137
|
+
anchor_x = (element[:x] * scale).round
|
|
138
|
+
baseline_y = (element[:y] * scale).round
|
|
139
|
+
glyph = font.render(text, color: fill)
|
|
140
|
+
bitmap = Inkplot.config.font.nil?
|
|
141
|
+
glyph = glyph.scale_nearest(glyph.width * scale, glyph.height * scale) if bitmap && scale > 1
|
|
142
|
+
ascent = font.ascent * (bitmap ? scale : 1)
|
|
143
|
+
pivot_x = case element[:anchor]
|
|
144
|
+
when :middle then glyph.width / 2.0
|
|
145
|
+
when :end then glyph.width.to_f
|
|
146
|
+
else 0.0
|
|
147
|
+
end
|
|
148
|
+
if element[:rotate]
|
|
149
|
+
pivot_y = ascent.to_f
|
|
150
|
+
radians = element[:rotate] * Math::PI / 180
|
|
151
|
+
source = glyph.bytes
|
|
152
|
+
glyph.height.times do |gy|
|
|
153
|
+
glyph.width.times do |gx|
|
|
154
|
+
offset = ((gy * glyph.width) + gx) * 4
|
|
155
|
+
alpha = source.getbyte(offset + 3)
|
|
156
|
+
next if alpha.zero?
|
|
157
|
+
|
|
158
|
+
dx = gx - pivot_x
|
|
159
|
+
dy = gy - pivot_y
|
|
160
|
+
px = (anchor_x + (dx * Math.cos(radians)) - (dy * Math.sin(radians))).round
|
|
161
|
+
py = (baseline_y + (dx * Math.sin(radians)) + (dy * Math.cos(radians))).round
|
|
162
|
+
blend_pixel(image, px, py, [fill[0], fill[1], fill[2], alpha])
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
else
|
|
166
|
+
image.blit(glyph, anchor_x - pivot_x.round, baseline_y - ascent, blend: :alpha)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
private_class_method :draw_text
|
|
170
|
+
|
|
171
|
+
def blend_pixel(image, x, y, rgba)
|
|
172
|
+
return unless x.between?(0, image.width - 1) && y.between?(0, image.height - 1)
|
|
173
|
+
|
|
174
|
+
image.fill_rect(x, y, 1, 1, rgba, blend: :alpha)
|
|
175
|
+
end
|
|
176
|
+
private_class_method :blend_pixel
|
|
177
|
+
|
|
178
|
+
module PathFiller
|
|
179
|
+
module_function
|
|
180
|
+
|
|
181
|
+
def fill(image, points, rgba, clip: nil)
|
|
182
|
+
return if points.length < 3
|
|
183
|
+
|
|
184
|
+
low = [points.map { |point| point[1] }.min.floor, clip ? clip[1].floor : 0, 0].max
|
|
185
|
+
high = [points.map { |point| point[1] }.max.ceil, clip ? clip[3].ceil : image.height, image.height].min
|
|
186
|
+
(low...high).each do |y|
|
|
187
|
+
scan = y + 0.5
|
|
188
|
+
crossings = points.each_with_index.filter_map do |point, index|
|
|
189
|
+
next_point = points[(index + 1) % points.length]
|
|
190
|
+
x1, y1 = point
|
|
191
|
+
x2, y2 = next_point
|
|
192
|
+
next if (y1 <= scan && y2 <= scan) || (y1 > scan && y2 > scan)
|
|
193
|
+
|
|
194
|
+
[(x1 + ((scan - y1) * (x2 - x1) / (y2 - y1))), y2 > y1 ? 1 : -1]
|
|
195
|
+
end.sort_by(&:first)
|
|
196
|
+
winding = 0
|
|
197
|
+
previous_x = nil
|
|
198
|
+
crossings.each do |crossing_x, direction|
|
|
199
|
+
fill_span(image, previous_x, crossing_x, y, rgba, clip) if winding != 0 && previous_x
|
|
200
|
+
winding += direction
|
|
201
|
+
previous_x = crossing_x
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def fill_span(image, x1, x2, y, rgba, clip)
|
|
207
|
+
left, right = [x1, x2].minmax
|
|
208
|
+
left = [left.ceil, clip ? clip[0].ceil : 0].max
|
|
209
|
+
right = [right.floor, clip ? clip[2].floor : image.width].min
|
|
210
|
+
(left...right).each { |x| image[x, y] = rgba if rgba[3] == 255 }
|
|
211
|
+
return if rgba[3] == 255
|
|
212
|
+
|
|
213
|
+
(left...right).each { |x| image.fill_rect(x, y, 1, 1, rgba, blend: :alpha) }
|
|
214
|
+
end
|
|
215
|
+
private_class_method :fill_span
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
module Stroker
|
|
219
|
+
module_function
|
|
220
|
+
|
|
221
|
+
def stroke(image, points, rgba, factor, width, dash: false, clip: nil)
|
|
222
|
+
return if points.length < 2
|
|
223
|
+
|
|
224
|
+
return stroke_dashed(image, points, rgba, factor, width, clip) if dash
|
|
225
|
+
|
|
226
|
+
scaled_points = points.map { |x, y| [(x * factor).round, (y * factor).round] }
|
|
227
|
+
scaled_points.each_cons(2) do |(x0, y0), (x1, y1)|
|
|
228
|
+
dx = x1 - x0
|
|
229
|
+
dy = y1 - y0
|
|
230
|
+
length = Math.hypot(dx, dy)
|
|
231
|
+
next if length.zero?
|
|
232
|
+
|
|
233
|
+
offset_x = -dy * width / (2 * length)
|
|
234
|
+
offset_y = dx * width / (2 * length)
|
|
235
|
+
polygon = [[x0 + offset_x, y0 + offset_y], [x1 + offset_x, y1 + offset_y], [x1 - offset_x, y1 - offset_y], [x0 - offset_x, y0 - offset_y]]
|
|
236
|
+
PathFiller.fill(image, polygon, rgba, clip: clip)
|
|
237
|
+
end
|
|
238
|
+
scaled_points.each { |x, y| Circle.fill(image, x, y, width / 2.0, rgba, clip: clip) }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def stroke_dashed(image, points, rgba, factor, width, clip)
|
|
242
|
+
dash_length = [width.round * 3, 6].max
|
|
243
|
+
distance = 0
|
|
244
|
+
points.each_cons(2) do |start_point, end_point|
|
|
245
|
+
x0, y0 = start_point.map { |value| (value * factor).round }
|
|
246
|
+
x1, y1 = end_point.map { |value| (value * factor).round }
|
|
247
|
+
dx = (x1 - x0).abs
|
|
248
|
+
dy = -(y1 - y0).abs
|
|
249
|
+
sx = x0 < x1 ? 1 : -1
|
|
250
|
+
sy = y0 < y1 ? 1 : -1
|
|
251
|
+
error = dx + dy
|
|
252
|
+
loop do
|
|
253
|
+
draw_brush(image, x0, y0, width, rgba, clip) unless (distance / dash_length).odd?
|
|
254
|
+
break if x0 == x1 && y0 == y1
|
|
255
|
+
|
|
256
|
+
twice = 2 * error
|
|
257
|
+
if twice >= dy
|
|
258
|
+
error += dy
|
|
259
|
+
x0 += sx
|
|
260
|
+
end
|
|
261
|
+
if twice <= dx
|
|
262
|
+
error += dx
|
|
263
|
+
y0 += sy
|
|
264
|
+
end
|
|
265
|
+
distance += 1
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def draw_brush(image, x, y, width, rgba, clip)
|
|
271
|
+
radius = [width / 2.0, 0.5].max
|
|
272
|
+
(y - radius).floor.upto((y + radius).ceil) do |py|
|
|
273
|
+
(x - radius).floor.upto((x + radius).ceil) do |px|
|
|
274
|
+
next if ((px - x)**2) + ((py - y)**2) > radius**2
|
|
275
|
+
next unless px.between?(0, image.width - 1) && py.between?(0, image.height - 1)
|
|
276
|
+
next if clip && (px < clip[0] || px > clip[2] || py < clip[1] || py > clip[3])
|
|
277
|
+
|
|
278
|
+
image.fill_rect(px, py, 1, 1, rgba, blend: :alpha)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
private_class_method :draw_brush
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
module Circle
|
|
286
|
+
module_function
|
|
287
|
+
|
|
288
|
+
def fill(image, cx, cy, radius, rgba, clip: nil)
|
|
289
|
+
low_y = [(cy - radius).floor, clip ? clip[1].floor : 0, 0].max
|
|
290
|
+
high_y = [(cy + radius).ceil, clip ? clip[3].ceil : image.height - 1, image.height - 1].min
|
|
291
|
+
(low_y..high_y).each do |y|
|
|
292
|
+
span = Math.sqrt([(radius**2) - ((y - cy)**2), 0].max)
|
|
293
|
+
left = [(cx - span).floor, clip ? clip[0].floor : 0, 0].max
|
|
294
|
+
right = [(cx + span).ceil, clip ? clip[2].ceil : image.width - 1, image.width - 1].min
|
|
295
|
+
image.fill_rect(left, y, right - left + 1, 1, rgba, blend: :alpha) if left <= right
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def stroke(image, cx, cy, radius, rgba, width, clip: nil)
|
|
300
|
+
outer = radius + (width / 2.0)
|
|
301
|
+
inner = [radius - (width / 2.0), 0].max
|
|
302
|
+
low_y = [(cy - outer).floor, clip ? clip[1].floor : 0, 0].max
|
|
303
|
+
high_y = [(cy + outer).ceil, clip ? clip[3].ceil : image.height - 1, image.height - 1].min
|
|
304
|
+
(low_y..high_y).each do |y|
|
|
305
|
+
dy = y - cy
|
|
306
|
+
outer_span = Math.sqrt([(outer**2) - (dy**2), 0].max)
|
|
307
|
+
outer_left = [(cx - outer_span).floor, clip ? clip[0].floor : 0, 0].max
|
|
308
|
+
outer_right = [(cx + outer_span).ceil, clip ? clip[2].ceil : image.width - 1, image.width - 1].min
|
|
309
|
+
inner_span = inner.zero? || dy.abs >= inner ? 0 : Math.sqrt((inner**2) - (dy**2))
|
|
310
|
+
inner_left = (cx - inner_span).ceil
|
|
311
|
+
inner_right = (cx + inner_span).floor
|
|
312
|
+
left_width = [inner_left - outer_left, outer_right - outer_left + 1].min
|
|
313
|
+
image.fill_rect(outer_left, y, left_width, 1, rgba, blend: :alpha) if left_width.positive?
|
|
314
|
+
right_start = [inner_right + 1, outer_left].max
|
|
315
|
+
right_width = outer_right - right_start + 1
|
|
316
|
+
image.fill_rect(right_start, y, right_width, 1, rgba, blend: :alpha) if right_width.positive?
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
data/lib/inkplot/version.rb
CHANGED
data/lib/inkplot.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "date"
|
|
3
4
|
require_relative "inkplot/version"
|
|
4
5
|
require_relative "inkplot/core"
|
|
5
6
|
require_relative "inkplot/renderers/svg"
|
|
@@ -42,6 +43,13 @@ module Inkplot
|
|
|
42
43
|
Chart.new(builder)
|
|
43
44
|
end
|
|
44
45
|
|
|
46
|
+
def histogram(values, bins: :auto, x_label: nil, y_label: "Count", title: nil, **options)
|
|
47
|
+
chart_options, mark_options = options.partition { |key, _| %i[width height theme].include?(key) }.map(&:to_h)
|
|
48
|
+
builder = Builder.new(**chart_options, title:, x_label:, y_label:)
|
|
49
|
+
builder.histogram(values, bins:, **mark_options)
|
|
50
|
+
Chart.new(builder)
|
|
51
|
+
end
|
|
52
|
+
|
|
45
53
|
def sparkline(values)
|
|
46
54
|
values = values.filter_map { |value| Data.number(value) }
|
|
47
55
|
return "" if values.empty?
|
data/sig/inkplot.rbs
CHANGED
|
@@ -15,8 +15,10 @@ module Inkplot
|
|
|
15
15
|
def area: (untyped x, ?untyped y, ?label: String?, ?color: String?, **untyped options) -> Builder
|
|
16
16
|
def step: (untyped x, ?untyped y, ?label: String?, ?color: String?, **untyped options) -> Builder
|
|
17
17
|
def bar: (untyped categories, ?untyped values, ?label: String?, ?color: String?, ?horizontal: bool, ?stacked: bool, **untyped options) -> Builder
|
|
18
|
+
def histogram: (untyped values, ?bins: untyped, ?label: String?, ?color: String?, **untyped options) -> Builder
|
|
18
19
|
def hline: (Numeric value, ?label: String?, ?color: String?, ?dash: bool) -> Array[Hash[Symbol, untyped]]
|
|
19
20
|
def vline: (untyped value, ?label: String?, ?color: String?, ?dash: bool) -> Array[Hash[Symbol, untyped]]
|
|
21
|
+
def text: (untyped x, Numeric y, String value, ?color: String?) -> Array[Hash[Symbol, untyped]]
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
class Chart
|
|
@@ -24,7 +26,10 @@ module Inkplot
|
|
|
24
26
|
attr_reader width: Integer
|
|
25
27
|
attr_reader height: Integer
|
|
26
28
|
def to_svg: (?width: Integer, ?height: Integer) -> String
|
|
27
|
-
def
|
|
29
|
+
def to_png: (?width: Integer, ?height: Integer, ?scale: Integer) -> String
|
|
30
|
+
def to_image: (?scale: Integer) -> untyped
|
|
31
|
+
def save: (String | _Path path, ?width: Integer, ?height: Integer, ?scale: Integer) -> String | _Path
|
|
32
|
+
def to_inlay: () -> Hash[Symbol, untyped]
|
|
28
33
|
end
|
|
29
34
|
|
|
30
35
|
def self.config: () -> Config
|
|
@@ -32,5 +37,6 @@ module Inkplot
|
|
|
32
37
|
def self.line: (untyped data, ?untyped values, ?x: untyped, ?y: untyped, ?color: untyped, ?title: String?, ?x_label: String?, ?y_label: String?, **untyped options) -> Chart
|
|
33
38
|
def self.scatter: (untyped data, ?untyped values, ?x: untyped, ?y: untyped, ?size: untyped, ?color: untyped, ?title: String?, ?x_label: String?, ?y_label: String?, **untyped options) -> Chart
|
|
34
39
|
def self.bar: (untyped data, ?horizontal: bool, ?title: String?, ?x_label: String?, ?y_label: String?, **untyped options) -> Chart
|
|
40
|
+
def self.histogram: (untyped values, ?bins: untyped, ?x_label: String?, ?y_label: String?, ?title: String?, **untyped options) -> Chart
|
|
35
41
|
def self.sparkline: (Array[untyped] values) -> String
|
|
36
42
|
end
|
metadata
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: inkplot
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yudai Takada
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: exe
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
+
date: 2026-09-25 00:00:00.000000000 Z
|
|
11
12
|
dependencies:
|
|
12
13
|
- !ruby/object:Gem::Dependency
|
|
13
14
|
name: rake
|
|
@@ -65,7 +66,8 @@ dependencies:
|
|
|
65
66
|
- - "~>"
|
|
66
67
|
- !ruby/object:Gem::Version
|
|
67
68
|
version: '1.0'
|
|
68
|
-
description: Build line, bar, scatter, and other charts as standalone SVG
|
|
69
|
+
description: Build line, bar, scatter, and other charts as standalone SVG, with optional
|
|
70
|
+
PNG rendering.
|
|
69
71
|
email:
|
|
70
72
|
- t.yudai92@gmail.com
|
|
71
73
|
executables: []
|
|
@@ -87,11 +89,22 @@ files:
|
|
|
87
89
|
- examples/gallery/06-horizontal-bars.svg
|
|
88
90
|
- examples/gallery/07-grouped-bars.svg
|
|
89
91
|
- examples/gallery/08-stacked-bars.svg
|
|
92
|
+
- examples/gallery/09-signed-stack.svg
|
|
90
93
|
- examples/gallery/10-area.svg
|
|
91
94
|
- examples/gallery/11-step.svg
|
|
95
|
+
- examples/gallery/12-histogram.svg
|
|
96
|
+
- examples/gallery/13-log-scale.svg
|
|
97
|
+
- examples/gallery/14-time-axis.svg
|
|
98
|
+
- examples/gallery/15-category-order.svg
|
|
99
|
+
- examples/gallery/16-dark-theme.svg
|
|
100
|
+
- examples/gallery/17-annotations.svg
|
|
101
|
+
- examples/gallery/18-combo.svg
|
|
102
|
+
- examples/gallery/19-legend-position.svg
|
|
103
|
+
- examples/gallery/20-long-categories.svg
|
|
92
104
|
- examples/gallery/README.md
|
|
93
105
|
- lib/inkplot.rb
|
|
94
106
|
- lib/inkplot/core.rb
|
|
107
|
+
- lib/inkplot/renderers/raster.rb
|
|
95
108
|
- lib/inkplot/renderers/svg.rb
|
|
96
109
|
- lib/inkplot/version.rb
|
|
97
110
|
- sig/inkplot.rbs
|
|
@@ -102,6 +115,7 @@ metadata:
|
|
|
102
115
|
homepage_uri: https://github.com/rbgfx/inkplot
|
|
103
116
|
source_code_uri: https://github.com/rbgfx/inkplot/tree/main
|
|
104
117
|
rubygems_mfa_required: 'true'
|
|
118
|
+
post_install_message:
|
|
105
119
|
rdoc_options: []
|
|
106
120
|
require_paths:
|
|
107
121
|
- lib
|
|
@@ -116,7 +130,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
116
130
|
- !ruby/object:Gem::Version
|
|
117
131
|
version: '0'
|
|
118
132
|
requirements: []
|
|
119
|
-
rubygems_version: 4.
|
|
133
|
+
rubygems_version: 3.4.19
|
|
134
|
+
signing_key:
|
|
120
135
|
specification_version: 4
|
|
121
|
-
summary: SVG charts for Ruby
|
|
136
|
+
summary: Dependency-free SVG charts for Ruby
|
|
122
137
|
test_files: []
|