diff --git a/apps/web/src/page/kit/widget-shell.ts b/apps/web/src/page/kit/widget-shell.ts index a1a89398ee..3f5215e6c8 100644 --- a/apps/web/src/page/kit/widget-shell.ts +++ b/apps/web/src/page/kit/widget-shell.ts @@ -37,7 +37,7 @@ const headerLegend = (h: HtmlBuilder, items: ReadonlyArray { it("formatBucketLabel names a midnight tick on a multi-day axis by its day alone", () => { const context = { rangeMs: 7 * 86_400_000, bucketSeconds: 3 * 3600, timeZone: "UTC", locale: "en-US" } expect(formatBucketLabel("2026-10-20T00:00:00Z", context, "tick")).toBe("Oct 20") - expect(formatBucketLabel("2026-10-20T12:00:00Z", context, "tick")).toBe("Oct 20, 12:00 PM") + expect(formatBucketLabel("2026-10-20T12:00:00Z", context, "tick")).toMatch(/^Oct 20, 12\sPM$/) + }) + + it("formatBucketLabel drops the minutes of a tick on the hour, on a 12-hour clock only", () => { + const day = { rangeMs: 6 * 3600 * 1000, bucketSeconds: 300, timeZone: "UTC" } + const us = { ...day, locale: "en-US" } + expect(formatBucketLabel("2026-10-20T06:00:00Z", us, "tick")).toMatch(/^6\sAM$/) + expect(formatBucketLabel("2026-10-20T06:30:00Z", us, "tick")).toMatch(/^6:30\sAM$/) + expect(formatBucketLabel("2026-10-20T06:00:00Z", { ...day, locale: "en-GB" }, "tick")).toBe("06:00") + // A :30 zone's hour is not UTC's. + expect( + formatBucketLabel("2026-10-20T06:30:00Z", { ...us, timeZone: "Asia/Kolkata" }, "tick"), + ).toMatch(/^12\sPM$/) + }) + + it("formatBucketLabel finds a tick on the hour in a locale that prints its own digits", () => { + const context = { rangeMs: 6 * 3600 * 1000, bucketSeconds: 300, timeZone: "UTC" } + const arabic = { ...context, locale: "en-US-u-nu-arab" } + expect(formatBucketLabel("2026-10-20T06:00:00Z", arabic, "tick")).toMatch(/^\u0666\sAM$/) + expect(formatBucketLabel("2026-10-20T06:30:00Z", arabic, "tick")).toMatch(/^\u0666:\u0663\u0660\sAM$/) + }) + + it("formatBucketLabel keeps the seconds of a tick on a window of minutes", () => { + const context = { rangeMs: 10 * 60 * 1000, bucketSeconds: 10, timeZone: "UTC", locale: "en-US" } + expect(formatBucketLabel("2026-10-20T06:00:00Z", context, "tick")).toMatch(/^6:00:00\sAM$/) }) it("infers spacing and range from tz-less buckets", () => { diff --git a/packages/ui/src/lib/format.ts b/packages/ui/src/lib/format.ts index 8331db94bd..9d7907b6fe 100644 --- a/packages/ui/src/lib/format.ts +++ b/packages/ui/src/lib/format.ts @@ -211,12 +211,64 @@ const isZonedMidnight = (date: Date, timeZone: string | undefined): boolean => date.toLocaleTimeString("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }) === "00:00" +const tickFormatters = new Map() + +const tickFormatter = ( + locale: string | undefined, + timeZone: string | undefined, + options: Intl.DateTimeFormatOptions, +): Intl.DateTimeFormat => { + const key = `${locale ?? ""}|${timeZone ?? ""}|${options.month ?? ""}|${options.hour ?? ""}|${options.minute ?? ""}|${options.second ?? ""}` + const cached = tickFormatters.get(key) + if (cached !== undefined) return cached + const formatter = new Intl.DateTimeFormat(locale, { timeZone, ...options }) + tickFormatters.set(key, formatter) + return formatter +} + +/** A 12-hour clock reads "6:00 AM"; a 24-hour one keeps its leading zero ("06:00"). */ +const isTwelveHourClock = (locale: string | undefined): boolean => + tickFormatter(locale, undefined, { hour: "numeric" }).resolvedOptions().hour12 === true + +/** + * A tick's clock, as short as the locale allows: a 12-hour clock drops the leading zero and, on + * the hour, the minutes ("6 AM", "6:30 AM"); a 24-hour clock keeps "06:00". + */ +const formatTickClock = ( + date: Date, + options: { + readonly locale: string | undefined + readonly timeZone: string | undefined + readonly withDate: boolean + readonly withSeconds: boolean + }, +): string => { + const day: Intl.DateTimeFormatOptions = options.withDate ? { month: "short", day: "numeric" } : {} + const twelveHour = isTwelveHourClock(options.locale) + const full = tickFormatter(options.locale, options.timeZone, { + ...day, + hour: twelveHour ? "numeric" : "2-digit", + minute: "2-digit", + second: options.withSeconds ? "2-digit" : undefined, + }) + // Read off a Latin-digit formatter: the label's own parts print in the locale's digits. + const onTheHour = + twelveHour && + !options.withSeconds && + tickFormatter("en-US", options.timeZone, { minute: "numeric" }).format(date) === "0" + const formatter = onTheHour + ? tickFormatter(options.locale, options.timeZone, { ...day, hour: "numeric" }) + : full + // An h24 clock prints midnight as "24:00". + return Str.replace(/(^|\s)24:/, "$100:")(formatter.format(date)) +} + /** * Format a bucket timestamp label that adapts based on the overall time range: * - >= 24h with daily buckets, or a tick on midnight: "Feb 14" - * - >= 24h with sub-day buckets: "Feb 14, 02:00 PM" - * - 30min - 24h: "02:00 PM" - * - <= 30min: "02:00:30 PM" + * - >= 24h with sub-day buckets: "Feb 14, 2 PM" + * - 30min - 24h: "2 PM", "2:30 PM" ("14:00" on a 24-hour clock) + * - <= 30min: "2:00:30 PM" */ export function formatBucketLabel( value: unknown, @@ -256,31 +308,10 @@ export function formatBucketLabel( if ((context.bucketSeconds ?? 0) >= 24 * 60 * 60 || isZonedMidnight(date, timeZone)) { return date.toLocaleDateString(locale, { timeZone, month: "short", day: "numeric" }) } - return Str.replace( - / 24:/, - " 00:", - )( - date.toLocaleString(locale, { - timeZone, - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }), - ) + return formatTickClock(date, { locale, timeZone, withDate: true, withSeconds: false }) } - return Str.replace( - /^24:/, - "00:", - )( - date.toLocaleTimeString(locale, { - timeZone, - hour: "2-digit", - minute: "2-digit", - second: includeSeconds ? "2-digit" : undefined, - }), - ) + return formatTickClock(date, { locale, timeZone, withDate: false, withSeconds: includeSeconds }) } const bucketLabelMap: Record = { diff --git a/packages/views/src/charts/bucket-tooltip.ts b/packages/views/src/charts/bucket-tooltip.ts index 9bd2a78e9c..3b3a45d9d5 100644 --- a/packages/views/src/charts/bucket-tooltip.ts +++ b/packages/views/src/charts/bucket-tooltip.ts @@ -1,6 +1,7 @@ import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import type { ChartTooltip } from "./chart-spec" import { hstack, hstackClass } from "../ui/stack" +import { swatch } from "../ui/swatch" import { typography } from "../ui/typography" import { Array as Arr } from "effect" @@ -18,14 +19,7 @@ export const bucketTooltipBody = ( h.div( [h.Class("grid min-w-[9rem] max-w-[22rem] items-start gap-1.5"), portal], [ - h.div( - [ - h.Class( - "border-border-subtle border-b pb-1 font-medium text-muted-foreground tracking-tight", - ), - ], - [tooltip.heading], - ), + h.div([h.Class("border-border-subtle border-b pb-1.5 text-muted-foreground")], [tooltip.heading]), h.div( [h.Class("grid gap-1.5")], Arr.map(tooltip.rows, (row, index) => @@ -39,18 +33,7 @@ export const bucketTooltipBody = ( ), ], [ - h.span([ - h.Class( - row.dashed - ? "size-2.5 shrink-0 rounded-xs border border-dashed" - : "size-2.5 shrink-0 rounded-xs", - ), - h.Style( - row.dashed - ? { "border-color": row.color } - : { "background-color": row.color }, - ), - ]), + swatch(h, { color: row.color, dashed: row.dashed }), hstack( h, { @@ -75,8 +58,7 @@ export const bucketTooltipBody = ( h, { tone: "foreground", - weight: "semibold", - mono: true, + weight: "medium", numeric: true, className: "shrink-0", }, @@ -101,11 +83,9 @@ export const bucketTooltipBody = ( }, [ typography(h, { tone: "muted" }, ["Total"]), - typography( - h, - { tone: "foreground", weight: "semibold", mono: true, numeric: true }, - [tooltip.total], - ), + typography(h, { tone: "foreground", weight: "medium", numeric: true }, [ + tooltip.total, + ]), ], ), ]), diff --git a/packages/views/src/charts/definitions/alert-rule.ts b/packages/views/src/charts/definitions/alert-rule.ts index b7b667dc4d..7591c259bd 100644 --- a/packages/views/src/charts/definitions/alert-rule.ts +++ b/packages/views/src/charts/definitions/alert-rule.ts @@ -1,5 +1,5 @@ import { focusCrosshair } from "../plot/plot-focus" -import { DASHED_Y_GRID } from "../plot/plot-grid" +import { Y_GRID } from "../plot/plot-grid" import { seriesRuns, silenceBridges } from "../plot/series-gaps" import { bucketTimeScale } from "../plot/plot-scales" import { formatBucketLabel } from "@maple/ui/lib/format" @@ -221,7 +221,7 @@ export const buildAlertRule = (spec: AlertRuleSpec): BuiltChart const STROKE_WIDTH = SERIES_STROKE_WIDTH -const STACKED_STROKE_WIDTH = 1 -const STACKED_FILL_OPACITY = 0.55 -const PARTIAL_STACKED_FILL_OPACITY = 0.2 +/** A band's top edge: thinner than a lone line, since a stack draws one per series. */ +const STACKED_STROKE_WIDTH = 1.25 +/** Light enough that the edges carry the shape and the grid reads through the stack. */ +const STACKED_FILL_OPACITY = 0.3 +const PARTIAL_STACKED_FILL_OPACITY = 0.12 const PARTIAL_FILL_GRADIENT = [AREA_FILL_PARTIAL, AREA_FILL_END] as const interface Series { @@ -170,6 +172,8 @@ export const buildQueryBuilderArea = (spec: AreaSpec): BuiltChart edge(solid, entry, false)), @@ -178,8 +182,8 @@ export const buildQueryBuilderArea = (spec: AreaSpec): BuiltChart band(solid, entry, false)), ...(hasDashed ? Arr.map(visible, (entry) => band(dashed, entry, true)) : []), - ...(isStacked ? [] : Arr.map(visible, (entry) => edge(solid, entry, false))), - ...(hasDashed ? Arr.map(visible, (entry) => edge(dashed, entry, true)) : []), + ...Arr.map(edgeOrder, (entry) => edge(solid, entry, false)), + ...(hasDashed ? Arr.map(edgeOrder, (entry) => edge(dashed, entry, true)) : []), ] const definition = defineChart({ diff --git a/packages/views/src/charts/definitions/bar.test.ts b/packages/views/src/charts/definitions/bar.test.ts new file mode 100644 index 0000000000..f67753c19f --- /dev/null +++ b/packages/views/src/charts/definitions/bar.test.ts @@ -0,0 +1,47 @@ +import { Array as Arr } from "effect" +import { describe, expect, test } from "vitest" +import type { TimeseriesRow } from "../timeseries-rows" +import { cellsOf } from "./bar" + +const SERIES = [ + { key: "s1", label: "api", color: "red" }, + { key: "s2", label: "web", color: "green" }, + { key: "s3", label: "worker", color: "blue" }, +] + +const row = (minute: number, values: Readonly>): TimeseriesRow => ({ + bucket: `2026-10-01 12:0${minute}:00`, + date: new Date(Date.UTC(2026, 9, 1, 12, minute)), + ...values, +}) + +/** The series keys whose cell is the top of its column, one entry per bucket. */ +const topsOf = (rows: ReadonlyArray): ReadonlyArray> => + Arr.map(rows, (bucket) => + Arr.map( + Arr.filter(cellsOf(rows, SERIES, -1, true), (cell) => cell.row === bucket && cell.top), + (cell) => cell.key, + ), + ) + +describe("cellsOf", () => { + test("the top of a stack is its last series with a value, not the last series", () => { + const rows = [ + row(0, { s1: 5, s2: 3, s3: 0 }), + row(1, { s1: 0, s2: 0, s3: 2 }), + row(2, { s1: 4, s2: 0, s3: 0 }), + ] + + expect(topsOf(rows)).toEqual([["s2"], ["s3"], ["s1"]]) + }) + + test("an empty bucket has no top", () => { + expect(topsOf([row(0, { s1: 0, s2: 0, s3: 0 })])).toEqual([[]]) + }) + + test("a stacked cell starts where the series under it end", () => { + const cells = cellsOf([row(0, { s1: 5, s2: 3, s3: 2 })], SERIES, -1, true) + + expect(Arr.map(cells, (cell) => cell.base)).toEqual([0, 5, 8]) + }) +}) diff --git a/packages/views/src/charts/definitions/bar.ts b/packages/views/src/charts/definitions/bar.ts index 317925c000..38d0045c3e 100644 --- a/packages/views/src/charts/definitions/bar.ts +++ b/packages/views/src/charts/definitions/bar.ts @@ -27,6 +27,7 @@ type BarSpec = Extract /** Matches the Recharts `maxBarSize={48}`. */ const MAX_BAR_THICKNESS = 48 const BAR_RADIUS = 2 +const STACK_TOP_RADIUS = [BAR_RADIUS, BAR_RADIUS, 0, 0] as const const PARTIAL_FILL_OPACITY = 0.45 const DIMMED_FILL_OPACITY = 0.3 const DIMMED_PARTIAL_FILL_OPACITY = 0.15 @@ -40,6 +41,8 @@ interface BarCell { /** The running total beneath it in a stack; 0 otherwise. */ readonly base: number readonly partial: boolean + /** The last non-empty cell of its bucket's stack: the one whose top is the column's. */ + readonly top: boolean } // `bucketBandDomain` and `timeseriesBandXAxis`. @@ -82,7 +85,7 @@ interface Series { } /** The cells of every visible series per bucket, in `visible` order (the stack is bottom-up). */ -const cellsOf = ( +export const cellsOf = ( rows: ReadonlyArray, visible: ReadonlyArray, first: number, @@ -90,6 +93,10 @@ const cellsOf = ( ): ReadonlyArray => Arr.flatMap(rows, (row, index) => { const partial = first !== -1 && index >= first + const topIndex = Option.getOrElse( + Arr.findLastIndex(visible, (entry) => asFiniteNumber(row[entry.key]) > 0), + () => -1, + ) return Arr.map(visible, (entry, entryIndex) => ({ row, key: entry.key, @@ -103,6 +110,7 @@ const cellsOf = ( ) : 0, partial, + top: topIndex === entryIndex, })) }) @@ -159,7 +167,10 @@ export const buildQueryBuilderBar = (spec: BarSpec): BuiltChart cell.color, fillOpacity: partialLane ? PARTIAL_FILL_OPACITY : undefined, maxThickness: MAX_BAR_THICKNESS, - radius: spec.stacked ? 0 : BAR_RADIUS, + // Only the column's top is rounded: a bar stands on the baseline, a segment on the one under it. + radius: spec.stacked + ? (cell: BarCell) => (cell.top ? STACK_TOP_RADIUS : 0) + : { end: BAR_RADIUS, stack: "each" }, layout: spec.stacked ? undefined : group(), states: [ { diff --git a/packages/views/src/charts/definitions/bench-overview.ts b/packages/views/src/charts/definitions/bench-overview.ts index 4963053159..745f98e7c5 100644 --- a/packages/views/src/charts/definitions/bench-overview.ts +++ b/packages/views/src/charts/definitions/bench-overview.ts @@ -1,3 +1,4 @@ +import { Y_GRID } from "../plot/plot-grid" import { focusCrosshair, focusDot } from "../plot/plot-focus" import { niceLinearDomain } from "../plot/plot-scales" import { @@ -59,7 +60,7 @@ const xScale = (axisContext: { rangeMs: number; bucketSeconds: number | undefine const yScale = (domain: readonly [number, number], format: (value: number) => string, nice: boolean) => ({ scale: scaleLinear().domain(domain), nice, - grid: true, + grid: Y_GRID, axis: { line: false, ticks: { size: 0, padding: 6, format: (value: number) => (value === 0 ? "0" : format(value)) }, diff --git a/packages/views/src/charts/definitions/histogram.ts b/packages/views/src/charts/definitions/histogram.ts index c739af15b5..95ebd40403 100644 --- a/packages/views/src/charts/definitions/histogram.ts +++ b/packages/views/src/charts/definitions/histogram.ts @@ -1,5 +1,5 @@ import { formatNumber } from "@maple/ui/lib/format" -import { DASHED_Y_GRID } from "../plot/plot-grid" +import { Y_GRID } from "../plot/plot-grid" import { integerTickValues, logYScale, minBarLength, niceLinearDomain } from "../plot/plot-scales" import { barY, defineChart, rect, type ChartPoint } from "@tanstack/charts" import { binX } from "@tanstack/charts/transform/bin" @@ -28,7 +28,7 @@ const countAxis = (maxCount: number, useLogY: boolean) => { baseline: 1, liftCount: (value: number) => value, y: { - grid: DASHED_Y_GRID, + grid: Y_GRID, scale: logYScale(maxCount), axis: { line: false, ticks: { size: 0, padding: 6, format: formatNumber } }, }, @@ -41,7 +41,7 @@ const countAxis = (maxCount: number, useLogY: boolean) => { baseline: 0, liftCount: (value: number) => lift(value) ?? value, y: { - grid: DASHED_Y_GRID, + grid: Y_GRID, scale: scaleLinear().domain(domain), axis: { line: false, ticks: { size: 0, padding: 6, values: ticks, format: formatNumber } }, }, diff --git a/packages/views/src/charts/definitions/infra-metric.ts b/packages/views/src/charts/definitions/infra-metric.ts index e1c724926f..070e6794de 100644 --- a/packages/views/src/charts/definitions/infra-metric.ts +++ b/packages/views/src/charts/definitions/infra-metric.ts @@ -21,7 +21,7 @@ import { decorative } from "@tanstack/charts/mark/decorative" import { scaleLinear } from "@tanstack/charts-scales/linear" import { bucketDate, makeBucketAxis } from "../plot/bucket-axis" import { focusCrosshair, focusDot } from "../plot/plot-focus" -import { DASHED_Y_GRID } from "../plot/plot-grid" +import { Y_GRID } from "../plot/plot-grid" import { type SeriesRuns, seriesRuns, silenceBridges } from "../plot/series-gaps" import { linearYDomain, niceLinearDomain } from "../plot/plot-scales" import { axisTickFormatter, axisTickValues, formatBytes } from "@maple/ui/lib/format" @@ -296,7 +296,7 @@ export const buildInfraMetric = (spec: InfraMetricSpec): BuiltChart(h: HtmlBuilder, series: ReadonlyArray, heading: string, @@ -88,31 +87,14 @@ export const countTooltipBody = ( color: string, portal: Attribute, ): Html => - h.div( - [h.Class("grid min-w-[9rem] items-start gap-1.5"), portal], - [ - h.div( - [ - h.Class( - "border-border-subtle border-b pb-1 font-medium text-muted-foreground tracking-tight", - ), - ], - [heading], - ), - hstack(h, { gap: "2", className: "w-full" }, [ - h.span([h.Class("size-2.5 shrink-0 rounded-xs"), h.Style({ "background-color": color })]), - hstack( - h, - { gap: "3", align: "center", justify: "between", grow: true, className: "leading-none" }, - [ - typography(h, { tone: "muted" }, ["Count"]), - typography(h, { tone: "foreground", weight: "semibold", mono: true, numeric: true }, [ - formatNumber(count), - ]), - ], - ), - ]), - ], + bucketTooltipBody( + h, + { + _tag: "Bucket", + heading, + rows: [{ label: "Count", color, dashed: false, value: formatNumber(count) }], + }, + portal, ) const histogramTooltipBody = ( diff --git a/packages/views/src/charts/plot/plot-focus.ts b/packages/views/src/charts/plot/plot-focus.ts index 2d4bff9ec0..856a0fcbcd 100644 --- a/packages/views/src/charts/plot/plot-focus.ts +++ b/packages/views/src/charts/plot/plot-focus.ts @@ -4,7 +4,7 @@ import type { ChartValue } from "@tanstack/charts" import type { PlotChromeColors } from "./theme" /** - * The dashed vertical cursor. + * The vertical cursor: a solid hairline, so it reads as a ruler and not as another series. * * `crosshair()` is a single mark painted from the chart's existing focus state, * so the rule itself costs nothing per datum. The horizontal guide is off @@ -15,7 +15,6 @@ export function focusCrosshair(chromeColors: PlotChromeColors) { x: { stroke: chromeColors.border, strokeWidth: 1, - strokeDasharray: "3 3", // The default is 0.35, which over an already-dark `--border` renders as // nothing on Maple's background. strokeOpacity: 1, diff --git a/packages/views/src/charts/plot/plot-grid.ts b/packages/views/src/charts/plot/plot-grid.ts index ff9aea68d1..cd09e43089 100644 --- a/packages/views/src/charts/plot/plot-grid.ts +++ b/packages/views/src/charts/plot/plot-grid.ts @@ -1,16 +1,14 @@ import type { ChartGuideLineStyle } from "@tanstack/charts" /** - * Horizontal grid lines at the y axis' ticks, dashed. + * Horizontal grid lines at the y axis' ticks: solid hairlines, quieter than the theme's own. * - * The dash is the Recharts `CartesianGrid` one (`vertical={false}`, - * `strokeDasharray="3 3"`), and it is all this carries: `grid` took a - * `ChartGuideLineStyle` in 0.18, and an omitted field keeps the theme's own grid - * paint. + * Solid because a dashed rule is the busiest thing in a plot that holds one line; the opacity is + * under the theme's 0.11, which was tuned for dashes that paint half the ink. * - * The style lands on each RULE, not on the `.ts-chart__grid` group — the group - * keeps the theme defaults, which is the opposite of where the mark put it. + * The style lands on each RULE, not on the `.ts-chart__grid` group, which keeps the + * theme defaults. */ -export const DASHED_Y_GRID: ChartGuideLineStyle = { - strokeDasharray: "3 3", +export const Y_GRID: ChartGuideLineStyle = { + strokeOpacity: 0.075, } diff --git a/packages/views/src/charts/timeseries-legend.ts b/packages/views/src/charts/timeseries-legend.ts index e37b087d1f..0f9675e908 100644 --- a/packages/views/src/charts/timeseries-legend.ts +++ b/packages/views/src/charts/timeseries-legend.ts @@ -68,7 +68,8 @@ const compactLegend = ( { onClick: Message.ToggledLegendSeries({ key: entry.key, keys }), className: cn( - "hover:bg-surface-sunken active:bg-surface-sunken flex min-w-0 max-w-full items-center gap-1.5 rounded-sm px-1 py-0.5 select-none", + // Labels sit one step under the card title, like the plot legend's; the hovered one reads full strength. + "text-muted-foreground hover:bg-surface-sunken hover:text-foreground active:bg-surface-sunken active:text-foreground flex min-w-0 max-w-full items-center gap-1.5 rounded-sm px-1 py-0.5 select-none", hidden.includes(entry.key) && "opacity-40", ), attributes: [h.Key(entry.key), h.Title(entry.label)], diff --git a/packages/views/src/charts/timeseries.ts b/packages/views/src/charts/timeseries.ts index 4315abe876..209c75fb89 100644 --- a/packages/views/src/charts/timeseries.ts +++ b/packages/views/src/charts/timeseries.ts @@ -8,7 +8,7 @@ import { import { resolveSeriesColors } from "@maple/ui/lib/semantic-series-colors" import { DEFAULT_TOKEN_FALLBACK } from "@maple/ui/lib/colors" import { hasOnlyIntegerValues } from "./series/sparse-series" -import { DASHED_Y_GRID } from "./plot/plot-grid" +import { Y_GRID } from "./plot/plot-grid" import { integerTickValues, linearYDomain, @@ -187,7 +187,7 @@ export const timeseriesYAxis = (options: YAxisOptions) => { return { domain, y: { - grid: DASHED_Y_GRID, + grid: Y_GRID, scale: logScale ? logYScale(dataDomain[1]) : scaleLinear().domain(domain), nice: logScale || max !== undefined ? false : NICE_TICK_COUNT, axis: {