| 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 |