Added legend positioning for Chart
This commit is contained in:
@@ -24,6 +24,30 @@ import { createMemo, createSignal, For, Show, onMount, onCleanup, JSXElement } f
|
|||||||
|
|
||||||
export type ChartKind = "line" | "area" | "bar" | "pie" | "donut";
|
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 {
|
export interface ChartSeries {
|
||||||
name: string;
|
name: string;
|
||||||
data: number[];
|
data: number[];
|
||||||
@@ -67,7 +91,7 @@ export interface ChartProps {
|
|||||||
// Format a value for the value-axis ticks and the tooltip. Defaults to en-US grouping.
|
// Format a value for the value-axis ticks and the tooltip. Defaults to en-US grouping.
|
||||||
valueFormat?: (v: number) => string;
|
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.
|
grid?: boolean; // cartesian only; default true.
|
||||||
axes?: boolean; // cartesian only; default true.
|
axes?: boolean; // cartesian only; default true.
|
||||||
tooltip?: boolean; // default true.
|
tooltip?: boolean; // default true.
|
||||||
@@ -277,7 +301,28 @@ export function Chart(props: ChartProps): JSXElement {
|
|||||||
const colorOf = (i: number) =>
|
const colorOf = (i: number) =>
|
||||||
props.series[i]?.color ?? props.palette?.[i % (props.palette.length || 1)] ?? CHART_TOKENS[i % CHART_TOKENS.length];
|
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 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,
|
// 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
|
// layout and marks recompute from what's left. A Set of the hidden indices — the index
|
||||||
@@ -290,22 +335,27 @@ export function Chart(props: ChartProps): JSXElement {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrap} class={"w-full" + (props.class ? " " + props.class : "")}>
|
<div class={"w-full" + (props.class ? " " + props.class : "")}>
|
||||||
<Show when={props.title}>
|
<Show when={props.title}>
|
||||||
<div class="mb-2 text-center text-sm font-medium text-ink">{props.title}</div>
|
<div class="mb-2 text-center text-sm font-medium text-ink">{props.title}</div>
|
||||||
</Show>
|
</Show>
|
||||||
{/* the plot + its absolutely-positioned tooltip share this relative box, so the
|
<div class={containerClass()}>
|
||||||
tooltip's pointer coordinates aren't thrown off by a title or legend outside it. */}
|
<Show when={legendVisible() && legendBefore()}>
|
||||||
<div class="relative w-full">
|
<Legend props={props} legend={legendConfig()} colorOf={colorOf} hidden={hidden()} onToggle={toggle} />
|
||||||
<Show when={isRadial()} fallback={
|
</Show>
|
||||||
<CartesianChart props={props} width={width()} height={height()} colorOf={colorOf} fmt={fmt} hidden={hidden} />
|
{/* 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. */}
|
||||||
<RadialChart props={props} width={width()} height={height()} colorOf={colorOf} fmt={fmt} hidden={hidden} />
|
<div ref={wrap} class="relative w-full min-w-0">
|
||||||
|
<Show when={isRadial()} fallback={
|
||||||
|
<CartesianChart props={props} width={width()} height={height()} colorOf={colorOf} fmt={fmt} hidden={hidden} />
|
||||||
|
}>
|
||||||
|
<RadialChart props={props} width={width()} height={height()} colorOf={colorOf} fmt={fmt} hidden={hidden} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={legendVisible() && !legendBefore()}>
|
||||||
|
<Legend props={props} legend={legendConfig()} colorOf={colorOf} hidden={hidden()} onToggle={toggle} />
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
<Show when={showLegend()}>
|
|
||||||
<Legend props={props} colorOf={colorOf} hidden={hidden()} onToggle={toggle} />
|
|
||||||
</Show>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -757,7 +807,7 @@ function RadialChart(p: SubProps): JSXElement {
|
|||||||
|
|
||||||
// ── legend ─────────────────────────────────────────────────────────────────────
|
// ── legend ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function Legend(p: { props: ChartProps; colorOf: (i: number) => string; hidden: Set<number>; onToggle: (i: number) => void }): JSXElement {
|
function Legend(p: { props: ChartProps; legend: ChartLegend | null; colorOf: (i: number) => string; hidden: Set<number>; onToggle: (i: number) => void }): JSXElement {
|
||||||
// Pie/donut identity is the SLICE; cartesian identity is the SERIES. A line keys with
|
// 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.
|
// 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
|
// Each key is a button: click it to toggle that series/slice, which greys the key and
|
||||||
@@ -767,8 +817,25 @@ function Legend(p: { props: ChartProps; colorOf: (i: number) => string; hidden:
|
|||||||
const items = () => isRadial
|
const items = () => isRadial
|
||||||
? (p.props.labels ?? p.props.series[0]?.data.map((_, i) => String(i + 1)) ?? []).map((name, i) => ({ name, i }))
|
? (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 }));
|
: 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 (
|
return (
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5">
|
<div class={containerClass()}>
|
||||||
<For each={items()}>{(it) => {
|
<For each={items()}>{(it) => {
|
||||||
const off = () => p.hidden.has(it.i);
|
const off = () => p.hidden.has(it.i);
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user