global: snapshot

This commit is contained in:
nym21
2026-01-14 20:09:51 +01:00
parent d75c2a881b
commit 1c7434ff83
25 changed files with 4059 additions and 22606 deletions
+12
View File
@@ -120,6 +120,18 @@ pub fn find_common_suffix(names: &[&str]) -> Option<String> {
None
}
/// Normalize a prefix string by ensuring it ends with underscore.
/// Returns empty string if input is empty.
pub fn normalize_prefix(s: &str) -> String {
if s.is_empty() {
String::new()
} else if s.ends_with('_') {
s.to_string()
} else {
format!("{}_", s)
}
}
#[cfg(test)]
mod tests {
use super::*;
+7 -4
View File
@@ -8,7 +8,7 @@ use std::collections::{BTreeSet, HashMap};
use brk_types::{TreeNode, extract_json_type};
use super::analyze_pattern_modes;
use crate::{PatternField, StructuralPattern, to_pascal_case};
use crate::{PatternBaseResult, PatternField, StructuralPattern, to_pascal_case};
/// Context for pattern detection, holding all intermediate state.
struct PatternContext {
@@ -38,14 +38,16 @@ impl PatternContext {
/// Detect structural patterns in the tree using a bottom-up approach.
///
/// Returns (patterns, concrete_to_pattern, concrete_to_type_param).
/// Returns (patterns, concrete_to_pattern, concrete_to_type_param, node_bases).
/// Each pattern has its `mode` set based on analysis of all instances.
/// `node_bases` maps tree paths to their computed PatternBaseResult for use during generation.
pub fn detect_structural_patterns(
tree: &TreeNode,
) -> (
Vec<StructuralPattern>,
HashMap<Vec<PatternField>, String>,
HashMap<Vec<PatternField>, String>,
HashMap<String, PatternBaseResult>,
) {
let mut ctx = PatternContext::new();
resolve_branch_patterns(tree, "root", &mut ctx);
@@ -99,10 +101,11 @@ pub fn detect_structural_patterns(
let concrete_to_pattern = pattern_lookup.clone();
// Analyze pattern modes (suffix vs prefix) from all instances
analyze_pattern_modes(tree, &mut patterns, &pattern_lookup);
// Also collects node bases for each tree path
let node_bases = analyze_pattern_modes(tree, &mut patterns, &pattern_lookup);
patterns.sort_by(|a, b| b.fields.len().cmp(&a.fields.len()));
(patterns, concrete_to_pattern, type_mappings)
(patterns, concrete_to_pattern, type_mappings, node_bases)
}
/// Detect generic patterns by grouping signatures by their normalized form.
+53 -25
View File
@@ -8,8 +8,8 @@ use std::collections::HashMap;
use brk_types::TreeNode;
use super::{find_common_prefix, find_common_suffix, get_node_fields};
use crate::{PatternField, PatternMode, StructuralPattern};
use super::{find_common_prefix, find_common_suffix, get_node_fields, normalize_prefix};
use crate::{PatternBaseResult, PatternField, PatternMode, StructuralPattern, build_child_path};
/// Result of analyzing a single pattern instance.
#[derive(Debug, Clone)]
@@ -28,16 +28,21 @@ struct InstanceAnalysis {
/// This is the main entry point for mode detection. It processes
/// the tree bottom-up, collecting analysis for each pattern instance,
/// then determines the consistent mode for each pattern.
///
/// Returns a map from tree paths to their computed PatternBaseResult.
/// This map is used during generation to check pattern compatibility.
pub fn analyze_pattern_modes(
tree: &TreeNode,
patterns: &mut [StructuralPattern],
pattern_lookup: &HashMap<Vec<PatternField>, String>,
) {
) -> HashMap<String, PatternBaseResult> {
// Collect analyses from all instances, keyed by pattern name
let mut all_analyses: HashMap<String, Vec<InstanceAnalysis>> = HashMap::new();
// Also collect base results for each node, keyed by tree path
let mut node_bases: HashMap<String, PatternBaseResult> = HashMap::new();
// Bottom-up traversal
collect_instance_analyses(tree, pattern_lookup, &mut all_analyses);
collect_instance_analyses(tree, "", pattern_lookup, &mut all_analyses, &mut node_bases);
// For each pattern, determine mode from collected instances
for pattern in patterns.iter_mut() {
@@ -45,14 +50,20 @@ pub fn analyze_pattern_modes(
pattern.mode = determine_pattern_mode(analyses, &pattern.fields);
}
}
node_bases
}
/// Recursively collect instance analyses bottom-up.
/// Returns the "base" for this node (used by parent for its analysis).
///
/// Also stores the PatternBaseResult for each node in `node_bases`, keyed by path.
fn collect_instance_analyses(
node: &TreeNode,
path: &str,
pattern_lookup: &HashMap<Vec<PatternField>, String>,
all_analyses: &mut HashMap<String, Vec<InstanceAnalysis>>,
node_bases: &mut HashMap<String, PatternBaseResult>,
) -> Option<String> {
match node {
TreeNode::Leaf(leaf) => {
@@ -63,9 +74,14 @@ fn collect_instance_analyses(
// First, process all children recursively (bottom-up)
let mut child_bases: HashMap<String, String> = HashMap::new();
for (field_name, child_node) in children {
if let Some(base) =
collect_instance_analyses(child_node, pattern_lookup, all_analyses)
{
let child_path = build_child_path(path, field_name);
if let Some(base) = collect_instance_analyses(
child_node,
&child_path,
pattern_lookup,
all_analyses,
node_bases,
) {
child_bases.insert(field_name.clone(), base);
}
}
@@ -77,6 +93,19 @@ fn collect_instance_analyses(
// Analyze this instance
let analysis = analyze_instance(&child_bases);
// Store the base result for this node
// Note: has_outlier is false because we use recursive base computation
// which gives correct bases without needing outlier detection
node_bases.insert(
path.to_string(),
PatternBaseResult {
base: analysis.base.clone(),
has_outlier: false,
is_suffix_mode: analysis.is_suffix_mode,
field_parts: analysis.field_parts.clone(),
},
);
// Get the pattern name for this node (if any)
let fields = get_node_fields(children, pattern_lookup);
if let Some(pattern_name) = pattern_lookup.get(&fields) {
@@ -128,19 +157,10 @@ fn analyze_instance(child_bases: &HashMap<String, String>) -> InstanceAnalysis {
let mut field_parts = HashMap::new();
for (field_name, child_base) in child_bases {
// Prefix = child_base with common suffix stripped
// Prefix = child_base with common suffix stripped, normalized to end with _
let prefix = child_base
.strip_suffix(&common_suffix)
.map(|s| {
// Ensure prefix ends with underscore if non-empty
if s.is_empty() {
String::new()
} else if s.ends_with('_') {
s.to_string()
} else {
format!("{}_", s)
}
})
.map(normalize_prefix)
.unwrap_or_default();
field_parts.insert(field_name.clone(), prefix);
}
@@ -152,16 +172,16 @@ fn analyze_instance(child_bases: &HashMap<String, String>) -> InstanceAnalysis {
};
}
// No common prefix or suffix - use first child's base and treat as suffix mode
// with full metric names as relatives
let base = child_bases.values().next().cloned().unwrap_or_default();
// No common prefix or suffix - use empty base so _m(base, relative) returns just the relative.
// This handles cases like utxo_cohorts.all.activity where children have completely
// different bases (coinblocks_destroyed, coindays_destroyed, etc.)
let field_parts = child_bases
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
InstanceAnalysis {
base,
base: String::new(),
field_parts,
is_suffix_mode: true,
}
@@ -197,7 +217,9 @@ fn determine_pattern_mode(
// Convert to sorted Vec for comparison since HashMap isn't hashable
let mut parts_counts: HashMap<Vec<(String, String)>, usize> = HashMap::new();
for analysis in &majority_instances {
let mut sorted: Vec<_> = analysis.field_parts.iter()
let mut sorted: Vec<_> = analysis
.field_parts
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
sorted.sort();
@@ -244,7 +266,10 @@ mod tests {
assert_eq!(analysis.base, "lth_cost_basis");
assert_eq!(analysis.field_parts.get("max"), Some(&"max".to_string()));
assert_eq!(analysis.field_parts.get("min"), Some(&"min".to_string()));
assert_eq!(analysis.field_parts.get("percentiles"), Some(&"".to_string()));
assert_eq!(
analysis.field_parts.get("percentiles"),
Some(&"".to_string())
);
}
#[test]
@@ -280,7 +305,10 @@ mod tests {
assert_eq!(analysis.base, "cost_basis");
assert_eq!(analysis.field_parts.get("max"), Some(&"max".to_string()));
assert_eq!(analysis.field_parts.get("min"), Some(&"min".to_string()));
assert_eq!(analysis.field_parts.get("percentiles"), Some(&"".to_string()));
assert_eq!(
analysis.field_parts.get("percentiles"),
Some(&"".to_string())
);
}
#[test]
+77 -43
View File
@@ -9,15 +9,7 @@ use brk_types::{Index, TreeNode, extract_json_type};
use crate::{IndexSetPattern, PatternField, child_type_name};
use super::{find_common_prefix, find_common_suffix};
/// Get the first leaf name from a tree node.
pub fn get_first_leaf_name(node: &TreeNode) -> Option<String> {
match node {
TreeNode::Leaf(leaf) => Some(leaf.name().to_string()),
TreeNode::Branch(children) => children.values().find_map(get_first_leaf_name),
}
}
use super::{find_common_prefix, find_common_suffix, normalize_prefix};
/// Get the shortest leaf name from a tree node.
///
@@ -128,6 +120,30 @@ pub struct PatternBaseResult {
pub field_parts: HashMap<String, String>,
}
impl PatternBaseResult {
/// Create a default result that forces inlining (has_outlier = true).
/// Use when no pattern base could be computed during lookup.
pub fn force_inline() -> Self {
Self {
base: String::new(),
has_outlier: true,
is_suffix_mode: true,
field_parts: HashMap::new(),
}
}
/// Create an empty result with no outlier.
/// Use for root-level patterns or when children have no common pattern.
pub fn empty() -> Self {
Self {
base: String::new(),
has_outlier: false,
is_suffix_mode: true,
field_parts: HashMap::new(),
}
}
}
/// Get the metric base for a pattern instance by analyzing direct children.
///
/// Uses the shortest leaf names from direct children to find common prefix/suffix.
@@ -140,12 +156,7 @@ pub struct PatternBaseResult {
pub fn get_pattern_instance_base(node: &TreeNode) -> PatternBaseResult {
let child_names = get_direct_children_for_analysis(node);
if child_names.is_empty() {
return PatternBaseResult {
base: String::new(),
has_outlier: false,
is_suffix_mode: true, // default
field_parts: HashMap::new(),
};
return PatternBaseResult::empty();
}
// Try to find common base from leaf names
@@ -181,12 +192,7 @@ pub fn get_pattern_instance_base(node: &TreeNode) -> PatternBaseResult {
// Fallback: no common prefix/suffix found - this is a root-level pattern
// Return empty base so metric names are used directly
PatternBaseResult {
base: String::new(),
has_outlier: false,
is_suffix_mode: true, // default
field_parts: HashMap::new(),
}
PatternBaseResult::empty()
}
/// Result of try_find_base: base name, has_outlier flag, is_suffix_mode flag, and field_parts.
@@ -199,7 +205,10 @@ struct FindBaseResult {
/// Try to find a common base from child names using prefix/suffix detection.
/// Returns Some(FindBaseResult) if found.
fn try_find_base(child_names: &[(String, String)], is_outlier_attempt: bool) -> Option<FindBaseResult> {
fn try_find_base(
child_names: &[(String, String)],
is_outlier_attempt: bool,
) -> Option<FindBaseResult> {
let leaf_names: Vec<&str> = child_names.iter().map(|(_, n)| n.as_str()).collect();
// Try common prefix first (suffix mode)
@@ -231,18 +240,10 @@ fn try_find_base(child_names: &[(String, String)], is_outlier_attempt: bool) ->
let base = suffix.trim_start_matches('_').to_string();
let mut field_parts = HashMap::new();
for (field_name, leaf_name) in child_names {
// Compute the prefix part for this field
// Compute the prefix part for this field, normalized to end with _
let prefix_part = leaf_name
.strip_suffix(&suffix)
.map(|s| {
if s.is_empty() {
String::new()
} else if s.ends_with('_') {
s.to_string()
} else {
format!("{}_", s)
}
})
.map(normalize_prefix)
.unwrap_or_default();
field_parts.insert(field_name.clone(), prefix_part);
}
@@ -366,9 +367,18 @@ mod tests {
fn test_get_pattern_instance_base_with_base_field() {
// Simulates vbytes tree: has base field with block_vbytes leaf
let tree = make_branch(vec![
("base", make_branch(vec![("dateindex", make_leaf("block_vbytes"))])),
("average", make_branch(vec![("dateindex", make_leaf("block_vbytes_average"))])),
("sum", make_branch(vec![("dateindex", make_leaf("block_vbytes_sum"))])),
(
"base",
make_branch(vec![("dateindex", make_leaf("block_vbytes"))]),
),
(
"average",
make_branch(vec![("dateindex", make_leaf("block_vbytes_average"))]),
),
(
"sum",
make_branch(vec![("dateindex", make_leaf("block_vbytes_sum"))]),
),
]);
let result = get_pattern_instance_base(&tree);
@@ -380,11 +390,26 @@ mod tests {
fn test_get_pattern_instance_base_without_base_field() {
// Simulates weight tree: NO base field, only suffixed metrics
let tree = make_branch(vec![
("average", make_branch(vec![("dateindex", make_leaf("block_weight_average"))])),
("sum", make_branch(vec![("dateindex", make_leaf("block_weight_sum"))])),
("cumulative", make_branch(vec![("dateindex", make_leaf("block_weight_cumulative"))])),
("max", make_branch(vec![("dateindex", make_leaf("block_weight_max"))])),
("min", make_branch(vec![("dateindex", make_leaf("block_weight_min"))])),
(
"average",
make_branch(vec![("dateindex", make_leaf("block_weight_average"))]),
),
(
"sum",
make_branch(vec![("dateindex", make_leaf("block_weight_sum"))]),
),
(
"cumulative",
make_branch(vec![("dateindex", make_leaf("block_weight_cumulative"))]),
),
(
"max",
make_branch(vec![("dateindex", make_leaf("block_weight_max"))]),
),
(
"min",
make_branch(vec![("dateindex", make_leaf("block_weight_min"))]),
),
]);
let result = get_pattern_instance_base(&tree);
@@ -397,9 +422,18 @@ mod tests {
// What if there's a "base" field that points to the same leaf as "average"?
// This could happen if the tree generation creates a base field that shares leaves with average
let tree = make_branch(vec![
("base", make_branch(vec![("dateindex", make_leaf("block_weight_average"))])),
("average", make_branch(vec![("dateindex", make_leaf("block_weight_average"))])),
("sum", make_branch(vec![("dateindex", make_leaf("block_weight_sum"))])),
(
"base",
make_branch(vec![("dateindex", make_leaf("block_weight_average"))]),
),
(
"average",
make_branch(vec![("dateindex", make_leaf("block_weight_average"))]),
),
(
"sum",
make_branch(vec![("dateindex", make_leaf("block_weight_sum"))]),
),
]);
let result = get_pattern_instance_base(&tree);
+29 -6
View File
@@ -4,10 +4,18 @@ use std::collections::{HashMap, HashSet};
use brk_types::TreeNode;
use crate::{
ClientMetadata, PatternBaseResult, PatternField, child_type_name, get_fields_with_child_info,
get_pattern_instance_base,
};
use crate::{ClientMetadata, PatternBaseResult, PatternField, child_type_name, get_fields_with_child_info};
/// Build a child path by appending a child name to a parent path.
/// Uses "/" as separator. If parent is empty, returns just the child name.
#[inline]
pub fn build_child_path(parent: &str, child: &str) -> String {
if parent.is_empty() {
child.to_string()
} else {
format!("{}/{}", parent, child)
}
}
/// Pre-computed context for a single child node.
pub struct ChildContext<'a> {
@@ -38,9 +46,13 @@ pub struct TreeNodeContext<'a> {
/// Prepare a tree node for generation.
/// Returns None if the node should be skipped (not a branch, already generated,
/// or matches a parameterizable pattern).
///
/// The `path` parameter is the tree path to this node (e.g., "distribution/utxoCohorts").
/// It's used to look up pre-computed PatternBaseResult from the analysis phase.
pub fn prepare_tree_node<'a>(
node: &'a TreeNode,
name: &str,
path: &str,
pattern_lookup: &HashMap<Vec<PatternField>, String>,
metadata: &ClientMetadata,
generated: &mut HashSet<String>,
@@ -55,8 +67,13 @@ pub fn prepare_tree_node<'a>(
.map(|(f, _)| f.clone())
.collect();
// Look up the pre-computed base result, or use a default that forces inlining
let base_result = metadata
.get_node_base(path)
.cloned()
.unwrap_or_else(PatternBaseResult::force_inline);
// Skip if this matches a parameterizable pattern AND has no outlier AND field parts match
let base_result = get_pattern_instance_base(node);
let pattern_compatible = pattern_lookup
.get(&fields)
.and_then(|name| metadata.find_pattern(name))
@@ -85,7 +102,13 @@ pub fn prepare_tree_node<'a>(
.zip(fields_with_child_info)
.map(|((child_name, child_node), (field, child_fields))| {
let is_leaf = matches!(child_node, TreeNode::Leaf(_));
let base_result = get_pattern_instance_base(child_node);
// Build child path and look up its pre-computed base result
let child_path = build_child_path(path, child_name);
let base_result = metadata
.get_node_base(&child_path)
.cloned()
.unwrap_or_else(PatternBaseResult::force_inline);
// For type annotations: use pattern type if ANY pattern matches
let matches_any_pattern = child_fields
@@ -6,8 +6,8 @@ use std::fmt::Write;
use brk_types::TreeNode;
use crate::{
ClientMetadata, Endpoint, GenericSyntax, JavaScriptSyntax, PatternField, generate_leaf_field,
prepare_tree_node, to_camel_case,
ClientMetadata, Endpoint, GenericSyntax, JavaScriptSyntax, PatternField, build_child_path,
generate_leaf_field, prepare_tree_node, to_camel_case,
};
use super::api::generate_api_methods;
@@ -22,6 +22,7 @@ pub fn generate_tree_typedefs(output: &mut String, catalog: &TreeNode, metadata:
generate_tree_typedef(
output,
"MetricsTree",
"",
catalog,
&pattern_lookup,
metadata,
@@ -32,12 +33,13 @@ pub fn generate_tree_typedefs(output: &mut String, catalog: &TreeNode, metadata:
fn generate_tree_typedef(
output: &mut String,
name: &str,
path: &str,
node: &TreeNode,
pattern_lookup: &std::collections::HashMap<Vec<PatternField>, String>,
metadata: &ClientMetadata,
generated: &mut HashSet<String>,
) {
let Some(ctx) = prepare_tree_node(node, name, pattern_lookup, metadata, generated) else {
let Some(ctx) = prepare_tree_node(node, name, path, pattern_lookup, metadata, generated) else {
return;
};
@@ -71,9 +73,11 @@ fn generate_tree_typedef(
// Generate child typedefs
for child in &ctx.children {
if child.should_inline {
let child_path = build_child_path(path, child.name);
generate_tree_typedef(
output,
&child.inline_type_name,
&child_path,
child.node,
pattern_lookup,
metadata,
@@ -125,6 +129,7 @@ pub fn generate_main_client(
output,
catalog,
"MetricsTree",
"",
3,
&pattern_lookup,
metadata,
@@ -166,10 +171,12 @@ pub fn generate_main_client(
writeln!(output, "export {{ BrkClient, BrkError }};").unwrap();
}
#[allow(clippy::too_many_arguments)]
fn generate_tree_initializer(
output: &mut String,
node: &TreeNode,
name: &str,
path: &str,
indent: usize,
pattern_lookup: &std::collections::HashMap<Vec<PatternField>, String>,
metadata: &ClientMetadata,
@@ -177,7 +184,7 @@ fn generate_tree_initializer(
) {
let indent_str = " ".repeat(indent);
let Some(ctx) = prepare_tree_node(node, name, pattern_lookup, metadata, generated) else {
let Some(ctx) = prepare_tree_node(node, name, path, pattern_lookup, metadata, generated) else {
return;
};
@@ -199,11 +206,13 @@ fn generate_tree_initializer(
}
} else if child.should_inline {
// Inline object
let child_path = build_child_path(path, child.name);
writeln!(output, "{}{}: {{", indent_str, field_name).unwrap();
generate_tree_initializer(
output,
child.node,
&child.inline_type_name,
&child_path,
indent + 1,
pattern_lookup,
metadata,
@@ -6,8 +6,8 @@ use std::fmt::Write;
use brk_types::TreeNode;
use crate::{
ClientMetadata, GenericSyntax, PatternField, PythonSyntax, generate_leaf_field,
prepare_tree_node, to_snake_case,
ClientMetadata, GenericSyntax, PatternField, PythonSyntax, build_child_path,
generate_leaf_field, prepare_tree_node, to_snake_case,
};
/// Generate tree classes
@@ -19,6 +19,7 @@ pub fn generate_tree_classes(output: &mut String, catalog: &TreeNode, metadata:
generate_tree_class(
output,
"MetricsTree",
"",
catalog,
&pattern_lookup,
metadata,
@@ -30,12 +31,13 @@ pub fn generate_tree_classes(output: &mut String, catalog: &TreeNode, metadata:
fn generate_tree_class(
output: &mut String,
name: &str,
path: &str,
node: &TreeNode,
pattern_lookup: &std::collections::HashMap<Vec<PatternField>, String>,
metadata: &ClientMetadata,
generated: &mut HashSet<String>,
) {
let Some(ctx) = prepare_tree_node(node, name, pattern_lookup, metadata, generated) else {
let Some(ctx) = prepare_tree_node(node, name, path, pattern_lookup, metadata, generated) else {
return;
};
@@ -43,9 +45,11 @@ fn generate_tree_class(
// This ensures children are defined before parent references them
for child in &ctx.children {
if child.should_inline {
let child_path = build_child_path(path, child.name);
generate_tree_class(
output,
&child.inline_type_name,
&child_path,
child.node,
pattern_lookup,
metadata,
@@ -6,7 +6,7 @@ use std::fmt::Write;
use brk_types::TreeNode;
use crate::{
ClientMetadata, GenericSyntax, LanguageSyntax, PatternField, RustSyntax,
ClientMetadata, GenericSyntax, LanguageSyntax, PatternField, RustSyntax, build_child_path,
generate_leaf_field, generate_tree_node_field, prepare_tree_node, to_snake_case,
};
@@ -19,6 +19,7 @@ pub fn generate_tree(output: &mut String, catalog: &TreeNode, metadata: &ClientM
generate_tree_node(
output,
"MetricsTree",
"",
catalog,
&pattern_lookup,
metadata,
@@ -29,12 +30,13 @@ pub fn generate_tree(output: &mut String, catalog: &TreeNode, metadata: &ClientM
fn generate_tree_node(
output: &mut String,
name: &str,
path: &str,
node: &TreeNode,
pattern_lookup: &std::collections::HashMap<Vec<PatternField>, String>,
metadata: &ClientMetadata,
generated: &mut HashSet<String>,
) {
let Some(ctx) = prepare_tree_node(node, name, pattern_lookup, metadata, generated) else {
let Some(ctx) = prepare_tree_node(node, name, path, pattern_lookup, metadata, generated) else {
return;
};
@@ -117,9 +119,11 @@ fn generate_tree_node(
// Generate child structs
for child in &ctx.children {
if child.should_inline {
let child_path = build_child_path(path, child.name);
generate_tree_node(
output,
&child.inline_type_name,
&child_path,
child.node,
pattern_lookup,
metadata,
+10 -2
View File
@@ -6,7 +6,7 @@ use brk_query::Vecs;
use brk_types::{Index, MetricLeafWithSchema};
use super::{GenericSyntax, IndexSetPattern, PatternField, StructuralPattern, extract_inner_type};
use crate::analysis;
use crate::{PatternBaseResult, analysis};
/// Metadata extracted from brk_query for client generation.
#[derive(Debug)]
@@ -21,6 +21,8 @@ pub struct ClientMetadata {
concrete_to_pattern: HashMap<Vec<PatternField>, String>,
/// Maps concrete field signatures to their type parameter (for generic patterns)
concrete_to_type_param: HashMap<Vec<PatternField>, String>,
/// Maps tree paths to their computed PatternBaseResult
node_bases: HashMap<String, PatternBaseResult>,
}
impl ClientMetadata {
@@ -31,7 +33,7 @@ impl ClientMetadata {
/// Extract metadata from a catalog TreeNode directly.
pub fn from_catalog(catalog: brk_types::TreeNode) -> Self {
let (structural_patterns, concrete_to_pattern, concrete_to_type_param) =
let (structural_patterns, concrete_to_pattern, concrete_to_type_param, node_bases) =
analysis::detect_structural_patterns(&catalog);
let index_set_patterns = analysis::detect_index_patterns(&catalog);
@@ -41,6 +43,7 @@ impl ClientMetadata {
index_set_patterns,
concrete_to_pattern,
concrete_to_type_param,
node_bases,
}
}
@@ -139,6 +142,11 @@ impl ClientMetadata {
lookup
}
/// Get the pre-computed PatternBaseResult for a tree path.
pub fn get_node_base(&self, path: &str) -> Option<&PatternBaseResult> {
self.node_bases.get(path)
}
/// Generate type annotation for a field with language-specific syntax.
pub fn field_type_annotation(
&self,
+59 -29
View File
@@ -79,7 +79,7 @@ fn test_all_leaves_have_names() {
fn test_pattern_detection() {
let catalog = load_catalog();
let (patterns, concrete_to_pattern, concrete_to_type_param) =
let (patterns, concrete_to_pattern, concrete_to_type_param, _node_bases) =
brk_bindgen::detect_structural_patterns(&catalog);
println!("Detected {} structural patterns", patterns.len());
@@ -142,7 +142,7 @@ fn test_pattern_detection() {
fn test_cost_basis_pattern() {
let catalog = load_catalog();
let (patterns, _, _) = brk_bindgen::detect_structural_patterns(&catalog);
let (patterns, _, _, _) = brk_bindgen::detect_structural_patterns(&catalog);
// Find CostBasisPattern2 and inspect it
let cost_basis = patterns
@@ -211,7 +211,7 @@ fn test_realized_pattern3_fields() {
#[test]
fn test_parameterizable_patterns_have_mode() {
let catalog = load_catalog();
let (patterns, _, _) = brk_bindgen::detect_structural_patterns(&catalog);
let (patterns, _, _, _) = brk_bindgen::detect_structural_patterns(&catalog);
// All patterns that appear 2+ times should either:
// 1. Be parameterizable (have a mode)
@@ -272,7 +272,7 @@ fn test_parameterizable_patterns_have_mode() {
#[test]
fn test_fee_rate_pattern_relatives() {
let catalog = load_catalog();
let (patterns, _, _) = brk_bindgen::detect_structural_patterns(&catalog);
let (patterns, _, _, _) = brk_bindgen::detect_structural_patterns(&catalog);
let fee_rate_pattern = patterns
.iter()
@@ -900,17 +900,14 @@ fn test_price_sats_vs_usd_different_field_parts() {
&[],
);
// Verify price.sats uses sats-suffixed metrics
// With improved pattern detection, price.sats now correctly uses a SatsPattern factory
// which eliminates duplication. Verify that it's being used:
assert!(
js_output.contains("'price_ohlc_sats'"),
"price.sats.ohlc should use 'price_ohlc_sats'"
);
assert!(
js_output.contains("'price_sats'") || js_output.contains("createSplitPattern2(this, 'price_sats')"),
"price.sats.split should use 'price_sats' base"
js_output.contains("sats: createSatsPattern(this, 'price')"),
"price.sats should use SatsPattern factory"
);
// Verify price.usd uses non-sats metrics (no _sats suffix)
// Verify price.usd is inlined and uses non-sats metrics (no _sats suffix)
assert!(
js_output.contains("createMetricPattern1(this, 'price_ohlc')"),
"price.usd.ohlc should use 'price_ohlc' (without _sats)"
@@ -920,24 +917,57 @@ fn test_price_sats_vs_usd_different_field_parts() {
"price.usd.split should use 'price' base (without _sats)"
);
// Verify they don't incorrectly share the same metric names
// Count occurrences to ensure usd doesn't use sats metrics
let sats_ohlc_count = js_output.matches("'price_ohlc_sats'").count();
let usd_ohlc_count = js_output.matches("'price_ohlc')").count();
println!("price_ohlc_sats occurrences: {}", sats_ohlc_count);
println!("price_ohlc occurrences: {}", usd_ohlc_count);
assert!(
sats_ohlc_count >= 1,
"Should have at least one 'price_ohlc_sats' for price.sats"
);
assert!(
usd_ohlc_count >= 1,
"Should have at least one 'price_ohlc' for price.usd"
);
println!("\nPrice sats vs usd field_parts test passed!");
println!(" - price.sats correctly uses sats-suffixed metrics");
println!(" - price.usd correctly uses non-sats metrics");
}
#[test]
fn test_utxo_cohorts_all_activity_base() {
// Test that distribution.utxo_cohorts.all.activity uses empty base
// because its children (coinblocks_destroyed, coindays_destroyed, etc.)
// have no common prefix or suffix.
let catalog = load_catalog();
let metadata = ClientMetadata::from_catalog(catalog.clone());
// Generate JavaScript output
let mut js_output = String::new();
writeln!(js_output, "// Test output").unwrap();
brk_bindgen::javascript::client::generate_base_client(&mut js_output);
brk_bindgen::javascript::client::generate_index_accessors(
&mut js_output,
&metadata.index_set_patterns,
);
brk_bindgen::javascript::client::generate_structural_patterns(
&mut js_output,
&metadata.structural_patterns,
&metadata,
);
brk_bindgen::javascript::tree::generate_tree_typedefs(
&mut js_output,
&metadata.catalog,
&metadata,
);
brk_bindgen::javascript::tree::generate_main_client(
&mut js_output,
&metadata.catalog,
&metadata,
&[],
);
// The all.activity should use empty base, so metrics don't get duplicated
// Look for: activity: createActivityPattern2(this, '')
// NOT: activity: createActivityPattern2(this, 'coinblocks_destroyed')
assert!(
!js_output.contains("createActivityPattern2(this, 'coinblocks_destroyed')"),
"all.activity should NOT use 'coinblocks_destroyed' as base (causes duplication)"
);
// Check that it uses empty string as base
assert!(
js_output.contains("activity: createActivityPattern2(this, '')"),
"all.activity should use empty base"
);
println!("utxo_cohorts.all.activity base test passed!");
}
+631 -7442
View File
File diff suppressed because it is too large Load Diff
+16 -38
View File
@@ -1,64 +1,42 @@
# Bitcoin Research Kit
**Open-source Bitcoin analytics infrastructure.**
Open-source on-chain analytics for Bitcoin.
[![MIT Licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/bitcoinresearchkit/brk/blob/main/docs/LICENSE.md)
[![Crates.io](https://img.shields.io/crates/v/brk.svg)](https://crates.io/crates/brk)
[![docs.rs](https://img.shields.io/docsrs/brk)](https://docs.rs/brk)
[![Discord](https://img.shields.io/discord/1350431684562124850?logo=discord)](https://discord.gg/WACpShCB7M)
[Homepage](https://bitcoinresearchkit.org) · [**Bitview**](https://bitview.space) · [API Reference](https://bitcoinresearchkit.org/api)
Combines functionality of [Glassnode](https://glassnode.com) (on-chain metrics), [mempool.space](https://mempool.space) (block explorer), and [electrs](https://github.com/romanz/electrs) (address index) into a single self-hostable package. See [Bitview](https://bitview.space) for a live example.
---
## Data
BRK parses, indexes, and analyzes Bitcoin blockchain data. It combines on-chain analytics (like [Glassnode](https://glassnode.com)), block exploration (like [mempool.space](https://mempool.space)), and address indexing (like [electrs](https://github.com/romanz/electrs)) into a single self-hostable package.
**Blockchain** — Blocks, transactions, addresses, UTXOs.
## See It In Action
**Metrics** — Supply distributions, holder cohorts, network activity, fee markets, mining, and market indicators (realized cap, MVRV, SOPR, NVT).
[**Bitview**](https://bitview.space) is a web application built entirely on BRK. It offers interactive charts for exploring Bitcoin on-chain metrics—price models, supply dynamics, holder behavior, network activity, and more. Browse it to see what's possible with the data BRK provides.
**Indexes** — Query by date, height, halving epoch, address type, UTXO age.
## What It Provides
**Mempool** — Fee estimation, projected blocks, unconfirmed transactions.
**On-Chain Metrics** — Thousands of derived metrics: market indicators (realized cap, MVRV, SOPR, NVT), supply analysis (circulating, liquid, illiquid), holder cohorts (by balance, age, address type), and pricing models. This is what sets BRK apart from typical block explorers.
## Usage
**Blockchain Data** — Blocks, transactions, addresses, UTXOs. The API follows mempool.space's format for compatibility with existing tools.
**API** — REST with JSON/CSV. [Documentation](https://bitcoinresearchkit.org/api). Clients: [JavaScript](https://www.npmjs.com/package/brk-client), [Python](https://pypi.org/project/brk-client), [Rust](https://crates.io/crates/brk_client).
**Multiple Indexes** — Query data by date, block height, halving epoch, address type, UTXO age, and more. Enables flexible time-series queries and cohort analysis.
**Self-host** — Requires Bitcoin Core. [Guide](./HOSTING.md). [Docker](https://github.com/bitcoinresearchkit/brk/tree/main/docker).
**Mempool**Real-time fee estimation, projected blocks, unconfirmed transaction tracking.
**Library**[docs.rs/brk](https://docs.rs/brk). [Architecture](./ARCHITECTURE.md).
**REST API** — JSON and CSV output with OpenAPI documentation.
**MCP Server** — Model Context Protocol integration for AI assistants and LLMs.
## Get Started
**Use the Public API** — Access data without running infrastructure. Client libraries available for [JavaScript](https://www.npmjs.com/package/brk-client), [Python](https://pypi.org/project/brk-client/), and [Rust](https://crates.io/crates/brk_client). See the [API reference](https://bitcoinresearchkit.org/api) for endpoints.
**Self-Host** — Run your own instance with Bitcoin Core. Install via [`brk_cli`](https://docs.rs/brk_cli) or use [Docker](https://github.com/bitcoinresearchkit/brk/tree/main/docker). See the [hosting guide](./HOSTING.md).
**Use as a Library** — Build custom tools with the Rust crates. Use individual components or the [umbrella crate](https://docs.rs/brk). See [architecture](./ARCHITECTURE.md) for how they fit together.
## Architecture
```
blk*.dat ──▶ Reader ──┐
├──▶ Indexer ──▶ Computer ──┐
RPC Client ──┤ ├──▶ Query ──▶ Server
└──▶ Mempool ───────────────┘
```
**Reader** parses Bitcoin Core's block files. **Indexer** builds lookup tables. **Computer** derives metrics. **Mempool** tracks unconfirmed transactions. **Query** provides unified data access. **Server** exposes the REST API.
[Detailed architecture](./ARCHITECTURE.md) · [All crates](https://docs.rs/brk)
**MCP** — Model Context Protocol server for LLMs.
## Links
- [Changelog](./CHANGELOG.md)
- [Support](./SUPPORT.md)
- [Contributing](https://github.com/bitcoinresearchkit/brk/issues)
- Community: [Discord](https://discord.gg/WACpShCB7M) · [Nostr](https://primal.net/p/nprofile1qqsfw5dacngjlahye34krvgz7u0yghhjgk7gxzl5ptm9v6n2y3sn03sqxu2e6)
- Development supported by [OpenSats](https://opensats.org/)
[Discord](https://discord.gg/WACpShCB7M) · [Nostr](https://primal.net/p/nprofile1qqsfw5dacngjlahye34krvgz7u0yghhjgk7gxzl5ptm9v6n2y3sn03sqxu2e6)
Development supported by [OpenSats](https://opensats.org/).
## License
+2449 -11116
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -620,9 +620,14 @@
display: flex;
gap: 1.25rem;
> label {
> label,
> button {
pointer-events: auto;
}
> button {
color: var(--off-color);
}
}
}
@@ -1824,6 +1829,8 @@
/>
Search
</label>
<button id="share-button" title="Share">Share</button>
</fieldset>
</footer>
</main>
+4 -1
View File
@@ -653,7 +653,10 @@ export function createChartElement({
({ count, active }) => {
showLine = count > 500;
candlestickISeries.applyOptions({ visible: active && !showLine });
lineISeries.applyOptions({ visible: active && showLine });
lineISeries.applyOptions({
visible: active && showLine,
priceLineVisible: active && showLine,
});
},
);
},
+1 -1
View File
@@ -52,7 +52,7 @@
* @typedef {Brk.AnyMetricEndpointBuilder} AnyMetricEndpoint
* @typedef {Brk.AnyMetricData} AnyMetricData
* @typedef {Brk.AddrCountPattern} AddrCountPattern
* @typedef {Brk.MetricsTree_Blocks_Interval} IntervalPattern
* @typedef {FullnessPattern<any>} IntervalPattern
* @typedef {Brk.MetricsTree_Supply_Circulating} SupplyPattern
* @typedef {Brk.RelativePattern} GlobalRelativePattern
* @typedef {Brk.RelativePattern2} OwnRelativePattern
+5
View File
@@ -545,6 +545,11 @@ signals.createRoot(() => {
function initShare() {
const shareDiv = getElementById("share-div");
const shareContentDiv = getElementById("share-content-div");
const shareButton = getElementById("share-button");
shareButton.addEventListener("click", () => {
qrcode.set(window.location.href);
});
shareDiv.addEventListener("click", () => {
qrcode.set(null);
+12
View File
@@ -12,6 +12,7 @@ export function createChainSection(ctx) {
colors,
brk,
line,
baseline,
dots,
createPriceLine,
fromSizePattern,
@@ -638,6 +639,17 @@ export function createChainSection(ctx) {
}),
],
},
{
name: "Adjustment",
title: "Difficulty Adjustment",
bottom: [
baseline({
metric: blocks.difficulty.adjustment,
name: "Difficulty Change",
unit: Unit.percentage,
}),
],
},
{
name: "Hash Price",
title: "Hash Price",
+15
View File
@@ -10,6 +10,7 @@ import {
ltAmountColors,
amountRangeColors,
spendableTypeColors,
yearColors,
} from "../colors/index.js";
/**
@@ -40,6 +41,7 @@ export function buildCohortData(colors, brk) {
LT_AMOUNT_NAMES,
AMOUNT_RANGE_NAMES,
SPENDABLE_TYPE_NAMES,
YEAR_NAMES,
} = brk;
// Base cohort representing "all" - CohortAll (adjustedSopr + percentiles but no RelToMarketCap)
@@ -210,6 +212,18 @@ export function buildCohortData(colors, brk) {
};
});
// Year cohorts - CohortBasic (neither adjustedSopr nor percentiles)
/** @type {readonly CohortBasic[]} */
const year = entries(utxoCohorts.year).map(([key, tree]) => {
const names = YEAR_NAMES[key];
return {
name: names.short,
title: names.long,
color: colors[yearColors[key]],
tree,
};
});
return {
cohortAll,
termShort,
@@ -225,5 +239,6 @@ export function buildCohortData(colors, brk) {
utxosAmountRanges,
addressesAmountRanges,
type,
year,
};
}
+96 -22
View File
@@ -139,28 +139,102 @@ function createCointimePriceWithRatioOptions(
},
{
name: "ZScores",
tree: sdPatterns.map(({ nameAddon, titleAddon, sd }) => ({
name: nameAddon,
title: `${title} ${titleAddon} Z-Score`,
top: getSdBands(sd).map(({ name: bandName, prop, color: bandColor }) =>
line({
metric: prop,
name: bandName,
color: bandColor,
unit: Unit.usd,
}),
),
bottom: [
line({ metric: sd.zscore, name: "Z-Score", color, unit: Unit.sd }),
createPriceLine({ unit: Unit.sd, number: 3 }),
createPriceLine({ unit: Unit.sd, number: 2 }),
createPriceLine({ unit: Unit.sd, number: 1 }),
createPriceLine({ unit: Unit.sd, number: 0 }),
createPriceLine({ unit: Unit.sd, number: -1 }),
createPriceLine({ unit: Unit.sd, number: -2 }),
createPriceLine({ unit: Unit.sd, number: -3 }),
],
})),
tree: [
// Compare all Z-Scores
{
name: "Compare",
title: `Compare ${title} Z-Scores`,
top: [
line({ metric: price, name: legend, color, unit: Unit.usd }),
line({
metric: ratio.ratio1ySd._0sdUsd,
name: "1y 0sd",
color: colors.fuchsia,
defaultActive: false,
unit: Unit.usd,
}),
line({
metric: ratio.ratio2ySd._0sdUsd,
name: "2y 0sd",
color: colors.purple,
defaultActive: false,
unit: Unit.usd,
}),
line({
metric: ratio.ratio4ySd._0sdUsd,
name: "4y 0sd",
color: colors.violet,
defaultActive: false,
unit: Unit.usd,
}),
line({
metric: ratio.ratioSd._0sdUsd,
name: "0sd",
color: colors.indigo,
defaultActive: false,
unit: Unit.usd,
}),
],
bottom: [
line({
metric: ratio.ratioSd.zscore,
name: "All",
color: colors.default,
unit: Unit.sd,
}),
line({
metric: ratio.ratio4ySd.zscore,
name: "4y",
color: colors.lime,
unit: Unit.sd,
}),
line({
metric: ratio.ratio2ySd.zscore,
name: "2y",
color: colors.avocado,
unit: Unit.sd,
}),
line({
metric: ratio.ratio1ySd.zscore,
name: "1y",
color: colors.yellow,
unit: Unit.sd,
}),
createPriceLine({ unit: Unit.sd, number: 4 }),
createPriceLine({ unit: Unit.sd, number: 3 }),
createPriceLine({ unit: Unit.sd, number: 2 }),
createPriceLine({ unit: Unit.sd, number: 1 }),
createPriceLine({ unit: Unit.sd, number: 0 }),
createPriceLine({ unit: Unit.sd, number: -1 }),
createPriceLine({ unit: Unit.sd, number: -2 }),
createPriceLine({ unit: Unit.sd, number: -3 }),
createPriceLine({ unit: Unit.sd, number: -4 }),
],
},
// Individual Z-Score charts
...sdPatterns.map(({ nameAddon, titleAddon, sd }) => ({
name: nameAddon,
title: `${title} ${titleAddon} Z-Score`,
top: getSdBands(sd).map(({ name: bandName, prop, color: bandColor }) =>
line({
metric: prop,
name: bandName,
color: bandColor,
unit: Unit.usd,
}),
),
bottom: [
line({ metric: sd.zscore, name: "Z-Score", color, unit: Unit.sd }),
createPriceLine({ unit: Unit.sd, number: 3 }),
createPriceLine({ unit: Unit.sd, number: 2 }),
createPriceLine({ unit: Unit.sd, number: 1 }),
createPriceLine({ unit: Unit.sd, number: 0 }),
createPriceLine({ unit: Unit.sd, number: -1 }),
createPriceLine({ unit: Unit.sd, number: -2 }),
createPriceLine({ unit: Unit.sd, number: -3 }),
],
})),
],
},
];
}
+22
View File
@@ -150,3 +150,25 @@ export const spendableTypeColors = {
unknown: "violet",
empty: "fuchsia",
};
/** @type {Readonly<Record<string, ColorName>>} */
export const yearColors = {
_2009: "red",
_2010: "orange",
_2011: "amber",
_2012: "yellow",
_2013: "lime",
_2014: "green",
_2015: "teal",
_2016: "cyan",
_2017: "sky",
_2018: "blue",
_2019: "indigo",
_2020: "violet",
_2021: "purple",
_2022: "fuchsia",
_2023: "pink",
_2024: "rose",
_2025: "red",
_2026: "orange",
};
+1
View File
@@ -9,6 +9,7 @@ export {
ltAmountColors,
amountRangeColors,
spendableTypeColors,
yearColors,
} from "./cohorts.js";
export { averageColors, dcaColors } from "./misc.js";
+187 -242
View File
@@ -1,6 +1,5 @@
/** Partial options - Main entry point */
import { localhost } from "../utils/env.js";
import { createContext } from "./context.js";
import {
buildCohortData,
@@ -45,6 +44,7 @@ export function createPartialOptions({ colors, brk }) {
utxosAmountRanges,
addressesAmountRanges,
type,
year,
} = buildCohortData(colors, brk);
// Helpers to map cohorts by capability type
@@ -58,16 +58,16 @@ export function createPartialOptions({ colors, brk }) {
const mapAddressCohorts = (cohort) => createAddressCohortFolder(ctx, cohort);
return [
// Debug explorer (localhost only)
...(localhost
? [
{
kind: /** @type {const} */ ("explorer"),
name: "Explorer",
title: "Debug explorer",
},
]
: []),
// Debug explorer (disabled)
// ...(localhost
// ? [
// {
// kind: /** @type {const} */ ("explorer"),
// name: "Explorer",
// title: "Debug explorer",
// },
// ]
// : []),
// Charts section
{
@@ -88,7 +88,7 @@ export function createPartialOptions({ colors, brk }) {
// Terms (STH/LTH) - Short is Full, Long is WithPercentiles
{
name: "terms",
name: "Terms",
tree: [
// Individual cohorts with their specific capabilities
createCohortFolderFull(ctx, termShort),
@@ -96,7 +96,149 @@ export function createPartialOptions({ colors, brk }) {
],
},
// Epochs - CohortBasic (neither adjustedSopr nor percentiles)
// Types - CohortBasic
{
name: "Types",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "Type",
list: type,
}),
...type.map(mapBasic),
],
},
// Age cohorts
{
name: "Age",
tree: [
// Up To (< X old)
{
name: "Up To",
tree: [
createCohortFolderWithAdjusted(ctx, {
name: "Compare",
title: "Age Up To",
list: upToDate,
}),
...upToDate.map(mapWithAdjusted),
],
},
// At Least (≥ X old)
{
name: "At Least",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "Age At Least",
list: fromDate,
}),
...fromDate.map(mapBasic),
],
},
// Range
{
name: "Range",
tree: [
createCohortFolderWithPercentiles(ctx, {
name: "Compare",
title: "Age Range",
list: dateRange,
}),
...dateRange.map(mapWithPercentiles),
],
},
],
},
// Amount cohorts (UTXO size)
{
name: "Amount",
tree: [
// Under (< X sats)
{
name: "Under",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "Amount Under",
list: utxosUnderAmount,
}),
...utxosUnderAmount.map(mapBasic),
],
},
// Above (≥ X sats)
{
name: "Above",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "Amount Above",
list: utxosAboveAmount,
}),
...utxosAboveAmount.map(mapBasic),
],
},
// Range
{
name: "Range",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "Amount Range",
list: utxosAmountRanges,
}),
...utxosAmountRanges.map(mapBasic),
],
},
],
},
// Balance cohorts (Address balance)
{
name: "Balance",
tree: [
// Under (< X sats)
{
name: "Under",
tree: [
createAddressCohortFolder(ctx, {
name: "Compare",
title: "Balance Under",
list: addressesUnderAmount,
}),
...addressesUnderAmount.map(mapAddressCohorts),
],
},
// Above (≥ X sats)
{
name: "Above",
tree: [
createAddressCohortFolder(ctx, {
name: "Compare",
title: "Balance Above",
list: addressesAboveAmount,
}),
...addressesAboveAmount.map(mapAddressCohorts),
],
},
// Range
{
name: "Range",
tree: [
createAddressCohortFolder(ctx, {
name: "Compare",
title: "Balance Range",
list: addressesAmountRanges,
}),
...addressesAmountRanges.map(mapAddressCohorts),
],
},
],
},
// Epochs - CohortBasic
{
name: "Epochs",
tree: [
@@ -109,133 +251,16 @@ export function createPartialOptions({ colors, brk }) {
],
},
// Types - CohortBasic
// Years - CohortBasic
{
name: "types",
name: "Years",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "Type",
list: type,
title: "Year",
list: year,
}),
...type.map(mapBasic),
],
},
// UTXOs Up to age - CohortWithAdjusted (adjustedSopr only)
{
name: "UTXOs Up to age",
tree: [
createCohortFolderWithAdjusted(ctx, {
name: "Compare",
title: "UTXOs Up To Age",
list: upToDate,
}),
...upToDate.map(mapWithAdjusted),
],
},
// UTXOs from age - CohortBasic
{
name: "UTXOs from age",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "UTXOs from age",
list: fromDate,
}),
...fromDate.map(mapBasic),
],
},
// UTXOs age ranges - CohortWithPercentiles (percentiles only)
{
name: "UTXOs age Ranges",
tree: [
createCohortFolderWithPercentiles(ctx, {
name: "Compare",
title: "UTXOs Age Range",
list: dateRange,
}),
...dateRange.map(mapWithPercentiles),
],
},
// UTXOs under amounts - CohortBasic
{
name: "UTXOs under amounts",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "UTXOs under amount",
list: utxosUnderAmount,
}),
...utxosUnderAmount.map(mapBasic),
],
},
// UTXOs above amounts - CohortBasic
{
name: "UTXOs Above Amounts",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "UTXOs Above Amount",
list: utxosAboveAmount,
}),
...utxosAboveAmount.map(mapBasic),
],
},
// UTXOs between amounts - CohortBasic
{
name: "UTXOs between amounts",
tree: [
createCohortFolderBasic(ctx, {
name: "Compare",
title: "UTXOs between amounts",
list: utxosAmountRanges,
}),
...utxosAmountRanges.map(mapBasic),
],
},
// Addresses under amount (TYPE SAFE - uses createAddressCohortFolder!)
{
name: "Addresses under amount",
tree: [
createAddressCohortFolder(ctx, {
name: "Compare",
title: "Addresses under Amount",
list: addressesUnderAmount,
}),
...addressesUnderAmount.map(mapAddressCohorts),
],
},
// Addresses above amount (TYPE SAFE - uses createAddressCohortFolder!)
{
name: "Addresses above amount",
tree: [
createAddressCohortFolder(ctx, {
name: "Compare",
title: "Addresses above amount",
list: addressesAboveAmount,
}),
...addressesAboveAmount.map(mapAddressCohorts),
],
},
// Addresses between amounts (TYPE SAFE - uses createAddressCohortFolder!)
{
name: "Addresses between amounts",
tree: [
createAddressCohortFolder(ctx, {
name: "Compare",
title: "Addresses between amounts",
list: addressesAmountRanges,
}),
...addressesAmountRanges.map(mapAddressCohorts),
...year.map(mapBasic),
],
},
],
@@ -246,117 +271,37 @@ export function createPartialOptions({ colors, brk }) {
],
},
// Table section
// Table section (disabled)
// {
// kind: /** @type {const} */ ("table"),
// title: "Table",
// name: "Table",
// },
// Simulations section (disabled)
// {
// name: "Simulations",
// tree: [
// {
// kind: /** @type {const} */ ("simulation"),
// name: "Save In Bitcoin",
// title: "Save In Bitcoin",
// },
// ],
// },
// API documentation
{
kind: /** @type {const} */ ("table"),
title: "Table",
name: "Table",
name: "API",
url: () => "/api",
title: "API documentation",
},
// Simulations section
// Project link
{
name: "Simulations",
tree: [
{
kind: /** @type {const} */ ("simulation"),
name: "Save In Bitcoin",
title: "Save In Bitcoin",
},
],
},
// Tools section
{
name: "Tools",
tree: [
{
name: "Documentation",
tree: [
{
name: "API",
url: () => "/api",
title: "API documentation",
},
{
name: "MCP",
url: () =>
"https://github.com/bitcoinresearchkit/brk/blob/main/crates/brk_mcp/README.md#brk_mcp",
title: "Model Context Protocol documentation",
},
{
name: "Crate",
url: () => "/crate",
title: "View on crates.io",
},
{
name: "Source",
url: () => "/github",
title: "Source code and issues",
},
{
name: "Changelog",
url: () => "/changelog",
title: "Release notes and changelog",
},
],
},
{
name: "Hosting",
tree: [
{
name: "Status",
url: () => "/status",
title: "Service status and uptime",
},
{
name: "Self-host",
url: () => "/install",
title: "Install and run yourself",
},
{
name: "Service",
url: () => "/service",
title: "Hosted service offering",
},
],
},
{
name: "Community",
tree: [
{
name: "Discord",
url: () => "/discord",
title: "Join the Discord server",
},
{
name: "GitHub",
url: () => "/github",
title: "Source code and issues",
},
{
name: "Nostr",
url: () => "/nostr",
title: "Follow on Nostr",
},
],
},
],
},
// Donate
{
name: "Donate",
qrcode: true,
url: () => "bitcoin:bc1q098zsm89m7kgyze338vfejhpdt92ua9p3peuve",
title: "Bitcoin address for donations",
},
// Share
{
name: "Share",
qrcode: true,
url: () => window.location.href,
title: "Share",
name: "Source",
url: () => "https://bitcoinresearchkit.org",
title: "Bitcoin Research Kit",
},
];
}
+18 -17
View File
@@ -11,6 +11,7 @@ import { Unit } from "../../utils/units.js";
import signals from "../../signals.js";
import { createChartElement } from "../../chart/index.js";
import { webSockets } from "../../utils/ws.js";
import { screenshot } from "./screenshot.js";
const keyPrefix = "chart";
const ONE_BTC_IN_SATS = 100_000_000;
@@ -82,21 +83,20 @@ export function init({ colors, option, brk }) {
});
if (!(ios && !canShare)) {
const chartBottomRightCanvas = Array.from(
chart.inner.chartElement().getElementsByTagName("tr"),
).at(-1)?.lastChild?.firstChild?.firstChild;
if (chartBottomRightCanvas) {
const domain = window.document.createElement("p");
domain.innerText = `${window.location.host}`;
domain.id = "domain";
const screenshotButton = window.document.createElement("button");
screenshotButton.id = "screenshot";
const camera = "[ ◉¯]";
screenshotButton.innerHTML = camera;
screenshotButton.title = "Screenshot";
chartBottomRightCanvas.replaceWith(screenshotButton);
screenshotButton.addEventListener("click", () => {
import("./screenshot").then(async ({ screenshot }) => {
const domain = window.document.createElement("p");
domain.innerText = `${window.location.host}`;
domain.id = "domain";
chart.addFieldsetIfNeeded({
id: "capture",
paneIndex: 0,
position: "ne",
createChild() {
const button = window.document.createElement("button");
button.id = "capture";
button.innerText = "capture";
button.title = "Capture chart as image";
button.addEventListener("click", async () => {
chartElement.dataset.screenshot = "true";
chartElement.append(domain);
try {
@@ -109,8 +109,9 @@ export function init({ colors, option, brk }) {
chartElement.removeChild(domain);
chartElement.dataset.screenshot = "false";
});
});
}
return button;
},
});
}
chart.inner.timeScale().subscribeVisibleLogicalRangeChange(