From 68b74eb60be1b3a5c296ff264218530b1223b8cd Mon Sep 17 00:00:00 2001 From: Aran Roig Date: Fri, 2 Oct 2026 14:53:00 +0200 Subject: [PATCH] Graph calculator improvmentes --- AGENTS.md | 10 +- frontend/app/assets/css/_book.scss | 26 ++ frontend/app/assets/css/_calculator.scss | 45 +++- frontend/app/assets/css/_graph.scss | 27 ++ frontend/app/assets/css/_search.scss | 11 + frontend/app/components/ArticleBookStack.vue | 62 +++-- frontend/app/components/CalculatorStage.vue | 268 +++++++++++++++++++ frontend/app/components/GraphStage.vue | 46 ++++ frontend/app/components/PlotCanvas.vue | 166 +++++++++++- frontend/app/components/SearchModal.vue | 103 ++++++- frontend/app/components/ToolPane.vue | 85 ++++++ frontend/app/composables/useArticleTabs.ts | 69 ++++- frontend/app/pages/calculator.vue | 262 +----------------- frontend/app/pages/graph.vue | 41 +-- frontend/app/utils/plot-expression.ts | 176 ++++++++++-- frontend/app/utils/routes.ts | 32 +++ frontend/i18n/locales/ca.json | 11 +- frontend/i18n/locales/de.json | 11 +- frontend/i18n/locales/en.json | 11 +- frontend/i18n/locales/es.json | 11 +- frontend/i18n/locales/fr.json | 11 +- 21 files changed, 1092 insertions(+), 392 deletions(-) create mode 100644 frontend/app/components/CalculatorStage.vue create mode 100644 frontend/app/components/GraphStage.vue create mode 100644 frontend/app/components/ToolPane.vue diff --git a/AGENTS.md b/AGENTS.md index c56053b..83942b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,7 +195,15 @@ copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`. address to link to until it is granted), the status pill, the approve/reject buttons, and the details diff — the article as sent against the text proposed, both through `MarkdownView`, where a creation reads its asked-for address instead of a page that - is not there. + is not there. The book can hold the wiki's own pages too: `SearchModal` lists + `/graph` and `/calculator` among its results (matched by name or by words like + *calc*, *plot*, *map*) and opening one lays the live surface on a page of the book + beside the articles — `ToolPane.vue` is that page (spine, ×, `inert` when not in + view), holding the surface extracted as `GraphStage.vue` / `CalculatorStage.vue`. + `useArticleTabs` files such a page under the identity `page:` and finds it + at its own address (`tabAt`/`tabHref` know `toolFromPath`); while its page is + open, the plain route hosts defer to the book and draw nothing, and asked plain + (no open page — a direct link or bookmark) they draw the stage full-page as ever. - `app/composables/` — `useRecentlyViewed` (localStorage), `useAuth` (the session: token in the `mathew-session` cookie so the server sees it too, `signIn`/`signUp`/`signOut`, `authHeaders()` for write calls, `restoreSession()` diff --git a/frontend/app/assets/css/_book.scss b/frontend/app/assets/css/_book.scss index b9e6c62..cfac660 100644 --- a/frontend/app/assets/css/_book.scss +++ b/frontend/app/assets/css/_book.scss @@ -269,6 +269,32 @@ &:hover { color: var(--text); background: var(--surface-2); } } +// ---------- A wiki page laid on the book ---------- +// The link map and the graphing calculator are pages too: the search popup +// lays one of them on the book beside the articles (ToolPane.vue). It fills +// the page edge to edge — no column of prose scrolls here, the surface keeps +// its own docks and toolbars — and it takes the whole share of the desk, a +// plane being no narrower read than a page of text. Each stage's own sheet +// answers for the rest (_graph.scss, _calculator.scss). + +.book-pane.pane-tools { + max-width: none; + overflow: hidden; +} + +.pane-stage { + position: relative; + flex: 1; + min-width: 0; + min-height: 0; +} + +// the surface's mark on the spine, under the ×, so a page of the map or the +// plane reads as itself fanned in among the articles +.pane-spine .spine-glyph { flex: none; opacity: 0.65; } + +.book-pane.active .pane-spine .spine-glyph { opacity: 1; } + @media (max-width: 900px) { .book-layer, .reader-note { inset: 0; box-shadow: none; } diff --git a/frontend/app/assets/css/_calculator.scss b/frontend/app/assets/css/_calculator.scss index 9fa6ad3..d654093 100644 --- a/frontend/app/assets/css/_calculator.scss +++ b/frontend/app/assets/css/_calculator.scss @@ -115,6 +115,12 @@ font: italic 0.95rem/1 'Computer Modern', Georgia, serif; } +// a relation or point carries no lone "y =", so it sheds the gutter and the +// input takes back the room it stood in +.calc-input-box.bare .calc-input { + padding-left: 10px; +} + .calc-input { flex: 1; min-width: 0; @@ -125,8 +131,6 @@ color: var(--text); font: 0.95rem/1.2 'Computer Modern', Georgia, serif; - &::placeholder { color: var(--faint); opacity: 0.75; } - &.input-error { color: var(--danger); } } @@ -246,3 +250,40 @@ // width back at once rather than after the slide .calc-dock-leave-active { position: absolute; left: 0; right: 0; } } + +// ---------- Laid on a page of the book (ToolPane) ---------- +// A pane of the book is a full surface again, at any screen width: the plane +// fills the page and the input dock pins to its edge as on the desktop, and +// the fold above — panels of the plain /calculator page — does not reach +// into the pane. + +.pane-stage .calc-stage { + position: absolute; + inset: 0; + display: block; + background: var(--bg); +} + +.pane-stage .plot-wrap { + position: absolute; + width: auto; + height: auto; + border: 0; + border-radius: 0; +} + +.pane-stage .calc-dock { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: clamp(260px, 24vw, 320px); + margin: 0; + border: 0; + border-right: 1px solid var(--border); + border-radius: 0; + background: color-mix(in srgb, var(--bg-elev) 92%, transparent); + backdrop-filter: blur(12px); + box-shadow: var(--shadow-lg); + overflow-y: auto; +} diff --git a/frontend/app/assets/css/_graph.scss b/frontend/app/assets/css/_graph.scss index 72afe0d..a757543 100644 --- a/frontend/app/assets/css/_graph.scss +++ b/frontend/app/assets/css/_graph.scss @@ -120,3 +120,30 @@ .graph-state { position: static; display: block; padding: 8px 0 32px; } } + +// ---------- Laid on a page of the book (ToolPane) ---------- +// A pane of the book is a full surface again, at any screen width: the map +// fills the page the way it fills the desk, and the fold above — panels of +// the plain /graph page — does not reach into the pane. + +.pane-stage .graph-stage { + position: absolute; + inset: 0; + background: var(--bg); +} + +.pane-stage .graph-canvas-wrap { + position: absolute; + height: auto; + margin: 0; + border: 0; + border-radius: 0; + box-shadow: none; +} + +.pane-stage .graph-state { + position: absolute; + inset: 0; + display: grid; + padding: 24px; +} diff --git a/frontend/app/assets/css/_search.scss b/frontend/app/assets/css/_search.scss index 9ad3b3d..f069ba7 100644 --- a/frontend/app/assets/css/_search.scss +++ b/frontend/app/assets/css/_search.scss @@ -117,6 +117,17 @@ } } +// the wiki's own pages carry their mark beside the title, so a row reads as +// the surface it opens rather than an article +.sr-page-glyph { + display: inline-block; + margin-right: 7px; + color: var(--muted); + vertical-align: -2px; + + svg { display: block; } +} + .sr-meta { display: flex; align-items: center; diff --git a/frontend/app/components/ArticleBookStack.vue b/frontend/app/components/ArticleBookStack.vue index 8511442..ba6b4e1 100644 --- a/frontend/app/components/ArticleBookStack.vue +++ b/frontend/app/components/ArticleBookStack.vue @@ -18,6 +18,8 @@ Narrow screens fold the book to the one page in view, with its neighbours as chips along the top. */ +import type { ArticleTab } from '~/composables/useArticleTabs' + const api = `${useRuntimeConfig().public.apiBase}/api` const route = useRoute() const router = useRouter() @@ -34,7 +36,18 @@ const { collapsed } = useSidebar() const activeTab = computed(() => tabAt(tabs.value, route.path)) const activeIdentity = computed(() => activeTab.value?.identity ?? null) -const visible = computed(() => isReaderPath(route.path) && tabs.value.length > 0) +/* the book lies open on a reader route, and on one of the wiki's own pages + while the book holds a page of it — asked plain, those pages draw + themselves instead */ +const visible = computed( + () => (isReaderPath(route.path) || tabAt(tabs.value, route.path) !== null) && tabs.value.length > 0 +) + +/* what a page of the book is called: an article wears its title; one of the + wiki's own pages is called by what it is, in the reader's language */ +function tabLabel(tab: ArticleTab): string { + return tab.tool ? t(TOOL_LABEL_KEYS[tab.tool]) : tab.title +} const trackEl = ref(null) @@ -157,11 +170,13 @@ watch(collapsed, () => { }) /* every page of the book should hold its article: pull the ones the route - host hasn't already written in (background pages, client-side only) */ + host hasn't already written in (background pages, client-side only). The + wiki's own pages carry no article to fetch — their surface fetches its + own map when it is turned to */ watch( tabs, () => { - for (const t of tabs.value) void load(t.slug) + for (const t of tabs.value) if (!t.tool) void load(t.slug) }, { immediate: true } ) @@ -215,11 +230,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown)) role="tab" tabindex="0" :aria-selected="tab.identity === activeIdentity" - :title="t('book.chipHint', { title: tab.title })" + :title="t('book.chipHint', { title: tabLabel(tab) })" @click="turnTo(tab.identity)" @keydown.enter.prevent="turnTo(tab.identity)" > - {{ tab.title }} + {{ tabLabel(tab) }} - {{ tab.title }} + {{ tabLabel(tab) }} diff --git a/frontend/app/components/CalculatorStage.vue b/frontend/app/components/CalculatorStage.vue new file mode 100644 index 0000000..048320f --- /dev/null +++ b/frontend/app/components/CalculatorStage.vue @@ -0,0 +1,268 @@ + + + diff --git a/frontend/app/components/GraphStage.vue b/frontend/app/components/GraphStage.vue new file mode 100644 index 0000000..c2c1175 --- /dev/null +++ b/frontend/app/components/GraphStage.vue @@ -0,0 +1,46 @@ + + + diff --git a/frontend/app/components/PlotCanvas.vue b/frontend/app/components/PlotCanvas.vue index 497d4b7..290fada 100644 --- a/frontend/app/components/PlotCanvas.vue +++ b/frontend/app/components/PlotCanvas.vue @@ -1,21 +1,23 @@ + + diff --git a/frontend/app/composables/useArticleTabs.ts b/frontend/app/composables/useArticleTabs.ts index c73c5d3..78e3bfd 100644 --- a/frontend/app/composables/useArticleTabs.ts +++ b/frontend/app/composables/useArticleTabs.ts @@ -1,6 +1,12 @@ +import type { ToolPage } from '~/utils/routes' + export interface ArticleTab { /** the article this page is of — its canonical slug, and the page's key */ identity: string + /** set when the page is one of the wiki's own pages — the link map, the + * graphing calculator — laid on the book instead of an article: the page + * draws that live surface, at its own address, for as long as it is open */ + tool?: ToolPage /** the language this page is turned to */ lang: string /** the version in view, as the library files it: its stored slug */ @@ -13,14 +19,31 @@ export interface ArticleTab { const MAX_TABS = 10 -/** The address a page of the book was read from: the version it is turned to. */ -export function tabHref(tab: Pick): string { - return versionHref(tab.identity, tab.lang) +/** What each of the wiki's own pages is called, as a message key — the label + * the book's spine, chips and dock wear for one of these pages. */ +export const TOOL_LABEL_KEYS: Record = { + graph: 'sidebar.graph', + calculator: 'sidebar.calculator', +} + +/** The key of the book page holding one of the wiki's own pages. No article + * slug carries a colon, so this can never read as an article's identity. */ +export function toolPageIdentity(page: ToolPage): string { + return `page:${page}` +} + +/** The address a page of the book was read from: the version it is turned to, + * or the wiki's own page's address where the page holds one of those. */ +export function tabHref(tab: Pick): string { + return tab.tool ? toolHref(tab.tool) : versionHref(tab.identity, tab.lang) } /** Which page of the stack an address points at, if any. An address that - * names no language points at the article's page however it is turned. */ + * names no language points at the article's page however it is turned; the + * wiki's own pages are found by their own addresses. */ export function tabAt(tabs: readonly ArticleTab[], path: string): ArticleTab | null { + const page = toolFromPath(path) + if (page) return tabs.find((t) => t.tool === page) ?? null const loc = parseReaderPath(path) if (!loc) return null /* one article is one page of the book, in whichever language that page is @@ -99,6 +122,26 @@ export function useArticleTabs() { tabs.value = stack } + /** + * Lay one of the wiki's own pages — the link map, the graphing calculator — + * on the book beside the articles. One surface is one page however many + * times it is asked for: asking again just finds the page already there. + */ + function openTool(page: ToolPage) { + const identity = toolPageIdentity(page) + if (tabs.value.some((t) => t.tool === page)) return + const stack = [ + ...tabs.value, + { identity, tool: page, lang: DEFAULT_LANGUAGE, slug: identity, title: page }, + ] + if (stack.length > MAX_TABS) { + /* don't stack forever — retire the oldest page that isn't this one */ + const drop = stack.findIndex((t) => t.identity !== identity) + if (drop !== -1) stack.splice(drop, 1) + } + tabs.value = stack + } + /* Close a page. Closing one that isn't in view just takes it out of the stack and the rest settle; closing the page in view turns back to the page before it — or returns to the last page outside the book once the @@ -113,7 +156,7 @@ export function useArticleTabs() { } } - return { tabs, origin, lastIdentity, openVersion, closeTab } + return { tabs, origin, lastIdentity, openVersion, openTool, closeTab } } /** @@ -132,9 +175,21 @@ export function useArticleTabsSync() { (to) => { /* filed under the article's identity, so a version turned to another language is still found as the same page of the book */ - if (isReaderPath(to)) + if (isReaderPath(to)) { lastIdentity.value = tabAt(tabs.value, to)?.identity ?? wikiSlugFromPath(to) ?? '' - else if (!isReadingSurface(to)) origin.value = to + return + } + /* one of the wiki's own pages held by the book belongs to the reading + surface too — it's where its page is in view, not a place to return + to; asked plain (no page open), it's an ordinary page of the site */ + if (isToolPath(to)) { + const page = tabAt(tabs.value, to) + if (page) { + lastIdentity.value = page.identity + return + } + } + if (!isReadingSurface(to)) origin.value = to }, { immediate: true } ) diff --git a/frontend/app/pages/calculator.vue b/frontend/app/pages/calculator.vue index 0d3b78b..7a2c728 100644 --- a/frontend/app/pages/calculator.vue +++ b/frontend/app/pages/calculator.vue @@ -1,156 +1,17 @@ diff --git a/frontend/app/pages/graph.vue b/frontend/app/pages/graph.vue index 7e4547f..6a08e7e 100644 --- a/frontend/app/pages/graph.vue +++ b/frontend/app/pages/graph.vue @@ -1,21 +1,18 @@ diff --git a/frontend/app/utils/plot-expression.ts b/frontend/app/utils/plot-expression.ts index 46e71c0..a7d43d1 100644 --- a/frontend/app/utils/plot-expression.ts +++ b/frontend/app/utils/plot-expression.ts @@ -1,9 +1,14 @@ /* The graphing calculator's little algebra: a hand-rolled tokenizer and recursive-descent parser that reads an expression in x — "sin(x) + x / 4", "2^x", "x(x+1)" — and compiles it to a function the canvas evaluates pixel - by pixel. No library: the grammar is small enough for one file, and the - parser can be strict about what it promises — everything it accepts is a - real function of x, and everything else is refused with a reason. + by pixel. A whole equation answers too: "y^2 + x^2 = 1" reads as a + relation F(x, y) = left − right, and the curve is where F is zero. So a + text with an "=" is one relation of x and y; without it, an expression + mentioning y is the relation that expression equals zero, and one in x + alone is the function y = f(x). No library: the grammar is small enough + for one file, and the parser can be strict about what it promises — + everything it accepts is a real function of x or a real relation in the + plane, and everything else is refused with a reason. Precedence is the usual one: + - bind loosest; then * / % (left, implicit multiplication included — "2x" is 2·x); then unary minus, so -x^2 negates @@ -12,21 +17,27 @@ export type PlotFunction = (x: number) => number +/* a relation of the plane: the curve is its zero set */ +export type PlaneFunction = (x: number, y: number) => number + /* why a text was refused: nothing to read, a name the calculator does not speak, or a shape the grammar rejects */ export type PlotErrorReason = 'empty' | 'unknown' | 'syntax' export type CompiledPlot = - | { ok: true; fn: PlotFunction } + | { ok: true; kind: 'function'; fn: PlotFunction } + | { ok: true; kind: 'relation'; fn: PlaneFunction } + // "(x, y)" or "P = (x, y)": a single dot at fixed coordinates, optionally named + | { ok: true; kind: 'point'; x: number; y: number; label: string | null } | { ok: false; reason: PlotErrorReason } -type Op = '+' | '-' | '*' | '/' | '%' | '^' | '(' | ')' | ',' +type Op = '+' | '-' | '*' | '/' | '%' | '^' | '=' | '(' | ')' | ',' type Token = { t: Op } | { t: 'num'; value: number } | { t: 'id'; name: string } type Node = | { kind: 'num'; value: number } - | { kind: 'var' } + | { kind: 'var'; name: 'x' | 'y' } | { kind: 'const'; value: number } | { kind: 'neg'; arg: Node } | { kind: 'bin'; op: '+' | '-' | '*' | '/' | '%' | '^'; a: Node; b: Node } @@ -85,10 +96,12 @@ class PlotParseError extends Error { } } -/* the input wears its "y =" beside the field; a reader who types it too is - answered the same — the rest is what gets plotted. f(x) = reads too. */ +/* the input may wear its "f(x) =" beside the field; a reader who types it + too is answered the same — the rest is what gets plotted. A leading "y =" + is read by the equation rule instead, so a right side that also carries y + still means the same relation, not a mystery name. */ function stripAssignment(src: string): string { - return src.replace(/^\s*(?:y|f\s*\(\s*x\s*\))\s*=/i, '') + return src.replace(/^\s*f\s*\(\s*x\s*\)\s*=/i, '') } function tokenize(src: string): Token[] { @@ -140,6 +153,11 @@ function tokenize(src: string): Token[] { i++ continue } + if (c === '=') { + toks.push({ t: '=' }) + i++ + continue + } if (c === '(' || c === ')' || c === ',') { toks.push({ t: c as Op }) i++ @@ -232,7 +250,8 @@ function parseAtom(p: Parser): Node { } if (tk.t === 'id') { p.pos++ - if (tk.name === 'x') return { kind: 'var' } + if (tk.name === 'x') return { kind: 'var', name: 'x' } + if (tk.name === 'y') return { kind: 'var', name: 'y' } if (Object.hasOwn(CONSTANTS, tk.name)) return { kind: 'const', value: CONSTANTS[tk.name]! } if (Object.hasOwn(FUNCTIONS, tk.name)) { const open = peek(p) @@ -254,23 +273,23 @@ function parseAtom(p: Parser): Node { throw new PlotParseError('syntax') } -function evaluate(node: Node, x: number): number { +function evaluate(node: Node, x: number, y: number): number { switch (node.kind) { case 'num': return node.value case 'var': - return x + return node.name === 'x' ? x : y case 'const': return node.value case 'neg': - return -evaluate(node.arg, x) + return -evaluate(node.arg, x, y) case 'call': { const fn = FUNCTIONS[node.name]! - return fn(...node.args.map((a) => evaluate(a, x))) + return fn(...node.args.map((a) => evaluate(a, x, y))) } case 'bin': { - const a = evaluate(node.a, x) - const b = evaluate(node.b, x) + const a = evaluate(node.a, x, y) + const b = evaluate(node.b, x, y) switch (node.op) { case '+': return a + b @@ -289,18 +308,129 @@ function evaluate(node: Node, x: number): number { } } -/* read one expression; a refusal says whether a name was not known or the - shape was not a function — an empty text is the caller's to treat as - "nothing asked for", not as an error */ +/* whether y stands anywhere in a tree — the difference between a function of + x, which the canvas can trace column by column, and a relation, which it + must search cell by cell */ +function mentionsY(node: Node): boolean { + switch (node.kind) { + case 'var': + return node.name === 'y' + case 'neg': + return mentionsY(node.arg) + case 'bin': + return mentionsY(node.a) || mentionsY(node.b) + case 'call': + return node.args.some(mentionsY) + default: + return false + } +} + +/* a point's coordinates hold no variable — a lone x or y has no single value + to place a dot at, so only constant expressions (numbers, pi, sqrt(2)…) + make a coordinate */ +function isConstant(node: Node): boolean { + switch (node.kind) { + case 'var': + return false + case 'neg': + return isConstant(node.arg) + case 'bin': + return isConstant(node.a) && isConstant(node.b) + case 'call': + return node.args.every(isConstant) + default: + return true + } +} + +/* the inside of a "(…, …)" split into its two coordinate token runs, or null + unless there is exactly one comma at this depth */ +function splitPair(inner: Token[]): [Token[], Token[]] | null { + let depth = 0 + let comma = -1 + for (let i = 0; i < inner.length; i++) { + const t = inner[i]! + if (t.t === '(') depth++ + else if (t.t === ')') depth-- + else if (t.t === ',' && depth === 0) { + if (comma !== -1) return null // more than one separator: not a plane point + comma = i + } + } + if (comma === -1) return null + return [inner.slice(0, comma), inner.slice(comma + 1)] +} + +/* tokens shaped "(x, y)": one pair of parens wrapping the whole run, split by + a single top-level comma — the point "(2, 3)". A "(x+1)" has no comma and a + "(1)+(2)" closes its parens early, so neither is read as a point. */ +function pointPair(toks: Token[]): [Token[], Token[]] | null { + if (toks.length < 4 || toks[0]!.t !== '(' || toks[toks.length - 1]!.t !== ')') return null + let depth = 0 + for (let i = 0; i < toks.length; i++) { + if (toks[i]!.t === '(') depth++ + else if (toks[i]!.t === ')') depth-- + if (depth === 0 && i < toks.length - 1) return null // parens closed before the end + } + return splitPair(toks.slice(1, toks.length - 1)) +} + +/* one run of tokens, the whole of it — a leftover is a shape the grammar + does not know */ +function parseAll(toks: Token[]): Node { + const p: Parser = { toks, pos: 0 } + const ast = parseExpr(p) + if (p.pos !== toks.length) throw new PlotParseError('syntax') + return ast +} + +/* read one expression, equation, or point; a refusal says whether a name was + not known or the shape was wrong — an empty text is the caller's to treat as + "nothing asked for", not as an error. A "(x, y)" — or "P = (x, y)" with a + name out front — is a point at fixed coordinates. With an "=" otherwise the + text is the relation left − right = 0 (a lone "y =" ahead of an expression + in x is still the fast function); without it, an expression in x is y = f(x) + and one carrying y is the relation that expression equals zero. */ export function compilePlotExpression(src: string): CompiledPlot { if (src.length > 400) return { ok: false, reason: 'syntax' } try { const toks = tokenize(stripAssignment(src)) if (toks.length === 0) return { ok: false, reason: 'empty' } - const p: Parser = { toks, pos: 0 } - const ast = parseExpr(p) - if (p.pos !== toks.length) throw new PlotParseError('syntax') - return { ok: true, fn: (x) => evaluate(ast, x) } + const at = toks.findIndex((tk) => tk.t === '=') + + // A point: "(x, y)" on its own, or "P = (x, y)" with a lone name ahead of + // the "=". Coordinates must be constant — a variable has no single value + // to place a dot at — and finite once read. + const named = at === 1 && toks[0]!.t === 'id' + const pair = pointPair(named ? toks.slice(2) : toks) + if (pair) { + const cx = parseAll(pair[0]) + const cy = parseAll(pair[1]) + if (!isConstant(cx) || !isConstant(cy)) return { ok: false, reason: 'syntax' } + const x = evaluate(cx, 0, 0) + const y = evaluate(cy, 0, 0) + if (!Number.isFinite(x) || !Number.isFinite(y)) return { ok: false, reason: 'syntax' } + const label = named ? src.slice(0, src.indexOf('=')).trim() : null + return { ok: true, kind: 'point', x, y, label: label || null } + } + + if (at === -1) { + const ast = parseAll(toks) + return mentionsY(ast) + ? { ok: true, kind: 'relation', fn: (x, y) => evaluate(ast, x, y) } + : { ok: true, kind: 'function', fn: (x) => evaluate(ast, x, 0) } + } + const left = toks.slice(0, at) + const right = toks.slice(at + 1) + if (left.length === 0 || right.length === 0 || right.some((tk) => tk.t === '=')) + throw new PlotParseError('syntax') + const rhs = parseAll(right) + // "y = " is the explicit curve it always was: x alone decides it + if (left.length === 1 && left[0]!.t === 'id' && left[0]!.name === 'y' && !mentionsY(rhs)) + return { ok: true, kind: 'function', fn: (x) => evaluate(rhs, x, 0) } + const lhs = parseAll(left) + return { ok: true, kind: 'relation', fn: (x, y) => evaluate(lhs, x, y) - evaluate(rhs, x, y) } } catch (err) { if (err instanceof PlotParseError) return { ok: false, reason: err.reason } return { ok: false, reason: 'syntax' } diff --git a/frontend/app/utils/routes.ts b/frontend/app/utils/routes.ts index 1c8c534..29d041c 100644 --- a/frontend/app/utils/routes.ts +++ b/frontend/app/utils/routes.ts @@ -64,3 +64,35 @@ export function versionHref(identity: string, language?: string): string { export function isReadingSurface(path: string): boolean { return isReaderPath(path) || WIKI_SUB_PATH.test(path) || path === '/new' } + +/* ---------- the wiki's own pages ---------- + + The link map and the graphing calculator are pages the shelf holds no + copies of — but the book can read them too. Each has one plain address of + its own, and useArticleTabs can lay it on the row beside the open articles: + the page then draws the live surface instead of an article, for as long as + its page stays open. */ + +export type ToolPage = 'graph' | 'calculator' + +const TOOL_PAGE_PATHS: Record = { + graph: '/graph', + calculator: '/calculator', +} + +/** The address a tool page lives at. */ +export function toolHref(page: ToolPage): string { + return TOOL_PAGE_PATHS[page] +} + +/** Which tool page an address names, if any. */ +export function toolFromPath(path: string): ToolPage | null { + const clean = path.replace(/\/+$/, '') + for (const page of Object.keys(TOOL_PAGE_PATHS) as ToolPage[]) + if (clean === TOOL_PAGE_PATHS[page]) return page + return null +} + +export function isToolPath(path: string): boolean { + return toolFromPath(path) !== null +} diff --git a/frontend/i18n/locales/ca.json b/frontend/i18n/locales/ca.json index 0a7793b..a95ee17 100644 --- a/frontend/i18n/locales/ca.json +++ b/frontend/i18n/locales/ca.json @@ -32,7 +32,7 @@ "articles": "Articles", "graph": "Graf", "calculator": "Calculadora", - "calculatorHint": "Calculadora — representa funcions de x", + "calculatorHint": "Calculadora — representa funcions i equacions", "admin": "Admin", "adminHint": "Administració — els comptes d'aquesta wiki", "requests": "Sol·licituds", @@ -162,18 +162,17 @@ }, "calc": { "metaTitle": "Calculadora gràfica", - "metaDescription": "Representa funcions de x — el pla es mou arrossegant i s'apropa amb la roda. sin, cos, ln, sqrt i companyia.", + "metaDescription": "Representa funcions de x o equacions en x i y — el pla es mou arrossegant i s'apropa amb la roda. sin, cos, ln, sqrt i companyia.", "panelHeading": "Funcions", "functionsPanel": "Funcions a representar", - "expressionAria": "Funció de x", - "placeholder": "p. ex. sin(x) + x / 4", + "expressionAria": "Funció de x, o una equació en x i y", "cycleColor": "Canvia el color", "show": "Mostra al pla", "hide": "Amaga del pla", "remove": "Treu la funció", "add": "Afegeix funció", - "errorUnknown": "No conec aquest nom — la calculadora entén x, pi, e i les funcions habituals.", - "errorSyntax": "Això no es llegeix com una funció de x.", + "errorUnknown": "No conec aquest nom — la calculadora entén x, y, pi, e i les funcions habituals.", + "errorSyntax": "Això no es llegeix com una funció de x ni com una equació en x i y.", "resetView": "Reinicia la vista", "zoomIn": "Apropa", "zoomOut": "Allunya", diff --git a/frontend/i18n/locales/de.json b/frontend/i18n/locales/de.json index 9f667c1..06b1dee 100644 --- a/frontend/i18n/locales/de.json +++ b/frontend/i18n/locales/de.json @@ -32,7 +32,7 @@ "articles": "Artikel", "graph": "Graph", "calculator": "Rechner", - "calculatorHint": "Rechner — Funktionen von x plotten", + "calculatorHint": "Rechner — Funktionen und Gleichungen plotten", "admin": "Admin", "adminHint": "Verwaltung — die Konten in dieser Wiki", "requests": "Anfragen", @@ -162,18 +162,17 @@ }, "calc": { "metaTitle": "Grafikrechner", - "metaDescription": "Funktionen von x zeichnen — die Ebene zieht man mit der Maus, die Rollen zoomt. sin, cos, ln, sqrt und Co.", + "metaDescription": "Funktionen von x oder ganze Gleichungen in x und y zeichnen — die Ebene zieht man mit der Maus, die Rollen zoomt. sin, cos, ln, sqrt und Co.", "panelHeading": "Funktionen", "functionsPanel": "Zu zeichnende Funktionen", - "expressionAria": "Funktion von x", - "placeholder": "z. B. sin(x) + x / 4", + "expressionAria": "Funktion von x oder Gleichung in x und y", "cycleColor": "Farbe wechseln", "show": "Auf der Ebene anzeigen", "hide": "Auf der Ebene ausblenden", "remove": "Funktion entfernen", "add": "Funktion hinzufügen", - "errorUnknown": "Den Namen kenne ich nicht — der Rechner spricht x, pi, e und die üblichen Funktionen.", - "errorSyntax": "Das liest sich nicht als Funktion von x.", + "errorUnknown": "Den Namen kenne ich nicht — der Rechner spricht x, y, pi, e und die üblichen Funktionen.", + "errorSyntax": "Das liest sich nicht als Funktion von x oder als Gleichung in x und y.", "resetView": "Ansicht zurücksetzen", "zoomIn": "Vergrößern", "zoomOut": "Verkleinern", diff --git a/frontend/i18n/locales/en.json b/frontend/i18n/locales/en.json index 4665375..cd8e876 100644 --- a/frontend/i18n/locales/en.json +++ b/frontend/i18n/locales/en.json @@ -32,7 +32,7 @@ "articles": "Articles", "graph": "Graph", "calculator": "Calculator", - "calculatorHint": "Calculator — plot functions of x", + "calculatorHint": "Calculator — plot functions and equations", "admin": "Admin", "adminHint": "Admin — the accounts on this wiki", "requests": "Requests", @@ -162,18 +162,17 @@ }, "calc": { "metaTitle": "Graphing calculator", - "metaDescription": "Plot functions of x — the plane moves with a drag and zooms with the wheel. sin, cos, ln, sqrt and friends.", + "metaDescription": "Plot functions of x or whole equations in x and y — the plane moves with a drag and zooms with the wheel. sin, cos, ln, sqrt and friends.", "panelHeading": "Functions", "functionsPanel": "Functions to plot", - "expressionAria": "Function of x", - "placeholder": "e.g. sin(x) + x / 4", + "expressionAria": "Function of x, or an equation in x and y", "cycleColor": "Change colour", "show": "Show on the plane", "hide": "Hide from the plane", "remove": "Remove function", "add": "Add function", - "errorUnknown": "I don't know that name — the calculator speaks x, pi, e and the usual functions.", - "errorSyntax": "This does not read as a function of x.", + "errorUnknown": "I don't know that name — the calculator speaks x, y, pi, e and the usual functions.", + "errorSyntax": "This does not read as a function of x or an equation in x and y.", "resetView": "Reset the view", "zoomIn": "Zoom in", "zoomOut": "Zoom out", diff --git a/frontend/i18n/locales/es.json b/frontend/i18n/locales/es.json index f28ac49..cb14631 100644 --- a/frontend/i18n/locales/es.json +++ b/frontend/i18n/locales/es.json @@ -32,7 +32,7 @@ "articles": "Artículos", "graph": "Grafo", "calculator": "Calculadora", - "calculatorHint": "Calculadora — representa funciones de x", + "calculatorHint": "Calculadora — representa funciones y ecuaciones", "admin": "Admin", "adminHint": "Administración — las cuentas de esta wiki", "requests": "Solicitudes", @@ -162,18 +162,17 @@ }, "calc": { "metaTitle": "Calculadora gráfica", - "metaDescription": "Representa funciones de x — el plano se mueve arrastrando y se acerca con la rueda. sin, cos, ln, sqrt y compañía.", + "metaDescription": "Representa funciones de x o ecuaciones en x e y — el plano se mueve arrastrando y se acerca con la rueda. sin, cos, ln, sqrt y compañía.", "panelHeading": "Funciones", "functionsPanel": "Funciones a representar", - "expressionAria": "Función de x", - "placeholder": "p. ej. sin(x) + x / 4", + "expressionAria": "Función de x, o una ecuación en x e y", "cycleColor": "Cambiar color", "show": "Mostrar en el plano", "hide": "Ocultar del plano", "remove": "Quitar función", "add": "Añadir función", - "errorUnknown": "No conozco ese nombre — la calculadora habla x, pi, e y las funciones de siempre.", - "errorSyntax": "Esto no se lee como una función de x.", + "errorUnknown": "No conozco ese nombre — la calculadora habla x, y, pi, e y las funciones de siempre.", + "errorSyntax": "Esto no se lee como una función de x ni como una ecuación en x e y.", "resetView": "Reiniciar la vista", "zoomIn": "Acercar", "zoomOut": "Alejar", diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 540d646..0e77920 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -32,7 +32,7 @@ "articles": "Articles", "graph": "Graphe", "calculator": "Calculatrice", - "calculatorHint": "Calculatrice — trace des fonctions de x", + "calculatorHint": "Calculatrice — trace des fonctions et des équations", "admin": "Admin", "adminHint": "Administration — les comptes de ce wiki", "requests": "Demandes", @@ -162,18 +162,17 @@ }, "calc": { "metaTitle": "Calculatrice graphique", - "metaDescription": "Tracez des fonctions de x — le plan se déplace à la souris et se zoome à la molette. sin, cos, ln, sqrt et compagnie.", + "metaDescription": "Tracez des fonctions de x ou des équations en x et y — le plan se déplace à la souris et se zoome à la molette. sin, cos, ln, sqrt et compagnie.", "panelHeading": "Fonctions", "functionsPanel": "Fonctions à tracer", - "expressionAria": "Fonction de x", - "placeholder": "p. ex. sin(x) + x / 4", + "expressionAria": "Fonction de x, ou une équation en x et y", "cycleColor": "Changer de couleur", "show": "Afficher sur le plan", "hide": "Masquer du plan", "remove": "Supprimer la fonction", "add": "Ajouter une fonction", - "errorUnknown": "Je ne connais pas ce nom — la calculatrice parle x, pi, e et les fonctions habituelles.", - "errorSyntax": "Cela ne se lit pas comme une fonction de x.", + "errorUnknown": "Je ne connais pas ce nom — la calculatrice parle x, y, pi, e et les fonctions habituelles.", + "errorSyntax": "Cela ne se lit pas comme une fonction de x ni comme une équation en x et y.", "resetView": "Réinitialiser la vue", "zoomIn": "Zoomer", "zoomOut": "Dézoomer",