1658 lines
53 KiB
TypeScript
1658 lines
53 KiB
TypeScript
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { Layer, Stage } from "react-konva";
|
|
import {
|
|
BarChart3,
|
|
ChevronDown,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
GripVertical,
|
|
MoreVertical,
|
|
Pencil,
|
|
Plus,
|
|
Settings,
|
|
Trash2,
|
|
Type,
|
|
X,
|
|
} from "lucide-react";
|
|
import { DeferredColorInput } from "@/components/slide-editor/toolbar/DeferredColorInput";
|
|
import {
|
|
numericInputMode,
|
|
preventInvalidNumberInput,
|
|
sanitizeNumericInput,
|
|
} from "@/components/slide-editor/toolbar/numericInput";
|
|
import {
|
|
CHART_TEXT_MAX_LENGTH,
|
|
DEFAULT_CHART_COLORS,
|
|
appendChartColorTarget,
|
|
chartColorTargetMode,
|
|
chartDataFromSeriesWithColors,
|
|
chartSupportsMultipleSeries,
|
|
ellipsizeChartText,
|
|
extendChartColors,
|
|
limitChartText,
|
|
resolvedChartColorTargets,
|
|
resolvedChartCategories,
|
|
updateChartColorTarget,
|
|
} from "@/components/slide-editor/charts/chart-data";
|
|
import {
|
|
EDITOR_STAGE_HEIGHT,
|
|
EDITOR_STAGE_WIDTH,
|
|
type ChartElement,
|
|
type ChartSeries,
|
|
type ChartType,
|
|
type DataLabelPosition,
|
|
} from "@/components/slide-editor/types";
|
|
import { ChartColorPaletteCard } from "@/components/slide-editor/charts/ChartColorPalette";
|
|
import { TemplateV2ChartJsElement } from "@/components/slide-editor/charts/TemplateV2ChartJsElement";
|
|
|
|
const CHART_TYPES: Array<{ label: string; value: ChartType }> = [
|
|
{ label: "Bar Chart", value: "bar" },
|
|
{ label: "Horizontal Bar", value: "horizontal_bar" },
|
|
{ label: "Stacked Bar", value: "stacked_bar" },
|
|
{ label: "Horizontal Stack Bar", value: "horizontal_stacked_bar" },
|
|
{ label: "Line Chart", value: "line" },
|
|
{ label: "Area Chart", value: "area" },
|
|
{ label: "Pie Chart", value: "pie" },
|
|
{ label: "Donut Chart", value: "donut" },
|
|
{ label: "Scatter Chart", value: "scatter" },
|
|
{ label: "Radar Chart", value: "radar" },
|
|
{ label: "Polar Area", value: "polar_area" },
|
|
];
|
|
const DATA_LABEL_TABS: Array<{
|
|
label: string;
|
|
value: DataLabelPosition;
|
|
}> = [
|
|
{ label: "Base", value: "base" },
|
|
{ label: "Middle", value: "mid" },
|
|
{ label: "Top", value: "top" },
|
|
{ label: "Outside", value: "outside" },
|
|
];
|
|
const DATA_MODAL_CHART_PREVIEW_WIDTH = 215;
|
|
const DATA_MODAL_CHART_PREVIEW_HEIGHT = 180;
|
|
const DATA_MODAL_MAX_HEIGHT = "min(650px, calc(100dvh - 32px))";
|
|
|
|
export function ChartEditorContent({
|
|
chart,
|
|
chartPath,
|
|
onChange,
|
|
onClose,
|
|
}: {
|
|
chart: ChartElement;
|
|
chartPath?: string | null;
|
|
onChange: (chart: ChartElement) => void;
|
|
onClose?: () => void;
|
|
}) {
|
|
const [tab, setTab] = useState<"data" | "customize">("data");
|
|
const [dataModalOpen, setDataModalOpen] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
data-inline-edit-ignore="true"
|
|
className="h-full overflow-y-auto px-5 pb-8 pt-6 font-syne hide-scrollbar"
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
>
|
|
<div className="mb-6 flex items-center justify-between gap-4">
|
|
<h3 className="text-[15px] font-semibold leading-5 text-[#101323]">
|
|
Edit Charts
|
|
</h3>
|
|
{onClose ? (
|
|
<button
|
|
type="button"
|
|
aria-label="Close chart editor"
|
|
className="grid h-8 w-8 place-items-center rounded-full text-[#191919] transition hover:bg-[#F5F5F7]"
|
|
onClick={onClose}
|
|
>
|
|
<X size={17} strokeWidth={2} />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
|
|
<label className="mb-2 block text-[12px] font-medium text-[#686873]">
|
|
Chart type
|
|
</label>
|
|
<ChartTypeSelect
|
|
value={chart.chart_type}
|
|
onChange={(chartType) =>
|
|
onChange({ ...chart, chart_type: chartType })
|
|
}
|
|
/>
|
|
|
|
<div className="mt-6 border-t border-[#ECECF1]">
|
|
<div className="grid grid-cols-2">
|
|
<button
|
|
type="button"
|
|
className={`h-12 border-b-2 text-[13px] font-medium transition ${tab === "data"
|
|
? "border-[#7C51F8] text-[#191919]"
|
|
: "border-transparent text-[#191919]"
|
|
}`}
|
|
onClick={() => setTab("data")}
|
|
>
|
|
Data
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className={`h-12 border-b-2 text-[13px] font-medium transition ${tab === "customize"
|
|
? "border-[#7C51F8] text-[#191919]"
|
|
: "border-transparent text-[#191919]"
|
|
}`}
|
|
onClick={() => setTab("customize")}
|
|
>
|
|
Customize
|
|
</button>
|
|
</div>
|
|
|
|
{tab === "data" ? (
|
|
<ChartDataPanel
|
|
chart={chart}
|
|
onOpenDataModal={() => setDataModalOpen(true)}
|
|
/>
|
|
) : (
|
|
<ChartCustomizePanel chart={chart} onChange={onChange} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{dataModalOpen ? (
|
|
<ChartDataEditorPopover
|
|
chart={chart}
|
|
chartPath={chartPath ?? "chart"}
|
|
onChange={onChange}
|
|
onClose={() => setDataModalOpen(false)}
|
|
/>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function ChartTypeSelect({
|
|
compact = false,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
compact?: boolean;
|
|
value: ChartType;
|
|
onChange: (value: ChartType) => void;
|
|
}) {
|
|
return (
|
|
<div className="relative">
|
|
<BarChart3 className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-[#191919]" />
|
|
<select
|
|
aria-label="Chart type"
|
|
className={`${compact ? "h-9 rounded-lg pl-10 pr-9 text-[12px]" : "h-12 rounded-xl pl-11 pr-10 text-[13px]"} w-full appearance-none border border-[#E6E6EA] bg-white font-medium text-[#191919] outline-none transition focus:border-[#7C51F8]`}
|
|
value={value}
|
|
onChange={(event) => onChange(event.target.value as ChartType)}
|
|
>
|
|
{CHART_TYPES.map((item) => (
|
|
<option key={item.value} value={item.value}>
|
|
{item.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<ChevronDown className="pointer-events-none absolute right-4 top-1/2 h-4 w-4 -translate-y-1/2 text-[#191919]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DataLabelsControl({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: DataLabelPosition | null;
|
|
onChange: (value: DataLabelPosition | null) => void;
|
|
}) {
|
|
const enabled = value != null;
|
|
const [lastPosition, setLastPosition] = useState<DataLabelPosition>(
|
|
value ?? "top",
|
|
);
|
|
const activePosition = value ?? lastPosition;
|
|
|
|
useEffect(() => {
|
|
if (value) {
|
|
setLastPosition(value);
|
|
}
|
|
}, [value]);
|
|
|
|
const setEnabled = (checked: boolean) => {
|
|
onChange(checked ? activePosition : null);
|
|
};
|
|
|
|
const selectPosition = (position: DataLabelPosition) => {
|
|
setLastPosition(position);
|
|
onChange(position);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex min-h-6 items-center justify-between gap-3 text-[12px] font-medium text-[#191919]">
|
|
<span>Data labels</span>
|
|
<CompactSwitch
|
|
checked={enabled}
|
|
label="Data labels"
|
|
onChange={setEnabled}
|
|
/>
|
|
</div>
|
|
<div
|
|
role="tablist"
|
|
aria-label="Data label position"
|
|
className={`grid grid-cols-4 rounded-lg bg-[#F3F4F7] p-1 transition ${enabled ? "" : "opacity-55"}`}
|
|
>
|
|
{DATA_LABEL_TABS.map((item) => {
|
|
const active = activePosition === item.value;
|
|
|
|
return (
|
|
<button
|
|
key={item.value}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={active && enabled}
|
|
disabled={!enabled}
|
|
className={`h-8 rounded-md px-1 text-[12px] font-semibold transition ${active && enabled
|
|
? "bg-white text-[#191919] shadow-sm"
|
|
: "text-[#686873] hover:text-[#191919]"
|
|
} disabled:cursor-not-allowed disabled:hover:text-[#686873]`}
|
|
onClick={() => selectPosition(item.value)}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChartDataPanel({
|
|
chart,
|
|
onOpenDataModal,
|
|
}: {
|
|
chart: ChartElement;
|
|
onOpenDataModal: () => void;
|
|
}) {
|
|
const categories = safeCategoriesForChart(chart);
|
|
const series = normalizedSeries(chart, categories.length);
|
|
|
|
return (
|
|
<div className="space-y-5 pt-5">
|
|
<MiniDataTable categories={categories} series={series} />
|
|
<button
|
|
type="button"
|
|
className="flex h-10 w-full items-center justify-center gap-2 rounded-full border border-[#E6E6EA] bg-white px-4 text-[12px] font-semibold text-[#191919] transition hover:bg-[#F7F7FA]"
|
|
onClick={onOpenDataModal}
|
|
>
|
|
<Pencil size={15} strokeWidth={2} />
|
|
Edit data
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MiniDataTable({
|
|
categories,
|
|
series,
|
|
}: {
|
|
categories: string[];
|
|
series: ChartSeries[];
|
|
}) {
|
|
const visibleSeries = series.slice(0, 2);
|
|
return (
|
|
<div className="overflow-hidden rounded-xl bg-[#F7F7FA]">
|
|
<div className="max-h-[210px] overflow-auto">
|
|
<table className="min-w-full border-collapse text-[12px] text-[#191919]">
|
|
<thead>
|
|
<tr>
|
|
<th className="sticky left-0 top-0 min-w-[110px] border-b border-r border-[#E6E6EA] bg-[#F3F4F7] px-3 py-3 text-left font-medium" />
|
|
{visibleSeries.map((item) => (
|
|
<th
|
|
key={item.name}
|
|
className="min-w-[120px] max-w-[180px] border-b border-r border-[#E6E6EA] bg-[#F3F4F7] px-3 py-3 text-left font-medium"
|
|
title={item.name}
|
|
>
|
|
<span className="block truncate">
|
|
{ellipsizeChartText(item.name)}
|
|
</span>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{categories.slice(0, 5).map((category, rowIndex) => (
|
|
<tr key={`${category}-${rowIndex}`}>
|
|
<td
|
|
className="sticky left-0 max-w-[180px] border-b border-r border-[#E6E6EA] bg-[#F7F7FA] px-3 py-3 font-medium"
|
|
title={category}
|
|
>
|
|
<span className="block truncate">
|
|
{ellipsizeChartText(category)}
|
|
</span>
|
|
</td>
|
|
{visibleSeries.map((item, seriesIndex) => (
|
|
<td
|
|
key={`${item.name}-${seriesIndex}`}
|
|
className="border-b border-r border-[#E6E6EA] bg-white px-3 py-3"
|
|
>
|
|
{item.values[rowIndex] ?? 0}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChartCustomizePanel({
|
|
chart,
|
|
compact = false,
|
|
defaultTextOpen = true,
|
|
onChange,
|
|
}: {
|
|
chart: ChartElement;
|
|
compact?: boolean;
|
|
defaultTextOpen?: boolean;
|
|
onChange: (chart: ChartElement) => void;
|
|
}) {
|
|
const hasCartesianAxes =
|
|
chart.chart_type !== "pie" &&
|
|
chart.chart_type !== "donut" &&
|
|
chart.chart_type !== "polar_area" &&
|
|
chart.chart_type !== "radar";
|
|
const hasRadialAxes = chart.chart_type === "radar";
|
|
const hasAxes = hasCartesianAxes || hasRadialAxes;
|
|
|
|
return (
|
|
<div className="space-y-1 py-2">
|
|
<AccordionSection
|
|
compact={compact}
|
|
defaultOpen={defaultTextOpen}
|
|
icon={<Type size={17} />}
|
|
label="Text"
|
|
>
|
|
<TextField
|
|
label="Title"
|
|
placeholder="Chart title"
|
|
value={chart.title ?? ""}
|
|
onChange={(title) => onChange({ ...chart, title: title || null })}
|
|
/>
|
|
<ColorRow
|
|
label="Title color"
|
|
value={chart.title_color ?? "344054"}
|
|
onChange={(titleColor) =>
|
|
onChange({ ...chart, title_color: titleColor })
|
|
}
|
|
/>
|
|
<DataLabelsControl
|
|
value={chart.data_labels ?? null}
|
|
onChange={(dataLabels) =>
|
|
onChange({ ...chart, data_labels: dataLabels })
|
|
}
|
|
/>
|
|
</AccordionSection>
|
|
|
|
{hasCartesianAxes ? (
|
|
<>
|
|
<AccordionSection
|
|
compact={compact}
|
|
icon={<BarChart3 size={17} />}
|
|
label="X Axis"
|
|
>
|
|
<ToggleRow
|
|
checked={chart.x_axis ?? true}
|
|
label="Show axis"
|
|
onChange={(xAxis) => onChange({ ...chart, x_axis: xAxis })}
|
|
/>
|
|
<TextField
|
|
label="Title"
|
|
placeholder="X-axis title"
|
|
value={chart.x_axis_title ?? ""}
|
|
onChange={(xAxisTitle) =>
|
|
onChange({ ...chart, x_axis_title: xAxisTitle || null })
|
|
}
|
|
/>
|
|
<ToggleRow
|
|
checked={chart.x_axis_grid ?? true}
|
|
label="Show grid"
|
|
onChange={(xAxisGrid) =>
|
|
onChange({ ...chart, x_axis_grid: xAxisGrid })
|
|
}
|
|
/>
|
|
</AccordionSection>
|
|
<AccordionSection
|
|
compact={compact}
|
|
icon={<BarChart3 size={17} />}
|
|
label="Y Axis"
|
|
>
|
|
<ToggleRow
|
|
checked={chart.y_axis ?? true}
|
|
label="Show axis"
|
|
onChange={(yAxis) => onChange({ ...chart, y_axis: yAxis })}
|
|
/>
|
|
<TextField
|
|
label="Title"
|
|
placeholder="Y-axis title"
|
|
value={chart.y_axis_title ?? ""}
|
|
onChange={(yAxisTitle) =>
|
|
onChange({ ...chart, y_axis_title: yAxisTitle || null })
|
|
}
|
|
/>
|
|
<ToggleRow
|
|
checked={chart.y_axis_grid ?? true}
|
|
label="Show grid"
|
|
onChange={(yAxisGrid) =>
|
|
onChange({ ...chart, y_axis_grid: yAxisGrid })
|
|
}
|
|
/>
|
|
</AccordionSection>
|
|
</>
|
|
) : null}
|
|
|
|
{hasRadialAxes ? (
|
|
<>
|
|
<AccordionSection
|
|
compact={compact}
|
|
icon={<BarChart3 size={17} />}
|
|
label="X Axis"
|
|
>
|
|
<ToggleRow
|
|
checked={chart.x_axis ?? true}
|
|
label="Category labels"
|
|
onChange={(xAxis) => onChange({ ...chart, x_axis: xAxis })}
|
|
/>
|
|
<ToggleRow
|
|
checked={chart.x_axis_grid ?? true}
|
|
label="Spokes"
|
|
onChange={(xAxisGrid) =>
|
|
onChange({ ...chart, x_axis_grid: xAxisGrid })
|
|
}
|
|
/>
|
|
</AccordionSection>
|
|
<AccordionSection
|
|
compact={compact}
|
|
icon={<BarChart3 size={17} />}
|
|
label="Y Axis"
|
|
>
|
|
<ToggleRow
|
|
checked={chart.y_axis ?? true}
|
|
label="Value labels"
|
|
onChange={(yAxis) => onChange({ ...chart, y_axis: yAxis })}
|
|
/>
|
|
<ToggleRow
|
|
checked={chart.y_axis_grid ?? true}
|
|
label="Rings"
|
|
onChange={(yAxisGrid) =>
|
|
onChange({ ...chart, y_axis_grid: yAxisGrid })
|
|
}
|
|
/>
|
|
</AccordionSection>
|
|
</>
|
|
) : null}
|
|
|
|
<AccordionSection
|
|
compact={compact}
|
|
icon={<Settings size={17} />}
|
|
label="Settings"
|
|
>
|
|
<ToggleRow
|
|
checked={chart.legend ?? defaultChartLegendVisible(chart)}
|
|
label="Show legend"
|
|
onChange={(legend) => onChange({ ...chart, legend })}
|
|
/>
|
|
<ChartSeriesColorControls chart={chart} onChange={onChange} />
|
|
{hasAxes ? (
|
|
<>
|
|
<ColorRow
|
|
label="Axis color"
|
|
value={chart.axis_color ?? "9AA7BD"}
|
|
onChange={(axisColor) =>
|
|
onChange({ ...chart, axis_color: axisColor })
|
|
}
|
|
/>
|
|
<ColorRow
|
|
label="Grid color"
|
|
value={chart.grid_color ?? chart.axis_color ?? "D0D5DD"}
|
|
onChange={(gridColor) =>
|
|
onChange({ ...chart, grid_color: gridColor })
|
|
}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</AccordionSection>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function defaultChartLegendVisible(chart: ChartElement) {
|
|
const series = chart.series ?? [];
|
|
return (
|
|
chart.chart_type === "pie" ||
|
|
chart.chart_type === "donut" ||
|
|
series.length > 1 ||
|
|
Boolean(series[0]?.name && series[0].name !== "Series 1")
|
|
);
|
|
}
|
|
|
|
function ChartSeriesColorControls({
|
|
chart,
|
|
onChange,
|
|
}: {
|
|
chart: ChartElement;
|
|
onChange: (chart: ChartElement) => void;
|
|
}) {
|
|
const [paletteAnchor, setPaletteAnchor] = useState<{
|
|
index: number;
|
|
rect: DOMRect;
|
|
} | null>(null);
|
|
const swatchRefs = useRef(new Map<number, HTMLButtonElement>());
|
|
const targets = resolvedChartColorTargets(chart);
|
|
const openTarget = paletteAnchor
|
|
? targets.find((target) => target.index === paletteAnchor.index)
|
|
: null;
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{targets.map((target) => (
|
|
<button
|
|
type="button"
|
|
key={`${target.mode}-${target.index}`}
|
|
aria-label={`Change chart color ${target.index + 1}`}
|
|
className={`grid h-8 w-8 place-items-center rounded-full border bg-white p-1 transition ${paletteAnchor?.index === target.index
|
|
? "border-[#7C51F8] ring-2 ring-[#E9E2FF]"
|
|
: "border-[#E6E6EA] hover:border-[#B8A3F8]"
|
|
}`}
|
|
ref={(node) => {
|
|
if (node) {
|
|
swatchRefs.current.set(target.index, node);
|
|
} else {
|
|
swatchRefs.current.delete(target.index);
|
|
}
|
|
}}
|
|
title={`Chart color ${target.index + 1}`}
|
|
onClick={() =>
|
|
setPaletteAnchor((current) => {
|
|
if (current?.index === target.index) return null;
|
|
const anchor = swatchRefs.current.get(target.index);
|
|
return anchor
|
|
? { index: target.index, rect: anchor.getBoundingClientRect() }
|
|
: null;
|
|
})
|
|
}
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className="h-full w-full rounded-full border border-black/10"
|
|
style={{ backgroundColor: `#${target.color}` }}
|
|
/>
|
|
</button>
|
|
))}
|
|
{targets.length < 12 ? (
|
|
<button
|
|
type="button"
|
|
aria-label="Add chart color"
|
|
className="grid h-8 w-8 place-items-center rounded-full border border-dashed border-[#B8A3F8] bg-white text-[#7C51F8] transition hover:bg-[#F7F3FF]"
|
|
title="Add chart color"
|
|
onClick={() => onChange(appendChartColorTarget(chart))}
|
|
>
|
|
<Plus size={15} strokeWidth={2.2} />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
{openTarget && paletteAnchor && typeof document !== "undefined"
|
|
? createPortal(
|
|
<ChartColorPaletteCard
|
|
colors={targets.map((target) => target.color)}
|
|
onAddColor={() => {
|
|
onChange(appendChartColorTarget(chart));
|
|
setPaletteAnchor(null);
|
|
}}
|
|
onChange={(color) =>
|
|
onChange(updateChartColorTarget(chart, openTarget.index, color))
|
|
}
|
|
onClose={() => setPaletteAnchor(null)}
|
|
onSelectIndex={(index) =>
|
|
setPaletteAnchor((current) =>
|
|
current ? { ...current, index } : current,
|
|
)
|
|
}
|
|
selectedIndex={openTarget.index}
|
|
style={chartPalettePortalStyle(paletteAnchor.rect)}
|
|
/>,
|
|
document.body,
|
|
)
|
|
: null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function chartPalettePortalStyle(anchorRect: DOMRect) {
|
|
const width = 296;
|
|
const estimatedHeight = 330;
|
|
const margin = 12;
|
|
const gap = 8;
|
|
const viewportWidth =
|
|
typeof window === "undefined" ? anchorRect.right + width : window.innerWidth;
|
|
const viewportHeight =
|
|
typeof window === "undefined"
|
|
? anchorRect.bottom + estimatedHeight + margin
|
|
: window.innerHeight;
|
|
const spaceBelow = viewportHeight - anchorRect.bottom - margin;
|
|
const spaceAbove = anchorRect.top - margin;
|
|
const shouldOpenAbove =
|
|
spaceBelow < estimatedHeight && spaceAbove > spaceBelow;
|
|
const preferredTop = shouldOpenAbove
|
|
? anchorRect.top - estimatedHeight - gap
|
|
: anchorRect.bottom + gap;
|
|
const top = Math.max(
|
|
margin,
|
|
Math.min(preferredTop, viewportHeight - estimatedHeight - margin),
|
|
);
|
|
|
|
return {
|
|
position: "fixed" as const,
|
|
left: Math.max(12, Math.min(anchorRect.right - width, viewportWidth - width - 12)),
|
|
top,
|
|
maxHeight: Math.max(180, viewportHeight - top - margin),
|
|
overflowY: "auto" as const,
|
|
zIndex: 10020,
|
|
};
|
|
}
|
|
|
|
function AccordionSection({
|
|
children,
|
|
compact = false,
|
|
defaultOpen = false,
|
|
icon,
|
|
label,
|
|
}: {
|
|
children: ReactNode;
|
|
compact?: boolean;
|
|
defaultOpen?: boolean;
|
|
icon: ReactNode;
|
|
label: string;
|
|
}) {
|
|
const [open, setOpen] = useState(defaultOpen);
|
|
|
|
return (
|
|
<details
|
|
className="group"
|
|
open={open}
|
|
onToggle={(event) => setOpen(event.currentTarget.open)}
|
|
>
|
|
<summary
|
|
className={`${compact ? "h-9 gap-2 px-2 text-[12px]" : "h-12 gap-3 px-3 text-[13px]"} flex cursor-pointer list-none items-center rounded-lg font-medium text-[#191919] transition hover:bg-[#F7F7FA] group-open:bg-[#F7F7FA] [&::-webkit-details-marker]:hidden`}
|
|
>
|
|
<span className={`${compact ? "h-5 w-5 [&>svg]:h-3.5 [&>svg]:w-3.5" : "h-6 w-6"} grid shrink-0 place-items-center text-[#191919]`}>
|
|
{icon}
|
|
</span>
|
|
<span className="min-w-0 flex-1">{label}</span>
|
|
<ChevronDown className="h-4 w-4 shrink-0 transition-transform group-open:rotate-180" />
|
|
</summary>
|
|
<div className={`${compact ? "px-2 pb-3 pt-2" : "px-3 pb-4 pt-3"} space-y-3`}>
|
|
{children}
|
|
</div>
|
|
</details>
|
|
);
|
|
}
|
|
|
|
function TextField({
|
|
label,
|
|
onChange,
|
|
placeholder,
|
|
value,
|
|
}: {
|
|
label: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
value: string;
|
|
}) {
|
|
const [draftValue, setDraftValue] = useState(() => limitChartText(value));
|
|
|
|
useEffect(() => {
|
|
setDraftValue(limitChartText(value));
|
|
}, [value]);
|
|
|
|
const commitValue = () => {
|
|
const nextValue = limitChartText(draftValue);
|
|
if (nextValue !== draftValue) {
|
|
setDraftValue(nextValue);
|
|
}
|
|
if (nextValue !== value) {
|
|
onChange(nextValue);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<label className="block text-[12px] font-medium text-[#686873]">
|
|
{label}
|
|
<input
|
|
className="mt-1.5 h-9 w-full truncate rounded-lg border border-[#E6E6EA] bg-white px-3 text-[12px] text-[#191919] outline-none transition placeholder:text-[#A6A6AF] focus:border-[#7C51F8]"
|
|
maxLength={CHART_TEXT_MAX_LENGTH}
|
|
placeholder={placeholder}
|
|
spellCheck={false}
|
|
value={draftValue}
|
|
onBlur={commitValue}
|
|
onChange={(event) => setDraftValue(limitChartText(event.target.value))}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
event.currentTarget.blur();
|
|
}
|
|
}}
|
|
/>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function ToggleRow({
|
|
checked,
|
|
label,
|
|
onChange,
|
|
}: {
|
|
checked: boolean;
|
|
label: string;
|
|
onChange: (checked: boolean) => void;
|
|
}) {
|
|
return (
|
|
<div className="flex min-h-6 items-center justify-between gap-3 text-[12px] font-medium text-[#191919]">
|
|
<span>{label}</span>
|
|
<CompactSwitch checked={checked} label={label} onChange={onChange} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CompactSwitch({
|
|
checked,
|
|
label,
|
|
onChange,
|
|
}: {
|
|
checked: boolean;
|
|
label: string;
|
|
onChange: (checked: boolean) => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={checked}
|
|
aria-label={label}
|
|
className={`relative h-5 w-9 shrink-0 rounded-full transition ${checked ? "bg-[#7C51F8]" : "bg-[#D8D8DE]"
|
|
}`}
|
|
onClick={() => onChange(!checked)}
|
|
>
|
|
<span
|
|
className={`absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${checked ? "translate-x-4" : "translate-x-0"
|
|
}`}
|
|
/>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ColorRow({
|
|
label,
|
|
value,
|
|
onChange,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
}) {
|
|
return (
|
|
<label className="flex items-center justify-between gap-3 text-[12px] font-medium text-[#191919]">
|
|
{label}
|
|
<DeferredColorInput
|
|
className="h-8 w-11 rounded-lg border border-[#E6E6EA] bg-white p-1"
|
|
value={value}
|
|
onCommit={onChange}
|
|
/>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
export function ChartDataEditorPopover({
|
|
chart,
|
|
chartPath,
|
|
onChange,
|
|
onClose,
|
|
}: {
|
|
chart: ChartElement;
|
|
chartPath: string;
|
|
onChange: (chart: ChartElement) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
if (typeof document === "undefined") return null;
|
|
|
|
return createPortal(
|
|
<ChartDataModal
|
|
chart={chart}
|
|
chartPath={chartPath}
|
|
onChange={onChange}
|
|
onClose={onClose}
|
|
/>,
|
|
document.body,
|
|
);
|
|
}
|
|
|
|
function ChartDataModal({
|
|
chart,
|
|
chartPath,
|
|
onChange,
|
|
onClose,
|
|
}: {
|
|
chart: ChartElement;
|
|
chartPath: string;
|
|
onChange: (chart: ChartElement) => void;
|
|
onClose: () => void;
|
|
}) {
|
|
const [draftChart, setDraftChart] = useState<ChartElement>(() => chart);
|
|
const categories = safeCategoriesForChart(draftChart);
|
|
const series = normalizedSeries(draftChart, categories.length);
|
|
const previewChart = useMemo(
|
|
() => chartPreviewElement(draftChart),
|
|
[draftChart],
|
|
);
|
|
|
|
const updateData = (
|
|
nextCategories: string[],
|
|
nextSeries: ChartSeries[],
|
|
nextColors = draftChart.colors ?? [],
|
|
) => {
|
|
setDraftChart((currentChart) => {
|
|
const normalizedCategories = nextCategories.slice(0, 24);
|
|
const limitedCategories = normalizedCategories.map(limitChartText);
|
|
const normalized = nextSeries
|
|
.map((item) => ({
|
|
name: limitChartText(item.name ?? ""),
|
|
values: normalizeValues(item.values, limitedCategories.length),
|
|
}))
|
|
.slice(
|
|
0,
|
|
chartSupportsMultipleSeries(currentChart.chart_type) ? 12 : 1,
|
|
);
|
|
const colorMode = chartColorTargetMode({
|
|
...currentChart,
|
|
categories: limitedCategories,
|
|
series: normalized,
|
|
});
|
|
const minimumColorCount = Math.max(
|
|
1,
|
|
nextColors.length,
|
|
currentChart.colors?.length ?? 0,
|
|
colorMode === "series" ? normalized.length : 1,
|
|
);
|
|
const colors = extendChartColors(
|
|
nextColors.length > 0 ? nextColors : currentChart.colors,
|
|
minimumColorCount,
|
|
currentChart.color ?? DEFAULT_CHART_COLORS[0],
|
|
);
|
|
|
|
return {
|
|
...currentChart,
|
|
categories: limitedCategories,
|
|
color: colors[0] ?? currentChart.color,
|
|
series: normalized,
|
|
colors,
|
|
data: chartDataFromSeriesWithColors(
|
|
limitedCategories,
|
|
normalized,
|
|
colors,
|
|
colorMode === "category",
|
|
),
|
|
};
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div
|
|
data-inline-edit-ignore="true"
|
|
className="fixed inset-0 z-[10010] flex items-center justify-center bg-black/35 p-4 pr-[72px] font-syne"
|
|
onMouseDown={(event) => event.stopPropagation()}
|
|
>
|
|
<div
|
|
className="relative flex w-full max-w-[1080px] flex-col overflow-visible"
|
|
style={{
|
|
height: DATA_MODAL_MAX_HEIGHT,
|
|
maxHeight: DATA_MODAL_MAX_HEIGHT,
|
|
}}
|
|
>
|
|
<div className="flex h-full min-h-0 flex-col overflow-hidden rounded-2xl bg-white shadow-[0_24px_80px_rgba(16,24,40,0.24)]">
|
|
<header className="flex h-[70px] shrink-0 items-center justify-between border-b border-[#ECECF1] px-5">
|
|
<div>
|
|
<h2 className="text-[15px] font-semibold text-[#191919]">
|
|
Edit Data Table
|
|
</h2>
|
|
<p className="mt-1 text-[11px] text-[#8B8B94]">
|
|
Edit Data Table
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="flex h-8 items-center gap-1.5 rounded-full border border-[#E6E6EA] bg-white px-4 text-[12px] font-semibold text-[#191919] transition hover:bg-[#F7F7FA]"
|
|
onClick={() =>
|
|
setDraftChart((currentChart) =>
|
|
clearChartData(currentChart),
|
|
)
|
|
}
|
|
>
|
|
<Trash2 size={14} strokeWidth={2} />
|
|
Clear data
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="h-8 min-w-[76px] rounded-full bg-[linear-gradient(100deg,#FFE6A6_0%,#D8B4FE_100%)] px-5 text-[12px] font-semibold text-[#191919] transition hover:brightness-95"
|
|
onClick={() => {
|
|
onChange(sanitizeChartTextFields(draftChart));
|
|
onClose();
|
|
}}
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
|
<aside className="min-h-0 w-[255px] shrink-0 overflow-y-auto overscroll-contain border-r border-[#ECECF1] px-4 py-4 hide-scrollbar">
|
|
<label className="mb-2 block text-[12px] font-medium text-[#191919]">
|
|
Charts
|
|
</label>
|
|
<ChartTypeSelect
|
|
compact
|
|
value={draftChart.chart_type}
|
|
onChange={(chartType) =>
|
|
setDraftChart((currentChart) => ({
|
|
...currentChart,
|
|
chart_type: chartType,
|
|
}))
|
|
}
|
|
/>
|
|
<div
|
|
className="relative mt-4 flex h-[210px] items-center justify-center overflow-hidden rounded-lg border border-[#ECECF1] bg-[#F8F8FA]"
|
|
style={{
|
|
backgroundImage: "url('/card_bg.svg')",
|
|
backgroundPosition: "center",
|
|
backgroundSize: "100% 100%",
|
|
}}
|
|
>
|
|
<div
|
|
className="pointer-events-none relative overflow-hidden"
|
|
style={{
|
|
height: DATA_MODAL_CHART_PREVIEW_HEIGHT,
|
|
width: DATA_MODAL_CHART_PREVIEW_WIDTH,
|
|
}}
|
|
>
|
|
<Stage
|
|
height={DATA_MODAL_CHART_PREVIEW_HEIGHT}
|
|
width={DATA_MODAL_CHART_PREVIEW_WIDTH}
|
|
>
|
|
<Layer listening={false}>
|
|
<TemplateV2ChartJsElement
|
|
element={previewChart}
|
|
height={DATA_MODAL_CHART_PREVIEW_HEIGHT}
|
|
interactive={false}
|
|
width={DATA_MODAL_CHART_PREVIEW_WIDTH}
|
|
/>
|
|
</Layer>
|
|
</Stage>
|
|
</div>
|
|
</div>
|
|
<div className="mt-2">
|
|
<ChartCustomizePanel
|
|
chart={draftChart}
|
|
compact
|
|
defaultTextOpen={false}
|
|
onChange={setDraftChart}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
|
|
<main className="min-h-0 min-w-0 flex-1 overflow-auto overscroll-contain px-8 py-5">
|
|
<EditableDataTable
|
|
allowMultipleSeries={chartSupportsMultipleSeries(
|
|
draftChart.chart_type,
|
|
)}
|
|
categories={categories}
|
|
chartPath={chartPath}
|
|
series={series}
|
|
colors={draftChart.colors ?? []}
|
|
fallbackColor={draftChart.color}
|
|
onUpdate={updateData}
|
|
/>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
aria-label="Close data editor"
|
|
className="absolute -right-14 top-0 grid h-11 w-11 place-items-center rounded-full bg-white text-[#191919] shadow-sm transition hover:bg-[#F7F7FA]"
|
|
onClick={onClose}
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EditableDataTable({
|
|
allowMultipleSeries,
|
|
categories,
|
|
chartPath,
|
|
onUpdate,
|
|
series,
|
|
colors,
|
|
fallbackColor,
|
|
}: {
|
|
allowMultipleSeries: boolean;
|
|
categories: string[];
|
|
fallbackColor?: string | null;
|
|
chartPath: string;
|
|
onUpdate: (
|
|
categories: string[],
|
|
series: ChartSeries[],
|
|
colors?: string[],
|
|
) => void;
|
|
series: ChartSeries[];
|
|
colors: string[];
|
|
}) {
|
|
const safeCategories = categories.length > 0 ? categories : ["Item 1"];
|
|
const safeSeries =
|
|
series.length > 0
|
|
? series
|
|
: [
|
|
{
|
|
name: "Series 1",
|
|
values: normalizeValues([], safeCategories.length),
|
|
},
|
|
];
|
|
const tableRootRef = useRef<HTMLDivElement | null>(null);
|
|
const [seriesMenu, setSeriesMenu] = useState<{
|
|
index: number;
|
|
left: number;
|
|
} | null>(null);
|
|
const [columnMenuOpen, setColumnMenuOpen] = useState(false);
|
|
const [selectedRowIndex, setSelectedRowIndex] = useState(0);
|
|
const [valueDrafts, setValueDrafts] = useState<Record<string, string>>({});
|
|
const selectedSeriesIndex = seriesMenu
|
|
? Math.min(seriesMenu.index, safeSeries.length - 1)
|
|
: null;
|
|
const selectedSeries =
|
|
selectedSeriesIndex == null ? null : safeSeries[selectedSeriesIndex];
|
|
const chartValueInputOptions = {
|
|
allowDecimal: true,
|
|
allowNegative: true,
|
|
};
|
|
const categoryColumnWidth = 160;
|
|
const seriesColumnWidth = 190;
|
|
const actionColumnWidth = 48;
|
|
const minimumTableWidth =
|
|
categoryColumnWidth +
|
|
safeSeries.length * seriesColumnWidth +
|
|
actionColumnWidth;
|
|
const dataGridTemplateColumns = `${categoryColumnWidth}px repeat(${safeSeries.length}, minmax(${seriesColumnWidth}px, 1fr)) ${actionColumnWidth}px`;
|
|
|
|
const updateCategory = (rowIndex: number, value: string) => {
|
|
const nextValue = limitChartText(value);
|
|
onUpdate(
|
|
safeCategories.map((category, index) =>
|
|
index === rowIndex ? nextValue : category,
|
|
),
|
|
safeSeries,
|
|
colors,
|
|
);
|
|
};
|
|
const updateSeriesName = (seriesIndex: number, name: string) => {
|
|
const nextName = limitChartText(name);
|
|
onUpdate(
|
|
safeCategories,
|
|
safeSeries.map((item, index) =>
|
|
index === seriesIndex ? { ...item, name: nextName } : item,
|
|
),
|
|
colors,
|
|
);
|
|
};
|
|
const updateValue = (
|
|
seriesIndex: number,
|
|
rowIndex: number,
|
|
value: string,
|
|
) => {
|
|
const numeric = Number(value);
|
|
onUpdate(
|
|
safeCategories,
|
|
safeSeries.map((item, index) =>
|
|
index === seriesIndex
|
|
? {
|
|
...item,
|
|
values: item.values.map((current, valueIndex) =>
|
|
valueIndex === rowIndex && Number.isFinite(numeric)
|
|
? numeric
|
|
: current,
|
|
),
|
|
}
|
|
: item,
|
|
),
|
|
colors,
|
|
);
|
|
};
|
|
const updateValueDraft = (
|
|
seriesIndex: number,
|
|
rowIndex: number,
|
|
value: string,
|
|
) => {
|
|
const key = `${seriesIndex}:${rowIndex}`;
|
|
setValueDrafts((current) => ({ ...current, [key]: value }));
|
|
if (isCompleteNumericInput(value)) {
|
|
updateValue(seriesIndex, rowIndex, value);
|
|
}
|
|
};
|
|
const clearValueDrafts = () => setValueDrafts({});
|
|
const addRow = () => {
|
|
const nextCategories = [
|
|
...safeCategories,
|
|
`Item ${safeCategories.length + 1}`,
|
|
];
|
|
onUpdate(
|
|
nextCategories,
|
|
safeSeries.map((item) => ({
|
|
...item,
|
|
values: [...item.values, 0],
|
|
})),
|
|
colors,
|
|
);
|
|
clearValueDrafts();
|
|
};
|
|
const deleteRow = (rowIndex: number) => {
|
|
if (safeCategories.length <= 1) return;
|
|
|
|
onUpdate(
|
|
safeCategories.filter((_, index) => index !== rowIndex),
|
|
safeSeries.map((item) => ({
|
|
...item,
|
|
values: item.values.filter((_, index) => index !== rowIndex),
|
|
})),
|
|
colors,
|
|
);
|
|
setSelectedRowIndex((current) =>
|
|
Math.max(0, Math.min(current, safeCategories.length - 2)),
|
|
);
|
|
clearValueDrafts();
|
|
};
|
|
const addSeries = () => {
|
|
const nextSeries = [
|
|
...safeSeries,
|
|
{
|
|
name: `Series ${safeSeries.length + 1}`,
|
|
values: normalizeValues([], safeCategories.length),
|
|
},
|
|
];
|
|
onUpdate(
|
|
safeCategories,
|
|
nextSeries,
|
|
extendChartColors(colors, nextSeries.length, fallbackColor),
|
|
);
|
|
clearValueDrafts();
|
|
};
|
|
const deleteSeries = (seriesIndex: number) => {
|
|
if (safeSeries.length <= 1) return;
|
|
onUpdate(
|
|
safeCategories,
|
|
safeSeries.filter((_, index) => index !== seriesIndex),
|
|
colors,
|
|
);
|
|
setSeriesMenu(null);
|
|
setColumnMenuOpen(false);
|
|
clearValueDrafts();
|
|
};
|
|
const moveSeries = (seriesIndex: number, direction: -1 | 1) => {
|
|
const targetIndex = seriesIndex + direction;
|
|
if (targetIndex < 0 || targetIndex >= safeSeries.length) return;
|
|
const nextSeries = [...safeSeries];
|
|
const [movedSeries] = nextSeries.splice(seriesIndex, 1);
|
|
if (!movedSeries) return;
|
|
nextSeries.splice(targetIndex, 0, movedSeries);
|
|
const nextColors = [...colors];
|
|
if (nextColors[seriesIndex] != null && nextColors[targetIndex] != null) {
|
|
const [movedColor] = nextColors.splice(seriesIndex, 1);
|
|
if (movedColor != null) nextColors.splice(targetIndex, 0, movedColor);
|
|
}
|
|
onUpdate(safeCategories, nextSeries, nextColors);
|
|
setSeriesMenu(null);
|
|
setColumnMenuOpen(false);
|
|
clearValueDrafts();
|
|
};
|
|
const showSeriesMenu = (seriesIndex: number, node: HTMLElement) => {
|
|
const root = tableRootRef.current;
|
|
if (!root) return;
|
|
const rootRect = root.getBoundingClientRect();
|
|
const columnRect = node.getBoundingClientRect();
|
|
const desiredLeft = columnRect.left + columnRect.width / 2 - rootRect.left;
|
|
if (seriesMenu?.index !== seriesIndex) setColumnMenuOpen(false);
|
|
setSeriesMenu({
|
|
index: seriesIndex,
|
|
left: Math.max(116, Math.min(desiredLeft, rootRect.width - 116)),
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={tableRootRef}
|
|
className="relative w-full px-4 pb-4 pt-10"
|
|
onMouseLeave={() => {
|
|
setSeriesMenu(null);
|
|
setColumnMenuOpen(false);
|
|
}}
|
|
>
|
|
{selectedSeries && seriesMenu && selectedSeriesIndex != null ? (
|
|
<div
|
|
className="absolute top-0 z-20 flex h-8 w-[168px] -translate-x-1/2 items-center overflow-hidden rounded-xl border border-[#E6E6EA] bg-white pl-3 pr-1 text-[#191919] shadow-[0_3px_12px_rgba(16,24,40,0.10)]"
|
|
style={{ left: seriesMenu.left }}
|
|
>
|
|
<input
|
|
className="h-full min-w-0 flex-1 truncate bg-transparent text-[12px] font-medium outline-none"
|
|
maxLength={CHART_TEXT_MAX_LENGTH}
|
|
spellCheck={false}
|
|
value={selectedSeries.name}
|
|
onChange={(event) =>
|
|
updateSeriesName(selectedSeriesIndex, event.target.value)
|
|
}
|
|
/>
|
|
<button
|
|
type="button"
|
|
aria-label="Delete selected series"
|
|
className="grid h-7 w-7 shrink-0 place-items-center border-l border-[#ECECF1] text-[#191919] disabled:cursor-not-allowed disabled:opacity-30"
|
|
disabled={safeSeries.length <= 1}
|
|
onClick={() => deleteSeries(selectedSeriesIndex)}
|
|
>
|
|
<Trash2 size={13} strokeWidth={2} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-expanded={columnMenuOpen}
|
|
aria-label="More column actions"
|
|
className="grid h-7 w-5 shrink-0 place-items-center text-[#191919]"
|
|
onClick={() => setColumnMenuOpen((current) => !current)}
|
|
>
|
|
<MoreVertical size={13} strokeWidth={2.3} />
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
|
|
{columnMenuOpen && seriesMenu && selectedSeriesIndex != null ? (
|
|
<div
|
|
className="absolute top-9 z-30 w-[232px] -translate-x-1/2 overflow-hidden rounded-2xl border border-[#E6E6EA] bg-white py-2 shadow-[0_14px_36px_rgba(16,24,40,0.18)]"
|
|
style={{ left: seriesMenu.left }}
|
|
>
|
|
<ColumnMenuItem
|
|
icon={<Trash2 size={16} />}
|
|
label="Delete Row"
|
|
disabled={safeCategories.length <= 1}
|
|
onClick={() => {
|
|
deleteRow(selectedRowIndex);
|
|
setColumnMenuOpen(false);
|
|
}}
|
|
/>
|
|
<ColumnMenuItem
|
|
icon={<Trash2 size={16} />}
|
|
label="Delete Column"
|
|
disabled={safeSeries.length <= 1}
|
|
onClick={() => deleteSeries(selectedSeriesIndex)}
|
|
/>
|
|
<ColumnMenuItem
|
|
icon={<Plus size={16} />}
|
|
label="Add Row"
|
|
onClick={() => {
|
|
addRow();
|
|
setColumnMenuOpen(false);
|
|
}}
|
|
/>
|
|
<ColumnMenuItem
|
|
icon={<Plus size={16} />}
|
|
label="Add Column"
|
|
disabled={!allowMultipleSeries}
|
|
onClick={() => {
|
|
addSeries();
|
|
setColumnMenuOpen(false);
|
|
}}
|
|
/>
|
|
<div className="my-2 h-px bg-[#ECECF1]" />
|
|
<ColumnMenuItem
|
|
icon={<ChevronRight size={16} />}
|
|
label="Move Column Right"
|
|
disabled={selectedSeriesIndex >= safeSeries.length - 1}
|
|
onClick={() => moveSeries(selectedSeriesIndex, 1)}
|
|
/>
|
|
<ColumnMenuItem
|
|
icon={<ChevronLeft size={16} />}
|
|
label="Move Column Left"
|
|
disabled={selectedSeriesIndex <= 0}
|
|
onClick={() => moveSeries(selectedSeriesIndex, -1)}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="relative rounded-b-lg bg-[#F3F4F6] pb-9 pr-9">
|
|
<div className="relative z-10 max-h-[390px] overflow-auto">
|
|
<div
|
|
className="min-w-full text-[12px] text-[#191919]"
|
|
style={{
|
|
minWidth: minimumTableWidth,
|
|
width: "100%",
|
|
}}
|
|
>
|
|
<div
|
|
className="sticky top-0 z-20 grid"
|
|
style={{ gridTemplateColumns: dataGridTemplateColumns }}
|
|
>
|
|
<div className="sticky left-0 z-20 border-b border-r border-[#E8E8EC] bg-[#F6F7F8]" />
|
|
{safeSeries.map((item, seriesIndex) => (
|
|
<div
|
|
key={`${chartPath}-series-${seriesIndex}`}
|
|
className="border-b border-r border-[#E8E8EC] bg-[#F6F7F8] px-3 py-2 text-center text-[12px] font-medium"
|
|
onMouseEnter={(event) =>
|
|
showSeriesMenu(seriesIndex, event.currentTarget)
|
|
}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="w-full truncate text-center outline-none"
|
|
title={item.name}
|
|
onFocus={(event) =>
|
|
showSeriesMenu(seriesIndex, event.currentTarget)
|
|
}
|
|
>
|
|
{item.name}
|
|
</button>
|
|
</div>
|
|
))}
|
|
<div className="sticky right-0 z-20 border-b border-[#E8E8EC] bg-[#F3F4F6]" />
|
|
</div>
|
|
|
|
{safeCategories.map((category, rowIndex) => (
|
|
<div
|
|
key={`${chartPath}-row-${rowIndex}`}
|
|
className="grid"
|
|
style={{ gridTemplateColumns: dataGridTemplateColumns }}
|
|
onMouseEnter={() => setSelectedRowIndex(rowIndex)}
|
|
>
|
|
<div className="sticky left-0 z-10 border-b border-r border-[#E8E8EC] bg-[#F7F8FA] px-3 py-1.5">
|
|
<span className="absolute -left-6 top-1/2 flex -translate-y-1/2 items-center justify-center">
|
|
<GripVertical size={13} strokeWidth={2.1} />
|
|
</span>
|
|
<input
|
|
className="h-7 w-full truncate rounded-md border border-transparent bg-transparent px-0 text-[12px] font-medium outline-none focus:border-[#7C51F8] focus:bg-white focus:px-2"
|
|
maxLength={CHART_TEXT_MAX_LENGTH}
|
|
spellCheck={false}
|
|
value={category}
|
|
title={category}
|
|
onFocus={() => setSelectedRowIndex(rowIndex)}
|
|
onChange={(event) =>
|
|
updateCategory(rowIndex, event.target.value)
|
|
}
|
|
/>
|
|
</div>
|
|
{safeSeries.map((item, seriesIndex) => {
|
|
const draftKey = `${seriesIndex}:${rowIndex}`;
|
|
const hasDraft = Object.prototype.hasOwnProperty.call(
|
|
valueDrafts,
|
|
draftKey,
|
|
);
|
|
const displayValue = hasDraft
|
|
? valueDrafts[draftKey]
|
|
: item.values[rowIndex] ?? "";
|
|
|
|
return (
|
|
<div
|
|
key={`${chartPath}-cell-${rowIndex}-${seriesIndex}`}
|
|
className="border-b border-r border-[#E8E8EC] bg-white px-3 py-1.5"
|
|
>
|
|
<input
|
|
className="h-7 w-full rounded-md border border-transparent bg-transparent px-0 text-[12px] outline-none focus:border-[#7C51F8] focus:bg-[#FAFAFF] focus:px-2"
|
|
type="text"
|
|
inputMode={numericInputMode(chartValueInputOptions)}
|
|
value={displayValue}
|
|
onFocus={() => setSelectedRowIndex(rowIndex)}
|
|
onBlur={() => {
|
|
const draft = valueDrafts[draftKey];
|
|
if (!isCompleteNumericInput(draft)) return;
|
|
setValueDrafts((current) => {
|
|
const next = { ...current };
|
|
delete next[draftKey];
|
|
return next;
|
|
});
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (
|
|
preventInvalidNumberInput(
|
|
event,
|
|
chartValueInputOptions,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
if (
|
|
event.key === "ArrowUp" ||
|
|
event.key === "ArrowDown"
|
|
) {
|
|
event.preventDefault();
|
|
const direction =
|
|
event.key === "ArrowUp" ? 1 : -1;
|
|
const draft = valueDrafts[draftKey];
|
|
const currentValue = isCompleteNumericInput(draft)
|
|
? Number(draft)
|
|
: item.values[rowIndex] ?? 0;
|
|
const nextValue = String(currentValue + direction);
|
|
updateValueDraft(
|
|
seriesIndex,
|
|
rowIndex,
|
|
nextValue,
|
|
);
|
|
}
|
|
}}
|
|
onChange={(event) =>
|
|
updateValueDraft(
|
|
seriesIndex,
|
|
rowIndex,
|
|
sanitizeNumericInput(
|
|
event.target.value,
|
|
chartValueInputOptions,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
<div className="sticky right-0 grid place-items-center border-b border-[#E8E8EC] bg-[#F3F4F6] px-1">
|
|
<button
|
|
type="button"
|
|
aria-label={`Delete ${category || `row ${rowIndex + 1}`}`}
|
|
className="grid h-7 w-7 place-items-center rounded-md text-[#8E8E98] transition hover:bg-white hover:text-[#191919] disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-[#8E8E98]"
|
|
disabled={safeCategories.length <= 1}
|
|
onClick={() => deleteRow(rowIndex)}
|
|
>
|
|
<Trash2 size={12} strokeWidth={2.1} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="absolute bottom-0 right-0 top-0 z-0 w-9 rounded-r-lg bg-[#EDEEF0]" />
|
|
<div className="absolute bottom-0 left-0 right-9 z-0 h-9 rounded-b-lg bg-[#EDEEF0]" />
|
|
{allowMultipleSeries ? (
|
|
<button
|
|
type="button"
|
|
className="absolute right-0 top-1/2 z-10 grid h-8 w-9 -translate-y-1/2 place-items-center text-[#191919]"
|
|
onClick={addSeries}
|
|
>
|
|
<Plus size={13} strokeWidth={2.2} />
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="absolute bottom-0 left-1/2 z-10 grid h-9 w-16 -translate-x-1/2 place-items-center text-[#191919]"
|
|
onClick={addRow}
|
|
>
|
|
<Plus size={12} strokeWidth={2.2} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function isCompleteNumericInput(value: string | undefined) {
|
|
if (!value || value === "-" || value === "." || value === "-.") {
|
|
return false;
|
|
}
|
|
return Number.isFinite(Number(value));
|
|
}
|
|
|
|
function ColumnMenuItem({
|
|
disabled = false,
|
|
icon,
|
|
label,
|
|
onClick,
|
|
}: {
|
|
disabled?: boolean;
|
|
icon: ReactNode;
|
|
label: string;
|
|
onClick?: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="flex h-10 w-full items-center gap-3 px-4 text-left text-[12px] font-medium text-[#191919] transition hover:bg-[#F7F7FA] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent"
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
>
|
|
<span className="grid h-5 w-5 shrink-0 place-items-center">{icon}</span>
|
|
<span>{label}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function safeCategoriesForChart(element: ChartElement) {
|
|
const categories = resolvedChartCategories(element);
|
|
return (categories.length > 0 ? categories : ["Item 1"]).map(limitChartText);
|
|
}
|
|
|
|
function normalizedSeries(element: ChartElement, categoryLength: number) {
|
|
const length = Math.max(1, categoryLength);
|
|
if (element.series?.length) {
|
|
return element.series
|
|
.slice(0, chartSupportsMultipleSeries(element.chart_type) ? 12 : 1)
|
|
.map((series) => ({
|
|
...series,
|
|
name: limitChartText(series.name ?? ""),
|
|
values: normalizeValues(series.values, length),
|
|
}));
|
|
}
|
|
return [
|
|
{
|
|
name: limitChartText(element.title ?? "Series 1"),
|
|
values: normalizeValues([], length),
|
|
},
|
|
];
|
|
}
|
|
|
|
function normalizeValues(values: number[], length: number) {
|
|
const normalized = values.slice(0, length);
|
|
while (normalized.length < length) normalized.push(0);
|
|
return normalized;
|
|
}
|
|
|
|
function clearChartData(chart: ChartElement): ChartElement {
|
|
const chartData = chart.data ?? [];
|
|
const sourceCategories =
|
|
chart.categories && chart.categories.length > 0
|
|
? chart.categories
|
|
: chartData.length > 0
|
|
? chartData.map((datum, index) => datum.label || `Item ${index + 1}`)
|
|
: resolvedChartCategories(chart);
|
|
const sourceSeries =
|
|
chart.series && chart.series.length > 0
|
|
? chart.series
|
|
: [
|
|
{
|
|
name: chart.title ?? "Series 1",
|
|
values: chartData.map((datum) => datum.value),
|
|
},
|
|
];
|
|
const valueLength = Math.min(
|
|
24,
|
|
Math.max(
|
|
1,
|
|
sourceCategories.length,
|
|
chartData.length,
|
|
...sourceSeries.map((item) => item.values.length),
|
|
),
|
|
);
|
|
const categories = Array.from(
|
|
{ length: valueLength },
|
|
(_, index) => limitChartText(sourceCategories[index] ?? `Item ${index + 1}`),
|
|
);
|
|
const series = sourceSeries.map((item) => ({
|
|
...item,
|
|
name: limitChartText(item.name ?? ""),
|
|
values: Array.from({ length: valueLength }, () => 0),
|
|
}));
|
|
const fallbackColors =
|
|
chart.colors && chart.colors.length > 0
|
|
? chart.colors
|
|
: [chart.color ?? DEFAULT_CHART_COLORS[0]];
|
|
const fallbackData = chartDataFromSeriesWithColors(
|
|
categories,
|
|
series,
|
|
fallbackColors,
|
|
chartColorTargetMode({ ...chart, categories, series }) === "category",
|
|
);
|
|
const data =
|
|
chartData.length > 0
|
|
? chartData.map((datum) => ({ ...datum, value: 0 }))
|
|
: fallbackData;
|
|
|
|
return {
|
|
...chart,
|
|
categories,
|
|
series,
|
|
data,
|
|
};
|
|
}
|
|
|
|
function sanitizeChartTextFields(chart: ChartElement): ChartElement {
|
|
return {
|
|
...chart,
|
|
title: chart.title ? limitChartText(chart.title) : chart.title,
|
|
x_axis_title: chart.x_axis_title
|
|
? limitChartText(chart.x_axis_title)
|
|
: chart.x_axis_title,
|
|
y_axis_title: chart.y_axis_title
|
|
? limitChartText(chart.y_axis_title)
|
|
: chart.y_axis_title,
|
|
categories: chart.categories?.map(limitChartText) ?? chart.categories,
|
|
series:
|
|
chart.series?.map((series) => ({
|
|
...series,
|
|
name: limitChartText(series.name ?? ""),
|
|
})) ?? chart.series,
|
|
data: (chart.data ?? []).map((datum) => ({
|
|
...datum,
|
|
label: limitChartText(datum.label ?? ""),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function chartPreviewElement(chart: ChartElement): ChartElement {
|
|
return {
|
|
...sanitizeChartTextFields(chart),
|
|
opacity: 1,
|
|
position: { x: 45, y: 45 },
|
|
rotation: 0,
|
|
size: {
|
|
width: EDITOR_STAGE_WIDTH - 90,
|
|
height: EDITOR_STAGE_HEIGHT - 90,
|
|
},
|
|
};
|
|
}
|