feat(ui): a price chart on the market page, and its placeholder
ci / gate (push) Successful in 4m20s
deploy / validate (push) Skipped
deploy / build (push) Successful in 8m32s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 18s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
This commit is contained in:
2026-10-03 22:38:23 +03:00
co-authored by Claude Opus 5.5
parent 5d22137a43
commit fb7148cc98
12 changed files with 1023 additions and 31 deletions
@@ -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"
}
+117
View File
@@ -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<String>,
trades: Option<i64>,
points: Option<usize>,
}
/// 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<AppState>,
Path(chain): Path<String>,
Query(query): Query<SeriesQuery>,
) -> Result<Json<blackbeard_entities::IntentsSeries>, 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<blackbeard_core::intents::SeriesTrade> = 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 {
+189
View File
@@ -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<PriceBucket>) {
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<i64, Acc> = 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);
}
}
+32
View File
@@ -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<Vec<(DateTime<Utc>, 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.
///
+57
View File
@@ -197,3 +197,60 @@ pub struct Markets {
/// Its venues.
pub markets: Vec<MarketSummary>,
}
/// 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<Utc>,
/// 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<String>,
/// Its decimals, when read.
pub asset_decimals: Option<u8>,
/// The counter token the prices are in.
pub counter_token: String,
/// Its own symbol, when read. Display only.
pub counter_symbol: Option<String>,
/// 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<IntentsSeriesPoint>,
/// Lowest NEAR height read.
#[ts(type = "number")]
pub indexed_from: Option<u64>,
/// Highest NEAR height read.
#[ts(type = "number")]
pub indexed_to: Option<u64>,
}
+2 -1
View File
@@ -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,
+60
View File
@@ -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<IntentsSeriesPoint>,
/**
* Lowest NEAR height read.
*/
indexed_from: number,
/**
* Highest NEAR height read.
*/
indexed_to: number, };
@@ -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, };
+13
View File
@@ -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<IntentsSwaps>(`/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<IntentsSeries> {
return get<IntentsSeries>(
`/chains/${chain}/intents/series?counter=${encodeURIComponent(counter)}`,
signal,
)
}
+37 -30
View File
@@ -193,37 +193,44 @@ export function IntentsPanel({
{listingFields}
</div>
{table && rest.length > 0 && (
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">Every other priced counter token</caption>
<thead>
<tr>
<th scope="col" className="left">
{asset} in
</th>
<th scope="col">Volume-weighted</th>
<th scope="col">Last</th>
<th scope="col">Trades</th>
<th scope="col" className="left">
Last traded
</th>
</tr>
</thead>
<tbody>
{rest.map((c) => (
<tr key={c.counter_token}>
<td className="left" title={c.counter_token}>
{tokenLabel(c.counter_token, c.symbol)}
</td>
<td className="numeral">{fmtPrice(c.vwap)}</td>
<td className="numeral">{fmtPrice(c.last_price)}</td>
<td className="numeral">{fmtHeight(c.trades)}</td>
<td className="left">{ago(c.last_at)}</td>
// Folded, so the chart below the figures is not pushed under a
// seventy-row table; the summary says how much is inside.
<details className="panel-note">
<summary>
{fmtHeight(rest.length)} more counter token{rest.length === 1 ? '' : 's'}
</summary>
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">Every other priced counter token</caption>
<thead>
<tr>
<th scope="col" className="left">
{asset} in
</th>
<th scope="col">Volume-weighted</th>
<th scope="col">Last</th>
<th scope="col">Trades</th>
<th scope="col" className="left">
Last traded
</th>
</tr>
))}
</tbody>
</table>
</div>
</thead>
<tbody>
{rest.map((c) => (
<tr key={c.counter_token}>
<td className="left" title={c.counter_token}>
{tokenLabel(c.counter_token, c.symbol)}
</td>
<td className="numeral">{fmtPrice(c.vwap)}</td>
<td className="numeral">{fmtPrice(c.last_price)}</td>
<td className="numeral">{fmtHeight(c.trades)}</td>
<td className="left">{ago(c.last_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
{(!table && rest.length > 0) || unpriced.length > 0 ? (
<p className="panel-note">
+3
View File
@@ -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({
<IntentsPanel chain={chain} decimals={decimals} symbol={symbol} tiles={8} table />
<PriceChart chain={chain} symbol={symbol} />
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Settling now</h2>
+435
View File
@@ -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<HTMLDivElement | null>, number] {
const ref = useRef<HTMLDivElement>(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<IntentsPrice | null>(null)
const [counter, setCounter] = useState<string | null>(null)
const [series, setSeries] = useState<IntentsSeries | null>(null)
const [error, setError] = useState<string | null>(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 (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Price</h2>
<span className="eyebrow">
{series && points.length > 0
? `${asset} in ${unit} · latest ${fmtHeight(series.trades)} trades · ${span(series.bucket_seconds)} buckets`
: `${asset} on NEAR Intents`}
</span>
</div>
{options.length > 1 && (
<div className="pager" style={{ justifyContent: 'flex-start' }}>
<label className="eyebrow" htmlFor="price-counter">
priced in · most traded first
</label>{' '}
<select
id="price-counter"
value={chosen ?? ''}
onChange={(e) => setCounter(e.target.value)}
>
{options.map((c) => (
<option key={c.counter_token} value={c.counter_token}>
{tokenLabel(c.counter_token, c.symbol)}
</option>
))}
</select>
</div>
)}
{error ? (
<p className="empty">{error}</p>
) : points.length === 0 ? (
<Placeholder price={price} asset={asset} loading={!price || (!!chosen && !series)} />
) : (
<Plot points={points} bucketSeconds={series!.bucket_seconds} unit={unit} asset={asset} />
)}
{points.length > 0 && (
<details className="panel-note">
<summary>The figures behind the chart</summary>
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">
{asset} price in {unit} per bucket, oldest first
</caption>
<thead>
<tr>
<th scope="col" className="left">
From
</th>
<th scope="col">Volume-weighted</th>
<th scope="col">Low</th>
<th scope="col">High</th>
<th scope="col">Trades</th>
<th scope="col">{asset}</th>
</tr>
</thead>
<tbody>
{points.map((p) => (
<tr key={p.at}>
<td className="left numeral">{when(p.at)}</td>
<td className="numeral">{fmtPrice(p.vwap)}</td>
<td className="numeral">{fmtPrice(p.low)}</td>
<td className="numeral">{fmtPrice(p.high)}</td>
<td className="numeral">{fmtHeight(p.trades)}</td>
<td className="numeral">
{tokens(p.asset_volume, series?.asset_decimals ?? 0)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
<p className="panel-note">
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.
</p>
</section>
)
}
/**
* 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 (
<div
style={{
height: HEIGHT,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
padding: '0 18px',
}}
>
<div
aria-hidden="true"
style={{
position: 'absolute',
left: PAD.left,
right: PAD.right,
bottom: PAD.bottom,
borderTop: '1px solid var(--border)',
}}
/>
<p className="empty" style={{ padding: 0, maxWidth: 560 }}>
{reason}
</p>
</div>
)
}
function Plot({
points,
bucketSeconds,
unit,
asset,
}: {
points: IntentsSeriesPoint[]
bucketSeconds: number
unit: string
asset: string
}) {
const [wrap, width] = useMeasuredWidth()
const [hover, setHover] = useState<Placed | null>(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<SVGSVGElement>) => {
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 (
<div ref={wrap} style={{ position: 'relative' }}>
<svg
className="chart"
width={width}
height={HEIGHT}
viewBox={`0 0 ${width} ${HEIGHT}`}
role="img"
aria-label={`${asset} price in ${unit} over its latest trades, from ${fmtPrice(points[0]?.vwap)} to ${fmtPrice(chart.last?.point.vwap)}`}
onPointerMove={onMove}
onPointerLeave={() => setHover(null)}
>
{chart.yTicks.map((t) => (
<g key={t.v}>
<line className="grid" x1={PAD.left} x2={width - PAD.right} y1={t.y} y2={t.y} />
<text className="chart-axis" x={PAD.left - 8} y={t.y + 3.5} textAnchor="end">
{fmtPrice(t.v)}
</text>
</g>
))}
{chart.xTicks.map((t, i) => (
<text
key={i}
className="chart-axis"
x={t.x}
y={HEIGHT - 8}
textAnchor={i === 0 ? 'start' : i === 2 ? 'end' : 'middle'}
>
{timeLabel(t.t, chart.spanMs)}
</text>
))}
<path className="line" d={chart.path} />
{chart.placed.map((p) => (
<circle key={p.point.at} className="marker" cx={p.x} cy={p.y} r={4} />
))}
{hover && (
<>
<line
className="crosshair"
x1={hover.x}
x2={hover.x}
y1={PAD.top}
y2={chart.baseline}
/>
<circle className="marker" cx={hover.x} cy={hover.y} r={5.5} />
</>
)}
{/* One direct label, on the newest point: the price a reader came for. */}
{!hover && chart.last && (
<text
className="chart-axis"
x={chart.last.x - 8}
y={Math.min(Math.max(chart.last.y - 10, PAD.top + 10), chart.baseline - 8)}
textAnchor="end"
style={{ fill: 'var(--text-primary)', fontSize: 11 }}
>
{fmtPrice(chart.last.point.vwap)}
</text>
)}
</svg>
{hover && (
<div
className="tooltip"
style={{
left: hover.x,
top: 4,
transform: hover.x > width / 2 ? 'translate(-100%, 0)' : 'translate(10px, 0)',
}}
>
<div className="tooltip-label">
{timeLabel(new Date(hover.point.at).getTime(), chart.spanMs)} · {span(bucketSeconds)}
</div>
<div>
<strong>{fmtPrice(hover.point.vwap)}</strong> {unit}
</div>
<div style={{ color: 'var(--text-secondary)' }}>
{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`}
</div>
</div>
)}
</div>
)
}