react-native-vroom-chart 0.12.0 → 0.13.1

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.
@@ -124,6 +124,14 @@ target_compile_options(vroomchart PRIVATE -Wno-mismatched-tags)
124
124
 
125
125
  find_library(VROOM_LOG_LIB log)
126
126
 
127
+ # Android 15+ devices can use 16KB pages. NDK r27 and older default to 4KB
128
+ # ELF alignment (align=2**12); Play/SDK 35 then rejects libvroomchart.so.
129
+ # r28+ already aligns to 16KB, so these flags are redundant there but harmless.
130
+ target_link_options(vroomchart PRIVATE
131
+ "-Wl,-z,max-page-size=16384"
132
+ "-Wl,-z,common-page-size=16384"
133
+ )
134
+
127
135
  target_link_libraries(vroomchart
128
136
  ${VROOM_LOG_LIB}
129
137
  ReactAndroid::jsi
package/android/README.md CHANGED
@@ -52,13 +52,14 @@ different, unmerged RTTI record for "the same" class and fails with
52
52
  `Expected a Skia object of a different type`.
53
53
 
54
54
  `VroomChartHostObject.cpp`'s `wrapPicture()` works around this only on
55
- Android: instead of constructing `RNSkia::JsiSkPicture` directly (as iOS
56
- does), it serializes the `SkPicture` (`SkPicture::serialize()`) and calls
57
- RN-Skia's own public JS API, `Skia.Picture.MakePicture(bytes)`, via JSI. That
58
- runs `JsiSkPictureFactory::MakePicture` genuinely compiled inside
59
- `librnskia.so` — so the returned object's RTTI matches what
60
- `librnskia.so`'s own code expects. This costs a serialize + re-parse of the
61
- picture's draw commands on every `render()`/gesture call, which is real
62
- overhead on what's meant to be a 60fps hot path; if that shows up in
63
- profiling, revisit (e.g. a merged-`.so` build, or a lighter-weight bridge
64
- that avoids the round trip).
55
+ Android. Two copies of Skia cannot share an `sk_sp<SkPicture>` pointer, and
56
+ serializing the chart picture (the original workaround) embedded the Android
57
+ system typeface on every pan/zoom frame an OOM that killed the process after
58
+ a few seconds of gesturing.
59
+
60
+ The hot path now rasterizes the picture in vroom's Skia and hands RN-Skia raw
61
+ pixels via `Skia.Image.MakeImage`, so `<Image>` paints a framebuffer whose size
62
+ is bounded by the view. If rasterization fails, we still serialize, but with
63
+ `SkTypeface::SerializeBehavior::kDontIncludeData` so the font file is not
64
+ copied; `MakePicture` re-resolves `sans-serif` inside `librnskia.so`. iOS is
65
+ unchanged (one binary, direct `JsiSkPicture` wrap).
@@ -4,6 +4,7 @@
4
4
  #include <string>
5
5
  #include <vector>
6
6
 
7
+ #include "chart.h"
7
8
  #include "chart_internal.h"
8
9
  #include "vroom/vroom_chart.h"
9
10
 
@@ -11,6 +12,18 @@
11
12
  #include "JsiSkNativeObjects.h"
12
13
  #include "JsiSkPicture.h"
13
14
 
15
+ #if defined(__ANDROID__)
16
+ #include <algorithm>
17
+ #include <cmath>
18
+
19
+ #include "include/core/SkBitmap.h"
20
+ #include "include/core/SkCanvas.h"
21
+ #include "include/core/SkData.h"
22
+ #include "include/core/SkImageInfo.h"
23
+ #include "include/core/SkSerialProcs.h"
24
+ #include "include/core/SkTypeface.h"
25
+ #endif
26
+
14
27
  namespace vroom {
15
28
 
16
29
  namespace jsi = facebook::jsi;
@@ -74,27 +87,21 @@ std::vector<jsi::PropNameID> ChartHostObject::getPropertyNames(
74
87
  }
75
88
 
76
89
  #if defined(__ANDROID__)
77
- // Android only: RN-Skia is compiled into its own librnskia.so, separate from
78
- // libvroomchart.so. Constructing a RNSkia::JsiSkPicture directly here (as iOS
79
- // does, below) gives it a vtable/typeinfo from *our* .so; when RN-Skia's own
80
- // compiled code later consumes it (e.g. Convertor.h's
81
- // `getPropertyValue<sk_sp<SkPicture>>`, which does
82
- // `getJsiObject<JsiSkPicture>(rt, value)` — a dynamic_pointer_cast
83
- // under the hood), the cast fails cross-.so with "Expected a Skia object of
84
- // a different type", because the object's *runtime* type was never actually
85
- // compiled inside librnskia.so. iOS statically links everything into one
86
- // binary, so no such mismatch exists there.
90
+ // Android only: RN-Skia lives in librnskia.so, vroom in libvroomchart.so, and
91
+ // each statically links its own libskia.a. A JsiSkPicture constructed here has
92
+ // our .so's RTTI, so RN-Skia's dynamic_pointer_cast fails with "Expected a
93
+ // Skia object of a different type". iOS statically links one binary, so the
94
+ // iOS wrap below can hand the sk_sp<SkPicture> through directly.
87
95
  //
88
- // The fix: build the picture through RN-Skia's own public JS API instead
89
- // (`Skia.Picture.MakePicture(bytes)`, the same call `require(...).png`-style
90
- // static pictures use), so the resulting JsiSkPicture is genuinely
91
- // constructed by librnskia.so's own code. This costs a serialize +
92
- // re-parse of the picture's draw ops per call — real overhead on a gesture
93
- // hot path but is the only ABI-safe option found so far. Revisit if
94
- // profiling shows this mattering (e.g. a merged-.so build, or a lighter
95
- // bridge that avoids the round trip).
96
- #include "include/core/SkData.h"
97
-
96
+ // Crossing that boundary used to serialize the chart picture and call
97
+ // Skia.Picture.MakePicture(bytes). Default SkPicture::serialize() embeds the
98
+ // Android system typeface (Noto-scale, megabytes) on every pan/zoom frame;
99
+ // Hermes never kept up and the process was OOM-killed (~3 GB RSS in ~10s).
100
+ //
101
+ // Preferred path: rasterize in *our* Skia and hand RN-Skia raw pixels via
102
+ // Skia.Image.MakeImage memory is one framebuffer, labels stay correct.
103
+ // Fallback: still serialize, but with kDontIncludeData so the font file is
104
+ // not copied; MakePicture re-resolves sans-serif on RN-Skia's side.
98
105
  namespace {
99
106
  class SkDataMutableBuffer : public facebook::jsi::MutableBuffer {
100
107
  public:
@@ -108,41 +115,119 @@ class SkDataMutableBuffer : public facebook::jsi::MutableBuffer {
108
115
  private:
109
116
  sk_sp<SkData> data_;
110
117
  };
111
- } // namespace
112
118
 
113
- static facebook::jsi::Value wrapPicture(facebook::jsi::Runtime& rt,
114
- const sk_sp<SkPicture>& pic) {
115
- if (!pic) return facebook::jsi::Value::null();
116
- sk_sp<SkData> serialized = pic->serialize();
117
- if (!serialized) return facebook::jsi::Value::null();
119
+ jsi::Value skiaApiObject(jsi::Runtime& rt, const char* name) {
120
+ auto skiaApi = rt.global().getProperty(rt, "SkiaApi");
121
+ if (!skiaApi.isObject()) return jsi::Value::undefined();
122
+ auto prop = skiaApi.asObject(rt).getProperty(rt, name);
123
+ return prop.isObject() ? std::move(prop) : jsi::Value::undefined();
124
+ }
118
125
 
119
- auto buffer = std::make_shared<SkDataMutableBuffer>(std::move(serialized));
126
+ // Uint8Array-shaped `{ buffer }` — MakePicture and Data.fromBytes both
127
+ // read the ArrayBuffer off that property.
128
+ jsi::Object bytesLike(jsi::Runtime& rt, sk_sp<SkData> data) {
129
+ auto buffer = std::make_shared<SkDataMutableBuffer>(std::move(data));
120
130
  jsi::ArrayBuffer arrayBuffer(rt, buffer);
131
+ jsi::Object arg(rt);
132
+ arg.setProperty(rt, "buffer", arrayBuffer);
133
+ return arg;
134
+ }
121
135
 
122
- auto skiaApi = rt.global().getProperty(rt, "SkiaApi");
123
- if (!skiaApi.isObject()) return facebook::jsi::Value::null();
124
- auto pictureFactory = skiaApi.asObject(rt).getProperty(rt, "Picture");
125
- if (!pictureFactory.isObject()) return facebook::jsi::Value::null();
136
+ sk_sp<const SkData> serializeTypefaceNoEmbed(SkTypeface* tf, void*) {
137
+ if (!tf) return nullptr;
138
+ return tf->serialize(SkTypeface::SerializeBehavior::kDontIncludeData);
139
+ }
140
+
141
+ jsi::Value wrapSerializedPicture(jsi::Runtime& rt, const sk_sp<SkPicture>& pic) {
142
+ SkSerialProcs procs;
143
+ procs.fTypefaceProc = serializeTypefaceNoEmbed;
144
+ sk_sp<SkData> serialized = pic->serialize(&procs);
145
+ if (!serialized) return jsi::Value::null();
146
+
147
+ auto pictureFactory = skiaApiObject(rt, "Picture");
148
+ if (pictureFactory.isUndefined()) return jsi::Value::null();
126
149
  auto pictureFactoryObj = pictureFactory.asObject(rt);
127
150
  auto makePicture =
128
151
  pictureFactoryObj.getPropertyAsFunction(rt, "MakePicture");
129
152
 
130
- // Mirrors JsiSkPictureFactory::MakePicture's expected argument shape: an
131
- // object with a `.buffer` property holding the ArrayBuffer (matching a
132
- // Uint8Array-like value; the factory ignores everything else about it).
133
- //
134
153
  // Skia 2.11's NativeState host methods recover the factory via `this`
135
154
  // (`fromThis` → "Expected PictureFactory but got non-object" if unbound).
136
- jsi::Object arg(rt);
137
- arg.setProperty(rt, "buffer", arrayBuffer);
138
- return makePicture.callWithThis(rt, pictureFactoryObj, arg);
155
+ return makePicture.callWithThis(rt, pictureFactoryObj,
156
+ bytesLike(rt, std::move(serialized)));
157
+ }
158
+
159
+ jsi::Value wrapRasterImage(jsi::Runtime& rt, const sk_sp<SkPicture>& pic,
160
+ float dpr) {
161
+ const SkRect cull = pic->cullRect();
162
+ const float scale = dpr > 0.f ? dpr : 1.f;
163
+ const int w =
164
+ std::max(1, static_cast<int>(std::ceil(cull.width() * scale)));
165
+ const int h =
166
+ std::max(1, static_cast<int>(std::ceil(cull.height() * scale)));
167
+
168
+ // Reused across frames on this JS thread so pan/zoom doesn't heap-allocate
169
+ // a new bitmap every time. Multiple charts share it; a size change reallocs.
170
+ thread_local SkBitmap raster;
171
+ const auto info = SkImageInfo::MakeN32Premul(w, h);
172
+ if ((raster.width() != w || raster.height() != h) &&
173
+ !raster.tryAllocPixels(info)) {
174
+ return jsi::Value::null();
175
+ }
176
+ raster.eraseColor(SK_ColorTRANSPARENT);
177
+ SkCanvas canvas(raster);
178
+ canvas.scale(scale, scale);
179
+ canvas.translate(-cull.left(), -cull.top());
180
+ canvas.drawPicture(pic);
181
+
182
+ SkPixmap pixmap;
183
+ if (!raster.peekPixels(&pixmap) || pixmap.addr() == nullptr) {
184
+ return jsi::Value::null();
185
+ }
186
+ sk_sp<SkData> pixels =
187
+ SkData::MakeWithCopy(pixmap.addr(), pixmap.computeByteSize());
188
+ if (!pixels) return jsi::Value::null();
189
+
190
+ auto dataFactory = skiaApiObject(rt, "Data");
191
+ auto imageFactory = skiaApiObject(rt, "Image");
192
+ if (dataFactory.isUndefined() || imageFactory.isUndefined()) {
193
+ return jsi::Value::null();
194
+ }
195
+ auto dataFactoryObj = dataFactory.asObject(rt);
196
+ auto imageFactoryObj = imageFactory.asObject(rt);
197
+ auto fromBytes = dataFactoryObj.getPropertyAsFunction(rt, "fromBytes");
198
+ auto makeImage = imageFactoryObj.getPropertyAsFunction(rt, "MakeImage");
199
+
200
+ auto skData =
201
+ fromBytes.callWithThis(rt, dataFactoryObj, bytesLike(rt, std::move(pixels)));
202
+ if (!skData.isObject()) return jsi::Value::null();
203
+
204
+ jsi::Object imageInfo(rt);
205
+ imageInfo.setProperty(rt, "width", static_cast<double>(w));
206
+ imageInfo.setProperty(rt, "height", static_cast<double>(h));
207
+ imageInfo.setProperty(rt, "colorType",
208
+ static_cast<double>(pixmap.colorType()));
209
+ imageInfo.setProperty(rt, "alphaType",
210
+ static_cast<double>(pixmap.alphaType()));
211
+ return makeImage.callWithThis(rt, imageFactoryObj, imageInfo, skData,
212
+ static_cast<double>(pixmap.rowBytes()));
213
+ }
214
+ } // namespace
215
+
216
+ static facebook::jsi::Value wrapPicture(facebook::jsi::Runtime& rt,
217
+ const sk_sp<SkPicture>& pic,
218
+ float dpr = 1.f) {
219
+ if (!pic) return facebook::jsi::Value::null();
220
+ auto image = wrapRasterImage(rt, pic, dpr);
221
+ if (!image.isNull()) return image;
222
+ return wrapSerializedPicture(rt, pic);
139
223
  }
140
224
  #else
141
225
  // Shared helper: wraps a fresh picture for return to JS. Memory pressure is
142
226
  // applied inside JsiSkPicture::create (NativeObject::create reads
143
227
  // getMemoryPressure() → picture->approximateBytesUsed()).
144
228
  static facebook::jsi::Value wrapPicture(facebook::jsi::Runtime& rt,
145
- const sk_sp<SkPicture>& pic) {
229
+ const sk_sp<SkPicture>& pic,
230
+ float /*dpr*/ = 1.f) {
146
231
  if (!pic) return facebook::jsi::Value::null();
147
232
  auto host =
148
233
  std::make_shared<RNSkia::JsiSkPicture>(/*context=*/nullptr, pic);
@@ -448,7 +533,8 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
448
533
  const float dx = static_cast<float>(args[0].asNumber());
449
534
  const float dy = static_cast<float>(args[1].asNumber());
450
535
  vroom_chart_pan(chart_, dx, dy);
451
- return wrapPicture(rt2, render_chart_picture(chart_));
536
+ return wrapPicture(rt2, render_chart_picture(chart_),
537
+ chart_->px_ratio);
452
538
  });
453
539
  }
454
540
 
@@ -467,7 +553,8 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
467
553
  const float dx = static_cast<float>(args[0].asNumber());
468
554
  const float dy = static_cast<float>(args[1].asNumber());
469
555
  vroom_chart_translate(chart_, dx, dy);
470
- return wrapPicture(rt2, render_chart_picture(chart_));
556
+ return wrapPicture(rt2, render_chart_picture(chart_),
557
+ chart_->px_ratio);
471
558
  });
