diff --git a/src/engine/evaluator.ts b/src/engine/evaluator.ts index a388ebc..4eaf43f 100644 --- a/src/engine/evaluator.ts +++ b/src/engine/evaluator.ts @@ -397,7 +397,7 @@ function evalDateArith(op: string, l: Value, r: Value): Value { return { ...date, ms, hasTime }; } -function evalAgg(name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product", ctx: EvalCtx): Value { +function evalAgg(name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart", ctx: EvalCtx): Value { const { lineValues, lineKinds, index } = ctx.line; if (name === "prev") { for (let i = index - 1; i >= 0; i--) { @@ -421,6 +421,17 @@ function evalAgg(name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "produ if (name === "count") return qty(new Decimal(block.length)); + if (name === "chart") { + const refDim = block[0].unit?.dimension ?? null; + const compatible = block.filter((q) => (q.unit?.dimension ?? null) === refDim); + if (compatible.length < 2) throw new EvalError("need at least 2 values for chart"); + return { + kind: "chart", + points: compatible.map((q) => toBase(q)), + unitLabel: block[0].unit?.format ?? null, + }; + } + if (name === "min" || name === "max") { const refDim = block[0].unit?.dimension ?? null; const compatible = block.filter((q) => (q.unit?.dimension ?? null) === refDim); diff --git a/src/engine/index.ts b/src/engine/index.ts index c5d3cdf..8680bc2 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -114,7 +114,7 @@ export class SumEngine { lineValues.push(value); lineKinds.push("normal"); results.push({ - text: value ? formatValue(value, this.settings) : null, + text: (value && value.kind !== "chart") ? formatValue(value, this.settings) : null, value, kind: "normal", tokens, diff --git a/src/engine/parser.ts b/src/engine/parser.ts index d284437..2737db0 100644 --- a/src/engine/parser.ts +++ b/src/engine/parser.ts @@ -19,7 +19,7 @@ export type Node = | { k: "const"; name: "pi" | "e" | "half" | "onehalf" } | { k: "var"; name: string } | { k: "date"; word: DateWord } - | { k: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" } + | { k: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart" } | { k: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node } | { k: "bit"; op: BitOp; l: Node; r: Node } | { k: "pctop"; op: PctOp; l: Node; r: Node } diff --git a/src/engine/registry.ts b/src/engine/registry.ts index d713f45..335a82e 100644 --- a/src/engine/registry.ts +++ b/src/engine/registry.ts @@ -27,7 +27,7 @@ export type Payload = | { t: "pctop"; op: PctOp } | { t: "percent" } | { t: "func"; name: string } - | { t: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" } + | { t: "agg"; name: "sum" | "avg" | "prev" | "count" | "min" | "max" | "product" | "chart" } | { t: "scale"; mult: Decimal } | { t: "repr"; repr: NumeralRepr } | { t: "special"; name: "unix" | "todate" } @@ -174,6 +174,7 @@ export function buildRegistry(): Registry { for (const v of vocab.variants("Operations", "min.variants")) reg.addPhrase(v, { t: "agg", name: "min" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "max.variants")) reg.addPhrase(v, { t: "agg", name: "max" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "product.variants")) reg.addPhrase(v, { t: "agg", name: "product" }, { caseSensitive: false }); + for (const v of vocab.variants("Operations", "chart.variants")) reg.addPhrase(v, { t: "agg", name: "chart" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "sqrt.variants")) reg.addPhrase(v, { t: "func", name: "sqrt" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "cbrt.variants")) reg.addPhrase(v, { t: "func", name: "cbrt" }, { caseSensitive: false }); for (const v of vocab.variants("Operations", "abs.variants")) reg.addPhrase(v, { t: "func", name: "abs" }, { caseSensitive: false }); diff --git a/src/engine/types.ts b/src/engine/types.ts index 5527756..37a967d 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -62,7 +62,13 @@ export interface DateVal { timeZone?: string; } -export type Value = Quantity | PercentVal | DateVal; +export interface ChartValue { + kind: "chart"; + points: Decimal[]; + unitLabel: string | null; +} + +export type Value = Quantity | PercentVal | DateVal | ChartValue; export function qty(value: Decimal.Value, unit: Unit | null = null, repr: NumeralRepr = "decimal"): Quantity { return { kind: "quantity", value: new Decimal(value), unit, repr }; diff --git a/src/engine/vocab-data.ts b/src/engine/vocab-data.ts index dcff414..2b58106 100644 --- a/src/engine/vocab-data.ts +++ b/src/engine/vocab-data.ts @@ -432,7 +432,8 @@ export const EN: VocabLocale = { "min.variants": "min,minimum,smallest", "max.variants": "max,maximum,largest", "product.variants": "product", - "random.variants": "random,rand" + "random.variants": "random,rand", + "chart.variants": "chart,spark,sparkline,graph" }, "Percent": { "as_a_percent_of.variants": "as a % of,as a percent of", @@ -909,7 +910,8 @@ export const RU: VocabLocale = { "min.variants": "минимум,мин,наименьшее", "max.variants": "максимум,макс,наибольшее", "product.variants": "произведение,произв", - "random.variants": "случайное,случайно,рандом" + "random.variants": "случайное,случайно,рандом", + "chart.variants": "график,спарклайн,диаграмма" }, "Percent": { "as_a_percent_of.variants": "как процент от,как процент", diff --git a/src/ui/app.css b/src/ui/app.css index cb95b84..ea4ff6f 100644 --- a/src/ui/app.css +++ b/src/ui/app.css @@ -321,6 +321,12 @@ body { text-overflow: ellipsis; } .result-line:hover { text-decoration: underline; } +.result-sparkline { + position: absolute; + right: 14px; + pointer-events: none; + opacity: 0.85; +} /* ---------- settings */ #settings-panel { diff --git a/src/ui/editor.ts b/src/ui/editor.ts index 4c0e91f..dcb1698 100644 --- a/src/ui/editor.ts +++ b/src/ui/editor.ts @@ -104,6 +104,52 @@ const varHighlightField = StateField.define({ provide: (f) => EditorView.decorations.from(f), }); +function buildSparkline(pts: number[], top: number): SVGSVGElement { + const NS = "http://www.w3.org/2000/svg"; + const W = 80, H = 20, pad = 2; + const svg = document.createElementNS(NS, "svg") as SVGSVGElement; + svg.setAttribute("width", String(W)); + svg.setAttribute("height", String(H)); + svg.setAttribute("viewBox", `0 0 ${W} ${H}`); + svg.classList.add("result-sparkline"); + svg.style.top = `${top}px`; + const min = Math.min(...pts), max = Math.max(...pts); + if (min === max) { + const line = document.createElementNS(NS, "line"); + line.setAttribute("x1", String(pad)); + line.setAttribute("y1", String(H / 2)); + line.setAttribute("x2", String(W - pad)); + line.setAttribute("y2", String(H / 2)); + line.setAttribute("stroke", "var(--result)"); + line.setAttribute("stroke-width", "1.5"); + svg.appendChild(line); + } else { + const coords = pts.map((v, i) => { + const x = pad + (i / (pts.length - 1)) * (W - 2 * pad); + const y = pad + (1 - (v - min) / (max - min)) * (H - 2 * pad); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(" "); + const poly = document.createElementNS(NS, "polyline"); + poly.setAttribute("points", coords); + poly.setAttribute("fill", "none"); + poly.setAttribute("stroke", "var(--result)"); + poly.setAttribute("stroke-width", "1.5"); + poly.setAttribute("stroke-linecap", "round"); + poly.setAttribute("stroke-linejoin", "round"); + svg.appendChild(poly); + const last = pts[pts.length - 1]; + const cx = (W - pad).toFixed(1); + const cy = (pad + (1 - (last - min) / (max - min)) * (H - 2 * pad)).toFixed(1); + const dot = document.createElementNS(NS, "circle"); + dot.setAttribute("cx", cx); + dot.setAttribute("cy", cy); + dot.setAttribute("r", "2"); + dot.setAttribute("fill", "var(--result)"); + svg.appendChild(dot); + } + return svg; +} + export class SumEditor { view: EditorView; private resultsEl: HTMLElement; @@ -272,22 +318,26 @@ export class SumEditor { private renderResults(): void { const frag = document.createDocumentFragment(); const doc = this.view.state.doc; - // documentTop is in screen coordinates; place results relative to the overlay const overlayTop = this.resultsEl.getBoundingClientRect().top; const docTop = this.view.documentTop; for (let i = 0; i < Math.min(this.results.length, doc.lines); i++) { const r = this.results[i]; - if (!r.text) continue; + const isChart = r.value?.kind === "chart"; + if (!r.text && !isChart) continue; const line = doc.line(i + 1); const block = this.view.lineBlockAt(line.from); const top = docTop + block.top - overlayTop; if (top < -40 || top > this.resultsEl.clientHeight + 40) continue; - const el = document.createElement("div"); - el.className = "result-line"; - el.textContent = r.text; - el.dataset.value = r.text; - el.style.top = `${top}px`; - frag.appendChild(el); + if (isChart && r.value?.kind === "chart") { + frag.appendChild(buildSparkline(r.value.points.map((p) => p.toNumber()), top)); + } else { + const el = document.createElement("div"); + el.className = "result-line"; + el.textContent = r.text; + el.dataset.value = r.text!; + el.style.top = `${top}px`; + frag.appendChild(el); + } } this.resultsEl.replaceChildren(frag); }