Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 83 additions & 9 deletions packages/viewer-charts/src/ts/axis/legend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ import type { Canvas2D, Context2D } from "../charts/canvas-types";
import type { PlotLayout, PlotRect } from "../layout/plot-layout";
import { formatTickValue } from "../layout/ticks";
import {
LEGEND_ENTRY_LEADING,
LEGEND_FRAME_H,
LEGEND_FRAME_PAD_L,
LEGEND_FRAME_W,
LEGEND_HEADER_H,
LEGEND_LINE_HEIGHT,
LEGEND_TITLE_PAD,
type LegendAutoFit,
type LegendController,
} from "../interaction/legend-controller";
import {
Expand All @@ -24,12 +31,14 @@ import {
} from "../theme/gradient";
import type { Theme } from "../theme/theme";

/** Entry row height shared by every swatch-list legend painter. */
export const LEGEND_LINE_HEIGHT = 18;
export { LEGEND_LINE_HEIGHT };

/** Painted scrollbar thumb width (the hit zone is wider). */
const SCROLLBAR_W = 4;

const LEGEND_BAR_W = 16;
const LEGEND_BAR_GAP = 5;

function rgbCss(c: [number, number, number, number]): string {
return `rgb(${Math.round(c[0] * 255)},${Math.round(c[1] * 255)},${Math.round(c[2] * 255)})`;
}
Expand All @@ -55,6 +64,71 @@ export interface LegendPaintView {
opacity?: number;
}

const AUTO_WIDTH_MAX_SAMPLES = 64;

export function legendAutoFit(
canvas: Canvas2D | null | undefined,
theme: Theme,
entryCount: number,
labels: () => Iterable<string>,
opts: { title?: string; leading?: number; fontPx?: number } = {},
): LegendAutoFit {
return {
entryCount,
boxWidth: () => {
const ctx = canvas?.getContext("2d") as Context2D | null;
if (!ctx) {
return 0;
}

ctx.save();
let text = 0;
let n = 0;
ctx.font = `${opts.fontPx ?? 11}px ${theme.fontFamily}`;
for (const label of labels()) {
text = Math.max(text, ctx.measureText(label).width);
if (++n >= AUTO_WIDTH_MAX_SAMPLES) {
break;
}
}

let title = 0;
if (opts.title) {
ctx.font = `bold 10px ${theme.fontFamily}`;
title = ctx.measureText(opts.title).width + LEGEND_TITLE_PAD;
}

ctx.restore();
const leading = opts.leading ?? LEGEND_ENTRY_LEADING;
return Math.ceil(Math.max(title, leading + text + LEGEND_FRAME_W));
},
};
}

export function gradientLegendAutoFit(
canvas: Canvas2D | null | undefined,
theme: Theme,
colorDomain: { min: number; max: number },
formatter: (v: number) => string = formatTickValue,
title?: string,
): LegendAutoFit {
return legendAutoFit(
canvas,
theme,
0,
() => [
formatter(colorDomain.max),
formatter((colorDomain.min + colorDomain.max) / 2),
formatter(colorDomain.min),
],
{
title,
leading: LEGEND_BAR_W + LEGEND_BAR_GAP,
fontPx: 10,
},
);
}

/**
* Paint the floating panel's chrome — themed background, border, and
* header strip — and return the content rect inside it. The header
Expand Down Expand Up @@ -91,18 +165,18 @@ export function paintFloatingLegendFrame(
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText(
truncateText(ctx, title, Math.max(0, box.width - 16)),
truncateText(ctx, title, Math.max(0, box.width - LEGEND_TITLE_PAD)),
box.x + 8,
box.y + LEGEND_HEADER_H / 2 + 0.5,
);
}

ctx.restore();
return {
x: box.x + 8,
y: box.y + LEGEND_HEADER_H + 4,
width: Math.max(0, box.width - 12),
height: Math.max(0, box.height - LEGEND_HEADER_H - 8),
x: box.x + LEGEND_FRAME_PAD_L,
y: box.y + LEGEND_HEADER_H + LEGEND_FRAME_H / 2,
width: Math.max(0, box.width - LEGEND_FRAME_W),
height: Math.max(0, box.height - LEGEND_HEADER_H - LEGEND_FRAME_H),
};
}

Expand Down Expand Up @@ -236,7 +310,7 @@ export function renderLegendAt(
let y: number;
let barHeight: number;
let content: PlotRect = rect;
const barWidth = 16;
const barWidth = LEGEND_BAR_W;
if (floating) {
content = paintFloatingLegendFrame(
ctx,
Expand Down Expand Up @@ -286,7 +360,7 @@ export function renderLegendAt(
ctx.textAlign = "left";
ctx.textBaseline = "middle";

const labelX = x + barWidth + 5;
const labelX = x + barWidth + LEGEND_BAR_GAP;
const labelW = Math.max(0, content.x + content.width - labelX);
ctx.fillText(
truncateText(ctx, formatter(colorDomain.max), labelW),
Expand Down
70 changes: 65 additions & 5 deletions packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,15 @@ import {
renderLegendAt,
renderCategoricalLegend,
renderCategoricalLegendAt,
legendAutoFit,
gradientLegendAutoFit,
type LegendPaintView,
} from "../../axis/legend";
import {
legendRightGutter,
legendSidebarWidth,
resolveLegendMode,
type LegendAutoFit,
} from "../../interaction/legend-controller";

/**
Expand All @@ -69,6 +73,43 @@ function rebaseOrigin(o: number): number {
return isNaN(o) ? 0 : o;
}

/**
* Legend entry count for `legend_mode: "auto"` resolution: the
* categorical swatch count when a string color column is wired, `0`
* for continuous gradient legends (no entry list — always compact).
*/
function legendEntryCount(chart: CartesianChart): number {
return chart._colorIsString ? chart._uniqueColorLabels.size : 0;
}

/**
* Content measurements for a `legend_size_mode: "auto"` floating panel.
* Categorical legends hug their label list; gradient legends have no
* rows and hug their tick labels instead.
*/
function legendFit(chart: CartesianChart, theme: Theme): LegendAutoFit {
const title = chart._colorName ?? undefined;
if (chart._colorIsString) {
return legendAutoFit(
chart._chromeCanvas,
theme,
chart._uniqueColorLabels.size,
() => chart._uniqueColorLabels.keys(),
{ title },
);
}

return gradientLegendAutoFit(
chart._chromeCanvas,
theme,
{ min: chart._colorMin, max: chart._colorMax },
chart._colorName
? chart.getColumnFormatter(chart._colorName, "value")
: undefined,
title,
);
}

/**
* Full-frame render: gridlines → glyph draw inside the plot-frame
* scissor → chrome overlay (axes + legend + tooltip).
Expand Down Expand Up @@ -374,7 +415,12 @@ function renderSinglePlotFrame(

// One-pass plot-width / plot-height estimate to size the
// categorical gutter overrides; same approach as series-render.
const estRight = legendRightGutter(chart._pluginConfig, hasColorCol);
const estRight = legendRightGutter(
chart._pluginConfig,
hasColorCol,
80,
legendEntryCount(chart),
);
const estLeftPlain = 55 + (chart._yLabel ? 16 : 0);
const estPlotWidth = Math.max(1, cssWidth - estLeftPlain - estRight);
const leftExtra = chart._yCategoryDomain
Expand All @@ -398,7 +444,10 @@ function renderSinglePlotFrame(
bottomExtra: 0,
rightExtra:
hasColorCol &&
chart._pluginConfig.legend_mode === "sidebar"
resolveLegendMode(
chart._pluginConfig,
legendEntryCount(chart),
) === "sidebar"
? legendSidebarWidth(chart._pluginConfig, 80)
: 0,
}
Expand Down Expand Up @@ -555,7 +604,10 @@ function renderFacetedFrame(
: chart._lastEffectiveSharedY
? "outer"
: "cell",
hasLegend: hasLegend && chart._pluginConfig.legend_mode === "sidebar",
hasLegend:
hasLegend &&
resolveLegendMode(chart._pluginConfig, legendEntryCount(chart)) ===
"sidebar",
legendWidth: legendSidebarWidth(chart._pluginConfig, 96),
hasXLabel: !bareMap && !!chart._xLabel,
hasYLabel: !bareMap && !!chart._yLabel,
Expand Down Expand Up @@ -806,7 +858,10 @@ function renderSinglePlotChromeOverlay(chart: CartesianChart): void {
);
}

const legendMode = chart._pluginConfig.legend_mode;
const legendMode = resolveLegendMode(
chart._pluginConfig,
legendEntryCount(chart),
);
let legendPainted = false;
if (chart._lastHasColorCol && legendMode !== "none") {
const stops = chart._lastGradientStops ?? theme.gradientStops;
Expand All @@ -822,6 +877,7 @@ function renderSinglePlotChromeOverlay(chart: CartesianChart): void {
chart._pluginConfig,
layout.cssWidth,
layout.cssHeight,
legendFit(chart, theme),
)
: null;
if (chart._colorIsString && chart._uniqueColorLabels.size > 0) {
Expand Down Expand Up @@ -1049,13 +1105,17 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void {
}

// Shared legend: categorical (string color) or gradient
const legendMode = chart._pluginConfig.legend_mode;
const legendMode = resolveLegendMode(
chart._pluginConfig,
legendEntryCount(chart),
);
const floating = legendMode === "floating";
const legendAnchor = floating
? chart._legend.floatingBox(
chart._pluginConfig,
chart._lastLayout!.cssWidth,
chart._lastLayout!.cssHeight,
legendFit(chart, theme),
)
: grid.legendRect;
let legendPainted = false;
Expand Down
40 changes: 33 additions & 7 deletions packages/viewer-charts/src/ts/charts/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,24 +465,47 @@ export interface PluginConfig {
numeric_axes: boolean;

/**
* Legend presentation mode.
* Legend presentation mode. `"auto"` (default) resolves per frame
* to `"floating"` when every entry fits the default floating panel
* without scrolling (≤ 7 entries; continuous gradient legends
* always qualify) and to `"sidebar"` otherwise — see
* `resolveLegendMode`. Treemap overrides the default to
* `"sidebar"` (a floating panel over edge-to-edge tiles always
* occludes data).
*/
legend_mode: "auto" | "sidebar" | "none" | "floating";

/**
* Floating-panel sizing regime. `"auto"` (default) sizes the panel
* to its CONTENT every frame — the height hugs the entry rows
* exactly, and the width hugs the widest entry label (measured on
* the chrome canvas). `"fixed"` uses the saved `legend_width_px` /
* `legend_height_px` verbatim.
*
* Applies to `legend_mode: "floating"` ONLY; the sidebar gutter is
* always `legend_width_px` (its height is the plot's). Dragging a
* resize handle switches an auto panel to `"fixed"` — otherwise
* the gesture would be undone by the next paint — and
* double-clicking a resize handle switches it back.
*/
legend_mode: "sidebar" | "none" | "floating";
legend_size_mode: LegendSizeMode;

/**
* Legend width in CSS pixels. `0` (default) = automatic — each
* chart family keeps its historical gutter width (80–96px). In
* `"sidebar"` mode this is the full right-gutter width; in
* `"floating"` mode it is the panel width. Clamped at paint time
* to at most half the canvas width so a saved wide legend cannot
* crush a small panel.
* crush a small panel. Ignored by a floating panel in
* `legend_size_mode: "auto"`.
*/
legend_width_px: number;

/**
* Floating-legend panel height in CSS pixels. Ignored in
* `"sidebar"` mode (the legend spans the plot height). Clamped at
* paint time to the canvas height.
* `"sidebar"` mode (the legend spans the plot height) and by a
* floating panel in `legend_size_mode: "auto"`. Clamped at paint
* time to the canvas height.
*/
legend_height_px: number;

Expand All @@ -505,6 +528,8 @@ export type LegendAnchor =
| "bottom-left"
| "bottom-right";

export type LegendSizeMode = "auto" | "fixed";

export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
auto_alt_y_axis: false,
facet_mode: "grid",
Expand All @@ -525,11 +550,12 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = {
map_tile_provider: TILE_SOURCES.list()[0].id,
map_tile_alpha: 1.0,
numeric_axes: true,
legend_mode: "sidebar",
legend_mode: "auto",
legend_size_mode: "auto",
legend_width_px: 0,
legend_height_px: 160,
legend_anchor: "top-right",
legend_x: 0,
legend_y: 0,
legend_opacity: 1.0,
legend_opacity: 0.8,
};
Loading
Loading