1/** Parses chart config and resolves per-type theme options. */ 2 3class ChartConfig { 4 5 static CHART_DEFAULT_SUBTITLE = 'Sample data'; 6 7 /** @returns {object} From server ChartPresentationDefaults (injected in _Layout or fetched from API). */ 8 static _presentation() { 9 if (!ChartConfig._presentationCache) { 10 ChartConfig._presentationCache = (typeof window !== 'undefined' && window.__chartPresentationDefaults) 11 ? window.__chartPresentationDefaults 12 : ChartConfig._PRESENTATION_FALLBACK; 13 } 14 return ChartConfig._presentationCache; 15 } 16 17 /** Load presentation defaults from the API when not injected (e.g. tests). */ 18 static async loadPresentationDefaults() { 19 if (typeof window === 'undefined') { 20 return ChartConfig._PRESENTATION_FALLBACK; 21 } 22 if (window.__chartPresentationDefaults) { 23 ChartConfig._presentationCache = window.__chartPresentationDefaults; 24 return ChartConfig._presentationCache; 25 } 26 const res = await fetch('/api/charts/presentation-defaults'); 27 if (!res.ok) { 28 return ChartConfig._PRESENTATION_FALLBACK; 29 } 30 ChartConfig._presentationCache = await res.json(); 31 window.__chartPresentationDefaults = ChartConfig._presentationCache; 32 return ChartConfig._presentationCache; 33 } 34 35 /** Used when chart-config.js is loaded outside the web app (tests). */ 36 static _PRESENTATION_FALLBACK = { 37 defaultSubtitle: ChartConfig.CHART_DEFAULT_SUBTITLE, 38 titleFontSize: 30, 39 subtitleFontSize: 15, 40 headerHeightPercent: 14.5, 41 headerHeightPercentBoth: 17.0, 42 headerTitleYPercent: 30.0, 43 headerSubtitleYPercent: 67.0, 44 categoryFontSize: 16, 45 scaleFontSize: 14, 46 valueFontSize: 14, 47 legendFontSize: 14, 48 gaugeValueFontSize: 28, 49 maxFontSize: 200, 50 referenceChartWidth: 1000, 51 referenceChartHeight: 600, 52 plotBorderColor: '#CBD5E1', 53 bodyStrokePercent: 1, 54 defaultBarColors: ['#0EA5E9', '#8B5CF6', '#10B981', '#F59E0B', '#EC4899', '#06B6D4'], 55 defaultLineColors: ['#2563EB', '#8B5CF6', '#10B981', '#F59E0B', '#EC4899'], 56 defaultPieColors: ['#0EA5E9', '#8B5CF6', '#10B981', '#F59E0B', '#EC4899', '#06B6D4'], 57 defaultVennCircleColors: ['#0EA5E9', '#EC4899', '#10B981'], 58 radarGridRings: 6, 59 chartDefaults: { 60 scatter: { 61 type: 'scatter', 62 title: 'Scatter Plot', 63 showValues: false, 64 showLegend: false, 65 scatter: { hasLegend: false, showTrendLine: true, pointRadius: 14 }, 66 bar: {}, 67 }, 68 column: { 69 type: 'column', 70 title: 'Column Chart', 71 showValues: true, 72 bar: { orientation: 'vertical', layoutMode: 'single' }, 73 }, 74 }, 75 }; 76 77 static _presentationCache = null; 78 79 /** One color for every series/slice when set in chart properties. */ 80 static pickColor(palette, index, uniformColor) { 81 if (uniformColor) return uniformColor; 82 const colors = Array.isArray(palette) && palette.length ? palette : ChartConfig.DEFAULT_BAR_COLORS; 83 return colors[index % colors.length]; 84 } 85 86 /** Bubble fill: uniform pointColor, else one palette entry per bubble (by point index). */ 87 static pickBubblePointColor(bubble, pointIndex) { 88 const b = bubble || {}; 89 if (typeof b.pointColor === 'string' && b.pointColor) return b.pointColor; 90 return ChartConfig.pickColor(b.pointColors, pointIndex, null); 91 } 92 93 static setNestedValue(obj, path, value) { 94 const parts = String(path).split('.'); 95 let cur = obj; 96 for (let i = 0; i < parts.length - 1; i++) { 97 const key = parts[i]; 98 if (!cur[key] || typeof cur[key] !== 'object') cur[key] = {}; 99 cur = cur[key]; 100 } 101 cur[parts[parts.length - 1]] = value; 102 } 103 104 static getNestedValue(obj, path) { 105 return String(path).split('.').reduce((cur, key) => (cur && typeof cur === 'object' ? cur[key] : undefined), obj); 106 } 107 108 static get DEFAULT_SUBTITLE() { 109 return ChartConfig._presentation().defaultSubtitle || ChartConfig.CHART_DEFAULT_SUBTITLE; 110 } 111 112 static get DEFAULT_TITLE_FONT_SIZE() { 113 return ChartConfig._presentation().titleFontSize; 114 } 115 116 static get DEFAULT_SUBTITLE_FONT_SIZE() { 117 return ChartConfig._presentation().subtitleFontSize; 118 } 119 120 /** Chart body / plot card border (slightly darker than grid #E2E8F0). */ 121 static get DEFAULT_PLOT_BORDER_COLOR() { 122 return ChartConfig._presentation().plotBorderColor; 123 } 124 125 /** Chart body card outline width (% of layer min side). */ 126 static get CHART_BODY_STROKE_PCT() { 127 return ChartConfig._presentation().bodyStrokePercent; 128 } 129 130 static get DEFAULT_CATEGORY_FONT_SIZE() { 131 return ChartConfig._presentation().categoryFontSize; 132 } 133 134 static get DEFAULT_SCALE_FONT_SIZE() { 135 return ChartConfig._presentation().scaleFontSize; 136 } 137 138 static get DEFAULT_VALUE_FONT_SIZE() { 139 return ChartConfig._presentation().valueFontSize; 140 } 141 142 static get DEFAULT_LEGEND_FONT_SIZE() { 143 return ChartConfig._presentation().legendFontSize; 144 } 145 146 /** Center readout on gauge / ring gauge charts. */ 147 static get DEFAULT_GAUGE_VALUE_FONT_SIZE() { 148 return ChartConfig._presentation().gaugeValueFontSize; 149 } 150 151 /** Header band height as a fraction of chart height (title or subtitle only). */ 152 static get CHART_HEADER_HEIGHT_FRAC() { 153 return ChartConfig._presentation().headerHeightPercent / 100; 154 } 155 156 /** Taller header when both title and subtitle are shown. */ 157 static get CHART_HEADER_HEIGHT_FRAC_BOTH() { 158 return ChartConfig._presentation().headerHeightPercentBoth / 100; 159 } 160 161 /** Title baseline as a fraction of header height from the top of the header band. */
162 static get CHART_HEADER_TITLE_Y_FRAC() { 163 return ChartConfig._presentation().headerTitleYPercent / 100; 164 } 165 166 /** Subtitle baseline as a fraction of header height from the top of the header band. */ 167 static get CHART_HEADER_SUBTITLE_Y_FRAC() { 168 return ChartConfig._presentation().headerSubtitleYPercent / 100; 169 } 170 171 static get DEFAULT_BAR_COLORS() { 172 return ChartConfig._presentation().defaultBarColors; 173 } 174 175 static get DEFAULT_LINE_COLORS() { 176 return ChartConfig._presentation().defaultLineColors; 177 } 178 179 static get DEFAULT_PIE_COLORS() { 180 return ChartConfig._presentation().defaultPieColors; 181 } 182 183 static get DEFAULT_VENN_CIRCLE_COLORS() { 184 return ChartConfig._presentation().defaultVennCircleColors 185 || ['#0EA5E9', '#EC4899', '#10B981']; 186 } 187 188 static get RADAR_GRID_RINGS() { 189 return ChartConfig._presentation().radarGridRings ?? 6; 190 } 191 192 /** Deep-clone a chart default entry from server presentation defaults. */ 193 static chartDefaults(key) { 194 const defs = ChartConfig._presentation().chartDefaults; 195 const base = defs?.[key]; 196 if (!base || typeof base !== 'object') return null; 197 return JSON.parse(JSON.stringify(base)); 198 } 199 200 static _applyHeaderFonts(config) { 201 const header = ChartConfig._defaultHeaderBar(); 202 for (const sec of ChartConfig.CONFIG_FONT_SECTIONS) { 203 if (config[sec] != null && typeof config[sec] === 'object') { 204 config[sec] = { ...header, ...config[sec] }; 205 } 206 } 207 return config; 208 } 209 210 static _applyVennCircleColors(config) { 211 const colors = ChartConfig.DEFAULT_VENN_CIRCLE_COLORS; 212 if (!config.venn || typeof config.venn !== 'object') return config; 213 config.venn = { 214 circle1Color: colors[0], 215 circle2Color: colors[1], 216 circle3Color: colors[2], 217 ...config.venn, 218 }; 219 return config; 220 } 221 222 static _applyRadarRingCount(config) { 223 if (!config.radar || typeof config.radar !== 'object') return config; 224 if (config.radar.ringCount == null) { 225 config.radar.ringCount = ChartConfig.RADAR_GRID_RINGS; 226 } 227 return config; 228 } 229 230 /** 231 * Build a parsed chart config from a server-defined default key. 232 */ 233 static defaultChart(key, overrides = {}) { 234 const raw = ChartConfig.chartDefaults(key); 235 if (!raw) { 236 throw new Error(`chartDefaults.${key} is not defined in chart presentation defaults`); 237 } 238 const merged = { ...raw, ...overrides }; 239 if (merged.subtitle == null) { 240 merged.subtitle = ChartConfig.DEFAULT_SUBTITLE; 241 } 242 ChartConfig._applyHeaderFonts(merged); 243 ChartConfig._applyVennCircleColors(merged); 244 ChartConfig._applyRadarRingCount(merged); 245 return ChartConfig.parse(merged); 246 } 247 248 /** Design-time defaults assume this chart frame (font sizes scale proportionally). */ 249 static get REFERENCE_WIDTH() { 250 return ChartConfig._presentation().referenceChartWidth ?? 1000; 251 } 252 253 static get REFERENCE_HEIGHT() { 254 return ChartConfig._presentation().referenceChartHeight ?? 600; 255 } 256 257 static get MAX_FONT_SIZE() { 258 return ChartConfig._presentation().maxFontSize ?? 200; 259 } 260 261 static CONFIG_FONT_SECTIONS = ['bar', 'line', 'area', 'pie', 'scatter', 'bubble', 'pyramid', 'radar', 'gauge', 'venn', 'heatmap', 'treemap', 'tree', 'lollipop']; 262 263 static FONT_SIZE_KEYS = new Set([ 264 'titleFontSize', 265 'subtitleFontSize', 266 'categoryFontSize', 267 'scaleFontSize', 268 'valueFontSize', 269 'legendFontSize', 270 'labelFontSize', 271 ]); 272 273 /** 274 * Non-font numeric options scaled with chart frame size (1000Ã600 baseline). 275 * designDefault: baseline at reference size; written into config when missing before scaling. 276 */ 277 static CONFIG_NUMERIC_SCALARS = [ 278 { section: 'line', key: 'pointRadius', min: 2, designDefault: 14 }, 279 { section: 'line', key: 'strokeWidth', min: 1, designDefault: 6 }, 280 { section: 'area', key: 'pointRadius', min: 2, designDefault: 14 }, 281 { section: 'area', key: 'strokeWidth', min: 1, designDefault: 6 }, 282 { section: 'lollipop', key: 'dotRadius', min: 3, designDefault: 14 }, 283 { section: 'lollipop', key: 'stemWidth', min: 1, designDefault: 4 }, 284 { section: 'scatter', key: 'pointRadius', min: 1 }, 285 { section: 'scatter', key: 'trendLineWidth', min: 1 }, 286 { section: 'radar', key: 'strokeWidth', min: 1, designDefault: 6 }, 287 { section: 'radar', key: 'pointRadius', min: 2, designDefault: 14 }, 288 ]; 289 290 static _applySectionNumericDefaults(config) { 291 for (const item of ChartConfig.CONFIG_NUMERIC_SCALARS) { 292 const { section, key, designDefault } = item; 293 if (designDefault == null) continue; 294 if (!config[section] || typeof config[section] !== 'object') { 295 config[section] = {}; 296 } 297 const cur = config[section][key]; 298 if (cur == null || !Number.isFinite(Number(cur))) { 299 config[section][key] = designDefault; 300 } 301 } 302 return config; 303 } 304 305 /** Baseline numeric at reference size for a properties-panel path (e.g. line.pointRadius). */ 306 static designNumericForPath(path) { 307 const item = ChartConfig.CONFIG_NUMERIC_SCALARS.find( 308 (x) => `${x.section}.${x.key}` === String(path) 309 ); 310 return item?.designDefault ?? null; 311 } 312 313 /** Scaled numeric default for a path and chart frame (matches scaleConfigForBounds). */ 314 static scaledDesignNumericForPath(path, width, height) { 315 const base = ChartConfig.designNumericForPath(path);
316 if (base == null) return null; 317 const item = ChartConfig.CONFIG_NUMERIC_SCALARS.find( 318 (x) => `${x.section}.${x.key}` === String(path) 319 ); 320 const scale = ChartConfig.scaleFactorForBounds(width, height); 321 if (scale === 1) return Math.max(item?.min ?? 1, Math.round(base)); 322 return Math.max(item?.min ?? 1, Math.round(base * scale)); 323 } 324 325 static scaleFactorForBounds(width, height) { 326 const w = Math.max(1, Number(width) || 1); 327 const h = Math.max(1, Number(height) || 1); 328 return Math.min(w / ChartConfig.REFERENCE_WIDTH, h / ChartConfig.REFERENCE_HEIGHT); 329 } 330 331 static scaledFontSize(baseSize, scale) { 332 const base = Number(baseSize); 333 if (!Number.isFinite(base)) return baseSize; 334 if (!Number.isFinite(scale) || scale === 1) return Math.round(base); 335 return Math.max(1, Math.round(base * scale)); 336 } 337 338 static scaleSectionFonts(section, scale) { 339 if (!section || typeof section !== 'object' || scale === 1) return; 340 for (const key of ChartConfig.FONT_SIZE_KEYS) { 341 if (section[key] != null && Number.isFinite(Number(section[key]))) { 342 section[key] = ChartConfig.scaledFontSize(section[key], scale); 343 } 344 } 345 } 346 347 static scaleSectionNumerics(section, keys, scale) { 348 if (!section || typeof section !== 'object' || scale === 1) return; 349 for (const { key, min } of keys) { 350 if (section[key] != null && Number.isFinite(Number(section[key]))) { 351 section[key] = Math.max(min ?? 1, Math.round(Number(section[key]) * scale)); 352 } 353 } 354 } 355 356 /** Scale all font sizes in chart config for a frame larger/smaller than the 1000Ã600 baseline. */ 357 static scaleConfigForBounds(config, width, height) { 358 const scale = ChartConfig.scaleFactorForBounds(width, height); 359 const scaled = ChartConfig.parse(config); 360 ChartConfig._applySectionNumericDefaults(scaled); 361 if (scale === 1) return scaled; 362 for (const sec of ChartConfig.CONFIG_FONT_SECTIONS) { 363 ChartConfig.scaleSectionFonts(scaled[sec], scale); 364 } 365 for (const sec of ChartConfig.CONFIG_FONT_SECTIONS) { 366 const keys = ChartConfig.CONFIG_NUMERIC_SCALARS.filter(item => item.section === sec); 367 if (keys.length) ChartConfig.scaleSectionNumerics(scaled[sec], keys, scale); 368 } 369 return scaled; 370 } 371 372 static layerBounds(layer) { 373 const frame = layer?.chartFrame; 374 if (frame?.w > 0 && frame?.h > 0) { 375 return { width: frame.w, height: frame.h }; 376 } 377 if (layer?.w > 0 && layer?.h > 0) { 378 return { width: layer.w, height: layer.h }; 379 } 380 return { width: ChartConfig.REFERENCE_WIDTH, height: ChartConfig.REFERENCE_HEIGHT }; 381 } 382 383 /** 384 * Ensure chart header text exists for layer rebuild (legacy configs may omit root title). 385 * Does not run for tile previews (title/subtitle intentionally cleared). 386 */ 387 static ensureHeader(cfg, { fallbackTitle } = {}) { 388 if (!cfg || typeof cfg !== 'object' || cfg.tilePreview === true) { 389 return cfg; 390 } 391 392 const title = cfg.title != null ? String(cfg.title).trim() : ''; 393 const subtitle = cfg.subtitle != null ? String(cfg.subtitle).trim() : ''; 394 395 if (!title && !subtitle) { 396 const fb = fallbackTitle != null ? String(fallbackTitle).trim() : ''; 397 if (fb) { 398 cfg.title = fb; 399 } 400 } 401 402 return cfg; 403 } 404 405 static parse(raw) { 406 if (!raw || typeof raw !== 'object') { 407 return { 408 type: 'column', showValues: true, showLegend: false, 409 showChartBodyCard: true, showPlotCard: true, 410 bar: {}, line: {}, pie: {}, area: {}, scatter: {}, bubble: {}, pyramid: {}, radar: {}, gauge: {}, venn: {}, heatmap: {}, treemap: {}, tree: {}, lollipop: {}, 411 }; 412 } 413 const type = String(raw.type || 'column').toLowerCase(); 414 const lollipop = ChartConfig._normalizeLollipopSection(raw.lollipop); 415 let showLegend = raw.showLegend === true; 416 let showValues = raw.showValues !== false; 417 let showChartBodyCard = raw.showChartBodyCard !== false; 418 const scatter = ChartConfig._normalizeScatterSection(raw.scatter); 419 if (type === 'lollipop') { 420 showValues = lollipop.showValues !== false; 421 showLegend = lollipop.showLegend === true; 422 showChartBodyCard = lollipop.showChartBodyCard !== false; 423 } 424 if (type === 'scatter') { 425 showLegend = scatter.hasLegend === true; 426 } 427 428 return { 429 type, 430 tilePreview: raw.tilePreview === true, 431 title: raw.title ?? null, 432 subtitle: raw.subtitle ?? null, 433 xAxisLabel: raw.xAxisLabel ?? null, 434 yAxisLabel: raw.yAxisLabel ?? null, 435 maxY: raw.maxY != null ? Number(raw.maxY) : null, 436 maxX: raw.maxX != null ? Number(raw.maxX) : null, 437 showLegend, 438 showValues, 439 showChartBodyCard, 440 showPlotCard: raw.showPlotCard !== false, 441 bar: ChartConfig._normalizeBarSection(raw.bar), 442 lollipop, 443 line: raw.line && typeof raw.line === 'object' ? raw.line : {}, 444 area: raw.area && typeof raw.area === 'object' ? raw.area : {}, 445 pie: raw.pie && typeof raw.pie === 'object' ? raw.pie : {}, 446 scatter, 447 bubble: raw.bubble && typeof raw.bubble === 'object' ? raw.bubble : {}, 448 pyramid: raw.pyramid && typeof raw.pyramid === 'object' ? raw.pyramid : {}, 449 radar: raw.radar && typeof raw.radar === 'object' ? raw.radar : {}, 450 gauge: raw.gauge && typeof raw.gauge === 'object' ? raw.gauge : {}, 451 venn: ChartConfig._normalizeVennSection(raw.venn), 452 heatmap: raw.heatmap && typeof raw.heatmap === 'object' ? raw.heatmap : {}, 453 treemap: raw.treemap && typeof raw.treemap === 'object' ? raw.treemap : {}, 454 tree: raw.tree && typeof raw.tree === 'object' ? raw.tree : {}, 455 }; 456 } 457 458 static _normalizeVennSection(venn) { 459 if (!venn || typeof venn !== 'object') return { circleCount: 2 }; 460 const section = { ...venn };
461 const n = Number(section.circleCount); 462 section.circleCount = n === 3 ? 3 : 2; 463 return section; 464 } 465 466 static _normalizeScatterSection(scatter) { 467 if (!scatter || typeof scatter !== 'object') { 468 return { hasLegend: false }; 469 } 470 const section = { ...scatter }; 471 if (section.hasLegend == null) section.hasLegend = false; 472 if (section.pointRadius == null && section.elements?.points?.size != null) { 473 section.pointRadius = section.elements.points.size; 474 } 475 return section; 476 } 477 478 static _normalizeLollipopSection(lollipop) { 479 if (!lollipop || typeof lollipop !== 'object') { 480 return { showValues: true, showLegend: false, showChartBodyCard: true }; 481 } 482 const section = { ...lollipop }; 483 if (section.showValues == null) section.showValues = true; 484 if (section.showLegend == null) section.showLegend = false; 485 if (section.showChartBodyCard == null) section.showChartBodyCard = true; 486 if (section.lollipopGap == null && section.barGap != null) { 487 section.lollipopGap = section.barGap; 488 } 489 if (section.lollipopColors == null && section.barColors != null) { 490 section.lollipopColors = section.barColors; 491 } 492 if (section.lollipopColor == null && section.barColor != null) { 493 section.lollipopColor = section.barColor; 494 } 495 return section; 496 } 497 498 static _normalizeBarSection(bar) { 499 if (!bar || typeof bar !== 'object') return {}; 500 const section = { ...bar }; 501 section.orientation = ChartConfig.normalizeBarOrientation(section.orientation); 502 const rawMode = section.layoutMode ?? section.LayoutMode; 503 if (rawMode === 1 || rawMode === '1' || String(rawMode).toLowerCase() === 'clustered') { 504 section.layoutMode = 'clustered'; 505 } else if (rawMode === 2 || rawMode === '2' || String(rawMode).toLowerCase() === 'stacked') { 506 section.layoutMode = 'stacked'; 507 } else if (rawMode != null) { 508 section.layoutMode = 'single'; 509 } 510 return section; 511 } 512 513 /** C# sends BarOrientation enum as 0/1 or "Vertical"/"Horizontal"; normalize for JS. */ 514 static normalizeBarOrientation(orientation) { 515 if (orientation === 0 || orientation === '0') return 'vertical'; 516 if (orientation === 1 || orientation === '1') return 'horizontal'; 517 const o = String(orientation ?? 'horizontal').toLowerCase(); 518 if (o === 'vertical') return 'vertical'; 519 return 'horizontal'; 520 } 521 522 static _looksLikeVennConfig(config) { 523 const v = config?.venn; 524 if (!v || typeof v !== 'object') return false; 525 return !!(v.circle1Name || v.circle2Name || v.circle3Name); 526 } 527 528 static visualKind(config) { 529 const type = String(config?.type || 'column').toLowerCase(); 530 if (type === 'lollipop') return 'lollipop'; 531 if (type === 'line' || type === 'area' || type === 'pie' || type === 'donut' || type === 'multipie' || type === 'multidonut' || type === 'gauge' || type === 'venn' || type === 'heatmap' || type === 'treemap' || type === 'tree' || type === 'scatter' || type === 'bubble' || type === 'pyramid' || type === 'radar') { 532 if (type === 'multipie') return 'multiPie'; 533 if (type === 'multidonut') return 'multiDonut'; 534 if (type === 'pie' || type === 'donut' || type === 'gauge' || type === 'venn' || type === 'heatmap' || type === 'treemap' || type === 'tree' || type === 'scatter' || type === 'bubble' || type === 'pyramid' || type === 'radar' || type === 'line' || type === 'area') return type; 535 } 536 if (type === 'column' && ChartConfig._looksLikeVennConfig(config)) return 'venn'; 537 if (type === 'column') return 'column'; 538 if (type === 'bar') { 539 return ChartConfig.normalizeBarOrientation(config?.bar?.orientation) === 'vertical' 540 ? 'column' 541 : 'bar'; 542 } 543 return 'column'; 544 } 545 546 static isHorizontalBar(config) { 547 return ChartConfig.visualKind(config) === 'bar'; 548 } 549 550 static themeOptions(config) { 551 return Chart.baseThemeOptions(config); 552 } 553 554 static isTilePreview(config) { 555 return config?.tilePreview === true; 556 } 557 558 static _defaultHeaderBar() { 559 return { 560 titleFontSize: ChartConfig.DEFAULT_TITLE_FONT_SIZE, 561 subtitleFontSize: ChartConfig.DEFAULT_SUBTITLE_FONT_SIZE, 562 categoryFontSize: ChartConfig.DEFAULT_CATEGORY_FONT_SIZE, 563 scaleFontSize: ChartConfig.DEFAULT_SCALE_FONT_SIZE, 564 valueFontSize: ChartConfig.DEFAULT_VALUE_FONT_SIZE, 565 legendFontSize: ChartConfig.DEFAULT_LEGEND_FONT_SIZE, 566 }; 567 } 568}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.