Skip to content

Commit 56abc6c

Browse files
authored
fix(usage): correct chart clipping, expand truncated rows, add source mix (#7199)
* fix(usage): correct chart clipping, expand truncated rows, add source mix Two rendering defects on the organization usage panel, plus the follow-on cleanup they surfaced. The y-axis maximum was clipped at the container's left edge. The chart family used a fixed 26px left gutter, which leaves 18px of drawable width once the label gap is taken out — four narrow glyphs — so every tick from `7.3k` up lost its first character. Both charts now derive the gutter from the labels they are about to draw, through one shared `resolveChartPadding` so a bar and a line chart still line up when stacked. Hovering near the foot of the plot raised a vertical scrollbar over the chart. The scroll container sets `overflow-x`, which promotes `overflow-y` to `auto`, and the tooltip's vertical clamp was a fixed inset that ignored the box's real height. The clamp now measures the tooltip, and the container pins `overflow-y`. Also on the panel: - The axis rules were invisible. `hsl(var(--border))` is unparseable — the token is a hex — so the presentation attribute was dropped and SVG's initial `stroke: none` applied. - GLM rendered without a mark: the settings provider-icon map held 11 of the registry's 24 providers. Completed, with a test that fails when the two drift. The server's parallel label map was the same 11-entry duplicate and now reads the registry directly. - `Other (N more)` opens the tail in place, raising the row limit to the API's ceiling. Fixed the drill-down name lookup this exposed, which was pinned to the top ten and so refused to open for any row below it. - A radar chart of the source mix sits beside the Sources list. The rows rank the sources; they cannot show whether spend is concentrated or spread. - "Open logs" pointed at `/workspace/<id>/logs` for a workspace picked from an organization-wide list. Organization admin is not workspace membership, so for any workspace the admin had not joined it was a one-way trip to an access denial. It now opens the organization's audit feed scoped to that workspace, which required threading the workspace filter the query builder already supported through the internal contract, route, hook, and URL state. - BYOK is withheld from the tab strip until the ledger carries BYOK rows. - The chip number field suppresses the native stepper, which painted browser chrome inside a flat chip surface. The component owns it, not its callers. - `ChartColumn` was a uniform 0.86 downscale of its source art, so it rendered ~2px small beside every other icon in the settings nav. * fix(usage): repair the chart family's shared geometry and the audit export Follow-up review of the previous commit, across the whole diff. The CSV export ignored the workspace filter. The contract accepted it, the on-screen feed applied it, and the export route dropped it on the floor — every field of `AuditLogFilterParams` is optional, so omitting one still type-checks. An admin exporting a workspace-scoped feed downloaded the whole organization, under a truncation warning that blamed the date range. The route now forwards the parsed query whole and refuses an out-of-organization id the way the list route does, with tests for both. The line chart's derived hover index was not clamped. The previous commit replaced stored state with a derivation, but reproduced the clamp only for the bar chart: `padding.left` follows the axis labels and `chartWidth` follows the container, so a sidebar collapse mid-hover pushed the ratio past 1 and indexed off the end — the dot, the rule and the tooltip all vanished until the cursor moved. Per-chart gutters de-aligned the logs dashboard, where three line charts sit in one row. Deriving each from its own labels put their plot origins at 26, 27 and 32 where they had shared one. The gutter now rounds up to a step, which collapses differences that small and leaves several pixels of slack instead of the sub-pixel margin `Math.ceil` alone gave. The radar chart, reviewed against its siblings: - Its hover targets were triangles, whose far edge is the chord. Along its own spoke a triangle reaches only `reach·cos(π/n)` — at three axes, 50px against a 74px radius — so the largest value's vertex, the one a reader aims at, sat outside every target. They are arc sectors now. - The tooltip was positioned against the scroll container rather than the plot, so below the width floor it stayed nailed while the web slid under it. It now sits beside the hovered vertex through the family's own placer, instead of covering the densest part of the gradient. - Captions below the centreline rode ~3px off the ring rather than the gap they were given, and captions beside the web were misaligned from their own vertex. - Web opacities, stroke width, vertex radius, the per-theme fill relationship and the `screen` blend now match the bar and line charts rather than freelancing. - Its rings read the shared grid fractions instead of dividing into even steps that agreed with the siblings only by coincidence. Also: one `expanded` flag drove both lists in the workspace drill-down, so opening either tail silently opened the other's; the Overview and tab lists rendered an inert `Other` row while the same row two clicks away was a button; the expand chevron knocked the value column out of alignment; row hover had regressed to the chip surface where every other settings row uses the active one; and the radar and the list beside it printed two different `Other (N more)` counts under identical wording. The Overview's two readings of the source data now share one section rather than drawing two half-width rules on one line. Rendered-geometry tests cover the clipping and caption bugs against the real SVG — a unit test of the helpers could not have caught either, since both came from a callsite combining correct helpers wrongly. * fix(usage): hide the workspace audit link where audit logs are disabled Usage monitoring and Audit logs share their hosted and enterprise gates, so reaching the usage panel proves both — but their self-hosted overrides are separate flags. An install with usage monitoring on and audit logs off was handed a drill-down action pointing at a section it had switched off. The window is deliberately not carried across the link: the audit feed speaks in rolling ranges and this panel in billing periods, so there is no honest mapping for the current-period preset. * fix(audit-logs): keep an unresolved workspace scope from widening the feed A workspace id in the URL that no longer resolves — deleted since, or never one of ours — dropped the filter, so a request for one workspace's history was answered with the whole organization's, under a URL that still claimed to be scoped. The CSV export followed the same filter and would have carried the same widening. Every other deep-linked id in the app degrades to the unfiltered view, which is right where the fallback shows less than was asked for. An audit feed is the one place where widening is the dangerous direction, so it now stays closed and says so, with the filter chip still rendered so the scope can be cleared. * fix(usage): correct the grid floor, tooltip height estimate, and Other row slot Three findings from review. A grid track minimum is a hard floor, so `minmax(320px, 1fr)` made the source section wider than its column on a narrow viewport and overflowed instead of collapsing. `min(320px, 100%)` caps the floor at the width actually available. The tooltip height estimate used font sizes where it needed line boxes. The type scale pairs no line-height with a size, so a line occupies the ambient 1.5 — a 10px date line is 15px, an 11px row is 16.5 — and the estimate came in ~1.5px under the real box. Since it is what the clamp measures against and the chart clips its overflow, an underestimate cuts the bottom off the box rather than moving it up. Now derived from the line boxes, every part rounded up, with a test that fails if any of the three constants drops below the rendered height. The `Other` row drew its disclosure chevron bare at 14px while the rows above reserved the 16px arrow or the 30px menu slot, pulling its figure out of the column. It now centres in the same slot the rest of the list reserves. * fix(audit-logs): present nothing when the workspace scope cannot be answered Disabling the query was not enough. An unresolved scope drops the filter, so its query key equals the unscoped feed's, and a disabled query still serves whatever is cached under its key — an admin reading the organization-wide feed who then followed a stale scoped link kept those rows on screen, with Export still armed against them because that gate reads the same list. The rule now has a name and a seam: `presentableAuditEntries` returns nothing unless the feed can answer the scope the URL asks for, and the export action states that condition where it is read rather than inheriting it through an empty list. * fix(audit-logs): make the placeholder scope-aware, and separate a failed lookup Two more from review, both on the same surface. The placeholder I added held previous pages across a workspace change, so following a scoped link from the organization-wide feed painted the organization's rows under a workspace-scoped URL until the scoped page arrived — with Export armed against them. Gating presentation on `isPlaceholderData` would have fixed it by throwing away the reason the placeholder exists, blanking the feed on every keystroke again. The scope now leads the query key instead, ahead of the filters, so "hold across a filter change, never across a scope change" is a prefix comparison — the same shape the breakdown query already uses, and it retires the hand-maintained key index. A failed workspace lookup was reported as a workspace that is not part of the organization. That is a wrong answer rather than a cautious one, and it offered nothing to do about it. The two states are now distinct, the error one says so, and Refresh retries the lookup alongside the feed so the control on screen can actually clear the state it is showing. * improvement(emcn): drop the number-stepper reset for plain text numeric fields The stepper was suppressed with a vendor-pseudo-element class string inside `ChipInput`. Removing browser chrome with custom CSS is the wrong end of the problem: the fields never wanted a stepper in the first place. They are text fields with a numeric input mode now — the choice the retry settings field already documents ("the native spinner is all that buys, and it does not fit the field chrome"). No CSS, the numeric keypad is unchanged, and `ChipModalField` gained an `inputMode` prop so a modal field can say the same thing without asking for the stepper. This also fixes a real defect in the credit-limit field. A number input reports `''` for anything the browser considers invalid, so a typo arrived indistinguishable from a cleared field and saved as "no limit"; as text it reaches the `Number.isInteger` check and is refused. The usage-limit field's `min` attribute went the same way — the minimum is enforced on commit, where it can explain itself, rather than silently by the browser. * chore(audit-logs): scope the refresh refetch, drop a test that could not fail `refetch` ignores `enabled`, so refreshing the unscoped feed fired a workspace lookup it has no use for and could fail a refresh that otherwise succeeded. It now runs only when a scope asked for it. The hook test claiming to cover an unresolved workspace scope passed `enabled: false` with no workspace at all, so it asserted TanStack's disabled handling and would have passed with the scope protection removed. The rule it named is derived in the component and is covered there by `presentableAuditEntries`; a test that cannot fail for its stated reason is worse than none. * fix(audit-logs): stop Refresh issuing the read the scope gate exists to prevent `refetch` ignores `enabled`, so pressing Refresh while the workspace scope was unresolved or its lookup had failed fired the audit query anyway — and its filter carries no workspace in that state, so the request was the organization-wide read the gate exists to prevent. The result was never presented, but it was still asked for. Refresh now repeats the gate: the feed is refetched only while the scope is answerable, and the lookup — the thing that has to succeed for a closed feed to reopen — is retried whenever a scope asked for it.
1 parent d8ebcc0 commit 56abc6c

40 files changed

Lines changed: 2189 additions & 474 deletions

apps/sim/app/api/audit-logs/export/route.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,29 @@ describe('GET /api/audit-logs/export', () => {
164164
expect(response.status).toBe(400)
165165
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
166166
})
167+
168+
/**
169+
* The export has to filter by everything the on-screen feed does. It did not
170+
* forward `workspaceId`, so an admin exporting from a workspace-scoped feed
171+
* downloaded the whole organization — silently, because every field of
172+
* `AuditLogFilterParams` is optional and dropping one still type-checks.
173+
*/
174+
it('forwards the workspace filter the on-screen feed applies', async () => {
175+
mockGetOrgWorkspaceIds.mockResolvedValue(['workspace-1'])
176+
177+
await GET(makeRequest('?workspaceId=workspace-1'))
178+
179+
expect(mockBuildFilterConditions).toHaveBeenCalledWith(
180+
expect.objectContaining({ workspaceId: 'workspace-1' })
181+
)
182+
})
183+
184+
it('rejects a workspaceId outside the organization, as the list route does', async () => {
185+
mockGetOrgWorkspaceIds.mockResolvedValue(['workspace-1'])
186+
187+
const response = await GET(makeRequest('?workspaceId=workspace-elsewhere'))
188+
189+
expect(response.status).toBe(400)
190+
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
191+
})
167192
})

