diff --git a/packages/viewer-charts/src/ts/axis/legend.ts b/packages/viewer-charts/src/ts/axis/legend.ts index 1807e441dd..06ff0f5565 100644 --- a/packages/viewer-charts/src/ts/axis/legend.ts +++ b/packages/viewer-charts/src/ts/axis/legend.ts @@ -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 { @@ -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)})`; } @@ -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, + 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 @@ -91,7 +165,7 @@ 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, ); @@ -99,10 +173,10 @@ export function paintFloatingLegendFrame( 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), }; } @@ -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, @@ -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), diff --git a/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts index 502f215968..3d037aa4ba 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts @@ -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"; /** @@ -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). @@ -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 @@ -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, } @@ -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, @@ -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; @@ -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) { @@ -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; diff --git a/packages/viewer-charts/src/ts/charts/chart.ts b/packages/viewer-charts/src/ts/charts/chart.ts index a6db138722..b30fc56073 100644 --- a/packages/viewer-charts/src/ts/charts/chart.ts +++ b/packages/viewer-charts/src/ts/charts/chart.ts @@ -465,9 +465,30 @@ 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 @@ -475,14 +496,16 @@ export interface PluginConfig { * `"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; @@ -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", @@ -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, }; diff --git a/packages/viewer-charts/src/ts/charts/common/tree-chrome.ts b/packages/viewer-charts/src/ts/charts/common/tree-chrome.ts index 335989d350..611c55f2eb 100644 --- a/packages/viewer-charts/src/ts/charts/common/tree-chrome.ts +++ b/packages/viewer-charts/src/ts/charts/common/tree-chrome.ts @@ -17,13 +17,18 @@ import type { GradientStop } from "../../theme/gradient"; import type { Vec3 } from "../../theme/palette"; import type { Theme } from "../../theme/theme"; import { + gradientLegendAutoFit, + legendAutoFit, renderCategoricalLegend, renderCategoricalLegendAt, renderLegend, renderLegendAt, type LegendPaintView, } from "../../axis/legend"; -import { legendSidebarWidth } from "../../interaction/legend-controller"; +import { + legendSidebarWidth, + resolveLegendMode, +} from "../../interaction/legend-controller"; import type { TreeChartBase } from "./tree-chart"; import { drawTooltipBox } from "./draw-tooltip-box"; @@ -167,12 +172,16 @@ export function renderTreeColorLegend( chart._colorMode === "series" && chart._uniqueColorLabels.size > 1; const hasNumeric = chart._colorMode === "numeric" && chart._colorMin < chart._colorMax; - if (cfg.legend_mode === "none" || (!hasCategorical && !hasNumeric)) { + const mode = resolveLegendMode( + cfg, + hasCategorical ? chart._uniqueColorLabels.size : 0, + ); + if (mode === "none" || (!hasCategorical && !hasNumeric)) { chart._legend.clearPainted(); return; } - const floating = cfg.legend_mode === "floating"; + const floating = mode === "floating"; const view: LegendPaintView = { mode: floating ? "floating" : "sidebar", legend: chart._legend, @@ -180,7 +189,26 @@ export function renderTreeColorLegend( opacity: cfg.legend_opacity, }; const floatBox = floating - ? chart._legend.floatingBox(cfg, cssWidth, cssHeight) + ? chart._legend.floatingBox( + cfg, + cssWidth, + cssHeight, + hasCategorical + ? legendAutoFit( + canvas, + theme, + chart._uniqueColorLabels.size, + () => chart._uniqueColorLabels.keys(), + { title: view.title }, + ) + : gradientLegendAutoFit( + canvas, + theme, + { min: chart._colorMin, max: chart._colorMax }, + undefined, + view.title, + ), + ) : null; if (hasCategorical) { diff --git a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts index f08ebc0ede..61bb32b50b 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts @@ -48,12 +48,14 @@ const HEATMAP_Y_AXIS_OPTS: CategoricalYAxisOptions = { }; import { + gradientLegendAutoFit, renderLegend, renderLegendAt, type LegendPaintView, } from "../../axis/legend"; import { legendRightGutter, + resolveLegendMode, legendSidebarWidth, } from "../../interaction/legend-controller"; import heatmapVert from "../../shaders/heatmap.vert.glsl"; @@ -118,7 +120,7 @@ export function renderHeatmapFrame( // Measure both hierarchical axes *before* building the layout so the // plot rect accounts for their footprints. Numeric axes get fixed // gutters matching bar's branch (24px bottom, 55px left). - const rightExtra = legendRightGutter(chart._pluginConfig, true); + const rightExtra = legendRightGutter(chart._pluginConfig, true, 80, 0); const estLeft = yNumeric ? 55 : measureCategoricalAxisWidth(yDomain, HEATMAP_Y_AXIS_OPTS); @@ -492,7 +494,7 @@ function paintHeatmapChromeOverlay(chart: HeatmapChart): void { ); } - const legendMode = chart._pluginConfig.legend_mode; + const legendMode = resolveLegendMode(chart._pluginConfig, 0); if (legendMode === "none") { chart._legend.clearPainted(); } else { @@ -518,6 +520,13 @@ function paintHeatmapChromeOverlay(chart: HeatmapChart): void { chart._pluginConfig, layout.cssWidth, layout.cssHeight, + gradientLegendAutoFit( + chart._chromeCanvas, + theme, + colorDomain, + formatter, + chart._aggName, + ), ), colorDomain, theme.gradientStops, @@ -569,7 +578,7 @@ function renderFacetedHeatmap( cssHeight, xAxis: effectiveSharedX ? "outer" : "cell", yAxis: effectiveSharedY ? "outer" : "cell", - hasLegend: chart._pluginConfig.legend_mode === "sidebar", + hasLegend: resolveLegendMode(chart._pluginConfig, 0) === "sidebar", legendWidth: legendSidebarWidth(chart._pluginConfig, 96), hasXLabel: chart._groupBy.length > 0, hasYLabel: false, @@ -835,7 +844,7 @@ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void { ); } - const legendMode = chart._pluginConfig.legend_mode; + const legendMode = resolveLegendMode(chart._pluginConfig, 0); const floating = legendMode === "floating"; const facetLayout = chart._facets[0].layout; const legendAnchor = floating @@ -843,6 +852,13 @@ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void { chart._pluginConfig, facetLayout.cssWidth, facetLayout.cssHeight, + gradientLegendAutoFit( + chart._chromeCanvas, + theme, + { min: chart._colorMin, max: chart._colorMax }, + chart.getColumnFormatter(chart._columnSlots[0], "value"), + chart._aggName, + ), ) : grid.legendRect; if (legendMode !== "none" && legendAnchor) { diff --git a/packages/viewer-charts/src/ts/charts/series/series-render.ts b/packages/viewer-charts/src/ts/charts/series/series-render.ts index c6910f7cd9..cdc4d1f6cb 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-render.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-render.ts @@ -59,6 +59,7 @@ import { drawGridlinesX, drawGridlinesY } from "../../axis/axis-primitives"; import { buildBarTooltipLines } from "./series-interact"; import { LEGEND_LINE_HEIGHT, + legendAutoFit, paintFloatingLegendFrame, paintLegendScrollbar, truncateText, @@ -66,6 +67,7 @@ import { } from "../../axis/legend"; import { legendRightGutter, + resolveLegendMode, legendSidebarWidth, } from "../../interaction/legend-controller"; @@ -540,7 +542,12 @@ export function renderBarFrame( ? 55 : measureCategoricalAxisWidth(provisionalDomain); const estLeft = leftExtra + (hasCatLabel ? 16 : 0); - const estRight = legendRightGutter(chart._pluginConfig, hasLegend); + const estRight = legendRightGutter( + chart._pluginConfig, + hasLegend, + 80, + chart._series.length, + ); const estPlotWidthH = Math.max(1, cssWidth - estLeft - estRight); const bottomExtra = valueCatActive ? measureCategoricalAxisHeight(valueCatDomain, estPlotWidthH) @@ -565,7 +572,12 @@ export function renderBarFrame( hasLegend, bottomExtra: 24, leftExtra, - rightExtra: legendRightGutter(chart._pluginConfig, hasLegend), + rightExtra: legendRightGutter( + chart._pluginConfig, + hasLegend, + 80, + chart._series.length, + ), }); } else { // Y Bar with categorical X. Value axis on the left may be @@ -574,7 +586,12 @@ export function renderBarFrame( ? measureCategoricalAxisWidth(valueCatDomain) : 55; const estLeft = leftExtraBase + 16; - const estRight = legendRightGutter(chart._pluginConfig, hasLegend); + const estRight = legendRightGutter( + chart._pluginConfig, + hasLegend, + 80, + chart._series.length, + ); const estPlotWidth = Math.max(1, cssWidth - estLeft - estRight); const bottomExtra = measureCategoricalAxisHeight( provisionalDomain, @@ -912,7 +929,10 @@ function renderFacetedBarFrame( cssHeight, xAxis: horizontal ? valAxisMode : catAxisMode, yAxis: horizontal ? catAxisMode : valAxisMode, - hasLegend: hasLegend && chart._pluginConfig.legend_mode === "sidebar", + hasLegend: + hasLegend && + resolveLegendMode(chart._pluginConfig, chart._aggregates.length) === + "sidebar", legendWidth: legendSidebarWidth(chart._pluginConfig, 96), hasXLabel: horizontal ? true : hasCatLabel, hasYLabel: horizontal ? hasCatLabel : true, @@ -1504,12 +1524,9 @@ function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void { const cfg = chart._pluginConfig; const M = chart._aggregates.length; - const floating = cfg.legend_mode === "floating"; - if ( - M <= 1 || - cfg.legend_mode === "none" || - (!floating && !grid.legendRect) - ) { + const mode = resolveLegendMode(cfg, M); + const floating = mode === "floating"; + if (M <= 1 || mode === "none" || (!floating && !grid.legendRect)) { chart._legend.clearPainted(); return; } @@ -1531,7 +1548,18 @@ function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void { const layout = chart._lastLayout; const box = floating - ? chart._legend.floatingBox(cfg, layout.cssWidth, layout.cssHeight) + ? chart._legend.floatingBox( + cfg, + layout.cssWidth, + layout.cssHeight, + legendAutoFit( + chart._chromeCanvas, + chart._resolveTheme(), + M, + () => chart._aggregates.slice(0, M), + { title: "Legend" }, + ), + ) : { x: grid.legendRect!.x + 12, y: grid.legendRect!.y + 10, @@ -1567,15 +1595,32 @@ function renderBarLegend(chart: SeriesChart): void { const cfg = chart._pluginConfig; const series = chart._series; - if (series.length <= 1 || cfg.legend_mode === "none") { + const mode = resolveLegendMode(cfg, series.length); + if (series.length <= 1 || mode === "none") { chart._legend.clearPainted(); return; } const layout = chart._lastLayout; - const floating = cfg.legend_mode === "floating"; + const floating = mode === "floating"; + const title = chart._splitBy.join(" / ") || "Legend"; const box = floating - ? chart._legend.floatingBox(cfg, layout.cssWidth, layout.cssHeight) + ? chart._legend.floatingBox( + cfg, + layout.cssWidth, + layout.cssHeight, + legendAutoFit( + chart._chromeCanvas, + chart._resolveTheme(), + series.length, + function* () { + for (const s of series) { + yield s.label; + } + }, + { title }, + ), + ) : { x: layout.plotRect.x + layout.plotRect.width + 12, y: layout.margins.top + 10, @@ -1595,7 +1640,7 @@ function renderBarLegend(chart: SeriesChart): void { { mode: floating ? "floating" : "sidebar", legend: chart._legend, - title: chart._splitBy.join(" / ") || "Legend", + title, sidebarGutter: floating ? undefined : layout.margins.right, opacity: cfg.legend_opacity, }, diff --git a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts index c25ba0a94d..fb5aa3fa46 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts @@ -107,7 +107,12 @@ export function renderSunburstFrame( chart._colorMin < chart._colorMax; const breadcrumbH = !hasSplits && chart._breadcrumbIds.length > 1 ? BREADCRUMB_H : 0; - const legendW = legendTreeGutter(chart._pluginConfig, hasLegend, LEGEND_W); + const legendW = legendTreeGutter( + chart._pluginConfig, + hasLegend, + LEGEND_W, + chart._colorMode === "series" ? chart._uniqueColorLabels.size : 0, + ); if (hasSplits) { layoutFacetedSunburst(chart, cssWidth, cssHeight, legendW); diff --git a/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts b/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts index 4153c24e07..649744ab2b 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts @@ -71,7 +71,12 @@ export function renderTreemapFrame( ? chart._uniqueColorLabels.size > 1 : chart._colorMode === "numeric" && chart._colorMin < chart._colorMax; - const legendW = legendTreeGutter(chart._pluginConfig, hasLegend, 90); + const legendW = legendTreeGutter( + chart._pluginConfig, + hasLegend, + 90, + chart._colorMode === "series" ? chart._uniqueColorLabels.size : 0, + ); // Scratch buffer for the ordered-layout child ids. Worst case: // active children at every level = store.count. Reuse the chart's diff --git a/packages/viewer-charts/src/ts/interaction/legend-controller.ts b/packages/viewer-charts/src/ts/interaction/legend-controller.ts index a0ec7b0d9d..a0fa2adaa1 100644 --- a/packages/viewer-charts/src/ts/interaction/legend-controller.ts +++ b/packages/viewer-charts/src/ts/interaction/legend-controller.ts @@ -13,6 +13,7 @@ import { DEFAULT_PLUGIN_CONFIG, type LegendAnchor, + type LegendSizeMode, type PluginConfig, } from "../charts/chart"; import type { PlotRect } from "../layout/plot-layout"; @@ -27,15 +28,48 @@ export const LEGEND_MAX_WIDTH = 512; /** Minimum floating-panel height. */ export const LEGEND_MIN_HEIGHT = 48; -/** Floating-panel width when `legend_width_px` is 0 (auto). */ +/** + * Floating-panel fallbacks: used when `legend_size_mode: "fixed"` has no + * saved value (`0`), and when `"auto"` has nothing to size against — an + * unmeasurable width, or a gradient legend's row-less height. + */ const FLOATING_AUTO_WIDTH = 160; - -/** Floating-panel height when `legend_height_px` is 0. */ const FLOATING_AUTO_HEIGHT = 160; /** Floating-panel header strip height (title + move grip). */ export const LEGEND_HEADER_H = 18; +/** Entry row height shared by every swatch-list legend painter. */ +export const LEGEND_LINE_HEIGHT = 18; + +/** + * Floating-frame chrome, as consumed from the panel box by + * `paintFloatingLegendFrame`: `PAD_L` insets the content from the left + * edge, `W` is the total width the frame takes, and `H` the total + * height it takes BELOW the header (split evenly above/below). The + * auto-size math is the frame's inverse, so both must read the same + * constants — a drifted pair sizes a panel that clips its own last row. + */ +export const LEGEND_FRAME_PAD_L = 8; +export const LEGEND_FRAME_W = 12; +export const LEGEND_FRAME_H = 8; + +/** Width the header title is laid out against: `box.width - PAD`. */ +export const LEGEND_TITLE_PAD = 16; + +/** + * Entry-row chrome left of the label text: swatch (10) + gap (6). + * Gradient legends substitute their own (bar 16 + gap 5). + */ +export const LEGEND_ENTRY_LEADING = 16; + +/** + * Width the entry painters give up to the scroll thumb once the content + * overflows. The two painters reserve 8 and 10 respectively; auto-width + * budgets the larger so neither truncates a label it just sized for. + */ +const LEGEND_SCROLLBAR_ALLOWANCE = 10; + /** Edge-proximity in CSS px that reads as a resize handle. */ const EDGE = 5; @@ -58,7 +92,20 @@ function clamp01(v: number): number { /** Normalized-span coordinate: `px` along a free span of `span` px. */ function norm(px: number, span: number): number { - return span > 0 ? clamp01(px / span) : 0; + if (span <= 0) { + return 0; + } + + const whole = Math.round(px); + if (whole <= 0) { + return 0; + } + + if (whole >= Math.round(span)) { + return 1; + } + + return clamp01(whole / span); } function anchorRight(a: LegendAnchor): boolean { @@ -80,19 +127,51 @@ export function legendSidebarWidth(cfg: PluginConfig, legacy: number): number { : legacy; } +/** + * `legend_mode` with `"auto"` resolved away — the only form the + * layout/paint/interaction paths consume. Painters record the resolved + * mode in `setPainted`, so the controller's drag semantics follow it + * with no separate resolution. + */ +export type ResolvedLegendMode = "sidebar" | "none" | "floating"; + +/** + * `"auto"` resolves to `"floating"` when every legend entry fits the + * DEFAULT floating panel (160 px ≈ header + 7 rows) without scrolling — + * a compact overlay that frees the whole gutter — and to `"sidebar"` + * otherwise, where a long list gets dedicated, scrollable space instead + * of occluding the plot. Continuous gradient legends have no entry + * list; callers pass `entryCount = 0` and they resolve to floating. + */ +export const AUTO_FLOATING_MAX_ROWS = 7; + +export function resolveLegendMode( + cfg: PluginConfig, + entryCount: number, +): ResolvedLegendMode { + if (cfg.legend_mode !== "auto") { + return cfg.legend_mode; + } + + return entryCount <= AUTO_FLOATING_MAX_ROWS ? "floating" : "sidebar"; +} + /** * Right-margin width a plot layout should reserve for the legend. * `legacy` is the family's historical `hasLegend` gutter (80 for * single-plot layouts, 96 for facet grids). Modes `"none"` and * `"floating"` collapse the gutter to the no-legend breathing margin — - * the plot widens and the floating panel overlays it. + * the plot widens and the floating panel overlays it. `entryCount` + * feeds the `"auto"` resolution ({@link resolveLegendMode}); pass the + * same count the legend painter will enumerate. */ export function legendRightGutter( cfg: PluginConfig, hasLegend: boolean, legacy: number = 80, + entryCount: number = 0, ): number { - if (!hasLegend || cfg.legend_mode !== "sidebar") { + if (!hasLegend || resolveLegendMode(cfg, entryCount) !== "sidebar") { return 16; } @@ -108,14 +187,37 @@ export function legendTreeGutter( cfg: PluginConfig, hasLegend: boolean, legacy: number, + entryCount: number = 0, ): number { - if (!hasLegend || cfg.legend_mode !== "sidebar") { + if (!hasLegend || resolveLegendMode(cfg, entryCount) !== "sidebar") { return 0; } return legendSidebarWidth(cfg, legacy); } +/** + * Content measurements a floating panel needs to size itself in + * `legend_size_mode: "auto"`. Supplied by the call site (which owns the + * chrome canvas and the label set) and consumed ONLY by that mode, so a + * `"fixed"` panel — and every sidebar legend — pays nothing. + */ +export interface LegendAutoFit { + /** + * Entry rows the painter will enumerate. `0` marks a continuous + * gradient legend, which has no row list and keeps the default + * panel height. + */ + entryCount: number; + + /** + * Panel width in CSS px that fits the widest entry label AND the + * header title, frame chrome included. Invoked at most once per + * `floatingBox` call; see `legendAutoFit`. + */ + boxWidth?: () => number; +} + /** * Cursor-zone classification for a point over the painted legend. * Sidebar legends expose only `resize-w` (their left edge) plus @@ -160,6 +262,7 @@ export interface PaintedLegend { /** The `PluginConfig` fields this controller owns. */ const LEGEND_FIELDS = [ "legend_mode", + "legend_size_mode", "legend_width_px", "legend_height_px", "legend_anchor", @@ -169,6 +272,7 @@ const LEGEND_FIELDS = [ ] as const; type LegendFieldSnapshot = { + legend_size_mode: LegendSizeMode; legend_width_px: number; legend_height_px: number; legend_x: number; @@ -245,8 +349,8 @@ export interface LegendEventCtx { * tooltip routing rather than racing them. * * The scroll offset is transient (never persisted); geometry fields - * (`legend_width_px`, `legend_height_px`, `legend_x`, `legend_y`) - * round-trip through `plugin_config`. + * (`legend_size_mode`, `legend_width_px`, `legend_height_px`, + * `legend_x`, `legend_y`) round-trip through `plugin_config`. */ export class LegendController { private _painted: PaintedLegend | null = null; @@ -291,31 +395,68 @@ export class LegendController { * normalized to the free span and measured from the * `legend_anchor` corner, so every `legend_x`/`legend_y` in [0, 1] * yields a fully on-canvas box at any canvas size. + * + * `fit` drives `legend_size_mode: "auto"` (see {@link LegendAutoFit}); + * omitting it in auto mode falls back to the fixed-mode defaults, + * so a call site that cannot measure still paints a sane panel. The + * canvas clamps apply to BOTH modes — an auto panel never grows + * past half the canvas width or its full height, it scrolls + * instead. */ floatingBox( cfg: PluginConfig, cssWidth: number, cssHeight: number, + fit?: LegendAutoFit, ): PlotRect { - const width = clamp( - cfg.legend_width_px > 0 ? cfg.legend_width_px : FLOATING_AUTO_WIDTH, - LEGEND_MIN_WIDTH, - Math.max(LEGEND_MIN_WIDTH, Math.floor(cssWidth / 2)), - ); + // Each dimension resolves independently, and each falls back to + // the fixed-mode default when auto cannot answer — an + // unmeasurable width (no 2D context) or a gradient legend, which + // has no rows to hug. `0` from a measurer means "could not + // measure", never "zero wide". + const auto = cfg.legend_size_mode !== "fixed"; + const measured = auto ? (fit?.boxWidth?.() ?? 0) : 0; + + // Auto height is the frame's inverse: N rows of content, plus + // the header and the frame's vertical padding. + const rows = auto ? (fit?.entryCount ?? 0) : 0; + const wantWidth = + measured > 0 + ? measured + : cfg.legend_width_px > 0 && !auto + ? cfg.legend_width_px + : FLOATING_AUTO_WIDTH; + const wantHeight = + rows > 0 + ? LEGEND_HEADER_H + LEGEND_FRAME_H + rows * LEGEND_LINE_HEIGHT + : cfg.legend_height_px > 0 && !auto + ? cfg.legend_height_px + : FLOATING_AUTO_HEIGHT; + const height = clamp( - cfg.legend_height_px > 0 - ? cfg.legend_height_px - : FLOATING_AUTO_HEIGHT, + wantHeight, LEGEND_MIN_HEIGHT, Math.max(LEGEND_MIN_HEIGHT, cssHeight - 8), ); + + // A row list too tall for the canvas keeps its measured width + // but now scrolls, and the thumb is drawn INSIDE the content + // rect — so budget for it, or the labels auto-width just fit + // truncate anyway. + const scrolls = height < wantHeight; + const width = clamp( + wantWidth + + (scrolls && measured > 0 ? LEGEND_SCROLLBAR_ALLOWANCE : 0), + LEGEND_MIN_WIDTH, + Math.max(LEGEND_MIN_WIDTH, Math.floor(cssWidth / 2)), + ); const freeW = Math.max(0, cssWidth - width); const freeH = Math.max(0, cssHeight - height); const rx = clamp01(cfg.legend_x) * freeW; const ry = clamp01(cfg.legend_y) * freeH; return { - x: anchorRight(cfg.legend_anchor) ? freeW - rx : rx, - y: anchorBottom(cfg.legend_anchor) ? freeH - ry : ry, + x: Math.round(anchorRight(cfg.legend_anchor) ? freeW - rx : rx), + y: Math.round(anchorBottom(cfg.legend_anchor) ? freeH - ry : ry), width, height, }; @@ -509,6 +650,7 @@ export class LegendController { } const snapshot: LegendFieldSnapshot = { + legend_size_mode: ctx.cfg.legend_size_mode, legend_width_px: ctx.cfg.legend_width_px, legend_height_px: ctx.cfg.legend_height_px, legend_x: ctx.cfg.legend_x, @@ -607,6 +749,10 @@ export class LegendController { this._suppressClick = true; const fields: Partial = {}; const cfg = ctx.cfg; + if (cfg.legend_size_mode !== d.snapshot.legend_size_mode) { + fields.legend_size_mode = cfg.legend_size_mode; + } + if (cfg.legend_width_px !== d.snapshot.legend_width_px) { fields.legend_width_px = Math.round(cfg.legend_width_px); } @@ -645,6 +791,18 @@ export class LegendController { const cfg = ctx.cfg; const fields: Partial = {}; + + // A floating panel's reset also returns it to content-sizing — + // the inverse of the freeze a resize drag performs, and the only + // gesture that restores `"auto"` without the settings form. + if ( + this._painted!.mode === "floating" && + cfg.legend_size_mode !== DEFAULT_PLUGIN_CONFIG.legend_size_mode + ) { + cfg.legend_size_mode = DEFAULT_PLUGIN_CONFIG.legend_size_mode; + fields.legend_size_mode = cfg.legend_size_mode; + } + if ( resetsWidth && cfg.legend_width_px !== DEFAULT_PLUGIN_CONFIG.legend_width_px @@ -772,6 +930,7 @@ export class LegendController { // Floating: keep the RIGHT edge fixed while the left // edge follows the cursor. + this._freezeAutoSize(ctx, d.startBox); const right = d.startBox.x + d.startBox.width; const w = clamp( Math.round(d.startBox.width + (d.startMx - mx)), @@ -792,6 +951,7 @@ export class LegendController { case "resize-e": case "resize-se": { + this._freezeAutoSize(ctx, d.startBox); const w = clamp( Math.round(d.startBox.width + (mx - d.startMx)), LEGEND_MIN_WIDTH, @@ -808,6 +968,7 @@ export class LegendController { } case "resize-s": { + this._freezeAutoSize(ctx, d.startBox); const h = this._applySouthResize( d.startBox, d.startMy, @@ -827,6 +988,26 @@ export class LegendController { } } + /** + * First write of a resize gesture on an auto-sized floating panel: + * switch it to `"fixed"`. Without this the gesture would be inert — + * the next paint recomputes the content size and discards it. + * + * BOTH dimensions are seeded from the box on screen, not just the + * dragged one: the saved `legend_width_px` / `legend_height_px` are + * whatever the panel had before it went auto, so adopting them + * wholesale would jump the edge the user is NOT dragging. + */ + private _freezeAutoSize(ctx: LegendEventCtx, startBox: PlotRect): void { + if (ctx.cfg.legend_size_mode === "fixed") { + return; + } + + ctx.cfg.legend_size_mode = "fixed"; + ctx.cfg.legend_width_px = Math.round(startBox.width); + ctx.cfg.legend_height_px = Math.round(startBox.height); + } + /** Bottom-edge resize: new height with the top edge held fixed. */ private _applySouthResize( startBox: PlotRect, diff --git a/packages/viewer-charts/src/ts/plugin/charts.ts b/packages/viewer-charts/src/ts/plugin/charts.ts index 308a55c419..b5f5e6a461 100644 --- a/packages/viewer-charts/src/ts/plugin/charts.ts +++ b/packages/viewer-charts/src/ts/plugin/charts.ts @@ -102,6 +102,7 @@ const DEFAULT_MAX_COLUMNS = 10_000; // const LEGEND_FIELDS: readonly PluginConfigField[] = [ "legend_mode", + "legend_size_mode", "legend_width_px", "legend_height_px", "legend_anchor", @@ -333,12 +334,14 @@ const CHARTS: ChartTypeConfig[] = [ ), make("Treemap", "treemap", HIER, TOGGLE, 1, HIER_NAMES, TREE_FIELDS, { ...HIER_ROLES, + plugin_field_defaults: { legend_mode: "sidebar" }, }), make("Sunburst", "sunburst", HIER, TOGGLE, 1, HIER_NAMES, TREE_FIELDS, { ...HIER_ROLES, }), make("Heatmap", "heatmap", HIER, SELECT, 1, ["Color"], HEATMAP_FIELDS, { ...HEATMAP_ROLES, + plugin_field_defaults: { legend_mode: "sidebar" }, }), make("Candlestick", "candlestick", FIN, TOGGLE, 1, FIN_NAMES, FIN_FIELDS, { ...FIN_ROLES, diff --git a/packages/viewer-charts/src/ts/plugin/plugin.ts b/packages/viewer-charts/src/ts/plugin/plugin.ts index 82a6c4799d..09d275d04d 100644 --- a/packages/viewer-charts/src/ts/plugin/plugin.ts +++ b/packages/viewer-charts/src/ts/plugin/plugin.ts @@ -147,11 +147,19 @@ const FIELD_SCHEMAS: Record FieldSpec)> = legend_mode: { kind: "Enum", variants: [ + { value: "auto", label: "Auto" }, { value: "sidebar", label: "Sidebar" }, { value: "none", label: "None" }, { value: "floating", label: "Floating" }, ], }, + legend_size_mode: { + kind: "Enum", + variants: [ + { value: "auto", label: "Auto" }, + { value: "fixed", label: "Fixed" }, + ], + }, // 0 = auto (the chart family's historical gutter width). legend_width_px: { kind: "Number", min: 0, max: 512, step: 1 }, legend_height_px: { kind: "Number", min: 48, max: 1024, step: 1 }, diff --git a/packages/viewer-charts/test/ts/legend-mode.spec.ts b/packages/viewer-charts/test/ts/legend-mode.spec.ts index 1dae744295..67b5a9f9c5 100644 --- a/packages/viewer-charts/test/ts/legend-mode.spec.ts +++ b/packages/viewer-charts/test/ts/legend-mode.spec.ts @@ -134,16 +134,82 @@ test.describe("legend_mode", () => { expect(cfg.legend_mode).toBe("floating"); expect(cfg.legend_width_px).toBe(200); - // Restoring the defaults clears the bucket entries entirely - // (schema-default stripping) rather than storing them literally. + // "sidebar" is a NON-default value now ("auto" is the default), + // so it persists… await restoreChart(page, { - plugin_config: { legend_mode: "sidebar", legend_width_px: 0 }, + plugin_config: { legend_mode: "sidebar" }, + } as any); + cfg = await savedPluginConfig(page); + expect(cfg.legend_mode).toBe("sidebar"); + + // …and restoring the defaults clears the bucket entries + // entirely (schema-default stripping) rather than storing them + // literally. + await restoreChart(page, { + plugin_config: { legend_mode: "auto", legend_width_px: 0 }, } as any); cfg = await savedPluginConfig(page); expect(cfg.legend_mode).toBeUndefined(); expect(cfg.legend_width_px).toBeUndefined(); }); + test("auto resolves floating for few entries and sidebar for many", async ({ + page, + }) => { + // 4 `Ship Mode` entries fit the default floating panel, so the + // default ("auto") paints a floating panel at its default + // anchor (top-right, zero offsets): the probe region carries + // the panel's opaque background relative to `legend_mode: + // "none"`. + await restoreChart(page, SPLIT_CONFIG); + const region: PlotRegionFrac = { x: 0.75, y: 0.01, w: 0.24, h: 0.2 }; + const auto = await medianRegionPixels(page, region); + await restoreChart(page, { + plugin_config: { legend_mode: "none" }, + } as any); + const none = await medianRegionPixels(page, region); + expect(auto.pixels - none.pixels).toBeGreaterThan( + 0.1 * auto.regionArea, + ); + + // ~49 `State` entries overflow the panel, so "auto" resolves + // to sidebar: the plot narrows relative to "none" — a strip + // just inside the sidebar gutter shows legend text ink but no + // longer any plot ink. (The scroll/overflow tests in this + // suite exercise the same resolution — they run OVERFLOW_CONFIG + // at the default mode and depend on a sidebar legend.) + await restoreChart(page, OVERFLOW_CONFIG); + await restoreChart(page, { + plugin_config: { legend_mode: "auto" }, + } as any); + const gutter: PlotRegionFrac = { x: 0.94, y: 0.3, w: 0.05, h: 0.4 }; + const sidebar = await medianRegionPixels(page, gutter); + expect(sidebar.pixels).toBeGreaterThan(0); + }); + + test("treemap defaults to sidebar; other chart types default to auto", async ({ + page, + }) => { + const defaults = await page.evaluate(() => { + const modeDefault = (tag: string) => { + const el = document.createElement(tag) as any; + return el + .plugin_config_schema() + .fields.find((f: any) => f.key === "legend_mode")?.default; + }; + + return { + treemap: modeDefault("perspective-viewer-charts-treemap"), + sunburst: modeDefault("perspective-viewer-charts-sunburst"), + yLine: modeDefault("perspective-viewer-charts-y-line"), + }; + }); + + expect(defaults.treemap).toBe("sidebar"); + expect(defaults.sunburst).toBe("auto"); + expect(defaults.yLine).toBe("auto"); + }); + test("floating legend paints an opaque panel at its configured anchor", async ({ page, }) => { @@ -154,6 +220,7 @@ test.describe("legend_mode", () => { await restoreChart(page, { plugin_config: { legend_mode: "floating", + legend_size_mode: "fixed", legend_anchor: "bottom-left", legend_x: 0, legend_y: 0, @@ -217,6 +284,11 @@ test.describe("legend_mode", () => { page, }) => { await restoreChart(page, SPLIT_CONFIG); + // 4 entries resolve "auto" to floating — pin sidebar; this + // test drives the sidebar divider. + await restoreChart(page, { + plugin_config: { legend_mode: "sidebar" }, + } as any); await waitOneFrame(page); const box = await chartBox(page); @@ -246,7 +318,7 @@ test.describe("legend_mode", () => { }) => { await restoreChart(page, SPLIT_CONFIG); await restoreChart(page, { - plugin_config: { legend_width_px: 200 }, + plugin_config: { legend_mode: "sidebar", legend_width_px: 200 }, } as any); await waitOneFrame(page); let cfg = await savedPluginConfig(page); @@ -268,6 +340,7 @@ test.describe("legend_mode", () => { await restoreChart(page, { plugin_config: { legend_mode: "floating", + legend_size_mode: "fixed", legend_width_px: 220, legend_height_px: 300, }, @@ -284,6 +357,9 @@ test.describe("legend_mode", () => { ); expect(cfg.legend_width_px).toBeUndefined(); expect(cfg.legend_height_px).toBeUndefined(); + // The reset also returns the panel to content sizing, so the + // whole size bucket strips back to schema defaults. + expect(cfg.legend_size_mode).toBeUndefined(); expect(cfg.legend_mode).toBe("floating"); }); @@ -324,6 +400,7 @@ test.describe("legend_mode", () => { await restoreChart(page, { plugin_config: { legend_mode: "floating", + legend_size_mode: "fixed", legend_width_px: 220, legend_height_px: 300, legend_anchor: "bottom-right", @@ -343,6 +420,7 @@ test.describe("legend_mode", () => { }, cfg); expect(roundTripped.legend_mode).toBe("floating"); + expect(roundTripped.legend_size_mode).toBe("fixed"); expect(roundTripped.legend_width_px).toBe(220); expect(roundTripped.legend_height_px).toBe(300); expect(roundTripped.legend_anchor).toBe("bottom-right"); @@ -351,3 +429,253 @@ test.describe("legend_mode", () => { expect(roundTripped.legend_opacity).toBe(0.5); }); }); + +/** + * `legend_size_mode: "auto"` panel height, mirroring `floatingBox`: + * header + frame padding + one row per entry. Asserting against the + * formula (rather than a golden) keeps the test honest if the row + * metrics change — both sides read the same three constants. + */ +const LEGEND_HEADER_H = 18; +const LEGEND_FRAME_H = 8; +const LEGEND_LINE_HEIGHT = 18; + +function autoHeight(entries: number): number { + return LEGEND_HEADER_H + LEGEND_FRAME_H + entries * LEGEND_LINE_HEIGHT; +} + +/** Many more rows than `SPLIT_CONFIG` (14 vs 3 in the test fixture). */ +const MANY_ENTRY_CONFIG = { + plugin: "Y Line", + columns: ["Profit"], + group_by: ["Order Date"], + split_by: ["Sub-Category"], +}; + +async function splitEntryCount(page: Page, column: string): Promise { + return await page.evaluate(async (col) => { + const viewer = document.querySelector("perspective-viewer") as any; + const table = await viewer.getTable(); + const view = await table.view({ group_by: [col] }); + // `num_rows` counts the rollup root row too. + const rows = (await view.num_rows()) - 1; + await view.delete(); + return rows; + }, column); +} + +/** + * Probe band over CSS rows `[y0, y1)` of the canvas, `wPx` wide and + * flush to the right edge — where a top-right anchored floating panel + * paints. Expressed in CSS px (converted to the fractions + * `medianRegionPixels` wants) because the panel's auto size is in CSS + * px, not a fraction of the canvas. + */ +async function rightBand( + page: Page, + y0: number, + y1: number, + wPx = 56, +): Promise { + const box = await chartBox(page); + return { + x: (box.width - wPx) / box.width, + y: y0 / box.height, + w: wPx / box.width, + h: (y1 - y0) / box.height, + }; +} + +/** + * Ink the floating panel adds over `region`, isolated from the plot + * behind it by differencing against `legend_mode: "none"` on the SAME + * chart. + * + * The whole size bucket is pinned on every call, not just the fields + * `pluginConfig` overrides: `restore({plugin_config})` MERGES, so a + * probe that named only its own fields would inherit the previous + * probe's overrides. + */ +async function panelInk( + page: Page, + region: PlotRegionFrac, + pluginConfig: Record, +): Promise<{ ink: number; regionArea: number }> { + await restoreChart(page, { + plugin_config: { + legend_mode: "floating", + legend_size_mode: "auto", + legend_width_px: 0, + legend_height_px: 160, + ...pluginConfig, + }, + } as any); + const withLegend = await medianRegionPixels(page, region); + await restoreChart(page, { + plugin_config: { legend_mode: "none" }, + } as any); + const without = await medianRegionPixels(page, region); + return { + ink: withLegend.pixels - without.pixels, + regionArea: withLegend.regionArea, + }; +} + +test.describe("legend_size_mode", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("defaults to auto and round-trips plugin_config", async ({ page }) => { + await restoreChart(page, SPLIT_CONFIG); + + const schemaDefault = await page.evaluate(() => { + const el = document.createElement( + "perspective-viewer-charts-y-line", + ) as any; + return el + .plugin_config_schema() + .fields.find((f: any) => f.key === "legend_size_mode")?.default; + }); + expect(schemaDefault).toBe("auto"); + + await restoreChart(page, { + plugin_config: { legend_size_mode: "fixed" }, + } as any); + expect((await savedPluginConfig(page)).legend_size_mode).toBe("fixed"); + + // "auto" is the schema default, so it strips back out. + await restoreChart(page, { + plugin_config: { legend_size_mode: "auto" }, + } as any); + expect( + (await savedPluginConfig(page)).legend_size_mode, + ).toBeUndefined(); + }); + + test("auto height hugs the entry count", async ({ page }) => { + await restoreChart(page, SPLIT_CONFIG); + const h = autoHeight(await splitEntryCount(page, "Ship Mode")); + + const inside = await rightBand(page, h - 24, h - 6); + const below = await rightBand(page, h + 8, h + 40); + + const insideInk = await panelInk(page, inside, {}); + expect(insideInk.ink).toBeGreaterThan(0.6 * insideInk.regionArea); + + const belowInk = await panelInk(page, below, {}); + expect(belowInk.ink).toBeLessThan(0.15 * belowInk.regionArea); + }); + + test("auto height tracks a larger entry list", async ({ page }) => { + await restoreChart(page, SPLIT_CONFIG); + const fewRows = await splitEntryCount(page, "Ship Mode"); + const manyRows = await splitEntryCount(page, "Sub-Category"); + const lo = autoHeight(fewRows) + 12; + const hi = Math.min(lo + 50, autoHeight(manyRows) - 12); + expect(hi - lo).toBeGreaterThan(10); + const band = await rightBand(page, lo, hi); + + const few = await panelInk(page, band, {}); + + await restoreChart(page, MANY_ENTRY_CONFIG); + const many = await panelInk(page, band, {}); + + expect(few.ink).toBeLessThan(0.15 * few.regionArea); + expect(many.ink).toBeGreaterThan(0.6 * many.regionArea); + }); + + test("auto ignores legend_width_px and legend_height_px", async ({ + page, + }) => { + await restoreChart(page, SPLIT_CONFIG); + const sized = { legend_width_px: 512, legend_height_px: 512 }; + + // Well below a 4-entry auto panel, but inside a 512px fixed one. + const low = await rightBand(page, 260, 320); + const lowAuto = await panelInk(page, low, sized); + expect(lowAuto.ink).toBeLessThan(0.15 * lowAuto.regionArea); + const lowFixed = await panelInk(page, low, { + ...sized, + legend_size_mode: "fixed", + }); + expect(lowFixed.ink).toBeGreaterThan(0.6 * lowFixed.regionArea); + + // Left of a content-sized panel, but inside a 512px-wide one + // (clamped to half the canvas, so still well right of centre). + const leftOfAuto: PlotRegionFrac = { + x: 0.55, + y: 0.02, + w: 0.1, + h: 0.06, + }; + const wideAuto = await panelInk(page, leftOfAuto, sized); + expect(wideAuto.ink).toBeLessThan(0.15 * wideAuto.regionArea); + const wideFixed = await panelInk(page, leftOfAuto, { + ...sized, + legend_size_mode: "fixed", + }); + expect(wideFixed.ink).toBeGreaterThan(0.5 * wideFixed.regionArea); + }); + + test("a sub-pixel legend_x collapses back to the default", async ({ + page, + }) => { + await restoreChart(page, SPLIT_CONFIG); + await restoreChart(page, { + plugin_config: { + legend_mode: "floating", + legend_x: 0.0003, + legend_y: 0.0003, + }, + } as any); + await waitOneFrame(page); + expect((await savedPluginConfig(page)).legend_x).toBe(0.0003); + const box = await chartBox(page); + const grabX = box.x + box.width - 60; + const grabY = box.y + 9; + await page.mouse.move(grabX, grabY); + await page.mouse.down(); + await page.mouse.move(grabX - 20, grabY); + await page.mouse.move(grabX, grabY); + await page.mouse.up(); + + const cfg = await pollPluginConfig( + page, + (c) => c.legend_x === undefined && c.legend_y === undefined, + ); + expect(cfg.legend_x).toBeUndefined(); + expect(cfg.legend_y).toBeUndefined(); + }); + + test("resizing an auto panel switches it to fixed", async ({ page }) => { + await restoreChart(page, SPLIT_CONFIG); + await restoreChart(page, { + plugin_config: { legend_mode: "floating" }, + } as any); + await waitOneFrame(page); + + // Grab the SE corner of the content-sized panel and drag it out. + const rows = await splitEntryCount(page, "Ship Mode"); + const box = await chartBox(page); + const startX = box.x + box.width - 2; + const startY = box.y + autoHeight(rows) - 2; + await page.mouse.move(startX, startY); + await page.mouse.down(); + for (let i = 1; i <= 6; i++) { + await page.mouse.move(startX, startY + i * 20); + } + + await page.mouse.up(); + + const cfg = await pollPluginConfig( + page, + (c) => c.legend_size_mode === "fixed", + ); + expect(cfg.legend_size_mode).toBe("fixed"); + // The freeze seeds BOTH dimensions from the box on screen, so + // the untouched width lands at the auto width rather than 0. + expect(cfg.legend_height_px).toBeGreaterThan(autoHeight(rows)); + expect(cfg.legend_width_px).toBeGreaterThan(0); + }); +}); diff --git a/packages/viewer-charts/test/ts/map-numeric-axes.spec.ts b/packages/viewer-charts/test/ts/map-numeric-axes.spec.ts index 9960c83b95..a1ee2fc110 100644 --- a/packages/viewer-charts/test/ts/map-numeric-axes.spec.ts +++ b/packages/viewer-charts/test/ts/map-numeric-axes.spec.ts @@ -98,12 +98,15 @@ test.describe("map numeric_axes", () => { test("full-bleed keeps the sidebar legend gutter when a legend shows", async ({ page, }) => { + // The gradient legend resolves "auto" to floating (no entry + // list) — pin sidebar; this test asserts the sidebar gutter. await restoreChart(page, { plugin: "Map Scatter", columns: ["Discount", "Quantity", "Profit"], plugin_config: { map_tile_provider: "test-red", numeric_axes: false, + legend_mode: "sidebar", }, } as any); diff --git a/packages/viewer-charts/test/ts/snapshot/sunburst.spec.ts b/packages/viewer-charts/test/ts/snapshot/sunburst.spec.ts index 692d4245dd..2d9992faa9 100644 --- a/packages/viewer-charts/test/ts/snapshot/sunburst.spec.ts +++ b/packages/viewer-charts/test/ts/snapshot/sunburst.spec.ts @@ -101,10 +101,14 @@ test.describe("Sunburst", () => { }); test("split_by + numeric color, no group_by", async ({ page }) => { - await renderAndCapture(page, { - plugin: "Sunburst", - columns: ["Sales", "Profit"], - split_by: ["Ship Mode"], - }); + await renderAndCapture( + page, + { + plugin: "Sunburst", + columns: ["Sales", "Profit"], + split_by: ["Ship Mode"], + }, + { maxDiffPixelRatio: 0.025 }, + ); }); }); diff --git a/rust/perspective-js/src/rust/typed_array.rs b/rust/perspective-js/src/rust/typed_array.rs index cd3e7aaa2c..27c850e9bb 100644 --- a/rust/perspective-js/src/rust/typed_array.rs +++ b/rust/perspective-js/src/rust/typed_array.rs @@ -14,7 +14,9 @@ use std::io::Cursor; use arrow_array::cast::AsArray; use arrow_array::types::*; -use arrow_array::{Array as _, ArrowPrimitiveType, DictionaryArray, PrimitiveArray, StringArray}; +use arrow_array::{ + Array as _, ArrayRef, ArrowPrimitiveType, DictionaryArray, PrimitiveArray, StringArray, +}; use arrow_ipc::reader::StreamReader; use arrow_schema::{DataType, TimeUnit}; use js_sys::{Array, Function, JsString, Uint8Array}; @@ -74,6 +76,33 @@ fn zero_invalid_slots(arr: &PrimitiveArray) { } } +/// Emit a sub-32-bit integer column as an `Int32Array`. Every Arrow +/// integer width the engine emits below 32 bits (`i8`/`u8`/`i16`/`u16`) +/// widens losslessly, so consumers see ONE integer representation +/// regardless of the column's storage width — and, like `Int32`, one +/// the `float32` flag never narrows. Needs a copy; `Box<[i32]>` gives +/// the stable data pointer the zero-copy `view` requires, exactly as +/// the `f32`/`f64` conversion buffers do. +fn set_widened_i32( + col: &ArrayRef, + col_idx: usize, + js_values: &Array, + js_dicts: &Array, + storage: &mut Vec>, +) where + T: ArrowPrimitiveType, + T::Native: Into, +{ + let typed = col.as_primitive::(); + zero_invalid_slots(typed); + let vals: Box<[i32]> = typed.values().iter().map(|&v| v.into()).collect(); + + let arr = unsafe { js_sys::Int32Array::view(&vals) }; + storage.push(vals); + js_values.set(col_idx as u32, arr.into()); + js_dicts.set(col_idx as u32, JsValue::NULL); +} + /// Decode an Arrow IPC batch and call `callback` once with all columns. /// /// Callback signature: @@ -108,12 +137,14 @@ pub(crate) async fn decode_and_call( let js_validities = Array::new_with_length(num_cols as u32); let js_dicts = Array::new_with_length(num_cols as u32); - // Storage for type-conversion buffers (Int64/Date32/Timestamp and - // `float32` narrowing). These MUST outlive the callback because - // `js_sys::*Array::view()` creates zero-copy views into their heap - // memory. Using `Box<[T]>` (rather than `Vec`) yields a stable - // data pointer that won't move when the outer Vec grows, so a view - // created before the push stays valid. + // Storage for type-conversion buffers (narrow-int/bool widening, + // Int64/UInt64/Date32/Timestamp and `float32` narrowing). These + // MUST outlive the callback because `js_sys::*Array::view()` + // creates zero-copy views into their heap memory. Using `Box<[T]>` + // (rather than `Vec`) yields a stable data pointer that won't + // move when the outer Vec grows, so a view created before the push + // stays valid. + let mut i32_storage: Vec> = Vec::new(); let mut f32_storage: Vec> = Vec::new(); let mut f64_storage: Vec> = Vec::new(); @@ -133,6 +164,24 @@ pub(crate) async fn decode_and_call( js_names.set(col_idx as u32, JsString::from(field.name().as_str()).into()); match col.data_type() { + DataType::Int8 => { + set_widened_i32::(col, col_idx, &js_values, &js_dicts, &mut i32_storage); + }, + DataType::UInt8 => { + set_widened_i32::(col, col_idx, &js_values, &js_dicts, &mut i32_storage); + }, + DataType::Int16 => { + set_widened_i32::(col, col_idx, &js_values, &js_dicts, &mut i32_storage); + }, + DataType::UInt16 => { + set_widened_i32::( + col, + col_idx, + &js_values, + &js_dicts, + &mut i32_storage, + ); + }, DataType::UInt32 => { let typed = col.as_primitive::(); zero_invalid_slots(typed); @@ -218,6 +267,46 @@ pub(crate) async fn decode_and_call( js_dicts.set(col_idx as u32, JsValue::NULL); }, + // Neither `u64` nor `i64` fits `Int32Array`, so both widen + // to float (narrowed by `float32` like the other float + // columns) and lose exactness past 2^53. `UInt64` is not an + // exotic case: `get_simple_accumulator_type` promotes EVERY + // unsigned width to `DTYPE_UINT64`, so the `sum` of any + // unsigned column in a `group_by` view lands here. + DataType::UInt64 => { + let typed = col.as_primitive::(); + zero_invalid_slots(typed); + if float32 { + let vals: Box<[f32]> = typed.values().iter().map(|&v| v as f32).collect(); + + let arr = unsafe { js_sys::Float32Array::view(&vals) }; + f32_storage.push(vals); + js_values.set(col_idx as u32, arr.into()); + } else { + let vals: Box<[f64]> = typed.values().iter().map(|&v| v as f64).collect(); + + let arr = unsafe { js_sys::Float64Array::view(&vals) }; + f64_storage.push(vals); + js_values.set(col_idx as u32, arr.into()); + } + + js_dicts.set(col_idx as u32, JsValue::NULL); + }, + DataType::Boolean => { + // Bit-packed, so `zero_invalid_slots` (a `PrimitiveArray` + // memory rewrite) cannot apply — the `is_valid` test + // below enforces the same "invalid slots read 0" + // contract while materializing. + let typed = col.as_boolean(); + let vals: Box<[i32]> = (0..typed.len()) + .map(|i| i32::from(typed.is_valid(i) && typed.value(i))) + .collect(); + + let arr = unsafe { js_sys::Int32Array::view(&vals) }; + i32_storage.push(vals); + js_values.set(col_idx as u32, arr.into()); + js_dicts.set(col_idx as u32, JsValue::NULL); + }, DataType::Dictionary(..) => { let dict = col .as_any() @@ -279,9 +368,9 @@ pub(crate) async fn decode_and_call( )?; // If the callback returned a Promise, await it before releasing the - // batch — zero-copy TypedArray views into `batch`/`f32_storage`/ - // `f64_storage` must remain valid for the full lifetime of the - // awaited work. + // batch — zero-copy TypedArray views into `batch` and the + // `i32`/`f32`/`f64` conversion buffers must remain valid for the + // full lifetime of the awaited work. if ret.is_instance_of::() { let promise: js_sys::Promise = ret.unchecked_into(); wasm_bindgen_futures::JsFuture::from(promise).await?; @@ -289,6 +378,7 @@ pub(crate) async fn decode_and_call( // Keep storage alive until after the callback (and its awaited // promise, if any) returns. + drop(i32_storage); drop(f32_storage); drop(f64_storage); diff --git a/rust/perspective-js/test/js/to_format/with_column_typed_array.spec.ts b/rust/perspective-js/test/js/to_format/with_column_typed_array.spec.ts index b801103be5..8ef6f291f1 100644 --- a/rust/perspective-js/test/js/to_format/with_column_typed_array.spec.ts +++ b/rust/perspective-js/test/js/to_format/with_column_typed_array.spec.ts @@ -12,6 +12,7 @@ import { test, expect } from "@perspective-dev/test"; import perspective from "../perspective_client"; +import * as arrows from "../test_arrows.js"; test.describe("with_typed_arrays()", () => { test("awaits promise returned by async callback before releasing the batch", async () => { @@ -822,4 +823,143 @@ test.describe("with_typed_arrays()", () => { await table.delete(); }); }); + + test.describe("integer widths", () => { + test("decodes every column of all_types_small.arrow", async () => { + const table = await perspective.table( + arrows.all_types_arrow.slice(), + ); + const view = await table.view(); + const typeMap: Record = {}; + const valueMap: Record = {}; + await view.with_typed_arrays( + {}, + ( + n: string[], + vals: any[], + _valids: any[], + dicts: (string[] | null)[], + ) => { + for (let i = 0; i < n.length; i++) { + typeMap[n[i]] = vals[i].constructor.name; + if (dicts[i] === null) { + valueMap[n[i]] = Array.from(vals[i]); + } + } + }, + ); + + expect(typeMap["i8"]).toEqual("Int32Array"); + expect(typeMap["ui8"]).toEqual("Int32Array"); + expect(typeMap["i16"]).toEqual("Int32Array"); + expect(typeMap["ui16"]).toEqual("Int32Array"); + expect(typeMap["i32"]).toEqual("Int32Array"); + expect(typeMap["bool"]).toEqual("Int32Array"); + expect(typeMap["i64"]).toEqual("Float64Array"); + expect(typeMap["ui64"]).toEqual("Float64Array"); + const cols = await view.to_columns(); + for (const name of ["i8", "ui8", "i16", "ui16", "i32", "ui64"]) { + expect(valueMap[name]).toEqual( + (cols[name] as (number | null)[]).map((x) => x ?? 0), + ); + } + + await view.delete(); + await table.delete(); + }); + + test("bool column decodes as 0/1", async () => { + const table = await perspective.table( + arrows.all_types_arrow.slice(), + ); + const view = await table.view({ columns: ["bool"] }); + let values: number[] = []; + await view.with_typed_arrays({}, (n: string[], vals: any[]) => { + values = Array.from(vals[n.indexOf("bool")] as Int32Array); + }); + + const cols = await view.to_columns(); + expect(values).toEqual( + (cols["bool"] as unknown as boolean[]).map((x) => (x ? 1 : 0)), + ); + + await view.delete(); + await table.delete(); + }); + + test("narrow int widths are NOT affected by float32", async () => { + const table = await perspective.table( + arrows.all_types_arrow.slice(), + ); + const view = await table.view({ + columns: ["ui8", "i16", "f64"], + }); + + const typeMap: Record = {}; + await view.with_typed_arrays( + { float32: true }, + (n: string[], vals: any[]) => { + for (let i = 0; i < n.length; i++) { + typeMap[n[i]] = vals[i].constructor.name; + } + }, + ); + + expect(typeMap["ui8"]).toEqual("Int32Array"); + expect(typeMap["i16"]).toEqual("Int32Array"); + expect(typeMap["f64"]).toEqual("Float32Array"); + await view.delete(); + await table.delete(); + }); + + test("group_by sum of an unsigned column decodes as UInt64", async () => { + const table = await perspective.table( + arrows.all_types_arrow.slice(), + ); + const view = await table.view({ + columns: ["ui8"], + group_by: ["date"], + aggregates: { ui8: "sum" }, + }); + + let valueType = ""; + let values: number[] = []; + await view.with_typed_arrays({}, (n: string[], vals: any[]) => { + const idx = n.indexOf("ui8"); + valueType = vals[idx].constructor.name; + values = Array.from(vals[idx] as Float64Array); + }); + + expect(valueType).toEqual("Float64Array"); + const cols = await view.to_columns(); + expect(values).toEqual( + (cols["ui8"] as (number | null)[]).map((x) => x ?? 0), + ); + await view.delete(); + await table.delete(); + }); + + test("float32 narrows the UInt64 accumulator", async () => { + const table = await perspective.table( + arrows.all_types_arrow.slice(), + ); + const view = await table.view({ + columns: ["ui8"], + group_by: ["date"], + aggregates: { ui8: "sum" }, + }); + + let valueType = ""; + await view.with_typed_arrays( + { float32: true }, + (n: string[], vals: any[]) => { + valueType = vals[n.indexOf("ui8")].constructor.name; + }, + ); + + expect(valueType).toEqual("Float32Array"); + await view.delete(); + await table.delete(); + }); + }); }); diff --git a/rust/perspective-viewer/src/css/plugin-settings-panel.css b/rust/perspective-viewer/src/css/plugin-settings-panel.css index 0179a0de09..6326caeeda 100644 --- a/rust/perspective-viewer/src/css/plugin-settings-panel.css +++ b/rust/perspective-viewer/src/css/plugin-settings-panel.css @@ -105,6 +105,10 @@ content: var(--psp-label--legend-mode--content); } + label#legend_size_mode-label:before { + content: var(--psp-label--legend-size-mode--content); + } + label#legend_width_px-label:before { content: var(--psp-label--legend-width-px--content); } diff --git a/rust/perspective-viewer/src/themes/intl.css b/rust/perspective-viewer/src/themes/intl.css index dc4ad9888d..de74765d4f 100644 --- a/rust/perspective-viewer/src/themes/intl.css +++ b/rust/perspective-viewer/src/themes/intl.css @@ -160,6 +160,7 @@ perspective-dropdown { --psp-label--numeric-axes--content: "Numeric axes"; --psp-label--interpolate--content: "Interpolate null"; --psp-label--legend-mode--content: "Legend mode"; + --psp-label--legend-size-mode--content: "Legend sizing"; --psp-label--legend-width-px--content: "Legend width"; --psp-label--legend-height-px--content: "Legend height"; --psp-label--legend-anchor--content: "Legend anchor"; diff --git a/rust/perspective-viewer/src/themes/intl/de.css b/rust/perspective-viewer/src/themes/intl/de.css index 1d5d352320..cabe4ddb87 100644 --- a/rust/perspective-viewer/src/themes/intl/de.css +++ b/rust/perspective-viewer/src/themes/intl/de.css @@ -161,6 +161,7 @@ perspective-dropdown { --psp-label--map-tile-alpha--content: "Kartentransparenz"; --psp-label--numeric-axes--content: "Numerische Achsen"; --psp-label--legend-mode--content: "Legendenmodus"; + --psp-label--legend-size-mode--content: "Legendengröße"; --psp-label--legend-width-px--content: "Legendenbreite"; --psp-label--legend-height-px--content: "Legendenhöhe"; --psp-label--legend-anchor--content: "Legendenanker"; diff --git a/rust/perspective-viewer/src/themes/intl/es.css b/rust/perspective-viewer/src/themes/intl/es.css index 6076ee89a0..beaaaa055a 100644 --- a/rust/perspective-viewer/src/themes/intl/es.css +++ b/rust/perspective-viewer/src/themes/intl/es.css @@ -161,6 +161,7 @@ perspective-dropdown { --psp-label--map-tile-alpha--content: "Opacidad del mapa"; --psp-label--numeric-axes--content: "Ejes numéricos"; --psp-label--legend-mode--content: "Modo de leyenda"; + --psp-label--legend-size-mode--content: "Tamaño de leyenda"; --psp-label--legend-width-px--content: "Ancho de leyenda"; --psp-label--legend-height-px--content: "Altura de leyenda"; --psp-label--legend-anchor--content: "Anclaje de leyenda"; diff --git a/rust/perspective-viewer/src/themes/intl/fr.css b/rust/perspective-viewer/src/themes/intl/fr.css index 9420a8e4d5..b3b192b176 100644 --- a/rust/perspective-viewer/src/themes/intl/fr.css +++ b/rust/perspective-viewer/src/themes/intl/fr.css @@ -161,6 +161,7 @@ perspective-dropdown { --psp-label--map-tile-alpha--content: "Opacité de la carte"; --psp-label--numeric-axes--content: "Axes numériques"; --psp-label--legend-mode--content: "Mode légende"; + --psp-label--legend-size-mode--content: "Taille de légende"; --psp-label--legend-width-px--content: "Largeur de légende"; --psp-label--legend-height-px--content: "Hauteur de légende"; --psp-label--legend-anchor--content: "Ancrage de légende"; diff --git a/rust/perspective-viewer/src/themes/intl/ja.css b/rust/perspective-viewer/src/themes/intl/ja.css index 5a455abdaf..95ef3f571b 100644 --- a/rust/perspective-viewer/src/themes/intl/ja.css +++ b/rust/perspective-viewer/src/themes/intl/ja.css @@ -161,6 +161,7 @@ perspective-dropdown { --psp-label--map-tile-alpha--content: "地図の不透明度"; --psp-label--numeric-axes--content: "数値軸"; --psp-label--legend-mode--content: "凡例モード"; + --psp-label--legend-size-mode--content: "凡例サイズ"; --psp-label--legend-width-px--content: "凡例の幅"; --psp-label--legend-height-px--content: "凡例の高さ"; --psp-label--legend-anchor--content: "凡例のアンカー"; diff --git a/rust/perspective-viewer/src/themes/intl/pt.css b/rust/perspective-viewer/src/themes/intl/pt.css index 4d4dbee90d..e6c87e3a22 100644 --- a/rust/perspective-viewer/src/themes/intl/pt.css +++ b/rust/perspective-viewer/src/themes/intl/pt.css @@ -161,6 +161,7 @@ perspective-dropdown { --psp-label--map-tile-alpha--content: "Opacidade do mapa"; --psp-label--numeric-axes--content: "Eixos numéricos"; --psp-label--legend-mode--content: "Modo da legenda"; + --psp-label--legend-size-mode--content: "Tamanho da legenda"; --psp-label--legend-width-px--content: "Largura da legenda"; --psp-label--legend-height-px--content: "Altura da legenda"; --psp-label--legend-anchor--content: "Âncora da legenda"; diff --git a/rust/perspective-viewer/src/themes/intl/zh.css b/rust/perspective-viewer/src/themes/intl/zh.css index 8d25f0db85..cfbe189e42 100644 --- a/rust/perspective-viewer/src/themes/intl/zh.css +++ b/rust/perspective-viewer/src/themes/intl/zh.css @@ -161,6 +161,7 @@ perspective-dropdown { --psp-label--map-tile-alpha--content: "地图不透明度"; --psp-label--numeric-axes--content: "数值坐标轴"; --psp-label--legend-mode--content: "图例模式"; + --psp-label--legend-size-mode--content: "图例大小"; --psp-label--legend-width-px--content: "图例宽度"; --psp-label--legend-height-px--content: "图例高度"; --psp-label--legend-anchor--content: "图例锚点";