diff --git a/Cargo.lock b/Cargo.lock index 7b0ecdbf9..fff5ba6cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -221,7 +221,7 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex", + "shlex 1.3.0", "syn", ] @@ -757,14 +757,14 @@ checksum = "1c53ba0f290bfc610084c05582d9c5d421662128fc69f4bf236707af6fd321b9" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -1567,9 +1567,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1838,9 +1838,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.27" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "392c70591e8749fe235ddaf513e6f58b26bce3dcc16524cecc8936f75afa161e" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "log", @@ -1852,9 +1852,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.27" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b605b0c050d845fc355bb11eb3f9a8deddc218ea60c76e61aa1f2adfb2c96a" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", @@ -2839,6 +2839,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -3840,18 +3846,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.49" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.49" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 026d4957c..c9b89c636 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,7 +66,7 @@ corepc-types = { version = "0.14.0", features = ["std"], default-features = fals derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] } fjall = "3.1.4" indexmap = { version = "2.14.0", features = ["serde"] } -jiff = { version = "0.2.27", features = ["perf-inline", "tz-system"], default-features = false } +jiff = { version = "0.2.28", features = ["perf-inline", "tz-system"], default-features = false } owo-colors = "4.3.0" parking_lot = "0.12.5" pco = "1.0.2" diff --git a/crates/brk_computer/src/prices/compute.rs b/crates/brk_computer/src/prices/compute.rs index b427d6818..7d61b328c 100644 --- a/crates/brk_computer/src/prices/compute.rs +++ b/crates/brk_computer/src/prices/compute.rs @@ -115,7 +115,13 @@ impl Vecs { let seed_bin = cents_to_bin(prev_cents.inner() as f64); let warmup = config.window_size.min(committed - START_HEIGHT_SLOW); let mut oracle = Oracle::from_checkpoint(seed_bin, config, |o| { - Self::feed_blocks(o, indexer, (committed - warmup)..committed, None); + Self::feed_blocks_with( + o, + indexer, + (committed - warmup)..committed, + None, + |_, _, _| {}, + ); }); let num_new = total_heights - committed; diff --git a/crates/brk_oracle/examples/report.rs b/crates/brk_oracle/examples/report.rs index 8298aea80..f48a0ad51 100644 --- a/crates/brk_oracle/examples/report.rs +++ b/crates/brk_oracle/examples/report.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use brk_indexer::Indexer; use brk_oracle::{ - Config, Oracle, PRICES, HistogramRaw, START_HEIGHT_FAST, bin_to_cents, cents_to_bin, + Config, HistogramRaw, Oracle, PRICES, START_HEIGHT_FAST, bin_to_cents, cents_to_bin, eligible_bin, }; use brk_types::{OutputType, Sats, TxIndex, TxOutIndex}; diff --git a/crates/brk_oracle/src/config.rs b/crates/brk_oracle/src/config.rs index e8d799f09..cb2c286e9 100644 --- a/crates/brk_oracle/src/config.rs +++ b/crates/brk_oracle/src/config.rs @@ -1,3 +1,5 @@ +use std::ops::Range; + /// First height the oracle computes on-chain, with the slow cold-start EMA /// ([`slow`](Config::slow)). Below it, prices come from [`PRICES`](crate::PRICES). pub const START_HEIGHT_SLOW: usize = 340_000; @@ -61,4 +63,41 @@ impl Config { Self::default() } } + + /// Split a block range into sub-ranges with a single EMA configuration. + pub fn segments_for_range(range: Range) -> impl Iterator> { + let split = START_HEIGHT_FAST.clamp(range.start, range.end); + [range.start..split, split..range.end] + .into_iter() + .filter(|range| !range.is_empty()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn segments_for_range_splits_at_fast_start() { + let segments: Vec<_> = + Config::segments_for_range((START_HEIGHT_FAST - 2)..(START_HEIGHT_FAST + 2)).collect(); + assert_eq!( + segments, + vec![ + (START_HEIGHT_FAST - 2)..START_HEIGHT_FAST, + START_HEIGHT_FAST..(START_HEIGHT_FAST + 2), + ] + ); + } + + #[test] + fn segments_for_range_omits_empty_sides() { + let slow: Vec<_> = + Config::segments_for_range((START_HEIGHT_FAST - 2)..START_HEIGHT_FAST).collect(); + assert_eq!(slow, vec![(START_HEIGHT_FAST - 2)..START_HEIGHT_FAST]); + + let fast: Vec<_> = + Config::segments_for_range(START_HEIGHT_FAST..(START_HEIGHT_FAST + 2)).collect(); + assert_eq!(fast, vec![START_HEIGHT_FAST..(START_HEIGHT_FAST + 2)]); + } } diff --git a/crates/brk_oracle/src/lib.rs b/crates/brk_oracle/src/lib.rs index ac88e5e31..13e86b253 100644 --- a/crates/brk_oracle/src/lib.rs +++ b/crates/brk_oracle/src/lib.rs @@ -169,4 +169,46 @@ mod tests { assert!(switched.ema().iter().eq(fresh.ema().iter())); } + + #[test] + fn sequential_ema_matches_fresh_warmups() { + let hists: Vec = (0..80) + .map(|i| { + let mut h = HistogramRaw::zeros(); + h.increment(1000 + i % 11); + h.increment(1300 + i % 13); + h.increment(1700 + i % 17); + h + }) + .collect(); + + for config in [Config::slow(), Config::default()] { + let query_start = config.window_size + 5; + let query_end = query_start + 20; + let seed = 1600.0; + let mut sequential = Oracle::from_checkpoint(seed, config.clone(), |o| { + hists[query_start + 1 - config.window_size..query_start + 1] + .iter() + .for_each(|h| { + o.process_histogram(h); + }); + }); + + for height in query_start..query_end { + if height != query_start { + sequential.process_histogram(&hists[height]); + } + + let fresh = Oracle::from_checkpoint(seed, config.clone(), |o| { + hists[height + 1 - config.window_size..height + 1] + .iter() + .for_each(|h| { + o.process_histogram(h); + }); + }); + + assert!(sequential.ema().iter().eq(fresh.ema().iter())); + } + } + } } diff --git a/crates/brk_oracle/src/shape.rs b/crates/brk_oracle/src/shape.rs index 078eed223..e80a58c75 100644 --- a/crates/brk_oracle/src/shape.rs +++ b/crates/brk_oracle/src/shape.rs @@ -61,7 +61,11 @@ impl ShapeAnchor { /// shifts off the round-USD ladder. 0 for an empty (no-mass) center. fn shape_match(&self, ema: &HistogramEma, center: i64) -> f64 { match normalized_arms_at(ema, center) { - Some(arms) => 1.0 - (0..N_ARMS).map(|i| (arms[i] - self.profile[i]).abs()).sum::(), + Some(arms) => { + 1.0 - (0..N_ARMS) + .map(|i| (arms[i] - self.profile[i]).abs()) + .sum::() + } None => 0.0, } } diff --git a/crates/brk_query/src/impl/oracle.rs b/crates/brk_query/src/impl/oracle.rs index e8fc4acd2..120a59f52 100644 --- a/crates/brk_query/src/impl/oracle.rs +++ b/crates/brk_query/src/impl/oracle.rs @@ -4,14 +4,15 @@ use brk_computer::prices::Vecs as PricesVecs; use brk_error::{Error, Result}; use brk_indexer::Lengths; use brk_oracle::{ - Config, HistogramEma, HistogramEmaCompact, HistogramRaw, Oracle, START_HEIGHT_FAST, - cents_to_bin, sats_to_bin, + Config, HistogramEma, HistogramEmaCompact, HistogramRaw, Oracle, cents_to_bin, sats_to_bin, }; use brk_types::{Day1, Dollars, Sats, TxOutIndex}; use vecdb::{AnyVec, ReadableVec, VecIndex}; use crate::Query; +const RAW_HISTOGRAM_VALUE_CHUNK: usize = 1_000_000; + impl Query { pub fn live_price(&self) -> Result { Ok(self.live_oracle()?.price_dollars()) @@ -48,9 +49,9 @@ impl Query { let count = range.len(); let mut acc = HistogramEma::zeros(); - for segment in ema_config_segments(range) { + for segment in Config::segments_for_range(range) { let mut oracle = self.ema_oracle_at(segment.start, safe)?; - add_ema(&mut acc, oracle.ema()); + acc.add_from(oracle.ema()); let feed_start = segment.start + 1; if feed_start < segment.end { @@ -59,13 +60,12 @@ impl Query { self.indexer(), feed_start..segment.end, Some(safe), - |_, oracle, _| add_ema(&mut acc, oracle.ema()), + |_, oracle, _| acc.add_from(oracle.ema()), ); } } - let count = count as f64; - acc.iter_mut().for_each(|a| *a /= count); + acc.divide_by(count as f64); Ok(acc) } @@ -83,7 +83,7 @@ impl Query { /// in the block binned by value, with no payment filtering. pub fn confirmed_histogram_raw(&self, height: usize) -> Result { let safe = self.check_histogram_height(height)?; - Ok(self.block_raw_histogram(height, &safe)) + Ok(self.raw_histogram_for_blocks(height..height + 1, &safe)) } /// Unfiltered per-bin output counts for a calendar `day`: every block's raw @@ -92,14 +92,7 @@ impl Query { pub fn confirmed_histogram_raw_day(&self, day: Day1) -> Result { let safe = self.safe_lengths(); let range = self.day_block_range(day, &safe)?; - let mut acc = HistogramRaw::zeros(); - for height in range { - let block = self.block_raw_histogram(height, &safe); - acc.iter_mut() - .zip(block.iter()) - .for_each(|(a, &v)| *a += v); - } - Ok(acc) + Ok(self.raw_histogram_for_blocks(range, &safe)) } /// The live tip oracle: the cached committed base, with the forming block's @@ -225,49 +218,51 @@ impl Query { Ok(start..end) } - /// One confirmed block's unfiltered histogram: every output in the block, + /// Unfiltered histogram for a contiguous confirmed block range: every output, /// coinbase included, binned by value via `sats_to_bin` with no payment - /// filtering. Built from a single batched columnar read of the block's - /// output-value range. - fn block_raw_histogram(&self, height: usize, safe: &Lengths) -> HistogramRaw { + /// filtering. Raw counts are additive, so a day can be read as one output + /// range instead of one block at a time. + fn raw_histogram_for_blocks(&self, range: Range, safe: &Lengths) -> HistogramRaw { let indexer = self.indexer(); let total_outputs = safe.txout_index.to_usize(); - let next_height = (height + 2).min(safe.height.to_usize()); + let collect_end = (range.end + 1).min(safe.height.to_usize()); let out_firsts: Vec = indexer .vecs .outputs .first_txout_index - .collect_range_at(height, next_height); + .collect_range_at(range.start, collect_end); let out_start = out_firsts[0].to_usize(); let out_end = out_firsts - .get(1) + .get(range.end - range.start) .copied() .unwrap_or(TxOutIndex::from(total_outputs)) .to_usize(); let mut hist = HistogramRaw::zeros(); - let values: Vec = indexer.vecs.outputs.value.collect_range_at(out_start, out_end); - for sats in values { - if let Some(bin) = sats_to_bin(sats) { - hist.increment(bin); - } + let mut values: Vec = Vec::new(); + let mut start = out_start; + while start < out_end { + let end = (start + RAW_HISTOGRAM_VALUE_CHUNK).min(out_end); + values.clear(); + indexer + .vecs + .outputs + .value + .collect_range_into_at(start, end, &mut values); + add_sats_to_raw_histogram(&mut hist, &values); + start = end; } hist } } -fn add_ema(acc: &mut HistogramEma, ema: &HistogramEma) { - acc.iter_mut() - .zip(ema.iter()) - .for_each(|(a, &e)| *a += e); -} - -fn ema_config_segments(range: Range) -> impl Iterator> { - let split = START_HEIGHT_FAST.clamp(range.start, range.end); - [range.start..split, split..range.end] - .into_iter() - .filter(|range| !range.is_empty()) +fn add_sats_to_raw_histogram(hist: &mut HistogramRaw, values: &[Sats]) { + for &sats in values { + if let Some(bin) = sats_to_bin(sats) { + hist.increment(bin); + } + } } #[cfg(test)] @@ -275,25 +270,24 @@ mod tests { use super::*; #[test] - fn ema_config_segments_split_at_fast_start() { - let segments: Vec<_> = - ema_config_segments((START_HEIGHT_FAST - 2)..(START_HEIGHT_FAST + 2)).collect(); - assert_eq!( - segments, - vec![ - (START_HEIGHT_FAST - 2)..START_HEIGHT_FAST, - START_HEIGHT_FAST..(START_HEIGHT_FAST + 2), - ] - ); - } + fn raw_histogram_accumulation_is_additive() { + let values = [ + Sats::ZERO, + Sats::new(1), + Sats::new(10), + Sats::new(100_000_000), + Sats::new(1_000_000_000_000), + Sats::new(5_000_000_000), + ]; - #[test] - fn ema_config_segments_omits_empty_sides() { - let slow: Vec<_> = ema_config_segments((START_HEIGHT_FAST - 2)..START_HEIGHT_FAST).collect(); - assert_eq!(slow, vec![(START_HEIGHT_FAST - 2)..START_HEIGHT_FAST]); + let mut one_shot = HistogramRaw::zeros(); + add_sats_to_raw_histogram(&mut one_shot, &values); - let fast: Vec<_> = - ema_config_segments(START_HEIGHT_FAST..(START_HEIGHT_FAST + 2)).collect(); - assert_eq!(fast, vec![START_HEIGHT_FAST..(START_HEIGHT_FAST + 2)]); + let mut chunked = HistogramRaw::zeros(); + for chunk in values.chunks(2) { + add_sats_to_raw_histogram(&mut chunked, chunk); + } + + assert!(one_shot.iter().eq(chunked.iter())); } } diff --git a/crates/brk_types/src/histogram.rs b/crates/brk_types/src/histogram.rs index ed9929444..bed842f02 100644 --- a/crates/brk_types/src/histogram.rs +++ b/crates/brk_types/src/histogram.rs @@ -66,6 +66,21 @@ impl Histogram { } Histogram(out) } + + /// Add another floating-point histogram bin-by-bin. + #[inline] + pub fn add_from(&mut self, rhs: &Self) { + self.0 + .iter_mut() + .zip(rhs.0.iter()) + .for_each(|(a, &b)| *a += b); + } + + /// Divide every bin by `rhs`. + #[inline] + pub fn divide_by(&mut self, rhs: f64) { + self.0.iter_mut().for_each(|v| *v /= rhs); + } } impl Serialize for Histogram { @@ -120,3 +135,26 @@ impl JsonSchema for Histogram { true } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn float_histogram_add_and_divide_are_binwise() { + let mut a = Histogram::::zeros(); + a[0] = 1.0; + a[1] = 2.0; + a[2] = 3.0; + + let mut b = Histogram::::zeros(); + b[0] = 3.0; + b[1] = 4.0; + b[2] = 5.0; + + a.add_from(&b); + a.divide_by(2.0); + + assert_eq!(*a, [2.0, 3.0, 4.0]); + } +} diff --git a/crates/brk_types/src/lib.rs b/crates/brk_types/src/lib.rs index ec41e035c..83dae08c6 100644 --- a/crates/brk_types/src/lib.rs +++ b/crates/brk_types/src/lib.rs @@ -32,6 +32,9 @@ mod block_rewards_entry; mod block_size_entry; mod block_sizes_weights; mod block_status; +mod block_template; +mod block_template_diff; +mod block_template_diff_entry; mod block_timestamp; mod block_tx_index; mod block_weight_entry; @@ -81,9 +84,6 @@ mod hour4; mod index; mod index_info; mod limit; -mod block_template; -mod block_template_diff; -mod block_template_diff_entry; mod mempool_block; mod mempool_entry_info; mod mempool_info; diff --git a/crates/brk_types/src/urpd_aggregation.rs b/crates/brk_types/src/urpd_aggregation.rs index 802c53b29..7ae320585 100644 --- a/crates/brk_types/src/urpd_aggregation.rs +++ b/crates/brk_types/src/urpd_aggregation.rs @@ -59,7 +59,12 @@ impl UrpdAggregation { let size = self.linear_size_cents().unwrap(); (price_cents / size) * size } - Self::Log10 | Self::Log50 | Self::Log100 | Self::Log200 | Self::Log500 | Self::Log1000 => { + Self::Log10 + | Self::Log50 + | Self::Log100 + | Self::Log200 + | Self::Log500 + | Self::Log1000 => { if price_cents == Cents::ZERO { return Cents::ZERO; } diff --git a/website/scripts/options/partial.js b/website/scripts/options/partial.js index 830aa7718..c30e574c9 100644 --- a/website/scripts/options/partial.js +++ b/website/scripts/options/partial.js @@ -306,7 +306,7 @@ export function createPartialOptions() { tree: [ demoHeatmapOption, { - name: "Oracle", + name: "output values", tree: [oracleRawHeatmapOption, oracleEmaHeatmapOption], }, ], diff --git a/website/src/heatmap/index.js b/website/src/heatmap/index.js index 475e70ebc..32b2b0e7d 100644 --- a/website/src/heatmap/index.js +++ b/website/src/heatmap/index.js @@ -3,6 +3,7 @@ import { createHeader, createSelect } from "../../scripts/utils/dom.js"; import { heatmapElement } from "../../scripts/utils/elements.js"; +import { createPersistedValue } from "../../scripts/utils/persisted.js"; import { debounce, next } from "../../scripts/utils/timing.js"; import { dark, onChange as onThemeChange } from "../../scripts/utils/theme.js"; import { createRenderer } from "./renderer.js"; @@ -87,7 +88,9 @@ export function setOption(option) { if (currentOption !== option) { currentOption = option; pointsByDate = new Map(); + updateDateControls(option); updateYControls(option); + renderRangeControls(); if (headingElement) headingElement.textContent = option.title; if (canvas) canvas.removeAttribute("title"); } @@ -331,11 +334,49 @@ function createRangeControls() { statusElement = document.createElement("small"); + return fieldset; +} + +/** @param {HeatmapOption} option */ +function updateDateControls(option) { const currentYear = new Date().getUTCFullYear(); const fromChoices = createFromChoices(currentYear); const toChoices = createToChoices(currentYear); - let fromChoice = fromChoices.at(-1) ?? fromChoices[0]; - let toChoice = toChoices[0]; + const defaultFromChoice = fromChoices.at(-1) ?? fromChoices[0]; + const defaultToChoice = toChoices[0]; + + const persistedFrom = createHeatmapPersistedValue( + option, + "from", + "hm_from", + rangeChoiceLabel(defaultFromChoice), + ); + const persistedTo = createHeatmapPersistedValue( + option, + "to", + "hm_to", + rangeChoiceLabel(defaultToChoice), + ); + + let fromChoice = findChoiceByKey( + fromChoices, + persistedFrom.value, + defaultFromChoice, + rangeChoiceLabel, + ); + let toChoice = findChoiceByKey( + toChoices, + persistedTo.value, + defaultToChoice, + rangeChoiceLabel, + ); + + if (fromChoice.date > toChoice.date) { + toChoice = findSameLabelChoice(toChoices, fromChoice, defaultToChoice); + } + from = fromChoice.date; + to = toChoice.date; + persistDateChoices(); const fromSelect = createSelect({ id: "heatmap-from", @@ -345,9 +386,10 @@ function createRangeControls() { onChange(choice) { fromChoice = choice; if (fromChoice.date > toChoice.date) { - toChoice = findSameLabelChoice(toChoices, fromChoice, toChoices[0]); + toChoice = findSameLabelChoice(toChoices, fromChoice, defaultToChoice); toSelect.set(toChoice); } + persistDateChoices(); setRange(fromChoice.date, toChoice.date); }, toKey: rangeChoiceLabel, @@ -361,9 +403,10 @@ function createRangeControls() { onChange(choice) { toChoice = choice; if (fromChoice.date > toChoice.date) { - fromChoice = findSameLabelChoice(fromChoices, toChoice, fromChoices[0]); + fromChoice = findSameLabelChoice(fromChoices, toChoice, defaultFromChoice); fromSelect.set(fromChoice); } + persistDateChoices(); setRange(fromChoice.date, toChoice.date); }, toKey: rangeChoiceLabel, @@ -371,9 +414,11 @@ function createRangeControls() { }); dateControlElements = [fromSelect.element, toSelect.element]; - renderRangeControls(); - return fieldset; + function persistDateChoices() { + persistedFrom.setImmediate(rangeChoiceLabel(fromChoice)); + persistedTo.setImmediate(rangeChoiceLabel(toChoice)); + } } /** @param {HeatmapOption} option */ @@ -384,14 +429,42 @@ function updateYControls(option) { yMin = undefined; yMax = undefined; yControlElements = []; - renderRangeControls(); return; } - let minChoice = choices[0]; - let maxChoice = choices.at(-1) ?? choices[0]; + const defaultMinChoice = choices[0]; + const defaultMaxChoice = choices.at(-1) ?? choices[0]; + const persistedMin = createHeatmapPersistedValue( + option, + "y-min", + "hm_y_min", + axisChoiceKey(defaultMinChoice), + ); + const persistedMax = createHeatmapPersistedValue( + option, + "y-max", + "hm_y_max", + axisChoiceKey(defaultMaxChoice), + ); + + let minChoice = findChoiceByKey( + choices, + persistedMin.value, + defaultMinChoice, + axisChoiceKey, + ); + let maxChoice = findChoiceByKey( + choices, + persistedMax.value, + defaultMaxChoice, + axisChoiceKey, + ); + if (minChoice.value > maxChoice.value) { + maxChoice = minChoice; + } yMin = minChoice.value; yMax = maxChoice.value; + persistYChoices(); const minSelect = createSelect({ id: "heatmap-y-min", @@ -404,6 +477,7 @@ function updateYControls(option) { maxChoice = minChoice; maxSelect.set(maxChoice); } + persistYChoices(); setYRange(minChoice.value, maxChoice.value); }, toKey: axisChoiceKey, @@ -420,6 +494,7 @@ function updateYControls(option) { minChoice = maxChoice; minSelect.set(minChoice); } + persistYChoices(); setYRange(minChoice.value, maxChoice.value); }, toKey: axisChoiceKey, @@ -427,7 +502,11 @@ function updateYControls(option) { }); yControlElements = [minSelect.element, maxSelect.element]; - renderRangeControls(); + + function persistYChoices() { + persistedMin.setImmediate(axisChoiceKey(minChoice)); + persistedMax.setImmediate(axisChoiceKey(maxChoice)); + } } function renderRangeControls() { @@ -513,6 +592,38 @@ function axisChoiceLabel(choice) { return choice.label; } +/** @param {HeatmapOption} option */ +function heatmapStoragePrefix(option) { + return `heatmap-${option.path.join("-")}`; +} + +/** + * @param {HeatmapOption} option + * @param {string} key + * @param {string} urlKey + * @param {string} defaultValue + */ +function createHeatmapPersistedValue(option, key, urlKey, defaultValue) { + return createPersistedValue({ + defaultValue, + storageKey: `${heatmapStoragePrefix(option)}-${key}`, + urlKey, + serialize: (value) => value, + deserialize: (value) => value, + }); +} + +/** + * @template T + * @param {readonly T[]} choices + * @param {string} key + * @param {T} fallback + * @param {(choice: T) => string} toKey + */ +function findChoiceByKey(choices, key, fallback, toKey) { + return choices.find((candidate) => toKey(candidate) === key) ?? fallback; +} + /** @param {number} year */ function yearStartISODate(year) { return toISODate(new Date(Date.UTC(year, 0, 1))); diff --git a/website/src/heatmap/oracle.js b/website/src/heatmap/oracle.js index b4667e805..e1bce9265 100644 --- a/website/src/heatmap/oracle.js +++ b/website/src/heatmap/oracle.js @@ -25,8 +25,11 @@ const AMOUNT_CHOICES = [ { label: "10k BTC", value: 4 }, ]; -export const oracleRawHeatmapOption = createOracleHeatmapOption("raw", "Raw"); -export const oracleEmaHeatmapOption = createOracleHeatmapOption("ema", "EMA"); +export const oracleRawHeatmapOption = createOracleHeatmapOption("raw", "raw"); +export const oracleEmaHeatmapOption = createOracleHeatmapOption( + "ema", + "smoothed", +); /** * @param {"raw" | "ema"} mode @@ -37,7 +40,7 @@ function createOracleHeatmapOption(mode, name) { return { kind: "heatmap", name, - title: `Oracle ${name} Histogram`, + title: `${capitalize(name)} Output Value Distribution`, points: { fetch: (date, signal, onPoints) => fetchOraclePoints(mode, date, signal, onPoints), @@ -135,3 +138,8 @@ function formatNumber(value) { function trimNumber(value) { return value.replace(/\.?0+$/, ""); } + +/** @param {string} value */ +function capitalize(value) { + return value.charAt(0).toUpperCase() + value.slice(1); +}