472
559
  }
473
560
 
@@ -489,7 +576,8 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
489
576
  const float fx = static_cast<float>(args[2].asNumber());
490
577
  const float fy = static_cast<float>(args[3].asNumber());
491
578
  vroom_chart_zoom(chart_, sx, sy, fx, fy);
492
- return wrapPicture(rt2, render_chart_picture(chart_));
579
+ return wrapPicture(rt2, render_chart_picture(chart_),
580
+ chart_->px_ratio);
493
581
  });
494
582
  }
495
583
 
@@ -505,7 +593,8 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
505
593
  if (count < 1) return jsi::Value::null();
506
594
  const float dy = static_cast<float>(args[0].asNumber());
507
595
  vroom_chart_scale_price_axis(chart_, dy);
508
- return wrapPicture(rt2, render_chart_picture(chart_));
596
+ return wrapPicture(rt2, render_chart_picture(chart_),
597
+ chart_->px_ratio);
509
598
  });
510
599
  }
511
600
 
@@ -521,7 +610,8 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
521
610
  if (count < 1) return jsi::Value::null();
522
611
  const float dx = static_cast<float>(args[0].asNumber());
523
612
  vroom_chart_scale_time_axis(chart_, dx);
524
- return wrapPicture(rt2, render_chart_picture(chart_));
613
+ return wrapPicture(rt2, render_chart_picture(chart_),
614
+ chart_->px_ratio);
525
615
  });
