feat: probe whether 1Click will quote QTC, against what could have filled it
On NEAR Intents a price exists only when a solver chooses to quote, and nothing on-chain records a refusal. Every minute the observer now asks 1Click for a keyless dry quote on QTC pairs only, both ways, against native USDC and Ethereum USDC: the smallest size each minute and the full grid each quarter hour, averaging about 4 requests a minute against a published limit of 1,200. At the same moment it reads two things from its own nodes: the Ref pool's quote for the same input, and every solver's balance of the output token inside intents.near. So each "No liquidity available" is stored with whether one solver alone held enough to settle the trade. A probe that got no answer is recorded as `failed`, never as a refusal. Only our own observations are kept: the 1ClickSwap API Terms (s4.1(e)) forbid keeping API data past 24 hours, so a quote becomes a markup against the pool on arrival and raw responses are pruned hourly. The first live round, 2026-10-07: "No liquidity available" both ways. Selling 1 QTC, the pool would have paid 101.53 USDC and three solvers each held enough. Buying with 100 USDC, solver-ref-2.near held 2,308 QTC. The near-intents market page shows a strip per direction and counter token. It is an emphasis chart: the accent marks only "no quote while a solver could fill", the other states are quiet ink told apart by height, and the neutral token is validated against the accent in both themes. Beside it are a summary table and a list of the latest refusals. The pause log and the price-move statistics in #45 come later, once there is history to support them. Refs #45 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tyzzRVjH9AdwGtYwsH5VC
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* One direction and counter token, summed over the window.
|
||||
*/
|
||||
export type QuoteAvailability = {
|
||||
/**
|
||||
* `sell` or `buy`.
|
||||
*/
|
||||
direction: string,
|
||||
/**
|
||||
* The counter token.
|
||||
*/
|
||||
counter: string,
|
||||
/**
|
||||
* Probes that got an answer: every outcome but `failed`.
|
||||
*/
|
||||
answered: number,
|
||||
/**
|
||||
* Of those, quoted.
|
||||
*/
|
||||
quoted: number,
|
||||
/**
|
||||
* Of those, refused as having no liquidity.
|
||||
*/
|
||||
no_liquidity: number,
|
||||
/**
|
||||
* Of the no-liquidity answers, those given while at least one solver held
|
||||
* enough of the output token to fill the trade on its own.
|
||||
*/
|
||||
no_liquidity_while_able: number,
|
||||
/**
|
||||
* Answered with some other refusal.
|
||||
*/
|
||||
refused: number,
|
||||
/**
|
||||
* Probes that got no answer at all.
|
||||
*/
|
||||
failed: number,
|
||||
/**
|
||||
* The median markup of the quotes, in basis points.
|
||||
*/
|
||||
median_markup_bps: number | null, };
|
||||
@@ -0,0 +1,58 @@
|
||||
// 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 probe: whether 1Click would quote one trade, and what this observer saw
|
||||
* that could have filled it (blackbeard/observer#45).
|
||||
*/
|
||||
export type QuoteProbe = {
|
||||
/**
|
||||
* When.
|
||||
*/
|
||||
at: string,
|
||||
/**
|
||||
* `sell` gives the asset for the counter token; `buy` the reverse.
|
||||
*/
|
||||
direction: string,
|
||||
/**
|
||||
* The counter token.
|
||||
*/
|
||||
counter: string,
|
||||
/**
|
||||
* The input, base units of the token given.
|
||||
*/
|
||||
size: BigUintDec,
|
||||
/**
|
||||
* `quoted`, `no_liquidity`, `refused`, or `failed` — the last meaning the
|
||||
* probe got no answer, which says nothing about the solvers.
|
||||
*/
|
||||
outcome: string,
|
||||
/**
|
||||
* The service's own words, or why the probe failed.
|
||||
*/
|
||||
message: string | null,
|
||||
/**
|
||||
* How long the service took, in milliseconds.
|
||||
*/
|
||||
latency_ms: number | null,
|
||||
/**
|
||||
* What the pool's own `quote` view gave for the same input, read from our
|
||||
* node at the same moment: what an on-chain fill would have paid.
|
||||
*/
|
||||
pool_out: BigUintDec | null,
|
||||
/**
|
||||
* For a quote, how much less than the pool it paid, in basis points.
|
||||
*/
|
||||
markup_bps: number | null,
|
||||
/**
|
||||
* The solver holding the most of the output token inside intents.near.
|
||||
*/
|
||||
best_solver: string | null,
|
||||
/**
|
||||
* What it held, base units of the output token.
|
||||
*/
|
||||
best_held: BigUintDec | null,
|
||||
/**
|
||||
* How many solvers each held at least `pool_out` of the output token.
|
||||
*/
|
||||
solvers_able: number | null, };
|
||||
@@ -0,0 +1,38 @@
|
||||
// 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 { NearToken } from "./NearToken";
|
||||
import type { QuoteAvailability } from "./QuoteAvailability";
|
||||
import type { QuoteProbe } from "./QuoteProbe";
|
||||
|
||||
/**
|
||||
* The probe's record over a window.
|
||||
*/
|
||||
export type QuoteProbes = {
|
||||
/**
|
||||
* Which chain's token.
|
||||
*/
|
||||
chain: ChainId,
|
||||
/**
|
||||
* The token, as NEAR Intents names it.
|
||||
*/
|
||||
asset: string,
|
||||
/**
|
||||
* The window, in hours.
|
||||
*/
|
||||
hours: number,
|
||||
/**
|
||||
* Whether the probe is configured to run at all.
|
||||
*/
|
||||
probing: boolean,
|
||||
/**
|
||||
* Every probe in the window, oldest first.
|
||||
*/
|
||||
probes: Array<QuoteProbe>,
|
||||
/**
|
||||
* Per direction and counter, over the window.
|
||||
*/
|
||||
availability: Array<QuoteAvailability>,
|
||||
/**
|
||||
* Display facts for the asset and every counter.
|
||||
*/
|
||||
tokens: Array<NearToken>, };
|
||||
@@ -31,6 +31,7 @@ import type { IntentsSwaps } from './generated/IntentsSwaps'
|
||||
import type { IntentsAccount } from './generated/IntentsAccount'
|
||||
import type { Markets } from './generated/Markets'
|
||||
import type { NearTx } from './generated/NearTx'
|
||||
import type { QuoteProbes } from './generated/QuoteProbes'
|
||||
import type { RefPools } from './generated/RefPools'
|
||||
import type { RefSwaps } from './generated/RefSwaps'
|
||||
import type { ReversibleState } from './generated/ReversibleState'
|
||||
@@ -455,3 +456,12 @@ export async function fetchIntentsAccount(
|
||||
): Promise<IntentsAccount> {
|
||||
return get<IntentsAccount>(`/chains/${chain}/near/intents/${encodeURIComponent(account)}`, signal)
|
||||
}
|
||||
|
||||
/** Whether 1Click would quote the asset, against what could have filled it. */
|
||||
export async function fetchQuoteProbes(
|
||||
chain: string,
|
||||
hours: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<QuoteProbes> {
|
||||
return get<QuoteProbes>(`/chains/${chain}/intents/quotes?hours=${hours}`, signal)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { FillsPanel } from './FillsPanel'
|
||||
import { IntentsPanel } from './IntentsPanel'
|
||||
import { InventoryPanel } from './InventoryPanel'
|
||||
import { PriceChart } from './PriceChart'
|
||||
import { QuotePanel } from './QuotePanel'
|
||||
import { PoolPriceChart } from './RefDclMarket'
|
||||
import { RefDclMarket } from './RefDclMarket'
|
||||
import { SwapHead, SwapRow } from './SwapRow'
|
||||
@@ -161,6 +162,8 @@ function NearIntents({
|
||||
|
||||
<IntentsPanel chain={chain} decimals={decimals} symbol={symbol} tiles={8} table />
|
||||
|
||||
<QuotePanel chain={chain} symbol={symbol} />
|
||||
|
||||
<BridgePanel chain={chain} decimals={decimals} symbol={symbol} />
|
||||
|
||||
<BridgeLinksPanel chain={chain} symbol={symbol} />
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Whether 1Click would quote the token, set against whether anyone could have
|
||||
* filled it (blackbeard/observer#45).
|
||||
*
|
||||
* On NEAR Intents a price exists only when a solver chooses to quote, and
|
||||
* "No liquidity available" is all a user is told when none does. The chain
|
||||
* records trades, never refusals. So the observer asks — keyless, `dry`, once
|
||||
* a minute each way — and beside every answer reads two things it can see for
|
||||
* itself: what the pool would pay for the same trade, and what each solver held
|
||||
* of the token the trade would need, one solver at a time.
|
||||
*
|
||||
* The strip is an emphasis chart, not a categorical one: its story is one
|
||||
* state, "no quote while a solver held enough to fill it", and that state alone
|
||||
* takes the accent. Everything else is the quiet ink, told apart by height
|
||||
* rather than hue — a quote, a refusal nobody could have filled, a probe that
|
||||
* got no answer. `--mark-quiet` is validated against `--accent` in both themes
|
||||
* (index.css).
|
||||
*
|
||||
* It records and does not accuse. A refusal while inventory existed has
|
||||
* benign explanations — risk limits, inventory reserved for other flows, a
|
||||
* pause during an audit — and the page says so beside the figures.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import type { QuoteAvailability } from '../api/generated/QuoteAvailability'
|
||||
import type { QuoteProbe } from '../api/generated/QuoteProbe'
|
||||
import type { QuoteProbes } from '../api/generated/QuoteProbes'
|
||||
import { RequestFailed, fetchQuoteProbes } from '../api/rest'
|
||||
import { tokens as fmtTokens, utc, when, zone } from '../lib/format'
|
||||
import { shortNear } from '../lib/near'
|
||||
import { tokenLabel } from '../lib/tokens'
|
||||
|
||||
const SPANS: [number, string][] = [
|
||||
[6, '6 h'],
|
||||
[24, '24 h'],
|
||||
[72, '3 d'],
|
||||
]
|
||||
|
||||
const REFRESH_MS = 60_000
|
||||
|
||||
/** Cells per strip, whatever the span: each is one bucket of minutes. */
|
||||
const BUCKETS = 144
|
||||
|
||||
/** Strip height, in the SVG's units and in pixels. */
|
||||
const STRIP = 16
|
||||
|
||||
/** What a bucket shows, most significant first. */
|
||||
type State = 'withheld' | 'quoted' | 'unquoted' | 'failed' | 'none'
|
||||
|
||||
/** A probe that came back "no liquidity" while a solver held enough. */
|
||||
function withheld(p: QuoteProbe): boolean {
|
||||
return p.outcome === 'no_liquidity' && (p.solvers_able ?? 0) > 0
|
||||
}
|
||||
|
||||
function stateOf(p: QuoteProbe): State {
|
||||
if (withheld(p)) return 'withheld'
|
||||
if (p.outcome === 'quoted') return 'quoted'
|
||||
if (p.outcome === 'failed') return 'failed'
|
||||
return 'unquoted'
|
||||
}
|
||||
|
||||
const RANK: Record<State, number> = { withheld: 4, quoted: 3, unquoted: 2, failed: 1, none: 0 }
|
||||
|
||||
const LEGEND: { state: State; label: string }[] = [
|
||||
{ state: 'withheld', label: 'no quote, while a solver held enough to fill it' },
|
||||
{ state: 'quoted', label: 'quoted' },
|
||||
{ state: 'unquoted', label: 'no quote, and no solver held enough' },
|
||||
{ state: 'failed', label: 'the probe got no answer' },
|
||||
]
|
||||
|
||||
export function QuotePanel({ chain, symbol: chainSymbol }: { chain: string; symbol: string }) {
|
||||
const [hours, setHours] = useState(24)
|
||||
const [data, setData] = useState<QuoteProbes | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [readout, setReadout] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const load = () =>
|
||||
fetchQuoteProbes(chain, hours, controller.signal)
|
||||
.then((d) => {
|
||||
setData(d)
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
load()
|
||||
const id = window.setInterval(load, REFRESH_MS)
|
||||
return () => {
|
||||
window.clearInterval(id)
|
||||
controller.abort()
|
||||
}
|
||||
}, [chain, hours])
|
||||
|
||||
const meta = (token: string) => data?.tokens.find((t) => t.token === token)
|
||||
const label = (token: string) => tokenLabel(token, meta(token)?.symbol)
|
||||
// The chain's own symbol when it has been read, else the token's on NEAR.
|
||||
const symbol = chainSymbol || (data ? label(data.asset) : '')
|
||||
const amount = (raw: string | null, token: string) => {
|
||||
const m = meta(token)
|
||||
return raw && m ? `${fmtTokens(raw, m.decimals)} ${label(token)}` : '—'
|
||||
}
|
||||
/** The token a probe's trade gives, and the one it would receive. */
|
||||
const sides = (p: { direction: string; counter: string }) =>
|
||||
p.direction === 'sell'
|
||||
? { given: data?.asset ?? '', received: p.counter }
|
||||
: { given: p.counter, received: data?.asset ?? '' }
|
||||
|
||||
const rows = useMemo(() => strips(data), [data])
|
||||
const incidents = useMemo(
|
||||
() => (data ? data.probes.filter(withheld).slice(-12).reverse() : []),
|
||||
[data],
|
||||
)
|
||||
|
||||
const describe = (p: QuoteProbe) => {
|
||||
const { given, received } = sides(p)
|
||||
const trade = `${p.direction === 'sell' ? 'selling' : 'buying with'} ${amount(p.size, given)}`
|
||||
const pool = p.pool_out ? `; the pool would have paid ${amount(p.pool_out, received)}` : ''
|
||||
const best = p.best_solver
|
||||
? `; ${shortNear(p.best_solver)} held ${amount(p.best_held, received)}`
|
||||
: ''
|
||||
const verdict =
|
||||
p.outcome === 'quoted'
|
||||
? `quoted${p.markup_bps !== null ? `, ${(p.markup_bps / 100).toFixed(2)}% below the pool` : ''}`
|
||||
: p.outcome === 'failed'
|
||||
? `no answer (${p.message ?? 'probe failed'})`
|
||||
: `“${p.message ?? p.outcome}”`
|
||||
return `${when(p.at)}, ${trade}: ${verdict}${pool}${best}`
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Will anyone quote {symbol}?</h2>
|
||||
<span className="eyebrow">
|
||||
1Click, asked every minute, against what could have filled it
|
||||
</span>
|
||||
</div>
|
||||
<div className="pager" style={{ justifyContent: 'flex-start' }}>
|
||||
{SPANS.map(([h, l]) => (
|
||||
<button
|
||||
key={h}
|
||||
className="segmented panel-button"
|
||||
aria-pressed={hours === h}
|
||||
onClick={() => setHours(h)}
|
||||
>
|
||||
{l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <p className="empty">{error}</p>}
|
||||
{!error && !data && <p className="empty">Reading the probe record…</p>}
|
||||
{data && !data.probing && <p className="empty">The quote probe is not configured here.</p>}
|
||||
{data && data.probing && data.probes.length === 0 && (
|
||||
<p className="empty">No probes yet in this window: the first runs on the next minute.</p>
|
||||
)}
|
||||
|
||||
{data && rows.length > 0 && (
|
||||
<>
|
||||
<div className="quote-strips">
|
||||
{rows.map((row) => (
|
||||
<div key={`${row.direction}-${row.counter}`} className="quote-strip">
|
||||
<div className="quote-strip-label">
|
||||
{row.direction === 'sell'
|
||||
? `Selling ${symbol} for ${label(row.counter)}`
|
||||
: `Buying ${symbol} with ${label(row.counter)}`}
|
||||
<span className="receipt-meta">
|
||||
{' '}
|
||||
· {amount(row.size, sides(row).given)} each minute
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
className="quote-cells"
|
||||
viewBox={`0 0 ${BUCKETS * 6} ${STRIP}`}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={summaryText(row, data.availability)}
|
||||
onMouseLeave={() => setReadout(null)}
|
||||
>
|
||||
<line
|
||||
className="quote-baseline"
|
||||
x1={0}
|
||||
x2={BUCKETS * 6}
|
||||
y1={STRIP - 0.5}
|
||||
y2={STRIP - 0.5}
|
||||
/>
|
||||
{row.buckets.map((b, i) => (
|
||||
<g
|
||||
key={i}
|
||||
onMouseEnter={() =>
|
||||
setReadout(
|
||||
b.last
|
||||
? `${b.count} probe${b.count === 1 ? '' : 's'} — latest ${describe(b.last)}`
|
||||
: `${when(new Date(b.from).toISOString())}: no probe`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* The hit area is the whole column, wider than any mark. */}
|
||||
<rect x={i * 6} y={0} width={6} height={STRIP} fill="transparent" />
|
||||
<Cell state={b.state} x={i * 6} />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="quote-axis receipt-meta">
|
||||
<span>{rows[0] ? when(new Date(rows[0].from).toISOString()) : ''}</span>
|
||||
<span>times in {zone()}</span>
|
||||
<span>now</span>
|
||||
</div>
|
||||
<p className="quote-readout receipt-meta" aria-live="polite">
|
||||
{readout ?? 'Hover the strip for any moment’s probes.'}
|
||||
</p>
|
||||
<ul className="quote-legend">
|
||||
{LEGEND.map((l) => (
|
||||
<li key={l.state}>
|
||||
<svg width={10} height={16} aria-hidden="true">
|
||||
<Cell state={l.state} x={2} height={16} />
|
||||
</svg>
|
||||
{l.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">
|
||||
Probe outcomes over the last {data.hours} hours, by direction and counter token
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
Trade
|
||||
</th>
|
||||
<th scope="col">Answered</th>
|
||||
<th scope="col">Quoted</th>
|
||||
<th scope="col">No quote</th>
|
||||
<th scope="col">…while a solver could fill</th>
|
||||
<th scope="col">No answer</th>
|
||||
<th scope="col">Median markup</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.availability.map((a) => (
|
||||
<tr key={`${a.direction}-${a.counter}`}>
|
||||
<td className="left">
|
||||
{a.direction === 'sell' ? 'sell' : 'buy'} {symbol} · {label(a.counter)}
|
||||
</td>
|
||||
<td className="numeral">{a.answered}</td>
|
||||
<td className="numeral">{share(a.quoted, a.answered)}</td>
|
||||
<td className="numeral">{share(a.no_liquidity, a.answered)}</td>
|
||||
<td className="numeral">
|
||||
<strong>{share(a.no_liquidity_while_able, a.answered)}</strong>
|
||||
</td>
|
||||
<td className="numeral">{a.failed}</td>
|
||||
<td className="numeral">
|
||||
{a.median_markup_bps === null
|
||||
? '—'
|
||||
: `${(a.median_markup_bps / 100).toFixed(2)}%`}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{incidents.length > 0 && (
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">
|
||||
The latest probes answered “no liquidity” while a solver held enough
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
When
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
Trade asked for
|
||||
</th>
|
||||
<th scope="col">Pool would pay</th>
|
||||
<th scope="col" className="left">
|
||||
Largest solver holding
|
||||
</th>
|
||||
<th scope="col">Solvers able</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{incidents.map((p) => {
|
||||
const { given, received } = sides(p)
|
||||
return (
|
||||
<tr key={`${p.at}-${p.direction}-${p.counter}-${p.size}`}>
|
||||
<td className="left numeral event-when" title={utc(p.at)}>
|
||||
{when(p.at)}
|
||||
</td>
|
||||
<td className="left">
|
||||
{p.direction} {amount(p.size, given)}
|
||||
</td>
|
||||
<td className="numeral">{amount(p.pool_out, received)}</td>
|
||||
<td className="left" title={p.best_solver ?? undefined}>
|
||||
{p.best_solver
|
||||
? `${shortNear(p.best_solver)}: ${amount(p.best_held, received)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="numeral">{p.solvers_able ?? '—'}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="panel-note">
|
||||
Each minute the observer asks 1Click for a dry quote — nothing is committed — selling and
|
||||
buying {symbol} at the smallest size, and every size each quarter hour, without a key and
|
||||
inside the rate limit 1Click publishes. At the same moment it reads, from its own NEAR
|
||||
nodes, what the Ref pool would pay for the same trade and what each known solver holds of
|
||||
the token the trade would need. A solver counts as able only if it alone held at least what
|
||||
the pool would pay: solvers do not pool inventory. “No liquidity available” while a solver
|
||||
was able says inventory existed and no quote was given; it does not say why. Risk limits,
|
||||
inventory reserved for other flows, or a pause during an audit would all look the same from
|
||||
here, and which solvers 1Click asked, and what they answered, is private to them. 1Click’s
|
||||
terms allow no API data to be kept beyond a day, so only these observations are kept: the
|
||||
quote itself becomes a markup against the pool and is then discarded. A probe that got no
|
||||
answer is shown as that, never as a refusal.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function share(n: number, of: number): string {
|
||||
if (of === 0) return '—'
|
||||
return `${n} (${Math.round((n / of) * 100)}%)`
|
||||
}
|
||||
|
||||
function summaryText(
|
||||
row: { direction: string; counter: string },
|
||||
availability: QuoteAvailability[],
|
||||
): string {
|
||||
const a = availability.find((x) => x.direction === row.direction && x.counter === row.counter)
|
||||
if (!a) return 'no probes'
|
||||
return `${a.answered} answered: ${a.quoted} quoted, ${a.no_liquidity} no quote, of which ${a.no_liquidity_while_able} while a solver could fill, ${a.failed} unanswered`
|
||||
}
|
||||
|
||||
/** One cell: height and fill say the state; only the one that matters is in the accent. */
|
||||
function Cell({ state, x, height = STRIP }: { state: State; x: number; height?: number }) {
|
||||
// Thin marks with a wide surface gap: a day of refusals is a run of
|
||||
// hairlines, not a slab.
|
||||
const w = 2.5
|
||||
switch (state) {
|
||||
case 'withheld':
|
||||
return <rect className="quote-withheld" x={x} y={0} width={w} height={height} rx={1} />
|
||||
case 'quoted':
|
||||
return (
|
||||
<rect
|
||||
className="quote-quoted"
|
||||
x={x}
|
||||
y={height * 0.4}
|
||||
width={w}
|
||||
height={height * 0.6}
|
||||
rx={1}
|
||||
/>
|
||||
)
|
||||
case 'unquoted':
|
||||
return (
|
||||
<rect
|
||||
className="quote-unquoted"
|
||||
x={x + 0.5}
|
||||
y={height * 0.7}
|
||||
width={w - 1}
|
||||
height={height * 0.3 - 0.5}
|
||||
rx={1}
|
||||
/>
|
||||
)
|
||||
case 'failed':
|
||||
return <rect className="quote-failed" x={x} y={height - 2} width={w} height={2} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface Bucket {
|
||||
from: number
|
||||
state: State
|
||||
count: number
|
||||
last: QuoteProbe | null
|
||||
}
|
||||
|
||||
interface Strip {
|
||||
direction: string
|
||||
counter: string
|
||||
/** The every-minute size. */
|
||||
size: string
|
||||
from: number
|
||||
buckets: Bucket[]
|
||||
}
|
||||
|
||||
/** One strip per direction and counter, from the every-minute (smallest) probes. */
|
||||
function strips(data: QuoteProbes | null): Strip[] {
|
||||
if (!data) return []
|
||||
const end = Date.now()
|
||||
const start = end - data.hours * 3_600_000
|
||||
const width = (end - start) / BUCKETS
|
||||
const out: Strip[] = []
|
||||
for (const a of data.availability) {
|
||||
const own = data.probes.filter((p) => p.direction === a.direction && p.counter === a.counter)
|
||||
if (own.length === 0) continue
|
||||
// The every-minute size is the smallest one probed.
|
||||
const size = own.reduce((m, p) => (BigInt(p.size) < BigInt(m) ? p.size : m), own[0]!.size)
|
||||
const buckets: Bucket[] = Array.from({ length: BUCKETS }, (_, i) => ({
|
||||
from: start + i * width,
|
||||
state: 'none' as State,
|
||||
count: 0,
|
||||
last: null,
|
||||
}))
|
||||
for (const p of own) {
|
||||
if (p.size !== size) continue
|
||||
const i = Math.min(BUCKETS - 1, Math.floor((Date.parse(p.at) - start) / width))
|
||||
const b = buckets[i]
|
||||
if (!b) continue
|
||||
b.count += 1
|
||||
const s = stateOf(p)
|
||||
if (RANK[s] > RANK[b.state]) b.state = s
|
||||
if (!b.last || Date.parse(p.at) > Date.parse(b.last.at)) b.last = p
|
||||
}
|
||||
out.push({ direction: a.direction, counter: a.counter, size, from: start, buckets })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -54,6 +54,11 @@
|
||||
--text-primary: #f2ece0;
|
||||
--text-secondary: #a2937c;
|
||||
--text-muted: #7a6c59;
|
||||
/* The quiet mark beside an accented one, where a chart's story is a single
|
||||
state (emphasis, not categories): the quote strip (#45). Validated against
|
||||
--accent on this surface: CVD ΔE 8.8 (deutan), normal 17.9. Low chroma is
|
||||
the point — it is the gray the accent stands out from. */
|
||||
--mark-quiet: #a2937c;
|
||||
|
||||
/* The single data hue: magnitude, share bars, the history area. */
|
||||
--data: #bd8829;
|
||||
@@ -108,6 +113,9 @@
|
||||
--text-primary: #1b1710;
|
||||
--text-secondary: #5b5142;
|
||||
--text-muted: #7c7263;
|
||||
/* Darker than the dark theme's step: against this --accent the secondary ink
|
||||
scores CVD ΔE 4.5 (protan), a hard fail. #3a3226 scores 14.7. */
|
||||
--mark-quiet: #3a3226;
|
||||
|
||||
/* The bronze, re-stepped. `--data-bright` is *darker* than `--data`: it is
|
||||
the emphasis step, and emphasis on paper is weight, not glare. */
|
||||
@@ -2111,3 +2119,78 @@ tr.mine .share-bar > i {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* The quote strip (blackbeard/observer#45). An emphasis chart: one state in
|
||||
the accent, the rest in the quiet ink, told apart by height — full for "no
|
||||
quote while a solver could fill", 60% for a quote, a hollow 30% for a refusal
|
||||
nobody could have filled, a baseline tick for a probe that got no answer.
|
||||
Height carries the meaning in greyscale, print and forced-colors too. */
|
||||
.quote-strips {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
.quote-strip-label {
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.quote-cells {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.quote-baseline {
|
||||
stroke: var(--border);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.quote-withheld {
|
||||
fill: var(--accent);
|
||||
}
|
||||
|
||||
.quote-quoted {
|
||||
fill: var(--mark-quiet);
|
||||
}
|
||||
|
||||
.quote-unquoted {
|
||||
fill: none;
|
||||
stroke: var(--mark-quiet);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.quote-failed {
|
||||
fill: var(--text-muted);
|
||||
}
|
||||
|
||||
.quote-axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 16px 0;
|
||||
}
|
||||
|
||||
.quote-readout {
|
||||
padding: 6px 16px 0;
|
||||
min-height: 1.6em;
|
||||
}
|
||||
|
||||
.quote-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 18px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 8px 16px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quote-legend li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user