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
+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>
)
}