526
616
  }
527
617
 
@@ -572,7 +662,8 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
572
662
  const float x = static_cast<float>(args[0].asNumber());
573
663
  const float y = static_cast<float>(args[1].asNumber());
574
664
  vroom_chart_set_crosshair(chart_, x, y);
575
- return wrapPicture(rt2, render_chart_picture(chart_));
665
+ return wrapPicture(rt2, render_chart_picture(chart_),
666
+ chart_->px_ratio);
576
667
  });
577
668
  }
578
669
 
@@ -587,7 +678,8 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
587
678
  const jsi::Value* /*args*/,
588
679
  size_t /*count*/) -> jsi::Value {
589
680
  vroom_chart_clear_crosshair(chart_);
590
- return wrapPicture(rt2, render_chart_picture(chart_));
681
+ return wrapPicture(rt2, render_chart_picture(chart_),
682
+ chart_->px_ratio);
591
683
  });
592
684
  }
593
685
 
@@ -1098,7 +1190,8 @@ jsi::Value ChartHostObject::get(jsi::Runtime& rt,
1098
1190
  const jsi::Value& /*thisVal*/,
1099
1191
  const jsi::Value* /*args*/,
1100
1192
  size_t /*count*/) -> jsi::Value {
1101
- return wrapPicture(rt2, render_chart_picture(chart_));
1193
+ return wrapPicture(rt2, render_chart_picture(chart_),
1194
+ chart_->px_ratio);
1102
1195
  });
