mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 20:21:58 +03:00
add chart/spark sparkline keyword — renders inline SVG in results column
This commit is contained in:
parent
1d77ee45d8
commit
eff90d3219
8 changed files with 91 additions and 15 deletions
|
|
@ -397,7 +397,7 @@ function evalDateArith(op: string, l: Value, r: Value): Value {
|
||||||
return { ...date, ms, hasTime };
|
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;
|
const { lineValues, lineKinds, index } = ctx.line;
|
||||||
if (name === "prev") {
|
if (name === "prev") {
|
||||||
for (let i = index - 1; i >= 0; i--) {
|
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 === "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") {
|
if (name === "min" || name === "max") {
|
||||||
const refDim = block[0].unit?.dimension ?? null;
|
const refDim = block[0].unit?.dimension ?? null;
|
||||||
const compatible = block.filter((q) => (q.unit?.dimension ?? null) === refDim);
|
const compatible = block.filter((q) => (q.unit?.dimension ?? null) === refDim);
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ export class SumEngine {
|
||||||
lineValues.push(value);
|
lineValues.push(value);
|
||||||
lineKinds.push("normal");
|
lineKinds.push("normal");
|
||||||
results.push({
|
results.push({
|
||||||
text: value ? formatValue(value, this.settings) : null,
|
text: (value && value.kind !== "chart") ? formatValue(value, this.settings) : null,
|
||||||
value,
|
value,
|
||||||
kind: "normal",
|
kind: "normal",
|
||||||
tokens,
|
tokens,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export type Node =
|
||||||
| { k: "const"; name: "pi" | "e" | "half" | "onehalf" }
|
| { k: "const"; name: "pi" | "e" | "half" | "onehalf" }
|
||||||
| { k: "var"; name: string }
|
| { k: "var"; name: string }
|
||||||
| { k: "date"; word: DateWord }
|
| { 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: "bin"; op: "plus" | "minus" | "mul" | "div" | "mod" | "pow"; l: Node; r: Node }
|
||||||
| { k: "bit"; op: BitOp; l: Node; r: Node }
|
| { k: "bit"; op: BitOp; l: Node; r: Node }
|
||||||
| { k: "pctop"; op: PctOp; l: Node; r: Node }
|
| { k: "pctop"; op: PctOp; l: Node; r: Node }
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export type Payload =
|
||||||
| { t: "pctop"; op: PctOp }
|
| { t: "pctop"; op: PctOp }
|
||||||
| { t: "percent" }
|
| { t: "percent" }
|
||||||
| { t: "func"; name: string }
|
| { 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: "scale"; mult: Decimal }
|
||||||
| { t: "repr"; repr: NumeralRepr }
|
| { t: "repr"; repr: NumeralRepr }
|
||||||
| { t: "special"; name: "unix" | "todate" }
|
| { 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", "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", "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", "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", "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", "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 });
|
for (const v of vocab.variants("Operations", "abs.variants")) reg.addPhrase(v, { t: "func", name: "abs" }, { caseSensitive: false });
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,13 @@ export interface DateVal {
|
||||||
timeZone?: string;
|
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 {
|
export function qty(value: Decimal.Value, unit: Unit | null = null, repr: NumeralRepr = "decimal"): Quantity {
|
||||||
return { kind: "quantity", value: new Decimal(value), unit, repr };
|
return { kind: "quantity", value: new Decimal(value), unit, repr };
|
||||||
|
|
|
||||||
|
|
@ -432,7 +432,8 @@ export const EN: VocabLocale = {
|
||||||
"min.variants": "min,minimum,smallest",
|
"min.variants": "min,minimum,smallest",
|
||||||
"max.variants": "max,maximum,largest",
|
"max.variants": "max,maximum,largest",
|
||||||
"product.variants": "product",
|
"product.variants": "product",
|
||||||
"random.variants": "random,rand"
|
"random.variants": "random,rand",
|
||||||
|
"chart.variants": "chart,spark,sparkline,graph"
|
||||||
},
|
},
|
||||||
"Percent": {
|
"Percent": {
|
||||||
"as_a_percent_of.variants": "as a % of,as a percent of",
|
"as_a_percent_of.variants": "as a % of,as a percent of",
|
||||||
|
|
@ -909,7 +910,8 @@ export const RU: VocabLocale = {
|
||||||
"min.variants": "минимум,мин,наименьшее",
|
"min.variants": "минимум,мин,наименьшее",
|
||||||
"max.variants": "максимум,макс,наибольшее",
|
"max.variants": "максимум,макс,наибольшее",
|
||||||
"product.variants": "произведение,произв",
|
"product.variants": "произведение,произв",
|
||||||
"random.variants": "случайное,случайно,рандом"
|
"random.variants": "случайное,случайно,рандом",
|
||||||
|
"chart.variants": "график,спарклайн,диаграмма"
|
||||||
},
|
},
|
||||||
"Percent": {
|
"Percent": {
|
||||||
"as_a_percent_of.variants": "как процент от,как процент",
|
"as_a_percent_of.variants": "как процент от,как процент",
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,12 @@ body {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
.result-line:hover { text-decoration: underline; }
|
.result-line:hover { text-decoration: underline; }
|
||||||
|
.result-sparkline {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- settings */
|
/* ---------- settings */
|
||||||
#settings-panel {
|
#settings-panel {
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,52 @@ const varHighlightField = StateField.define<DecorationSet>({
|
||||||
provide: (f) => EditorView.decorations.from(f),
|
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 {
|
export class SumEditor {
|
||||||
view: EditorView;
|
view: EditorView;
|
||||||
private resultsEl: HTMLElement;
|
private resultsEl: HTMLElement;
|
||||||
|
|
@ -272,23 +318,27 @@ export class SumEditor {
|
||||||
private renderResults(): void {
|
private renderResults(): void {
|
||||||
const frag = document.createDocumentFragment();
|
const frag = document.createDocumentFragment();
|
||||||
const doc = this.view.state.doc;
|
const doc = this.view.state.doc;
|
||||||
// documentTop is in screen coordinates; place results relative to the overlay
|
|
||||||
const overlayTop = this.resultsEl.getBoundingClientRect().top;
|
const overlayTop = this.resultsEl.getBoundingClientRect().top;
|
||||||
const docTop = this.view.documentTop;
|
const docTop = this.view.documentTop;
|
||||||
for (let i = 0; i < Math.min(this.results.length, doc.lines); i++) {
|
for (let i = 0; i < Math.min(this.results.length, doc.lines); i++) {
|
||||||
const r = this.results[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 line = doc.line(i + 1);
|
||||||
const block = this.view.lineBlockAt(line.from);
|
const block = this.view.lineBlockAt(line.from);
|
||||||
const top = docTop + block.top - overlayTop;
|
const top = docTop + block.top - overlayTop;
|
||||||
if (top < -40 || top > this.resultsEl.clientHeight + 40) continue;
|
if (top < -40 || top > this.resultsEl.clientHeight + 40) continue;
|
||||||
|
if (isChart && r.value?.kind === "chart") {
|
||||||
|
frag.appendChild(buildSparkline(r.value.points.map((p) => p.toNumber()), top));
|
||||||
|
} else {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.className = "result-line";
|
el.className = "result-line";
|
||||||
el.textContent = r.text;
|
el.textContent = r.text;
|
||||||
el.dataset.value = r.text;
|
el.dataset.value = r.text!;
|
||||||
el.style.top = `${top}px`;
|
el.style.top = `${top}px`;
|
||||||
frag.appendChild(el);
|
frag.appendChild(el);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
this.resultsEl.replaceChildren(frag);
|
this.resultsEl.replaceChildren(frag);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue