From fb7148cc98184c019d4c762c3f107deccf5c6375 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Sat, 3 Oct 2026 22:38:23 +0300 Subject: [PATCH] feat(ui): a price chart on the market page, and its placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The asset's price in one counter token over its latest trades, read against the dataviz skill before it was drawn. One series in the data hue and no legend: a token trades against dozens of counters, in units no axis could share, so the counter is a control above the chart rather than a hue per line. Buckets come from blackbeard_core::intents::bucket_prices over /intents/series — volume-weighted, with low and high, empty buckets left out so the line breaks where nothing traded instead of drawing a price nobody paid. The y-axis is fitted to the prices; the window is a count of trades, so the time axis covers only what they span. A table view sits under it. Until anything has traded the frame keeps the chart's height — nothing moves when the first trade lands — with a baseline and the reason, and no axes, since a scale over no data is a range nobody measured. For QTC today that reads "no QTC is bridged to NEAR yet, so none can have traded there". Rendering it caught three things the build did not. The line never drew: the API reported the bucket width truncated to whole seconds, so no two buckets looked adjacent — widths are now whole seconds by construction. The chart painted at its 720px fallback until a ResizeObserver reported, centred in a full-width panel; it is measured before first paint now. And on the market page the seventy-row counter table pushed the chart below the fold; it folds. Checked against real wNEAR trades and the QTC placeholder, light and dark. Closes #33 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj --- ...421a0d58d00518148c2114c68619cea9362e3.json | 43 ++ crates/blackbeard-api/src/routes.rs | 117 +++++ crates/blackbeard-core/src/intents.rs | 189 ++++++++ crates/blackbeard-data/src/store.rs | 32 ++ crates/blackbeard-entities/src/intents.rs | 57 +++ crates/blackbeard-entities/src/lib.rs | 3 +- web/src/api/generated/IntentsSeries.ts | 60 +++ web/src/api/generated/IntentsSeriesPoint.ts | 35 ++ web/src/api/rest.ts | 13 + web/src/components/IntentsPanel.tsx | 67 +-- web/src/components/MarketPage.tsx | 3 + web/src/components/PriceChart.tsx | 435 ++++++++++++++++++ 12 files changed, 1023 insertions(+), 31 deletions(-) create mode 100644 .sqlx/query-8c400b39e4fead228447f1e63c5421a0d58d00518148c2114c68619cea9362e3.json create mode 100644 web/src/api/generated/IntentsSeries.ts create mode 100644 web/src/api/generated/IntentsSeriesPoint.ts create mode 100644 web/src/components/PriceChart.tsx diff --git a/.sqlx/query-8c400b39e4fead228447f1e63c5421a0d58d00518148c2114c68619cea9362e3.json b/.sqlx/query-8c400b39e4fead228447f1e63c5421a0d58d00518148c2114c68619cea9362e3.json new file mode 100644 index 0000000..1c6fd9b --- /dev/null +++ b/.sqlx/query-8c400b39e4fead228447f1e63c5421a0d58d00518148c2114c68619cea9362e3.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select at, asset_amount::text as \"asset_amount!\",\n counter_amount::text as \"counter_amount!\", kind\n from intents_trade\n where chain = $1 and asset = $2 and counter_token = $3\n order by near_height desc, trade_index desc\n limit $4\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "at", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "asset_amount!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "counter_amount!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "kind", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + null, + null, + false + ] + }, + "hash": "8c400b39e4fead228447f1e63c5421a0d58d00518148c2114c68619cea9362e3" +} diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index f3f8f08..ed39202 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -65,6 +65,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { .route("/v1/chains/{chain}/markets", get(markets)) .route("/v1/chains/{chain}/intents/swaps", get(intents_swaps)) .route("/v1/chains/{chain}/intents/price", get(intents_price)) + .route("/v1/chains/{chain}/intents/series", get(intents_series)) .route("/v1/chains/{chain}/nodes", get(nodes)) .route("/v1/chains/{chain}/nodes/{peer}", get(node)) .route("/v1/chains/{chain}/accounts", get(accounts)) @@ -2009,6 +2010,122 @@ async fn intents_price( })) } +/// Trades a price series is taken over, by default and at most. +const PRICE_SERIES_TRADES: i64 = 500; +const PRICE_SERIES_TRADES_MAX: i64 = 5000; +/// Buckets in a price series, by default and at most. +const PRICE_SERIES_POINTS: usize = 48; +const PRICE_SERIES_POINTS_MAX: usize = 200; +/// The narrowest bucket: a burst of trades seconds apart draws as one point, +/// not as a day's worth of one-second buckets. +const PRICE_SERIES_MIN_BUCKET_MS: i64 = 60_000; + +#[derive(Debug, Deserialize)] +struct SeriesQuery { + /// The counter token, as a multi-token id. + counter: Option, + trades: Option, + points: Option, +} + +/// The asset's price in one counter token, bucketed over its latest trades. +/// +/// One counter per request: prices in different tokens share no axis, and a +/// chart of all of them at once would be one hue per token. blackbeard/observer#33. +async fn intents_series( + State(state): State, + Path(chain): Path, + Query(query): Query, +) -> Result, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + let config = runtime + .spec + .intents + .clone() + .ok_or_else(|| no_intents(&chain))?; + let id = runtime.id(); + let counter = query.counter.filter(|c| !c.is_empty()).ok_or_else(|| { + Failure( + StatusCode::BAD_REQUEST, + ApiError::new("no_counter", "name the counter token: `counter=nep141:…`"), + ) + })?; + let window = query + .trades + .unwrap_or(PRICE_SERIES_TRADES) + .clamp(1, PRICE_SERIES_TRADES_MAX); + let points = query + .points + .unwrap_or(PRICE_SERIES_POINTS) + .clamp(1, PRICE_SERIES_POINTS_MAX); + + let rows = state + .store + .intent_series_trades(&id, &config.asset, &counter, window) + .await + .map_err(database_unavailable)?; + let scan = state + .store + .intents_scan(&id, &config.asset) + .await + .map_err(database_unavailable)?; + let tokens = near_tokens(&state.store, vec![config.asset.clone(), counter.clone()]).await?; + let asset_meta = tokens.get(&config.asset).cloned(); + let counter_meta = tokens.get(&counter).cloned(); + + let trades: Vec = rows + .iter() + .filter_map(|(at, a, c, kind)| { + Some(blackbeard_core::intents::SeriesTrade { + at_ms: at.timestamp_millis(), + asset: a.parse().ok()?, + counter: c.parse().ok()?, + taker: kind == "taker", + }) + }) + .collect(); + // Without both decimal counts there is no price to draw: the bucket list + // stays empty and the page says why, rather than plotting raw base units. + let (width, buckets) = match (&asset_meta, &counter_meta) { + (Some((_, ad)), Some((_, cd))) => blackbeard_core::intents::bucket_prices( + &trades, + *ad, + *cd, + points, + PRICE_SERIES_MIN_BUCKET_MS, + ), + _ => (PRICE_SERIES_MIN_BUCKET_MS, Vec::new()), + }; + + Ok(Json(blackbeard_entities::IntentsSeries { + chain: id, + asset: config.asset, + asset_symbol: asset_meta.as_ref().map(|m| m.0.clone()), + asset_decimals: asset_meta.map(|m| m.1), + counter_symbol: counter_meta.map(|m| m.0), + counter_token: counter, + window_trades: window as u32, + trades: trades.len() as u32, + bucket_seconds: (width / 1000).max(1) as u64, + points: buckets + .into_iter() + .filter_map(|b| { + Some(blackbeard_entities::IntentsSeriesPoint { + at: chrono::DateTime::from_timestamp_millis(b.start_ms)?, + trades: b.trades, + taker_trades: b.taker_trades, + vwap: b.vwap, + low: b.low, + high: b.high, + asset_volume: BigUintDec(b.asset_volume.to_string()), + }) + }) + .collect(), + indexed_from: scan.map(|(low, _)| low), + indexed_to: scan.map(|(_, high)| high), + })) +} + /// Paging cursor for the block index. #[derive(Debug, Deserialize)] struct BeforeQuery { diff --git a/crates/blackbeard-core/src/intents.rs b/crates/blackbeard-core/src/intents.rs index fdc70b3..fe1a026 100644 --- a/crates/blackbeard-core/src/intents.rs +++ b/crates/blackbeard-core/src/intents.rs @@ -342,6 +342,125 @@ pub fn vwap(trades: &[(u128, u128)], asset_decimals: u8, counter_decimals: u8) - (asset > 0.0).then(|| counter / asset) } +/// One trade, as the price series reads it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SeriesTrade { + /// When it settled, milliseconds since the epoch. + pub at_ms: i64, + /// Asset that changed hands, base units. + pub asset: u128, + /// Counter token that changed hands, base units. + pub counter: u128, + /// A user's trade rather than solvers trading with each other. + pub taker: bool, +} + +/// Every trade that settled in one time bucket. +#[derive(Debug, Clone, PartialEq)] +pub struct PriceBucket { + /// The bucket's start, milliseconds since the epoch. + pub start_ms: i64, + /// Trades in it. + pub trades: u32, + /// How many of them were users'. + pub taker_trades: u32, + /// Volume-weighted: summed counter over summed asset, in whole units. + pub vwap: f64, + /// The cheapest single trade in it. + pub low: f64, + /// The dearest. + pub high: f64, + /// Asset traded in it, base units. + pub asset_volume: u128, +} + +/// Bucket trades into at most `points` equal spans of time, for a price chart. +/// +/// The span is whatever the trades cover — the window is a count of trades, as +/// a window elsewhere on this site is a count of blocks, so the time axis is a +/// consequence of the data and can never claim more than it holds. A bucket +/// never narrows below `min_bucket_ms`: a burst of trades a second apart would +/// otherwise draw as forty-eight one-second buckets and read as a day. +/// +/// **Empty buckets are omitted, not zero-filled and not interpolated.** A +/// price chart that joined two trades hours apart would draw a path nobody +/// traded at; leaving the bucket out lets the chart break its line there. +/// +/// Returns the bucket width and the non-empty buckets, oldest first. +pub fn bucket_prices( + trades: &[SeriesTrade], + asset_decimals: u8, + counter_decimals: u8, + points: usize, + min_bucket_ms: i64, +) -> (i64, Vec) { + let priced: Vec<&SeriesTrade> = trades + .iter() + .filter(|t| t.asset > 0 && t.counter > 0) + .collect(); + let (Some(t0), Some(t1)) = ( + priced.iter().map(|t| t.at_ms).min(), + priced.iter().map(|t| t.at_ms).max(), + ) else { + return (min_bucket_ms.max(1), Vec::new()); + }; + let points = points.max(1) as i64; + // Ceiling division, so the newest trade lands in the last bucket rather + // than one past it. + // Rounded up to a whole second, so the width the API reports in seconds is + // the width the buckets actually have — a reader that tests "are these two + // buckets adjacent" against a truncated width finds none of them adjacent. + let width = ((t1 - t0 + points) / points).max(min_bucket_ms).max(1); + let width = (width + 999) / 1000 * 1000; + + /// What a bucket accumulates before it becomes a [`PriceBucket`]. + struct Acc { + trades: u32, + taker_trades: u32, + asset: f64, + counter: f64, + low: f64, + high: f64, + volume: u128, + } + + let mut buckets: BTreeMap = BTreeMap::new(); + for t in priced { + let a = scaled(t.asset, asset_decimals); + let c = scaled(t.counter, counter_decimals); + let p = c / a; + let acc = buckets.entry((t.at_ms - t0) / width).or_insert(Acc { + trades: 0, + taker_trades: 0, + asset: 0.0, + counter: 0.0, + low: f64::INFINITY, + high: f64::NEG_INFINITY, + volume: 0, + }); + acc.trades += 1; + acc.taker_trades += u32::from(t.taker); + acc.asset += a; + acc.counter += c; + acc.low = acc.low.min(p); + acc.high = acc.high.max(p); + acc.volume = acc.volume.saturating_add(t.asset); + } + let out = buckets + .into_iter() + .map(|(index, acc)| PriceBucket { + start_ms: t0 + index * width, + trades: acc.trades, + taker_trades: acc.taker_trades, + vwap: acc.counter / acc.asset, + low: acc.low, + high: acc.high, + asset_volume: acc.volume, + }) + .collect(); + (width, out) +} + #[cfg(test)] mod tests { use super::*; @@ -578,4 +697,74 @@ mod tests { assert!((v - 19.0).abs() < 1e-9); assert_eq!(vwap(&[], 0, 0), None); } + fn trade(at_ms: i64, asset: u128, counter: u128) -> SeriesTrade { + SeriesTrade { + at_ms, + asset, + counter, + taker: true, + } + } + + #[test] + fn no_trades_is_no_buckets() { + let (width, buckets) = bucket_prices(&[], 0, 0, 48, 60_000); + assert_eq!(width, 60_000); + assert!(buckets.is_empty()); + } + + #[test] + fn a_bucket_is_volume_weighted_with_its_range() { + // 1 at 10 and 9 at 20, in one bucket: VWAP 19, low 10, high 20. + let (_, b) = bucket_prices(&[trade(0, 1, 10), trade(1_000, 9, 180)], 0, 0, 48, 60_000); + assert_eq!(b.len(), 1); + assert!((b[0].vwap - 19.0).abs() < 1e-9); + assert_eq!((b[0].low, b[0].high), (10.0, 20.0)); + assert_eq!((b[0].trades, b[0].asset_volume), (2, 10)); + } + + #[test] + fn empty_buckets_are_left_out_so_the_line_can_break() { + // Two trades a day apart, 48 buckets: a bucket each, nothing between. + let day = 86_400_000; + let (width, b) = bucket_prices(&[trade(0, 1, 5), trade(day, 1, 6)], 0, 0, 48, 60_000); + assert_eq!(width % 1000, 0, "a whole number of seconds"); + assert!( + width > day / 48 && width - day / 48 <= 1000, + "within a second of exact" + ); + assert_eq!(b.len(), 2); + assert_eq!(b[0].start_ms, 0); + assert_eq!( + b[1].start_ms, + 47 * width, + "the newest trade is in the last bucket, not past it" + ); + } + + #[test] + fn adjacent_buckets_are_exactly_one_width_apart() { + let trades: Vec<_> = (0..100).map(|i| trade(i * 7_013, 1, 2)).collect(); + let (width, b) = bucket_prices(&trades, 0, 0, 48, 60_000); + assert!( + b.windows(2) + .all(|w| (w[1].start_ms - w[0].start_ms) % width == 0) + ); + assert_eq!(width % 1000, 0); + } + + #[test] + fn a_burst_does_not_collapse_to_one_second_buckets() { + let burst: Vec<_> = (0..10).map(|i| trade(i * 1_000, 1, 2)).collect(); + let (width, b) = bucket_prices(&burst, 0, 0, 48, 60_000); + assert_eq!(width, 60_000); + assert_eq!(b.len(), 1); + } + + #[test] + fn decimals_are_applied_per_token() { + // The captured swap: 7.02585188 ZEC (8 decimals) for 9,036.3 USDT (6). + let (_, b) = bucket_prices(&[trade(0, 702_585_188, 9_036_300_000)], 8, 6, 48, 60_000); + assert!((b[0].vwap - 1286.15).abs() < 0.01, "{}", b[0].vwap); + } } diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index fae8020..1cadbdc 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -2946,6 +2946,38 @@ impl Store { .collect()) } + /// The latest `limit` trades of `asset` against one counter token, as + /// `(at, asset_amount, counter_amount, kind)`, newest first. What the price + /// chart buckets. + pub async fn intent_series_trades( + &self, + chain: &ChainId, + asset: &str, + counter: &str, + limit: i64, + ) -> Result, String, String, String)>, DataError> { + let rows = sqlx::query!( + r#" + select at, asset_amount::text as "asset_amount!", + counter_amount::text as "counter_amount!", kind + from intents_trade + where chain = $1 and asset = $2 and counter_token = $3 + order by near_height desc, trade_index desc + limit $4 + "#, + chain.as_str(), + asset, + counter, + limit, + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| (r.at, r.asset_amount, r.counter_amount, r.kind)) + .collect()) + } + /// The latest `per_counter` trades against each counter token, newest /// first within each. /// diff --git a/crates/blackbeard-entities/src/intents.rs b/crates/blackbeard-entities/src/intents.rs index 2060f75..def7458 100644 --- a/crates/blackbeard-entities/src/intents.rs +++ b/crates/blackbeard-entities/src/intents.rs @@ -197,3 +197,60 @@ pub struct Markets { /// Its venues. pub markets: Vec, } + +/// One time bucket of a price series: every trade that settled in it. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "IntentsSeriesPoint.ts")] +pub struct IntentsSeriesPoint { + /// The bucket's start. + pub at: DateTime, + /// Trades in it. + pub trades: u32, + /// How many were users' rather than solvers trading with each other. + pub taker_trades: u32, + /// Volume-weighted price, counter tokens per one whole asset. + pub vwap: f64, + /// The cheapest single trade in the bucket. + pub low: f64, + /// The dearest. + pub high: f64, + /// Asset traded in the bucket, base units. + pub asset_volume: BigUintDec, +} + +/// The asset's price in one counter token over its latest trades. +/// +/// Only buckets with trades in them: an empty one is left out rather than +/// zero-filled or interpolated, so a chart breaks its line where nothing +/// traded instead of drawing a price nobody paid. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "IntentsSeries.ts")] +pub struct IntentsSeries { + /// Which chain's token. + pub chain: ChainId, + /// The tracked asset. + pub asset: String, + /// Its own symbol, when read. + pub asset_symbol: Option, + /// Its decimals, when read. + pub asset_decimals: Option, + /// The counter token the prices are in. + pub counter_token: String, + /// Its own symbol, when read. Display only. + pub counter_symbol: Option, + /// Trades the series is taken over, at most. A count, not a duration. + pub window_trades: u32, + /// Trades actually found. + pub trades: u32, + /// Width of each bucket, seconds. + #[ts(type = "number")] + pub bucket_seconds: u64, + /// The buckets with trades in them, oldest first. + pub points: Vec, + /// Lowest NEAR height read. + #[ts(type = "number")] + pub indexed_from: Option, + /// Highest NEAR height read. + #[ts(type = "number")] + pub indexed_to: Option, +} diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index f10f032..349f8ca 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -40,7 +40,8 @@ pub use chain::{ }; pub use error::{ApiError, EntityError}; pub use intents::{ - CounterPrice, IntentsListing, IntentsPrice, IntentsSwap, IntentsSwaps, MarketSummary, Markets, + CounterPrice, IntentsListing, IntentsPrice, IntentsSeries, IntentsSeriesPoint, IntentsSwap, + IntentsSwaps, MarketSummary, Markets, }; pub use miner::{ AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, NodeActivity, diff --git a/web/src/api/generated/IntentsSeries.ts b/web/src/api/generated/IntentsSeries.ts new file mode 100644 index 0000000..a436987 --- /dev/null +++ b/web/src/api/generated/IntentsSeries.ts @@ -0,0 +1,60 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChainId } from "./ChainId"; +import type { IntentsSeriesPoint } from "./IntentsSeriesPoint"; + +/** + * The asset's price in one counter token over its latest trades. + * + * Only buckets with trades in them: an empty one is left out rather than + * zero-filled or interpolated, so a chart breaks its line where nothing + * traded instead of drawing a price nobody paid. + */ +export type IntentsSeries = { +/** + * Which chain's token. + */ +chain: ChainId, +/** + * The tracked asset. + */ +asset: string, +/** + * Its own symbol, when read. + */ +asset_symbol: string | null, +/** + * Its decimals, when read. + */ +asset_decimals: number | null, +/** + * The counter token the prices are in. + */ +counter_token: string, +/** + * Its own symbol, when read. Display only. + */ +counter_symbol: string | null, +/** + * Trades the series is taken over, at most. A count, not a duration. + */ +window_trades: number, +/** + * Trades actually found. + */ +trades: number, +/** + * Width of each bucket, seconds. + */ +bucket_seconds: number, +/** + * The buckets with trades in them, oldest first. + */ +points: Array, +/** + * Lowest NEAR height read. + */ +indexed_from: number, +/** + * Highest NEAR height read. + */ +indexed_to: number, }; diff --git a/web/src/api/generated/IntentsSeriesPoint.ts b/web/src/api/generated/IntentsSeriesPoint.ts new file mode 100644 index 0000000..19844db --- /dev/null +++ b/web/src/api/generated/IntentsSeriesPoint.ts @@ -0,0 +1,35 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BigUintDec } from "./BigUintDec"; + +/** + * One time bucket of a price series: every trade that settled in it. + */ +export type IntentsSeriesPoint = { +/** + * The bucket's start. + */ +at: string, +/** + * Trades in it. + */ +trades: number, +/** + * How many were users' rather than solvers trading with each other. + */ +taker_trades: number, +/** + * Volume-weighted price, counter tokens per one whole asset. + */ +vwap: number, +/** + * The cheapest single trade in the bucket. + */ +low: number, +/** + * The dearest. + */ +high: number, +/** + * Asset traded in the bucket, base units. + */ +asset_volume: BigUintDec, }; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index bd54762..d9ad92d 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -18,6 +18,7 @@ import type { ChainRoles } from './generated/ChainRoles' import type { ChainState } from './generated/ChainState' import type { GenesisDetail } from './generated/GenesisDetail' import type { IntentsPrice } from './generated/IntentsPrice' +import type { IntentsSeries } from './generated/IntentsSeries' import type { IntentsSwaps } from './generated/IntentsSwaps' import type { Markets } from './generated/Markets' import type { ReversibleState } from './generated/ReversibleState' @@ -310,3 +311,15 @@ export async function fetchSwapsNewest( const cursor = before ? `&before=${encodeURIComponent(before)}` : '' return get(`/chains/${chain}/intents/swaps?order=newest&limit=50${cursor}`, signal) } + +/** The asset's price in one counter token, bucketed over its latest trades. */ +export async function fetchIntentsSeries( + chain: string, + counter: string, + signal?: AbortSignal, +): Promise { + return get( + `/chains/${chain}/intents/series?counter=${encodeURIComponent(counter)}`, + signal, + ) +} diff --git a/web/src/components/IntentsPanel.tsx b/web/src/components/IntentsPanel.tsx index 55b070d..59302df 100644 --- a/web/src/components/IntentsPanel.tsx +++ b/web/src/components/IntentsPanel.tsx @@ -193,37 +193,44 @@ export function IntentsPanel({ {listingFields} {table && rest.length > 0 && ( -
- - - - - - - - - - - - - {rest.map((c) => ( - - - - - - + // Folded, so the chart below the figures is not pushed under a + // seventy-row table; the summary says how much is inside. +
+ + {fmtHeight(rest.length)} more counter token{rest.length === 1 ? '' : 's'} + +
+
Every other priced counter token
- {asset} in - Volume-weightedLastTrades - Last traded -
- {tokenLabel(c.counter_token, c.symbol)} - {fmtPrice(c.vwap)}{fmtPrice(c.last_price)}{fmtHeight(c.trades)}{ago(c.last_at)}
+ + + + + + + + - ))} - -
Every other priced counter token
+ {asset} in + Volume-weightedLastTrades + Last traded +
-
+ + + {rest.map((c) => ( + + + {tokenLabel(c.counter_token, c.symbol)} + + {fmtPrice(c.vwap)} + {fmtPrice(c.last_price)} + {fmtHeight(c.trades)} + {ago(c.last_at)} + + ))} + + + + )} {(!table && rest.length > 0) || unpriced.length > 0 ? (

diff --git a/web/src/components/MarketPage.tsx b/web/src/components/MarketPage.tsx index a3e83d7..33d1ffa 100644 --- a/web/src/components/MarketPage.tsx +++ b/web/src/components/MarketPage.tsx @@ -21,6 +21,7 @@ import { height as fmtHeight } from '../lib/format' import { useObserverInstance } from '../lib/observer-context' import { href } from '../lib/routes' import { IntentsPanel } from './IntentsPanel' +import { PriceChart } from './PriceChart' import { SwapHead, SwapRow } from './SwapRow' /** The venues this page knows how to show. */ @@ -133,6 +134,8 @@ function NearIntents({ + +

Settling now

diff --git a/web/src/components/PriceChart.tsx b/web/src/components/PriceChart.tsx new file mode 100644 index 0000000..0314cc2 --- /dev/null +++ b/web/src/components/PriceChart.tsx @@ -0,0 +1,435 @@ +/** + * The asset's price over its latest trades, in one counter token at a time. + * + * Read against the `dataviz` skill before it was written (blackbeard/observer#33): + * + * - **One series, one hue, no legend.** The job is trend-over-time for one + * quantity. A token trades against dozens of counters, and one line each + * would be a hue per counter — the categorical blow-up both the skill and + * CLAUDE.md rule out, on an axis the lines would not even share, since each + * price is in a different unit. So the counter is a control above the chart. + * - **Gaps are gaps.** The API leaves out buckets nothing traded in, and the + * line is only drawn between adjacent buckets. Joining two trades hours apart + * would draw a price path nobody traded at; on a thin market that would be + * most of the chart. Every bucket with trades carries a dot, so a lone trade + * is still visible. + * - **The y-axis floats.** A price has no meaningful zero here, and a zero + * baseline would flatten every move into the top pixel. Exact values are in + * the tooltip and the table. + * - **The window is a count of trades.** The time axis spans whatever those + * trades cover, so it cannot claim more than the data holds. + * + * Until anything has traded, the frame keeps its height and draws no axes — a + * scale over no data would invent a range — and says why there is nothing yet. + */ + +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' + +import type { CounterPrice } from '../api/generated/CounterPrice' +import type { IntentsPrice } from '../api/generated/IntentsPrice' +import type { IntentsSeries } from '../api/generated/IntentsSeries' +import type { IntentsSeriesPoint } from '../api/generated/IntentsSeriesPoint' +import { RequestFailed, fetchIntentsPrice, fetchIntentsSeries } from '../api/rest' +import { height as fmtHeight, price as fmtPrice, tokens, when } from '../lib/format' +import { useObserverInstance } from '../lib/observer-context' +import { tokenLabel } from '../lib/tokens' + +const HEIGHT = 220 +const PAD = { top: 16, right: 18, bottom: 26, left: 64 } +const FALLBACK_WIDTH = 720 + +interface Placed { + point: IntentsSeriesPoint + x: number + y: number +} + +/** + * The container's width, measured before the first paint. + * + * Read synchronously in a layout effect, then kept current by a + * ResizeObserver. Waiting for the observer alone paints the chart once at the + * fallback width — 720px, centred in a full-width panel — and then jumps; and + * where the observer is slow to report, that first paint is all there is. + */ +function useMeasuredWidth(): [React.RefObject, number] { + const ref = useRef(null) + const [width, setWidth] = useState(FALLBACK_WIDTH) + useLayoutEffect(() => { + const node = ref.current + if (node) setWidth(Math.max(320, node.getBoundingClientRect().width)) + }, []) + useEffect(() => { + const node = ref.current + if (!node) return + const observer = new ResizeObserver(([entry]) => { + if (entry) setWidth(Math.max(320, entry.contentRect.width)) + }) + observer.observe(node) + return () => observer.disconnect() + }, []) + return [ref, width] +} + +function timeLabel(ms: number, spanMs: number): string { + return new Date(ms).toLocaleString(undefined, { + ...(spanMs > 86_400_000 ? { month: 'short', day: 'numeric' } : {}), + hour: '2-digit', + minute: '2-digit', + }) +} + +/** A bucket width as words: "5 min", "2 h". */ +function span(seconds: number): string { + if (seconds < 3600) return `${Math.round(seconds / 60)} min` + if (seconds < 86_400) return `${(seconds / 3600).toFixed(seconds < 36_000 ? 1 : 0)} h` + return `${(seconds / 86_400).toFixed(1)} d` +} + +/** Counters with a price, most traded first — the ones a chart can be drawn for. */ +function chartable(price: IntentsPrice | null): CounterPrice[] { + return (price?.counters ?? []) + .filter((c) => c.decimals !== null && c.vwap !== null) + .sort((a, b) => b.trades - a.trades) +} + +export function PriceChart({ chain, symbol }: { chain: string; symbol: string }) { + const [price, setPrice] = useState(null) + const [counter, setCounter] = useState(null) + const [series, setSeries] = useState(null) + const [error, setError] = useState(null) + const [settled, setSettled] = useState(0) + const observer = useObserverInstance() + + // A settled swap can be one this chart should show, and can be the first. + useEffect(() => observer.onIntentsSwap(() => setSettled((n) => n + 1)), [observer]) + + useEffect(() => { + const controller = new AbortController() + fetchIntentsPrice(chain, controller.signal) + .then(setPrice) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + return () => controller.abort() + }, [chain, settled]) + + const options = chartable(price) + // Default to the most traded counter; keep the reader's choice once made. + const chosen = counter ?? options[0]?.counter_token ?? null + + useEffect(() => { + if (!chosen) return + const controller = new AbortController() + fetchIntentsSeries(chain, chosen, controller.signal) + .then(setSeries) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + return () => controller.abort() + }, [chain, chosen, settled]) + + const asset = series?.asset_symbol ?? price?.asset_symbol ?? symbol + const unit = chosen + ? tokenLabel(chosen, options.find((c) => c.counter_token === chosen)?.symbol) + : '' + const points = series && series.counter_token === chosen ? series.points : [] + + return ( +
+
+

Price

+ + {series && points.length > 0 + ? `${asset} in ${unit} · latest ${fmtHeight(series.trades)} trades · ${span(series.bucket_seconds)} buckets` + : `${asset} on NEAR Intents`} + +
+ + {options.length > 1 && ( +
+ {' '} + +
+ )} + + {error ? ( +

{error}

+ ) : points.length === 0 ? ( + + ) : ( + + )} + + {points.length > 0 && ( +
+ The figures behind the chart +
+ + + + + + + + + + + + + + {points.map((p) => ( + + + + + + + + + ))} + +
+ {asset} price in {unit} per bucket, oldest first +
+ From + Volume-weightedLowHighTrades{asset}
{when(p.at)}{fmtPrice(p.vwap)}{fmtPrice(p.low)}{fmtPrice(p.high)}{fmtHeight(p.trades)} + {tokens(p.asset_volume, series?.asset_decimals ?? 0)} +
+
+
+ )} + +

+ Each point is one time bucket: the volume-weighted price of every trade that settled in it, + in {unit || 'the counter token'}. The line breaks where nothing traded — it is never drawn + across a gap — and the vertical axis is fitted to the prices, not anchored at zero. The + window is the latest trades against this one token, so the time axis covers only what they + span. +

+
+ ) +} + +/** + * The frame, without a chart in it. Same height as the plot, so nothing below + * moves when the first trade arrives; a baseline and the reason, and no axes, + * because a scale over no data is a range nobody measured. + */ +function Placeholder({ + price, + asset, + loading, +}: { + price: IntentsPrice | null + asset: string + loading: boolean +}) { + const bridged = price?.listing?.total_supply + const reason = loading + ? 'Reading…' + : bridged === '0' + ? `The chart starts with the first settled ${asset} trade. None has: no ${asset} is bridged to NEAR yet, so none can have traded there.` + : `The chart starts with the first settled ${asset} trade with a price. None has settled in the blocks read.` + return ( +
+ + ) +} + +function Plot({ + points, + bucketSeconds, + unit, + asset, +}: { + points: IntentsSeriesPoint[] + bucketSeconds: number + unit: string + asset: string +}) { + const [wrap, width] = useMeasuredWidth() + const [hover, setHover] = useState(null) + const bucketMs = bucketSeconds * 1000 + + const chart = useMemo(() => { + const times = points.map((p) => new Date(p.at).getTime()) + const t0 = Math.min(...times) + // The last bucket's end, so its point does not sit on the right edge. + const t1 = Math.max(...times) + bucketMs + const spanMs = t1 - t0 || 1 + const lo = Math.min(...points.map((p) => p.vwap)) + const hi = Math.max(...points.map((p) => p.vwap)) + // Headroom either side; a flat series gets a band around its one value + // rather than a zero-height axis. + const pad = (hi - lo) * 0.15 || Math.abs(hi) * 0.02 || 1 + const yMin = lo - pad + const yMax = hi + pad + + const plotW = width - PAD.left - PAD.right + const plotH = HEIGHT - PAD.top - PAD.bottom + const baseline = PAD.top + plotH + const x = (t: number) => PAD.left + ((t - t0 + bucketMs / 2) / spanMs) * plotW + const y = (v: number) => baseline - ((v - yMin) / (yMax - yMin)) * plotH + + const placed: Placed[] = points.map((point, i) => ({ + point, + x: x(times[i] ?? t0), + y: y(point.vwap), + })) + + // A segment only between buckets that are next to each other in time. + let path = '' + placed.forEach((p, i) => { + const prev = placed[i - 1] + const adjacent = + prev && new Date(p.point.at).getTime() - new Date(prev.point.at).getTime() <= bucketMs + 1 + path += `${adjacent ? 'L' : 'M'}${p.x.toFixed(1)} ${p.y.toFixed(1)} ` + }) + + const yTicks = [yMin + pad, (yMin + yMax) / 2, yMax - pad].map((v) => ({ v, y: y(v) })) + const xTicks = [0, 0.5, 1].map((f) => ({ t: t0 + spanMs * f, x: PAD.left + f * plotW })) + return { placed, path, yTicks, xTicks, baseline, spanMs, last: placed[placed.length - 1] } + }, [points, width, bucketMs]) + + const onMove = (event: React.PointerEvent) => { + const local = event.clientX - event.currentTarget.getBoundingClientRect().left + let nearest: Placed | null = null + let best = Infinity + for (const p of chart.placed) { + const d = Math.abs(p.x - local) + if (d < best) { + best = d + nearest = p + } + } + setHover(nearest) + } + + return ( +
+ setHover(null)} + > + {chart.yTicks.map((t) => ( + + + + {fmtPrice(t.v)} + + + ))} + {chart.xTicks.map((t, i) => ( + + {timeLabel(t.t, chart.spanMs)} + + ))} + + + {chart.placed.map((p) => ( + + ))} + + {hover && ( + <> + + + + )} + + {/* One direct label, on the newest point: the price a reader came for. */} + {!hover && chart.last && ( + + {fmtPrice(chart.last.point.vwap)} + + )} + + + {hover && ( +
width / 2 ? 'translate(-100%, 0)' : 'translate(10px, 0)', + }} + > +
+ {timeLabel(new Date(hover.point.at).getTime(), chart.spanMs)} · {span(bucketSeconds)} +
+
+ {fmtPrice(hover.point.vwap)} {unit} +
+
+ {hover.point.trades === 1 + ? 'one trade' + : `${fmtPrice(hover.point.low)}–${fmtPrice(hover.point.high)} over ${fmtHeight(hover.point.trades)} trades`} + {hover.point.taker_trades !== hover.point.trades && + `, ${fmtHeight(hover.point.taker_trades)} by users`} +
+
+ )} +
+ ) +}