diff --git a/docs/md/SUMMARY.md b/docs/md/SUMMARY.md index 3c3a57c90b..9809af0b74 100644 --- a/docs/md/SUMMARY.md +++ b/docs/md/SUMMARY.md @@ -44,6 +44,7 @@ - [Saving and restoring UI state](./how_to/javascript/save_restore.md) - [Listening for events](./how_to/javascript/events.md) - [Plugin render limits](./how_to/javascript/plugin_settings.md) + - [Map tile sources](./how_to/javascript/map_tile_sources.md) - [Configuring the LLM agent](./how_to/javascript/agent.md) - [Virtual Servers](./how_to/javascript/virtual_server.md) - [DuckDB](./how_to/javascript/virtual_server/duckdb.md) diff --git a/docs/md/how_to/javascript/map_tile_sources.md b/docs/md/how_to/javascript/map_tile_sources.md new file mode 100644 index 0000000000..ccc3729b73 --- /dev/null +++ b/docs/md/how_to/javascript/map_tile_sources.md @@ -0,0 +1,51 @@ +# Map tile sources + +The map plugins (`Map Scatter`, `Map Line`, `Map Density`) draw their glyphs +over a raster XYZ basemap. Which basemap is used is controlled by the +`map_tile_provider` `plugin_config` field, an enum of _tile sources_ — each a +small metadata record describing where to fetch tiles and how to attribute them. +Two providers ship with `@perspective-dev/viewer-charts`: + +- `osm` OpenStreetMap's standard raster tiles (the default) +- `versatiles-satellite` Global satellite imagery from + [VersaTiles](https://versatiles.org) + +## Registering a custom tile source + +Any raster XYZ provider can be added at runtime with `registerTileSource`, +exported from the `@perspective-dev/viewer-charts` module. Registered sources +appear in the settings panel's "Map provider" control alongside the bundled ones +and are available to every current and future map chart. For example, the +[CARTO](https://carto.com) basemaps: + +```javascript +import { registerTileSource } from "@perspective-dev/viewer-charts"; + +for (const [id, label, path] of [ + ["carto-positron", "Light (Positron)", "light_all"], + ["carto-dark-matter", "Dark Matter", "dark_all"], + ["carto-voyager", "Voyager", "rastertiles/voyager"], +]) { + registerTileSource({ + id, + label, + template: `https://{s}.basemaps.cartocdn.com/${path}/{z}/{x}/{y}.png?api_key=CARTO_API_KEY`, + subdomains: ["a", "b", "c", "d"], + attribution: "© OpenStreetMap contributors © CARTO", + tile_size: 256, + max_zoom: 19, + }); +} +``` + +If you consume the plugin as a registered Custom Element rather than an ES +module, the same function is available as a static on the plugin element class: + +```javascript +customElements + .get("perspective-viewer-charts-map-scatter") + .registerTileSource({ ... }); +``` + +`tileSources()` (also exported, and available as a static) returns the current +list of registered specs. diff --git a/docs/src/data/projects/blocks.ts b/docs/src/data/projects/blocks.ts index 49c47e5697..9c4cc840f8 100644 --- a/docs/src/data/projects/blocks.ts +++ b/docs/src/data/projects/blocks.ts @@ -626,7 +626,6 @@ export const SF_PROJECTS: Project[] = [ }, plugin: "Map Density", plugin_config: { - map_tile_provider: "carto-dark-matter", gradient_radius_px: 15, gradient_intensity: 1, }, @@ -658,9 +657,6 @@ export const SF_PROJECTS: Project[] = [ workspace: singlePanel({ columns_config: {}, plugin: "Map Scatter", - plugin_config: { - map_tile_provider: "carto-dark-matter", - }, table: "evictions", theme: null, title: null, diff --git a/docs/src/data/tile_sources.ts b/docs/src/data/tile_sources.ts new file mode 100644 index 0000000000..70198c6714 --- /dev/null +++ b/docs/src/data/tile_sources.ts @@ -0,0 +1,61 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { registerTileSource } from "@perspective-dev/viewer-charts"; + +/** + * The CARTO basemaps, registered through the public `viewer-charts` + * tile-source API so the docs site's map examples offer them alongside + * the bundled providers. They live here rather than in the library's + * bundled metadata — this file doubles as the worked example for the + * "Map tile sources" guide page (how_to/javascript/map_tile_sources.md); + * keep the two in sync, except the guide's templates append a + * placeholder `?api_key=CARTO_API_KEY` to illustrate keyed providers — + * these live registrations stay key-less (the public CARTO basemaps + * don't require one). + */ +const CARTO_TILE_SOURCES = [ + { + id: "carto-positron", + label: "Light (Positron)", + template: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", + subdomains: ["a", "b", "c", "d"], + attribution: "© OpenStreetMap contributors © CARTO", + tile_size: 256, + max_zoom: 19, + }, + { + id: "carto-dark-matter", + label: "Dark Matter", + template: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", + subdomains: ["a", "b", "c", "d"], + attribution: "© OpenStreetMap contributors © CARTO", + tile_size: 256, + max_zoom: 19, + }, + { + id: "carto-voyager", + label: "Voyager", + template: + "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png", + subdomains: ["a", "b", "c", "d"], + attribution: "© OpenStreetMap contributors © CARTO", + tile_size: 256, + max_zoom: 19, + }, +]; + +export function registerCartoTileSources(): void { + for (const spec of CARTO_TILE_SOURCES) { + registerTileSource(spec); + } +} diff --git a/docs/src/index.ts b/docs/src/index.ts index 656ea24504..be0548501e 100644 --- a/docs/src/index.ts +++ b/docs/src/index.ts @@ -21,6 +21,7 @@ import { initSourceModal } from "./components/source_modal.js"; import { initSqlDrawer } from "./components/sql_drawer.js"; import { bindViewer } from "./data/engines.js"; import { initTheme } from "./data/theme.js"; +import { registerCartoTileSources } from "./data/tile_sources.js"; import type { HTMLPerspectiveViewerElement } from "@perspective-dev/viewer"; const shell = document.getElementById("app")!; @@ -28,6 +29,7 @@ const viewer = document.getElementById( "viewer", ) as HTMLPerspectiveViewerElement; +registerCartoTileSources(); bindViewer(viewer); void initTheme(viewer); diff --git a/packages/viewer-charts/src/ts/axis/legend.ts b/packages/viewer-charts/src/ts/axis/legend.ts index 4a982127f8..1807e441dd 100644 --- a/packages/viewer-charts/src/ts/axis/legend.ts +++ b/packages/viewer-charts/src/ts/axis/legend.ts @@ -13,6 +13,10 @@ import type { Canvas2D, Context2D } from "../charts/canvas-types"; import type { PlotLayout, PlotRect } from "../layout/plot-layout"; import { formatTickValue } from "../layout/ticks"; +import { + LEGEND_HEADER_H, + type LegendController, +} from "../interaction/legend-controller"; import { colorValueToT, sampleGradient, @@ -20,10 +24,152 @@ 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; + +/** Painted scrollbar thumb width (the hit zone is wider). */ +const SCROLLBAR_W = 4; + 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)})`; } +/** + * Presentation context threaded into every legend painter. Carries the + * resolved `legend_mode` branch and the chart's {@link LegendController} + * — the painter reads its scroll offset and reports the painted + * geometry back so hit-testing always resolves against what is on + * screen. `"none"` mode never reaches a painter (call sites skip and + * `clearPainted()` instead). + */ +export interface LegendPaintView { + mode: "sidebar" | "floating"; + legend: LegendController; + title?: string; + sidebarGutter?: number; + + /** + * Floating background-fill opacity (`plugin_config.legend_opacity`, + * default 1). Border, header text, and entries stay opaque. + */ + opacity?: number; +} + +/** + * Paint the floating panel's chrome — themed background, border, and + * header strip — and return the content rect inside it. The header + * carries `title` and doubles as the move grip. `opacity` applies to + * the background fill only. + */ +export function paintFloatingLegendFrame( + ctx: Context2D, + box: PlotRect, + theme: Theme, + title: string | undefined, + opacity: number = 1, +): PlotRect { + ctx.save(); + ctx.fillStyle = theme.backgroundColor; + ctx.strokeStyle = theme.legendBorder; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.rect(box.x + 0.5, box.y + 0.5, box.width - 1, box.height - 1); + ctx.globalAlpha = Math.min(1, Math.max(0, opacity)); + ctx.fill(); + ctx.globalAlpha = 1; + ctx.stroke(); + + // Header separator line under the grip strip. + ctx.beginPath(); + ctx.moveTo(box.x + 0.5, box.y + LEGEND_HEADER_H + 0.5); + ctx.lineTo(box.x + box.width - 0.5, box.y + LEGEND_HEADER_H + 0.5); + ctx.stroke(); + + if (title) { + ctx.fillStyle = theme.legendText; + ctx.font = `bold 10px ${theme.fontFamily}`; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + ctx.fillText( + truncateText(ctx, title, Math.max(0, box.width - 16)), + 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), + }; +} + +/** + * Paint the scroll thumb on the content rect's right edge. No-op when + * the content fits. + */ +export function paintLegendScrollbar( + ctx: Context2D, + content: PlotRect, + scroll: number, + contentHeight: number, + theme: Theme, +): void { + if (contentHeight <= content.height) { + return; + } + + const trackX = content.x + content.width - SCROLLBAR_W; + const thumbH = Math.max( + 20, + (content.height / contentHeight) * content.height, + ); + const travel = content.height - thumbH; + const maxScroll = contentHeight - content.height; + const thumbY = + content.y + (maxScroll > 0 ? (scroll / maxScroll) * travel : 0); + ctx.save(); + ctx.fillStyle = theme.legendBorder; + ctx.fillRect(trackX, content.y, SCROLLBAR_W, content.height); + ctx.fillStyle = theme.legendText; + ctx.fillRect(trackX, thumbY, SCROLLBAR_W, thumbH); + ctx.restore(); +} + +/** + * Clip `text` to `maxWidth` with a trailing ellipsis. Only called for + * entries inside the visible window, so the `measureText` cost is + * bounded by the box height, not the entry count. + */ +export function truncateText( + ctx: Context2D, + text: string, + maxWidth: number, +): string { + if (maxWidth <= 0) { + return ""; + } + + if (ctx.measureText(text).width <= maxWidth) { + return text; + } + + let lo = 0; + let hi = text.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + if (ctx.measureText(text.slice(0, mid) + "…").width <= maxWidth) { + lo = mid; + } else { + hi = mid - 1; + } + } + + return lo > 0 ? text.slice(0, lo) + "…" : "…"; +} + /** * Render a vertical color gradient legend on the Canvas2D overlay. * Only call when a color column is active. When `colorDomain` crosses @@ -40,6 +186,7 @@ export function renderLegend( stops: GradientStop[], theme: Theme, formatter?: (v: number) => string, + view?: LegendPaintView, ): void { const rect: PlotRect = { x: layout.plotRect.x + layout.plotRect.width + 12, @@ -50,7 +197,15 @@ export function renderLegend( ), height: Math.max(1, layout.plotRect.height), }; - renderLegendAt(canvas, rect, colorDomain, stops, theme, formatter); + renderLegendAt( + canvas, + rect, + colorDomain, + stops, + theme, + formatter, + view && { ...view, sidebarGutter: layout.margins.right }, + ); } /** @@ -65,6 +220,7 @@ export function renderLegendAt( stops: GradientStop[], theme: Theme, formatter: (v: number) => string = formatTickValue, + view?: LegendPaintView, ): void { const ctx = canvas.getContext("2d") as Context2D | null; if (!ctx) { @@ -74,17 +230,34 @@ export function renderLegendAt( const textColor = theme.legendText; const borderColor = theme.legendBorder; const fontFamily = theme.fontFamily; + const floating = view?.mode === "floating"; + let x: number; + let y: number; + let barHeight: number; + let content: PlotRect = rect; const barWidth = 16; - const barHeight = Math.min(120, rect.height * 0.4); - const x = rect.x; - const y = rect.y; - - ctx.fillStyle = textColor; - ctx.font = `9px ${fontFamily}`; - ctx.textAlign = "left"; - ctx.textBaseline = "bottom"; - ctx.fillText(colorDomain.label, x, y - 4); + if (floating) { + content = paintFloatingLegendFrame( + ctx, + rect, + theme, + colorDomain.label, + view?.opacity, + ); + x = content.x; + y = content.y + 4; + barHeight = Math.max(8, content.height - 12); + } else { + x = rect.x; + y = rect.y; + barHeight = Math.min(120, rect.height * 0.4); + ctx.fillStyle = textColor; + ctx.font = `9px ${fontFamily}`; + ctx.textAlign = "left"; + ctx.textBaseline = "bottom"; + ctx.fillText(colorDomain.label, x, y - 4); + } // Paint the gradient by walking `colorDomain.min..max` top→bottom and // feeding each value through `colorValueToT` so the legend matches the @@ -114,13 +287,26 @@ export function renderLegendAt( ctx.textBaseline = "middle"; const labelX = x + barWidth + 5; - ctx.fillText(formatter(colorDomain.max), labelX, y + 2); + const labelW = Math.max(0, content.x + content.width - labelX); + ctx.fillText( + truncateText(ctx, formatter(colorDomain.max), labelW), + labelX, + y + 2, + ); ctx.fillText( - formatter((colorDomain.min + colorDomain.max) / 2), + truncateText( + ctx, + formatter((colorDomain.min + colorDomain.max) / 2), + labelW, + ), labelX, y + barHeight / 2, ); - ctx.fillText(formatter(colorDomain.min), labelX, y + barHeight - 2); + ctx.fillText( + truncateText(ctx, formatter(colorDomain.min), labelW), + labelX, + y + barHeight - 2, + ); // Sign-pivot marker when the data crosses zero: a small tick on the // right edge of the bar + a "0" label. @@ -137,6 +323,16 @@ export function renderLegendAt( ctx.fillStyle = textColor; ctx.fillText("0", labelX, zeroY); } + + if (view) { + view.legend.setPainted({ + mode: view.mode, + box: rect, + content, + contentHeight: Math.min(content.height, barHeight + 8), + sidebarGutter: view.sidebarGutter, + }); + } } /** @@ -154,6 +350,7 @@ export function renderCategoricalLegend( labels: Map, palette: [number, number, number][], theme: Theme, + view?: LegendPaintView, ): void { const rect: PlotRect = { x: layout.plotRect.x + layout.plotRect.width + 12, @@ -164,7 +361,14 @@ export function renderCategoricalLegend( ), height: Math.max(1, layout.plotRect.height), }; - renderCategoricalLegendAt(canvas, rect, labels, palette, theme); + renderCategoricalLegendAt( + canvas, + rect, + labels, + palette, + theme, + view && { ...view, sidebarGutter: layout.margins.right }, + ); } /** @@ -178,6 +382,7 @@ export function renderCategoricalLegendAt( labels: Map, palette: [number, number, number][], theme: Theme, + view?: LegendPaintView, ): void { const ctx = canvas.getContext("2d") as Context2D | null; if (!ctx) { @@ -185,34 +390,82 @@ export function renderCategoricalLegendAt( } if (labels.size === 0) { + view?.legend.clearPainted(); return; } const textColor = theme.legendText; const fontFamily = theme.fontFamily; + const floating = view?.mode === "floating"; + + let content: PlotRect = rect; + if (floating) { + content = paintFloatingLegendFrame( + ctx, + rect, + theme, + view?.title ?? "Legend", + view?.opacity, + ); + } const swatchSize = 10; - const lineHeight = 18; - const x = rect.x; - let y = rect.y + lineHeight / 2; + const lineHeight = LEGEND_LINE_HEIGHT; + const contentHeight = labels.size * lineHeight; + const scroll = view + ? view.legend.clampScroll(content.height, contentHeight) + : 0; + const scrollable = contentHeight > content.height; + const textMax = Math.max( + 0, + content.width - swatchSize - 6 - (scrollable ? SCROLLBAR_W + 4 : 0), + ); + + ctx.save(); + ctx.beginPath(); + ctx.rect(content.x, content.y, content.width, content.height); + ctx.clip(); ctx.font = `11px ${fontFamily}`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; - for (const [label, idx] of labels) { - if (y + swatchSize / 2 > rect.y + rect.height) { + const start = Math.floor(scroll / lineHeight); + const x = content.x; + let idx = 0; + let y = content.y + lineHeight / 2 + start * lineHeight - scroll; + for (const [label, palIdx] of labels) { + if (idx < start) { + idx++; + continue; + } + + if (y - lineHeight / 2 >= content.y + content.height) { break; } - const color = palette[idx] ?? - palette[idx % palette.length] ?? [0, 0, 0]; + const color = palette[palIdx] ?? + palette[palIdx % palette.length] ?? [0, 0, 0]; ctx.fillStyle = `rgb(${Math.round(color[0] * 255)},${Math.round(color[1] * 255)},${Math.round(color[2] * 255)})`; ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize); ctx.fillStyle = textColor; - ctx.fillText(label, x + swatchSize + 6, y); + ctx.fillText(truncateText(ctx, label, textMax), x + swatchSize + 6, y); y += lineHeight; + idx++; + } + + ctx.restore(); + paintLegendScrollbar(ctx, content, scroll, contentHeight, theme); + + if (view) { + view.legend.setPainted({ + mode: view.mode, + box: rect, + content, + contentHeight, + sidebarGutter: view.sidebarGutter, + }); } } diff --git a/packages/viewer-charts/src/ts/axis/map-ticks.ts b/packages/viewer-charts/src/ts/axis/map-ticks.ts new file mode 100644 index 0000000000..1d84c9c41d --- /dev/null +++ b/packages/viewer-charts/src/ts/axis/map-ticks.ts @@ -0,0 +1,94 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { PlotLayout } from "../layout/plot-layout"; +import { computeNiceTicks } from "../layout/ticks"; +import { + lonLatToMercator, + mercatorToLonLat, + MAX_LAT, + WORLD_HALF, +} from "../map/mercator"; + +export interface MapDegreeTicks { + xTicks: number[]; + yTicks: number[]; + formatX: (meters: number) => string; + formatY: (meters: number) => string; +} + +/** + * Compute "nice" longitude/latitude ticks for the visible window of a + * map layout. Must be called AFTER `buildProjectionMatrix` has seeded + * the layout's padded-domain fields — the ticks are derived from the + * exact meter domain the projection maps, or labels would misalign + * with glyphs. + */ +export function computeMapDegreeTicks(layout: PlotLayout): MapDegreeTicks { + const plot = layout.plotRect; + const targetX = Math.max(2, Math.floor(plot.width / 90)); + const targetY = Math.max(2, Math.floor(plot.height / 60)); + + const lonMin = metersToLon(layout.paddedXMin); + const lonMax = metersToLon(layout.paddedXMax); + const latMin = clampLat(mercatorToLonLat(0, layout.paddedYMin)[1]); + const latMax = clampLat(mercatorToLonLat(0, layout.paddedYMax)[1]); + + const lonTicks = safeNiceTicks(lonMin, lonMax, targetX); + const latTicks = safeNiceTicks(latMin, latMax, targetY); + const lonStep = tickStep(lonTicks); + const latStep = tickStep(latTicks); + + return { + xTicks: lonTicks.map((deg) => (deg / 180) * WORLD_HALF), + yTicks: latTicks.map((deg) => lonLatToMercator(0, deg)[1]), + formatX: (m) => formatDegrees(metersToLon(m), lonStep, "E", "W"), + formatY: (m) => + formatDegrees(mercatorToLonLat(0, m)[1], latStep, "N", "S"), + }; +} + +function metersToLon(m: number): number { + return (m / WORLD_HALF) * 180; +} + +function clampLat(lat: number): number { + return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat)); +} + +function safeNiceTicks(min: number, max: number, target: number): number[] { + if (!isFinite(min) || !isFinite(max) || max <= min) { + return []; + } + + return computeNiceTicks(min, max, target); +} + +function tickStep(ticks: number[]): number { + return ticks.length > 1 ? Math.abs(ticks[1] - ticks[0]) : 1; +} + +function formatDegrees( + deg: number, + step: number, + pos: string, + neg: string, +): string { + const decimals = + step >= 1 ? 0 : Math.min(6, Math.ceil(-Math.log10(step) + 1e-9)); + const magnitude = String(parseFloat(Math.abs(deg).toFixed(decimals))); + if (parseFloat(magnitude) === 0) { + return "0°"; + } + + return `${magnitude}°${deg > 0 ? pos : neg}`; +} 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 daccc0b2b1..502f215968 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian-render.ts @@ -41,6 +41,7 @@ import { type AxisDomain, } from "../../axis/numeric-axis"; import { initCanvas, getScaledContext } from "../../axis/canvas"; +import { computeMapDegreeTicks } from "../../axis/map-ticks"; import { type CategoricalDomain, type CategoricalLevel, @@ -54,7 +55,12 @@ import { renderLegendAt, renderCategoricalLegend, renderCategoricalLegendAt, + type LegendPaintView, } from "../../axis/legend"; +import { + legendRightGutter, + legendSidebarWidth, +} from "../../interaction/legend-controller"; /** * NaN guard: `_xOrigin`/`_yOrigin` start as NaN before the first valid sample. @@ -368,7 +374,7 @@ function renderSinglePlotFrame( // One-pass plot-width / plot-height estimate to size the // categorical gutter overrides; same approach as series-render. - const estRight = hasColorCol ? 80 : 16; + const estRight = legendRightGutter(chart._pluginConfig, hasColorCol); const estLeftPlain = 55 + (chart._yLabel ? 16 : 0); const estPlotWidth = Math.max(1, cssWidth - estLeftPlain - estRight); const leftExtra = chart._yCategoryDomain @@ -378,13 +384,33 @@ function renderSinglePlotFrame( ? measureCategoricalAxisHeight(chart._xCategoryDomain, estPlotWidth) : undefined; - const layout = new PlotLayout(cssWidth, cssHeight, { - hasXLabel: !!chart._xLabel, - hasYLabel: !!chart._yLabel, - hasLegend: hasColorCol, - leftExtra, - bottomExtra, - }); + const isMap = chart._renderMode === "map"; + const bareMap = isMap && !chart._pluginConfig.numeric_axes; + const layout = new PlotLayout( + cssWidth, + cssHeight, + bareMap + ? { + hasXLabel: false, + hasYLabel: false, + hasLegend: hasColorCol, + leftExtra: 0, + bottomExtra: 0, + rightExtra: + hasColorCol && + chart._pluginConfig.legend_mode === "sidebar" + ? legendSidebarWidth(chart._pluginConfig, 80) + : 0, + } + : { + hasXLabel: !!chart._xLabel, + hasYLabel: !!chart._yLabel, + hasLegend: hasColorCol, + leftExtra, + bottomExtra, + rightExtra: estRight, + }, + ); chart._lastLayout = layout; if (chart._zoomController) { chart._zoomController.updateLayout(layout); @@ -408,8 +434,6 @@ function renderSinglePlotFrame( const xTicks = chart._xIsString ? [] : numericTicks.xTicks; const yTicks = chart._yIsString ? [] : numericTicks.yTicks; - const isMap = chart._renderMode === "map"; - // Defer the gridline draw past the GPU fence (see `_defer2D`) so the // gridline canvas doesn't present ahead of the GL glyphs on resize. // The closure captures this frame's `layout` / ticks / `theme`. @@ -516,14 +540,25 @@ function renderFacetedFrame( // charts always have both axes, so the false branch maps to // per-cell mode (never to "none", which is reserved for tree // charts). + const isMap = chart._renderMode === "map"; + const bareMap = isMap && !chart._pluginConfig.numeric_axes; const grid: FacetGrid = buildFacetGrid(labels, { cssWidth, cssHeight, - xAxis: chart._lastEffectiveSharedX ? "outer" : "cell", - yAxis: chart._lastEffectiveSharedY ? "outer" : "cell", - hasLegend, - hasXLabel: !!chart._xLabel, - hasYLabel: !!chart._yLabel, + xAxis: bareMap + ? "none" + : chart._lastEffectiveSharedX + ? "outer" + : "cell", + yAxis: bareMap + ? "none" + : chart._lastEffectiveSharedY + ? "outer" + : "cell", + hasLegend: hasLegend && chart._pluginConfig.legend_mode === "sidebar", + legendWidth: legendSidebarWidth(chart._pluginConfig, 96), + hasXLabel: !bareMap && !!chart._xLabel, + hasYLabel: !bareMap && !!chart._yLabel, gap: chart._facetConfig.facet_padding, }); chart._facetGrid = grid; @@ -618,7 +653,6 @@ function renderFacetedFrame( // own domain). Map mode skips gridlines entirely; the // basemap layer is rendered into the GL canvas inside the // facet's scissor below. - const isMap = chart._renderMode === "map"; if (gridlineCanvas && !isMap) { // Deferred to the post-fence 2D flush. The closure captures // this facet's `cell` (whose `cell.layout` already carries @@ -733,6 +767,30 @@ function renderSinglePlotChromeOverlay(chart: CartesianChart): void { const isMap = chart._renderMode === "map"; if (isMap) { + if (chart._pluginConfig.numeric_axes) { + const mt = computeMapDegreeTicks(layout); + renderCellXAxis( + chart._chromeCanvas!, + chart._lastXDomain!, + layout, + mt.xTicks, + theme, + !!chart._xLabel, + dpr, + mt.formatX, + ); + renderCellYAxis( + chart._chromeCanvas!, + chart._lastYDomain!, + layout, + mt.yTicks, + theme, + !!chart._yLabel, + dpr, + mt.formatY, + ); + } + chart.renderMapChrome(chart._chromeCanvas!, layout, theme, dpr); } else { renderCartesianCellAxes( @@ -748,37 +806,91 @@ function renderSinglePlotChromeOverlay(chart: CartesianChart): void { ); } - if (chart._lastHasColorCol) { + const legendMode = chart._pluginConfig.legend_mode; + let legendPainted = false; + if (chart._lastHasColorCol && legendMode !== "none") { const stops = chart._lastGradientStops ?? theme.gradientStops; + const floating = legendMode === "floating"; + const view: LegendPaintView = { + mode: floating ? "floating" : "sidebar", + legend: chart._legend, + title: chart._colorName ?? undefined, + opacity: chart._pluginConfig.legend_opacity, + }; + const floatBox = floating + ? chart._legend.floatingBox( + chart._pluginConfig, + layout.cssWidth, + layout.cssHeight, + ) + : null; if (chart._colorIsString && chart._uniqueColorLabels.size > 0) { const palette = resolvePalette( theme.seriesPalette, stops, chart._uniqueColorLabels.size, ); - renderCategoricalLegend( - chart._chromeCanvas!, - layout, - chart._uniqueColorLabels, - palette, - theme, - ); + if (floatBox) { + renderCategoricalLegendAt( + chart._chromeCanvas!, + floatBox, + chart._uniqueColorLabels, + palette, + theme, + view, + ); + } else { + renderCategoricalLegend( + chart._chromeCanvas!, + layout, + chart._uniqueColorLabels, + palette, + theme, + view, + ); + } + + legendPainted = true; } else if (chart._colorName) { - renderLegend( - chart._chromeCanvas!, - layout, - { - min: chart._colorMin, - max: chart._colorMax, - label: chart._colorName, - }, - stops, - theme, - chart.getColumnFormatter(chart._colorName, "value"), + const colorDomain = { + min: chart._colorMin, + max: chart._colorMax, + label: chart._colorName, + }; + const formatter = chart.getColumnFormatter( + chart._colorName, + "value", ); + if (floatBox) { + renderLegendAt( + chart._chromeCanvas!, + floatBox, + colorDomain, + stops, + theme, + formatter, + view, + ); + } else { + renderLegend( + chart._chromeCanvas!, + layout, + colorDomain, + stops, + theme, + formatter, + view, + ); + } + + legendPainted = true; } } + if (!legendPainted) { + chart._legend.clearPainted(); + } + renderScatterLabels(chart, chart._chromeCanvas!, layout, 0, 1); if (chart._hoveredIndex >= 0 && chart._xData && chart._yData) { @@ -816,35 +928,44 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void { // (one pass per leftmost-column cell). Map mode replaces both // with `renderMapChrome` (attribution + scale bar), painted once // over the whole facet grid. + const mapAxes = isMap && chart._pluginConfig.numeric_axes; + const sharedMapTicks = + mapAxes && grid.cells.length > 0 + ? computeMapDegreeTicks(grid.cells[0].layout) + : null; if (isMap) { chart.renderMapChrome(canvas, chart._lastLayout!, theme, dpr); } - if (!isMap && sharedX && grid.outerXAxisRect) { + if ((!isMap || sharedMapTicks) && sharedX && grid.outerXAxisRect) { renderOuterXAxis( canvas, grid.outerXAxisRect, xDomain, - sharedXTicks, + sharedMapTicks ? sharedMapTicks.xTicks : sharedXTicks, bottomRowLayouts(grid), theme, !!chart._xLabel, dpr, - chart.getColumnFormatter(chart._xName, "tick"), + sharedMapTicks + ? sharedMapTicks.formatX + : chart.getColumnFormatter(chart._xName, "tick"), ); } - if (!isMap && sharedY && grid.outerYAxisRect) { + if ((!isMap || sharedMapTicks) && sharedY && grid.outerYAxisRect) { renderOuterYAxis( canvas, grid.outerYAxisRect, yDomain, - sharedYTicks, + sharedMapTicks ? sharedMapTicks.yTicks : sharedYTicks, leftColumnLayouts(grid), theme, !!chart._yLabel, dpr, - chart.getColumnFormatter(chart._yName, "tick"), + sharedMapTicks + ? sharedMapTicks.formatY + : chart.getColumnFormatter(chart._yName, "tick"), ); } @@ -857,11 +978,16 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void { const d = zc ? zc.getVisibleDomain() : null; const localX = d ? { ...xDomain, min: d.xMin, max: d.xMax } : xDomain; const localY = d ? { ...yDomain, min: d.yMin, max: d.yMax } : yDomain; - const ticks = independent - ? computeTicks(localX, localY, cell.layout) - : { xTicks: sharedXTicks, yTicks: sharedYTicks }; + const cellMapTicks = mapAxes + ? computeMapDegreeTicks(cell.layout) + : null; + const ticks = cellMapTicks + ? cellMapTicks + : independent + ? computeTicks(localX, localY, cell.layout) + : { xTicks: sharedXTicks, yTicks: sharedYTicks }; - if (!isMap && !sharedX) { + if ((!isMap || cellMapTicks) && !sharedX) { if (chart._xIsString && chart._xCategoryDomain) { const cellCtx = getScaledContext(canvas, dpr); if (cellCtx) { @@ -881,12 +1007,14 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void { theme, !!chart._xLabel, dpr, - chart.getColumnFormatter(chart._xName, "tick"), + cellMapTicks + ? cellMapTicks.formatX + : chart.getColumnFormatter(chart._xName, "tick"), ); } } - if (!isMap && !sharedY) { + if ((!isMap || cellMapTicks) && !sharedY) { if (chart._yIsString && chart._yCategoryDomain) { const cellCtx = getScaledContext(canvas, dpr); if (cellCtx) { @@ -906,7 +1034,9 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void { theme, !!chart._yLabel, dpr, - chart.getColumnFormatter(chart._yName, "tick"), + cellMapTicks + ? cellMapTicks.formatY + : chart.getColumnFormatter(chart._yName, "tick"), ); } } @@ -919,10 +1049,25 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void { } // Shared legend: categorical (string color) or gradient - // (numeric color). Position derives from `grid.legendRect` - // which `buildFacetGrid` populates when `hasLegend` was set. - if (chart._lastHasColorCol && grid.legendRect) { + const legendMode = chart._pluginConfig.legend_mode; + const floating = legendMode === "floating"; + const legendAnchor = floating + ? chart._legend.floatingBox( + chart._pluginConfig, + chart._lastLayout!.cssWidth, + chart._lastLayout!.cssHeight, + ) + : grid.legendRect; + let legendPainted = false; + if (chart._lastHasColorCol && legendMode !== "none" && legendAnchor) { const stops = chart._lastGradientStops ?? theme.gradientStops; + const view: LegendPaintView = { + mode: floating ? "floating" : "sidebar", + legend: chart._legend, + title: chart._colorName ?? undefined, + sidebarGutter: floating ? undefined : grid.legendRect?.width, + opacity: chart._pluginConfig.legend_opacity, + }; if (chart._colorIsString && chart._uniqueColorLabels.size > 0) { const palette = resolvePalette( theme.seriesPalette, @@ -931,23 +1076,27 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void { ); renderCategoricalLegendAt( canvas, - grid.legendRect, + legendAnchor, chart._uniqueColorLabels, palette, theme, + view, ); + legendPainted = true; } else if (chart._colorName) { // Numeric gradient legend in the shared outer rect. The // label sits above the bar, so inset the rect's top by // the usual 20 px that `renderLegend` reserves. renderLegendAt( canvas, - { - x: grid.legendRect.x, - y: grid.legendRect.y + 20, - width: grid.legendRect.width, - height: grid.legendRect.height - 20, - }, + floating + ? legendAnchor + : { + x: legendAnchor.x, + y: legendAnchor.y + 20, + width: legendAnchor.width, + height: legendAnchor.height - 20, + }, { min: chart._colorMin, max: chart._colorMax, @@ -956,10 +1105,16 @@ function renderFacetedChromeOverlay(chart: CartesianChart): void { stops, theme, chart.getColumnFormatter(chart._colorName, "value"), + view, ); + legendPainted = true; } } + if (!legendPainted) { + chart._legend.clearPainted(); + } + // Coordinated hover / click indicators across facets. The tooltip // lines are whatever the last resolved lazy fetch produced (or // null while a fetch is still in flight); `renderCanvasTooltip` diff --git a/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts b/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts index 6854d23189..8beed045d0 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/cartesian.ts @@ -65,6 +65,15 @@ export class CartesianChart extends AbstractChart { return this._colorName || null; } + /** + * Chrome-only repaint for legend scroll / floating-legend drag — + * the same lightweight path hover updates use. No GL pass; the + * composite re-presents over the retained plot bitmap. + */ + repaintChrome(): void { + renderCartesianChromeOverlay(this); + } + /** * Rendering pipeline selector. `"cartesian"` is the default — * draws axes, gridlines, and ticks via the chrome canvas. diff --git a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts index 3110df08c3..337c9fa293 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/lines.ts @@ -19,6 +19,7 @@ import { getInstancing, } from "../../../webgl/instanced-attrs"; import { compileProgram } from "../../../webgl/program-cache"; +import { colorRangePivot } from "../../../theme/gradient"; import { formatTickValue, formatDateTickValue } from "../../../layout/ticks"; import lineVert from "../../../shaders/line.vert.glsl"; import lineFrag from "../../../shaders/line.frag.glsl"; @@ -195,10 +196,13 @@ function bindLineState( gl.uniformMatrix4fv(cache.u_projection, false, projection); gl.uniform2f(cache.u_resolution, gl.canvas.width, gl.canvas.height); gl.uniform1f(cache.u_line_width, chart._pluginConfig.line_width_px * dpr); - if (chart._colorMin < chart._colorMax) { - gl.uniform2f(cache.u_color_range, chart._colorMin, chart._colorMax); - } else { + if (chart._colorMin >= chart._colorMax) { gl.uniform2f(cache.u_color_range, 0.0, 0.0); + } else if (chart._colorName && !chart._colorIsString) { + const [lo, hi] = colorRangePivot(chart._colorMin, chart._colorMax); + gl.uniform2f(cache.u_color_range, lo, hi); + } else { + gl.uniform2f(cache.u_color_range, chart._colorMin, chart._colorMax); } bindGradientTexture( diff --git a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/points.ts b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/points.ts index 74b2f6fa68..4390bc7568 100644 --- a/packages/viewer-charts/src/ts/charts/cartesian/glyphs/points.ts +++ b/packages/viewer-charts/src/ts/charts/cartesian/glyphs/points.ts @@ -14,6 +14,7 @@ import type { WebGLContextManager } from "../../../webgl/context-manager"; import type { CartesianChart } from "../cartesian"; import type { Glyph } from "../glyph"; import { bindGradientTexture } from "../../../webgl/gradient-texture"; +import { colorRangePivot } from "../../../theme/gradient"; import { compileProgram } from "../../../webgl/program-cache"; import { buildPointRowTooltipLines } from "../tooltip-lines"; import scatterVert from "../../../shaders/scatter.vert.glsl"; @@ -155,10 +156,13 @@ function setUniforms( gl.uniformMatrix4fv(cache.u_projection, false, projection); gl.uniform1f(cache.u_point_size, chart._pluginConfig.point_size_px * dpr); - if (chart._colorMin < chart._colorMax) { - gl.uniform2f(cache.u_color_range, chart._colorMin, chart._colorMax); - } else { + if (chart._colorMin >= chart._colorMax) { gl.uniform2f(cache.u_color_range, 0.0, 0.0); + } else if (chart._colorName && !chart._colorIsString) { + const [lo, hi] = colorRangePivot(chart._colorMin, chart._colorMax); + gl.uniform2f(cache.u_color_range, lo, hi); + } else { + gl.uniform2f(cache.u_color_range, chart._colorMin, chart._colorMax); } if (chart._sizeMin < chart._sizeMax) { diff --git a/packages/viewer-charts/src/ts/charts/chart-base.ts b/packages/viewer-charts/src/ts/charts/chart-base.ts index 1003de6797..42e1c195fb 100644 --- a/packages/viewer-charts/src/ts/charts/chart-base.ts +++ b/packages/viewer-charts/src/ts/charts/chart-base.ts @@ -41,6 +41,7 @@ import { type UserClickPayload, type UserSelectPayload, } from "../interaction/tooltip-controller"; +import { LegendController } from "../interaction/legend-controller"; import type { PerspectiveClickDetail } from "../event-detail"; import type { ViewConfig } from "@perspective-dev/client"; import { resolveThemeFromVars, type Theme } from "../theme/theme"; @@ -238,6 +239,7 @@ export abstract class AbstractChart implements ChartImplementation { */ _pluginConfig: PluginConfig = { ...DEFAULT_PLUGIN_CONFIG }; + _legend = new LegendController(); _tooltip = new TooltipController(); /** @@ -608,6 +610,11 @@ export abstract class AbstractChart implements ChartImplementation { * inputs in `uploadAndRender`; they take effect on next data load. */ setPluginConfig(cfg: PluginConfig): void { + // Persistence echo vs. real change: a restore whose legend + // fields equal the current values (the round-trip of a + // completed drag) must not disturb legend scroll or an + // in-flight gesture; different values win and cancel any drag. + this._legend.reconcileConfig(this._pluginConfig, cfg); this._pluginConfig = { ...cfg }; this._facetConfig = { ...this._facetConfig, diff --git a/packages/viewer-charts/src/ts/charts/chart.ts b/packages/viewer-charts/src/ts/charts/chart.ts index 33c55777bd..a6db138722 100644 --- a/packages/viewer-charts/src/ts/charts/chart.ts +++ b/packages/viewer-charts/src/ts/charts/chart.ts @@ -11,6 +11,7 @@ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { View } from "@perspective-dev/client"; +import { TILE_SOURCES } from "../map/tile-source"; import type { ColumnDataMap } from "../data/view-reader"; import type { WebGLContextManager } from "../webgl/context-manager"; import type { ZoomController } from "../interaction/zoom-controller"; @@ -198,6 +199,8 @@ export interface ChartImplementation { */ deselect?(): void; + repaintChrome?(): void; + destroy(): void; } @@ -431,13 +434,15 @@ export interface PluginConfig { gradient_color_mode: "mean" | "density" | "extreme" | "signed"; /** - * Map basemap tile provider. Applies only to map plugin tags - * (`map-scatter`, `map-line`, `map-density`). Cartesian charts - * ignore the field. Surfaced as an enum on the settings panel so - * users can switch light/dark/voyager without writing custom - * tile-source code. + * Map basemap tile provider — a `TileSourceSpec` id from the + * tile-source registry ([map/tile-sources.json] entries plus any + * runtime `registerTileSource` additions). Applies only to map + * plugin tags (`map-scatter`, `map-line`, `map-density`); other + * charts ignore the field. The default is the JSON's FIRST entry + * (reordering the file changes the default), and unknown ids fall + * back to that same entry rather than blanking the map. */ - map_tile_provider: "carto-positron" | "carto-dark-matter" | "carto-voyager"; + map_tile_provider: string; /** * Map basemap alpha (0..1). Pre-multiplied into the tile fragment @@ -446,8 +451,60 @@ export interface PluginConfig { * shows the tiles at full opacity. */ map_tile_alpha: number; + + /** + * Map plugins only. `true` (default): standard numeric axes in the + * usual cartesian gutters, with tick labels in degrees + * longitude/latitude (`122.4°W`) rather than Mercator meters. + * `false`: no axes, and the plot is full-bleed — the basemap fills + * the entire canvas, minus only the sidebar legend gutter when a + * legend is actually shown. Gridlines are never drawn in map mode + * — the gridline canvas composites BELOW the GL layer, so opaque + * basemap tiles would hide them. + */ + numeric_axes: boolean; + + /** + * Legend presentation mode. + */ + legend_mode: "sidebar" | "none" | "floating"; + + /** + * 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. + */ + 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. + */ + legend_height_px: number; + + /** + * Canvas corner that `legend_x` / `legend_y` are measured FROM. + * Floating mode only. The panel keeps its distance to this corner + * across panel resizes — anchor `"bottom-right"` with small + * offsets stays glued to the bottom-right. + */ + legend_anchor: LegendAnchor; + + legend_x: number; + legend_y: number; + legend_opacity: number; } +export type LegendAnchor = + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right"; + export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { auto_alt_y_axis: false, facet_mode: "grid", @@ -465,6 +522,14 @@ export const DEFAULT_PLUGIN_CONFIG: PluginConfig = { gradient_intensity: 0.6, gradient_heat_max: 4.0, gradient_color_mode: "mean", - map_tile_provider: "carto-positron", + map_tile_provider: TILE_SOURCES.list()[0].id, map_tile_alpha: 1.0, + numeric_axes: true, + legend_mode: "sidebar", + legend_width_px: 0, + legend_height_px: 160, + legend_anchor: "top-right", + legend_x: 0, + legend_y: 0, + legend_opacity: 1.0, }; 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 996268aa53..335989d350 100644 --- a/packages/viewer-charts/src/ts/charts/common/tree-chrome.ts +++ b/packages/viewer-charts/src/ts/charts/common/tree-chrome.ts @@ -20,7 +20,10 @@ import { renderCategoricalLegend, renderCategoricalLegendAt, renderLegend, + renderLegendAt, + type LegendPaintView, } from "../../axis/legend"; +import { legendSidebarWidth } from "../../interaction/legend-controller"; import type { TreeChartBase } from "./tree-chart"; import { drawTooltipBox } from "./draw-tooltip-box"; @@ -159,50 +162,96 @@ export function renderTreeColorLegend( cssHeight: number, categoricalRect: PlotRect | null = null, ): void { - if (chart._colorMode === "series" && chart._uniqueColorLabels.size > 1) { - if (categoricalRect) { + const cfg = chart._pluginConfig; + const hasCategorical = + chart._colorMode === "series" && chart._uniqueColorLabels.size > 1; + const hasNumeric = + chart._colorMode === "numeric" && chart._colorMin < chart._colorMax; + if (cfg.legend_mode === "none" || (!hasCategorical && !hasNumeric)) { + chart._legend.clearPainted(); + return; + } + + const floating = cfg.legend_mode === "floating"; + const view: LegendPaintView = { + mode: floating ? "floating" : "sidebar", + legend: chart._legend, + title: chart._colorName || "Legend", + opacity: cfg.legend_opacity, + }; + const floatBox = floating + ? chart._legend.floatingBox(cfg, cssWidth, cssHeight) + : null; + + if (hasCategorical) { + if (floatBox) { + renderCategoricalLegendAt( + canvas, + floatBox, + chart._uniqueColorLabels, + palette, + theme, + view, + ); + } else if (categoricalRect) { renderCategoricalLegendAt( canvas, categoricalRect, chart._uniqueColorLabels, palette, theme, + { ...view, sidebarGutter: categoricalRect.width }, ); } else { renderCategoricalLegend( canvas, - syntheticLegendLayout(cssWidth, cssHeight), + syntheticLegendLayout(cssWidth, cssHeight, cfg), chart._uniqueColorLabels, palette, theme, + view, + ); + } + } else { + const colorDomain = { + min: chart._colorMin, + max: chart._colorMax, + label: chart._colorName, + }; + const formatter = chart.getColumnFormatter(chart._colorName, "value"); + if (floatBox) { + renderLegendAt( + canvas, + floatBox, + colorDomain, + stops, + theme, + formatter, + view, + ); + } else { + renderLegend( + canvas, + syntheticLegendLayout(cssWidth, cssHeight, cfg), + colorDomain, + stops, + theme, + formatter, + view, ); } - } else if ( - chart._colorMode === "numeric" && - chart._colorMin < chart._colorMax - ) { - renderLegend( - canvas, - syntheticLegendLayout(cssWidth, cssHeight), - { - min: chart._colorMin, - max: chart._colorMax, - label: chart._colorName, - }, - stops, - theme, - chart.getColumnFormatter(chart._colorName, "value"), - ); } } function syntheticLegendLayout( cssWidth: number, cssHeight: number, + cfg: TreeChartBase["_pluginConfig"], ): PlotLayout { return new PlotLayout(cssWidth, cssHeight, { hasXLabel: false, hasYLabel: false, hasLegend: true, + rightExtra: legendSidebarWidth(cfg, 80), }); } 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 c9aaef31c0..f08ebc0ede 100644 --- a/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts +++ b/packages/viewer-charts/src/ts/charts/heatmap/heatmap-render.ts @@ -47,7 +47,15 @@ const HEATMAP_Y_AXIS_OPTS: CategoricalYAxisOptions = { skipLeafLevel: true, }; -import { renderLegend, renderLegendAt } from "../../axis/legend"; +import { + renderLegend, + renderLegendAt, + type LegendPaintView, +} from "../../axis/legend"; +import { + legendRightGutter, + legendSidebarWidth, +} from "../../interaction/legend-controller"; import heatmapVert from "../../shaders/heatmap.vert.glsl"; import heatmapFrag from "../../shaders/heatmap.frag.glsl"; import { colorValueToT } from "../../theme/gradient"; @@ -110,6 +118,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 estLeft = yNumeric ? 55 : measureCategoricalAxisWidth(yDomain, HEATMAP_Y_AXIS_OPTS); @@ -117,7 +126,7 @@ export function renderHeatmapFrame( ? 24 : measureCategoricalAxisHeight( xDomain, - Math.max(1, cssWidth - estLeft - 110), + Math.max(1, cssWidth - estLeft - 30 - rightExtra), ); const layout = new PlotLayout(cssWidth, cssHeight, { @@ -126,6 +135,7 @@ export function renderHeatmapFrame( hasLegend: true, bottomExtra, leftExtra: estLeft, + rightExtra, }); chart._lastLayout = layout; if (chart._zoomController) { @@ -482,20 +492,51 @@ function paintHeatmapChromeOverlay(chart: HeatmapChart): void { ); } - // Color legend on the right. The aggregate column name is in - // `_columnSlots[0]` (heatmap's only data column slot is "Color"). - renderLegend( - chart._chromeCanvas, - layout, - { + const legendMode = chart._pluginConfig.legend_mode; + if (legendMode === "none") { + chart._legend.clearPainted(); + } else { + const colorDomain = { min: chart._colorMin, max: chart._colorMax, label: chart._aggName, - }, - theme.gradientStops, - theme, - chart.getColumnFormatter(chart._columnSlots[0], "value"), - ); + }; + const formatter = chart.getColumnFormatter( + chart._columnSlots[0], + "value", + ); + const view: LegendPaintView = { + mode: legendMode === "floating" ? "floating" : "sidebar", + legend: chart._legend, + title: chart._aggName, + opacity: chart._pluginConfig.legend_opacity, + }; + if (legendMode === "floating") { + renderLegendAt( + chart._chromeCanvas, + chart._legend.floatingBox( + chart._pluginConfig, + layout.cssWidth, + layout.cssHeight, + ), + colorDomain, + theme.gradientStops, + theme, + formatter, + view, + ); + } else { + renderLegend( + chart._chromeCanvas, + layout, + colorDomain, + theme.gradientStops, + theme, + formatter, + view, + ); + } + } if (chart._hoveredCell) { renderHeatmapTooltip(chart); @@ -528,7 +569,8 @@ function renderFacetedHeatmap( cssHeight, xAxis: effectiveSharedX ? "outer" : "cell", yAxis: effectiveSharedY ? "outer" : "cell", - hasLegend: true, + hasLegend: chart._pluginConfig.legend_mode === "sidebar", + legendWidth: legendSidebarWidth(chart._pluginConfig, 96), hasXLabel: chart._groupBy.length > 0, hasYLabel: false, gap: 8, @@ -793,18 +835,27 @@ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void { ); } - // Shared colorbar at `grid.legendRect`. No meaningful single label — - // the facet titles already name each column, and a combined label - // would be ambiguous when columns differ. - if (grid.legendRect) { + const legendMode = chart._pluginConfig.legend_mode; + const floating = legendMode === "floating"; + const facetLayout = chart._facets[0].layout; + const legendAnchor = floating + ? chart._legend.floatingBox( + chart._pluginConfig, + facetLayout.cssWidth, + facetLayout.cssHeight, + ) + : grid.legendRect; + if (legendMode !== "none" && legendAnchor) { renderLegendAt( chart._chromeCanvas, - { - x: grid.legendRect.x, - y: grid.legendRect.y + 20, - width: grid.legendRect.width, - height: Math.max(1, grid.legendRect.height - 20), - }, + floating + ? legendAnchor + : { + x: legendAnchor.x, + y: legendAnchor.y + 20, + width: legendAnchor.width, + height: Math.max(1, legendAnchor.height - 20), + }, { min: chart._colorMin, max: chart._colorMax, @@ -813,7 +864,16 @@ function renderFacetedHeatmapChromeOverlay(chart: HeatmapChart): void { theme.gradientStops, theme, chart.getColumnFormatter(chart._columnSlots[0], "value"), + { + mode: floating ? "floating" : "sidebar", + legend: chart._legend, + title: chart._aggName, + sidebarGutter: floating ? undefined : grid.legendRect?.width, + opacity: chart._pluginConfig.legend_opacity, + }, ); + } else { + chart._legend.clearPainted(); } if (chart._hoveredCell) { diff --git a/packages/viewer-charts/src/ts/charts/map/map.ts b/packages/viewer-charts/src/ts/charts/map/map.ts index 4cef7661a2..d8de183566 100644 --- a/packages/viewer-charts/src/ts/charts/map/map.ts +++ b/packages/viewer-charts/src/ts/charts/map/map.ts @@ -22,7 +22,7 @@ import type { Canvas2D, Context2D } from "../canvas-types"; import type { ZoomConfig } from "../../interaction/zoom-controller"; import type { PluginConfig } from "../chart"; import { TileLayer } from "../../map/tile-layer"; -import { tileSourceFor, type TileProviderId } from "../../map/tile-source"; +import { TILE_SOURCES } from "../../map/tile-source"; import { lonLatToMercator } from "../../map/mercator"; import { getScaledContext } from "../../axis/canvas"; @@ -76,7 +76,7 @@ export class MapChart extends CartesianChart { if (this._glManager) { this._tileLayer.setSource( this._glManager.gl, - tileSourceFor(cfg.map_tile_provider as TileProviderId), + TILE_SOURCES.sourceFor(cfg.map_tile_provider), ); } @@ -97,9 +97,7 @@ export class MapChart extends CartesianChart { if (!this._tileLayer.source) { this._tileLayer.setSource( glManager.gl, - tileSourceFor( - this._pluginConfig.map_tile_provider as TileProviderId, - ), + TILE_SOURCES.sourceFor(this._pluginConfig.map_tile_provider), ); this._tileLayer.setAlpha(this._pluginConfig.map_tile_alpha); } diff --git a/packages/viewer-charts/src/ts/charts/series/series-build.ts b/packages/viewer-charts/src/ts/charts/series/series-build.ts index 147a0d91ff..9ce8fc9328 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-build.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-build.ts @@ -336,6 +336,14 @@ export interface SeriesPipelineResult { * is the position. Indexed by `catIdx` (0..numCategories-1). */ categoryPositions: Float64Array | null; + + /** + * One entry per (aggregate × split), in `k * P + p` order. INVARIANT: + * `series.length === aggregates.length * splitPrefixes.length` — + * consumers index `series[k * P]` directly (glyph runs, the + * `domain_mode: "expand"` axis signature), so an empty `series` MUST + * be paired with empty `aggregates` / `splitPrefixes`. + */ series: SeriesInfo[]; /** @@ -477,10 +485,14 @@ export function buildSeriesPipeline( ); if (numCategories === 0) { + // NOT `aggregates` / `splitPrefixes`: `series` is empty here, and + // carrying the non-empty lists would break the `series.length === + // M * P` invariant — `series[k * P]` consumers then read + // `undefined` (the `loadAndRender failed … reading 'axis'` crash). + // Same shape as the `aggregates.length === 0` return above, which + // every downstream path already renders as an empty chart. return { ...empty, - aggregates, - splitPrefixes, rowPaths, rowOffset, }; 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 466cbef9ad..c6910f7cd9 100644 --- a/packages/viewer-charts/src/ts/charts/series/series-render.ts +++ b/packages/viewer-charts/src/ts/charts/series/series-render.ts @@ -57,6 +57,17 @@ import { drawFacetTitle } from "../../axis/facet-chrome"; import { getScaledContext, initCanvas } from "../../axis/canvas"; import { drawGridlinesX, drawGridlinesY } from "../../axis/axis-primitives"; import { buildBarTooltipLines } from "./series-interact"; +import { + LEGEND_LINE_HEIGHT, + paintFloatingLegendFrame, + paintLegendScrollbar, + truncateText, + type LegendPaintView, +} from "../../axis/legend"; +import { + legendRightGutter, + legendSidebarWidth, +} from "../../interaction/legend-controller"; /** * Reusable scratch for bar instance uploads. @@ -529,7 +540,7 @@ export function renderBarFrame( ? 55 : measureCategoricalAxisWidth(provisionalDomain); const estLeft = leftExtra + (hasCatLabel ? 16 : 0); - const estRight = hasLegend ? 80 : 16; + const estRight = legendRightGutter(chart._pluginConfig, hasLegend); const estPlotWidthH = Math.max(1, cssWidth - estLeft - estRight); const bottomExtra = valueCatActive ? measureCategoricalAxisHeight(valueCatDomain, estPlotWidthH) @@ -540,6 +551,7 @@ export function renderBarFrame( hasLegend, leftExtra, bottomExtra, + rightExtra: estRight, }); } else if (numericCat) { // Y Bar with numeric category axis on X. Value axis (Y, left) @@ -553,6 +565,7 @@ export function renderBarFrame( hasLegend, bottomExtra: 24, leftExtra, + rightExtra: legendRightGutter(chart._pluginConfig, hasLegend), }); } else { // Y Bar with categorical X. Value axis on the left may be @@ -561,7 +574,7 @@ export function renderBarFrame( ? measureCategoricalAxisWidth(valueCatDomain) : 55; const estLeft = leftExtraBase + 16; - const estRight = hasLegend ? 80 : 16; + const estRight = legendRightGutter(chart._pluginConfig, hasLegend); const estPlotWidth = Math.max(1, cssWidth - estLeft - estRight); const bottomExtra = measureCategoricalAxisHeight( provisionalDomain, @@ -573,6 +586,7 @@ export function renderBarFrame( hasLegend, bottomExtra, leftExtra: valueCatActive ? leftExtraBase : undefined, + rightExtra: estRight, }); } @@ -898,7 +912,8 @@ function renderFacetedBarFrame( cssHeight, xAxis: horizontal ? valAxisMode : catAxisMode, yAxis: horizontal ? catAxisMode : valAxisMode, - hasLegend, + hasLegend: hasLegend && chart._pluginConfig.legend_mode === "sidebar", + legendWidth: legendSidebarWidth(chart._pluginConfig, 96), hasXLabel: horizontal ? true : hasCatLabel, hasYLabel: horizontal ? hasCatLabel : true, gap: chart._facetConfig.facet_padding, @@ -1351,66 +1366,89 @@ function renderFacetedBarChromeOverlay(chart: SeriesChart): void { } /** - * Aggregate-level legend for the faceted frame, painted into the - * grid's shared right gutter. One entry per aggregate (facets absorb - * the split dimension; every split of an aggregate shares its color — - * see `ensurePalette`). A legend toggle targets the aggregate's full - * seriesId set, so hiding "Sales" hides it in every facet at once; an - * entry reads as hidden only when ALL of its series are hidden. + * One toggleable legend row, resolved lazily — only rows inside the + * visible scroll window are ever materialized. */ -function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void { - chart._legendRects = []; - if (!chart._chromeCanvas || !grid.legendRect) { - return; - } - - const M = chart._aggregates.length; - if (M <= 1) { - return; - } +interface SeriesLegendEntry { + label: string; + color: [number, number, number]; + seriesIds: number[]; + hidden: boolean; +} - const ctx = chart._chromeCanvas.getContext("2d") as Context2D | null; +/** + * Shared swatch-list painter for both series legends (per-series and + * per-aggregate). Paints only the rows inside the scroll window, + * clipped to the content rect, rebuilds `chart._legendRects` with the + * visible rows' canvas-space rects (so toggle clicks stay correct + * while scrolled), and reports the painted geometry to the chart's + * `LegendController`. + */ +function paintSeriesLegend( + chart: SeriesChart, + box: PlotRect, + view: LegendPaintView, + count: number, + entryAt: (i: number) => SeriesLegendEntry, +): void { + const ctx = chart._chromeCanvas!.getContext("2d") as Context2D | null; if (!ctx) { return; } ctx.save(); - const theme = chart._resolveTheme(); + let content = box; + if (view.mode === "floating") { + content = paintFloatingLegendFrame( + ctx, + box, + theme, + view.title, + view.opacity, + ); + } + const swatchSize = 10; - const lineHeight = 18; - const x = grid.legendRect.x + 12; - let y = grid.legendRect.y + 10; + const lineHeight = LEGEND_LINE_HEIGHT; + const contentHeight = count * lineHeight; + const scroll = chart._legend.clampScroll(content.height, contentHeight); + const scrollable = contentHeight > content.height; + const textMax = Math.max( + 0, + content.width - swatchSize - 6 - (scrollable ? 10 : 0), + ); + ctx.beginPath(); + ctx.rect(content.x, content.y, content.width, content.height); + ctx.clip(); ctx.font = `11px ${theme.fontFamily}`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; - for (let k = 0; k < M; k++) { - const seriesIds: number[] = []; - let color: [number, number, number] = [0.5, 0.5, 0.5]; - for (const s of chart._series) { - if (s.aggIdx === k) { - seriesIds.push(s.seriesId); - color = s.color; - } - } - - const label = chart._aggregates[k]; - const hidden = seriesIds.every((sid) => chart._hiddenSeries.has(sid)); - const r = Math.round(color[0] * 255); - const g = Math.round(color[1] * 255); - const b = Math.round(color[2] * 255); + const x = content.x; + const start = Math.floor(scroll / lineHeight); + let y = content.y + lineHeight / 2 + start * lineHeight - scroll; + for ( + let i = start; + i < count && y - lineHeight / 2 < content.y + content.height; + i++ + ) { + const e = entryAt(i); + const r = Math.round(e.color[0] * 255); + const g = Math.round(e.color[1] * 255); + const b = Math.round(e.color[2] * 255); - ctx.globalAlpha = hidden ? 0.3 : 1.0; + ctx.globalAlpha = e.hidden ? 0.3 : 1.0; ctx.fillStyle = `rgb(${r},${g},${b})`; ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize); + const shown = truncateText(ctx, e.label, textMax); + const textW = ctx.measureText(shown).width; ctx.fillStyle = theme.legendText; - ctx.fillText(label, x + swatchSize + 6, y); + ctx.fillText(shown, x + swatchSize + 6, y); - const textW = ctx.measureText(label).width; - if (hidden) { + if (e.hidden) { ctx.strokeStyle = theme.legendText; ctx.lineWidth = 1; ctx.beginPath(); @@ -1422,11 +1460,14 @@ function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void { ctx.globalAlpha = 1.0; chart._legendRects.push({ - seriesIds, + seriesIds: e.seriesIds, rect: { x: x - 2, y: y - lineHeight / 2, - width: swatchSize + 6 + textW + 4, + width: Math.min( + swatchSize + 6 + textW + 4, + Math.max(1, content.width), + ), height: lineHeight, }, }); @@ -1435,38 +1476,87 @@ function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void { } ctx.restore(); + paintLegendScrollbar(ctx, content, scroll, contentHeight, theme); + + chart._legend.setPainted({ + mode: view.mode, + box, + content, + contentHeight, + sidebarGutter: view.sidebarGutter, + }); } /** - * Cached parallel array of measured legend text widths. The legend - * renderer reads from this each frame instead of re-running - * `ctx.measureText` per series; the widths only change on series-set - * or theme change. `_legendCacheValid` gates rebuild. + * Aggregate-level legend for the faceted frame, painted into the + * grid's shared right gutter (or as a floating panel). One entry per + * aggregate (facets absorb the split dimension; every split of an + * aggregate shares its color — see `ensurePalette`). A legend toggle + * targets the aggregate's full seriesId set, so hiding "Sales" hides + * it in every facet at once; an entry reads as hidden only when ALL of + * its series are hidden. */ -let _legendTextWidths: Float64Array = new Float64Array(0); - -function ensureLegendLayout( - chart: SeriesChart, - ctx: Context2D, - fontFamily: string, -): void { - if (chart._legendCacheValid) { +function renderFacetedBarLegend(chart: SeriesChart, grid: FacetGrid): void { + chart._legendRects = []; + if (!chart._chromeCanvas || !chart._lastLayout) { return; } - const series = chart._series; - if (_legendTextWidths.length < series.length) { - _legendTextWidths = new Float64Array(series.length); + 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) + ) { + chart._legend.clearPainted(); + return; } - ctx.save(); - ctx.font = `11px ${fontFamily}`; - for (let i = 0; i < series.length; i++) { - _legendTextWidths[i] = ctx.measureText(series[i].label).width; + // Pre-bucket series by aggregate once (O(series)) so the per-row + // resolver is O(1) — the windowed painter may touch only a few of + // potentially many rows. + const idsByAgg: number[][] = Array.from({ length: M }, () => []); + const colorByAgg: [number, number, number][] = Array.from( + { length: M }, + () => [0.5, 0.5, 0.5], + ); + for (const s of chart._series) { + if (s.aggIdx >= 0 && s.aggIdx < M) { + idsByAgg[s.aggIdx].push(s.seriesId); + colorByAgg[s.aggIdx] = s.color; + } } - ctx.restore(); - chart._legendCacheValid = true; + const layout = chart._lastLayout; + const box = floating + ? chart._legend.floatingBox(cfg, layout.cssWidth, layout.cssHeight) + : { + x: grid.legendRect!.x + 12, + y: grid.legendRect!.y + 10, + width: Math.max(1, grid.legendRect!.width - 16), + height: Math.max(1, grid.legendRect!.height - 20), + }; + + paintSeriesLegend( + chart, + box, + { + mode: floating ? "floating" : "sidebar", + legend: chart._legend, + title: "Legend", + sidebarGutter: floating ? undefined : grid.legendRect!.width, + opacity: cfg.legend_opacity, + }, + M, + (k) => ({ + label: chart._aggregates[k], + color: colorByAgg[k], + seriesIds: idsByAgg[k], + hidden: idsByAgg[k].every((sid) => chart._hiddenSeries.has(sid)), + }), + ); } function renderBarLegend(chart: SeriesChart): void { @@ -1475,73 +1565,48 @@ function renderBarLegend(chart: SeriesChart): void { return; } - if (chart._series.length <= 1) { - return; - } - - const ctx = chart._chromeCanvas.getContext("2d") as Context2D | null; - if (!ctx) { + const cfg = chart._pluginConfig; + const series = chart._series; + if (series.length <= 1 || cfg.legend_mode === "none") { + chart._legend.clearPainted(); return; } - ctx.save(); - - const theme = chart._resolveTheme(); - const textColor = theme.legendText; - const fontFamily = theme.fontFamily; - - ensureLegendLayout(chart, ctx, fontFamily); - const layout = chart._lastLayout; - const swatchSize = 10; - const lineHeight = 18; - const x = layout.plotRect.x + layout.plotRect.width + 12; - let y = layout.margins.top + 10; - - ctx.font = `11px ${fontFamily}`; - ctx.textAlign = "left"; - ctx.textBaseline = "middle"; - - const series = chart._series; - const widths = _legendTextWidths; - for (let i = 0; i < series.length; i++) { - const s = series[i]; - const hidden = chart._hiddenSeries.has(s.seriesId); - const r = Math.round(s.color[0] * 255); - const g = Math.round(s.color[1] * 255); - const b = Math.round(s.color[2] * 255); - - ctx.globalAlpha = hidden ? 0.3 : 1.0; - ctx.fillStyle = `rgb(${r},${g},${b})`; - ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize); - - ctx.fillStyle = textColor; - ctx.fillText(s.label, x + swatchSize + 6, y); - - const textW = widths[i]; - if (hidden) { - ctx.strokeStyle = textColor; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(x + swatchSize + 6, y); - ctx.lineTo(x + swatchSize + 6 + textW, y); - ctx.stroke(); - } - - ctx.globalAlpha = 1.0; - - const rect: PlotRect = { - x: x - 2, - y: y - lineHeight / 2, - width: swatchSize + 6 + textW + 4, - height: lineHeight, - }; - chart._legendRects.push({ seriesIds: [s.seriesId], rect }); - - y += lineHeight; - } - - ctx.restore(); + const floating = cfg.legend_mode === "floating"; + const box = floating + ? chart._legend.floatingBox(cfg, layout.cssWidth, layout.cssHeight) + : { + x: layout.plotRect.x + layout.plotRect.width + 12, + y: layout.margins.top + 10, + width: Math.max( + 1, + layout.cssWidth - + layout.plotRect.x - + layout.plotRect.width - + 16, + ), + height: Math.max(1, layout.plotRect.height - 10), + }; + + paintSeriesLegend( + chart, + box, + { + mode: floating ? "floating" : "sidebar", + legend: chart._legend, + title: chart._splitBy.join(" / ") || "Legend", + sidebarGutter: floating ? undefined : layout.margins.right, + opacity: cfg.legend_opacity, + }, + series.length, + (i) => ({ + label: series[i].label, + color: series[i].color, + seriesIds: [series[i].seriesId], + hidden: chart._hiddenSeries.has(series[i].seriesId), + }), + ); } function renderBarTooltipCanvas(chart: SeriesChart): void { diff --git a/packages/viewer-charts/src/ts/charts/series/series.ts b/packages/viewer-charts/src/ts/charts/series/series.ts index 38ecc02d0a..d4475b226c 100644 --- a/packages/viewer-charts/src/ts/charts/series/series.ts +++ b/packages/viewer-charts/src/ts/charts/series/series.ts @@ -30,6 +30,7 @@ import { } from "./series-build"; import { renderBarFrame, + renderBarChromeOverlay, uploadBarInstances, invalidateGlyphBuffers, rebuildGlyphBuffers, @@ -126,6 +127,15 @@ export class SeriesChart extends CategoricalYChart { return { lockAxis: this._isHorizontal ? "x" : "y" }; } + /** + * Chrome-only repaint for legend scroll / floating-legend drag — + * the same lightweight path hover updates use. No GL pass; the + * composite re-presents over the retained plot bitmap. + */ + repaintChrome(): void { + renderBarChromeOverlay(this); + } + _locations: CachedLocations | null = null; // Series-specific categorical-axis bookkeeping. `_rowPaths`, 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 c4737bcc45..c25ba0a94d 100644 --- a/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts +++ b/packages/viewer-charts/src/ts/charts/sunburst/sunburst-render.ts @@ -28,6 +28,7 @@ import { INNER_RING_PX, } from "./sunburst-layout"; import { buildFacetGrid } from "../../layout/facet-grid"; +import { legendTreeGutter } from "../../interaction/legend-controller"; import { withChromeCache } from "../common/chrome-cache"; import { renderBreadcrumbs as renderTreeBreadcrumbs, @@ -106,7 +107,7 @@ export function renderSunburstFrame( chart._colorMin < chart._colorMax; const breadcrumbH = !hasSplits && chart._breadcrumbIds.length > 1 ? BREADCRUMB_H : 0; - const legendW = hasLegend ? LEGEND_W : 0; + const legendW = legendTreeGutter(chart._pluginConfig, hasLegend, LEGEND_W); 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 1fa6882a54..4153c24e07 100644 --- a/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts +++ b/packages/viewer-charts/src/ts/charts/treemap/treemap-render.ts @@ -23,6 +23,7 @@ import { Theme } from "../../theme/theme"; import { resolvePalette, type Vec3 } from "../../theme/palette"; import { type GradientStop } from "../../theme/gradient"; import { buildFacetGrid } from "../../layout/facet-grid"; +import { legendTreeGutter } from "../../interaction/legend-controller"; import { leafColor, leafRGBA, luminance } from "../common/leaf-color"; import treemapVert from "../../shaders/treemap.vert.glsl"; import treemapFrag from "../../shaders/treemap.frag.glsl"; @@ -70,7 +71,7 @@ export function renderTreemapFrame( ? chart._uniqueColorLabels.size > 1 : chart._colorMode === "numeric" && chart._colorMin < chart._colorMax; - const legendW = hasLegend ? 90 : 0; + const legendW = legendTreeGutter(chart._pluginConfig, hasLegend, 90); // 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/data/view-reader.ts b/packages/viewer-charts/src/ts/data/view-reader.ts index ac417409ea..b5176c522a 100644 --- a/packages/viewer-charts/src/ts/data/view-reader.ts +++ b/packages/viewer-charts/src/ts/data/view-reader.ts @@ -95,12 +95,9 @@ export async function viewToColumnDataMap( } else if (vals instanceof Float64Array) { // Datetime/Date columns are emitted as Float64 to keep // millisecond precision; numeric Float64 also lands here - // when `float32` mode is off. Keep them as f64 — the - // chart's CPU mirrors and extents will rebase to f32 at - // upload time. + // when `float32` mode is off. result.set(name, { type: "float64", values: vals, valid }); } else { - // Fallback: treat as float32 // TODO: Instance check if this needs a copy? result.set(name, { type: "float32", diff --git a/packages/viewer-charts/src/ts/index.ts b/packages/viewer-charts/src/ts/index.ts index a722695d45..94cd63581c 100644 --- a/packages/viewer-charts/src/ts/index.ts +++ b/packages/viewer-charts/src/ts/index.ts @@ -15,6 +15,8 @@ import { HTMLPerspectiveViewerWebGLPluginElement } from "./plugin/plugin"; export type { PerspectiveClickDetail } from "./event-detail"; export { PerspectiveSelectDetail } from "./event-detail"; +export { registerTileSource } from "./plugin/plugin"; +export { tileSources, type TileSourceSpec } from "./map/tile-source"; export function register(...plugin_names: string[]) { const plugins = new Set( diff --git a/packages/viewer-charts/src/ts/interaction/legend-controller.ts b/packages/viewer-charts/src/ts/interaction/legend-controller.ts new file mode 100644 index 0000000000..a0ec7b0d9d --- /dev/null +++ b/packages/viewer-charts/src/ts/interaction/legend-controller.ts @@ -0,0 +1,903 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { + DEFAULT_PLUGIN_CONFIG, + type LegendAnchor, + type PluginConfig, +} from "../charts/chart"; +import type { PlotRect } from "../layout/plot-layout"; +import type { InteractionEvent } from "../transport/protocol"; + +/** Minimum legend width for any mode (drag clamp + config clamp). */ +export const LEGEND_MIN_WIDTH = 48; + +/** Maximum configurable legend width (further clamped to canvas/2). */ +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). */ +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; + +/** Edge-proximity in CSS px that reads as a resize handle. */ +const EDGE = 5; + +/** Corner zone size for the combined SE resize handle. */ +const CORNER = 12; + +/** Scrollbar hit-zone width (paint width is narrower). */ +const SCROLLBAR_HIT_W = 10; + +/** Pointer travel in CSS px before a body-press becomes a move drag. */ +const MOVE_THRESHOLD = 3; + +function clamp(v: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, v)); +} + +function clamp01(v: number): number { + return Number.isFinite(v) ? clamp(v, 0, 1) : 0; +} + +/** 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; +} + +function anchorRight(a: LegendAnchor): boolean { + return a === "top-right" || a === "bottom-right"; +} + +function anchorBottom(a: LegendAnchor): boolean { + return a === "bottom-left" || a === "bottom-right"; +} + +/** + * Sidebar gutter width when the sidebar legend is active: the + * configured `legend_width_px`, or `legacy` (the chart family's + * historical gutter constant) when the config is 0 (auto). + */ +export function legendSidebarWidth(cfg: PluginConfig, legacy: number): number { + return cfg.legend_width_px > 0 + ? clamp(cfg.legend_width_px, LEGEND_MIN_WIDTH, LEGEND_MAX_WIDTH) + : legacy; +} + +/** + * 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. + */ +export function legendRightGutter( + cfg: PluginConfig, + hasLegend: boolean, + legacy: number = 80, +): number { + if (!hasLegend || cfg.legend_mode !== "sidebar") { + return 16; + } + + return legendSidebarWidth(cfg, legacy); +} + +/** + * Tree-chart variant of {@link legendRightGutter}: trees reserve `0` + * (fully flush cells) rather than a 16px breathing margin when no + * sidebar legend is present. + */ +export function legendTreeGutter( + cfg: PluginConfig, + hasLegend: boolean, + legacy: number, +): number { + if (!hasLegend || cfg.legend_mode !== "sidebar") { + return 0; + } + + return legendSidebarWidth(cfg, legacy); +} + +/** + * Cursor-zone classification for a point over the painted legend. + * Sidebar legends expose only `resize-w` (their left edge) plus + * `scrollbar` / `body`; floating panels add the E/S/SE handles and the + * `move` grip (header strip — the body also moves, gated by + * {@link MOVE_THRESHOLD} so entry clicks still land). + */ +export type LegendHitZone = + | "none" + | "body" + | "move" + | "scrollbar" + | "resize-w" + | "resize-e" + | "resize-s" + | "resize-se"; + +/** + * Frame snapshot the legend painters hand back after painting: the + * panel's outer box, the scrollable content rect inside it, and the + * full (unclipped) content height. Hit-testing and wheel/drag routing + * all consult the LAST PAINTED geometry — between paints the on-screen + * pixels are exactly this snapshot, so events resolve against what the + * user actually sees. + */ +export interface PaintedLegend { + mode: "sidebar" | "floating"; + box: PlotRect; + content: PlotRect; + contentHeight: number; + + /** + * The resolved right-gutter width this frame (sidebar mode only). + * The width-drag applies its delta to this — NOT to `box.width`, + * which excludes the gutter's padding — so a drag that starts on + * an auto-width (config 0) legend continues from the family's + * legacy width without a jump. + */ + sidebarGutter?: number; +} + +/** The `PluginConfig` fields this controller owns. */ +const LEGEND_FIELDS = [ + "legend_mode", + "legend_width_px", + "legend_height_px", + "legend_anchor", + "legend_x", + "legend_y", + "legend_opacity", +] as const; + +type LegendFieldSnapshot = { + legend_width_px: number; + legend_height_px: number; + legend_x: number; + legend_y: number; +}; + +type DragState = + | { + kind: "resize-w" | "resize-e" | "resize-s" | "resize-se"; + startMx: number; + startMy: number; + startBox: PlotRect; + startGutter: number; + snapshot: LegendFieldSnapshot; + } + | { + kind: "move"; + startMx: number; + startMy: number; + startBox: PlotRect; + moved: boolean; + snapshot: LegendFieldSnapshot; + } + | { + kind: "scrollbar"; + startMy: number; + startScroll: number; + }; + +/** + * Per-event services the worker renderer supplies. The controller is + * pure state — it never reaches into the chart or transport itself. + */ +export interface LegendEventCtx { + /** + * The chart's live plugin config. Drag gestures mutate the legend + * fields on this object directly (the worker-local copy), so the + * next paint lays out from the in-progress values; the persisted + * delta is posted once at pointerup. + */ + cfg: PluginConfig; + cssWidth: number; + cssHeight: number; + + /** + * Visible toggleable entry rects (series legends). A floating-panel + * click that lands on one passes through to the chart's own legend + * click handler instead of being consumed. + */ + legendRects: ReadonlyArray<{ rect: PlotRect }>; + + /** + * Schedule a repaint. `relayout: true` when the gesture changed the + * plot geometry (sidebar width) and a full GL pass is required; + * `false` for chrome-only changes (scroll, floating move/resize). + */ + repaint(relayout: boolean): void; + + /** Post a persisted config delta (pointerup, changed fields only). */ + postDelta(fields: Partial): void; + + setCursor(cursor: string): void; + + /** Dismiss any active plot hover (cursor entered the legend). */ + dispatchLeave(): void; +} + +/** + * Single owner of legend interaction state: last-painted geometry, + * scroll offset, hover cursor, and the drag state machine. One + * instance per chart (`AbstractChart._legend`); the worker renderer + * consults {@link handleEvent} FIRST for every forwarded interaction + * event, so legend gestures structurally preempt plot zoom / pan / + * 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`. + */ +export class LegendController { + private _painted: PaintedLegend | null = null; + private _scroll = 0; + private _drag: DragState | null = null; + private _suppressClick = false; + private _hoverInside = false; + private _lastCursor = ""; + + // Painter surface + + /** + * Clamp and return the scroll offset for this frame. Painters call + * this before drawing the entry window so the offset is always + * valid against the CURRENT content/viewport pair (content can + * shrink between frames — data updates, panel resizes). + */ + clampScroll(viewHeight: number, contentHeight: number): number { + this._scroll = clamp( + this._scroll, + 0, + Math.max(0, contentHeight - viewHeight), + ); + return this._scroll; + } + + /** Record this frame's painted geometry. */ + setPainted(p: PaintedLegend): void { + this._painted = p; + } + + /** + * Record that no legend was painted this frame (mode `"none"`, or + * no legend content). Kills every hit zone until the next paint. + */ + clearPainted(): void { + this._painted = null; + } + + /** + * Resolve the floating panel's outer box from config. Offsets are + * 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. + */ + floatingBox( + cfg: PluginConfig, + cssWidth: number, + cssHeight: number, + ): 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)), + ); + const height = clamp( + cfg.legend_height_px > 0 + ? cfg.legend_height_px + : FLOATING_AUTO_HEIGHT, + LEGEND_MIN_HEIGHT, + Math.max(LEGEND_MIN_HEIGHT, cssHeight - 8), + ); + 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, + width, + height, + }; + } + + // Config lifecycle + + /** + * Called by `setPluginConfig` BEFORE the incoming config replaces + * the current one. A restore whose legend fields EQUAL the current + * values is the persistence echo of a completed drag — a no-op + * here, so scroll and any in-flight gesture survive. Different + * values (the user typed in the settings form, or a restore raced + * in) win unconditionally: any in-flight drag is cancelled. + */ + reconcileConfig(prev: PluginConfig, next: PluginConfig): void { + if (!this._drag) { + return; + } + + for (const key of LEGEND_FIELDS) { + if (prev[key] !== next[key]) { + this._drag = null; + return; + } + } + } + + // Interaction + + cancelGesture(): void { + this._drag = null; + this._suppressClick = false; + } + + /** + * Route one forwarded interaction event. Returns `true` when the + * event was consumed (the caller must not forward it to zoom / + * tooltip routing). + */ + handleEvent(event: InteractionEvent, ctx: LegendEventCtx): boolean { + switch (event.type) { + case "wheel": + return this._onWheel(event.mx, event.my, event.deltaY, ctx); + case "pointerdown": + return this._onPointerDown(event.mx, event.my, ctx); + case "pointermove": + return this._onPointerMove(event.mx, event.my, ctx); + case "pointerup": + return this._onPointerUp(ctx); + case "click": + return this._onClick(event.mx, event.my, ctx); + case "dblclick": + return this._onDblClick(event.mx, event.my, ctx); + case "pointerleave": + this._hoverInside = false; + this._setCursor("", ctx); + return false; + } + } + + /** Classify a canvas-CSS-px point against the last painted legend. */ + hitTest(mx: number, my: number): LegendHitZone { + const p = this._painted; + if (!p) { + return "none"; + } + + const b = p.box; + if (p.mode === "floating") { + const inX = mx >= b.x - EDGE && mx <= b.x + b.width + EDGE; + const inY = my >= b.y - EDGE && my <= b.y + b.height + EDGE; + if (!inX || !inY) { + return "none"; + } + + const nearW = Math.abs(mx - b.x) <= EDGE; + const nearE = Math.abs(mx - (b.x + b.width)) <= EDGE; + const nearS = Math.abs(my - (b.y + b.height)) <= EDGE; + if ( + (nearE && my >= b.y + b.height - CORNER) || + (nearS && mx >= b.x + b.width - CORNER) + ) { + return "resize-se"; + } + + if (nearE) { + return "resize-e"; + } + + if (nearW) { + return "resize-w"; + } + + if (nearS) { + return "resize-s"; + } + + if (this._inScrollbar(mx, my)) { + return "scrollbar"; + } + + if (my <= b.y + LEGEND_HEADER_H) { + return "move"; + } + + return "body"; + } + + // Sidebar: the grab zone straddles the legend's left edge and + // the 12px pad between it and the plot rect. + const inY = my >= b.y && my <= b.y + b.height; + if (inY && mx >= b.x - 14 && mx <= b.x + 2) { + return "resize-w"; + } + + if (inY && mx >= b.x && mx <= b.x + b.width) { + if (this._inScrollbar(mx, my)) { + return "scrollbar"; + } + + return "body"; + } + + return "none"; + } + + private _scrollable(): boolean { + const p = this._painted; + return !!p && p.contentHeight > p.content.height + 0.5; + } + + private _inScrollbar(mx: number, my: number): boolean { + const p = this._painted; + if (!p || !this._scrollable()) { + return false; + } + + const c = p.content; + return ( + mx >= c.x + c.width - SCROLLBAR_HIT_W && + mx <= c.x + c.width && + my >= c.y && + my <= c.y + c.height + ); + } + + private _onWheel( + mx: number, + my: number, + deltaY: number, + ctx: LegendEventCtx, + ): boolean { + if (this._drag) { + return true; + } + + if (this.hitTest(mx, my) === "none") { + return false; + } + + if (this._scrollable()) { + this._setScroll(this._scroll + deltaY, ctx); + } + + // Consume regardless: a wheel over the legend must never zoom + // the plot underneath (floating), and the sidebar gutter has no + // zoom target anyway. + return true; + } + + private _onPointerDown( + mx: number, + my: number, + ctx: LegendEventCtx, + ): boolean { + const zone = this.hitTest(mx, my); + if (zone === "none") { + return false; + } + + const p = this._painted!; + this._suppressClick = false; + if (zone === "scrollbar") { + this._drag = { + kind: "scrollbar", + startMy: my, + startScroll: this._scroll, + }; + return true; + } + + const snapshot: LegendFieldSnapshot = { + legend_width_px: ctx.cfg.legend_width_px, + legend_height_px: ctx.cfg.legend_height_px, + legend_x: ctx.cfg.legend_x, + legend_y: ctx.cfg.legend_y, + }; + + if ( + zone === "resize-w" || + zone === "resize-e" || + zone === "resize-s" || + zone === "resize-se" + ) { + this._drag = { + kind: zone, + startMx: mx, + startMy: my, + startBox: { ...p.box }, + startGutter: p.sidebarGutter ?? p.box.width, + snapshot, + }; + this._setCursor( + zone === "resize-s" + ? "ns-resize" + : zone === "resize-se" + ? "nwse-resize" + : "ew-resize", + ctx, + ); + return true; + } + + if (p.mode === "floating") { + // Header and body both arm a move; the threshold in + // `_applyDrag` keeps plain clicks (entry toggles) intact. + this._drag = { + kind: "move", + startMx: mx, + startMy: my, + startBox: { ...p.box }, + moved: false, + snapshot, + }; + return true; + } + + // Sidebar body press: nothing to drag, but consume so no other + // handler interprets the press. + return true; + } + + private _onPointerMove( + mx: number, + my: number, + ctx: LegendEventCtx, + ): boolean { + if (this._drag) { + this._applyDrag(mx, my, ctx); + return true; + } + + const zone = this.hitTest(mx, my); + const inside = zone !== "none"; + if (inside && !this._hoverInside) { + // Entering the legend: clear any plot hover so a stale + // tooltip doesn't sit frozen under / beside the panel. + ctx.dispatchLeave(); + } + + this._hoverInside = inside; + this._setCursor(LegendController._cursorFor(zone), ctx); + + // Only the floating panel overlays plot data — consume so the + // hover dispatch can't tooltip through it. The sidebar gutter + // is outside the plot rect; hover there is already inert. + return inside && this._painted!.mode === "floating"; + } + + private _onPointerUp(ctx: LegendEventCtx): boolean { + const d = this._drag; + if (!d) { + return false; + } + + this._drag = null; + if (d.kind === "scrollbar") { + this._suppressClick = true; + return true; + } + + if (d.kind === "move" && !d.moved) { + // A press that never crossed the move threshold: plain + // click — let the click event through for entry toggles. + return true; + } + + this._suppressClick = true; + const fields: Partial = {}; + const cfg = ctx.cfg; + if (cfg.legend_width_px !== d.snapshot.legend_width_px) { + fields.legend_width_px = Math.round(cfg.legend_width_px); + } + + if (cfg.legend_height_px !== d.snapshot.legend_height_px) { + fields.legend_height_px = Math.round(cfg.legend_height_px); + } + + if (round4(cfg.legend_x) !== round4(d.snapshot.legend_x)) { + fields.legend_x = round4(cfg.legend_x); + } + + if (round4(cfg.legend_y) !== round4(d.snapshot.legend_y)) { + fields.legend_y = round4(cfg.legend_y); + } + + if (Object.keys(fields).length > 0) { + ctx.postDelta(fields); + } + + return true; + } + + private _onDblClick(mx: number, my: number, ctx: LegendEventCtx): boolean { + const zone = this.hitTest(mx, my); + if (zone === "none") { + return false; + } + + const resetsWidth = + zone === "resize-w" || zone === "resize-e" || zone === "resize-se"; + const resetsHeight = zone === "resize-s" || zone === "resize-se"; + if (!resetsWidth && !resetsHeight) { + return this._painted!.mode === "floating"; + } + + const cfg = ctx.cfg; + const fields: Partial = {}; + if ( + resetsWidth && + cfg.legend_width_px !== DEFAULT_PLUGIN_CONFIG.legend_width_px + ) { + cfg.legend_width_px = DEFAULT_PLUGIN_CONFIG.legend_width_px; + fields.legend_width_px = cfg.legend_width_px; + } + + if ( + resetsHeight && + cfg.legend_height_px !== DEFAULT_PLUGIN_CONFIG.legend_height_px + ) { + cfg.legend_height_px = DEFAULT_PLUGIN_CONFIG.legend_height_px; + fields.legend_height_px = cfg.legend_height_px; + } + + if (Object.keys(fields).length > 0) { + // Sidebar width changes the plot rect — full relayout; + // floating resets are chrome-only. The anchored-coords + // model keeps the anchor corner glued through the size + // change, so no position rewrite is needed. + ctx.repaint(this._painted!.mode === "sidebar"); + ctx.postDelta(fields); + } + + return true; + } + + private _onClick(mx: number, my: number, ctx: LegendEventCtx): boolean { + if (this._suppressClick) { + this._suppressClick = false; + return true; + } + + const zone = this.hitTest(mx, my); + if (zone === "none") { + return false; + } + + if (zone === "scrollbar") { + return true; + } + + if (this._painted!.mode === "floating") { + // Pass through only when the click lands on a toggleable + // entry (series legends populate `legendRects` with the + // visible window); everything else is consumed so the + // click can't pin a tooltip on the plot under the panel. + for (const entry of ctx.legendRects) { + const r = entry.rect; + if ( + mx >= r.x && + mx <= r.x + r.width && + my >= r.y && + my <= r.y + r.height + ) { + return false; + } + } + + return true; + } + + // Sidebar clicks keep their existing path (series toggle via + // the chart's own click handler; the gutter is outside the + // plot so nothing else can trigger). + return false; + } + + private _applyDrag(mx: number, my: number, ctx: LegendEventCtx): void { + const d = this._drag!; + const cfg = ctx.cfg; + const { cssWidth, cssHeight } = ctx; + const maxW = Math.max(LEGEND_MIN_WIDTH, Math.floor(cssWidth / 2)); + switch (d.kind) { + case "scrollbar": { + const p = this._painted; + if (!p || p.content.height <= 0) { + return; + } + + const ratio = p.contentHeight / p.content.height; + this._setScroll(d.startScroll + (my - d.startMy) * ratio, ctx); + return; + } + + case "move": { + if (!d.moved) { + const dist = Math.hypot(mx - d.startMx, my - d.startMy); + if (dist < MOVE_THRESHOLD) { + return; + } + + d.moved = true; + this._setCursor("grabbing", ctx); + } + + const w = d.startBox.width; + const h = d.startBox.height; + const x = clamp( + d.startBox.x + (mx - d.startMx), + 0, + Math.max(0, cssWidth - w), + ); + const y = clamp( + d.startBox.y + (my - d.startMy), + 0, + Math.max(0, cssHeight - h), + ); + this._writeAnchoredPos(ctx, x, y, w, h); + ctx.repaint(false); + return; + } + + case "resize-w": { + if (this._painted?.mode === "sidebar") { + cfg.legend_width_px = clamp( + Math.round(d.startGutter + (d.startMx - mx)), + LEGEND_MIN_WIDTH, + Math.min(LEGEND_MAX_WIDTH, maxW), + ); + ctx.repaint(true); + return; + } + + // Floating: keep the RIGHT edge fixed while the left + // edge follows the cursor. + const right = d.startBox.x + d.startBox.width; + const w = clamp( + Math.round(d.startBox.width + (d.startMx - mx)), + LEGEND_MIN_WIDTH, + Math.min(LEGEND_MAX_WIDTH, maxW), + ); + cfg.legend_width_px = w; + this._writeAnchoredPos( + ctx, + right - w, + d.startBox.y, + w, + d.startBox.height, + ); + ctx.repaint(false); + return; + } + + case "resize-e": + case "resize-se": { + const w = clamp( + Math.round(d.startBox.width + (mx - d.startMx)), + LEGEND_MIN_WIDTH, + Math.min(LEGEND_MAX_WIDTH, maxW), + ); + cfg.legend_width_px = w; + const h = + d.kind === "resize-se" + ? this._applySouthResize(d.startBox, d.startMy, my, ctx) + : d.startBox.height; + this._writeAnchoredPos(ctx, d.startBox.x, d.startBox.y, w, h); + ctx.repaint(false); + return; + } + + case "resize-s": { + const h = this._applySouthResize( + d.startBox, + d.startMy, + my, + ctx, + ); + this._writeAnchoredPos( + ctx, + d.startBox.x, + d.startBox.y, + d.startBox.width, + h, + ); + ctx.repaint(false); + return; + } + } + } + + /** Bottom-edge resize: new height with the top edge held fixed. */ + private _applySouthResize( + startBox: PlotRect, + startMy: number, + my: number, + ctx: LegendEventCtx, + ): number { + const h = clamp( + Math.round(startBox.height + (my - startMy)), + LEGEND_MIN_HEIGHT, + Math.max(LEGEND_MIN_HEIGHT, ctx.cssHeight - 8), + ); + ctx.cfg.legend_height_px = h; + return h; + } + + private _writeAnchoredPos( + ctx: LegendEventCtx, + xPx: number, + yPx: number, + w: number, + h: number, + ): void { + const freeW = Math.max(0, ctx.cssWidth - w); + const freeH = Math.max(0, ctx.cssHeight - h); + const a = ctx.cfg.legend_anchor; + ctx.cfg.legend_x = norm(anchorRight(a) ? freeW - xPx : xPx, freeW); + ctx.cfg.legend_y = norm(anchorBottom(a) ? freeH - yPx : yPx, freeH); + } + + private _setScroll(next: number, ctx: LegendEventCtx): void { + const p = this._painted; + if (!p) { + return; + } + + const clamped = clamp( + next, + 0, + Math.max(0, p.contentHeight - p.content.height), + ); + if (clamped !== this._scroll) { + this._scroll = clamped; + ctx.repaint(false); + } + } + + private static _cursorFor(zone: LegendHitZone): string { + switch (zone) { + case "resize-w": + case "resize-e": + return "ew-resize"; + case "resize-s": + return "ns-resize"; + case "resize-se": + return "nwse-resize"; + case "move": + return "grab"; + default: + return ""; + } + } + + private _setCursor(cursor: string, ctx: LegendEventCtx): void { + if (cursor !== this._lastCursor) { + this._lastCursor = cursor; + ctx.setCursor(cursor); + } + } +} + +function round4(v: number): number { + return Math.round(v * 10000) / 10000; +} diff --git a/packages/viewer-charts/src/ts/layout/facet-grid.ts b/packages/viewer-charts/src/ts/layout/facet-grid.ts index 42e76944b2..4599d15902 100644 --- a/packages/viewer-charts/src/ts/layout/facet-grid.ts +++ b/packages/viewer-charts/src/ts/layout/facet-grid.ts @@ -50,6 +50,13 @@ export interface FacetGridOptions { */ hasLegend?: boolean; + /** + * Width of the legend gutter when `hasLegend` is set. Defaults to + * the historical `LEGEND_GUTTER` (96). Callers resolve this from + * `plugin_config.legend_width_px` via `legendSidebarWidth`. + */ + legendWidth?: number; + /** Axis-label allowance (consumed only when the corresponding axis * mode produces a gutter — outer band or per-cell). */ hasXLabel?: boolean; @@ -220,7 +227,7 @@ export function buildFacetGrid( } const titleBand = opts.titleBand ?? TITLE_BAND_DEFAULT; - const legendW = opts.hasLegend ? LEGEND_GUTTER : 0; + const legendW = opts.hasLegend ? (opts.legendWidth ?? LEGEND_GUTTER) : 0; const xMode: AxisMode = opts.xAxis ?? "cell"; const yMode: AxisMode = opts.yAxis ?? "cell"; diff --git a/packages/viewer-charts/src/ts/map/tile-loader.ts b/packages/viewer-charts/src/ts/map/tile-loader.ts index 401e9220ce..9ce2dd16c8 100644 --- a/packages/viewer-charts/src/ts/map/tile-loader.ts +++ b/packages/viewer-charts/src/ts/map/tile-loader.ts @@ -119,7 +119,22 @@ export class TileLoader { url: string, signal: AbortSignal, ): Promise { - const resp = await fetch(url, { signal }); + // Explicit same-origin `referrer`: in worker mode this code runs + // in a blob-URL worker, whose client URL is unreferable — its + // fetches carry NO `Referer` under ANY document Referrer-Policy + // (verified empirically, Chromium 139). Tile providers gate on + // it: OSM serves "403r — referer is required" blocked TILES + // (HTTP 200, error drawn into the image; see the OSM wiki's + // Blocked_tiles page) to referer-less requests. An explicit + // same-origin referrer serializes to the page origin under the + // default policy, which is exactly the identification those + // providers ask for. Opaque/non-http origins (`"null"`) are + // skipped — the option would throw. + const referrer = self.origin?.startsWith("http") + ? `${self.origin}/` + : undefined; + + const resp = await fetch(url, { signal, referrer }); if (!resp.ok) { return null; } diff --git a/packages/viewer-charts/src/ts/map/tile-source.ts b/packages/viewer-charts/src/ts/map/tile-source.ts index bf68fe8445..326e2c5d05 100644 --- a/packages/viewer-charts/src/ts/map/tile-source.ts +++ b/packages/viewer-charts/src/ts/map/tile-source.ts @@ -10,6 +10,8 @@ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ +import TILE_SOURCE_SEED from "./tile-sources.json"; + /** * A tile source describes *where* to fetch raster XYZ tiles and what * attribution text the renderer must display in the chrome canvas. @@ -84,73 +86,217 @@ export class TemplatedTileSource implements TileSource { } /** - * Identifier of the default tile providers shipped with `viewer-charts`. - * Surfaced as the `map_tile_provider` PluginConfig enum so users can - * pick light vs. dark vs. labels-only without writing a custom source. + * Declarative description of one raster XYZ tile provider — the shape + * of an entry in [tile-sources.json] and of the argument to + * `registerTileSource`. Everything the renderer needs to construct a + * concrete {@link TileSource} lives here; the TypeScript is generic + * over these entries and hardcodes no provider URLs. */ -export type TileProviderId = - | "carto-positron" - | "carto-dark-matter" - | "carto-voyager"; +export interface TileSourceSpec { + /** Registry key; the persisted `map_tile_provider` value. */ + readonly id: string; + + /** Human-readable enum-variant label on the settings panel. */ + readonly label: string; + + /** + * URL template with `{z}`/`{x}`/`{y}` placeholders and optional + * `{s}` subdomain rotation. A provider that needs an API key + * embeds it directly in the template it registers — keys never + * pass through `plugin_config`, so they never appear in `save()` + * output. + */ + readonly template: string; + + /** `{s}` rotation pool; required non-empty iff `template` has `{s}`. */ + readonly subdomains: readonly string[]; + + /** See {@link TileSource.attribution}. */ + readonly attribution: string; + + /** See {@link TileSource.tileSize}. Default 256. */ + readonly tile_size: number; + + /** See {@link TileSource.maxZoom}. Default 19. */ + readonly max_zoom: number; +} /** - * CartoDB's "Positron" basemap — light, low-contrast, designed to sit - * behind a chart overlay. Default for light themes. + * Validate + normalize an untrusted spec (a JSON entry or a + * `registerTileSource` argument). Throws `TypeError` naming the + * offending field; returns a frozen spec with defaults applied. */ -function cartoPositron(): TileSource { - return new TemplatedTileSource( - "carto-positron", - "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", - "© OpenStreetMap contributors © CARTO", - 256, - 19, - ["a", "b", "c", "d"], - ); +export function parseTileSourceSpec(input: unknown): TileSourceSpec { + if (typeof input !== "object" || input === null) { + throw new TypeError("TileSourceSpec must be an object"); + } + + const raw = input as Record; + const str = (key: string): string => { + const v = raw[key]; + if (typeof v !== "string" || v.length === 0) { + throw new TypeError( + `TileSourceSpec.${key} must be a non-empty string`, + ); + } + + return v; + }; + + const id = str("id"); + const label = str("label"); + const template = str("template"); + const attribution = str("attribution"); + for (const placeholder of ["{z}", "{x}", "{y}"]) { + if (!template.includes(placeholder)) { + throw new TypeError( + `TileSourceSpec.template must contain "${placeholder}"`, + ); + } + } + + const rawSubdomains = raw["subdomains"] ?? []; + if ( + !Array.isArray(rawSubdomains) || + rawSubdomains.some((s) => typeof s !== "string" || s.length === 0) + ) { + throw new TypeError( + "TileSourceSpec.subdomains must be an array of non-empty strings", + ); + } + + const subdomains = Object.freeze([...rawSubdomains] as string[]); + if (template.includes("{s}") && subdomains.length === 0) { + throw new TypeError( + 'TileSourceSpec.template uses "{s}" but no subdomains were given', + ); + } + + const num = (key: string, dflt: number): number => { + const v = raw[key]; + if (v === undefined) { + return dflt; + } + + if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) { + throw new TypeError( + `TileSourceSpec.${key} must be a positive number`, + ); + } + + return v; + }; + + return Object.freeze({ + id, + label, + template, + subdomains, + attribution, + tile_size: num("tile_size", 256), + max_zoom: num("max_zoom", 19), + }); } /** - * CartoDB's "Dark Matter" basemap — dark, low-contrast. Default for - * dark themes. + * 32-bit FNV-1a over a string, hex-encoded. Fingerprints a spec's + * fetch-relevant fields into the {@link TileSource.id} cache key. */ -function cartoDarkMatter(): TileSource { - return new TemplatedTileSource( - "carto-dark-matter", - "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png", - "© OpenStreetMap contributors © CARTO", - 256, - 19, - ["a", "b", "c", "d"], - ); +function fnv1a(s: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + + return (h >>> 0).toString(16); } /** - * CartoDB's "Voyager" basemap — full color, more land/water contrast - * than Positron. Good when the chart glyphs are translucent. + * The set of known tile providers: the bundled entries from + * [tile-sources.json] plus any registered at runtime via + * `registerTileSource`. One instance exists per JS realm ({@link + * TILE_SOURCES}) — the plugin realm's is the source of truth for the + * settings-panel enum; the worker realm's resolves ids at render time. + * The realms need no eager mirroring: every `setPluginConfig` / `init` + * control message carries the resolved spec for the config's + * `map_tile_provider` alongside the config itself, so a worker can + * never hold a config whose spec it lacks. */ -function cartoVoyager(): TileSource { - return new TemplatedTileSource( - "carto-voyager", - "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png", - "© OpenStreetMap contributors © CARTO", - 256, - 19, - ["a", "b", "c", "d"], - ); +export class TileSourceRegistry { + private _specs = new Map(); + + constructor(seed: readonly unknown[]) { + for (const entry of seed) { + const spec = parseTileSourceSpec(entry); + this._specs.set(spec.id, spec); + } + } + + /** + * Add (or replace, by id) a provider. Validates via + * {@link parseTileSourceSpec} and returns the normalized spec. + * Replacement is safe for live charts: the cache identity below is + * content-derived, so a changed template yields a new cache key + * and the tile layer drops its stale textures on next bind. + */ + register(input: unknown): TileSourceSpec { + const spec = parseTileSourceSpec(input); + this._specs.set(spec.id, spec); + return spec; + } + + /** Every known spec, bundled first, in insertion order. */ + list(): readonly TileSourceSpec[] { + return [...this._specs.values()]; + } + + /** The spec registered under `id`, or `undefined` if unknown. */ + specFor(id: string): TileSourceSpec | undefined { + return this._specs.get(id); + } + + /** + * Resolve a `map_tile_provider` id to a concrete `TileSource`. + * Unknown ids fall back to the first bundled entry so a + * misconfigured `plugin_config` never produces a blank map — and + * so a config restored *before* its custom source is registered + * degrades to the default basemap until the next config forward + * carries the registered spec. + */ + sourceFor(id: string): TileSource { + const spec = this._specs.get(id) ?? this._specs.values().next().value!; + + // Cache identity = id + content hash of the fetch-relevant + // fields. Re-registering an id with a different template MUST + // invalidate `TileCache` entries (keyed on `TileSource.id`) + // or stale tiles from the old URL would keep rendering; the + // hash does that without any cross-realm revision state. + const fingerprint = fnv1a( + `${spec.template}${spec.subdomains.join(",")}`, + ); + + return new TemplatedTileSource( + `${spec.id}@${fingerprint}`, + spec.template, + spec.attribution, + spec.tile_size, + spec.max_zoom, + spec.subdomains, + ); + } } /** - * Resolve a `TileProviderId` (from PluginConfig) to a concrete - * `TileSource`. Unknown ids fall back to Positron so a misconfigured - * `_pluginConfig` never produces a blank map. + * Realm-wide provider registry, seeded from [tile-sources.json]. A + * malformed bundled entry fails loudly here, at first import. */ -export function tileSourceFor(id: TileProviderId | string): TileSource { - switch (id) { - case "carto-dark-matter": - return cartoDarkMatter(); - case "carto-voyager": - return cartoVoyager(); - case "carto-positron": - default: - return cartoPositron(); - } +export const TILE_SOURCES = new TileSourceRegistry(TILE_SOURCE_SEED); + +/** + * Read-only listing of every known tile provider (bundled + runtime- + * registered), in settings-panel enum order. Public package export. + */ +export function tileSources(): readonly TileSourceSpec[] { + return TILE_SOURCES.list(); } diff --git a/packages/viewer-charts/src/ts/map/tile-sources.json b/packages/viewer-charts/src/ts/map/tile-sources.json new file mode 100644 index 0000000000..aecdfe6454 --- /dev/null +++ b/packages/viewer-charts/src/ts/map/tile-sources.json @@ -0,0 +1,18 @@ +[ + { + "id": "osm", + "label": "OpenStreetMap", + "template": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "attribution": "© OpenStreetMap contributors", + "tile_size": 256, + "max_zoom": 19 + }, + { + "id": "versatiles-satellite", + "label": "Satellite (VersaTiles)", + "template": "https://tiles.versatiles.org/tiles/satellite/{z}/{x}/{y}", + "attribution": "© VersaTiles (versatiles.org/sources)", + "tile_size": 256, + "max_zoom": 12 + } +] diff --git a/packages/viewer-charts/src/ts/plugin/charts.ts b/packages/viewer-charts/src/ts/plugin/charts.ts index 96126b837b..308a55c419 100644 --- a/packages/viewer-charts/src/ts/plugin/charts.ts +++ b/packages/viewer-charts/src/ts/plugin/charts.ts @@ -95,11 +95,21 @@ const Y_AXIS = ["Y Axis"]; const SELECT = "select"; const TOGGLE = "toggle"; -const DEFAULT_MAX_CELLS = 2_000_000; +const DEFAULT_MAX_CELLS = 10_000_000; const DEFAULT_MAX_COLUMNS = 10_000; // Plugin-config field sets, by chart family. // +const LEGEND_FIELDS: readonly PluginConfigField[] = [ + "legend_mode", + "legend_width_px", + "legend_height_px", + "legend_anchor", + "legend_x", + "legend_y", + "legend_opacity", +]; + // Series charts paint bars / lines / scatter / area glyphs (selected // per-column via `chart_type`), so the union covers every glyph that // might appear. `auto_alt_y_axis` + `series_zoom_mode` are Series-only. @@ -113,6 +123,7 @@ const SERIES_FIELDS: readonly PluginConfigField[] = [ "point_size_px", "band_inner_frac", "bar_inner_pad", + ...LEGEND_FIELDS, ]; // The band pipeline's historical split_by rendering is a single plot @@ -137,6 +148,7 @@ const CARTESIAN_FIELDS: readonly PluginConfigField[] = [ "domain_mode", "line_width_px", "point_size_px", + ...LEGEND_FIELDS, ]; // Candlestick/OHLC share the categorical-X build pipeline (band slots) @@ -151,11 +163,13 @@ const FIN_FIELDS: readonly PluginConfigField[] = [ "ohlc_line_width_px", ]; -// Hierarchical — none of the listed fields apply. -const NO_FIELDS: readonly PluginConfigField[] = []; +const TREE_FIELDS: readonly PluginConfigField[] = [...LEGEND_FIELDS]; // Heatmap -const HEATMAP_FIELDS: readonly PluginConfigField[] = ["facet_zoom_mode"]; +const HEATMAP_FIELDS: readonly PluginConfigField[] = [ + "facet_zoom_mode", + ...LEGEND_FIELDS, +]; // Map — reuses the cartesian build pipeline with a Mercator // projection hook. Carries the basemap controls (`map_tile_provider`, @@ -168,6 +182,8 @@ const MAP_BASE_FIELDS: readonly PluginConfigField[] = [ "domain_mode", "map_tile_provider", "map_tile_alpha", + "numeric_axes", + ...LEGEND_FIELDS, ]; const MAP_SCATTER_FIELDS: readonly PluginConfigField[] = [ ...MAP_BASE_FIELDS, @@ -197,6 +213,7 @@ const DENSITY_FIELDS: readonly PluginConfigField[] = [ "gradient_radius_px", "gradient_intensity", "gradient_heat_max", + ...LEGEND_FIELDS, ]; function make( @@ -314,10 +331,10 @@ const CHARTS: ChartTypeConfig[] = [ DENSITY_FIELDS, { ...CART_ROLES }, ), - make("Treemap", "treemap", HIER, TOGGLE, 1, HIER_NAMES, NO_FIELDS, { + make("Treemap", "treemap", HIER, TOGGLE, 1, HIER_NAMES, TREE_FIELDS, { ...HIER_ROLES, }), - make("Sunburst", "sunburst", HIER, TOGGLE, 1, HIER_NAMES, NO_FIELDS, { + make("Sunburst", "sunburst", HIER, TOGGLE, 1, HIER_NAMES, TREE_FIELDS, { ...HIER_ROLES, }), make("Heatmap", "heatmap", HIER, SELECT, 1, ["Color"], HEATMAP_FIELDS, { diff --git a/packages/viewer-charts/src/ts/plugin/plugin.ts b/packages/viewer-charts/src/ts/plugin/plugin.ts index 2f07e3a2f3..82a6c4799d 100644 --- a/packages/viewer-charts/src/ts/plugin/plugin.ts +++ b/packages/viewer-charts/src/ts/plugin/plugin.ts @@ -27,6 +27,7 @@ import { } from "../charts/chart"; import { RawEventForwarder } from "../interaction/raw-event-forwarder"; import { RendererTransport } from "../transport/renderer-transport"; +import { TILE_SOURCES, type TileSourceSpec } from "../map/tile-source"; import { RENDER_BLIT_MODE } from "../config"; import { snapshotThemeVars } from "../theme/theme-snapshot"; import { resolveThemeFromVars, type Theme } from "../theme/theme"; @@ -42,6 +43,26 @@ import { vec3ToHexColor } from "../utils/css"; */ const FACET_CONFIG_DEFAULTS: FacetConfig = { ...DEFAULT_FACET_CONFIG }; +/** + * Register a raster XYZ tile provider at runtime. The provider joins + * the bundled [map/tile-sources.json] entries in the settings panel's + * `map_tile_provider` enum and resolves on any map chart from its next + * config forward onward — the resolved spec rides every outgoing + * `setPluginConfig` / `init` message alongside the config that names + * it, so no separate registry-sync operation exists. Re-registering an + * id replaces it (a changed template drops cached tiles via the + * content-derived cache identity); a chart currently displaying that + * id picks the replacement up on its next `restore()`. Throws + * `TypeError` on a malformed spec. Returns the normalized spec. + * + * A key-gated provider is registered with the key embedded in its + * `template` — keys never enter `plugin_config`, so they never appear + * in `save()` output. + */ +export function registerTileSource(spec: unknown): TileSourceSpec { + return TILE_SOURCES.register(spec); +} + /** * Static UI-control spec per `plugin_config` field. Mirrors the shape * `column_config_schema` already returns (datagrid). The runtime default @@ -57,71 +78,104 @@ type FieldSpec = } | { kind: "Number"; min: number; max: number; step?: number }; -const FIELD_SCHEMAS: Record = { - auto_alt_y_axis: { kind: "Bool" }, - include_zero: { kind: "Bool" }, - domain_mode: { - kind: "Enum", - variants: [ - { value: "fit", label: "Fit" }, - { value: "expand", label: "Expand" }, - ], - }, - facet_mode: { - kind: "Enum", - variants: [ - { value: "grid", label: "Grid" }, - { value: "overlay", label: "Overlay" }, - ], - }, - facet_zoom_mode: { - kind: "Enum", - variants: [ - { value: "shared", label: "Shared" }, - { value: "independent", label: "Independent" }, - ], - }, - series_zoom_mode: { - kind: "Enum", - variants: [ - { value: "dynamic", label: "Dynamic" }, - { value: "fixed", label: "Fixed" }, - ], - }, - line_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 16 }, - point_size_px: { kind: "Number", min: 1, max: 32 }, - band_inner_frac: { kind: "Number", min: 0.1, max: 1, step: 0.01 }, - bar_inner_pad: { kind: "Number", min: 0, max: 0.9, step: 0.01 }, - wick_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 }, - ohlc_line_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 }, - gradient_radius_px: { kind: "Number", min: 2, step: 1, max: 256 }, - gradient_intensity: { kind: "Number", min: 0.05, step: 0.05, max: 4 }, - gradient_heat_max: { kind: "Number", min: 0.1, step: 0.1, max: 64 }, - gradient_color_mode: { - kind: "Enum", - variants: [ - { value: "mean", label: "Mean (density-weighted)" }, - { value: "density", label: "Density only" }, - { value: "extreme", label: "Extremes" }, - { value: "signed", label: "Signed sum" }, - ], - }, - map_tile_provider: { - kind: "Enum", - variants: [ - { value: "carto-positron", label: "Light (Positron)" }, - { value: "carto-dark-matter", label: "Dark Matter" }, - { value: "carto-voyager", label: "Voyager" }, - ], - }, - map_tile_alpha: { kind: "Number", min: 0, max: 1, step: 0.05 }, -}; +/** + * A `FieldSpec` entry may be a thunk when its contents depend on + * runtime state — `map_tile_provider`'s variants come from the tile- + * source registry, which grows via `registerTileSource`, so the enum + * must be resolved per `plugin_config_schema()` call rather than at + * module init. + */ +const FIELD_SCHEMAS: Record FieldSpec)> = + { + auto_alt_y_axis: { kind: "Bool" }, + include_zero: { kind: "Bool" }, + domain_mode: { + kind: "Enum", + variants: [ + { value: "fit", label: "Fit" }, + { value: "expand", label: "Expand" }, + ], + }, + facet_mode: { + kind: "Enum", + variants: [ + { value: "grid", label: "Grid" }, + { value: "overlay", label: "Overlay" }, + ], + }, + facet_zoom_mode: { + kind: "Enum", + variants: [ + { value: "shared", label: "Shared" }, + { value: "independent", label: "Independent" }, + ], + }, + series_zoom_mode: { + kind: "Enum", + variants: [ + { value: "dynamic", label: "Dynamic" }, + { value: "fixed", label: "Fixed" }, + ], + }, + line_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 16 }, + point_size_px: { kind: "Number", min: 1, max: 32 }, + band_inner_frac: { kind: "Number", min: 0.1, max: 1, step: 0.01 }, + bar_inner_pad: { kind: "Number", min: 0, max: 0.9, step: 0.01 }, + wick_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 }, + ohlc_line_width_px: { kind: "Number", min: 0.5, step: 0.5, max: 8 }, + gradient_radius_px: { kind: "Number", min: 2, step: 1, max: 256 }, + gradient_intensity: { kind: "Number", min: 0.05, step: 0.05, max: 4 }, + gradient_heat_max: { kind: "Number", min: 0.1, step: 0.1, max: 64 }, + gradient_color_mode: { + kind: "Enum", + variants: [ + { value: "mean", label: "Mean (density-weighted)" }, + { value: "density", label: "Density only" }, + { value: "extreme", label: "Extremes" }, + { value: "signed", label: "Signed sum" }, + ], + }, + map_tile_provider: () => ({ + kind: "Enum", + variants: TILE_SOURCES.list().map((s) => ({ + value: s.id, + label: s.label, + })), + }), + map_tile_alpha: { kind: "Number", min: 0, max: 1, step: 0.05 }, + numeric_axes: { kind: "Bool" }, + legend_mode: { + kind: "Enum", + variants: [ + { value: "sidebar", label: "Sidebar" }, + { value: "none", label: "None" }, + { value: "floating", label: "Floating" }, + ], + }, + // 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 }, + legend_anchor: { + kind: "Enum", + variants: [ + { value: "top-right", label: "Top Right" }, + { value: "top-left", label: "Top Left" }, + { value: "bottom-right", label: "Bottom Right" }, + { value: "bottom-left", label: "Bottom Left" }, + ], + }, + legend_x: { kind: "Number", min: 0, max: 1, step: 0.01 }, + legend_y: { kind: "Number", min: 0, max: 1, step: 0.01 }, + legend_opacity: { kind: "Number", min: 0, max: 1, step: 0.05 }, + }; function fieldSpec( key: PluginConfigField, defaults: PluginConfig, ): Record & { kind: string } { - return { ...FIELD_SCHEMAS[key], key, default: defaults[key] }; + const entry = FIELD_SCHEMAS[key]; + const spec = typeof entry === "function" ? entry() : entry; + return { ...spec, key, default: defaults[key] }; } const GLOBAL_STYLES = (() => { @@ -399,6 +453,19 @@ export class HTMLPerspectiveViewerWebGLPluginElement zoomControls.classList.toggle("visible", !isDefault); } }, + onPluginConfigDelta: (fields) => { + this._pluginConfig = { ...this._pluginConfig, ...fields }; + const host = this + .parentElement as HTMLPerspectiveViewerElement | null; + ( + host?.restore( + { plugin_config: fields }, + panel ? { panel } : undefined, + ) as Promise | undefined + )?.catch((e: unknown) => { + console.error("legend config persistence failed", e); + }); + }, }); await transport.init({ @@ -437,6 +504,14 @@ export class HTMLPerspectiveViewerWebGLPluginElement BLIT_MODE = mode; } + static registerTileSource(spec: unknown): TileSourceSpec { + return registerTileSource(spec); + } + + static tileSources(): readonly TileSourceSpec[] { + return TILE_SOURCES.list(); + } + get_static_config(): PluginStaticConfig { return { name: this._chartType.name, diff --git a/packages/viewer-charts/src/ts/shaders/line.vert.glsl b/packages/viewer-charts/src/ts/shaders/line.vert.glsl index 01c7543835..3ca2eb646d 100644 --- a/packages/viewer-charts/src/ts/shaders/line.vert.glsl +++ b/packages/viewer-charts/src/ts/shaders/line.vert.glsl @@ -21,8 +21,11 @@ // `a_color_start` / `a_color_end` carry the segment endpoints' raw // color values (numeric data value for gradient, dictionary index for // categorical). The gradient LUT is sampled using the same mapping the -// scatter shader uses — `(v - cmin) / (cmax - cmin)` with sign-aware -// handling for zero-crossing domains. The two endpoints' colors are +// scatter shader uses — `u_color_range` arrives PRE-SHAPED by the host: +// the symmetric sign-pivot range (`colorRangePivot`) for numeric +// columns, so linear normalization matches the CPU `colorValueToT` +// used by the legend / heatmap / tree charts; raw `[0, N-1]` extents +// for categorical index domains. The two endpoints' colors are // averaged so the segment reads as a single chord in gradient space. attribute vec2 a_start; attribute vec2 a_end; diff --git a/packages/viewer-charts/src/ts/shaders/scatter.vert.glsl b/packages/viewer-charts/src/ts/shaders/scatter.vert.glsl index d652aa7c39..6a61c5a454 100644 --- a/packages/viewer-charts/src/ts/shaders/scatter.vert.glsl +++ b/packages/viewer-charts/src/ts/shaders/scatter.vert.glsl @@ -47,13 +47,6 @@ void main() { v_point_size = gl_PointSize; - // Color-t mapping. Linear across `[cmin, cmax]` for single-sign - // domains (which includes categorical `[0, N-1]` split / string - // indices, so the colors match `interpolatePalette`'s even sampling - // used by the legend). When the domain actually crosses zero we - // switch to sign-aware so the value 0 always lands at the 50% stop - // of the diverging gradient — matching heatmap and the Canvas2D - // tooltip paths. float cmin = u_color_range.x; float cmax = u_color_range.y; if(cmax <= cmin) { diff --git a/packages/viewer-charts/src/ts/theme/gradient.ts b/packages/viewer-charts/src/ts/theme/gradient.ts index d93a88269f..4a5d7c397a 100644 --- a/packages/viewer-charts/src/ts/theme/gradient.ts +++ b/packages/viewer-charts/src/ts/theme/gradient.ts @@ -497,6 +497,30 @@ export function colorValueToT( return t < 0 ? 0 : t > 1 ? 1 : t; } +export function colorRangePivot( + colorMin: number, + colorMax: number, +): [number, number] { + if (!isFinite(colorMin) || !isFinite(colorMax) || colorMin >= colorMax) { + return [0, 0]; + } + + let denom: number; + if (colorMin >= 0) { + denom = colorMax; + } else if (colorMax <= 0) { + denom = -colorMin; + } else { + denom = Math.max(-colorMin, colorMax); + } + + if (denom <= 0) { + return [0, 0]; + } + + return [-denom, denom]; +} + /** * Convert a discrete series palette (from `--psp-charts--series-N--color`) * into a `GradientStop[]` with stops at `i / (N - 1)`. The resulting diff --git a/packages/viewer-charts/src/ts/transport/protocol.ts b/packages/viewer-charts/src/ts/transport/protocol.ts index 687ece17d5..060833a21f 100644 --- a/packages/viewer-charts/src/ts/transport/protocol.ts +++ b/packages/viewer-charts/src/ts/transport/protocol.ts @@ -13,6 +13,7 @@ import type { FacetConfig, PluginConfig } from "../charts/chart"; import type { PerspectiveClickDetail } from "../event-detail"; import type { ThemeSnapshot } from "../theme/theme"; +import type { TileSourceSpec } from "../map/tile-source"; import type { ViewConfig } from "@perspective-dev/client"; export type { ThemeSnapshot }; @@ -53,6 +54,7 @@ export type WorkerMsg = | SetCursorMsg | UserClickMsg | UserSelectMsg + | PluginConfigDeltaMsg | LoadAndRenderAckMsg | ResizeAckMsg | FrameBitmapMsg @@ -185,6 +187,17 @@ export interface InitMsg { columnsConfig?: Record; defaultChartType?: string; + /** + * Resolved spec for `pluginConfig.map_tile_provider`, when the + * plugin realm's registry knows the id — see + * {@link SetPluginConfigMsg.tileSource} for the invariant. Applied + * to the worker realm's registry before the chart impl is + * constructed. Bundled [map/tile-sources.json] entries ship inside + * the worker bundle, so this matters only for runtime-registered + * providers. + */ + tileSource?: TileSourceSpec; + /** * Pre-resolved CSS-variable theme snapshot from the host. */ @@ -271,6 +284,18 @@ export interface SetColumnsConfigMsg { export interface SetPluginConfigMsg { kind: "setPluginConfig"; cfg: PluginConfig; + + /** + * Resolved spec for `cfg.map_tile_provider`, when the plugin + * realm's registry knows the id. Riding the config keeps the two + * realms' registries convergent with NO eager mirroring: the + * worker registers this spec before applying `cfg`, so it can + * never hold a config whose provider it cannot resolve — + * regardless of when `registerTileSource` ran relative to + * renderer construction. Absent for unknown ids (the worker falls + * back to the default basemap). + */ + tileSource?: TileSourceSpec; } export interface SetBufferMaxCapacityMsg { @@ -317,6 +342,7 @@ export interface LoadAndRenderMsg { export interface LoadAndRenderAckMsg { kind: "loadAndRenderAck"; msgId: number; + error?: string; } export interface RedrawMsg { @@ -417,7 +443,8 @@ export interface SnapshotPngReqMsg { export interface SnapshotPngReplyMsg { kind: "snapshotPngReply"; requestId: number; - blob: Blob; + blob?: Blob; + error?: string; } export interface DestroyMsg { @@ -501,6 +528,21 @@ export interface SetCursorMsg { cursor: string; } +/** + * Renderer → host: a completed legend gesture (sidebar width drag, + * floating move / resize) produced new values for the legend's + * `plugin_config` fields. Posted ONCE per gesture, at pointerup — never + * per pointermove — with only the fields the gesture changed. The host + * plugin persists them through the viewer's public `restore` surface + * (a user-gesture echo), which merges the host bucket, refreshes the + * settings form, and echoes one `setPluginConfig` back with the same + * values (a no-op by the worker's legend-field equality guard). + */ +export interface PluginConfigDeltaMsg { + kind: "pluginConfigDelta"; + fields: Record; +} + /** * Renderer → host: a user click landed on a chart glyph. Host * re-dispatches as `CustomEvent` on the diff --git a/packages/viewer-charts/src/ts/transport/renderer-transport.ts b/packages/viewer-charts/src/ts/transport/renderer-transport.ts index 8926495e0c..df660b0992 100644 --- a/packages/viewer-charts/src/ts/transport/renderer-transport.ts +++ b/packages/viewer-charts/src/ts/transport/renderer-transport.ts @@ -26,6 +26,7 @@ import { } from "../event-detail"; import { snapshotThemeVars } from "../theme/theme-snapshot"; import { snapshotFontFaces } from "../utils/font-snapshot"; +import { TILE_SOURCES } from "../map/tile-source"; import { DomHostSink } from "../interaction/host-sink-dom"; import { RUNTIME_MODE } from "../config"; @@ -156,6 +157,10 @@ export class RendererTransport { private _pendingCounter = 0; private _onZoomChanged: ((isDefault: boolean) => void) | null = null; + private _onPluginConfigDelta: + | ((fields: Record) => void) + | null = null; + /** * Cached zoom-default flag pushed by the renderer after each zoom * mutation. Surfaced sync via `allZoomsDefault()`; updates between @@ -235,6 +240,9 @@ export class RendererTransport { maxCells: number; precompileShaders?: boolean; onZoomChanged?: (isDefault: boolean) => void; + onPluginConfigDelta?: ( + fields: Record, + ) => void; }) { this._client = opts.client; this._view = opts.view; @@ -246,6 +254,7 @@ export class RendererTransport { this._maxCells = opts.maxCells; this._precompileShaders = opts.precompileShaders ?? false; this._onZoomChanged = opts.onZoomChanged ?? null; + this._onPluginConfigDelta = opts.onPluginConfigDelta ?? null; this._ready = new Promise((resolve, reject) => { this._resolveReady = resolve; this._rejectReady = reject; @@ -351,6 +360,9 @@ export class RendererTransport { pluginConfig: opts.pluginConfig, columnsConfig: opts.columnsConfig, defaultChartType: opts.defaultChartType, + tileSource: TILE_SOURCES.specFor( + opts.pluginConfig.map_tile_provider, + ), themeVars, fontFaces, cssWidth: rect.width, @@ -488,7 +500,11 @@ export class RendererTransport { } setPluginConfig(cfg: PluginConfig): void { - this._post({ kind: "setPluginConfig", cfg }); + this._post({ + kind: "setPluginConfig", + cfg, + tileSource: TILE_SOURCES.specFor(cfg.map_tile_provider), + }); } setBufferMaxCapacity(n: number): void { @@ -815,6 +831,9 @@ export class RendererTransport { case "setCursor": this._ensureHostSink()?.setCursor(msg.cursor); break; + case "pluginConfigDelta": + this._onPluginConfigDelta?.(msg.fields); + break; case "userClick": this._dispatchOnViewer( new CustomEvent( @@ -878,13 +897,35 @@ export class RendererTransport { this._rejectReady(new Error(msg.message)); break; case "loadAndRenderAck": - this._resolvePending(msg.msgId, "loadAndRender", undefined); + if (msg.error !== undefined) { + this._rejectPending( + msg.msgId, + "loadAndRender", + new Error(msg.error), + ); + } else { + this._resolvePending(msg.msgId, "loadAndRender", undefined); + } + break; case "resizeAck": this._resolvePending(msg.msgId, "resize", undefined); break; case "snapshotPngReply": - this._resolvePending(msg.requestId, "snapshotPng", msg.blob); + if (msg.error !== undefined) { + this._rejectPending( + msg.requestId, + "snapshotPng", + new Error(msg.error), + ); + } else { + this._resolvePending( + msg.requestId, + "snapshotPng", + msg.blob, + ); + } + break; } } @@ -910,6 +951,20 @@ export class RendererTransport { entry.resolve(value); } + private _rejectPending( + id: number, + kind: PendingRenderType, + error: Error, + ): void { + const entry = this._pending.get(id); + if (!entry || entry.kind !== kind) { + return; + } + + this._pending.delete(id); + entry.reject(error); + } + /** * Blit-mode handler: draw a renderer-emitted frame into the * visible 2D-context display canvas, then close the bitmap so its diff --git a/packages/viewer-charts/src/ts/worker/dispatch.ts b/packages/viewer-charts/src/ts/worker/dispatch.ts index cbbfed7a98..c670a78a70 100644 --- a/packages/viewer-charts/src/ts/worker/dispatch.ts +++ b/packages/viewer-charts/src/ts/worker/dispatch.ts @@ -28,7 +28,7 @@ export function dispatch(r: WorkerRenderer, msg: ControlMsg): void { r.chartImpl.setColumnsConfig?.(msg.cfg); break; case "setPluginConfig": - r.chartImpl.setPluginConfig?.(msg.cfg); + r.setPluginConfig(msg.cfg, msg.tileSource); r.redraw(); break; case "setBufferMaxCapacity": @@ -93,7 +93,11 @@ export function dispatch(r: WorkerRenderer, msg: ControlMsg): void { r.post({ kind: "snapshotPngReply", requestId, blob }); }) .catch((err) => { - r.post({ kind: "error", message: String(err) }); + r.post({ + kind: "snapshotPngReply", + requestId, + error: String(err), + }); }); break; } diff --git a/packages/viewer-charts/src/ts/worker/renderer.worker.ts b/packages/viewer-charts/src/ts/worker/renderer.worker.ts index 74dec56dce..a37ec0a8b9 100644 --- a/packages/viewer-charts/src/ts/worker/renderer.worker.ts +++ b/packages/viewer-charts/src/ts/worker/renderer.worker.ts @@ -18,7 +18,7 @@ import type * as wasm_module_type from "@perspective-dev/viewer/dist/wasm/perspe import { WebGLContextManager } from "../webgl/context-manager"; import { ContextPool } from "../webgl/context-pool"; import { RENDER_CONTEXT_POOL_SIZE } from "../config"; -import { ChartImplementation } from "../charts/chart"; +import { ChartImplementation, type PluginConfig } from "../charts/chart"; import { ZoomController } from "../interaction/zoom-controller"; import { applyPan, @@ -36,6 +36,7 @@ import type { WorkerMsg, } from "../transport/protocol"; import { viewToColumnDataMap } from "../data/view-reader"; +import { TILE_SOURCES, type TileSourceSpec } from "../map/tile-source"; import { loadFontDeduped } from "./font-loader"; import { dispatch } from "./dispatch"; import { installSessionHost } from "./session-host"; @@ -166,6 +167,13 @@ export class WorkerRenderer { this.chartImpl = new ImplClass(); + // Registry write must precede the chart impl's first + // `setPluginConfig` — `pluginConfig.map_tile_provider` may + // name this runtime-registered source. + if (msg.tileSource) { + TILE_SOURCES.register(msg.tileSource); + } + // Three surfaces, by mode: // - direct: the host's transferred `.webgl-canvas` (1:1 with a // context, permanently). @@ -253,6 +261,20 @@ export class WorkerRenderer { this.chartImpl.setView?.(this.view); } + /** + * Registry write precedes the config apply — `cfg` may name the + * spec riding alongside it (see `SetPluginConfigMsg.tileSource`), + * and a replaced template must be registered first so the map + * chart's rebind sees the new content-derived cache id. + */ + setPluginConfig(cfg: PluginConfig, tileSource?: TileSourceSpec): void { + if (tileSource) { + TILE_SOURCES.register(tileSource); + } + + this.chartImpl.setPluginConfig?.(cfg); + } + /** * Full data-fetch + render pipeline. Owns every `Client`/`Table`/ * `View` await on the render path: @@ -283,6 +305,7 @@ export class WorkerRenderer { */ async loadAndRender(msg: LoadAndRenderMsg): Promise { const myGen = ++this._renderGen; + let error: string | undefined; try { const [numRows, schema, exprSchema, tableSchema] = await Promise.all([ @@ -352,9 +375,10 @@ export class WorkerRenderer { } catch (err) { if ((err + "").indexOf("View not found") === -1) { console.error("loadAndRender failed", err); + error = String(err); } } finally { - this.post({ kind: "loadAndRenderAck", msgId: msg.msgId }); + this.post({ kind: "loadAndRenderAck", msgId: msg.msgId, error }); } } @@ -523,7 +547,59 @@ export class WorkerRenderer { return { controller: this.zoomController, layout }; } + /** + * Legend-first interaction routing. The chart's `LegendController` + * sees every forwarded event BEFORE the zoom / tooltip paths, so + * legend gestures (scroll, width drag, floating move / resize) + * structurally preempt plot pan / zoom / hover — a wheel over the + * legend can never zoom the plot under it, and a floating-panel + * drag can never start a pan. + */ + private _legendInteraction(event: InteractionEvent): boolean { + const chart = this.chartImpl as any; + const legend = chart?._legend; + const cfg = chart?._pluginConfig; + if (!legend || !cfg) { + return false; + } + + return legend.handleEvent(event, { + cfg, + cssWidth: this.cssWidth, + cssHeight: this.cssHeight, + legendRects: chart._legendRects ?? [], + repaint: (relayout: boolean) => { + if (!relayout && typeof chart.repaintChrome === "function") { + chart.repaintChrome(); + } else { + this.chartImpl.requestRender(this.glManager); + } + }, + postDelta: (fields: Record) => + this.post({ kind: "pluginConfigDelta", fields }), + setCursor: (cursor: string) => chart._hostSink?.setCursor?.(cursor), + dispatchLeave: () => this._tooltip()?.dispatchLeave(), + }); + } + onInteraction(event: InteractionEvent): void { + try { + this._routeInteraction(event); + } catch (err) { + this._dragTarget = null; + (this.chartImpl as any)?._legend?.cancelGesture?.(); + console.error("interaction dispatch failed", err); + } + } + + private _routeInteraction(event: InteractionEvent): void { + const plotDragging = + this._dragTarget !== null && + (event.type === "pointermove" || event.type === "pointerup"); + if (!plotDragging && this._legendInteraction(event)) { + return; + } + switch (event.type) { case "wheel": { const target = this._resolveTarget(event.mx, event.my); diff --git a/packages/viewer-charts/test/ts/empty-view.spec.ts b/packages/viewer-charts/test/ts/empty-view.spec.ts new file mode 100644 index 0000000000..fc719a7bc2 --- /dev/null +++ b/packages/viewer-charts/test/ts/empty-view.spec.ts @@ -0,0 +1,92 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { ConsoleMessage, Page } from "@playwright/test"; +import type { ViewerConfigUpdate } from "@perspective-dev/viewer"; +import { expect, test } from "@perspective-dev/test"; +import { + calibratePlotBaseline, + gotoBasic, + restoreChart, + waitOneFrame, +} from "./helpers"; + +const UPLOAD_CRASH = /loadAndRender failed|reading 'axis'|reading 'chartType'/i; + +const EMPTY_FILTER: [string, string, number][] = [["Row ID", "<", 0]]; + +const PLAIN_CONFIG: ViewerConfigUpdate = { + plugin: "Y Line", + columns: ["Profit"], + group_by: ["Order Date"], +}; + +const SPLIT_CONFIG: ViewerConfigUpdate = { + plugin: "Y Bar", + columns: ["Profit", "Sales"], + group_by: ["Order Date"], + split_by: ["Ship Mode"], +}; + +function collectUploadCrashes(page: Page): string[] { + const hits: string[] = []; + page.on("console", (m: ConsoleMessage) => { + if (UPLOAD_CRASH.test(m.text())) { + hits.push(m.text()); + } + }); + + page.on("pageerror", (e: Error) => { + if (UPLOAD_CRASH.test(String(e))) { + hits.push(String(e)); + } + }); + + return hits; +} + +test.describe("empty view (regression)", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + for (const [name, config] of [ + ["plain", PLAIN_CONFIG], + ["split", SPLIT_CONFIG], + ] as const) { + test(`${name} chart draws an empty-filtered view without crashing`, async ({ + page, + }) => { + const errors = collectUploadCrashes(page); + await restoreChart(page, { ...config, filter: EMPTY_FILTER }); + await waitOneFrame(page); + expect(errors).toEqual([]); + }); + + test(`${name} chart recovers when the empty filter is relaxed`, async ({ + page, + }) => { + const errors = collectUploadCrashes(page); + await restoreChart(page, { ...config, filter: [] }); + await restoreChart(page, { ...config, filter: EMPTY_FILTER }); + await restoreChart(page, { ...config, filter: [] }); + const pixels = await calibratePlotBaseline(page); + expect(pixels).toBeGreaterThan(0); + expect(errors).toEqual([]); + const saved = await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer")!; + return await (viewer as any).save(); + }); + expect(saved.plugin).toEqual(config.plugin); + }); + } +}); diff --git a/packages/viewer-charts/test/ts/gradient-legend.spec.ts b/packages/viewer-charts/test/ts/gradient-legend.spec.ts new file mode 100644 index 0000000000..41ef0e0af9 --- /dev/null +++ b/packages/viewer-charts/test/ts/gradient-legend.spec.ts @@ -0,0 +1,142 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Regression: single-sign numeric color domains rendered with the FULL + * diverging gradient in the GL glyph shaders (plain linear + * normalization) while the gradient legend — and every other CPU + * consumer of `colorValueToT` — halved the gradient at the sign pivot, + * so an all-positive color column plotted brown/orange "negative" + * colors its legend never showed. Fixed by shipping the sign-pivot + * `u_color_range` (`colorRangePivot`) for numeric columns, making the + * shaders' linear branch reproduce `colorValueToT` exactly. + * + * Probe: count "warm" pixels — red channel dominant over blue — across + * the visible composite canvas. The default theme's diverging gradient + * keeps its negative half in browns / oranges / yellows and its + * positive half in neutral → green → blue, so warm ink implies + * negative-side colors. The zero-crossing control test proves the + * probe's sensitivity: raw superstore `Profit` HAS negatives, so warm + * ink must appear. + */ + +import type { Page } from "@playwright/test"; +import { expect, test } from "@perspective-dev/test"; +import { gotoBasic, restoreChart, waitOneFrame } from "./helpers"; + +/** + * Count pixels whose red channel dominates blue — the negative + * (brown / orange / yellow) half of the default diverging gradient. + * Excludes the positive half (`#f0f0f0` neutral pivot, greens with + * `r ≈ b + ~20`, blue-dominant blues) and grayscale chrome. The + * darkest browns (`r < 140`) are deliberately missed; the mid-orange + * band is more than enough signal for both directions of assertion. + */ +async function countWarmPixels(page: Page): Promise { + return await page.evaluate(() => { + const visit = ( + root: Document | ShadowRoot, + ): HTMLCanvasElement | null => { + const direct = root.querySelector( + ".webgl-canvas", + ) as HTMLCanvasElement | null; + if (direct) { + return direct; + } + + for (const el of Array.from(root.querySelectorAll("*"))) { + const sr = (el as Element & { shadowRoot?: ShadowRoot }) + .shadowRoot; + if (sr) { + const found = visit(sr); + if (found) { + return found; + } + } + } + + return null; + }; + + const canvas = visit(document); + if (!canvas || canvas.width === 0 || canvas.height === 0) { + throw new Error("countWarmPixels: no .webgl-canvas found"); + } + + const sampler = document.createElement("canvas"); + sampler.width = canvas.width; + sampler.height = canvas.height; + const ctx = sampler.getContext("2d", { willReadFrequently: true }); + if (!ctx) { + throw new Error("countWarmPixels: sampler 2D context unavailable"); + } + + ctx.drawImage(canvas, 0, 0); + const data = ctx.getImageData(0, 0, sampler.width, sampler.height).data; + let warm = 0; + for (let i = 0; i < data.length; i += 4) { + const r = data[i]; + const b = data[i + 2]; + if (r > 140 && r > b + 48) { + warm += 1; + } + } + + return warm; + }); +} + +/** Columns are `[X Axis, Y Axis, Color]` for the X/Y families. */ +const ALL_POSITIVE_COLOR = ["Sales", "Profit", "Quantity"]; +const CROSSING_COLOR = ["Sales", "Quantity", "Profit"]; + +test.describe("gradient color mapping (regression)", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + for (const plugin of ["X/Y Scatter", "X/Y Line"] as const) { + test(`${plugin} keeps an all-positive color column on the positive gradient half`, async ({ + page, + }) => { + await restoreChart(page, { + plugin, + columns: ALL_POSITIVE_COLOR, + }); + await waitOneFrame(page); + await waitOneFrame(page); + const warm = await countWarmPixels(page); + + // AA slack only — pre-fix this counted thousands of + // orange/brown pixels for `Quantity` ∈ [1, 14]. + expect(warm).toBeLessThan(50); + }); + + test(`${plugin} zero-crossing color column spans both halves (probe control)`, async ({ + page, + }) => { + await restoreChart(page, { + plugin, + columns: CROSSING_COLOR, + }); + await waitOneFrame(page); + await waitOneFrame(page); + const warm = await countWarmPixels(page); + + // Raw superstore `Profit` has negative rows, so both the + // plot AND the legend bar must show warm negative-side + // ink. A probe regression (theme change, warm-threshold + // rot) fails HERE first, not in the assertion above. + expect(warm).toBeGreaterThan(500); + }); + } +}); diff --git a/packages/viewer-charts/test/ts/legend-mode.spec.ts b/packages/viewer-charts/test/ts/legend-mode.spec.ts new file mode 100644 index 0000000000..1dae744295 --- /dev/null +++ b/packages/viewer-charts/test/ts/legend-mode.spec.ts @@ -0,0 +1,353 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * `legend_mode` / legend scroll / legend resize+move suite. + * + * Assertions are config round-trips (`viewer.save()`) and pixel-region + * invariants — no screenshot goldens, so the suite runs without a + * snapshot publish. The pixel probes read the visible `.webgl-canvas`, + * which in the default blit mode carries the full composite (gridlines + * + GL plot + chrome legend), so legend ink is visible to them. + */ + +import type { Page } from "@playwright/test"; +import { expect, test } from "@perspective-dev/test"; +import { + calibratePlotBaseline, + captureFrames, + gotoBasic, + restoreChart, + waitOneFrame, + assertViewerQuiescent, + type PlotRegionFrac, +} from "./helpers"; + +/** Multi-series chart with a sidebar legend (4 `Ship Mode` entries). */ +const SPLIT_CONFIG = { + plugin: "Y Line", + columns: ["Profit"], + group_by: ["Order Date"], + split_by: ["Ship Mode"], +}; + +/** Enough series (~49 `State`s ≈ 880px of rows) to overflow any box. */ +const OVERFLOW_CONFIG = { + plugin: "Y Line", + columns: ["Profit"], + group_by: ["Order Date"], + split_by: ["State"], +}; + +async function savedPluginConfig(page: Page): Promise> { + return await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer") as any; + const config = await viewer.save(); + return config.plugin_config ?? {}; + }); +} + +/** + * Poll `viewer.save()`'s `plugin_config` until `pred` passes or the + * timeout lapses — the drag→persist round-trip is async (worker post → + * `viewer.restore` → host bucket). Returns the last observed value so + * a timeout still produces a readable assertion failure. + */ +async function pollPluginConfig( + page: Page, + pred: (cfg: Record) => boolean, + timeoutMs = 5000, +): Promise> { + const start = Date.now(); + for (;;) { + const cfg = await savedPluginConfig(page); + if (pred(cfg) || Date.now() - start > timeoutMs) { + return cfg; + } + + await new Promise((x) => setTimeout(x, 100)); + } +} + +async function chartBox( + page: Page, +): Promise<{ x: number; y: number; width: number; height: number }> { + const box = await page.locator(".webgl-canvas").boundingBox(); + if (!box) { + throw new Error("legend-mode.spec: .webgl-canvas has no box"); + } + + return box; +} + +/** Median composite pixel count over `region` at quiescence. */ +async function medianRegionPixels( + page: Page, + region: PlotRegionFrac, +): Promise<{ pixels: number; regionArea: number }> { + const frames = await captureFrames( + page, + async () => { + await waitOneFrame(page); + await waitOneFrame(page); + }, + { plotRegionFrac: region }, + ); + if (frames.length === 0) { + throw new Error("medianRegionPixels: no frames captured"); + } + + const sorted = frames.map((f) => f.plotPixels).sort((a, b) => a - b); + const median = sorted[Math.floor(sorted.length / 2)]; + const { canvasWidth, canvasHeight } = frames[frames.length - 1]; + return { + pixels: median, + regionArea: + Math.round(region.w * canvasWidth) * + Math.round(region.h * canvasHeight), + }; +} + +test.describe("legend_mode", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("legend fields round-trip plugin_config and defaults are stripped", async ({ + page, + }) => { + await restoreChart(page, SPLIT_CONFIG); + await restoreChart(page, { + plugin_config: { legend_mode: "floating", legend_width_px: 200 }, + } as any); + + let cfg = await savedPluginConfig(page); + 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. + await restoreChart(page, { + plugin_config: { legend_mode: "sidebar", legend_width_px: 0 }, + } as any); + cfg = await savedPluginConfig(page); + expect(cfg.legend_mode).toBeUndefined(); + expect(cfg.legend_width_px).toBeUndefined(); + }); + + test("floating legend paints an opaque panel at its configured anchor", async ({ + page, + }) => { + await restoreChart(page, SPLIT_CONFIG); + + const region: PlotRegionFrac = { x: 0, y: 0.65, w: 0.25, h: 0.35 }; + + await restoreChart(page, { + plugin_config: { + legend_mode: "floating", + legend_anchor: "bottom-left", + legend_x: 0, + legend_y: 0, + legend_width_px: 160, + legend_height_px: 160, + }, + } as any); + const floating = await medianRegionPixels(page, region); + + await restoreChart(page, { + plugin_config: { legend_mode: "none" }, + } as any); + const none = await medianRegionPixels(page, region); + + // The panel's opaque background must dominate the probe region + // relative to the legend-less frame. + expect(floating.pixels - none.pixels).toBeGreaterThan( + 0.1 * floating.regionArea, + ); + }); + + test("overflowing sidebar legend never paints past its box", async ({ + page, + }) => { + await restoreChart(page, OVERFLOW_CONFIG); + await waitOneFrame(page); + + // The bottom slice of the right-hand gutter sits BELOW the + // legend box (which ends at the plot bottom). The pre-scroll + // renderer painted all ~49 entries straight through it; the + // windowed painter must leave it empty. + const region: PlotRegionFrac = { x: 0.88, y: 0.96, w: 0.12, h: 0.04 }; + const below = await medianRegionPixels(page, region); + expect(below.pixels).toBeLessThan(500); + }); + + test("wheel over the sidebar legend scrolls it, not the plot", async ({ + page, + }) => { + await restoreChart(page, OVERFLOW_CONFIG); + await waitOneFrame(page); + const baseline = await calibratePlotBaseline(page); + const box = await chartBox(page); + + // Park the cursor in the legend gutter and wheel. The plot + // region (central 80%) must stay quiescent — a zoom would + // rescale every glyph. + await page.mouse.move(box.x + box.width - 40, box.y + 120); + const frames = await captureFrames(page, async () => { + await page.mouse.wheel(0, 240); + await waitOneFrame(page); + await page.mouse.wheel(0, 240); + await waitOneFrame(page); + await waitOneFrame(page); + }); + + assertViewerQuiescent(frames, Math.max(500, baseline * 0.05)); + }); + + test("dragging the sidebar legend edge persists legend_width_px", async ({ + page, + }) => { + await restoreChart(page, SPLIT_CONFIG); + await waitOneFrame(page); + const box = await chartBox(page); + + // Default sidebar gutter is 80px; the grab zone straddles the + // legend's left edge at (width - 80 + 12). Drag it 80px left → + // the persisted gutter should land near 160. + const startX = box.x + box.width - 74; + const y = box.y + 100; + await page.mouse.move(startX, y); + await page.mouse.down(); + for (let i = 1; i <= 8; i++) { + await page.mouse.move(startX - i * 10, y); + } + + await page.mouse.up(); + + const cfg = await pollPluginConfig( + page, + (c) => typeof c.legend_width_px === "number", + ); + expect(cfg.legend_width_px).toBeGreaterThan(130); + expect(cfg.legend_width_px).toBeLessThan(190); + }); + + test("double-click on the sidebar divider resets legend_width_px", async ({ + page, + }) => { + await restoreChart(page, SPLIT_CONFIG); + await restoreChart(page, { + plugin_config: { legend_width_px: 200 }, + } as any); + await waitOneFrame(page); + let cfg = await savedPluginConfig(page); + expect(cfg.legend_width_px).toBe(200); + const box = await chartBox(page); + await page.mouse.dblclick(box.x + box.width - 194, box.y + 100); + + cfg = await pollPluginConfig( + page, + (c) => c.legend_width_px === undefined, + ); + expect(cfg.legend_width_px).toBeUndefined(); + }); + + test("double-click on the floating corner resets width and height", async ({ + page, + }) => { + await restoreChart(page, SPLIT_CONFIG); + await restoreChart(page, { + plugin_config: { + legend_mode: "floating", + legend_width_px: 220, + legend_height_px: 300, + }, + } as any); + await waitOneFrame(page); + const box = await chartBox(page); + await page.mouse.dblclick(box.x + box.width - 2, box.y + 298); + + const cfg = await pollPluginConfig( + page, + (c) => + c.legend_width_px === undefined && + c.legend_height_px === undefined, + ); + expect(cfg.legend_width_px).toBeUndefined(); + expect(cfg.legend_height_px).toBeUndefined(); + expect(cfg.legend_mode).toBe("floating"); + }); + + test("dragging the floating legend persists legend_x / legend_y", async ({ + page, + }) => { + await restoreChart(page, SPLIT_CONFIG); + await restoreChart(page, { + plugin_config: { legend_mode: "floating" }, + } as any); + await waitOneFrame(page); + const box = await chartBox(page); + + const startX = box.x + box.width - 80; + const startY = box.y + 9; + await page.mouse.move(startX, startY); + await page.mouse.down(); + for (let i = 1; i <= 8; i++) { + await page.mouse.move(startX - i * 15, startY + i * 12); + } + + await page.mouse.up(); + + const cfg = await pollPluginConfig( + page, + (c) => + typeof c.legend_x === "number" && + typeof c.legend_y === "number", + ); + expect(cfg.legend_x).toBeGreaterThan(0.05); + expect(cfg.legend_y).toBeGreaterThan(0.05); + }); + + test("form-driven legend geometry survives a save/restore round-trip", async ({ + page, + }) => { + await restoreChart(page, SPLIT_CONFIG); + await restoreChart(page, { + plugin_config: { + legend_mode: "floating", + legend_width_px: 220, + legend_height_px: 300, + legend_anchor: "bottom-right", + legend_x: 0.25, + legend_y: 0.75, + legend_opacity: 0.5, + }, + } as any); + + const cfg = await savedPluginConfig(page); + const roundTripped = await page.evaluate(async (c) => { + const viewer = document.querySelector("perspective-viewer") as any; + await viewer.restore({ plugin_config: { legend_mode: "none" } }); + await viewer.restore({ plugin_config: c }); + const after = await viewer.save(); + return after.plugin_config ?? {}; + }, cfg); + + expect(roundTripped.legend_mode).toBe("floating"); + expect(roundTripped.legend_width_px).toBe(220); + expect(roundTripped.legend_height_px).toBe(300); + expect(roundTripped.legend_anchor).toBe("bottom-right"); + expect(roundTripped.legend_x).toBe(0.25); + expect(roundTripped.legend_y).toBe(0.75); + expect(roundTripped.legend_opacity).toBe(0.5); + }); +}); diff --git a/packages/viewer-charts/test/ts/map-helpers.ts b/packages/viewer-charts/test/ts/map-helpers.ts new file mode 100644 index 0000000000..de845a7947 --- /dev/null +++ b/packages/viewer-charts/test/ts/map-helpers.ts @@ -0,0 +1,194 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Shared fixtures for the map-chart suites (map-tile-sources, + * map-numeric-axes). The core trick: register solid-color `data:` URL + * tile sources so basemap COVERAGE is directly measurable as pixel + * color, with no live tile-CDN traffic and no dependence on Playwright + * request interception (which cannot see dedicated-worker fetches). + */ + +import type { Page } from "@playwright/test"; +import type { PlotRegionFrac } from "./helpers"; + +/** + * Superstore has no lon/lat columns; `Discount` (0–0.8) and `Quantity` + * (1–14) are numeric and inside the Mercator lon/lat domain, which is + * all the projection needs. + */ +export const MAP_CONFIG = { + plugin: "Map Scatter", + columns: ["Discount", "Quantity"], +}; + +/** Central plot sub-region, clear of axes, legend, and attribution. */ +export const CENTER: PlotRegionFrac = { x: 0.3, y: 0.3, w: 0.4, h: 0.4 }; + +export interface MeanColor { + r: number; + g: number; + b: number; + opaque: number; +} + +export async function savedPluginConfig( + page: Page, +): Promise> { + return await page.evaluate(async () => { + const viewer = document.querySelector("perspective-viewer") as any; + const config = await viewer.save(); + return config.plugin_config ?? {}; + }); +} + +/** + * Register solid-color data-URL tile sources in the page realm via the + * plugin element class's static `registerTileSource`. The `#{z}/{x}/{y}` + * suffix satisfies template validation and is inert at fetch time (URL + * fragments never leave the client). + */ +export async function registerColorSources( + page: Page, + entries: ReadonlyArray<{ id: string; color: string }>, +): Promise { + await page.evaluate( + (entries) => { + const cls = customElements.get( + "perspective-viewer-charts-map-scatter", + ) as any; + for (const { id, color } of entries) { + const canvas = document.createElement("canvas"); + canvas.width = 4; + canvas.height = 4; + const ctx = canvas.getContext("2d")!; + ctx.fillStyle = color; + ctx.fillRect(0, 0, 4, 4); + cls.registerTileSource({ + id, + label: `Test ${id}`, + template: canvas.toDataURL("image/png") + "#{z}/{x}/{y}", + attribution: "test fixture", + }); + } + }, + entries as Array<{ id: string; color: string }>, + ); +} + +/** + * Mean RGB over the opaque pixels of `region` on the visible + * `.webgl-canvas` composite. Routed through a 2D sampler canvas for + * the same context-mode reasons as `captureFrames` in helpers.ts. + */ +export async function regionMeanColor( + page: Page, + region: PlotRegionFrac, +): Promise { + return await page.evaluate((region) => { + const visit = ( + root: Document | ShadowRoot, + ): HTMLCanvasElement | null => { + const direct = root.querySelector( + ".webgl-canvas", + ) as HTMLCanvasElement | null; + if (direct) { + return direct; + } + + for (const el of Array.from(root.querySelectorAll("*"))) { + const sr = (el as Element & { shadowRoot?: ShadowRoot }) + .shadowRoot; + if (sr) { + const found = visit(sr); + if (found) { + return found; + } + } + } + + return null; + }; + + const canvas = visit(document); + if (!canvas || canvas.width === 0 || canvas.height === 0) { + return { r: 0, g: 0, b: 0, opaque: 0 }; + } + + const x0 = Math.round(region.x * canvas.width); + const y0 = Math.round(region.y * canvas.height); + const rw = Math.max(1, Math.round(region.w * canvas.width)); + const rh = Math.max(1, Math.round(region.h * canvas.height)); + const sampler = document.createElement("canvas"); + sampler.width = rw; + sampler.height = rh; + const ctx = sampler.getContext("2d", { willReadFrequently: true })!; + try { + ctx.drawImage(canvas, x0, y0, rw, rh, 0, 0, rw, rh); + } catch { + return { r: 0, g: 0, b: 0, opaque: 0 }; + } + + const data = ctx.getImageData(0, 0, rw, rh).data; + let r = 0; + let g = 0; + let b = 0; + let opaque = 0; + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] > 200) { + r += data[i]; + g += data[i + 1]; + b += data[i + 2]; + opaque++; + } + } + + if (opaque === 0) { + return { r: 0, g: 0, b: 0, opaque: 0 }; + } + + return { + r: r / opaque, + g: g / opaque, + b: b / opaque, + opaque, + }; + }, region); +} + +/** + * Poll `regionMeanColor` until `pred` passes or the timeout lapses — + * tile fetch + decode + rebind is async even for data-URL tiles. + * Returns the last sample so a timeout produces a readable failure. + */ +export async function pollMeanColor( + page: Page, + region: PlotRegionFrac, + pred: (c: MeanColor) => boolean, + timeoutMs = 8000, +): Promise { + const start = Date.now(); + for (;;) { + const c = await regionMeanColor(page, region); + if (pred(c) || Date.now() - start > timeoutMs) { + return c; + } + + await new Promise((x) => setTimeout(x, 100)); + } +} + +export const isRed = (c: MeanColor) => + c.opaque > 0 && c.r > 150 && c.r > c.g + 80 && c.r > c.b + 80; + +export const isBlue = (c: MeanColor) => + c.opaque > 0 && c.b > 150 && c.b > c.r + 80 && c.b > c.g + 80; diff --git a/packages/viewer-charts/test/ts/map-numeric-axes.spec.ts b/packages/viewer-charts/test/ts/map-numeric-axes.spec.ts new file mode 100644 index 0000000000..9960c83b95 --- /dev/null +++ b/packages/viewer-charts/test/ts/map-numeric-axes.spec.ts @@ -0,0 +1,140 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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 { expect, test } from "@perspective-dev/test"; +import { gotoBasic, restoreChart, type PlotRegionFrac } from "./helpers"; +import { + MAP_CONFIG, + isRed, + pollMeanColor, + regionMeanColor, + registerColorSources, + savedPluginConfig, +} from "./map-helpers"; + +const LEFT_EDGE: PlotRegionFrac = { x: 0, y: 0.3, w: 0.03, h: 0.4 }; +const BOTTOM_EDGE: PlotRegionFrac = { x: 0.3, y: 0.97, w: 0.4, h: 0.03 }; +const RIGHT_EDGE: PlotRegionFrac = { x: 0.97, y: 0.3, w: 0.03, h: 0.4 }; + +test.describe("map numeric_axes", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + await registerColorSources(page, [{ id: "test-red", color: "#f00" }]); + }); + + test("numeric_axes round-trips plugin_config and the default (true) is stripped", async ({ + page, + }) => { + await restoreChart(page, { + ...MAP_CONFIG, + plugin_config: { numeric_axes: false }, + } as any); + + let cfg = await savedPluginConfig(page); + expect(cfg.numeric_axes).toBe(false); + + await restoreChart(page, { + plugin_config: { numeric_axes: true }, + } as any); + cfg = await savedPluginConfig(page); + expect(cfg.numeric_axes).toBeUndefined(); + }); + + test("numeric_axes off renders the basemap full-bleed to every edge", async ({ + page, + }) => { + await restoreChart(page, { + ...MAP_CONFIG, + plugin_config: { + map_tile_provider: "test-red", + numeric_axes: false, + }, + } as any); + + for (const region of [LEFT_EDGE, BOTTOM_EDGE, RIGHT_EDGE]) { + const c = await pollMeanColor(page, region, isRed); + expect(isRed(c)).toBe(true); + } + }); + + test("default (on) reserves axis gutters and paints tick labels", async ({ + page, + }) => { + await restoreChart(page, { + ...MAP_CONFIG, + plugin_config: { map_tile_provider: "test-red" }, + } as any); + + const center = await pollMeanColor( + page, + { x: 0.3, y: 0.3, w: 0.4, h: 0.4 }, + isRed, + ); + expect(isRed(center)).toBe(true); + const left = await regionMeanColor(page, LEFT_EDGE); + expect(isRed(left)).toBe(false); + const bottom = await regionMeanColor(page, BOTTOM_EDGE); + expect(isRed(bottom)).toBe(false); + + const leftGutter = await regionMeanColor(page, { + x: 0, + y: 0.2, + w: 0.05, + h: 0.6, + }); + expect(leftGutter.opaque).toBeGreaterThan(0); + expect(isRed(leftGutter)).toBe(false); + }); + + test("full-bleed keeps the sidebar legend gutter when a legend shows", async ({ + page, + }) => { + await restoreChart(page, { + plugin: "Map Scatter", + columns: ["Discount", "Quantity", "Profit"], + plugin_config: { + map_tile_provider: "test-red", + numeric_axes: false, + }, + } as any); + + const centerOn = await pollMeanColor( + page, + { x: 0.3, y: 0.3, w: 0.35, h: 0.4 }, + isRed, + ); + expect(isRed(centerOn)).toBe(true); + const right = await regionMeanColor(page, RIGHT_EDGE); + expect(isRed(right)).toBe(false); + + await restoreChart(page, { + plugin_config: { legend_mode: "none" }, + } as any); + const rightNone = await pollMeanColor(page, RIGHT_EDGE, isRed); + expect(isRed(rightNone)).toBe(true); + }); + + test("faceted bare map cells are flush to the canvas edge", async ({ + page, + }) => { + await restoreChart(page, { + ...MAP_CONFIG, + split_by: ["Ship Mode"], + plugin_config: { + map_tile_provider: "test-red", + numeric_axes: false, + }, + } as any); + const left = await pollMeanColor(page, LEFT_EDGE, isRed); + expect(isRed(left)).toBe(true); + }); +}); diff --git a/packages/viewer-charts/test/ts/map-tile-sources.spec.ts b/packages/viewer-charts/test/ts/map-tile-sources.spec.ts new file mode 100644 index 0000000000..98a79ce1ad --- /dev/null +++ b/packages/viewer-charts/test/ts/map-tile-sources.spec.ts @@ -0,0 +1,238 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ 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). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +/** + * Metadata-driven tile-source suite: bundled tile-sources.json listing, + * dynamic `map_tile_provider` enum, `registerTileSource` runtime + * registration, and end-to-end basemap rendering of registered sources. + * + * No goldens and NO live tile-CDN traffic: Playwright cannot intercept + * fetches made from dedicated workers, so registered test sources use + * `data:` URL templates (solid-color PNGs with the `{z}/{x}/{y}` + * placeholders riding in the URL fragment, which `fetch` ignores) and + * assertions read basemap pixels off the visible composite canvas. + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { expect, test } from "@perspective-dev/test"; +import { calibratePlotBaseline, gotoBasic, restoreChart } from "./helpers"; +import { + CENTER, + MAP_CONFIG, + isBlue, + isRed, + pollMeanColor, + registerColorSources, + savedPluginConfig, +} from "./map-helpers"; + +/** + * The bundled metadata, read from the SOURCE json so expectations track + * the file verbatim: entry order defines the enum order AND the + * default/`sourceFor`-fallback provider (first entry) — reordering the + * file is a behavior change this suite must follow, not fight. Read + * via `fs` (not an import): the test tsconfig's `rootDir: "."` + * excludes cross-tree module imports. + */ +function bundledSpecs(): Array<{ id: string; label: string }> { + const path = join( + dirname(test.info().file), + "../../src/ts/map/tile-sources.json", + ); + return JSON.parse(readFileSync(path, "utf8")); +} + +test.describe("map tile sources", () => { + test.beforeEach(async ({ page }) => { + await gotoBasic(page); + }); + + test("bundled providers list in JSON order and drive the schema enum", async ({ + page, + }) => { + const { ids, variants, dflt } = await page.evaluate(() => { + const cls = customElements.get( + "perspective-viewer-charts-map-scatter", + ) as any; + const el = document.createElement( + "perspective-viewer-charts-map-scatter", + ) as any; + const schema = el.plugin_config_schema(); + const field = schema.fields.find( + (f: any) => f.key === "map_tile_provider", + ); + return { + ids: cls.tileSources().map((s: any) => s.id), + variants: field?.variants ?? [], + dflt: field?.default, + }; + }); + + const bundled = bundledSpecs(); + expect(ids).toEqual(bundled.map((s) => s.id)); + expect(variants).toEqual( + bundled.map((s) => ({ value: s.id, label: s.label })), + ); + + // The default provider is the JSON's first entry. + expect(dflt).toBe(bundled[0].id); + }); + + test("registered sources render and switch end-to-end", async ({ + page, + }) => { + await registerColorSources(page, [ + { id: "test-red", color: "#ff0000" }, + { id: "test-blue", color: "#0000ff" }, + ]); + + await restoreChart(page, { + ...MAP_CONFIG, + plugin_config: { map_tile_provider: "test-red" }, + } as any); + + const red = await pollMeanColor(page, CENTER, isRed); + expect(isRed(red)).toBe(true); + + await restoreChart(page, { + plugin_config: { map_tile_provider: "test-blue" }, + } as any); + + const blue = await pollMeanColor(page, CENTER, isBlue); + expect(isBlue(blue)).toBe(true); + }); + + test("registered sources join the schema enum and round-trip save()", async ({ + page, + }) => { + await registerColorSources(page, [{ id: "test-red", color: "#f00" }]); + + const variants = await page.evaluate(() => { + const el = document.createElement( + "perspective-viewer-charts-map-scatter", + ) as any; + const field = el + .plugin_config_schema() + .fields.find((f: any) => f.key === "map_tile_provider"); + return field.variants.map((v: any) => v.value); + }); + expect(variants).toContain("test-red"); + + await restoreChart(page, { + ...MAP_CONFIG, + plugin_config: { map_tile_provider: "test-red" }, + } as any); + + let cfg = await savedPluginConfig(page); + expect(cfg.map_tile_provider).toBe("test-red"); + + // Restoring the schema default (the JSON's first entry) clears + // the bucket entry rather than storing it literally. + await restoreChart(page, { + plugin_config: { map_tile_provider: bundledSpecs()[0].id }, + } as any); + cfg = await savedPluginConfig(page); + expect(cfg.map_tile_provider).toBeUndefined(); + }); + + test("re-registering an id with a new template applies on the next config change", async ({ + page, + }) => { + await registerColorSources(page, [{ id: "test-live", color: "#f00" }]); + await restoreChart(page, { + ...MAP_CONFIG, + plugin_config: { map_tile_provider: "test-live" }, + } as any); + + const red = await pollMeanColor(page, CENTER, isRed); + expect(isRed(red)).toBe(true); + + await registerColorSources(page, [ + { id: "test-live", color: "#0000ff" }, + ]); + await restoreChart(page, { + plugin_config: { + map_tile_provider: "test-live", + map_tile_alpha: 0.99, + }, + } as any); + + const blue = await pollMeanColor(page, CENTER, isBlue); + expect(isBlue(blue)).toBe(true); + }); + + test("unknown provider id degrades to the default basemap without blanking", async ({ + page, + }) => { + await restoreChart(page, { + ...MAP_CONFIG, + plugin_config: { map_tile_provider: "no-such-provider" }, + } as any); + + // The chart still renders its glyph layer (the fallback + // basemap may or may not resolve tiles in a sandboxed test + // environment — only the id resolution must not throw)… + const baseline = await calibratePlotBaseline(page, { + plotRegionFrac: CENTER, + }); + expect(baseline).toBeGreaterThan(0); + + // …and the unrecognized id is persisted verbatim, so a config + // restored before its `registerTileSource` call self-heals + // once registration arrives. + const cfg = await savedPluginConfig(page); + expect(cfg.map_tile_provider).toBe("no-such-provider"); + }); + + test("malformed specs are rejected with TypeError", async ({ page }) => { + const results = await page.evaluate(() => { + const cls = customElements.get( + "perspective-viewer-charts-map-scatter", + ) as any; + const attempt = (spec: unknown): string | null => { + try { + cls.registerTileSource(spec); + return null; + } catch (e) { + return e instanceof TypeError + ? e.message + : `not-a-TypeError: ${e}`; + } + }; + + return [ + attempt({ + id: "bad-template", + label: "Bad", + template: "https://example.com/tiles/x/y.png", + attribution: "test", + }), + attempt({ + label: "No Id", + template: "https://example.com/{z}/{x}/{y}.png", + attribution: "test", + }), + attempt({ + id: "bad-subdomains", + label: "Bad", + template: "https://{s}.example.com/{z}/{x}/{y}.png", + attribution: "test", + }), + ]; + }); + + expect(results[0]).toContain("{z}"); + expect(results[1]).toContain("id"); + expect(results[2]).toContain("subdomains"); + }); +}); diff --git a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts index 28e4b403fe..0d75d842d7 100644 --- a/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts +++ b/packages/viewer-datagrid/src/ts/style_handlers/table_cell/numeric.ts @@ -137,7 +137,7 @@ export function cell_style_numeric( ]; { - const [hex, r, g, b] = bg_tuple; + const [hex, _r, _g, _b] = bg_tuple; td.style.position = ""; if (metadata._is_hidden_by_aggregate_depth) { diff --git a/rust/perspective-viewer/src/css/plugin-settings-panel.css b/rust/perspective-viewer/src/css/plugin-settings-panel.css index 4c1d746107..0179a0de09 100644 --- a/rust/perspective-viewer/src/css/plugin-settings-panel.css +++ b/rust/perspective-viewer/src/css/plugin-settings-panel.css @@ -96,4 +96,36 @@ label#map_tile_alpha-label:before { content: var(--psp-label--map-tile-alpha--content); } + + label#numeric_axes-label:before { + content: var(--psp-label--numeric-axes--content); + } + + label#legend_mode-label:before { + content: var(--psp-label--legend-mode--content); + } + + label#legend_width_px-label:before { + content: var(--psp-label--legend-width-px--content); + } + + label#legend_height_px-label:before { + content: var(--psp-label--legend-height-px--content); + } + + label#legend_anchor-label:before { + content: var(--psp-label--legend-anchor--content); + } + + label#legend_x-label:before { + content: var(--psp-label--legend-x--content); + } + + label#legend_y-label:before { + content: var(--psp-label--legend-y--content); + } + + label#legend_opacity-label:before { + content: var(--psp-label--legend-opacity--content); + } } diff --git a/rust/perspective-viewer/src/rust/renderer/dispatch.rs b/rust/perspective-viewer/src/rust/renderer/dispatch.rs index 9fead97aeb..a05182284b 100644 --- a/rust/perspective-viewer/src/rust/renderer/dispatch.rs +++ b/rust/perspective-viewer/src/rust/renderer/dispatch.rs @@ -165,10 +165,8 @@ impl Renderer { width: f64, height: f64, ) -> ApiResult> { - self.0.presize_pending.set(self.0.presize_pending.get() + 1); - let result = self.presize_inner(None, width, height).await; - self.0.presize_pending.set(self.0.presize_pending.get() - 1); - result + let _pending = PresizePendingGuard::increment(self); + self.presize_inner(None, width, height).await } /// Pre-size AND pre-position the plugin to its target layout bo. @@ -179,10 +177,8 @@ impl Renderer { width: f64, height: f64, ) -> ApiResult> { - self.0.presize_pending.set(self.0.presize_pending.get() + 1); - let result = self.presize_inner(Some((dx, dy)), width, height).await; - self.0.presize_pending.set(self.0.presize_pending.get() - 1); - result + let _pending = PresizePendingGuard::increment(self); + self.presize_inner(Some((dx, dy)), width, height).await } async fn presize_inner( @@ -374,7 +370,14 @@ impl Renderer { .debounce_with(|guard| async move { set_timeout(timer.get_throttle()).await?; if self.0.presize_pending.get() > 0 { + // Record the drop like the hidden-panel path + // (`wire_panel_render_sub`) does — a silently + // discarded update otherwise leaves the plugin's + // retained frame stale until the NEXT data tick, + // or forever if none comes. `draw_view` / + // `activation_render` consume the marker. tracing::debug!("Update skipped, presize pending"); + self.set_data_stale(); return Ok(()); } @@ -473,3 +476,26 @@ impl Renderer { self.draw_lock.clone() } } + +/// RAII increment of [`RendererData::presize_pending`], the counter that +/// gates [`Renderer::update_lazy`] dispatches. Decrementing on `Drop` +/// (not on the happy path) makes the release unconditional — a presize +/// future cancelled mid-await can never strand the counter above zero, +/// which would silently discard every subsequent debounced update for +/// the panel's lifetime. +struct PresizePendingGuard(Renderer); + +impl PresizePendingGuard { + fn increment(renderer: &Renderer) -> Self { + let cell = &renderer.0.presize_pending; + cell.set(cell.get() + 1); + Self(renderer.clone()) + } +} + +impl Drop for PresizePendingGuard { + fn drop(&mut self) { + let cell = &self.0.0.presize_pending; + cell.set(cell.get() - 1); + } +} diff --git a/rust/perspective-viewer/src/themes/intl.css b/rust/perspective-viewer/src/themes/intl.css index 1b3c963ada..dc4ad9888d 100644 --- a/rust/perspective-viewer/src/themes/intl.css +++ b/rust/perspective-viewer/src/themes/intl.css @@ -157,5 +157,13 @@ perspective-dropdown { --psp-label--gradient-heat-max--content: "Heat max"; --psp-label--map-tile-provider--content: "Map provider"; --psp-label--map-tile-alpha--content: "Map opacity"; + --psp-label--numeric-axes--content: "Numeric axes"; --psp-label--interpolate--content: "Interpolate null"; + --psp-label--legend-mode--content: "Legend mode"; + --psp-label--legend-width-px--content: "Legend width"; + --psp-label--legend-height-px--content: "Legend height"; + --psp-label--legend-anchor--content: "Legend anchor"; + --psp-label--legend-x--content: "Legend X offset"; + --psp-label--legend-y--content: "Legend Y offset"; + --psp-label--legend-opacity--content: "Legend opacity"; } diff --git a/rust/perspective-viewer/src/themes/intl/de.css b/rust/perspective-viewer/src/themes/intl/de.css index e33938f4b4..1d5d352320 100644 --- a/rust/perspective-viewer/src/themes/intl/de.css +++ b/rust/perspective-viewer/src/themes/intl/de.css @@ -80,7 +80,7 @@ perspective-dropdown { --psp-label--style--content: "Stil"; --psp-label--stack--content: "Stapel"; --psp-label--alt-axis--content: "Zweite Achse"; - --psp-label--interpolate--content: "Interpolieren"; + --psp-label--interpolate--content: "Null interpolieren"; --psp-label--minimum-integer-digits--content: "Mindestanzahl ganzzahliger Ziffern"; --psp-label--rounding-increment--content: "Rundungsinkrement"; --psp-label--notation--content: "Notation"; @@ -159,4 +159,12 @@ perspective-dropdown { --psp-label--gradient-heat-max--content: "Maximale Hitze"; --psp-label--map-tile-provider--content: "Kartenanbieter"; --psp-label--map-tile-alpha--content: "Kartentransparenz"; + --psp-label--numeric-axes--content: "Numerische Achsen"; + --psp-label--legend-mode--content: "Legendenmodus"; + --psp-label--legend-width-px--content: "Legendenbreite"; + --psp-label--legend-height-px--content: "Legendenhöhe"; + --psp-label--legend-anchor--content: "Legendenanker"; + --psp-label--legend-x--content: "Legenden-X-Versatz"; + --psp-label--legend-y--content: "Legenden-Y-Versatz"; + --psp-label--legend-opacity--content: "Legendentransparenz"; } diff --git a/rust/perspective-viewer/src/themes/intl/es.css b/rust/perspective-viewer/src/themes/intl/es.css index 8576168dd2..6076ee89a0 100644 --- a/rust/perspective-viewer/src/themes/intl/es.css +++ b/rust/perspective-viewer/src/themes/intl/es.css @@ -80,7 +80,7 @@ perspective-dropdown { --psp-label--style--content: "Estilo"; --psp-label--stack--content: "Apilar"; --psp-label--alt-axis--content: "Eje Alterno"; - --psp-label--interpolate--content: "Interpolar"; + --psp-label--interpolate--content: "Interpolar nulos"; --psp-label--minimum-integer-digits--content: "Dígitos enteros mínimos"; --psp-label--rounding-increment--content: "Incremento de redondeo"; --psp-label--notation--content: "Notación"; @@ -159,4 +159,12 @@ perspective-dropdown { --psp-label--gradient-heat-max--content: "Calor máximo"; --psp-label--map-tile-provider--content: "Proveedor de mapa"; --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-width-px--content: "Ancho de leyenda"; + --psp-label--legend-height-px--content: "Altura de leyenda"; + --psp-label--legend-anchor--content: "Anclaje de leyenda"; + --psp-label--legend-x--content: "Desplazamiento X de leyenda"; + --psp-label--legend-y--content: "Desplazamiento Y de leyenda"; + --psp-label--legend-opacity--content: "Opacidad de leyenda"; } diff --git a/rust/perspective-viewer/src/themes/intl/fr.css b/rust/perspective-viewer/src/themes/intl/fr.css index c02c8efb27..9420a8e4d5 100644 --- a/rust/perspective-viewer/src/themes/intl/fr.css +++ b/rust/perspective-viewer/src/themes/intl/fr.css @@ -80,7 +80,7 @@ perspective-dropdown { --psp-label--style--content: "Style"; --psp-label--stack--content: "Empiler"; --psp-label--alt-axis--content: "Axe alternatif"; - --psp-label--interpolate--content: "Interpoler"; + --psp-label--interpolate--content: "Interpoler les nuls"; --psp-label--minimum-integer-digits--content: "Chiffres entiers minimaux"; --psp-label--rounding-increment--content: "Incrément d'arrondi"; --psp-label--notation--content: "Notation"; @@ -159,4 +159,12 @@ perspective-dropdown { --psp-label--gradient-heat-max--content: "Chaleur max"; --psp-label--map-tile-provider--content: "Fournisseur de carte"; --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-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"; + --psp-label--legend-x--content: "Décalage X légende"; + --psp-label--legend-y--content: "Décalage Y légende"; + --psp-label--legend-opacity--content: "Opacité de légende"; } diff --git a/rust/perspective-viewer/src/themes/intl/ja.css b/rust/perspective-viewer/src/themes/intl/ja.css index d263ba7bb9..5a455abdaf 100644 --- a/rust/perspective-viewer/src/themes/intl/ja.css +++ b/rust/perspective-viewer/src/themes/intl/ja.css @@ -81,7 +81,7 @@ perspective-dropdown { --psp-label--style--content: "スタイル"; --psp-label--stack--content: "スタック"; --psp-label--alt-axis--content: "副軸"; - --psp-label--interpolate--content: "補間"; + --psp-label--interpolate--content: "null値を補間"; --psp-label--minimum-integer-digits--content: "整数の最小桁数"; --psp-label--rounding-increment--content: "丸め増分"; --psp-label--notation--content: "表記"; @@ -159,4 +159,12 @@ perspective-dropdown { --psp-label--gradient-heat-max--content: "最大ヒート"; --psp-label--map-tile-provider--content: "地図プロバイダー"; --psp-label--map-tile-alpha--content: "地図の不透明度"; + --psp-label--numeric-axes--content: "数値軸"; + --psp-label--legend-mode--content: "凡例モード"; + --psp-label--legend-width-px--content: "凡例の幅"; + --psp-label--legend-height-px--content: "凡例の高さ"; + --psp-label--legend-anchor--content: "凡例のアンカー"; + --psp-label--legend-x--content: "凡例Xオフセット"; + --psp-label--legend-y--content: "凡例Yオフセット"; + --psp-label--legend-opacity--content: "凡例の不透明度"; } diff --git a/rust/perspective-viewer/src/themes/intl/pt.css b/rust/perspective-viewer/src/themes/intl/pt.css index d4592c7588..4d4dbee90d 100644 --- a/rust/perspective-viewer/src/themes/intl/pt.css +++ b/rust/perspective-viewer/src/themes/intl/pt.css @@ -80,7 +80,7 @@ perspective-dropdown { --psp-label--style--content: "Estilo"; --psp-label--stack--content: "Empilhar"; --psp-label--alt-axis--content: "Eixo Alternativo"; - --psp-label--interpolate--content: "Interpolar"; + --psp-label--interpolate--content: "Interpolar nulos"; --psp-label--minimum-integer-digits--content: "Dígitos inteiros mínimos"; --psp-label--rounding-increment--content: "Incremento de arredondamento"; --psp-label--notation--content: "Notação"; @@ -159,4 +159,12 @@ perspective-dropdown { --psp-label--gradient-heat-max--content: "Calor máximo"; --psp-label--map-tile-provider--content: "Provedor de mapa"; --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-width-px--content: "Largura da legenda"; + --psp-label--legend-height-px--content: "Altura da legenda"; + --psp-label--legend-anchor--content: "Âncora da legenda"; + --psp-label--legend-x--content: "Deslocamento X da legenda"; + --psp-label--legend-y--content: "Deslocamento Y da legenda"; + --psp-label--legend-opacity--content: "Opacidade da legenda"; } diff --git a/rust/perspective-viewer/src/themes/intl/zh.css b/rust/perspective-viewer/src/themes/intl/zh.css index 58a0045e80..8d25f0db85 100644 --- a/rust/perspective-viewer/src/themes/intl/zh.css +++ b/rust/perspective-viewer/src/themes/intl/zh.css @@ -80,7 +80,7 @@ perspective-dropdown { --psp-label--style--content: "风格"; --psp-label--stack--content: "堆叠"; --psp-label--alt-axis--content: "副轴"; - --psp-label--interpolate--content: "插值"; + --psp-label--interpolate--content: "插值空值"; --psp-label--minimum-integer-digits--content: "最小整数位数"; --psp-label--rounding-increment--content: "舍入增量"; --psp-label--notation--content: "符号"; @@ -159,4 +159,12 @@ perspective-dropdown { --psp-label--gradient-heat-max--content: "最大热度"; --psp-label--map-tile-provider--content: "地图提供商"; --psp-label--map-tile-alpha--content: "地图不透明度"; + --psp-label--numeric-axes--content: "数值坐标轴"; + --psp-label--legend-mode--content: "图例模式"; + --psp-label--legend-width-px--content: "图例宽度"; + --psp-label--legend-height-px--content: "图例高度"; + --psp-label--legend-anchor--content: "图例锚点"; + --psp-label--legend-x--content: "图例X偏移"; + --psp-label--legend-y--content: "图例Y偏移"; + --psp-label--legend-opacity--content: "图例不透明度"; }