返回 oh-my-ppt
insert-charts.ts
根目录 / src / renderer / src / components / session-detail / workspace / insert-charts.ts
1 import { escapeHtmlText } from '@renderer/lib/utils'
2
3 export type InsertChartType = 'bar' | 'line' | 'pie' | 'doughnut' | 'radar'
4
5 export interface InsertChartSeries {
6 name: string
7 values: number[]
8 }
9
10 export interface InsertChartData {
11 type: InsertChartType
12 title: string
13 labels: string[]
14 values: number[]
15 series?: InsertChartSeries[]
16 primaryColor?: string
17 accentColor?: string
18 textColor?: string
19 smooth?: boolean
20 horizontal?: boolean
21 stacked?: boolean
22 areaFill?: boolean
23 showPoints?: boolean
24 showLegend?: boolean
25 doughnutCutout?: number
26 radarFill?: boolean
27 }
28
29 export interface NormalizedChartData extends InsertChartData {
30 values: number[]
31 series: InsertChartSeries[]
32 primaryColor: string
33 accentColor: string
34 textColor: string
35 smooth: boolean
36 horizontal: boolean
37 stacked: boolean
38 areaFill: boolean
39 showPoints: boolean
40 showLegend: boolean
41 doughnutCutout: number
42 radarFill: boolean
43 }
44
45 export interface InsertChartLayout {
46 blockId: string
47 left: number
48 top: number
49 width: number
50 height: number
51 zIndex: number
52 }
53
54 export const CHART_TYPE_LIST: Array<{ type: InsertChartType; labelKey: string }> = [
55 { type: 'bar', labelKey: 'editMode.chartBar' },
56 { type: 'line', labelKey: 'editMode.chartLine' },
57 { type: 'pie', labelKey: 'editMode.chartPie' },
58 { type: 'doughnut', labelKey: 'editMode.chartDoughnut' },
59 { type: 'radar', labelKey: 'editMode.chartRadar' }
60 ]
61
62 export const DEFAULT_CHART_DATA: Record<InsertChartType, InsertChartData> = {
63 bar: {
64 type: 'bar',
65 title: 'Quarterly Revenue',
66 labels: ['Q1', 'Q2', 'Q3', 'Q4'],
67 values: [24, 36, 31, 48]
68 },
69 line: {
70 type: 'line',
71 title: 'Growth Trend',
72 labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May'],
73 values: [12, 19, 16, 28, 34]
74 },
75 pie: {
76 type: 'pie',
77 title: 'Market Share',
78 labels: ['A', 'B', 'C', 'D'],
79 values: [42, 28, 18, 12]
80 },
81 doughnut: {
82 type: 'doughnut',
83 title: 'Channel Mix',
84 labels: ['Online', 'Retail', 'Partner', 'Other'],
85 values: [45, 25, 20, 10]
86 },
87 radar: {
88 type: 'radar',
89 title: 'Capability Score',
90 labels: ['Speed', 'Quality', 'Cost', 'Reach', 'Trust'],
91 values: [82, 74, 68, 79, 88]
92 }
93 }
94
95 const DEFAULT_CHART_PRIMARY_COLOR = '#5d6b4d'
96 const DEFAULT_CHART_ACCENT_COLOR = '#8fbc8f'
97 const DEFAULT_CHART_TEXT_COLOR = '#2f3b28'
98 const CHART_COLORS = [
99 DEFAULT_CHART_PRIMARY_COLOR,
100 DEFAULT_CHART_ACCENT_COLOR,
101 '#d9a26f',
102 '#5b8bb2',
103 '#c86f6f',
104 '#7b6bb7',
105 '#6aa6a3',
106 '#b98c58'
107 ]
108 const MAX_CHART_LABELS = 200
109 const MAX_CHART_SERIES = 8
110
111 const BLOCK_ID_RE = /^select-arcsin1-[A-Za-z0-9_-]{4,32}$/
112
113 function assertBlockId(blockId: string): void {
114 if (!BLOCK_ID_RE.test(blockId)) {
115 throw new Error(`buildChartElementHtml: invalid blockId "${blockId}"`)
116 }
117 }
118
119 export function normalizeChartData(data: InsertChartData): NormalizedChartData {
120 const type = CHART_TYPE_LIST.some((item) => item.type === data.type) ? data.type : 'bar'
121 const labels = data.labels
122 .map((item) => String(item || '').trim())
123 .filter(Boolean)
124 .slice(0, MAX_CHART_LABELS)
125 const safeLabels = labels.length > 0 ? labels : DEFAULT_CHART_DATA[type].labels
126 const fallbackValues = DEFAULT_CHART_DATA[type].values
127 const normalizeValues = (values: unknown[] | undefined): number[] =>
128 safeLabels.map((_, index) => {
129 const value = Number(values?.[index])
130 if (Number.isFinite(value)) return value
131 return 0
132 })
133 const values = normalizeValues(data.values)
134 const safeValues = values.map((item, index) => {
135 const value = Number(item)
136 if (Number.isFinite(value)) return value
137 return Number.isFinite(fallbackValues[index]) ? fallbackValues[index] : 0
138 })
139 const rawSeries = Array.isArray(data.series) ? data.series : []
140 const series = rawSeries
141 .map((item, index) => ({
142 name: String(item?.name || `Series ${index + 1}`).trim().slice(0, 80),
143 values: normalizeValues(Array.isArray(item?.values) ? item.values : [])
144 }))
145 .filter((item) => item.name)
146 .slice(0, MAX_CHART_SERIES)
147 const safeSeries =
148 series.length > 0
149 ? series
150 : [
151 {
152 name: String(data.title || 'Value').trim().slice(0, 80) || 'Value',
153 values: safeValues
154 }
155 ]
156 return {
157 type,
158 title: String(data.title ?? DEFAULT_CHART_DATA[type].title).trim().slice(0, 120),
159 labels: safeLabels,
160 values: safeSeries[0]?.values ?? safeValues,
161 series: safeSeries,
162 primaryColor: normalizeHexColor(data.primaryColor, DEFAULT_CHART_PRIMARY_COLOR),
163 accentColor: normalizeHexColor(data.accentColor, DEFAULT_CHART_ACCENT_COLOR),
164 textColor: normalizeHexColor(data.textColor, DEFAULT_CHART_TEXT_COLOR),
165 smooth: data.smooth !== false,
166 horizontal: data.horizontal === true,
167 stacked: data.stacked === true,
168 areaFill: data.areaFill !== false,
169 showPoints: data.showPoints !== false,
170 showLegend:
171 data.showLegend ??
172 (type === 'pie' || type === 'doughnut' || type === 'radar' || safeSeries.length > 1),
173 doughnutCutout: normalizePercent(data.doughnutCutout, 58),
174 radarFill: data.radarFill !== false
175 }
176 }
177
178 function normalizeHexColor(value: string | undefined, fallback: string): string {
179 const text = String(value || '').trim()
180 if (/^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i.test(text)) return text
181 return fallback
182 }
183
184 function hexToRgba(hex: string, alpha: number): string {
185 const normalized = normalizeHexColor(hex, DEFAULT_CHART_PRIMARY_COLOR)
186 const raw =
187 normalized.length === 4
188 ? normalized
189 .slice(1)
190 .split('')
191 .map((item) => item + item)
192 .join('')
193 : normalized.slice(1)
194 const value = Number.parseInt(raw, 16)
195 const r = (value >> 16) & 255
196 const g = (value >> 8) & 255
197 const b = value & 255
198 return `rgba(${r}, ${g}, ${b}, ${alpha})`
199 }
200
201 function normalizePercent(value: number | undefined, fallback: number): number {
202 const parsed = Number(value)
203 if (!Number.isFinite(parsed)) return fallback
204 return Math.max(0, Math.min(85, Math.round(parsed)))
205 }
206
207 function buildCircularPalette(primaryColor: string, accentColor: string, count: number): string[] {
208 const palette = [
209 primaryColor,
210 accentColor,
211 ...CHART_COLORS.filter((item) => item !== primaryColor && item !== accentColor)
212 ]
213 return Array.from({ length: count }, (_, index) => palette[index % palette.length])
214 }
215
216 function getSeriesColor(index: number, primaryColor: string, accentColor: string): string {
217 const palette = [
218 primaryColor,
219 accentColor,
220 ...CHART_COLORS.filter((item) => item !== primaryColor && item !== accentColor)
221 ]
222 return palette[index % palette.length]
223 }
224
225 export function buildChartJsConfig(data: InsertChartData): Record<string, unknown> {
226 const chart = normalizeChartData(data)
227 const isPieLike = chart.type === 'pie' || chart.type === 'doughnut'
228 const isRadar = chart.type === 'radar'
229 const showsLegend = isPieLike || isRadar
230 const primaryColor = chart.primaryColor || DEFAULT_CHART_PRIMARY_COLOR
231 const accentColor = chart.accentColor || DEFAULT_CHART_ACCENT_COLOR
232 const textColor = chart.textColor || DEFAULT_CHART_TEXT_COLOR
233 const smooth = chart.smooth !== false
234 const horizontal = chart.type === 'bar' && chart.horizontal === true
235 const stacked = chart.type === 'bar' && chart.stacked === true
236 const areaFill = chart.type === 'line' && chart.areaFill !== false
237 const showPoints =
238 (chart.type === 'line' || chart.type === 'radar') && chart.showPoints !== false
239 const radarFill = chart.type === 'radar' && chart.radarFill !== false
240 const doughnutCutout = normalizePercent(chart.doughnutCutout, 58)
241 const showLegend = chart.showLegend ?? showsLegend
242 const buildDataset = (series: InsertChartSeries, index: number) => {
243 const color = getSeriesColor(index, primaryColor, accentColor)
244 return {
245 label: series.name || chart.title || `Series ${index + 1}`,
246 data: series.values,
247 borderColor: color,
248 backgroundColor: isPieLike
249 ? buildCircularPalette(primaryColor, accentColor, chart.labels.length)
250 : chart.type === 'line'
251 ? areaFill
252 ? hexToRgba(color, 0.2)
253 : hexToRgba(color, 0.08)
254 : isRadar
255 ? radarFill
256 ? hexToRgba(color, 0.22)
257 : hexToRgba(color, 0.06)
258 : color,
259 borderWidth: 2,
260 fill: chart.type === 'line' ? areaFill : isRadar ? radarFill : undefined,
261 tension: chart.type === 'line' && smooth ? 0.34 : undefined,
262 pointRadius:
263 chart.type === 'line' || chart.type === 'radar' ? (showPoints ? 4 : 0) : undefined,
264 pointHoverRadius: chart.type === 'line' || chart.type === 'radar' ? 5 : undefined
265 }
266 }
267 const datasets = isPieLike
268 ? [buildDataset(chart.series[0], 0)]
269 : chart.series.map((series, index) => buildDataset(series, index))
270 return {
271 type: chart.type,
272 data: {
273 labels: chart.labels,
274 datasets
275 },
276 options: {
277 responsive: true,
278 maintainAspectRatio: false,
279 animation: false,
280 indexAxis: horizontal ? 'y' : undefined,
281 cutout: chart.type === 'doughnut' ? `${doughnutCutout}%` : undefined,
282 plugins: {
283 legend: {
284 display: showLegend,
285 labels: { color: textColor }
286 },
287 pptEditorColors: {
288 primaryColor,
289 accentColor,
290 textColor,
291 smooth,
292 horizontal,
293 stacked,
294 areaFill,
295 showPoints,
296 showLegend,
297 doughnutCutout,
298 radarFill
299 },
300 title: {
301 display: Boolean(chart.title),
302 text: chart.title,
303 color: textColor,
304 font: { size: 18, weight: '700' }
305 }
306 },
307 scales: isPieLike
308 ? undefined
309 : isRadar
310 ? {
311 r: {
312 beginAtZero: true,
313 grid: { color: hexToRgba(primaryColor, 0.14) },
314 angleLines: { color: hexToRgba(primaryColor, 0.16) },
315 ticks: { color: textColor, backdropColor: 'transparent' },
316 pointLabels: { color: textColor }
317 }
318 }
319 : {
320 x: {
321 stacked,
322 grid: { color: hexToRgba(primaryColor, 0.12) },
323 ticks: { color: textColor }
324 },
325 y: {
326 beginAtZero: true,
327 stacked,
328 grid: { color: hexToRgba(primaryColor, 0.12) },
329 ticks: { color: textColor }
330 }
331 }
332 }
333 }
334 }
335
336 function buildChartRenderScript(blockId: string): string {
337 return `(function(){function render(){var root=document.querySelector('[data-block-id="${blockId}"]');var canvas=root&&root.querySelector('canvas');var holder=root&&root.querySelector('script[data-ppt-chart-config="1"]');if(!root||!canvas||!holder||!window.PPT||typeof window.PPT.createChart!=="function")return;try{window.PPT.createChart(canvas,JSON.parse(holder.textContent||"{}"));}catch(error){console.error("[ppt-chart]",error);}}if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",render,{once:true});}else{render();}})();`
338 }
339
340 function escapeScriptText(value: string): string {
341 return value.replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--')
342 }
343
344 export function buildChartElementHtml(layout: InsertChartLayout, data: InsertChartData): string {
345 assertBlockId(layout.blockId)
346 const chart = normalizeChartData(data)
347 const config = buildChartJsConfig(chart)
348 const style = [
349 'position:absolute',
350 `left:${layout.left}px`,
351 `top:${layout.top}px`,
352 `width:${layout.width}px`,
353 `height:${layout.height}px`,
354 `z-index:${layout.zIndex}`,
355 'box-sizing:border-box',
356 'padding:12px',
357 'border-radius:8px',
358 'background:#fffdf8',
359 'border:1px solid rgba(216,204,181,0.72)',
360 'box-shadow:0 8px 22px rgba(74,59,42,0.08)'
361 ].join('; ')
362 return [
363 `<div data-block-id="${layout.blockId}" data-ppt-edit-kind="chart" data-ppt-chart-editable="simple" style="${escapeHtmlText(style)}">`,
364 '<canvas style="display:block;width:100%;height:100%;"></canvas>',
365 `<script type="application/json" data-ppt-chart-config="1">${escapeScriptText(JSON.stringify(config))}</script>`,
366 `<script data-ppt-generated-chart-script="1">${buildChartRenderScript(layout.blockId)}</script>`,
367 '</div>'
368 ].join('')
369 }
370
370 lines TYPESCRIPT