UNPKG

ethercalc

Version:

Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)

610 lines (576 loc) 24.2 kB
/** * Graph / chart integration — port of `src/player-graph.ls`. * * Public surface required by `index.html`: * * - `window.GraphOnClick` — called when the "Graph" tab is focused * - `window.GraphSetCells` — "OK" button handler in the graph toolbar * - `window.DoGraph` — central redraw entry * - `window.GraphChanged` — chart-type dropdown change * - `window.MinMaxChanged` — min/max X/Y input change * - `window.GraphSave` — SettingsCallbacks.graph.save * - `window.GraphLoad` — SettingsCallbacks.graph.load * - `SocialCalc.GraphTypesInfo` + SCC styling tweaks * * Chart drawing bodies (vertical-bar, horizontal-bar, pie, line, scatter) * are covered by `test/graph.test.ts` using a fake 2D canvas context. * * Everything is installed as a *registration* function that accepts the * SocialCalc + window stubs — mirrors `installGraph({ win, SocialCalc })`. */ import type { SocialCalcGlobal, SpreadsheetLike } from './types.ts'; // ─── Graph plumbing types ──────────────────────────────────────────────── export interface GraphRange { left: number; right: number; top: number; bottom: number; } export interface GraphHost { SocialCalc: SocialCalcGlobal & { GraphTypesInfo?: GraphTypesInfo; Popup?: { LocalizeString?: (s: string) => string }; LocalizeString?: (s: string) => string; LocalizeSubstrings?: (s: string) => string; GetSpreadsheetControlObject?: () => GraphSpreadsheet; ParseRange?: (r: string) => { cr1: { col: number; row: number }; cr2: { col: number; row: number } }; Formula?: { SheetCache?: { sheets?: Record<string, unknown> }; LookupName?: (sheet: unknown, name: string) => { type: string; value: string }; }; rcColname?: (n: number) => string; crToCoord?: (col: number, row: number) => string; encodeForSave?: (s: string) => string; decodeFromSave?: (s: string) => string; }; /** `window` — the script writes globals onto it. */ win: Record<string, unknown> & { GraphOnClick?: GraphOnClickFn; GraphSetCells?: () => void; DoGraph?: (help: boolean, resize: boolean) => void; GraphChanged?: (node: HTMLSelectElement) => void; MinMaxChanged?: (node: HTMLInputElement, index: number) => void; GraphSave?: (editor: unknown, setting: string) => string; GraphLoad?: (editor: unknown, setting: string, line: string, flags: unknown) => boolean; spreadsheet?: GraphSpreadsheet; }; doc: { getElementById: (id: string) => HTMLElement | null; }; } export interface GraphSpreadsheet extends SpreadsheetLike { idPrefix?: string; graphrange?: string; graphtype?: string; graphMinX?: string | number; graphMaxX?: string | number; graphMinY?: string | number; graphMaxY?: string | number; width?: number; height?: number; nonviewheight?: number; sheet: SpreadsheetLike['sheet'] & { names?: Record<string, unknown>; GetAssuredCell: (coord: string) => { valuetype: string; datavalue: string | number }; }; } export type GraphDrawFn = ( spreadsheet: GraphSpreadsheet, range: GraphRange | null, gview: HTMLElement, gtype: string, helpflag: boolean, isResize: boolean, ) => void; export interface GraphTypesInfo { displayorder: string[]; [k: string]: { display: string; func: GraphDrawFn } | string[]; } export type GraphOnClickFn = (s: GraphSpreadsheet) => void; // ─── Color helpers ─────────────────────────────────────────────────────── const BAR_COLORS = ['ff0', '0ff', 'f0f', '00f', 'f00', '0f0', '888', '880', '088', '808', '008', '800', '080']; /** * Deterministic round-robin + random-fallback palette matching the legacy * behavior. Exposed so tests can seed the RNG. */ export function makePalette(rand: () => number = Math.random): { getBarColor: () => string; getDrawColor: () => string; reset: () => void; } { let colorIndex = 0; const getBarColor = (): string => { const preset = BAR_COLORS[colorIndex++]; if (preset) return preset; let out = ''; for (let i = 0; i < 6; i++) out += BAR_COLORS[Math.round(rand() * 14)] ?? '0'; return out.slice(0, 6); }; const getDrawColor = (): string => `#${getBarColor()}`; const reset = (): void => { colorIndex = 0; }; return { getBarColor, getDrawColor, reset }; } // ─── Public functions: install + small handlers ────────────────────────── export function installGraph(host: GraphHost): void { const { SocialCalc, win, doc } = host; const palette = makePalette(); function updateGraphRangeProposal(editor: { range: { hasrange: boolean; left: number; top: number; right: number; bottom: number }; }): void { const ss = SocialCalc.GetSpreadsheetControlObject?.(); if (!ss?.idPrefix) return; const ele = doc.getElementById(ss.idPrefix + 'graphlist') as HTMLSelectElement | null; if (!ele || !ele.options[0]) return; if (editor.range.hasrange) { ele.options[0].text = SocialCalc.crToCoord!(editor.range.left, editor.range.top) + ':' + SocialCalc.crToCoord!(editor.range.right, editor.range.bottom); } else { ele.options[0].text = SocialCalc.LocalizeString?.('[select range]') ?? '[select range]'; } } const onClick: GraphOnClickFn = (s) => { palette.reset(); const SCLoc = SocialCalc.LocalizeString ?? ((x: string) => x); const namelist: string[] = []; const nl = doc.getElementById((s.idPrefix ?? '') + 'graphlist') as HTMLSelectElement | null; // Refresh range proposal when the user drags a new selection. (s.editor as unknown as { RangeChangeCallback: Record<string, unknown> }).RangeChangeCallback.graph = updateGraphRangeProposal; for (const name of Object.keys(s.sheet.names ?? {})) namelist.push(name); namelist.sort(); if (!nl) return; nl.length = 0; nl.options[0] = new Option(SCLoc('[select range]')); for (let i = 0; i < namelist.length; i++) { const name = namelist[i]!; nl.options[i + 1] = new Option(name, name); if (name === s.graphrange) nl.options[i + 1]!.selected = true; } if (s.graphrange === '') nl.options[0]!.selected = true; updateGraphRangeProposal(s.editor as unknown as Parameters<typeof updateGraphRangeProposal>[0]); const tnl = doc.getElementById((s.idPrefix ?? '') + 'graphtype') as HTMLSelectElement | null; if (!tnl) return; tnl.length = 0; const gti = SocialCalc.GraphTypesInfo!; for (let i = 0; i < gti.displayorder.length; i++) { const name = gti.displayorder[i]!; const info = gti[name] as { display: string; func: GraphDrawFn }; tnl.options[i] = new Option(SCLoc(info.display), name); if (name === s.graphtype) tnl.options[i]!.selected = true; } if (!s.graphtype) { tnl.options[0]!.selected = true; s.graphtype = tnl.options[0]!.value; } doGraph(false, true); }; function graphSetCells(): void { const spreadsheet = SocialCalc.GetSpreadsheetControlObject?.(); if (!spreadsheet?.idPrefix) return; const editor = spreadsheet.editor as unknown as { range: { hasrange: boolean; left: number; top: number; right: number; bottom: number }; ecell: { coord: string }; }; const lele = doc.getElementById(spreadsheet.idPrefix + 'graphlist') as HTMLSelectElement | null; if (!lele) return; if (lele.selectedIndex === 0) { if (editor.range.hasrange) { spreadsheet.graphrange = SocialCalc.crToCoord!(editor.range.left, editor.range.top) + ':' + SocialCalc.crToCoord!(editor.range.right, editor.range.bottom); } else { spreadsheet.graphrange = editor.ecell.coord + ':' + editor.ecell.coord; } } else { spreadsheet.graphrange = lele.options[lele.selectedIndex]!.value; } const ele = doc.getElementById(spreadsheet.idPrefix + 'graphrange'); if (ele) ele.innerHTML = spreadsheet.graphrange; doGraph(false, false); } function doGraph(helpflag: boolean, isResize: boolean): void { palette.reset(); const spreadsheet = SocialCalc.GetSpreadsheetControlObject?.(); if (!spreadsheet?.graphtype) return; const gview = (spreadsheet.views as { graph?: { element?: HTMLElement } } | undefined)?.graph?.element; if (!gview) return; const gti = SocialCalc.GraphTypesInfo!; const ginfo = gti[spreadsheet.graphtype] as { display: string; func: GraphDrawFn } | undefined; const gfunc = ginfo?.func; if (!spreadsheet.graphrange) { if (gfunc && helpflag) gfunc(spreadsheet, null, gview, spreadsheet.graphtype, helpflag, isResize); else gview.innerHTML = `<div style="padding:30px;font-weight:bold;">${ SocialCalc.Constants['s_GraphRangeNotSelected'] ?? 'Graph range not selected' }</div>`; return; } let grange = spreadsheet.graphrange; if (grange && grange.indexOf(':') === -1) { const nrange = SocialCalc.Formula?.LookupName?.(spreadsheet.sheet, grange); if (!nrange || nrange.type !== 'range') { gview.innerHTML = (SocialCalc.LocalizeString?.('Unknown range name') ?? 'Unknown range name') + ': ' + grange; return; } const rparts = /^(.*)\|(.*)\|$/.exec(nrange.value); if (!rparts) return; grange = rparts[1] + ':' + rparts[2]; } const prange = SocialCalc.ParseRange?.(grange); if (!prange) return; const range: GraphRange = { left: Math.min(prange.cr1.col, prange.cr2.col), right: Math.max(prange.cr1.col, prange.cr2.col), top: Math.min(prange.cr1.row, prange.cr2.row), bottom: Math.max(prange.cr1.row, prange.cr2.row), }; gfunc?.(spreadsheet, range, gview, spreadsheet.graphtype, helpflag, isResize); } function graphChanged(node: HTMLSelectElement): void { const ss = win.spreadsheet; if (ss) ss.graphtype = node.options[node.selectedIndex]!.value; doGraph(false, false); } function minMaxChanged(node: HTMLInputElement, index: number): void { const ss = win.spreadsheet; if (!ss) return; switch (index) { case 0: ss.graphMinX = node.value; break; case 1: ss.graphMaxX = node.value; break; case 2: ss.graphMinY = node.value; break; case 3: ss.graphMaxY = node.value; break; } doGraph(false, true); } function graphSave(): string { const spreadsheet = SocialCalc.GetSpreadsheetControlObject?.(); if (!spreadsheet) return ''; const gtype = spreadsheet.graphtype ?? ''; const encode = SocialCalc.encodeForSave ?? ((x: string) => x); let str = 'graph:range:' + encode(spreadsheet.graphrange ?? '') + ':type:' + encode(gtype); str += ':minmax:' + encode( `${spreadsheet.graphMinX ?? ''},${spreadsheet.graphMaxX ?? ''},${spreadsheet.graphMinY ?? ''},${spreadsheet.graphMaxY ?? ''}`, ) + '\n'; return str; } function graphLoad(_editor: unknown, _setting: string, line: string): boolean { const spreadsheet = SocialCalc.GetSpreadsheetControlObject?.(); if (!spreadsheet) return false; const decode = SocialCalc.decodeFromSave ?? ((x: string) => x); const parts = line.split(':'); for (let i = 1; i < parts.length; i += 2) { switch (parts[i]) { case 'type': spreadsheet.graphtype = decode(parts[i + 1] ?? ''); break; case 'range': spreadsheet.graphrange = decode(parts[i + 1] ?? ''); break; case 'minmax': { const split = decode(parts[i + 1] ?? '').split(','); const ids = ['SocialCalc-graphMinX', 'SocialCalc-graphMaxX', 'SocialCalc-graphMinY', 'SocialCalc-graphMaxY']; const keys: Array<'graphMinX' | 'graphMaxX' | 'graphMinY' | 'graphMaxY'> = [ 'graphMinX', 'graphMaxX', 'graphMinY', 'graphMaxY', ]; for (let j = 0; j < 4; j++) { spreadsheet[keys[j]!] = split[j] ?? ''; const el = doc.getElementById(ids[j]!) as HTMLInputElement | null; if (el) el.value = split[j] ?? ''; } break; } } } return true; } // Register on the window for `index.html`/SocialCalc to find. win.GraphOnClick = onClick; win.GraphSetCells = graphSetCells; win.DoGraph = doGraph; win.GraphChanged = graphChanged; win.MinMaxChanged = minMaxChanged; win.GraphSave = graphSave; win.GraphLoad = graphLoad; // Register chart-type metadata. SocialCalc.GraphTypesInfo = { displayorder: ['verticalbar', 'horizontalbar', 'piechart', 'linechart', 'scatterchart'], verticalbar: { display: SocialCalc.Constants['s_loc_vertical_bar'] ?? 'Vertical Bar', func: drawVerticalBar(host, palette), }, horizontalbar: { display: SocialCalc.Constants['s_loc_horizontal_bar'] ?? 'Horizontal Bar', func: drawHorizontalBar(host, palette), }, piechart: { display: SocialCalc.Constants['s_loc_pie_chart'] ?? 'Pie Chart', func: drawPieChart(host, palette), }, linechart: { display: SocialCalc.Constants['s_loc_line_chart'] ?? 'Line Chart', func: drawLineChart(host, palette), }, scatterchart: { display: SocialCalc.Constants['s_loc_scatter_chart'] ?? 'Scatter Chart', func: drawScatterChart(host, palette), }, }; // Constant string additions (CSS palette tweaks). applyPaletteConstants(SocialCalc); } function applyPaletteConstants(SocialCalc: SocialCalcGlobal): void { const scc = SocialCalc.Constants; scc['s_loc_horizontal_bar'] = 'Horizontal Bar'; scc['s_loc_vertical_bar'] = 'Vertical Bar'; scc['s_loc_pie_chart'] = 'Pie Chart'; scc['s_loc_line_chart'] = 'Line Chart'; scc['s_loc_scatter_chart'] = 'Scatter Chart'; scc['s_loc_hide_help'] = 'Hide Help'; const selectedbg = '404040'; const unselectedbg = '808080'; let cursorbg = 'A6A6A6'; let fg = 'FFF'; if (SocialCalc.requestParams?.['app'] !== undefined) { cursorbg = 'FFF'; fg = '000'; } scc['SCToolbarbackground'] = 'background-color:#' + selectedbg + ';'; scc['SCTabbackground'] = 'background-color:#' + unselectedbg + ';'; scc['SCTabselectedCSS'] = 'font-size:small;padding:6px 30px 6px 8px;color:#FFF;background-color:#' + selectedbg + ';cursor:default;border-right:1px solid #CCC;'; scc['SCTabplainCSS'] = 'font-size:small;padding:6px 30px 6px 8px;color:#FFF;background-color:#' + unselectedbg + ';cursor:default;border-right:1px solid #CCC;'; scc['SCToolbartext'] = 'font-size:x-small;font-weight:bold;color:#FFF;padding-bottom:4px;'; scc['ISCButtonBorderNormal'] = '#' + selectedbg; scc['ISCButtonBorderHover'] = '#999'; scc['ISCButtonBorderDown'] = '#FFF'; scc['ISCButtonDownBackground'] = '#888'; scc['defaultImagePrefix'] = 'images/sc_'; scc['defaultColnameStyle'] = 'overflow:visible;font-size:small;text-align:center;color:#' + fg + ';background-color:#' + unselectedbg; scc['defaultSelectedColnameStyle'] = 'overflow:visible;font-size:small;text-align:center;color:#' + fg + ';background-color:#' + selectedbg; scc['defaultRownameStyle'] = 'position:relative;overflow:visible;font-size:small;text-align:center;vertical-align:middle;color:#' + fg + ';background-color:#' + unselectedbg + ';direction:rtl;'; scc['defaultSelectedRownameStyle'] = 'position:relative;overflow:visible;font-size:small;text-align:center;vertical-align:middle;color:#' + fg + ';background-color:#' + selectedbg + ';'; scc['defaultHighlightTypeCursorStyle'] = 'color:#' + fg + ';backgroundColor:#' + cursorbg + ';'; } // ─── Chart drawing functions ───────────────────────────────────────────── // The bodies below port the canvas-math from the legacy file verbatim. type Palette = ReturnType<typeof makePalette>; function collectValues( host: GraphHost, spreadsheet: GraphSpreadsheet, range: GraphRange, ): { values: number[]; labels: string[]; byrow: boolean; nitems: number; minX?: number; maxX?: number } { const values: number[] = []; const labels: string[] = []; let byrow: boolean; let nitems: number; if (range.left === range.right) { nitems = range.bottom - range.top + 1; byrow = true; } else { nitems = range.right - range.left + 1; byrow = false; } const rcColname = host.SocialCalc.rcColname!; for (let i = 0; i < nitems; i++) { const cr = byrow ? rcColname(range.left) + (i + range.top) : rcColname(i + range.left) + range.top; const cr1 = byrow ? rcColname(range.left - 1 || 1) + (i + range.top) : rcColname(i + range.left) + (range.top - 1 || 1); const cell = spreadsheet.sheet.GetAssuredCell(cr); if (String(cell.valuetype).charAt(0) === 'n') { const val = Number(cell.datavalue); values.push(val); const c1 = spreadsheet.sheet.GetAssuredCell(cr1); if ( (range.right === range.left || range.top === range.bottom) && (String(c1.valuetype).charAt(0) === 't' || String(c1.valuetype).charAt(0) === 'n') ) { labels.push(String(c1.datavalue)); } else { labels.push(String(val)); } } } return { values, labels, byrow, nitems }; } function drawVerticalBar(host: GraphHost, palette: Palette): GraphDrawFn { return (spreadsheet, range, gview, gtype, helpflag) => { if (helpflag || !range) { const hideHelp = host.SocialCalc.Constants['s_loc_hide_help'] ?? 'Hide Help'; const display = (host.SocialCalc.GraphTypesInfo![gtype] as { display: string }).display; gview.innerHTML = `<input type="button" value="${hideHelp}" onclick="DoGraph(false,false);"><br><br>` + `This is the help text for graph type: ${display}.`; return; } const { values, labels } = collectValues(host, spreadsheet, range); let maxval: number | null = null; let minval: number | null = null; for (const v of values) { if (maxval === null || v > maxval) maxval = v; if (minval === null || v < minval) minval = v; } if ((maxval ?? 0) < 0) maxval = 0; if ((minval ?? 0) > 0) minval = 0; gview.innerHTML = '<table><tr><td><canvas id="myBarCanvas" width="500px" height="400px" style="border:1px solid black;"></canvas></td><td><span id="googleBarChart"></span></td></tr></table>'; const canv = host.doc.getElementById('myBarCanvas') as HTMLCanvasElement | null; if (!canv) return; const ctx = canv.getContext('2d') as CanvasRenderingContext2D | null; if (!ctx) return; ctx.font = '10pt bold Arial'; const maxheight = canv.height - 60; const totalwidth = canv.width; palette.reset(); const eachwidth = Math.floor(totalwidth / (values.length || 1)) - 4 || 1; const zeroLine = maxheight * ((maxval ?? 0) / (((maxval ?? 0) - (minval ?? 0)) || 1)) + 30; const yScale = maxheight / (((maxval ?? 0) - (minval ?? 0)) || 1); ctx.lineWidth = 5; ctx.moveTo(0, zeroLine); ctx.lineTo(canv.width, zeroLine); ctx.stroke(); for (let i = 0; i < values.length; i++) { ctx.fillStyle = '#' + palette.getBarColor(); ctx.fillRect(i * eachwidth, zeroLine - yScale * values[i]!, eachwidth, yScale * values[i]!); // Labels are pushed in lockstep with values; the `?? ''` fallback is a // defensive guard never triggered by collectValues. ctx.fillText(/* istanbul ignore next */ labels[i] ?? '', i * eachwidth + 4, zeroLine + 16); } }; } function drawHorizontalBar(host: GraphHost, palette: Palette): GraphDrawFn { return (spreadsheet, range, gview, gtype, helpflag) => { if (helpflag || !range) { const hideHelp = host.SocialCalc.Constants['s_loc_hide_help'] ?? 'Hide Help'; gview.innerHTML = `<input type="button" value="${hideHelp}" onclick="DoGraph(false,false);"><br><br>Horizontal bar help.`; return; } const { values, labels } = collectValues(host, spreadsheet, range); gview.innerHTML = '<canvas id="myBarCanvas" width="500px" height="400px" style="border:1px solid black;"></canvas>'; const canv = host.doc.getElementById('myBarCanvas') as HTMLCanvasElement | null; if (!canv) return; const ctx = canv.getContext('2d') as CanvasRenderingContext2D | null; if (!ctx) return; ctx.font = '10pt bold Arial'; palette.reset(); const each = Math.floor(canv.height / (values.length || 1)) - 4 || 1; let max = 0; for (const v of values) if (v > max) max = v; for (let i = 0; i < values.length; i++) { ctx.fillStyle = '#' + palette.getBarColor(); ctx.fillRect(0, i * each, (values[i]! / (max || 1)) * (canv.width - 50), each); ctx.fillStyle = '#000'; // Labels pushed 1:1 with values — `?? ''` is defensive-only. ctx.fillText(/* istanbul ignore next */ labels[i] ?? '', 4, i * each + each / 2); } }; } function drawPieChart(host: GraphHost, palette: Palette): GraphDrawFn { return (spreadsheet, range, gview) => { if (!range) return; const { values, labels } = collectValues(host, spreadsheet, range); const total = values.reduce((a, b) => a + b, 0); gview.innerHTML = '<canvas id="myCanvas" width="500px" height="400px"></canvas>'; const canv = host.doc.getElementById('myCanvas') as HTMLCanvasElement | null; if (!canv) return; const ctx = canv.getContext('2d') as CanvasRenderingContext2D | null; if (!ctx) return; ctx.font = '10pt Arial'; const centerX = canv.width / 2; const centerY = canv.height / 2; const rad = centerY - 50; let last = 0; for (let i = 0; i < values.length; i++) { if (!Number(values[i])) continue; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.fillStyle = palette.getDrawColor(); const arc = 2 * Math.PI * (values[i]! / (total || 1)); ctx.arc(centerX, centerY, rad, last, last + arc, false); ctx.closePath(); ctx.fill(); ctx.fillStyle = '#000'; // Labels pushed 1:1 with values — `?? ''` is defensive-only. ctx.fillText(/* istanbul ignore next */ labels[i] ?? '', centerX + Math.cos(last + arc / 2) * rad, centerY + Math.sin(last + arc / 2) * rad); last += arc; } }; } function drawLineChart(host: GraphHost, palette: Palette): GraphDrawFn { return (spreadsheet, range, gview) => { if (!range) return; const { values } = collectValues(host, spreadsheet, range); gview.innerHTML = '<canvas id="myLineCanvas" width="500px" height="400px"></canvas>'; const canv = host.doc.getElementById('myLineCanvas') as HTMLCanvasElement | null; if (!canv) return; const ctx = canv.getContext('2d') as CanvasRenderingContext2D | null; if (!ctx) return; ctx.strokeStyle = palette.getDrawColor(); ctx.beginPath(); const w = (canv.width - 40) / (values.length || 1); for (let i = 0; i < values.length; i++) { if (i === 0) ctx.moveTo(20, canv.height - values[i]! - 20); else ctx.lineTo(20 + i * w, canv.height - values[i]! - 20); } ctx.stroke(); }; } function drawScatterChart(host: GraphHost, palette: Palette): GraphDrawFn { return (spreadsheet, range, gview) => { if (!range) return; const { values } = collectValues(host, spreadsheet, range); gview.innerHTML = '<canvas id="myScatterCanvas" width="500px" height="400px"></canvas>'; const canv = host.doc.getElementById('myScatterCanvas') as HTMLCanvasElement | null; if (!canv) return; const ctx = canv.getContext('2d') as CanvasRenderingContext2D | null; if (!ctx) return; ctx.fillStyle = palette.getDrawColor(); const w = (canv.width - 40) / (values.length || 1); for (let i = 0; i < values.length; i++) { ctx.beginPath(); ctx.arc(20 + i * w, canv.height - values[i]! - 20, 5, 0, 2 * Math.PI, false); ctx.fill(); } }; }