1103
1196
  }
1104
1197
 
@@ -6,7 +6,7 @@
6
6
  // Methods exposed to JS:
7
7
  // setCandles(arrayBuffer) — copies packed VroomCandle[] into the core
8
8
  // setSize(width, height, pxRatio)
9
- // render() -> JsiSkPicture — returns the current chart picture
9
+ // render() -> SkPicture (iOS) / SkImage (Android)
10
10
 
11
11
  #pragma once
12
12
 
@@ -5,7 +5,7 @@
5
5
  // methods on the returned object:
6
6
  // setCandles(arrayBuffer)
7
7
  // setSize(w, h, pxRatio)
8
- // render() -> JsiSkPicture
8
+ // render() -> SkPicture (iOS) / SkImage (Android)
9
9
  //
10
10
  // The host object's lifetime is managed by JS GC — when the React component
11
11
  // drops its reference, the underlying VroomChart is destroyed.
@@ -2,6 +2,7 @@
2
2
 
3
3
  #include <algorithm>
4
4
  #include <cmath>
5
+ #include <cstddef>
5
6
  #include <cstdio>
6
7
 
7
8
  namespace vroom {
@@ -15,6 +16,38 @@ constexpr size_t kPlainCap = 340;
15
16
  constexpr char kGroupSeparator = ',';
16
17
  constexpr int kGroupSize = 3;
17
18
 
19
+ constexpr double kMillion = 1e6;
20
+ constexpr double kBillion = 1e9;
21
+ constexpr double kTrillion = 1e12;
22
+
23
+ double compact_divisor(CompactScale scale) {
24
+ switch (scale) {
25
+ case CompactScale::Million: return kMillion;
26
+ case CompactScale::Billion: return kBillion;
27
+ case CompactScale::Trillion: return kTrillion;
28
+ case CompactScale::None: return 1.0;
29
+ }
30
+ return 1.0;
31
+ }
32
+
33
+ char compact_suffix(CompactScale scale) {
34
+ switch (scale) {
35
+ case CompactScale::Million: return 'M';
36
+ case CompactScale::Billion: return 'B';
37
+ case CompactScale::Trillion: return 'T';
38
+ case CompactScale::None: return '\0';
39
+ }
40
+ return '\0';
41
+ }
42
+
43
+ CompactScale compact_scale_for(double reference) {
44
+ const double r = std::fabs(reference);
45
+ if (!std::isfinite(r) || r < kMillion) return CompactScale::None;
46
+ if (r >= kTrillion) return CompactScale::Trillion;
47
+ if (r >= kBillion) return CompactScale::Billion;
48
+ return CompactScale::Million;
49
+ }
50
+
18
51
  } // namespace
