Releases: LubrieDev/lmath
Release list
1.5.0
1.5.0
The block syntax changes. obs-graph becomes _graph, and the same for the other five.
Nothing you already wrote breaks in this release. Both names render, side by side, so notes
written before today keep working after you update.
⚠️ Read this before skipping the update1.5.0 is the only release that can convert your notes for you.
In 2.0.0 the old
obs-*names stop being accepted completely — a note still written
obs-graphwill not render, it will sit there as a plain code block — and the Update notes
button is removed in that same release. There will be nothing left to convert your notes
automatically; the alternative will be editing every fence by hand.If you have blocks written the old way, open Settings → LMath → Update notes while you are
on 1.5.0. It takes one click and asks before writing anything.
That warning is the whole reason this version exists. The migration — the button, the startup
notice, the tool behind them — is temporary scaffolding to get from the old syntax to the new one,
and it is all removed in 2.0.0 together with the syntax it exists to leave behind.
The rename
| Until 1.4.0 | From 1.5.0 |
|---|---|
obs-graph |
_graph |
obs-system |
_system |
obs-derivate |
_derivate |
obs-integral |
_integral |
obs-trig |
_trig |
obs-vector |
_vector |
Why change them at all. A code-block identifier is a global key shared by every plugin you
have installed. graph bare is among the easiest names for someone else to have taken, and
whoever loses that draw stops rendering with no warning to anybody. The names needed a prefix that
marks them as this plugin's without being a word another plugin would want.
Why an underscore, specifically. The first attempt was graph*, and it did not work: Obsidian
does not preserve the asterisk from a fence's info string, so the key the plugin registered never
matched what was written in the note. \graph and .graph fail the same way — the identifier has
to stay inside the character set Obsidian accepts. The underscore does, and still marks the block
at a glance.
Both names work for now, and that ordering is deliberate. If the old identifier stopped
rendering in the same release that introduces the new one, notes would break before anyone could
press the button that fixes them. The migration button only makes sense on a plugin that already
accepts both syntaxes.
Updating your notes
Settings → LMath → Update notes. It scans the vault, shows you what it found — how many notes,
how many blocks, which files — and asks before writing anything. Files are written one at a time
through Obsidian's own atomic path, and only the fence line changes; nothing inside a block is
touched. If you cancel at the summary, nothing has been written.
You do not have to run it the moment you update — the old names keep rendering for as long as
1.5.0 is what you have installed. But 1.5.0 is the last release where the button exists, so
running it before you move on to 2.0.0 is the difference between one click and editing every
fence by hand.
It has been run by hand over a vault containing 300 LMath blocks, and all 300 were rewritten
correctly. That is a check on real notes, not part of the automated suite quoted at the end of
these notes; the scanner underneath it has 19 tests of its own.
There is also a notice at startup describing the change. It appears on every load, which is
deliberate and not a bug: it used to appear once per install, and start-up is exactly the moment
when several notices are stacked on top of each other, so the only one there was got spent without
anyone reading it and never came back. It goes away with the rest of the migration in 2.0.0.
A system written with ± was listing half its solutions
y = ±⁴√(1−x⁴) is not one curve, it is two. The plot always knew that — it draws both halves —
but the solver behind the ⓘ panel paired the equations as written and only ever crossed one of
them. Against y = ∛x, whose two curves are odd and therefore meet in a symmetric pair, the panel
named one point while the plot drew the crossing of the other.
The double sign is now expanded before any pairing happens, through the same code the plot uses.
Branches stay grouped by the equation they came from and are crossed group against group, never a
branch against its own sibling: where the two halves meet — the radicand at zero — the curve is
closing on itself, not crossing another one, and listing that point would be inventing a solution.
Measured over 162 hand-written blocks from a real vault, this changed that one system and no
other.
\sin{…} now accepts braces inside its argument
\cos{\sin{x}}, \sin{\sqrt{x}} and anything else with a braced group inside a trigonometric
function's braced argument did not render at all — the block came up empty, with no error.
The rule that converts \sin{ARG} could not match an argument containing braces, and it ran once,
outward in: in \cos{\tan{x}} it converted the inner \tan and left the outer \cos{ broken.
Repeating the pass would not have helped, because radicals are converted later in the pipeline,
so \sin{\sqrt{x}} still has the root's braces at that point. It now counts brace levels, which
is what \ln{…} and \log{…} have done for a while.
The same change covers the hyperbolic family, which had never been in that rule at all:
\sinh{x}, \cosh{x}, \tanh{x}, \sech{x}, \csch{x} and \coth{x} with braces did not work
either. Their arguments are not converted to degrees, unlike the trigonometric ones — \sinh{30}
is 30, \sin{30} is 30°.
The ⓘ box no longer jumps when you open it
Opening an ⓘ panel showed it briefly stretched to full height with a scrollbar, which then
collapsed to the right size a moment later. Its lines are typeset with KaTeX, and until that
finished each line was still a paragraph carrying its own margins — enough to push five lines past
the box's maximum height. The box now measures the same before and after, so it opens at its final
size.
Colour
The block used to take its panel colour from the theme's secondary background, which is lighter
than the primary in some themes and darker in others — so depending on your theme, the same
release rendered either as a recessed card or as a pale grey box around a dark plot.
The chrome is now three layers, all defined against the note itself: the plot surface is the note's
background, the area around the formula sits a step above it, and the formula card and the ⓘ box
sit a step below. It follows any theme, light or dark, and the relationship between the layers
stays the same in all of them.
Also in this release
- A
_derivateor_integralblock still shows its formula; the ⓘ panels of every block continue
to typeset their mathematics with KaTeX rather than as plain text. - Documentation rewritten for 1.5.0: the technical reference gains sections on the rename and its
tool, on the colour tokens, and on the±expansion.
Known limitations
These are real and not fixed in this release:
- A
_derivateblock does not understand the{0 ≤ x ≤ 2π}domain restriction, which
_graphdoes. Written with one, the block reports no function. - A
_derivateor_integralblock with a parameter declaration (A = 2on its own line)
does not work: parameters are a_graph/_systemfeature, and in these two blocks the
declaration is taken as the block's first equation. - A parameter cannot be defined from another parameter.
B = 2Ais not read as a
declaration, and the block draws nothing. - An integral whose interval contains two poles that cancel reports
0rather than
divergent.∫₀^π cot x dxis the case: each half diverges — the plugin says so if you ask for
either half on its own — but over the whole interval the two infinities cancel into a Cauchy
principal value, which is not the value of the integral.
Compatibility
minAppVersion stays at 1.13.0. Blocks written with obs-* render exactly as before.
1.4.0
Version 1.4.0
This release has two halves.
The first is a new block, obs-vector. Like obs-trig, it does not plot a function; unlike
every other block, it shows one card per line instead of one formula per block. It typesets
vector notation the way you would write it on paper, and it has an ⓘ panel for what follows
from what you wrote.
The second is a math engine, and it exists to correct a design decision that was wrong. The
solutions a system reported were taken from the plotted curves — the crossings of the drawn
polylines, clipped to whatever part of the plane you happened to be looking at. That is why an
intersection at the origin would read (0, 0) and then (8.4e-6, 8.4e-6) after you moved the
plane, and why a solution outside the view did not exist at all. Asking the drawing something only
the equations know was the mistake. Solutions are now computed from the equations, and for a
polynomial system they are exact and do not change when you zoom. The same defect, smaller, was in
the crosshair; that is corrected for explicit curves and described honestly below.
Around those two there is a third language, Portuguese; a fix to the ⓘ panels of obs-trig and
obs-vector, which closed themselves when you clicked anywhere; and a reorganisation of the source
that changes nothing you can see and is described at the end for whoever reads the code.
No existing block changes its syntax or draws a different curve. The things that do reach the five
published blocks are listed under Compatibility. minAppVersion stays at
1.13.0.
The new block: obs-vector
One line, one card
The block has no syntax of options: what a line means comes from the shape it has, and nothing is
resolved for you. The case of the first letter is the whole rule.
```obs-vector
v = (3,2)
A = (1,2)
F(x,y) = (-y, x)
```
| you write | it is | typeset as | drawn as |
|---|---|---|---|
v = (3,2) |
a vector — lowercase | \vec{v}, the real filled arrow of KaTeX |
an arrow from the origin |
A = (1,2) |
a point — UPPERCASE | A, bare |
a dot |
F(x,y) = (-y,x) |
a vector field — it has arguments | F(x,y), as written |
nothing |
AB, A->B, A → B, \vec{AB} |
the vector between two declared points | \overrightarrow{AB} |
an arrow from A to B |
| anything else | free notation | as written | nothing |
A name of two or more letters takes \overrightarrow, not \vec. \vec is a short mark
centred on one glyph: over AB it reads as the arrow of the A, with the B left outside.
Subscripts and primes decorate a single variable, so v_1 and u' keep \vec.
Two things override the case rule, both deliberately: arguments win, so f(x,y) = (-y,x) is a
field even in lowercase; and an arrow you wrote yourself wins, so \vec{A} = (1,2) really is a
vector. Writing \vec{v} = (3,2) is not doubled up.
A point is not drawn as an arrow from the origin. It is a dot. A point is not a position vector
unless you say so, and drawing it as an arrow would state something the author did not write.
The vector between two points
AB on its own line resolves to the vector from A to B, provided both are declared in the same
block — in any order, so AB may come first.
```obs-vector
A = (1,2)
B = (5,4)
AB
```
That block draws the two dots and the arrow that joins them, and puts the declarations and the
result on two separate views (below). If the two points are not both declared, AB is simply
the product A·B and is typeset as such: the block never invents coordinates you did not write.
Anything it does not recognise is still typeset
A line the block cannot classify gets its card anyway, which is what makes it usable for notation
the engine cannot evaluate. A LaTeX command LMath does not support is handed to KaTeX untouched
instead of being reported as an unsupported symbol:
```obs-vector
\nabla f(x,y)
```
Untouched is literal, and it applies only to that case. Every other line goes through the
plugin's ordinary pipeline, which normalizes before typesetting and there inserts the implicit
product it thinks it sees between a name and an opening parenthesis. So an unclassified line that
looks like a function call shows a stray ∗: ∇f(x,y) with the Unicode symbol comes out
∇f∗(x,y), and G(x,y) = -y comes out G∗(x,y) = −y. The clean forms are the LaTeX command
(\nabla f(x,y)) and, for a function of two variables, a pair on the right — which is exactly what
makes it a field.
Components accept everything the rest of the plugin accepts (\frac{1}{2}, 2\pi, -\sqrt{2},
3), and both (3,2) and [3,2] are read as pairs.
The plane is always there, and says when it is empty
The view is computed once from the vectors themselves and has no camera: there is no panning,
no zooming and no dragging here, because a finite set of arrows is fully known in advance and there
is nothing to explore. Each card and its arrow share the colour of their line.
When no line has numbers to draw, the plane is dimmed and tells you why instead of sitting
there empty:
- an empty block says No vector, and the card still shows the shape it expects,
\vec{v} = […]; - a block that writes something undrawable — a field
F(x,y), a gradient∇f(x,y), an unresolved
w = u + v— says Nothing to draw. Those lines are typeset above; they simply are not arrows.
An empty plane that says nothing looks like a broken block, and hiding it made the same block look
like two different blocks depending on what you had written.
The labels are the same letters as the cards
The name beside an arrow is rendered with KaTeX over the canvas, not drawn on it. The \vec{v}
on the plane is the very same LaTeX as the \vec{v} on its card — the same filled arrow, the same
italic — and \overrightarrow{AB} keeps the long arrow that stretches over both letters. A canvas
fillText can only put the system's italic there, and next to a real formula it shows.
What is not drawn is not labelled either: a field and a free line have no mark on the plane.
ⓘ — what follows from what you wrote
When there is a plane there is an ⓘ on it. It reports what can be deduced from the arrows and
dots already drawn — properties of what is there, not new objects. There is no u+v in it, because
you did not write one. That is the same line the cards do not cross, applied to the panel.
- One collapsible section per vector, headed by the name you gave it: its
xandy, its
magnitude, its direction, the quadrant or semiaxis it falls in, and its unit vector. ForABthe
components are the differenceB − A, which is not the pair of any card. - With exactly two vectors, one further section: dot product, angle between them, determinant,
the area of the parallelogram they span and that of the triangle — and, when it holds,
Perpendicular or Parallel. With exactly two points: distance and midpoint. - Two and only two. Five vectors make ten pairs, and the panel would become a matrix nobody
reads; the block will not choose a pair for you. - Values are exact when they were earned: with integer components the magnitude of
(3,2)is
√13 ≈ 3.606, with the largest square taken out of the radicand (√12is written2√3).
(0.5, 1.3)gets the decimal alone. It is the discipline ofobs-trig, applied to the only
provenance this block can check. - Angles are written by the same function that labels
obs-trig, so they follow the Angle
unit setting and come with their exact form when they have one. The angle between two vectors is
computed asatan2(|det|, dot)and not with the arccosine of the textbook formula, which loses
its precision exactly at 0 and π — the two cases a reader would check first.
Two views when the block deduces something
A line like AB is not of the same order as A = (1,2): the second declares, the first asks for a
result. When both are present, the panel separates them behind the button bar obs-derivate and
obs-integral already use — the main button shows what the block declares, one card each, and
the menu (☰) leads to \overrightarrow{AB} alone.
It also fixes a matter of room. Stacked, three cards split the column equally and
\overrightarrow{AB} — taller than a bare name — was the only one left with a scrollbar. Alone in
its view, a card grows with its content instead of shrinking. The panel is sized for the fullest
view, so switching never changes the height of the block.
A block without a difference has no bar in its panel, which is still the common case.
How the cards are laid out
Up to four lines, the block grows to fit them. From the fifth on, the cards share the panel height
instead of making the block taller, so a long block gets small cards, each with its own scrollbar.
On a narrow block the whole panel moves behind an f(x) button over the plane, as in the other
blocks; it and the ⓘ are mutually exclusive, since they open over the same plane.
What this version deliberately does not do
obs-vector writes, draws and reports. It does not operate: nothing in it combines two of your
vectors into a third. None of the following is implemented, and nothing in the block hints that it
might be:
- No operations between vectors.
w = u + vis typeset, not resolved — not in the cards and
not in the ⓘ. - No relation between three or more. The pair sections need exactly two vectors, or exactly two
points; the block does not pick a pair out of five. - No arrow field for
F(x,y). It is not one vector, it is infinitely many, and drawing a single
one would misreport it. - No camera. No panning, no zoo...
1.3.3
1.3.3
Why 1.3.2 was superseded
1.3.2 did not pass Obsidian's automated review. Everything else in that review passed: the
main.js and styles.css assets carried verified GitHub attestations, no suspicious network
patterns were found, no vulnerable dependencies were detected, and the build check reproduced
the released main.js byte for byte from the repository, which is the strongest thing the
report can say about a plugin. The only failure was in the source-code check: eight instances of
obsidianmd/no-static-styles-assignment, all of them in the new block's chrome.
A release marked Failed is not one anyone should install, so 1.3.2 is superseded by this
one. This release is 1.3.2 with that check satisfied and nothing else changed: the plugin
behaves exactly as the 1.3.2 release notes describe, and that document remains the reference
for what obs-trig does.
The fix
The eight findings were one defect repeated, not eight different problems: styling that never
changes, written as inline strings in the code instead of as rules in the stylesheet. The rule
asks for CSS classes, setCssStyles or setCssProps, and all eight cases could simply be
replaced with CSS classes. What stayed in the host is what genuinely depends on values computed
at runtime: the chip's side, the box's width and the handle's position. That is why the review
never flagged them.
-
The slider's focus ring left the TypeScript entirely. A
focuslistener and ablur
listener that painted an outline became a single:focus-visiblerule instyles.css. That
also fixes an accessibility detail: the ring used to appear on mouse clicks too, which is
precisely when nobody needs it.:focus-visiblelets the browser make that distinction. The
inlineoutline: nonehad to disappear with those listeners, otherwise it would have overridden
the stylesheet rule and the focus ring would never have appeared. -
The trig controls' two layouts became two CSS classes, toggled at the width threshold
instead of swapping style strings over one another. The height of the bottom strip now reaches
the stylesheet throughsetCssPropsas a custom property, soALTO_CONTROLES_TRIGremains the
single source of truth for that measurement. Copying the value into the stylesheet would have
created two sources of truth for the same layout, and that dimension is shared with the canvas. -
The remaining four findings were static pieces: the reading band, the component row, the
live value itself — the large number the whole panel revolves around — and one row of the ratio
table. They are now ordinary CSS classes. The row that holds that value (θ on the left, the
number on the right) was converted as well, even though the review did not flag it, because it
was the same kind of rule one line away. Converting only half of a block is how a stylesheet
becomes harder to maintain.
What did not change
No behavior, no syntax, no rendering and no settings changed. minAppVersion remains
1.13.0. If any block, including obs-trig, behaves or renders differently after updating,
that is a defect and should be reported.
Validation
- Main suite: 486 passed, 0 failed.
- Zoom suite: 12 passed, 0 failed.
- Typecheck and build: clean.
- Review audit: 0 findings, down from the eight this release exists to remove.
- CSS audit: clean.
What the tests do not cover. The block host still has no automated DOM tests, and this
release only moved presentation code, which is exactly the part no automated suite can see. Two
things were therefore verified manually: that the slider shows its focus ring when reached with
Tab but not when clicked, and that the controls transition correctly across the 520 px
breakpoint in both directions.
1.3.2
Version 1.3.2
This release adds one new block, obs-trig, and two settings that go with it. It is the first
LMath block that does not plot a curve: it draws the unit circle, and you drive an angle around it.
It also brings an angle unit selector — degrees, radians or gradians — as a global plugin
setting and as a chip on the block.
Nothing else changes. The four existing blocks — obs-graph, obs-system, obs-derivate,
obs-integral — keep the same syntax and draw the same curves, and minAppVersion stays at
1.13.0.
The new block: obs-trig
An empty block already renders a working figure at 30°: in this block the unit circle is the
content, so there is nothing missing to report.
```obs-trig
θ = 30°
```
Writing a block
One line, one angle. The = only assigns a name, and the name is optional — it is not an
equation, so 30° on its own is a complete block and gets called θ.
| you write | you get |
|---|---|
| (empty) | 30° |
30° |
one angle, named θ by default |
θ = 30° |
the same angle, named θ explicitly |
α = 30° … δ = 330° |
four angles at once |
θ = 750° |
two turns and 30°: the point sits at 30° and the turn count says so |
θ = -45° |
negative angles are ordinary input |
θ = 30 |
30 radians, not 30 degrees |
θ = \frac{\pi}{6}, pi/6, 2\pi |
π is written however you write it anywhere else |
The two unit rules, which are not the same rule. The angle a block declares is read in
radians when it is a bare number: θ = 30 is 30 radians and lands at 1718.9°, not at 30°.
Degrees need the °. But inside a trigonometric function the plugin keeps its usual
convention, which is the opposite one: a literal argument is read in degrees, so sin(30) is
0.5 — sine of 30 degrees — here exactly as in obs-graph. Written as the angle of a block,
θ = sin(30) is therefore 0.5 radians. Two rules, two places; the ° is what removes all
doubt from the first.
A line that is not a readable angle is reported in the panel, just under the card — not over
the plane, where it would cover the controls. At most three are listed, and a +N counts the
rest. If no line is readable the block still draws: it falls back to 30° and reports what it
could not read.
Naming a ratio turns its trace on
If the expression is exactly a call to sin, cos or tan on a constant angle, the block
opens with that component already drawn, solid and with its construction. Writing the name of a
ratio is already saying which one you want to look at; making you press the toggle afterwards is
asking you to repeat yourself.
```obs-trig
sin(30)
```
It chooses a trace, it does not reinterpret the source. The angle is still the value the
expression evaluates to, by the same rule as everywhere else — sin(30) is 0.5, so the block
draws 0.5 radians and lights the sine of that angle. Nothing about the reading changes.
"Exactly" is meant literally, and it is checked on the normalized expression, which is the one
that actually gets evaluated:
| you write | opens with |
|---|---|
sin(30), \sin{30}, \sin 30, cos(45°), \tan{\frac{\pi}{4}} |
that component |
2sin(30), -sin(30), sin(30)*2, sin(30)+cos(30) |
nothing: the call is not the whole expression |
asin(0.5), arcsin(0.5), sinh(1), cot(30), sec(30) |
nothing: no trace on the figure |
sin(x) |
nothing — and it is not an angle either, so it is reported |
30°, \frac{\pi}{6}, 750° |
nothing, as before |
Only the angle that opens active is consulted, because the components belong to the active
angle. And it is only a seed: the moment you touch a toggle the selection is yours, and moving to
another angle with Tab does not seed it again — a choice that undid itself as you navigated would
be the same mistake as rewriting the note when you drag.
The figure
The framing is fixed — no camera, no zoom, no pan. The wheel is never captured, so the note
scrolls normally when the mouse is over the block. On touch the plane does keep the gesture, as
every LMath plane does: a swipe that starts on the circle drives the angle instead of scrolling the
note, so scroll from the panel or from the margin.
- Two grids. A faint cartesian 1:1 grid and dotted radial spokes every 15°. Each measures what
the other cannot: the cartesian grid measures the vertical and horizontal lengths that sine and
cosine are, so "this leg is a half" can be counted in quarter-unit cells — half-unit ones once
the circle gets small enough that a quarter would stop being legible — while the spokes
measure the angle and anchor the labels around the rim. The four multiples of 90° carry no spoke —
the axes are already there, and a dotted line over an axis only blurs it — so 20 are drawn. - The 24 notable angles are marked on the circumference, the sixteen classics — the multiples
of 30° and 45° — with a fatter dot than the rest. Those sixteen are also labelled outside the
rim in both units at once, degrees over the fraction of π. The labelling adapts to the size
of the plane: two lines, then one line in the active unit, then the four axes only, then none.
The label that falls under the active angle stays quiet, because the coordinates of P need that
gap — and it is matched by coterminal, soθ = 750°silences the label at 30°. - Per angle, its terminal side from the origin and its point on the circumference. The
active angle is drawn last, so it stays on top where two cross, and it is heavier and carries
a halo. It also takes the figure's own line colour; the angles that are not active are the ones
that carry a colour each, from the same palette the other blocks use for several curves. - The exact coordinates of P next to the point:
(√3/2, 1/2)where other tools print
(0.866, 0.5). Only for the active angle, and only when it has earned an exact value (see
below); otherwise nothing is written.
Sine, cosine and tangent
The three are always on the plane — for the active angle, which is the one the whole panel
talks about — dotted and each in its own colour: sine purple, cosine blue, tangent green. The
panel's three toggles promote them from dotted to solid and add their construction: the sine's
guide to the axis, and for the tangent the auxiliary line x = 1 and the point S where it meets the
terminal side. They are independent, so you can have none, one, two or all three, and a block opens
with none of them promoted unless it names one (see above).
The three tones are the plugin's own blue, green and purple — the same ones the other blocks give
to several curves, not a new family — and the suite checks that the three stay distinct in both
themes. The names sin, cos and tan in the panel carry the colour of their component, so the
table reads as the plane's legend.
The tangent is joined to the terminal side. Where the join starts depends on the sign of the
cosine, because the tangent is built on the terminal side's line, not on its ray: with cos θ > 0
it runs from P outward, and with cos θ < 0 the line meets x = 1 on the opposite side, so it runs
from the origin.
At 90° and 270° the drawing shows why the tangent does not exist. There the terminal side is
the Y axis, parallel to x = 1, so it never meets it: only that prolongation is drawn, running off
the plane. No segment over x = 1, which would be infinite, and no point S, which has nowhere to be.
Exact values
All 24 notable angles — the multiples of 15° — carry their six ratios in exact form, √3/2 and
(√6−√2)/4 included. On the four angles that land on an axis two of the six do not exist, and
those read "undefined" rather than a number.
They are written in plain unicode, on the plane and in the panel alike: the panel is rebuilt on
every frame of a drag, and putting six KaTeX formulas through that would be untenable — at this
size the unicode reads just as well. The only formula the block renders as maths is the fixed
x² + y² = 1 at the top of the panel.
The right to an exact value comes from the written text, not from the number. A block earns it
by naming the angle in degrees or in terms of π — 30°, \frac{\pi}{6}, 2\pi. 0.5236 never
claims to be sine 1/2, however close it passes to π/6, and neither does θ = 30, which is a
perfectly ordinary angle of 30 radians with no closed form.
An angle produced by the block's own controls earns the right too, because its provenance is
known: drag it, step it with the keyboard, move the slider or run the animation and that angle may
show exact values from then on. It will only actually show them when it lands on a multiple of 15°
— which the magnet, Page Up/Page Down and Home do exactly. A decimal typed by hand never
earns it.
The panel
Everything in it describes the active angle. Three bands:
x² + y² = 1, fixed, with the live point below it:P(30°) = (√3/2, 1/2). The law that
defines the figure stays put while the point that satisfies it moves. The angle is written in
the unit the chip selects, so it readsP(π/6 rad)orP(33.33 gon)just the same.- The reading. With exactly one component selected, that ratio alone and large, headed by its
name in its own colour; with none or with several, the three ratios in a table. Each one shows
its exact form with the decimal beside it, or just the decimal when there is no exact form. - The controls: the three component toggles, the live value of θ, and the angle slider.
Below 520 px of block width the panel stops being a column: it floats over a square plane and
starts closed. The controls are the one part that does not go with it — they move to a fixed strip
a...
1.3.1
Version 1.3.1
This release touches three areas: the explicit tracer's geometry budget, the algebra and
notation of the panels, and the ⓘ information panel, which now describes polar curves,
parametric curves, integrals and derivatives on their own terms instead of with categories
borrowed from y = f(x).
The common problem behind all three is the same: results that were correct but not usable.
The tracer spent a correct budget in an order that made two passes disagree; the panel printed
values that were exactly right and unreadable (7.0710678118654755 for √50, 1.5708 for
π/2); and the ⓘ panel answered cartesian questions about objects that are not cartesian curves
— it told you where the integrand of ∫₀² 2x dx crossed the y axis, which is a fact about a line,
not about the integral the block is there to compute.
What a user notices: less flicker when releasing a drag on dense curves, expressions written the
way a person writes them, and an information panel that describes the object in front of them.
Two changes affect existing notes — one alters what a block draws, the other raises the minimum
Obsidian version. See Breaking changes.
What changed?
Graphics engine and tracer
Before. 1.2.9 capped how much geometry the explicit tracer may emit, so tan(e^x) could no
longer take Obsidian down. The ceiling was right; its distribution was not. It was a single
global bag of anchoPx × 2048 vertices, spent in sweep order, left to right.
Now. Each pixel column carries its own quota. The bar per column is the same 2048, so the
total ceiling does not rise — density in one place can no longer borrow from another.
Why the old scheme was visible. The point at which the bag ran dry depended on how many base
samples a pass had, and the interactive pass and the final pass do not have the same number. They
exhausted it at different values of x and truncated different branches, which is flicker arriving
exactly when you release the mouse. Measured over the canvas:
| view | pixels changed between gesture and final image |
|---|---|
tan(x²) at ±300 |
14.2 % |
tan(x²) at ±200 |
3.5 % |
tan(e^x) default |
9.9 % |
(Those three are measurements of the old code, taken while the defect existed. They are not
reproducible from this build for the obvious reason, and are quoted as the reason the change was
made, not as evidence of its result — for that, see the pixel maps under Validation.)
In the last one the gesture pass drew more than the final one — detail appeared while dragging
and vanished on release, the opposite of what a final pass is for. Views where the budget never
bites, which is the whole ordinary repertoire, were unaffected then and are unaffected now.
A fairer split of the same ceiling also does less work, which is not obvious. The old bag
was genuinely being spent to the last vertex: in tan(e^x)'s default view, 768 px × 2048 is
1 572 864 and the tracer emitted 1 573 289. With a per-column quota only the dense columns
hit the bar and the sparse ones never spend what they do not need: 521 914 vertices, 67 %
fewer, for a nearly identical drawing. It also bites locally where a global bag noticed nothing:
tan(x²) at ±120 was nowhere near the old ceiling and still emits 9 % fewer vertices while
painting exactly the same pixels — that refinement was sub-pixel work inside crowded columns.
Two of the thirty-four bench cases draw differently, both in the pathological zone.
Everywhere else the pixel map is identical, hash for hash, for less work.
| case | pixels | branches | asymptotes |
|---|---|---|---|
tan(x²) ±300 |
129 861 → 136 300 (+5 %) | 11 914 → 10 770 | — |
tan(e^x) default |
72 313 → 91 908 (+27 %) | 23 740 → 18 228 | 23 734 → 6 385 |
The asymptote drop deserves stating plainly: agotado decides not only how much geometry is
emitted but also whether a pole is recorded as an asymptote and whether a continuous crossing is
joined instead of split, so stopping earlier in a column registers fewer. In that view they were
31 per pixel of width and are now 8 — still denser than the screen can separate, but whether
this is visible has not been verified, and the asymptote overlay draws outside the saturated
band as well as inside it. This is the one open question in the release.
Algebra and symbolic simplification
Exact constants recovered from decimals
Before. Recovering an exact constant scanned a table of √k for k = 2…40. Inside the
table it worked; one step outside, the decimal went to the screen. √50, √72, √300, √20+√5
(which is √45) and every cube root that has ever existed were outside it.
Now. Arithmetic instead of a catalogue: square the number and see whether an integer comes
back. That covers all k rather than the first forty, extends to cube roots for free, and costs
one multiplication.
Rationalisation limited to fractions people write
Before. simplify turns every decimal into an exact fraction with a factory denominator
limit of 10 000. A four-digit denominator is the decimal expansion wearing a hat.
Now. Lowered to 64. Everything genuinely written by hand survives — 0.5→1/2,
2.5→5/2, 0.125→1/8, 1.75→7/4, 0.0625→1/16 — and 0.5637 is left as typed.
| you write | before | now |
|---|---|---|
√20 |
√20 |
2√5 |
√50 |
7.0710678118654755 |
5√2 |
√20+√5 |
6.708203932499369 |
3√5 |
∛54 |
3.7797631496846193 |
3∛2 |
√(20x) |
√(20x) |
2√(5x) |
x^{0.5637} |
\sqrt[10000]{x^{5637}} |
x^{0.5637} |
(x^{0.5637})² |
\sqrt[5000]{x^{5637}} |
x^{1.1274} |
√2/2 |
1/√2 |
√2/2 |
That last row ran backwards: the reciprocal was returned unrationalised, so writing the
correct form produced the incorrect one and Simplify stopped being a no-op on its own output.
Pulling a square factor out of a radical is exact and does not move the domain (20x ≥ 0 and
5x ≥ 0 are the same condition), so no curve changes. √(2)^√2 correctly stays a decimal — it
is not a·ⁿ√b in any form, and the recogniser says so instead of guessing.
Radicals are drawn only when they read better than the power
Before. The emitter's rule was "a rational exponent becomes a radical", with no ceiling on
the index; the rationalisation limit above was doing all the work. Writing the fraction
yourself still produced x^{5/64} → \sqrt[64]{x^{5}} and x^{7/32} → \sqrt[32]{x^{7}}.
Now. Two ceilings, applied to the radical that would actually be painted:
- index ≤ 5 with a power inside;
- index ≤ 8 for a pure root, which has no exponent to read on top —
\sqrt[8]{x}is fine,
\sqrt[8]{x^{7}}is worse thanx^{7/8}and is exactly the trade being avoided.
The 5 rather than a rounder 4 is a deliberate trade-off: y^{2.5} = x ⇒ ⁵√(x²) is a case this
same release adds, and a ceiling of 4 silently removed it. Where the readability argument cannot
separate two options, not regressing an existing decision wins.
The purpose of a ceiling is stability: under the old rule an expression's appearance depended
on whether the rationaliser happened to find a fraction, which is an internal detail.
Irrational exponents are also left alone now. x^{π/2} used to be painted \sqrt{x^{π}}, which
is true and not what anyone writes: the radical is the canonical notation for a rational
exponent, where p/q means "q-th root of the p-th power", and the /2 in π/2 is ordinary
division, not an index. Same for φ/2, e/3 and τ/4. Consistency is preserved in the other
direction: x^{0.5φ} and x^{φ/2} still render identically.
Radicals finish the job
Before. Converting a rational power to a radical was the only rewrite. \sqrt[4]{x^{11}} is
correct and nobody writes it.
Now. The Euclidean division m = q·k + r pulls every perfect power out of the radicand, and
a negative rational exponent becomes the reciprocal of the radical.
| you write | before | now |
|---|---|---|
x^{3/2} |
\sqrt{x^{3}} |
x\sqrt{x} |
x^{7/2} |
\sqrt{x^{7}} |
x^{3}\sqrt{x} |
x^{11/4} |
\sqrt[4]{x^{11}} |
x^{2}\sqrt[4]{x^{3}} |
x^{5/4} |
\sqrt[4]{x^{5}} |
x\sqrt[4]{x} |
(2*x)^(5/2) † |
4\sqrt{2}x^{2}\sqrt{x} |
4x^{2}\sqrt{2x} |
(3*x)^(7/2) † |
27\sqrt{3}x^{3}\sqrt{x} |
27x^{3}\sqrt{3x} |
x^{-1/2} |
x^{\frac{-1}{2}} |
\frac{1}{\sqrt{x}} |
x^{-5/2} |
x^{\frac{-5}{2}} |
\frac{1}{x^{2}\sqrt{x}} |
† The two composite-base rows hold for that spelling only; written (2x)^{5/2} with braces the
panel shows 4\sqrt{2x^{5}}. The reason, and what it would take to close it, are at the end of
this section.
Written as a decimal the result is identical: x^{1.5} and x^{3/2} are the same function and
now look it. A negative integer exponent stays a power, because x^{-2} is already how that
is written.
Composite bases stay composite. simplify distributes a power over a product, turning
(2x)^{5/2} into 4·2^{1/2}·x^{5/2}, and the panel painted 4√2·x²√x — two loose radicals
where one is written by hand. It also contradicted the plugin's own convention, since √(20x)
is left as 2√(5x). Reaching 4x²√(2x) needed no new machinery, only the right order: the
Euclidean extraction moved from the emitted LaTeX to the tree, so splitting x^{5/2} into
x²·x^{1/2} as nodes leaves that x^{1/2} visible to a second pass, which merges radicals of
equal exponent and finds 2^{1/2} beside it. On the finished string that √x was text and
could not be touched.
Merging radicands is guarded, not free. √a·√b = √(ab) fails when both are negative
(√(−1)·√(−1) is NaN while √1 is 1). One radicand being provably non-negative is enough: then
`a...
1.3.0
1.3.0
The block on a phone: the plane gets the whole width, the formula waits behind a button
Until now the block applied a desktop layout everywhere. The formula asks for half the
width and the plane takes two thirds of what is left, which on a 412 px phone leaves a
plane 214 px wide and 261 px tall — a graph taller than it is wide, with a panel beside
it whose lower half is empty. Nothing was broken; it was a proportion designed for a
window that a phone does not have.
This release splits that decision in two, because two different things were being
confused: how much room there is, and how you are pointing. They now travel separately,
and that is what lets the phone gain a usable plane without changing anything in
landscape, where a phone has ~700 px and the desktop layout already works.
-
A narrow block is now just the plane, with the formula in a panel on top of it.
Below 520 px of container width, the formula panel leaves the flow and becomes a card
floating over the plane, opened and closed by a new f(x) button next to the ⓘ. The
plane goes from 214 × 261 to 321 × 264 on that same phone — 52 % more area, and
landscape at last.The threshold is not a round number picked by feel. In the side-by-side layout the plane
takes ⅔ of the block, so for it not to end up taller than wide you need ⅔·W ≥ 261, that
is W ≥ 392; at 520 the plane never drops below 4:3, which is the least a graph needs to
read as a graph. Being measured on the container and not on the device, a narrow side
pane on the desktop gets the same treatment — it has exactly the same problem.The panel does not change parent when the threshold is crossed: it stays a sibling of
the plane and only its box is rewritten. So rotating the phone is one style write —
KaTeX is not re-rendered, and your zoom and panning survive the turn. Rotating with the
formula open closes it, so coming back to portrait starts closed rather than with a panel
nobody asked to open.The card takes 180 of the 264 px and rests 46 px above the bottom edge instead of
sitting on it: the ⓘ and the button that closes the formula live down there, and a panel
that covers its own close button is a trap. While it is open the f(x) turns into ✕,
the same rule the ☰ menu got in 1.2.8, and the zoom column steps aside — at that height
it would be underneath the card. That is the price of a large panel, and it is the right
one: with the formula in front of you, you are not navigating the graph. Tapping the
plane also closes it, but only a clean tap — under 8 px of travel and half a second —
because a drag to move the view ends up emitting a click just like a tap, and closing the
panel every time you moved the plane would make it unusable. -
One finger moves the plane on both axes; two fingers zoom. Until now the only zoom on
a phone was the ± buttons, which are precisely the ones that step aside when the formula
is open. Panning was also fragile: a single stored position meant the second finger's
pointerdownoverwrote the first one's, and the view jumped from one finger to the
other.Pointers are now tracked per id. With one, the arithmetic is the same one as always,
so the desktop drag is unchanged. With two, the midpoint drags the view and the
separation scales it, anchored on that midpoint: pulling the fingers apart zooms in,
bringing them together zooms out, and the point of the world under your fingers stays
where it is. Lifting one finger no longer jumps, because the one that remains already has
a known position.pointercancelis finally handled — the system can take a finger away
at any moment (an incoming call, a gesture of its own), and that finger used to stay
"down" forever.The wheel and the pinch now share the same anchored-scale routine, so there is one
invariant to keep and one to test. Two guards: below 24 px of separation the gesture
only pans (the ratio explodes, and divides by zero in the limit), and the scale per event
is capped at ×4.This has a consequence worth knowing: a swipe that starts on the plane no longer
scrolls the note.touch-actionis set on the canvas only, so swipes that start on the
margins, above, below, or on the formula panel — 180 of those 264 px when it is open —
still scroll normally. -
Nothing that needs a mouse pretends to work with a finger. On touch there is no
hover, so the crosshair and the cursor cross had nothing to follow, and the rail is
driven with A/D and W/S on a keyboard that is not there. All three are now off, along
with the ⌖ button that opened a mode with no way to steer it, and the canvas no longer
hides the system cursor. Controls that stay grow from 22 to 30 px — not the 44 that
the touch guidelines ask for, because four 44 px targets over a 321 px plane would take a
third of its height and bring back the problem we came to solve. The ⓘ popover now rises
with the chip row and is capped against the plane instead of against fixed numbers, and
the popover and the formula are mutually exclusive: opening one closes the other, since
on a phone they overlap almost completely.What is lost with this: on a phone there is now no way to read the coordinates of a
point. The crosshair and the rail were the two ways to do it, and both were mouse and
keyboard. Nothing has replaced them yet; a tap that shows coordinates is the natural
candidate. A tablet with a Bluetooth keyboard also loses keyboard navigation — there is
no way to detect that keyboard until a key is pressed. -
A button to edit the block, because on mobile there is no
</>. Obsidian's button
for reaching the source of a rendered block appears on hover, so on a phone it never
does, and our canvas takes the taps. The block was left with no door to its own code.
There is now a ✎ chip in the top-left corner — away from the others on purpose: the ones
on the top right move the view, the ones at the bottom right open something inside the
block, and this one leaves the block. It takes the cursor to the end of the block's
body, not the start, because you press edit to carry on writing; if the note is in
reading mode it switches to editing first, and it scrolls the cursor into view, which on
a phone matters because the keyboard covers the lower half of the screen. It hides while
the formula panel is open, where it would only add noise. -
The block no longer flashes while it mounts. The formula panel goes through
MarkdownRenderer, and that has to be awaited before the plane can be built. In that gap
the browser painted whatever was there — the block with its formula and no graph at
all — and then jumped to the finished block. On the desktop it lasted an instant; on a
phone, leaving the editor, it was plainly visible. The block now mounts hidden and is
revealed once the layout is decided, the canvas sized, and the geometry traced and
painted. It is hidden withvisibilityand notdisplay:noneon purpose: it has to keep
occupying its place and measuring for real, because the layout is decided from
clientWidthand the canvas is sized from its real box. There is a two-second safety net
so that a failure halfway can never leave a block invisible.
The graphing engine itself is untouched: no sampler, tracer or analysis code changed, and
the geometry of every existing case is the same as in 1.2.9. Both suites pass, now with
nine new tests for the gestures — 354 tests in the main suite and 12 in the zoom
suite. Those nine check the properties that are easy to break without noticing: that the
world point under the anchor does not move when pinching (with the anchor deliberately off
centre, where a wrong anchor would go unnoticed), that one pointer still pans exactly as
before, that lifting a finger does not jump, and that a cancelled pointer stops counting.
Verified on Android. The behaviour on iOS has not been tested, and neither has a
tablet.
1.2.9
1.2.9
A block that froze Obsidian past restarting it, and a plugin that finally has a light theme
One serious bug fix, one long-standing design gap, and two smaller fixes. The
serious one is a graph that could lock the app hard enough to survive quitting
it: reopening the note re-rendered the block and froze it again, so the only way
out was to delete the block with an external editor. The design gap is the light
theme: until now the block was a dark island on a white page, with a formula that
was nearly invisible in it.
-
A block containing
tan(e^x)froze Obsidian, and reopening the app froze it
again. This bug was found during testing on a Redmagic 11S Pro and later
reproduced on Windows. The cause is arithmetic, not hardware: the plane's very
first paint runs the final, highest-quality pass, and on this function that
pass generated pathological amounts of work and memory, making the issue
algorithmic rather than hardware-dependent, regardless of the device's
performance. Two independent defects met on this one expression. First, the
explicit sampler had no bound on how much geometry it could produce:
tan(e^x)oscillates at a local frequency ofe^x/π— about 1.5·10⁷
oscillations per pixel at the right edge of the default view — and the
sampler kept subdividing, ending with 1,084,444 branches and 21,460,279
vertices after 30.3 seconds and 1.14 GB of heap. That case is deliberately
not covered by the high-frequency envelope added in 1.2.6, whose
boundedness test rejects it on purpose: an unbounded function has no band
to draw, it has asymptotes, and it was handed back to the sampler with no
limit at all. Second, the deduplication of notable points was quadratic, so on
that geometry it never finished: over 9 minutes and still running when it
was killed, all of it wasted, since a category with more than 30 points is
discarded whole.The sampler now enforces a refinement budget tied to resolution —
2048 vertices per pixel column, so a phone screen protects itself
proportionally — and when it runs out the trace does not stop: it stops
subdividing and continues at the base sampling density, which is already
bounded, so the curve is still drawn end to end without the sub-pixel detail
that resolves nothing at that scale. The deduplication now indexes points in a
grid whose cell equals the tolerance, making it linear while returning exactly
what the exhaustive scan returned. The first render oftan(e^x)goes from
30 s, 1.14 GB and a pass that never finished to 705 ms and 112 MB
(378 ms on a phone-sized canvas).Nothing else changes: the budget was calibrated against the existing
repertoire. The most expensive legitimate case,tan(x²)at ±300, uses
699 vertices per pixel column, leaving roughly a threefold safety margin,
and 240 of 240 traced cases (20 expressions × 6 zoom levels × both
passes) come out bit-identical to 1.2.8, vertex for vertex, with
identical notable points. Where the drawing does change is inside the
pathological stretch itself: past x ≈ 10.3 the old picture was a saturated
black rectangle, every pixel of every column painted, and it is now a lighter
haze over the same region, without a single pixel drawn that was not drawn
before. What is lost there is the density of an aliasing smear, not the
curve itself. -
The plugin follows your theme, and the formula is legible in a light one.
The block painted itself#1e1e1eand never set a text colour, so the formula
inherited--text-normalfrom the theme: in a dark theme that happens to be
light text on a dark panel and it worked by accident, and in a light theme it
was near-black text on a near-black panel — around 2:1 of contrast, where
4.5:1 is the minimum for legible text. That part was a defect, not a
preference.The fix splits colour in two along the line that matters. The frame —
container, formula panel, buttons, menus, borders — no longer has a colour of
its own: it derives from Obsidian's own variables (--background-primary,
--background-secondary,--text-normal,--background-modifier-border,
--shadow-s), so the block is made of the same material as the note and works
with any theme, including community ones, with nothing for us to maintain. The
plot's ink — grid, axes, labels, curves, markers — keeps a palette of its
own in two hand-tuned versions, because a graph needs guaranteed contrast
between its layers and no theme variable promises that. The only thing the
theme is asked is whether it is light or dark.The light palette is not the dark one inverted. On white the blue
#4f9eff
washes out and drops to#2f6df6; the grid goes from light grey at 12 % to
black at 10 %; the axes gain weight, because a faint grey disappears against
white; and the white halo behind each marker — which separates it from the
curve on a dark ground — turns dark, since on white a white halo separates
nothing. Every layer was measured against its own background: axis labels sit
at 5.03:1 in dark and 5.67:1 in light, and the six curve colours at 5.50–9.30
and 4.38–5.78 respectively. The six hues are the same in both themes — they are
the plugin's identity — only darkened for a light ground.Switching theme now recolours an open block in place. Curve colours are
declared by role (which equation they belong to) and resolved against the
active palette when painting rather than when the scene is built, so a theme
change is a repaint: your zoom and panning survive it. -
The same function no longer renders two different ways depending on how you
typed it. Fractional powers were already drawn as radicals —x^{1/2}as
√x,x^{2/3}as∛(x²)— but that rewrite only recognised an exponent
written as a quotient of literal integers, sox^{0.5}came out as
x^{1/2}in fraction form andx^{0.5φ}asx^{φ/2}. The decimal becomes a
fraction after the step that does the rewriting, andφis not a digit.
The decision now happens in the LaTeX writer, where every form passes through:
x^{0.5}andx^{1/2}both render√x,x^{1.5}matchesx^{3/2}, and
x^{0.5φ}renders√(x^φ). Exponents with a free variable keep their
exponential form (e^{x/2}is not a root), and so do negative ones. -
The options button now closes the menu it opens. The ☰ icon stayed the
same whether the transformations menu was open or closed, so the button gave
no sign of what pressing it would do. It now turns into ✕ while the menu is
open, with its tooltip changing to match, and back to ☰ when it closes,
including when the menu closes by clicking outside it or by applying one of
its options. The three blocks that have this menu (obs-graph/
obs-system,obs-derivateandobs-integral) all follow the same rule,
each keeping its own description of what the menu contains.
Both suites pass unchanged: 345 tests in the main suite and 12 tests in
the zoom suite.
1.2.8
1.2.8
Graph controls: an icon set, one tooltip per control, and a crosshair that survives panning sin(1/x)
Most of this release is cosmetic — a real icon set for the plane's controls, a
single tooltip per control instead of two, zoom buttons that repeat while held,
and the removal of a decorative marker — and it carries two bug fixes: curves
now close against the edge of their domain instead of stopping short of it, and
the crosshair on sin(1/x) no longer goes dark after you pan the view.
-
Curves stopped short of the edge of their domain, leaving a visible gap
there. Reported on the systemy = ±⁴√(1−x⁴)— the squirclex⁴+y⁴=1—
which at high zoom showed a hole at (±1, 0) with the stroke cut in two, so the
near-vertical tip read as a dashed asymptote rather than a curve. Where a curve
meets the end of its domain with a vertical tangent, the value falls off as a
root of the distance to the edge: for⁴√(1−x⁴)it isy ≈ (4ε)^(1/4), which
descends so slowly that after the sampler'sPROF_MAXrefinement steps y is
still ≈7·10⁻³ — tens of pixels once you zoom in — and the branch simply ended
there. It closed only by luck, when the edge happened to land exactly on the
sampling grid, which is why the gap appeared at some zoom levels and not
others, and why it was never a regression: the defect had been latent since
long before 1.2.6. The sampler already located the edge to machine precision —
it bisects 40 times to decide whether the discontinuity is a pole — but threw
that point away instead of drawing it. It is now emitted, attached to the
finite end and inside the same segment, so the polyline reaches the edge: the
gap goes from 6.6·10⁻³ (2.5·10⁻² during a gesture) to zero, both passes agree,
and it stays under a pixel at any zoom. This applies to every explicit curve
that touches a domain boundary —√x, half-parabolas, and the rest — all of
which had the same gap, just smaller than the fourth root made it. -
The crosshair on
sin(1/x)stopped appearing after a pan — a regression
from 1.2.6, unnoticed until now and fixed here. 1.2.6 began drawing
stretches that oscillate faster than one pixel as a min/max envelope band,
markedCalidadRama: "incierta"and carrying noparametro, since a band is
not a curve you can walk along. The walkability test that gates the crosshair,
curvaRecorrible, rejects any branch without aparametro— its purpose is to
catch curves that fold back in x and are therefore multivalued. A band trips
that test even though it is not a fold, so the presence of a single band
disabled the crosshair for the whole curve. It surfaced onsin(1/x)
specifically, and only after panning: the band near x=0 forms or not depending
on how the sampling grid lands on the pixels, which shifts as the view moves,
so a settled default view resolved cleanly while a panned one did not — and a
wheel zoom, which resolves the oscillation as it magnifies, never triggered it
either. The test now excludes"incierta"bands from its judgement, exactly as
the crosshair's ownyEnRamasalready skips them: the crosshair works over the
rest of the curve and is simply not drawn over the band itself, where there is
no single y to report. Before 1.2.6 the curve was traced as ordinary branches
throughout and was always walkable, so this restores the earlier behavior. -
The plane's controls now use a real icon set. Home, zoom-in, zoom-out,
trace, info and the options menu were text glyphs and emoji (🏠, +, −, ⌖, ⓘ,
☰), and the pointer over the plane was a hand-drawn cross. All are now Material
Symbols, drawn as inline SVG through Obsidian's DOM API rather than
innerHTML, and filled withcurrentColorso each icon inherits its button's
colour and follows the active/inactive highlight unchanged. The pointer icon is
drawn on the canvas as aPath2D, built lazily so it costs nothing in the Node
test bundle, which never paints. -
The zoom buttons repeat while held. Pressing + or − zoomed a single notch
and had to be clicked again for every further step. They now keep zooming for
as long as the button is held, at a steady cadence, reusing the same centred,
smoothed zoom a single press already used — so holding reads as one continuous
zoom rather than a stack of steps. A quick tap still does exactly one notch. -
One tooltip per control, above it rather than below. The options button
carried both atitleattribute and anaria-label, so hovering it produced
two tooltips at once — the browser's native one and Obsidian's. Every control
now uses Obsidian'ssetTooltipwith top placement and notitle, so there is
a single dark tooltip, positioned above the control where the pointer does not
cover it. -
The experimental-engine ⚙ marker is gone. The small gear in the top-right
corner of the plane was a decorative label; it has been removed along with its
now-unused translation strings, and the zoom buttons moved up to take its
place.
Both suites pass unchanged — 345 tests in the main suite and 12 in the zoom
suite.
1.2.7
1.2.7
No generated code: the expression compiler no longer uses new Function
A maintenance release with a single change. 1.2.6 introduced a native
expression compiler that made tracing 2.3× to 18× faster by generating
JavaScript source at runtime and compiling it with the Function
constructor. That is dynamic code execution: it requires unsafe-eval in the
Content Security Policy, and it means the plugin's behavior cannot be
determined by reading the plugin's code, since part of what runs is a string
assembled while it runs. This release removes it without giving up the speed.
-
Expressions are now compiled to a tree of closures instead of to source
code. The compiler walks the same mathjs AST as before, but each node
becomes a nested JavaScript function that has already resolved — closed over
— which operation it performs and what its operands are. Evaluating means
descending that tree calling ordinary functions; there is no typed-function
dispatch, no scope object and no name lookup left in the sampling loop, which
is where the original speedup came from. This is closure compilation: the
work that depends only on the expression is done once, and only the work
that depends on the point remains per sample. No string is ever turned into
a program, soevaland theFunctionconstructor are both gone from the
plugin, andunsafe-evalis no longer needed.Compiled closures take a fixed two-argument signature even when the
expression has one variable. That is deliberate: it avoids allocating an
argument array on every evaluation and keeps the call sites inside the tree
monomorphic, which is most of the difference between a closure tree that is
fast and one that is not.Closures are somewhat slower than generated source on large expressions, and
measuring that honestly matters more than the headline. Against the 1.2.6
implementation, over 300,000 evaluations per expression:sin x12.7 ms →
11.8,x³−3x+1/x14.0 → 15.1,(x²+y²−1)³−x²y³19.1 → 17.9,
sin(1/x)·e^(−x²)+ln(|x|+1)16.2 → 39.0,sin(xy)+cos x/(1+y²)29.3 →
63.1. So the worst case measured gives up a factor of 2.4 against generated
code — while still evaluating 8.4× to 26.8× faster than mathjs, which is the
comparison that decides frame time, since mathjs takes 222 ms to 530 ms on
those same runs. The tracing speedup of 1.2.6 therefore stands.The three safeguards are unchanged and still apply: a whitelist that refuses
anything whose semantics have not been verified against mathjs, a
differential validation of the compiled function against mathjs over ~40
probe points before it is used, and a fallback to the mathjs path whenever
either fails.
No behavior changes: both suites pass unmodified — 345 tests in the main suite
and 12 in the zoom suite — which between them cover the tracing geometry the
compiler feeds.
1.2.6
1.2.6
Graphing engine: speed, and fewer curves that change while you move the view
This release is about the graphing engine. It makes evaluation 2.3× to 18×
faster, addresses three visual defects reported while panning and zooming, and
bounds the curvature of the parametric and implicit tracers, which were drawing
smooth curves as visible polygons. It also unlocks the explicit sampler, which a
test had frozen against a dead copy of itself.
Two of those three defects are fixed outright; the third, the flicker on
sin(1/x), is greatly reduced rather than removed. A fourth — the moving
stripes on tan(x²) — is diagnosed but not fixed, and is described at the end.
-
Expressions are compiled to native JavaScript: 2.3×–18× faster tracing.
mathjs remains the parser — its AST is still the source of truth for the
syntax — but it is no longer the evaluator.mathjs.compile().evaluate(scope)
pays typed-function dispatch and scope construction on every sample, and a
trace takes between 2,500 and 220,000 evaluations, so that wrapper was the
bulk of the frame rather than the mathematics.src/compiladorNativo.tswalks
the AST and generates the equivalent JS, compiled once withnew Function.
Measured over the complete tracer:√(9−x²)12.5 ms → 1.0 ms,ln|x|
12.6 → 1.0,sin x13.2 → 1.3,tan xat zoom-out 333.7 → 52.5,
sin(1/x)deep-zoomed 585.8 → 85.6. Geometry came out bit-identical — same
branches, same vertices — in all 160 comparisons made (8 curves × 10 zoom
levels × both passes); that is a broad check, not a proof for every possible
expression, which is what the safeguards below are for. Three of them keep an
acceleration from ever becoming a change of drawing: a whitelist that refuses
to generate code for
any node whose semantics have not been verified against mathjs; a differential
validation of the generated function against mathjs over ~40 probe points
before it is used; and a fallback to the old path when either fails. Note that
mathjs 12 does not useevalornew Function, so this is a genuinely new
capability the plugin exercises where the environment allows it. Degradation
is clean by construction: the call sits inside atry, so a Content Security
Policy that blocks it returnsnull, every expression falls back to mathjs,
and the engine behaves exactly as before — verified by sabotaging the
Functionconstructor and re-rendering the curves in the test repertoire. -
tan(y) = xdrew short spurious strokes across its branches. The
separable-implicit provider rescues thin slivers next to each pole, because
regular sampling can miss a branch that only exists within a fraction of a
pixel. That rescue ran unconditionally, so where the ordinary sampling had
already covered a pole it added a second, much coarser trace of the same
place: 8 extra branches of 6 or 7 points each, which is what showed up as
marks over the curve. The rescue now consults what was already traced and
skips poles that are covered, using a sorted index of the visible x-values and
a binary search rather than a scan per pole. Real slivers are still rescued —
there is a regression test for a pole where sampling genuinely misses the
branch. -
sin(1/x)flickers far less when zooming — reduced, not eliminated.
Where a curve oscillates faster than one pixel, no sampling density resolves
it: several whole cycles
fall between consecutive samples, so what gets drawn is an arbitrary subset
of a dense band — and which subset depends on the number of samples. Since the
interactive pass and the final pass use different densities, each picked
different threads and the curve changed when the gesture ended: measured, 122
branches interactive against 38 final, with 31% of pixels differing. Those
stretches are now detected and drawn as the min/max envelope per pixel column,
on a grid pinned to the pixels and with a fixed number of samples per column,
so both passes compute exactly the same thing. Flicker went from 30.9% to 0.5%
on average, with a 6.6% peak — a large reduction, but not zero: some change
between passes remains, and the surrounding curve is still sampled the usual
way. For scale,x²measures 14.3% on the same metric purely from rasterising
a curve that legitimately moves, so the residue sits below that floor. The
deep-zoom case also got 6.8× faster (586 ms → 86 ms) because the sampler stops
trying to resolve the unresolvable. These bands are marked
CalidadRama: "incierta"— the contract value that had never been emitted —
and carry noparametro, since a band is not a curve you can walk along.
Telling a pole from an oscillation is what made this hard: inside the
column holding a pole, the function rises, jumps and rises again, which reads
as the same signature as a cycle. Neither a threshold on turning points nor
the shape of the sign change is reliable; what works is the combination of
four or more turning points with the local and global max/median ratio,
since unboundedness is a property of the function rather than of the sampling. -
Smooth parametric and polar curves were drawn as polygons. Reported on
r = sin(θ/10): faceted while zooming, smooth once stopped. Refinement used
only a deviation test — the distance from the midpoint to the chord — and
deviation is a sagitta, which scales with the square of the chord: on an arc
of screen radius R a turn of θ leaves a sagitta of only R(1−cos(θ/2)), so with
the curve small on screen a vertex can turn 36° and still pass a 1-pixel
threshold. Measured, the turn angles came out quantised at 36/18/9/4.5° —
exact powers of two, that is, the uniform-sampling polyline never refined once
— with a mean turn per vertex of 19.7° interactive and 9.9° final. A curvature
criterion now bounds the turn itself, and the worst facet measured is 3.93°
across the curves tested — a polar rose, a cardioid, a circle, a Lissajous
figure, a spiral and a parabola, each at six zoom levels in both passes — at a
cost of at most 2.5 ms. Two details matter: the measured angle is doubled
before comparison, because an accepted segment
discards its midpoint and emits the whole chord, so the angle actually drawn
at the joint is about twice the one measured; and the criterion switches off
below a 1.5-pixel chord, where a kink fits inside the stroke width and where
chasing curvature would never terminate at a genuine cusp such as the r=0 of a
rose. -
The same blind spot in implicit curves. The continuation tracer advanced
with a fixed arc-length step — 4.5 px interactive, 2.5 px final — and its
existing turn test is a validity check at ~45°, which bounds nothing visible.
Measured turn per vertex during a gesture: 17.7° on the foliumx³+y³=3xy,
7.8° on an ellipse, 6.5° on a circle. A smoothness criterion now reuses the
step-halving loop that was already there, bringing those to 3.9°, 3.7° and
3.8°, with no vertex above 5° on those three. It does not bound every curve:
x²y²=1zoomed out still leaves 6.9° at two vertices during a gesture, where
the shorter steps are rejected by the chord test and the fallback below is
what gets used. It cannot make things worse: the first valid step is kept as
a fallback and returned when no scale manages to be smooth, so
the function never returnsnullwhere it previously returned a step, which
would have triggered spurious straight-line crossings or cut branches short.
Explicit curves were checked too and needed nothing — they sample per pixel
column, so their turns were already under 2°. -
Parametric curves were drawn as a single straight line where they left the
view. On crossing the visibility boundary the tracer bisected to find the
edge and emitted only that point, so the whole visible arc leading up to it
became one chord. It bites as soon as zoom leaves the visible portion inside a
single step of the initial sampling int. Measured onr = sin(θ/10)at
semiY=0.005: 7 points and 37.6 px of deviation interactive, against 519 points
and 0.07 px final. The visible arc is now refined with the normal logic:
515 points and 0.07 px in both passes, and the deeper zooms where both
passes were broken are fixed as well. -
The explicit sampler was frozen by a parity test against a dead copy of
itself.tests/modulos/trazado.test.tsrequired vertex-by-vertex equality
withsrc/render/muestreoExplicito.ts— the legacy GraphEngine sampler, which
no longer draws anything and which the two copies had already outgrown. That
made the legacy code the definition of correct, and broke any improvement to
sampling or refinement by construction, even one that did not move a single
pixel: capping refinement below the pixel failed with "expected 4146 points,
got 4090", a 1.4% difference in vertices all of them sub-pixel. The test now
compares what is observable — same branch count, same vertical asymptotes
measured in pixels, and the two curves within 0.25 px of each other on
screen, as a two-way Hausdorff distance against the segments rather than the
vertices. It still catches genuine regressions, verified by sabotage: nearly
disabling refinement produces 6 failures and cutting the sample count by 8×
produces 22.
Existing behavior is unchanged elsewhere: the full suite is now 345 tests, the
zoom suite 12, and both pass. Every performance figure above was measured with
medians over repeated runs on jittered viewports, so caching is not being timed.
Known and not fixed
Functions whose local frequency grows, such as tan(x²), still show regularly
spaced dark stripes when zoomed out, and the pattern changes between the
interactive and the final pass....