返回 presentation-ai
use-chart-editor.ts
1 import { useCallback, useMemo, useState } from "react";
2
3 import {
4 buildFields,
5 createEmptyRow,
6 getDefaultSeriesChartType,
7 getInitialEditorState,
8 rowsToChartData,
9 type ChartDataField,
10 type ChartDataRow,
11 } from "./schemas";
12 import {
13 type ChartDataMode,
14 type ChartDataType,
15 type SeriesChartType,
16 } from "./types";
17
18 function toNumber(value: string | number): number {
19 if (typeof value === "number") return Number.isFinite(value) ? value : 0;
20 const parsed = Number(value);
21 return Number.isFinite(parsed) ? parsed : 0;
22 }
23
24 function toCellValue(
25 field: ChartDataField,
26 value: string | number,
27 ): string | number {
28 return field.type === "number" ? toNumber(value) : String(value);
29 }
30
31 export function useChartEditor(
32 initialData: ChartDataType,
33 chartType: ChartDataMode,
34 initialSeriesChartTypes?: Record<string, SeriesChartType>,
35 ) {
36 const initialState = useMemo(
37 () => getInitialEditorState(initialData, chartType),
38 [chartType, initialData],
39 );
40 const [rows, setRows] = useState<ChartDataRow[]>(initialState.rows);
41 const [labelKey] = useState(initialState.labelKey);
42 const [seriesNames, setSeriesNames] = useState<string[]>(
43 initialState.seriesNames,
44 );
45 const [seriesChartTypes, setSeriesChartTypes] = useState<
46 Record<string, SeriesChartType>
47 >(() => {
48 if (
49 initialSeriesChartTypes &&
50 Object.keys(initialSeriesChartTypes).length > 0
51 ) {
52 return initialSeriesChartTypes;
53 }
54
55 return initialState.seriesNames.reduce<Record<string, SeriesChartType>>(
56 (types, seriesName, index) => {
57 types[seriesName] = getDefaultSeriesChartType(index);
58 return types;
59 },
60 {},
61 );
62 });
63 const [focusedCell, setFocusedCell] = useState<{
64 row: number;
65 col: number;
66 } | null>(null);
67
68 const hasZColumn =
69 initialState.schema.mode === "xyz" ||
70 rows.some((row) => typeof row.z === "number");
71
72 const fields = useMemo(
73 () => buildFields(initialState.schema, labelKey, seriesNames, hasZColumn),
74 [hasZColumn, initialState.schema, labelKey, seriesNames],
75 );
76
77 const data = useMemo(() => rowsToChartData(rows), [rows]);
78
79 const updateCell = useCallback(
80 (rowIndex: number, fieldKey: string, value: string | number) => {
81 const field = fields.find((candidate) => candidate.key === fieldKey);
82 if (!field) return;
83
84 setRows((currentRows) =>
85 currentRows.map((row, index) =>
86 index === rowIndex
87 ? { ...row, [fieldKey]: toCellValue(field, value) }
88 : row,
89 ),
90 );
91 },
92 [fields],
93 );
94
95 const addRow = useCallback(() => {
96 setRows((currentRows) => [...currentRows, createEmptyRow(fields)]);
97 }, [fields]);
98
99 const removeRow = useCallback((index: number) => {
100 setRows((currentRows) =>
101 currentRows.length <= 1
102 ? currentRows
103 : currentRows.filter((_, rowIndex) => rowIndex !== index),
104 );
105 }, []);
106
107 const addSeries = useCallback(() => {
108 const newSeriesName = `Series ${seriesNames.length + 1}`;
109 const nextSeriesNames = [...seriesNames, newSeriesName];
110 setSeriesNames(nextSeriesNames);
111 setRows((currentRows) =>
112 currentRows.map((row) => ({
113 ...row,
114 [newSeriesName]: 0,
115 })),
116 );
117 setSeriesChartTypes((currentTypes) => ({
118 ...currentTypes,
119 [newSeriesName]: getDefaultSeriesChartType(seriesNames.length),
120 }));
121 }, [seriesNames]);
122
123 const removeSeries = useCallback(
124 (seriesName: string) => {
125 if (seriesNames.length <= 1) return;
126
127 setSeriesNames((currentNames) =>
128 currentNames.filter((name) => name !== seriesName),
129 );
130 setRows((currentRows) =>
131 currentRows.map((row) => {
132 const { [seriesName]: _removed, ...rest } = row;
133 return rest;
134 }),
135 );
136 setSeriesChartTypes((currentTypes) => {
137 const { [seriesName]: _removed, ...rest } = currentTypes;
138 return rest;
139 });
140 },
141 [seriesNames.length],
142 );
143
144 const renameSeries = useCallback(
145 (oldName: string, newName: string) => {
146 const trimmedName = newName.trim();
147 if (!trimmedName || trimmedName === oldName) return;
148 if (seriesNames.includes(trimmedName)) return;
149
150 setSeriesNames((currentNames) =>
151 currentNames.map((name) => (name === oldName ? trimmedName : name)),
152 );
153 setRows((currentRows) =>
154 currentRows.map((row) => {
155 const { [oldName]: value, ...rest } = row;
156 return { ...rest, [trimmedName]: value ?? 0 };
157 }),
158 );
159 setSeriesChartTypes((currentTypes) => {
160 const { [oldName]: chartTypeForSeries, ...rest } = currentTypes;
161 return {
162 ...rest,
163 [trimmedName]: chartTypeForSeries ?? "bar",
164 };
165 });
166 },
167 [seriesNames],
168 );
169
170 const updateSeriesChartType = useCallback(
171 (seriesName: string, newChartType: SeriesChartType) => {
172 setSeriesChartTypes((currentTypes) => ({
173 ...currentTypes,
174 [seriesName]: newChartType,
175 }));
176 },
177 [],
178 );
179
180 const addZColumn = useCallback(() => {
181 setRows((currentRows) =>
182 currentRows.map((row) => ({
183 ...row,
184 z: typeof row.z === "number" ? row.z : 10,
185 })),
186 );
187 }, []);
188
189 const removeZColumn = useCallback(() => {
190 if (initialState.schema.mode === "xyz") return;
191
192 setRows((currentRows) =>
193 currentRows.map((row) => {
194 const { z: _removed, ...rest } = row;
195 return rest;
196 }),
197 );
198 }, [initialState.schema.mode]);
199
200 return {
201 data: data as ChartDataType,
202 rows,
203 fields,
204 schema: initialState.schema,
205 seriesNames,
206 seriesChartTypes,
207 focusedCell,
208 setFocusedCell,
209 updateCell,
210 addRow,
211 removeRow,
212 addSeries,
213 removeSeries,
214 renameSeries,
215 updateSeriesChartType,
216 addZColumn,
217 removeZColumn,
218 hasZColumn,
219 labelKey,
220 };
221 }
222
222 lines TYPESCRIPT