apps/sim/app/api/audit-logs/export/route.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
6666
}
6767

6868
const { organizationId, orgMemberIds } = authResult.context
69-
const { search, action, resourceType, actorId, startDate, endDate, includeDeparted } =
70-
parsed.data.query
69+
const { actorId, workspaceId, includeDeparted } = parsed.data.query
7170

7271
if (actorId && !orgMemberIds.includes(actorId)) {
7372
return NextResponse.json(
@@ -77,20 +76,34 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
7776
}
7877

7978
const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
79+
/**
80+
* The same refusal `listAuditLogs` gives. The scope predicate already makes an
81+
* out-of-organization id return nothing, but an empty CSV and a 400 that names the
82+
* problem are very different answers to the same bad request, and the two paths
83+
* disagreeing about which one you get is what an audit trail cannot afford.
84+
*/
85+
if (workspaceId && !orgWorkspaceIds.includes(workspaceId)) {
86+
return NextResponse.json(
87+
{ error: 'workspaceId does not belong to your organization' },
88+
{ status: 400 }
89+
)
90+
}
8091
const scopeCondition = buildOrgScopeCondition({
8192
organizationId,
8293
orgWorkspaceIds,
8394
orgMemberIds,
8495
includeDeparted,
8596
})
86-
const filterConditions = buildFilterConditions({
87-
action,
88-
resourceType,
89-
actorId,
90-
search,
91-
startDate,
92-
endDate,
93-
})
97+
/**
98+
* The whole parsed query, not a hand-listed subset.
99+
*
100+
* Every field of `AuditLogFilterParams` is optional, so dropping one type-checks
101+
* silently — which is how `workspaceId` came to be accepted by the contract,
102+
* honoured by the list route, and ignored here: an admin looking at one
103+
* workspace's feed downloaded the entire organization's, under a truncation
104+
* warning that blamed the date range.
105+
*/
106+
const filterConditions = buildFilterConditions(parsed.data.query)
94107
const conditions = [scopeCondition, ...filterConditions]
95108

96109
const rows: ReturnType<typeof formatAuditLogEntry>[] = []

apps/sim/app/api/audit-logs/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const GET = defineInternalJsonRoute({
2727
action: query.action,
2828
resourceType: query.resourceType,
2929
actorId: query.actorId,
30+
workspaceId: query.workspaceId,
3031
startDate: query.startDate,
3132
endDate: query.endDate,
3233
},

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,11 @@ export function SettingsPage({ section }: SettingsPageProps) {
176176
<AuditLogs organizationId={organizationId} />
177177
)}
178178
{effectiveSection === 'usage' && organizationId && (
179-
<UsageMonitoring organizationId={organizationId} workspaceId={hostContext.workspace.id} />
179+
<UsageMonitoring
180+
organizationId={organizationId}
181+
eventsHref={`/workspace/${hostContext.workspace.id}/settings/usage/events`}
182+
auditLogsHref={`/workspace/${hostContext.workspace.id}/settings/audit-logs`}
183+
/>
180184
)}
181185
{effectiveSection === 'apikeys' && <ApiKeys scope='combined' />}
182186
{isBillingEnabled && effectiveSection === 'billing' && (

apps/sim/app/workspace/[workspaceId]/settings/components/billing/components/usage-limit-field/usage-limit-field.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,14 @@ export function UsageLimitField({
121121

122122
return (
123123
<SettingsSection label='Usage limit' headerAccessory={USAGE_LIMIT_INFO}>
124+
{/*
125+
Text with a numeric input mode rather than `type='number'`: the native stepper
126+
is all that type buys and it does not fit the chip chrome. The minimum is
127+
enforced on commit below, where it can explain itself, rather than by a `min`
128+
attribute the browser enforces silently.
129+
*/}
124130
<ChipInput
125-
type='number'
126131
inputMode='numeric'
127-
min={dollarsToCredits(minimumLimit)}
128132
value={draft}
129133
onChange={(e) => setDraft(e.target.value)}
130134
placeholder={
@@ -135,7 +139,6 @@ export function UsageLimitField({
135139
: String(dollarsToCredits(currentLimit))
136140
}
137141
disabled={!canEdit}
138-
inputClassName='[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
139142
/>
140143
</SettingsSection>
141144
)

apps/sim/app/workspace/[workspaceId]/settings/components/manage-credits-modal/manage-credits-modal.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,17 @@ export function ManageCreditsModal({
109109
value={isLoading ? 'Loading…' : creditsUsed}
110110
copyLabel='Copy credits used'
111111
/>
112+
{/*
113+
Text with a numeric input mode, not `inputType='number'` — the same choice
114+
the retry settings field documents. The native stepper is all the number
115+
type buys, and it paints browser chrome inside a flat chip surface. It also
116+
reports `''` for anything the browser considers invalid, so a typo arrived
117+
here indistinguishable from a cleared field and saved as "no limit"; as text
118+
it reaches the `Number.isInteger` check below and is refused.
119+
*/}
112120
<ChipModalField
113121
type='input'
114-
inputType='number'
122+
inputMode='numeric'
115123
title={
116124
<span className='inline-flex items-center gap-1.5'>
117125
Credit limit

apps/sim/app/workspace/[workspaceId]/settings/usage/events/loading.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export default function UsageEventsLoading() {
1616
onSelect: () => router.push(`/workspace/${workspaceId}/settings/usage`),
1717
}}
1818
title='Usage events'
19-
description='Every credit-consuming event behind your usage.'
19+
description="Every credit-consuming event across your organization's workspaces."
2020
/>
2121
)
2222
}

apps/sim/components/charts/bar-chart.tsx

Lines changed: 49 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,21 @@ import {
88
formatChartTimestamp,
99
} from '@/components/charts/chart-format'
1010
import {
11+
CHART_AXIS_LABEL_GAP,
1112
CHART_DEFAULT_HEIGHT,
1213
CHART_GRID_FRACTIONS,
13-
CHART_PADDING,
1414
CHART_TICK_FILL,
1515
CHART_TICK_FONT_SIZE,
1616
chartPlotBand,
1717
formatTimeTick,
18+
resolveChartPadding,
1819
resolveSpanMs,
1920
resolveTimeTickIndices,
2021
} from '@/components/charts/chart-geometry'
2122
import {
2223
ChartTooltip,
2324
ChartTooltipRow,
25+
estimateTooltipHeight,
2426
estimateTooltipWidth,
2527
positionChartTooltip,
2628
} from '@/components/charts/chart-tooltip'
@@ -47,6 +49,17 @@ interface BarChartProps {
4749
highlightIndex?: number
4850
}
4951

52+
/** Tick and tooltip text for a bucket's value, in the caller's unit. */
53+
function formatBarValue(value: number | undefined, unit: string | undefined): string {
54+
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
55+
const suffix = (unit ?? '').toLowerCase()
56+
if (suffix.includes('%')) return `${value.toFixed(1)}%`
57+
if (suffix === 'latency') return formatChartLatency(value)
58+
if (suffix.includes('ms')) return `${Math.round(value)}ms`
59+
if (suffix === 'credits') return formatChartCompactNumber(value)
60+
return `${Math.round(value)}${unit ?? ''}`
61+
}
62+
5063
/**
5164
* Discrete time buckets as bars.
5265
*
@@ -71,16 +84,11 @@ function BarChartComponent({
7184
const uniqueId = useId().replace(/:/g, '')
7285
const [containerRef, containerWidth] = useChartWidth()
7386
const width = containerWidth ?? 0
74-
const padding = CHART_PADDING
75-
const chartWidth = width - padding.left - padding.right
76-
const chartHeight = height - padding.top - padding.bottom
7787
const { yMin, yMax } = chartPlotBand(height)
7888
const isDark = useIsDarkTheme()
79-
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
8089
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
8190

82-
const colorTokens = useMemo(() => ({ base: color }), [color])
83-
const resolvedColors = useResolvedChartColors(colorTokens)
91+
const resolvedColors = useResolvedChartColors({ base: color })
8492
const resolvedColor = resolvedColors.base || color
8593

8694
const hasExternalWrapper = !label
@@ -100,10 +108,24 @@ function BarChartComponent({
100108
return peak <= 0 ? 1 : peak * 1.1
101109
}, [data])
102110

111+
const padding = resolveChartPadding([formatBarValue(maxValue, unit), '0'])
112+
const chartWidth = width - padding.left - padding.right
113+
const chartHeight = height - padding.top - padding.bottom
114+
103115
/** Slot geometry: every bucket owns an equal slice, with the bar centred in it. */
104116
const slot = data.length > 0 ? Math.max(1, chartWidth) / data.length : 0
105117
const barWidth = Math.max(1, Math.min(24, slot * 0.7))
106118

119+
/**
120+
* Bars own a slot, so the hovered bucket is which slot the cursor is in — not the
121+
* nearest sample, which is how a line chart resolves it. Derived, so a resize
122+
* mid-hover cannot leave an index disagreeing with the slot geometry.
123+
*/
124+
const hoverIndex =
125+
hoverPos === null || data.length === 0 || slot <= 0
126+
? null
127+
: Math.max(0, Math.min(data.length - 1, Math.floor((hoverPos.x - padding.left) / slot)))
128+
107129
const bars = useMemo(
108130
() =>
109131
data.map((point, index) => {
@@ -127,21 +149,14 @@ function BarChartComponent({
127149
[data, slot, barWidth, maxValue, chartHeight, height, padding.left, padding.top, yMin, yMax]
128150
)
129151

130-
const formatValue = (value?: number) => {
131-
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
132-
const suffix = (unit ?? '').toLowerCase()
133-
if (suffix.includes('%')) return `${value.toFixed(1)}%`
134-
if (suffix === 'latency') return formatChartLatency(value)
135-
if (suffix.includes('ms')) return `${Math.round(value)}ms`
136-
if (suffix === 'credits') return formatChartCompactNumber(value)
137-
return `${Math.round(value)}${unit ?? ''}`
138-
}
139-
140152
if (containerWidth === null) {
141153
return (
142154
<div
143155
ref={containerRef}
144-
className={cn('w-full', !hasExternalWrapper && 'rounded-lg border bg-card p-4')}
156+
className={cn(
157+
'w-full',
158+
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4'
159+
)}
145160
style={{ height }}
146161
/>
147162
)
@@ -156,7 +171,7 @@ function BarChartComponent({
156171
ref={containerRef}
157172
className={cn(
158173
'flex w-full items-center justify-center',
159-
!hasExternalWrapper && 'rounded-lg border bg-card p-4'
174+
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4'
160175
)}
161176
/*
162177
Height only. `width` is floored at CHART_MIN_WIDTH for the plot geometry,
@@ -185,8 +200,8 @@ function BarChartComponent({
185200
contradicted the constant's own note that the chart "scrolls rather than
186201
compresses". At or above the floor there is no overflow and nothing changes.
187202
*/
188-
'w-full overflow-x-auto',
189-
!hasExternalWrapper && 'rounded-[11px] border bg-card p-4 shadow-sm'
203+
'w-full overflow-x-auto overflow-y-hidden',
204+
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4 shadow-card'
190205
)}
191206
>
192207
{!hasExternalWrapper && (
@@ -202,20 +217,9 @@ function BarChartComponent({
202217
onMouseMove={(e) => {
203218
if (bars.length === 0 || slot <= 0) return
204219
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect()
205-
const x = e.clientX - rect.left
206-
// Bars own a slot, so the hovered bucket is which slot the cursor is in —
207-
// not the nearest sample, which is how a line chart resolves it.
208-
const index = Math.max(
209-
0,
210-
Math.min(data.length - 1, Math.floor((x - padding.left) / slot))
211-
)
212-
setHoverIndex(index)
213-
setHoverPos({ x, y: e.clientY - rect.top })
214-
}}
215-
onMouseLeave={() => {
216-
setHoverIndex(null)
217-
setHoverPos(null)
220+
setHoverPos({ x: e.clientX - rect.left, y: e.clientY - rect.top })
218221
}}
222+
onMouseLeave={() => setHoverPos(null)}
219223
>
220224
<defs>
221225
<linearGradient id={`bar-${uniqueId}`} x1='0' x2='0' y1='0' y2='1'>
@@ -229,7 +233,7 @@ function BarChartComponent({
229233
y1={padding.top}
230234
x2={padding.left}
231235
y2={height - padding.bottom}
232-
stroke='hsl(var(--border))'
236+
stroke='var(--border)'
233237
strokeWidth='1'
234238
/>
235239

@@ -240,7 +244,7 @@ function BarChartComponent({
240244
y1={padding.top + chartHeight * fraction}
241245
x2={width - padding.right}
242246
y2={padding.top + chartHeight * fraction}
243-
stroke='hsl(var(--muted))'
247+
stroke='var(--border)'
244248
strokeOpacity='0.35'
245249
strokeWidth='1'
246250
/>
@@ -313,18 +317,18 @@ function BarChartComponent({
313317
})}
314318

315319
<text
316-
x={padding.left - 8}
320+
x={padding.left - CHART_AXIS_LABEL_GAP}
317321
y={padding.top}
318322
textAnchor='end'
319323
fontSize={CHART_TICK_FONT_SIZE}
320324
fill={CHART_TICK_FILL}
321325
>
322326
{/* Same formatter the tooltip uses, or the axis and the hover disagree
323327
about what the numbers mean on any non-`credits` unit. */}
324-
{formatValue(maxValue)}
328+
{formatBarValue(maxValue, unit)}
325329
</text>
326330
<text
327-
x={padding.left - 8}
331+
x={padding.left - CHART_AXIS_LABEL_GAP}
328332
y={height - padding.bottom}
329333
textAnchor='end'
330334
fontSize={CHART_TICK_FONT_SIZE}
@@ -338,7 +342,7 @@ function BarChartComponent({
338342
y1={height - padding.bottom}
339343
x2={width - padding.right}
340344
y2={height - padding.bottom}
341-
stroke='hsl(var(--border))'
345+
stroke='var(--border)'
342346
strokeWidth='1'
343347
/>
344348
</svg>
@@ -347,20 +351,19 @@ function BarChartComponent({
347351
bars[hoverIndex] &&
348352
(() => {
349353
const bar = bars[hoverIndex]
350-
const value = formatValue(bar.point.value)
354+
const value = formatBarValue(bar.point.value, unit)
355+
const date = formatChartTimestamp(bar.point.timestamp)
351356
const { left, top } = positionChartTooltip({
352357
anchorX: hoverPos?.x ?? bar.x,
353358
anchorY: hoverPos?.y ?? bar.y,
354359
width,
355360
height,
356361
tooltipMaxWidth: estimateTooltipWidth(value.length),
362+
tooltipHeight: estimateTooltipHeight(1, Boolean(date)),
363+
padding,
357364
})
358365
return (
359-
<ChartTooltip
360-
left={left}
361-
top={top}
362-
date={formatChartTimestamp(bar.point.timestamp) || undefined}
363-
>
366+
<ChartTooltip left={left} top={top} date={date || undefined}>
364367
<ChartTooltipRow color={resolvedColor} value={value} />
365368
</ChartTooltip>
366369
)

0 commit comments

Comments
 (0)