diff --git a/helpers/parser.ts b/helpers/parser.ts index cd8ec4b..11f2eff 100644 --- a/helpers/parser.ts +++ b/helpers/parser.ts @@ -371,7 +371,7 @@ export function evaluateExpressions(parsedText: parsedText, xRange: [number,numb compiledNested[obj.signature] = parsed // expr is an array } else { - compiledNested[obj.signature] = math.compile(expr); // expr is supposed to be a valid equation. We compile it and the compiled object. + compiledNested[obj.signature] = (expr); // expr is supposed to be a valid equation. } } @@ -398,20 +398,20 @@ export function evaluateExpressions(parsedText: parsedText, xRange: [number,numb if (!isConstant) { scope[variable] = val; } - for (let name in compiledNested) { - const v = compiledNested[name]; // grabs the expression with "name" signature + for (let signature in compiledNested) { + const nestedExpr = compiledNested[signature]; // grabs the expression with "name" signature - if (v === undefined) continue; + if (nestedExpr === undefined) continue; - if (typeof v === "number") { - scope[name] = v; // if the expression is a scalar (constant). Simply assign it to the scope with the signature as its variable. + if (typeof nestedExpr === "number") { + scope[signature] = nestedExpr; // if the expression is a scalar (constant). Simply assign it to the scope with the signature as its variable. } - else if (Array.isArray(v)) { // if the expression is a list of values the current equation needs to be evaluated with the current val + else if (Array.isArray(nestedExpr)) { // if the expression is a list of values the current equation needs to be evaluated with the current val // at each value in the array. // So if f(x) = x^2 * D -> D: [0.3,0.4,0.5]. We get three plots, one for each D value. // ONLY the equation that contains the nested function needs to be plotted. Other functions work as normal. - if ( vars.includes(name) ) { // Checks if the signature that matches the list is a variable of the current equation. + if ( vars.includes(signature) ) { // Checks if the signature that matches the list is a variable of the current equation. // Essentially evaluateExpressions needs to be called for each one. But evaluateExpressions has too much logic not need at this point. // I also need to stop evaluating this equation entirely. @@ -419,12 +419,32 @@ export function evaluateExpressions(parsedText: parsedText, xRange: [number,numb // Then continue to the next equation if (variable === undefined) continue equationLoop; - results.push(...handleNestedArray(equation,variable,localRange,v,name)); + results.push(...handleNestedArray(equation.expr,variable,localRange,nestedExpr,signature,scope)); continue equationLoop; } } else { - scope[name] = v.evaluate(scope); // if the expression is neither a scalar nor an array then its an expression. evaluate it with normal the current val + const xranges = parsedText.lineProperties.map(prop => `${prop.signature}.${prop.property}=${prop.value}`).filter(s => s.includes("xrange")); + const indexRange = xranges.findIndex(s => s.startsWith(`${signature}.xrange=`)); + if (indexRange !== -1){ + // As of version 1.0.3 I want to support independant variables inside the nested equation. + // Re = DV/v + // D: 0.3 + // v: 2T + 10 + // v.xrange = [100,300,50] Which creates a reynolds plot for each viscosity. Where Velocity is the main variable + if (!xranges[indexRange]) continue equationLoop; + const nestedLocalRangeString = xranges[indexRange].split("=")[1]; + if (!nestedLocalRangeString) continue equationLoop; + let nestedLocalRange: [number,number,number] = JSON.parse(nestedLocalRangeString); + if (variable === undefined) continue equationLoop; + results.push(...handleNestedIndependant(equation,variable,localRange,nestedLocalRange,nestedExpr,signature, nestInfo,scope)); + continue equationLoop; + + + } else { + const compiledNestedExpr = math.compile(nestedExpr); + scope[signature] = compiledNestedExpr.evaluate(scope); // if the expression is neither a scalar nor an array then its an expression. evaluate it with normal the current val + } } } @@ -467,18 +487,20 @@ export function getVariable(expr: Equation | NestedEquations | RawExpr) { } + function isNumberString(val: string): boolean { return val.trim() !== "" && !isNaN(Number(val)); } -function handleNestedArray(eq: Equation,variable: string, localRange: [number, number, number], v: number[], name: string) { - const expr = math.compile(eq.expr); +function handleNestedArray(mainExpr: string ,variable: string, localRange: [number, number, number], v: number[], name: string, baseScope: Record, legendName?: string): PlotData[] { + const expr = math.compile(mainExpr); const results: PlotData[] = []; for (let i of v) { const mDataPoints: Data[] = [] for (let val = localRange[0]; val <= localRange[1]; val += localRange[2]) { const scope = { + ...baseScope, [variable]: val, [name]: i } @@ -491,11 +513,46 @@ function handleNestedArray(eq: Equation,variable: string, localRange: [number, n } mDataPoints.push({x:val,y}); } - results.push({signature: `${name}=${i}`, data: mDataPoints}); + if (!legendName) results.push({signature: `${name}=${i}`, data: mDataPoints}); + else results.push({signature: `${legendName}=${i}`, data: mDataPoints}); + } return results; } +function handleNestedIndependant(eq: Equation, variable: string, localRange: [number,number,number], + nestedLocalRange: [number,number,number], nestedExpr: any, signature: string, nestInfo: string[][], baseScope: Record): PlotData[]{ + + const nestedResults: number[] = []; + // Get Main Equation Variable -------------------------------------- + const nestedEquationObject: Equation = { + expr: nestedExpr, + signature:signature + }; + const vars = getVariable(nestedEquationObject).filter((v) => !builtInConstants.includes(v)); + // vars can include builtInConstants that need to be filtered out so they arent recognized as the variable. + const newVars = vars.flatMap(v => { + if (nestInfo[1]?.includes(v)) { + return []; + } + return [v]; + }) + // vars also filters out any variabales that are actually nested function signatures. Think if f(x) = G + x -> filters out G as long as G is declared as G: val + const nestedVariable = newVars[0]; + if (!nestedVariable) return []; + const nestedEquation = math.compile(nestedExpr); + for (let i = nestedLocalRange[0]; i <= nestedLocalRange[1]; i += nestedLocalRange[2]){ + const nestedScope: Record = { + ...baseScope, + }; + nestedScope[nestedVariable] = i; + let nestedY = nestedEquation.evaluate(nestedScope); + nestedResults.push(nestedY); + }; + return handleNestedArray(eq.expr, variable, localRange, nestedResults, signature, baseScope, nestedVariable); + +} + function handleDiscontinuities(mDataPoints: Data[], localRange: [number, number, number], y: number) { // ---------------- Discontinuities --------------------------------------- const prev = mDataPoints[mDataPoints.length - 1]; diff --git a/helpers/svgFormatting.ts b/helpers/svgFormatting.ts new file mode 100644 index 0000000..a394328 --- /dev/null +++ b/helpers/svgFormatting.ts @@ -0,0 +1,194 @@ +import { ChartType} from "chart.js/auto"; +import {Data} from "./parser"; + +export function generateSVG(chartInstance: any, chartType: ChartType) { + const width = chartInstance.width; + const height = chartInstance.height; + + //const backgroundColor = this.settings.backgroundColor; + const xScale = chartInstance.scales.x; + const yScale = chartInstance.scales.y; + const area = chartInstance.chartArea; + const style = getComputedStyle(document.body); + const bg = style.getPropertyValue("--background-secondary").trim() || "#ffffff"; + const text = style.getPropertyValue("--text-normal").trim() || "#000000"; + const muted = style.getPropertyValue("--text-muted").trim() || "#666666"; + const border = style.getPropertyValue("--background-modifier-border").trim() || "#d0d0d0"; + let svg = ""; + switch(chartType) { + case "line": { + svg = ``; + svg += ` + + + + `; + const title = chartInstance.options?.plugins?.title; + + if (title?.display && title.text) { + svg += ` + ${title.text} + `; + } + xScale.ticks.forEach((tick: any, i: number) => { + const x = xScale.getPixelForTick(i); + + svg += ` + + `; + svg += ` + + + ${tick.label} + + `; + + + }); + + yScale.ticks.forEach((tick: any, i: number) => { + const y = yScale.getPixelForTick(i); + svg += ` + + `; + svg += ` + + ${tick.label} + `; + }); + let lx = width - 74; + let ly = 25; + + chartInstance.data.datasets.forEach((dataset: any, i: number) => { + const y = ly + i*20; + const meta = chartInstance.getDatasetMeta(i); + const points = meta.data.map((pt: Data) => `${pt.x},${pt.y}`).join(" "); + const color = dataset.borderColor || "black"; + const radius = (dataset.pointRadius && dataset.pointRadius === 0) ? 0 : dataset.pointRadius; + //const radius = dataset.pointRadius && dataset.pointRadius > 0 ? dataset.pointRadius : 3; + svg += ` + + ${dataset.label || `Series ${i+1}`} + + `; + meta.data.forEach((pt: Data) => { + svg += ` + + `; + }); + + + }); + const xTitle = chartInstance.options?.scales?.x?.title; + + if (xTitle?.display && xTitle.text) { + svg += ` + ${xTitle.text} + `; + } + + const yTitle = chartInstance.options?.scales?.y?.title; + + if (yTitle?.display && yTitle.text) { + svg += ` + + ${yTitle.text} + + `; + } + svg += ``; + break; + } + } + return svg +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 70ee410..20ea588 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,18 +1,19 @@ -import {handleMarkdown, handleGlobalOptions, evaluateExpressions, PlotData, parsedText, handleTableData, Equation, GlobalProperties,Data} from "../helpers/parser" +import {handleMarkdown, evaluateExpressions, PlotData, parsedText, handleTableData, Equation, GlobalProperties,Data} from "../helpers/parser" import { createPlot, buildDatasets} from "../helpers/graphs"; import {Menu, Notice, Plugin, MarkdownView} from "obsidian"; import { apply } from "mathjs"; import { setApp } from "../helpers/appContext"; import { PlotPluginSettings, DEFAULT_SETTINGS, PlotSettingTab } from "settings"; -import { ChartOptions, ChartType, Ticks } from "chart.js/auto"; +import { ChartOptions, ChartType} from "chart.js/auto"; import {autocompletion} from "@codemirror/autocomplete"; - +import {generateSVG} from "../helpers/svgFormatting"; import { PlotCompletionSource } from "../helpers/plotProperties"; import { zoom } from "chartjs-plugin-zoom"; import { divideScalarDependencies } from "mathjs"; import { codePointSize } from "@codemirror/state"; import { e } from "mathjs"; +import { simplify } from "mathjs"; export default class PlotPlugin extends Plugin { settings!: PlotPluginSettings; @@ -40,6 +41,7 @@ export default class PlotPlugin extends Plugin { const defaultProperties: ChartOptions = getDefaultPlotProperties(this.settings,chartType); const sourcePaths = getSourcePaths(newMarkdown); // Gets any source:: item from path PATHS. Stores them for comparisons later. if (defaultProperties === undefined) return; + let cachedParsedText = await handleMarkdown(newMarkdown,defaultProperties,chartType); // Evaluates all the markdown in the codeblock and creates a ParsedText type object. let chartInstance: any = undefined; @@ -228,7 +230,7 @@ export default class PlotPlugin extends Plugin { .setTitle("Save SVG to Vault") .setIcon("image-file") .onClick( async () => { - await this.exportChartSVG(chartInstance) + await this.exportChartSVG(chartInstance,chartType) })); menu.showAtPosition({x: e.pageX, y: e.pageY}); @@ -251,193 +253,10 @@ export default class PlotPlugin extends Plugin { } await this.app.vault.createBinary(path,bytes.buffer); } - async exportChartSVG(chartInstance: any, path = `${this.settings.saveImagesPath}/Chart_${Date.now()}.svg`) { - const width = chartInstance.width; - const height = chartInstance.height; - - //const backgroundColor = this.settings.backgroundColor; - const xScale = chartInstance.scales.x; - const yScale = chartInstance.scales.y; - const area = chartInstance.chartArea; - const style = getComputedStyle(document.body); - const bg = style.getPropertyValue("--background-secondary").trim() || "#ffffff"; - const text = style.getPropertyValue("--text-normal").trim() || "#000000"; - const muted = style.getPropertyValue("--text-muted").trim() || "#666666"; - const border = style.getPropertyValue("--background-modifier-border").trim() || "#d0d0d0"; - - let svg = ``; - svg += ` - - - - `; - const title = chartInstance.options?.plugins?.title; - - if (title?.display && title.text) { - svg += ` - ${title.text} - `; - } - xScale.ticks.forEach((tick: any, i: number) => { - const x = xScale.getPixelForTick(i); - - svg += ` - - `; - svg += ` - - - ${tick.label} - - `; - - - }); - - yScale.ticks.forEach((tick: any, i: number) => { - const y = yScale.getPixelForTick(i); - svg += ` - - `; - svg += ` - - ${tick.label} - `; - }); - let lx = width - 74; - let ly = 25; - - chartInstance.data.datasets.forEach((dataset: any, i: number) => { - const y = ly + i*20; - const meta = chartInstance.getDatasetMeta(i); - const points = meta.data.map((pt: Data) => `${pt.x},${pt.y}`).join(" "); - const color = dataset.borderColor || "black"; - const radius = (dataset.pointRadius && dataset.pointRadius === 0) ? 0 : dataset.pointRadius; - //const radius = dataset.pointRadius && dataset.pointRadius > 0 ? dataset.pointRadius : 3; - svg += ` - - ${dataset.label || `Series ${i+1}`} - - `; - meta.data.forEach((pt: Data) => { - svg += ` - - `; - }); - - - }); - const xTitle = chartInstance.options?.scales?.x?.title; - - if (xTitle?.display && xTitle.text) { - svg += ` - ${xTitle.text} - `; - } - - const yTitle = chartInstance.options?.scales?.y?.title; - - if (yTitle?.display && yTitle.text) { - svg += ` - - ${yTitle.text} - - `; - } - svg += ``; + async exportChartSVG(chartInstance: any, chartType: ChartType, path = `${this.settings.saveImagesPath}/Chart_${Date.now()}.svg`) { + const svg = generateSVG(chartInstance,chartType); await this.app.vault.create(path, svg); - - } + } } @@ -494,7 +313,7 @@ export function customNotice(msg: string, cls = "", timeout = 4000) { const notice = new Notice(msg, timeout); requestAnimationFrame(() => { - const el = notice.noticeEl; + const el = notice.containerEl; if (el && cls) el.classList.add(cls); }); } @@ -506,7 +325,27 @@ export function getDefaultPlotProperties(settings: PlotPluginSettings, chartType }; } +function showError(container: HTMLElement, title: string, err: unknown) { + const msg = err instanceof Error ? err.message: String(err); + container.empty(); + const box = container.createDiv("datachart-error"); + box.createEl("div",{text: title, cls: "datachart-error-title"}); + box.createEl("div",{text: simplifyError(msg)}); + console.error(err); +} +function simplifyError(err: unknown): string { + const msg = err instanceof Error ? err.message : String(err); + + if (msg.includes("Undefined symbol")) { + const m = msg.match(/Undefined symbol (\w+)/); + if (m) return `Unknown variable: ${m[1]}. Variables written as var(x) are not currently supported.`; + } + if (msg.includes("Unexpected token")) return "Syntax error in expression"; + if (msg.includes("Cannot read")) return "Missing property or invalid object path."; + + return msg; +} export function getTypeDefaults(chartType: ChartType, settings: PlotPluginSettings): ChartOptions { const style = getComputedStyle(document.body); diff --git a/styles.css b/styles.css index 034f4c7..14b173b 100644 --- a/styles.css +++ b/styles.css @@ -56,3 +56,12 @@ box-shadow:none ; } +.datachart-error{ + padding:12px; + border-radius:8px; + background: var(--background-secondary); + border:1px solid var(--color-red); + color: var(--text-normal); + font-family: var(--font-monospace); + white-space: pre-wrap; +} \ No newline at end of file