// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ Copyright (c) 2017, the Perspective Authors. ┃ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ // ┃ This file is part of the Perspective library, distributed under the terms ┃ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { Canvas2D, Context2D } from "../charts/canvas-types"; import { PlotLayout } from "../layout/plot-layout"; import { formatTickValue, formatDateTickValue } from "../layout/ticks"; import { initCanvas } from "./canvas"; import { renderCategoricalXTicks, renderCategoricalYTicks, type CategoricalDomain, } from "./categorical-axis"; import { drawGridlinesX, drawGridlinesY, drawXTickRow, drawYTickColumn, } from "./axis-primitives"; import type { AxisDomain } from "./numeric-axis"; import type { Theme } from "../theme/theme"; function tickFormatter( domain: AxisDomain, ticks: number[], override?: (v: number) => string, ): (v: number) => string { if (override) { return override; } if (!domain.isDate) { return formatTickValue; } const step = ticks.length > 1 ? ticks[1] - ticks[0] : 0; return (v: number) => formatDateTickValue(v, step); } /** * Render a numeric axis along the bottom or top of the plot area. */ function drawNumericXAxis( ctx: Context2D, layout: PlotLayout, domain: AxisDomain, ticks: number[], side: "top" | "bottom", theme: Theme, formatter?: (v: number) => string, ): void { const { labelColor, fontFamily } = theme; const { plotRect: plot } = layout; const axisY = side === "bottom" ? plot.y + plot.height : plot.y; ctx.fillStyle = labelColor; ctx.font = `11px ${fontFamily}`; ctx.lineWidth = 1; drawXTickRow( ctx, plot, ticks, axisY, side, (v) => layout.dataToPixel(v, 0).px, tickFormatter(domain, ticks, formatter), ); ctx.font = `13px ${fontFamily}`; ctx.textAlign = "center"; ctx.textBaseline = "bottom"; ctx.fillText( domain.label, plot.x + plot.width / 2, side === "bottom" ? layout.cssHeight - 2 : 10, ); } /** * Render a numeric Y axis along either the left or right side of the plot * area. The caller must have already `initCanvas`'d the target canvas. * Used by bar charts with a categorical X and optional split Y axes. */ function drawYAxis( ctx: Context2D, layout: PlotLayout, domain: AxisDomain, ticks: number[], side: "left" | "right", theme: Theme, formatter?: (v: number) => string, ): void { const { labelColor, fontFamily } = theme; const { plotRect: plot } = layout; const axisX = side === "left" ? plot.x : plot.x + plot.width; ctx.fillStyle = labelColor; ctx.font = `11px ${fontFamily}`; ctx.lineWidth = 1; drawYTickColumn( ctx, plot, ticks, axisX, side, (v) => layout.dataToPixel(0, v).py, tickFormatter(domain, ticks, formatter), ); ctx.font = `13px ${fontFamily}`; ctx.save(); if (side === "left") { ctx.translate(14, plot.y + plot.height / 2); ctx.rotate(-Math.PI / 2); } else { ctx.translate(layout.cssWidth - 10, plot.y + plot.height / 2); ctx.rotate(Math.PI / 2); } ctx.textAlign = "center"; ctx.textBaseline = "bottom"; ctx.fillText(domain.label, 0, 0); ctx.restore(); } /** * The category-axis side of a bar chart can render as either a * stringified hierarchical category axis or a true numeric axis (when * the single group_by level is date / datetime / integer / float). * Both shapes flow through `renderBarAxesChrome`; the discriminator is * the `mode` field. */ export type BarCategoryAxis = | { mode: "category"; domain: CategoricalDomain } | { mode: "numeric"; domain: AxisDomain; ticks: number[] }; export type BarValueAxis = | { mode: "category"; domain: CategoricalDomain } | { mode: "numeric"; domain: AxisDomain; ticks: number[] }; /** * Render a numeric date-aware axis along the bottom of the plot. Aliases * the bar-axis bottom variant so heatmap can share the implementation. */ export function drawNumericCategoryX( ctx: Context2D, layout: PlotLayout, domain: AxisDomain, ticks: number[], theme: Theme, formatter?: (v: number) => string, ): void { drawNumericXAxis(ctx, layout, domain, ticks, "bottom", theme, formatter); } export function drawNumericCategoryY( ctx: Context2D, layout: PlotLayout, domain: AxisDomain, ticks: number[], theme: Theme, formatter?: (v: number) => string, ): void { drawYAxis(ctx, layout, domain, ticks, "left", theme, formatter); } /** * Render bar-chart chrome: L-shaped axis lines, a categorical axis * (bottom for Y Bar, left for X Bar), and one or two numeric axes on * the opposite sides. * * `isHorizontal=true` flips orientation for X Bar: categorical axis on * the left, numeric axes on the bottom (and top for dual-axis). The * `altDomain`/`altTicks` arguments always describe the *secondary* * numeric axis regardless of orientation. */ export interface BarAxesFormatters { /** Formatter for the value (Y in vertical, X in horizontal) axis. */ value?: (v: number) => string; /** Formatter for the secondary alt value axis. */ alt?: (v: number) => string; /** Formatter for the numeric category axis (when `catAxis.mode === "numeric"`). */ category?: (v: number) => string; } export function renderBarAxesChrome( canvas: Canvas2D, catAxis: BarCategoryAxis, valueAxis: BarValueAxis, layout: PlotLayout, theme: Theme, dpr: number, altAxis: BarValueAxis | undefined, isHorizontal = false, formatters: BarAxesFormatters = {}, ): void { const ctx = initCanvas(canvas, layout, dpr); if (!ctx) { return; } const { plotRect: plot } = layout; ctx.strokeStyle = theme.axisLineColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(plot.x, plot.y); ctx.lineTo(plot.x, plot.y + plot.height); ctx.lineTo(plot.x + plot.width, plot.y + plot.height); if (altAxis) { if (isHorizontal) { ctx.moveTo(plot.x, plot.y); ctx.lineTo(plot.x + plot.width, plot.y); } else { ctx.moveTo(plot.x + plot.width, plot.y); ctx.lineTo(plot.x + plot.width, plot.y + plot.height); } } ctx.stroke(); if (isHorizontal) { if (catAxis.mode === "category") { renderCategoricalYTicks(ctx, layout, catAxis.domain, theme); } else { drawNumericCategoryY( ctx, layout, catAxis.domain, catAxis.ticks, theme, formatters.category, ); } if (valueAxis.mode === "category") { // Categorical value axis on the bottom: reuse the X // categorical painter. Slot indices on the layout's X // domain already place each category at its slot pixel. renderCategoricalXTicks(ctx, layout, valueAxis.domain, theme); } else { drawNumericXAxis( ctx, layout, valueAxis.domain, valueAxis.ticks, "bottom", theme, formatters.value, ); } if (altAxis) { // Alt-axis painter expects the layout's X domain to match // the alt domain — temporarily swap in `altDomain.min/max` // for the duration of the call. Categorical alt has no // top-side painter; render with the bottom-side painter // (visual overlap with the primary side — documented // limitation; user-pinned categorical alt is rare). if (altAxis.mode === "category") { renderCategoricalXTicks(ctx, layout, altAxis.domain, theme); } else { const origMin = layout.paddedXMin; const origMax = layout.paddedXMax; layout.paddedXMin = altAxis.domain.min; layout.paddedXMax = altAxis.domain.max; drawNumericXAxis( ctx, layout, altAxis.domain, altAxis.ticks, "top", theme, formatters.alt, ); layout.paddedXMin = origMin; layout.paddedXMax = origMax; } } } else { if (catAxis.mode === "category") { renderCategoricalXTicks(ctx, layout, catAxis.domain, theme); } else { drawNumericCategoryX( ctx, layout, catAxis.domain, catAxis.ticks, theme, formatters.category, ); } if (valueAxis.mode === "category") { renderCategoricalYTicks(ctx, layout, valueAxis.domain, theme); } else { drawYAxis( ctx, layout, valueAxis.domain, valueAxis.ticks, "left", theme, formatters.value, ); } if (altAxis) { if (altAxis.mode === "category") { renderCategoricalYTicks(ctx, layout, altAxis.domain, theme); } else { const origMin = layout.paddedYMin; const origMax = layout.paddedYMax; layout.paddedYMin = altAxis.domain.min; layout.paddedYMax = altAxis.domain.max; drawYAxis( ctx, layout, altAxis.domain, altAxis.ticks, "right", theme, formatters.alt, ); layout.paddedYMin = origMin; layout.paddedYMax = origMax; } } } } /** * Render gridlines at the numeric axis ticks. In vertical bar charts * the gridlines run horizontally at numeric Y ticks; in horizontal bar * charts they run vertically at numeric X ticks. */ export function renderBarGridlines( canvas: Canvas2D, layout: PlotLayout, valueTicks: number[], theme: Theme, dpr: number, isHorizontal = false, ): void { const ctx = initCanvas(canvas, layout, dpr); if (!ctx) { return; } ctx.strokeStyle = theme.gridlineColor; ctx.lineWidth = 1; if (isHorizontal) { drawGridlinesX( ctx, layout.plotRect, valueTicks, (v) => layout.dataToPixel(v, 0).px, ); } else { drawGridlinesY( ctx, layout.plotRect, valueTicks, (v) => layout.dataToPixel(0, v).py, ); } }