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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3b849fabe00ddcb70337337fe69039b30a4d5c6897ba99c1ab23503c856c9355
4
- data.tar.gz: 1303d52c866fbab95d6a933200162b890f2e1deee22c2dc1bc3eb95e7bf3942b
3
+ metadata.gz: 90a3ec94d835a8c52be1645190c450486b74e3d69e69f9c7a32759c52cd9fbad
4
+ data.tar.gz: 670616432fd769febb36b31b338d506e58abb41fbdc32820fd0b97b0df9c2ec3
5
5
  SHA512:
6
- metadata.gz: f5c7e69766bb2bd50ed69f680693e90463390b7183366802273c3d940ac343a5a3ec120a94b2afe362438fbca4c6d8b7ecf7afc9af2624583852971240a0122d
7
- data.tar.gz: e4a74f925d2c6d2eec4af35f034f4278531e09e0f3517ce8c82dde71611b0771da34bdf4ea6a3cc548c68cf5690f19137c189985e1a28a1012bf0dbf859c312e
6
+ metadata.gz: 23eef7bbe84575c0b7d32626a36d880de2a7e5c408791df60b9b0675a8a561bf9c58aa2aea0d28dd0653c126ce9746672bfd83ea734480a84cfbf74af334a861
7
+ data.tar.gz: c80647ebc946d60e8ec623010735aea0f71e922c296d2777265251ad8766a2fef0d3848326ed735d48ca72c0d797cf8eda00468a2dc76cad36c22fa85e9c358c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # Changelog
2
2
 
3
- ## [0.1.0] - 2026-09-25
3
+ ## [0.3.0] - 2026-09-25
4
4
 
5
- Initial release
5
+ - Implement SVG and PNG chart rendering, scales, annotations, terminal sparklines, REPL adaptation, and the 20-chart gallery.
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2026 Yudai Takada
3
+ Copyright (c) 2026 ydah
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  <h1 align="center">Inkplot</h1>
2
2
 
3
- <p align="center">Build clear charts as standalone SVG with plain Ruby data.</p>
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
- Inkplot draws line, scatter, bar, area, and step charts without native libraries or runtime dependencies.
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 the GitHub repository to your Gemfile:
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
- Use the builder to combine series and configure axes:
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 "Quarterly requests"
35
- plot.x_axis(label: "Quarter")
36
- plot.y_axis(label: "Requests", min: 0)
37
- plot.line(%w[Q1 Q2 Q3 Q4], [18, 23, 28, 36], label: "Current")
38
- plot.line(%w[Q1 Q2 Q3 Q4], [14, 19, 21, 30], label: "Previous", dash: true)
39
- plot.hline(25, label: "Target")
40
- plot.legend(position: :top_right)
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
- ## Data and output
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
- Charts accept arrays, hashes, CSV tables, and records with string or symbol keys. Scales include linear, logarithmic, and ordered categories. Light and dark themes, grouped and stacked bars, and terminal sparklines are included.
78
+ ## Gallery
49
79
 
50
- The [gallery](examples/gallery/README.md) contains ten runnable examples. Regenerate its SVG previews with:
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
- - `to_svg` returns a standalone SVG string; `save` writes an `.svg` file.
59
- - Paired x/y arrays must have the same length. Non-finite y values break line segments by default; use `gaps: :connect` to bridge them.
60
- - Explicit axis limits must be ordered. Log scales omit non-positive values.
61
- - Invalid scales, colors, dimensions, and file extensions raise `ArgumentError`.
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. See [LICENSE.txt](LICENSE.txt).
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>
@@ -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 previews.
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 | ![Multiple series](03-series.svg) | Scatter | ![Scatter plot](04-scatter.svg) |
9
9
  | Bars | ![Bars](05-bars.svg) | Horizontal bars | ![Horizontal bars](06-horizontal-bars.svg) |
10
10
  | Grouped bars | ![Grouped bars](07-grouped-bars.svg) | Stacked bars | ![Stacked bars](08-stacked-bars.svg) |
11
- | Area | ![Area chart](10-area.svg) | Step | ![Step chart](11-step.svg) |
11
+ | Signed stacks | ![Signed stacks](09-signed-stack.svg) | Area | ![Area chart](10-area.svg) |
12
+ | Step | ![Step chart](11-step.svg) | Histogram | ![Histogram](12-histogram.svg) |
13
+ | Log scale | ![Log scale](13-log-scale.svg) | Time scale | ![Time scale](14-time-axis.svg) |
14
+ | Category order | ![Ordered categories](15-category-order.svg) | Dark theme | ![Dark chart](16-dark-theme.svg) |
15
+ | Annotations | ![Chart annotations](17-annotations.svg) | Mixed marks | ![Mixed chart](18-combo.svg) |
16
+ | Legend position | ![Legend placement](19-legend-position.svg) | Long labels | ![Rotated labels](20-long-categories.svg) |
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
- raise ArgumentError, "usage: ruby examples/gallery.rb" unless ARGV.empty?
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, "font metrics need glyphic; install it with `gem install glyphic` (#{e.message})"
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 save(path, width: self.width, height: self.height)
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
- else raise ArgumentError, "output path must end in .svg"
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 == :band || (explicit.nil? && (bar_band || values.any? { |value| !Data.number(value) }))
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Inkplot
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
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 save: (String | _Path path, ?width: Integer, ?height: Integer) -> String | _Path
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.1.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: 1980-01-02 00:00:00.000000000 Z
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.0.16
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: []