diff --git a/app/src/components/chart-renderer.tsx b/app/src/components/chart-renderer.tsx
index b8ec5cb7..103d39da 100644
--- a/app/src/components/chart-renderer.tsx
+++ b/app/src/components/chart-renderer.tsx
@@ -11,23 +11,11 @@ import {
Skeleton,
EmptyState,
JsonViewer,
- MarkdownWidget,
IframeWidget,
} from "@neoboard/components";
-// Chart components use ECharts (browser APIs) — must be loaded client-side only
-const BarChart = dynamic(
- () => import("@neoboard/components").then((m) => ({ default: m.BarChart })),
- { ssr: false, loading: () => },
-);
-const LineChart = dynamic(
- () => import("@neoboard/components").then((m) => ({ default: m.LineChart })),
- { ssr: false, loading: () => },
-);
-const PieChart = dynamic(
- () => import("@neoboard/components").then((m) => ({ default: m.PieChart })),
- { ssr: false, loading: () => },
-);
+// Chart components use ECharts (browser APIs) — must be loaded client-side only.
+// Bar/Line/Pie are now loaded via their plugins in app/src/plugins/.
const SingleValueChart = dynamic(
() =>
import("@neoboard/components").then((m) => ({
@@ -36,9 +24,6 @@ const SingleValueChart = dynamic(
{ ssr: false, loading: () => },
);
import type {
- BarChartDataPoint,
- LineChartDataPoint,
- PieChartDataPoint,
GraphNode,
GraphEdge,
MapMarker,
@@ -213,93 +198,13 @@ function ChartRendererInner({
switch (type) {
case "bar":
- return (
-
- );
-
case "line":
- return (
- s.trim())
- .filter(Boolean)
- : undefined
- }
- rightYAxisLabel={settings.rightYAxisLabel as string | undefined}
- onClick={handleEChartsClick}
- enableDataZoom={settings.enableDataZoom as boolean | undefined}
- colorPalette={settings.colorPalette as string | undefined}
- colorblindMode={settings.colorblindMode as boolean | undefined}
- />
- );
-
case "pie":
- return (
-
- );
+ case "markdown":
+ // Handled by plugins (app/src/plugins/). The plugin lookup above
+ // intercepts these types — this branch is defensive and should
+ // never execute in practice.
+ return null;
case "single-value": {
const raw = data ?? 0;
@@ -476,11 +381,6 @@ function ChartRendererInner({
/>
);
- case "markdown":
- return (
-
- );
-
case "iframe":
return (
({
+ default: () => {
+ const Stub = (props: Record) => (
+
+ );
+ Stub.displayName = "BarChartStub";
+ return Stub;
+ },
+}));
+
+vi.mock("@neoboard/components", () => ({
+ Skeleton: () => null,
+}));
+
+describe("barPlugin", () => {
+ it("declares type = 'bar'", () => {
+ expect(barPlugin.type).toBe("bar");
+ });
+
+ it("supports click action and styling", () => {
+ expect(barPlugin.capabilities.supportsClickAction).toBe(true);
+ expect(barPlugin.capabilities.supportsStyling).toBe(true);
+ expect(barPlugin.capabilities.isECharts).toBe(true);
+ expect(barPlugin.capabilities.requiresQuery).toBe(true);
+ });
+
+ it("compatible with neo4j and postgresql", () => {
+ expect(barPlugin.compatibleWith).toEqual(["neo4j", "postgresql"]);
+ });
+
+ it("has a styling target for bar color", () => {
+ expect(barPlugin.stylingTargets).toEqual([
+ { value: "color", label: "Bar Color" },
+ ]);
+ });
+
+ it("renders BarChart with settings passed through", () => {
+ const Component = barPlugin.component;
+ render(
+ ,
+ );
+ const chart = screen.getByTestId("bar-chart");
+ expect(chart).toHaveAttribute("data-stacked", "true");
+ expect(chart).toHaveAttribute("data-orientation", "horizontal");
+ });
+
+ it("has transform and validate from chart registry", () => {
+ expect(typeof barPlugin.transform).toBe("function");
+ expect(typeof barPlugin.validate).toBe("function");
+ });
+});
diff --git a/app/src/plugins/bar.tsx b/app/src/plugins/bar.tsx
new file mode 100644
index 00000000..02068616
--- /dev/null
+++ b/app/src/plugins/bar.tsx
@@ -0,0 +1,86 @@
+/**
+ * Bar chart plugin.
+ *
+ * Horizontal / vertical / stacked bars for comparing categories.
+ * Supports click actions, rule-based styling, and axis data zoom.
+ */
+
+import dynamic from "next/dynamic";
+import { Skeleton } from "@neoboard/components";
+import type {
+ BarChartDataPoint,
+ EChartsClickEvent,
+ StylingRule,
+} from "@neoboard/components";
+import { defineChartPlugin } from "./registry";
+import { chartRegistry } from "@/lib/chart-registry";
+
+// Charts use ECharts (browser APIs) — must be loaded client-side only
+const BarChart = dynamic(
+ () => import("@neoboard/components").then((m) => ({ default: m.BarChart })),
+ { ssr: false, loading: () => },
+);
+
+interface PluginComponentProps {
+ data: unknown;
+ settings: Record;
+ stylingRules?: StylingRule[];
+ paramValues?: Record;
+ onClick?: (e: EChartsClickEvent) => void;
+ colorThresholds?: string;
+}
+
+function BarPluginComponent({
+ data,
+ settings,
+ stylingRules,
+ paramValues,
+ onClick,
+ colorThresholds,
+}: PluginComponentProps) {
+ return (
+
+ );
+}
+
+export const barPlugin = defineChartPlugin({
+ type: "bar",
+ label: "Bar Chart",
+ component: BarPluginComponent,
+ transform: chartRegistry.bar.transform,
+ transformWithMapping: chartRegistry.bar.transformWithMapping,
+ validate: chartRegistry.bar.validate,
+ compatibleWith: ["neo4j", "postgresql"],
+ stylingTargets: [{ value: "color", label: "Bar Color" }],
+ capabilities: {
+ supportsClickAction: true,
+ supportsStyling: true,
+ isECharts: true,
+ requiresQuery: true,
+ },
+ queryHint:
+ "Return 2+ columns: first = category label (string), rest = numeric series.\n" +
+ "Example: RETURN genre, count(*) AS films",
+});
diff --git a/app/src/plugins/index.ts b/app/src/plugins/index.ts
index efafb56b..d64bfd9e 100644
--- a/app/src/plugins/index.ts
+++ b/app/src/plugins/index.ts
@@ -17,11 +17,18 @@
import { pluginRegistry } from "./registry";
import { markdownPlugin } from "./markdown";
+import { barPlugin } from "./bar";
+import { linePlugin } from "./line";
+import { piePlugin } from "./pie";
+
+const BUILT_IN_PLUGINS = [markdownPlugin, barPlugin, linePlugin, piePlugin];
// Idempotent registration — the first import of this module registers
// plugins; subsequent imports are no-ops thanks to Node's module cache.
-if (!pluginRegistry.has(markdownPlugin.type)) {
- pluginRegistry.register(markdownPlugin);
+for (const plugin of BUILT_IN_PLUGINS) {
+ if (!pluginRegistry.has(plugin.type)) {
+ pluginRegistry.register(plugin);
+ }
}
// Re-export for convenience
diff --git a/app/src/plugins/line.tsx b/app/src/plugins/line.tsx
new file mode 100644
index 00000000..568b867c
--- /dev/null
+++ b/app/src/plugins/line.tsx
@@ -0,0 +1,95 @@
+/**
+ * Line chart plugin.
+ *
+ * Line/area/stepped time-series charts with optional dual Y-axis support.
+ * Supports click actions, rule-based styling, and axis data zoom.
+ */
+
+import dynamic from "next/dynamic";
+import { Skeleton } from "@neoboard/components";
+import type {
+ LineChartDataPoint,
+ EChartsClickEvent,
+ StylingRule,
+} from "@neoboard/components";
+import { defineChartPlugin } from "./registry";
+import { chartRegistry } from "@/lib/chart-registry";
+
+const LineChart = dynamic(
+ () => import("@neoboard/components").then((m) => ({ default: m.LineChart })),
+ { ssr: false, loading: () => },
+);
+
+interface PluginComponentProps {
+ data: unknown;
+ settings: Record;
+ stylingRules?: StylingRule[];
+ paramValues?: Record;
+ onClick?: (e: EChartsClickEvent) => void;
+ colorThresholds?: string;
+}
+
+function LinePluginComponent({
+ data,
+ settings,
+ stylingRules,
+ paramValues,
+ onClick,
+ colorThresholds,
+}: PluginComponentProps) {
+ // Parse comma-separated rightAxisSeries string into string array
+ const rightAxisSeriesRaw = settings.rightAxisSeries as string | undefined;
+ const rightAxisSeries = rightAxisSeriesRaw
+ ? rightAxisSeriesRaw
+ .split(",")
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0)
+ : undefined;
+
+ return (
+
+ );
+}
+
+export const linePlugin = defineChartPlugin({
+ type: "line",
+ label: "Line Chart",
+ component: LinePluginComponent,
+ transform: chartRegistry.line.transform,
+ transformWithMapping: chartRegistry.line.transformWithMapping,
+ validate: chartRegistry.line.validate,
+ compatibleWith: ["neo4j", "postgresql"],
+ stylingTargets: [{ value: "color", label: "Line Color" }],
+ capabilities: {
+ supportsClickAction: true,
+ supportsStyling: true,
+ isECharts: true,
+ requiresQuery: true,
+ },
+ queryHint:
+ "Return 2+ columns: first = x-axis label, rest = numeric series.\n" +
+ "Example: RETURN month, revenue, expenses",
+});
diff --git a/app/src/plugins/pie.tsx b/app/src/plugins/pie.tsx
new file mode 100644
index 00000000..f0366a3f
--- /dev/null
+++ b/app/src/plugins/pie.tsx
@@ -0,0 +1,82 @@
+/**
+ * Pie / Doughnut chart plugin.
+ *
+ * Proportional slices for part-to-whole comparisons. Supports donut mode,
+ * rose (nightingale), top-N truncation, and scrollable legend.
+ */
+
+import dynamic from "next/dynamic";
+import { Skeleton } from "@neoboard/components";
+import type {
+ PieChartDataPoint,
+ EChartsClickEvent,
+ StylingRule,
+} from "@neoboard/components";
+import { defineChartPlugin } from "./registry";
+import { chartRegistry } from "@/lib/chart-registry";
+
+const PieChart = dynamic(
+ () => import("@neoboard/components").then((m) => ({ default: m.PieChart })),
+ { ssr: false, loading: () => },
+);
+
+interface PluginComponentProps {
+ data: unknown;
+ settings: Record;
+ stylingRules?: StylingRule[];
+ paramValues?: Record;
+ onClick?: (e: EChartsClickEvent) => void;
+ colorThresholds?: string;
+}
+
+function PiePluginComponent({
+ data,
+ settings,
+ stylingRules,
+ paramValues,
+ onClick,
+ colorThresholds,
+}: PluginComponentProps) {
+ return (
+
+ );
+}
+
+export const piePlugin = defineChartPlugin({
+ type: "pie",
+ label: "Pie / Doughnut",
+ component: PiePluginComponent,
+ transform: chartRegistry.pie.transform,
+ transformWithMapping: chartRegistry.pie.transformWithMapping,
+ validate: chartRegistry.pie.validate,
+ compatibleWith: ["neo4j", "postgresql"],
+ stylingTargets: [{ value: "color", label: "Slice Color" }],
+ capabilities: {
+ supportsClickAction: true,
+ supportsStyling: true,
+ isECharts: true,
+ requiresQuery: true,
+ },
+ queryHint:
+ "Return 2 columns: first = slice label (string), second = numeric value.\n" +
+ "Example: RETURN category, count(*) AS total",
+});