19
52
 
20
53
  int significant_decimals(double reference) {
@@ -28,7 +61,8 @@ int significant_decimals(double reference) {
28
61
  }
29
62
 
30
63
  PriceFormat price_format_for(double reference) {
31
- return PriceFormat{significant_decimals(reference), true};
64
+ return PriceFormat{significant_decimals(reference), true,
65
+ compact_scale_for(reference)};
32
66
  }
33
67
 
34
68
  int price_decimals(double interval) {
@@ -40,8 +74,13 @@ int price_decimals(double interval) {
40
74
  }
41
75
 
42
76
  PriceFormat with_tick_guard(const PriceFormat& fmt, double interval) {
43
- return PriceFormat{std::max(fmt.decimals, price_decimals(interval)),
44
- fmt.group};
77
+ const double divisor = compact_divisor(fmt.compact);
78
+ const double display_interval =
79
+ (divisor != 1.0 && interval > 0.0 && std::isfinite(interval))
80
+ ? interval / divisor
81
+ : interval;
82
+ return PriceFormat{std::max(fmt.decimals, price_decimals(display_interval)),
83
+ fmt.group, fmt.compact};
45
84
  }
46
85
 
47
86
  void format_price(char* buf, size_t buf_size, double price,
@@ -49,11 +88,23 @@ void format_price(char* buf, size_t buf_size, double price,
49
88
  if (!buf || buf_size == 0) return;
50
89
  const int decimals = std::clamp(fmt.decimals, 0, kPriceMaxDecimals);
51
90
 
91
+ const double divisor = compact_divisor(fmt.compact);
92
+ const double display = (divisor != 1.0) ? price / divisor : price;
93
+
52
94
  char plain[kPlainCap];
53
- if (std::snprintf(plain, sizeof(plain), "%.*f", decimals, price) < 0) {
95
+ if (std::snprintf(plain, sizeof(plain), "%.*f", decimals, display) < 0) {
54
96
  buf[0] = '\0';
55
97
  return;
56
98
  }
99
+
100
+ const char suffix = compact_suffix(fmt.compact);
101
+ if (suffix != '\0') {
102
+ // Compact form is already short; grouping would only fire if a tick
103
+ // wandered across a suffix boundary (1000.00M). Skip it.
104
+ std::snprintf(buf, buf_size, "%s%c", plain, suffix);
105
+ return;
106
+ }
107
+
57
108
  if (!fmt.group) {
58
109
  std::snprintf(buf, buf_size, "%s", plain);
59
110
  return;
@@ -13,6 +13,16 @@
13
13
  // 0.023397 -> 6 decimals 0.023397
14
14
  // 80285.20 -> 2 decimals 80,285.20 (floor)
15
15
  // 2513.92 -> 2 decimals 2,513.92 (floor)
16
+ // 1.59e6 -> compact 1.59M
17
+ // 3.219e7 -> compact 32.19M
18
+ // 3.454e10 -> compact 34.54B
19
+ // 1.2e12 -> compact 1.20T
20
+ //
21
+ // Past a million the integer digits eat the axis, so the format switches to a
22
+ // suffix (M / B / T) with two decimal places. The suffix is keyed off the
23
+ // asset's own price, not each tick, so every label on a market-cap chart
24
+ // shares a column and a zoomed-in 32.1M–32.2M range does not mix "32.15M"
25
+ // with "32,150,000.00".
16
26
  //
17
27
  // The reference is the asset's own price, not the visible range, so the
18
28
  // precision holds steady while the user pans and zooms. A range-derived count
@@ -32,18 +42,31 @@ namespace vroom {
32
42
  inline constexpr int kPriceSigDigits = 5;
33
43
 
34
44
  // Prices at or above ~1 unit read as currency, where fewer than two decimals
35
- // looks broken (a "$84,000" axis next to a "$80,285.20" last price).
45
+ // looks broken (a "$84,000" axis next to a "$80,285.20" last price). Compact
46
+ // suffixes use the same floor: "32.19M", not "32.2M".
36
47
  inline constexpr int kPriceMinDecimals = 2;
37
48
 
38
49
  // Past this the digits are noise, and %.*f starts printing the binary
39
50
  // representation's tail rather than anything the feed meant.
40
51
  inline constexpr int kPriceMaxDecimals = 12;
41
52
 
53
+ // Compact suffix for assets priced in the millions and up. Keyed off the
54
+ // reference, then applied to every label so the column stays uniform. Nothing
55
+ // below a million is abbreviated — no K.
56
+ enum class CompactScale {
57
+ None,
58
+ Million, // |ref| >= 1e6
59
+ Billion, // |ref| >= 1e9
60
+ Trillion, // |ref| >= 1e12
61
+ };
62
+
42
63
  // How to render a price. `group` inserts thousands separators, which only ever
43
- // affects the integer part.
64
+ // affects the integer part of a non-compact format. `compact` scales the value
65
+ // and appends M / B / T; grouping is skipped in that path.
44
66
  struct PriceFormat {
45
- int decimals = kPriceMinDecimals;
46
- bool group = true;
67
+ int decimals = kPriceMinDecimals;
68
+ bool group = true;
69
+ CompactScale compact = CompactScale::None;
47
70
  };
48
71
 
49
72
  // Decimals giving kPriceSigDigits significant digits for an asset priced around
@@ -51,7 +74,8 @@ struct PriceFormat {
51
74
  // non-finite reference falls back to the floor.
52
75
  int significant_decimals(double reference);
53
76
 
54
- // The format for an asset priced around `reference`.
77
+ // The format for an asset priced around `reference`, including the compact
78
+ // suffix when |reference| is at least a million.
55
79
  PriceFormat price_format_for(double reference);
56
80
 
57
81
  // Decimal places needed so adjacent ticks `interval` apart don't collapse to
@@ -60,6 +84,9 @@ int price_decimals(double interval);
60
84
 
61
85
  // `fmt` raised, if need be, so ticks `interval` apart stay distinct. The asset's
62
86
  // precision is the floor; only a zoom deeper than that precision adds to it.
87
+ // `interval` is in the data's units; when `fmt` is compact the guard converts
88
+ // it into display units (interval / 1e6 for millions, etc.) so a 1,000-wide
89
+ // step on a 32M chart still gets enough decimals to print as 32.192M vs 32.193M.
63
90
  PriceFormat with_tick_guard(const PriceFormat& fmt, double interval);
64
91
 
65
92
  // Writes `price` into `buf` per `fmt`, truncating rather than overrunning.