diff --git a/go/jsruntime/uikit/Chart.tsx b/go/jsruntime/uikit/Chart.tsx index c62271db..90bec626 100644 --- a/go/jsruntime/uikit/Chart.tsx +++ b/go/jsruntime/uikit/Chart.tsx @@ -24,6 +24,30 @@ import { createMemo, createSignal, For, Show, onMount, onCleanup, JSXElement } f export type ChartKind = "line" | "area" | "bar" | "pie" | "donut"; +interface HorizontalLegend { + orientation: "horizontal"; + position: + | "top-left" + | "top-center" + | "top-right" + | "bottom-left" + | "bottom-center" + | "bottom-right"; +} + +interface VerticalLegend { + orientation: "vertical"; + position: + | "top-left" + | "center-left" + | "bottom-left" + | "top-right" + | "center-right" + | "bottom-right"; +} + +type ChartLegend = HorizontalLegend | VerticalLegend; + export interface ChartSeries { name: string; data: number[]; @@ -72,7 +96,7 @@ export interface ChartProps { // Format a value for the value-axis ticks and the tooltip. Defaults to en-US grouping. valueFormat?: (v: number) => string; - legend?: boolean; // default: true when there is more than one series (or a pie). + legend?: boolean | ChartLegend; // default: true when there is more than one series (or a pie). grid?: boolean; // cartesian only; default true. axes?: boolean; // cartesian only; default true. tooltip?: boolean; // default true. @@ -88,6 +112,30 @@ const CHART_TOKENS = [ "var(--color-chart-5)", "var(--color-chart-6)", "var(--color-chart-7)", "var(--color-chart-8)", ]; +export const CDRL_OLD_PALETTE = [ + "#FF0F00", + "#FF6600", + "#FF9E01", + "#FCD202", + "#F8FF01", + "#B0DE09", + "#04D215", + "#0D8ECF", + "#0D52D1", + "#2A0CD0", + "#8A0CCF", + "#CD0D74", + "#754DEB", + "#DDDDDD", + "#999999", + "#333333", + "#000000", + "#57032A", + "#CA9726", + "#990000", + "#4B0C25" +] + const DEFAULT_HEIGHT = 300; const DEFAULT_WIDTH = 640; // used only until the container is measured (and on the server) const BAR_MAX_W = 24; // cap a bar's thickness; the band's leftover is deliberate air @@ -259,7 +307,28 @@ export function Chart(props: ChartProps): JSXElement { const colorOf = (i: number) => props.series[i]?.color ?? props.palette?.[i % (props.palette.length || 1)] ?? CHART_TOKENS[i % CHART_TOKENS.length]; const fmt = (v: number) => (props.valueFormat ?? defaultFormat)(v); - const showLegend = () => props.legend ?? (isRadial() || props.series.length > 1); + + const legendVisible = () => { + if (props.legend === false) return false; + if (props.legend === true || props.legend === undefined) return isRadial() || props.series.length > 1; + return true; // an explicit ChartLegend object always shows the legend + }; + // `true`/undefined carries no orientation or position — null tells the layout below to + // keep the original placement (a left-aligned wrapping row under the plot) untouched. + // Only a caller-supplied ChartLegend object turns on the orientation/position logic. + const legendConfig = (): ChartLegend | null => + props.legend && typeof props.legend === "object" ? props.legend : null; + // Horizontal legends stack above/below the plot; vertical legends sit beside it. + const legendBefore = () => { + const cfg = legendConfig(); + if (!cfg) return false; // default position is "after", matching the original layout + return cfg.orientation === "horizontal" ? cfg.position.startsWith("top") : cfg.position.endsWith("left"); + }; + const containerClass = () => { + const cfg = legendConfig(); + if (!cfg) return "w-full"; + return cfg.orientation === "vertical" ? "flex w-full items-stretch gap-4" : "flex flex-col w-full gap-3"; + }; // Clicking a legend key hides its series (cartesian) or slice (radial); the domain, // layout and marks recompute from what's left. A Set of the hidden indices — the index @@ -272,22 +341,27 @@ export function Chart(props: ChartProps): JSXElement { }); return ( -
+
{props.title}
- {/* the plot + its absolutely-positioned tooltip share this relative box, so the - tooltip's pointer coordinates aren't thrown off by a title or legend outside it. */} -
-
); } @@ -796,7 +870,7 @@ function RadialChart(p: SubProps): JSXElement { // ── legend ───────────────────────────────────────────────────────────────────── -function Legend(p: { props: ChartProps; colorOf: (i: number) => string; hidden: Set; onToggle: (i: number) => void }): JSXElement { +function Legend(p: { props: ChartProps; legend: ChartLegend | null; colorOf: (i: number) => string; hidden: Set; onToggle: (i: number) => void }): JSXElement { // Pie/donut identity is the SLICE; cartesian identity is the SERIES. A line keys with // a short stroke, a fill (bar/area/slice) with a swatch — the legend mirrors the mark. // Each key is a button: click it to toggle that series/slice, which greys the key and @@ -806,8 +880,25 @@ function Legend(p: { props: ChartProps; colorOf: (i: number) => string; hidden: const items = () => isRadial ? (p.props.labels ?? p.props.series[0]?.data.map((_, i) => String(i + 1)) ?? []).map((name, i) => ({ name, i })) : p.props.series.map((s, i) => ({ name: s.name, i })); + + // No legend config (bare `true`/default): the original layout — a left-aligned wrapping + // row under the plot. An explicit ChartLegend picks the row/column axis (orientation) + // and where along it the legend sits (position). + const containerClass = () => { + const cfg = p.legend; + if (!cfg) return "mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5"; + if (cfg.orientation === "vertical") { + const justify = cfg.position.startsWith("top") ? "justify-start" + : cfg.position.startsWith("bottom") ? "justify-end" : "justify-center"; + return `flex flex-col ${justify} gap-1.5 shrink-0`; + } + const justify = cfg.position.endsWith("left") ? "justify-start" + : cfg.position.endsWith("right") ? "justify-end" : "justify-center"; + return `flex flex-wrap items-center gap-x-4 gap-y-1.5 ${justify}`; + }; + return ( -
+
{(it) => { const off = () => p.hidden.has(it.i); return (