Graph calculator improvmentes
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s
This commit is contained in:
10
AGENTS.md
10
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,
|
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
|
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
|
`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:<name>` 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),
|
- `app/composables/` — `useRecentlyViewed` (localStorage),
|
||||||
`useAuth` (the session: token in the `mathew-session` cookie so the server sees it
|
`useAuth` (the session: token in the `mathew-session` cookie so the server sees it
|
||||||
too, `signIn`/`signUp`/`signOut`, `authHeaders()` for write calls, `restoreSession()`
|
too, `signIn`/`signUp`/`signOut`, `authHeaders()` for write calls, `restoreSession()`
|
||||||
|
|||||||
@@ -269,6 +269,32 @@
|
|||||||
&:hover { color: var(--text); background: var(--surface-2); }
|
&: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) {
|
@media (max-width: 900px) {
|
||||||
.book-layer, .reader-note { inset: 0; box-shadow: none; }
|
.book-layer, .reader-note { inset: 0; box-shadow: none; }
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,12 @@
|
|||||||
font: italic 0.95rem/1 'Computer Modern', Georgia, serif;
|
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 {
|
.calc-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -125,8 +131,6 @@
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: 0.95rem/1.2 'Computer Modern', Georgia, serif;
|
font: 0.95rem/1.2 'Computer Modern', Georgia, serif;
|
||||||
|
|
||||||
&::placeholder { color: var(--faint); opacity: 0.75; }
|
|
||||||
|
|
||||||
&.input-error { color: var(--danger); }
|
&.input-error { color: var(--danger); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,3 +250,40 @@
|
|||||||
// width back at once rather than after the slide
|
// width back at once rather than after the slide
|
||||||
.calc-dock-leave-active { position: absolute; left: 0; right: 0; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,3 +120,30 @@
|
|||||||
|
|
||||||
.graph-state { position: static; display: block; padding: 8px 0 32px; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
.sr-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
Narrow screens fold the book to the one page in view, with its neighbours
|
Narrow screens fold the book to the one page in view, with its neighbours
|
||||||
as chips along the top. */
|
as chips along the top. */
|
||||||
|
|
||||||
|
import type { ArticleTab } from '~/composables/useArticleTabs'
|
||||||
|
|
||||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -34,7 +36,18 @@ const { collapsed } = useSidebar()
|
|||||||
|
|
||||||
const activeTab = computed(() => tabAt(tabs.value, route.path))
|
const activeTab = computed(() => tabAt(tabs.value, route.path))
|
||||||
const activeIdentity = computed(() => activeTab.value?.identity ?? null)
|
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<HTMLElement | null>(null)
|
const trackEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
@@ -157,11 +170,13 @@ watch(collapsed, () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
/* every page of the book should hold its article: pull the ones the route
|
/* 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(
|
watch(
|
||||||
tabs,
|
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 }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
@@ -215,11 +230,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
|||||||
role="tab"
|
role="tab"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
:aria-selected="tab.identity === activeIdentity"
|
:aria-selected="tab.identity === activeIdentity"
|
||||||
:title="t('book.chipHint', { title: tab.title })"
|
:title="t('book.chipHint', { title: tabLabel(tab) })"
|
||||||
@click="turnTo(tab.identity)"
|
@click="turnTo(tab.identity)"
|
||||||
@keydown.enter.prevent="turnTo(tab.identity)"
|
@keydown.enter.prevent="turnTo(tab.identity)"
|
||||||
>
|
>
|
||||||
<span class="chip-title">{{ tab.title }}</span>
|
<span class="chip-title">{{ tabLabel(tab) }}</span>
|
||||||
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.identity)">
|
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.identity)">
|
||||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" aria-hidden="true">
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" aria-hidden="true">
|
||||||
<path d="M6 6l12 12M18 6L6 18" />
|
<path d="M6 6l12 12M18 6L6 18" />
|
||||||
@@ -229,17 +244,28 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div ref="trackEl" class="book-track" @scroll.passive="watchDock">
|
<div ref="trackEl" class="book-track" @scroll.passive="watchDock">
|
||||||
<ArticlePane
|
<!-- an article's page holds its text; one of the wiki's own pages
|
||||||
v-for="(tab, i) in tabs"
|
holds the live surface itself, the map or the plane -->
|
||||||
:key="tab.identity"
|
<template v-for="(tab, i) in tabs" :key="tab.identity">
|
||||||
:identity="tab.identity"
|
<ToolPane
|
||||||
:slug="tab.slug"
|
v-if="tab.tool"
|
||||||
:index="i"
|
:tool="tab.tool"
|
||||||
:active="tab.identity === activeIdentity"
|
:index="i"
|
||||||
@activate="turnTo(tab.identity)"
|
:active="tab.identity === activeIdentity"
|
||||||
@close="closeTab(tab.identity)"
|
@activate="turnTo(tab.identity)"
|
||||||
@remove="removeArticle(tab.identity)"
|
@close="closeTab(tab.identity)"
|
||||||
/>
|
/>
|
||||||
|
<ArticlePane
|
||||||
|
v-else
|
||||||
|
:identity="tab.identity"
|
||||||
|
:slug="tab.slug"
|
||||||
|
:index="i"
|
||||||
|
:active="tab.identity === activeIdentity"
|
||||||
|
@activate="turnTo(tab.identity)"
|
||||||
|
@close="closeTab(tab.identity)"
|
||||||
|
@remove="removeArticle(tab.identity)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav v-if="waiting.length" class="book-dock" :aria-label="t('book.dockAria')">
|
<nav v-if="waiting.length" class="book-dock" :aria-label="t('book.dockAria')">
|
||||||
@@ -249,7 +275,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
|||||||
class="dock-spine"
|
class="dock-spine"
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
:title="t('book.dockHint', { title: tab.title })"
|
:title="t('book.dockHint', { title: tabLabel(tab) })"
|
||||||
@click="turnTo(tab.identity)"
|
@click="turnTo(tab.identity)"
|
||||||
@keydown.enter.prevent="turnTo(tab.identity)"
|
@keydown.enter.prevent="turnTo(tab.identity)"
|
||||||
>
|
>
|
||||||
@@ -258,7 +284,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
|||||||
<path d="M6 6l12 12M18 6L6 18" />
|
<path d="M6 6l12 12M18 6L6 18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<span class="spine-title">{{ tab.title }}</span>
|
<span class="spine-title">{{ tabLabel(tab) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
268
frontend/app/components/CalculatorStage.vue
Normal file
268
frontend/app/components/CalculatorStage.vue
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PlaneFunction, PlotFunction } from '~/utils/plot-expression'
|
||||||
|
import type { PlotSeries } from './PlotCanvas.vue'
|
||||||
|
|
||||||
|
/* The graphing calculator itself: the plane owns the surface, the inputs
|
||||||
|
dock to its left edge — foldable, so the plane can have every pixel.
|
||||||
|
Each field reads as y = f(x), but a text that carries its own "y" or an
|
||||||
|
"=" — "y^2 + x^2 = 1" — is a relation F(x, y) and its zero set is drawn,
|
||||||
|
so any equation in two variables plots. Everything is compiled as it is
|
||||||
|
typed; one that reads wrong goes red and simply stops being drawn. The
|
||||||
|
rows and the dock's fold are the reader's own — kept between visits and
|
||||||
|
between instances, the way the link map keeps its display switch.
|
||||||
|
The /calculator address draws this as the page; the book lays it on one
|
||||||
|
of its pages (ToolPane), an instance beside the open articles. */
|
||||||
|
|
||||||
|
interface PlotRow {
|
||||||
|
id: number
|
||||||
|
expr: string
|
||||||
|
color: string
|
||||||
|
visible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/* a curve wears one of eight hues, each readable on paper and on slate */
|
||||||
|
const PALETTE = [
|
||||||
|
'#e5484d', '#0091ff', '#46a758', '#f76808', '#8e4ec6', '#12a594', '#e93d82', '#3e63dd',
|
||||||
|
]
|
||||||
|
const STORAGE_KEY = 'mathew-calculator-plots'
|
||||||
|
const DOCK_KEY = 'mathew-calc-dock'
|
||||||
|
const MAX_ROWS = 12
|
||||||
|
|
||||||
|
let nextId = 1
|
||||||
|
const seedRows = (): PlotRow[] => [
|
||||||
|
{ id: nextId++, expr: 'sin(x)', color: PALETTE[0]!, visible: true },
|
||||||
|
{ id: nextId++, expr: 'x^2 + y^2 = 9', color: PALETTE[1]!, visible: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const rows = ref<PlotRow[]>(seedRows())
|
||||||
|
|
||||||
|
/* one compile pass per render: the rows read alongside what they became —
|
||||||
|
a function or relation to plot, and a reason for the ones that read wrong
|
||||||
|
(an empty field is nothing asked for, not a mistake). A relation carries
|
||||||
|
its own y, so the field drops the "y =" it would otherwise wear. */
|
||||||
|
interface PlotEntry {
|
||||||
|
row: PlotRow
|
||||||
|
kind: 'function' | 'relation' | null
|
||||||
|
fn: PlotFunction | PlaneFunction | null
|
||||||
|
error: 'unknown' | 'syntax' | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = computed<PlotEntry[]>(() =>
|
||||||
|
rows.value.map((row) => {
|
||||||
|
const src = row.expr.trim()
|
||||||
|
if (!src) return { row, kind: null, fn: null, error: null }
|
||||||
|
const result = compilePlotExpression(src)
|
||||||
|
if (result.ok) return { row, kind: result.kind, fn: result.fn, error: null }
|
||||||
|
return { row, kind: null, fn: null, error: result.reason === 'empty' ? null : result.reason }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const series = computed<PlotSeries[]>(() => {
|
||||||
|
const out: PlotSeries[] = []
|
||||||
|
for (const e of entries.value) {
|
||||||
|
if (!e.kind || !e.fn || !e.row.visible) continue
|
||||||
|
// the union is keyed to the compile result's kind, which picks fn's shape
|
||||||
|
if (e.kind === 'function')
|
||||||
|
out.push({ id: e.row.id, color: e.row.color, visible: true, kind: 'function', fn: e.fn as PlotFunction })
|
||||||
|
else out.push({ id: e.row.id, color: e.row.color, visible: true, kind: 'relation', fn: e.fn as PlaneFunction })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
/* a new curve takes the first hue no other row is wearing */
|
||||||
|
function nextColor(): string {
|
||||||
|
const used = new Set(rows.value.map((r) => r.color))
|
||||||
|
return PALETTE.find((c) => !used.has(c)) ?? PALETTE[rows.value.length % PALETTE.length]!
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
if (rows.value.length >= MAX_ROWS) return
|
||||||
|
rows.value.push({ id: nextId++, expr: '', color: nextColor(), visible: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(id: number) {
|
||||||
|
rows.value = rows.value.filter((r) => r.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVisible(id: number) {
|
||||||
|
const row = rows.value.find((r) => r.id === id)
|
||||||
|
if (row) row.visible = !row.visible
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleColor(id: number) {
|
||||||
|
const row = rows.value.find((r) => r.id === id)
|
||||||
|
if (!row) return
|
||||||
|
const at = PALETTE.indexOf(row.color)
|
||||||
|
row.color = PALETTE[(at + 1) % PALETTE.length]!
|
||||||
|
}
|
||||||
|
|
||||||
|
/* pressing Enter on the last row opens the next one, the way the list
|
||||||
|
invites one more function */
|
||||||
|
function onEnter(id: number) {
|
||||||
|
if (id === rows.value[rows.value.length - 1]?.id) addRow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* whether the dock stands open is the reader's own — the fold slides the
|
||||||
|
rows away and leaves the plane alone; the choice is kept between visits */
|
||||||
|
const dockOpen = ref(true)
|
||||||
|
|
||||||
|
function toggleDock() {
|
||||||
|
dockOpen.value = !dockOpen.value
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DOCK_KEY, dockOpen.value ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
/* private mode etc. — the fold just won't persist */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the rows keep themselves between visits */
|
||||||
|
function saveRows() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
STORAGE_KEY,
|
||||||
|
JSON.stringify(rows.value.map(({ expr, color, visible }) => ({ expr, color, visible })))
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
/* private mode etc. — the rows just won't persist */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(rows, saveRows, { deep: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
try {
|
||||||
|
dockOpen.value = localStorage.getItem(DOCK_KEY) !== '0'
|
||||||
|
} catch {
|
||||||
|
/* storage unreadable — the dock stays open */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (!raw) return
|
||||||
|
const stored: unknown = JSON.parse(raw)
|
||||||
|
if (!Array.isArray(stored)) return
|
||||||
|
const restored: PlotRow[] = []
|
||||||
|
for (const item of stored) {
|
||||||
|
if (typeof item !== 'object' || item === null) continue
|
||||||
|
const { expr, color, visible } = item as Partial<Record<'expr' | 'color' | 'visible', unknown>>
|
||||||
|
restored.push({
|
||||||
|
id: nextId++,
|
||||||
|
expr: typeof expr === 'string' ? expr : '',
|
||||||
|
color: typeof color === 'string' ? color : PALETTE[restored.length % PALETTE.length]!,
|
||||||
|
visible: visible !== false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (restored.length) rows.value = restored.slice(0, MAX_ROWS)
|
||||||
|
} catch {
|
||||||
|
/* storage unreadable — the default functions stay */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="calc-stage">
|
||||||
|
<PlotCanvas :series="series" />
|
||||||
|
|
||||||
|
<Transition name="calc-dock">
|
||||||
|
<aside v-if="dockOpen" class="calc-dock" :aria-label="t('calc.functionsPanel')">
|
||||||
|
<div class="calc-dock-head">
|
||||||
|
<h2 class="calc-dock-title">{{ t('calc.panelHeading') }}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="panel-icon-btn"
|
||||||
|
:title="t('calc.collapse')"
|
||||||
|
:aria-label="t('calc.collapse')"
|
||||||
|
@click="toggleDock()"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m15 18-6-6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="calc-rows">
|
||||||
|
<li v-for="e in entries" :key="e.row.id" class="calc-item" :class="{ invalid: !!e.error }">
|
||||||
|
<div class="calc-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="calc-swatch"
|
||||||
|
:style="{ background: e.row.color }"
|
||||||
|
:title="t('calc.cycleColor')"
|
||||||
|
:aria-label="t('calc.cycleColor')"
|
||||||
|
@click="cycleColor(e.row.id)"
|
||||||
|
/>
|
||||||
|
<label class="calc-input-box" :class="{ relation: e.kind === 'relation' }">
|
||||||
|
<span v-if="e.kind !== 'relation'" class="calc-y" aria-hidden="true">y=</span>
|
||||||
|
<input
|
||||||
|
v-model="e.row.expr"
|
||||||
|
class="calc-input"
|
||||||
|
:class="{ 'input-error': !!e.error }"
|
||||||
|
type="text"
|
||||||
|
:aria-label="t('calc.expressionAria')"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
autocapitalize="off"
|
||||||
|
@keydown.enter="onEnter(e.row.id)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="panel-icon-btn"
|
||||||
|
:title="e.row.visible ? t('calc.hide') : t('calc.show')"
|
||||||
|
:aria-label="e.row.visible ? t('calc.hide') : t('calc.show')"
|
||||||
|
:aria-pressed="String(e.row.visible)"
|
||||||
|
@click="toggleVisible(e.row.id)"
|
||||||
|
>
|
||||||
|
<svg v-if="e.row.visible" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.5-6.5 10-6.5S22 12 22 12s-3.5 6.5-10 6.5S2 12 2 12z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M9.9 4.24A9.1 9.1 0 0 1 12 4c6.5 0 10 8 10 8a13.2 13.2 0 0 1-1.67 2.68" />
|
||||||
|
<path d="M6.61 6.61A13.5 13.5 0 0 0 2 12s3.5 8 10 8a9.7 9.7 0 0 0 5.39-1.61" />
|
||||||
|
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
|
||||||
|
<path d="m2 2 20 20" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="panel-icon-btn calc-remove"
|
||||||
|
:title="t('calc.remove')"
|
||||||
|
:aria-label="t('calc.remove')"
|
||||||
|
@click="removeRow(e.row.id)"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span v-if="e.error" class="calc-error">{{
|
||||||
|
t(e.error === 'unknown' ? 'calc.errorUnknown' : 'calc.errorSyntax')
|
||||||
|
}}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button v-if="rows.length < MAX_ROWS" type="button" class="btn btn-ghost btn-sm calc-add" @click="addRow">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
{{ t('calc.add') }}
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="!dockOpen"
|
||||||
|
type="button"
|
||||||
|
class="calc-dock-open"
|
||||||
|
:title="t('calc.expand')"
|
||||||
|
:aria-label="t('calc.expand')"
|
||||||
|
@click="toggleDock()"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m9 18 6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
{{ t('calc.panelHeading') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
46
frontend/app/components/GraphStage.vue
Normal file
46
frontend/app/components/GraphStage.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { GraphEdgeDto, GraphNodeDto } from './GraphView.vue'
|
||||||
|
|
||||||
|
interface GraphDto {
|
||||||
|
nodes: GraphNodeDto[]
|
||||||
|
edges: GraphEdgeDto[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The link map itself: the whole surface, with its toolbar floating over
|
||||||
|
it. The /graph address draws it as the page; the book lays this same
|
||||||
|
surface on one of its pages (ToolPane), an instance beside the open
|
||||||
|
articles. */
|
||||||
|
|
||||||
|
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const { data, status, error } = await useFetch<GraphDto>(`${api}/articles/graph`, {
|
||||||
|
default: () => ({ nodes: [], edges: [] }),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="graph-stage">
|
||||||
|
<div v-if="error" class="graph-state">
|
||||||
|
<div class="banner">
|
||||||
|
<span class="glyph">!</span>
|
||||||
|
<span v-html="t('common.apiDown')"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GraphView
|
||||||
|
v-else-if="status !== 'pending' && data.nodes.length"
|
||||||
|
:nodes="data.nodes"
|
||||||
|
:edges="data.edges"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="status !== 'pending'" class="graph-state">
|
||||||
|
<div class="empty">
|
||||||
|
<div class="glyph">∅</div>
|
||||||
|
<h3>{{ t('graph.emptyTitle') }}</h3>
|
||||||
|
<p>{{ t('graph.emptyText') }}</p>
|
||||||
|
<NuxtLink to="/new" class="btn btn-primary">{{ t('common.startWriting') }}</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,21 +1,23 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/* The graphing calculator's plane: one canvas painted with a labelled grid
|
/* The graphing calculator's plane: one canvas painted with a labelled grid
|
||||||
and any number of y = f(x) curves, each evaluated once per pixel column.
|
and any number of curves — each y = f(x) traced once per pixel column,
|
||||||
Drag to move the plane, the wheel to zoom — anchored at the cursor, the
|
each relation F(x, y) = 0 found by marching squares over a coarse grid
|
||||||
way the link map does it — the buttons to zoom from the middle, and a
|
(every cell whose corners straddle zero gets a segment, so "y^2 + x^2 = 1"
|
||||||
double-click to return to the default view. A curve breaks where the
|
draws its circle) — and each point "(x, y)" as a labelled dot. Drag to move
|
||||||
numbers break (NaN, ±∞) or where it jumps the canvas between two
|
the plane, the wheel to zoom — anchored at
|
||||||
|
the cursor, the way the link map does it — the buttons to zoom from the
|
||||||
|
middle, and a double-click to return to the default view. A curve breaks
|
||||||
|
where the numbers break (NaN, ±∞) or where it jumps the canvas between two
|
||||||
pixels: tan(x) keeps its gaps rather than a false connecting line.
|
pixels: tan(x) keeps its gaps rather than a false connecting line.
|
||||||
Grid, axes and labels follow the site theme, accent included. */
|
Grid, axes and labels follow the site theme, accent included. */
|
||||||
|
|
||||||
import type { PlotFunction } from '~/utils/plot-expression'
|
import type { PlaneFunction, PlotFunction } from '~/utils/plot-expression'
|
||||||
|
|
||||||
export interface PlotSeries {
|
export type PlotSeries =
|
||||||
id: number
|
| { id: number; color: string; visible: boolean; kind: 'function'; fn: PlotFunction }
|
||||||
color: string
|
| { id: number; color: string; visible: boolean; kind: 'relation'; fn: PlaneFunction }
|
||||||
visible: boolean
|
// a single dot at fixed coordinates, optionally with a label ("P")
|
||||||
fn: PlotFunction | null
|
| { id: number; color: string; visible: boolean; kind: 'point'; x: number; y: number; label: string | null }
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{ series: PlotSeries[] }>()
|
const props = defineProps<{ series: PlotSeries[] }>()
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ const colors = {
|
|||||||
gridMajor: '#cfd5e3',
|
gridMajor: '#cfd5e3',
|
||||||
axis: '#8a93a8',
|
axis: '#8a93a8',
|
||||||
tick: '#8a93a8',
|
tick: '#8a93a8',
|
||||||
|
pointRing: '#ffffff',
|
||||||
}
|
}
|
||||||
function readColors() {
|
function readColors() {
|
||||||
const cs = getComputedStyle(document.documentElement)
|
const cs = getComputedStyle(document.documentElement)
|
||||||
@@ -55,6 +58,7 @@ function readColors() {
|
|||||||
colors.gridMajor = at('--border-strong', colors.gridMajor)
|
colors.gridMajor = at('--border-strong', colors.gridMajor)
|
||||||
colors.axis = at('--faint', colors.axis)
|
colors.axis = at('--faint', colors.axis)
|
||||||
colors.tick = at('--faint', colors.tick)
|
colors.tick = at('--faint', colors.tick)
|
||||||
|
colors.pointRing = at('--surface', colors.pointRing)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* the spacing between labelled lines: a 1/2/5 × 10ⁿ step wide enough for
|
/* the spacing between labelled lines: a 1/2/5 × 10ⁿ step wide enough for
|
||||||
@@ -83,6 +87,12 @@ function formatReadout(v: number): string {
|
|||||||
return s === '-0.0' || /^-0(\.0+)?$/.test(s) ? s.slice(1) : s
|
return s === '-0.0' || /^-0(\.0+)?$/.test(s) ? s.slice(1) : s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a point's coordinate as read on the plane: whole numbers stand plain, the
|
||||||
|
rest keep six significant figures with the trailing zeros trimmed */
|
||||||
|
function formatCoord(v: number): string {
|
||||||
|
return Number.isInteger(v) ? String(v) : String(Number(v.toPrecision(6)))
|
||||||
|
}
|
||||||
|
|
||||||
function draw() {
|
function draw() {
|
||||||
const ctx = canvasEl.value?.getContext('2d')
|
const ctx = canvasEl.value?.getContext('2d')
|
||||||
if (!ctx || width === 0 || height === 0) return
|
if (!ctx || width === 0 || height === 0) return
|
||||||
@@ -173,13 +183,25 @@ function draw() {
|
|||||||
ctx.fillText('0', axisX - 5, axisY + 9)
|
ctx.fillText('0', axisX - 5, axisY + 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* the curves last, one pixel column at a time */
|
/* the curves last. A function of x is traced one pixel column at a time;
|
||||||
|
a relation F(x, y) = 0 has no such column form, so it is found by
|
||||||
|
marching squares over a coarse grid — every cell whose corners straddle
|
||||||
|
zero earns a short segment, and together they draw the level curve; a
|
||||||
|
point is a single labelled dot. */
|
||||||
for (const s of props.series) {
|
for (const s of props.series) {
|
||||||
if (!s.visible || !s.fn) continue
|
if (!s.visible) continue
|
||||||
ctx.strokeStyle = s.color
|
ctx.strokeStyle = s.color
|
||||||
ctx.lineWidth = 2
|
ctx.lineWidth = 2
|
||||||
ctx.lineJoin = 'round'
|
ctx.lineJoin = 'round'
|
||||||
ctx.lineCap = 'round'
|
ctx.lineCap = 'round'
|
||||||
|
if (s.kind === 'relation') {
|
||||||
|
drawRelation(ctx, s.fn)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (s.kind === 'point') {
|
||||||
|
drawPoint(ctx, s.x, s.y, s.color, s.label)
|
||||||
|
continue
|
||||||
|
}
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
let pen = false
|
let pen = false
|
||||||
let prevPy = 0
|
let prevPy = 0
|
||||||
@@ -205,6 +227,122 @@ function draw() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* one point: a filled dot in the series colour, ringed in the surface tint so
|
||||||
|
it stays legible over gridlines on paper and slate alike, and labelled with
|
||||||
|
its name (when it has one) and its coordinates, in the same colour so it
|
||||||
|
reads as belonging to that dot */
|
||||||
|
function drawPoint(
|
||||||
|
ctx: CanvasRenderingContext2D,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
color: string,
|
||||||
|
label: string | null,
|
||||||
|
) {
|
||||||
|
const px = toScreenX(x)
|
||||||
|
const py = toScreenY(y)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(px, py, 4, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.fill()
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.strokeStyle = colors.pointRing
|
||||||
|
ctx.stroke()
|
||||||
|
const coords = `(${formatCoord(x)}, ${formatCoord(y)})`
|
||||||
|
ctx.font = "12px 'Computer Modern', Georgia, serif"
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.textBaseline = 'bottom'
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.fillText(label ? `${label} = ${coords}` : coords, px + 8, py - 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* one edge of a marching-squares cell: where the segment between two corners
|
||||||
|
crosses zero, as a point in screen space — null when the two agree in
|
||||||
|
sign (no crossing) or either is a bad number */
|
||||||
|
function edgeCross(ax: number, ay: number, av: number, bx: number, by: number, bv: number) {
|
||||||
|
if (!Number.isFinite(av) || !Number.isFinite(bv)) return null
|
||||||
|
if (av >= 0 === bv >= 0) return null
|
||||||
|
const t = av / (av - bv)
|
||||||
|
return { x: ax + (bx - ax) * t, y: ay + (by - ay) * t }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the pixel grid a relation is sampled on, and the largest |F| taken for a
|
||||||
|
real value — past it a corner reads as a pole, not a crossing, so its cell
|
||||||
|
is left blank rather than connected by a false line */
|
||||||
|
const RELATION_CELL = 8
|
||||||
|
const RELATION_MAG = 1e12
|
||||||
|
|
||||||
|
function drawRelation(ctx: CanvasRenderingContext2D, f: PlaneFunction) {
|
||||||
|
const cols = Math.ceil(width / RELATION_CELL)
|
||||||
|
const rows = Math.ceil(height / RELATION_CELL)
|
||||||
|
const nx = cols + 1
|
||||||
|
const sample = (px: number, py: number): number => {
|
||||||
|
const v = f(toWorldX(px), toWorldY(py))
|
||||||
|
return !Number.isFinite(v) || Math.abs(v) > RELATION_MAG ? NaN : v
|
||||||
|
}
|
||||||
|
// two rows of samples, kept and swapped as the sweep walks down the canvas
|
||||||
|
let prev = new Float64Array(nx)
|
||||||
|
let curr = new Float64Array(nx)
|
||||||
|
for (let i = 0; i < nx; i++) prev[i] = sample(i * RELATION_CELL, 0)
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
let drew = false
|
||||||
|
for (let j = 0; j < rows; j++) {
|
||||||
|
const y0 = j * RELATION_CELL
|
||||||
|
const y1 = Math.min(y0 + RELATION_CELL, height)
|
||||||
|
for (let i = 0; i < nx; i++) curr[i] = sample(i * RELATION_CELL, y1)
|
||||||
|
for (let i = 0; i < cols; i++) {
|
||||||
|
const x0 = i * RELATION_CELL
|
||||||
|
const x1 = Math.min(x0 + RELATION_CELL, width)
|
||||||
|
const tl = prev[i]!
|
||||||
|
const tr = prev[i + 1]!
|
||||||
|
const bl = curr[i]!
|
||||||
|
const br = curr[i + 1]!
|
||||||
|
// a cell with any bad corner is a singularity's neighbourhood: skip it
|
||||||
|
if (!Number.isFinite(tl) || !Number.isFinite(tr) || !Number.isFinite(bl) || !Number.isFinite(br))
|
||||||
|
continue
|
||||||
|
const top = edgeCross(x0, y0, tl, x1, y0, tr)
|
||||||
|
const right = edgeCross(x1, y0, tr, x1, y1, br)
|
||||||
|
const bottom = edgeCross(x1, y1, br, x0, y1, bl)
|
||||||
|
const left = edgeCross(x0, y0, tl, x0, y1, bl)
|
||||||
|
const idx = ((tl >= 0 ? 8 : 0) | (tr >= 0 ? 4 : 0) | (br >= 0 ? 2 : 0) | (bl >= 0 ? 1 : 0)) >>> 0
|
||||||
|
if (idx === 0 || idx === 15) continue
|
||||||
|
|
||||||
|
const seg = (p: { x: number; y: number } | null, q: { x: number; y: number } | null) => {
|
||||||
|
if (!p || !q) return
|
||||||
|
ctx.moveTo(p.x, p.y)
|
||||||
|
ctx.lineTo(q.x, q.y)
|
||||||
|
drew = true
|
||||||
|
}
|
||||||
|
if (idx === 5 || idx === 10) {
|
||||||
|
// saddle: four crossings, and the cell centre says how they pair up
|
||||||
|
const mid = sample((x0 + x1) / 2, (y0 + y1) / 2)
|
||||||
|
const centreUp = Number.isFinite(mid) ? mid >= 0 : tl >= 0
|
||||||
|
if (idx === 5) {
|
||||||
|
seg(top, centreUp ? left : right)
|
||||||
|
seg(bottom, centreUp ? right : left)
|
||||||
|
} else {
|
||||||
|
seg(top, centreUp ? right : left)
|
||||||
|
seg(bottom, centreUp ? left : right)
|
||||||
|
}
|
||||||
|
} else if (idx === 3 || idx === 12) {
|
||||||
|
seg(left, right)
|
||||||
|
} else if (idx === 6 || idx === 9) {
|
||||||
|
seg(top, bottom)
|
||||||
|
} else {
|
||||||
|
// one corner cut off: exactly two edges cross, and they form the segment
|
||||||
|
const pts = [top, right, bottom, left].filter(
|
||||||
|
(p): p is { x: number; y: number } => p !== null,
|
||||||
|
)
|
||||||
|
if (pts.length >= 2) seg(pts[0]!, pts[1]!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const swap = prev
|
||||||
|
prev = curr
|
||||||
|
curr = swap
|
||||||
|
}
|
||||||
|
if (drew) ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
/* paint on demand: one frame is scheduled whenever something moved */
|
/* paint on demand: one frame is scheduled whenever something moved */
|
||||||
let scheduled = false
|
let scheduled = false
|
||||||
function requestDraw() {
|
function requestDraw() {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ArticleMeta } from './ArticleCard.vue'
|
import type { ArticleMeta } from './ArticleCard.vue'
|
||||||
|
import type { ToolPage } from '~/utils/routes'
|
||||||
|
|
||||||
/* Centered search popup: the sidebar and home search bars (or Ctrl/⌘+K,
|
/* Centered search popup: the sidebar and home search bars (or Ctrl/⌘+K,
|
||||||
or "/" anywhere but a text field) open it, then it live-searches
|
or "/" anywhere but a text field) open it, then it live-searches
|
||||||
articles against the API as you type. ↑/↓ select, ↵ opens, esc closes. */
|
articles against the API as you type. ↑/↓ select, ↵ opens, esc closes.
|
||||||
|
The wiki's own pages — the link map and the graphing calculator — answer
|
||||||
|
the search too, and ↵ on one lays that surface on the book beside the
|
||||||
|
open articles rather than turning the page away to it. */
|
||||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -13,9 +17,20 @@ const { open, show, hide, toggle } = useSearch()
|
|||||||
const { hide: hideSettings } = useSettings()
|
const { hide: hideSettings } = useSettings()
|
||||||
const { hide: hideProfile } = useProfileMenu()
|
const { hide: hideProfile } = useProfileMenu()
|
||||||
const { viewed } = useRecentlyViewed()
|
const { viewed } = useRecentlyViewed()
|
||||||
|
const { openTool } = useArticleTabs()
|
||||||
|
|
||||||
const MAX_RESULTS = 8
|
const MAX_RESULTS = 8
|
||||||
|
|
||||||
|
/* the wiki's own pages join the search under their names and a few plain
|
||||||
|
words for what they are; each answers at its own address */
|
||||||
|
const PAGE_TOOLS: { tool: ToolPage; terms: string[] }[] = [
|
||||||
|
{ tool: 'graph', terms: ['graph', 'map', 'link map', 'network', 'constellation'] },
|
||||||
|
{
|
||||||
|
tool: 'calculator',
|
||||||
|
terms: ['calculator', 'calc', 'plot', 'plotter', 'graphing', 'plane'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const q = ref('')
|
const q = ref('')
|
||||||
const results = ref<ArticleMeta[]>([])
|
const results = ref<ArticleMeta[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -24,6 +39,17 @@ const activeIndex = ref(0)
|
|||||||
|
|
||||||
const trimmed = computed(() => q.value.trim())
|
const trimmed = computed(() => q.value.trim())
|
||||||
|
|
||||||
|
/* which of the wiki's own pages the term names — matched against the words
|
||||||
|
and the page's own name, either reading into the other, so "calc" leads
|
||||||
|
to the Calculator as surely as the whole word does */
|
||||||
|
const pageRows = computed(() => {
|
||||||
|
const term = trimmed.value.toLowerCase()
|
||||||
|
if (!term) return []
|
||||||
|
return PAGE_TOOLS.filter(({ tool, terms }) =>
|
||||||
|
terms.some((w) => w.includes(term) || term.includes(w)) || tool.includes(term)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
/* --- focus: the field takes focus on open, focus returns on close --- */
|
/* --- focus: the field takes focus on open, focus returns on close --- */
|
||||||
const inputEl = ref<HTMLInputElement | null>(null)
|
const inputEl = ref<HTMLInputElement | null>(null)
|
||||||
let previouslyFocused: HTMLElement | null = null
|
let previouslyFocused: HTMLElement | null = null
|
||||||
@@ -122,8 +148,19 @@ function openAllResults() {
|
|||||||
void router.push({ path: '/', query: { q: trimmed.value } })
|
void router.push({ path: '/', query: { q: trimmed.value } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* open one of the wiki's own pages: lay the page on the book first, so it
|
||||||
|
arrives with the surface already holding it, then turn the desk to its
|
||||||
|
address — where the page is already open, this simply turns back to it */
|
||||||
|
function openPageTool(tool: ToolPage) {
|
||||||
|
hide()
|
||||||
|
openTool(tool)
|
||||||
|
void router.push(toolHref(tool))
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ↑/↓ walk the page rows and the articles as one list — the wiki's own
|
||||||
|
pages lead when they match */
|
||||||
function onFieldKeydown(e: KeyboardEvent) {
|
function onFieldKeydown(e: KeyboardEvent) {
|
||||||
const n = results.value.length
|
const n = pageRows.value.length + results.value.length
|
||||||
if (e.key === 'ArrowDown' && n) {
|
if (e.key === 'ArrowDown' && n) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
activeIndex.value = (activeIndex.value + 1) % n
|
activeIndex.value = (activeIndex.value + 1) % n
|
||||||
@@ -132,7 +169,12 @@ function onFieldKeydown(e: KeyboardEvent) {
|
|||||||
activeIndex.value = (activeIndex.value - 1 + n) % n
|
activeIndex.value = (activeIndex.value - 1 + n) % n
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const chosen = results.value[activeIndex.value] ?? results.value[0]
|
const page = pageRows.value[activeIndex.value]
|
||||||
|
if (page) {
|
||||||
|
openPageTool(page.tool)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const chosen = results.value[activeIndex.value - pageRows.value.length] ?? results.value[0]
|
||||||
if (chosen) openArticle(chosen)
|
if (chosen) openArticle(chosen)
|
||||||
else if (trimmed.value) openAllResults()
|
else if (trimmed.value) openAllResults()
|
||||||
}
|
}
|
||||||
@@ -209,18 +251,52 @@ function highlight(text: string): Part[] {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="search-modal-body">
|
<div class="search-modal-body">
|
||||||
<div v-if="failed" class="banner">
|
<!-- one list, arrowed as one: the wiki's own pages lead when the
|
||||||
<span class="glyph">!</span>
|
term names them, the articles answer under them -->
|
||||||
<span>{{ t('search.apiDown') }}</span>
|
<ul v-if="pageRows.length || results.length" class="search-results" role="listbox">
|
||||||
</div>
|
<li v-for="(p, i) in pageRows" :key="p.tool" role="option" :aria-selected="i === activeIndex">
|
||||||
|
|
||||||
<ul v-else-if="results.length" class="search-results" role="listbox">
|
|
||||||
<li v-for="(a, i) in results" :key="a._id" role="option" :aria-selected="i === activeIndex">
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="articleHref(a)"
|
:to="toolHref(p.tool)"
|
||||||
class="search-result"
|
class="search-result"
|
||||||
:class="{ active: i === activeIndex }"
|
:class="{ active: i === activeIndex }"
|
||||||
@mouseenter="activeIndex = i"
|
@mouseenter="activeIndex = i"
|
||||||
|
@click.prevent="openPageTool(p.tool)"
|
||||||
|
>
|
||||||
|
<span class="sr-title">
|
||||||
|
<span class="sr-page-glyph" aria-hidden="true">
|
||||||
|
<svg v-if="p.tool === 'graph'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="18" cy="5" r="3" />
|
||||||
|
<circle cx="6" cy="12" r="3" />
|
||||||
|
<circle cx="18" cy="19" r="3" />
|
||||||
|
<path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 3v17a1 1 0 0 0 1 1h17" />
|
||||||
|
<path d="M4.5 16.5c2.6 0 3.4-10.5 6.8-10.5s3.9 7 8.2 7" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<template v-for="(p2, pi) in highlight(t(TOOL_LABEL_KEYS[p.tool]))" :key="pi">
|
||||||
|
<mark v-if="p2.hit">{{ p2.text }}</mark>
|
||||||
|
<template v-else>{{ p2.text }}</template>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span class="sr-meta">
|
||||||
|
<span>{{ toolHref(p.tool) }}</span>
|
||||||
|
</span>
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li
|
||||||
|
v-for="(a, i) in results"
|
||||||
|
:key="a._id"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="i + pageRows.length === activeIndex"
|
||||||
|
>
|
||||||
|
<NuxtLink
|
||||||
|
:to="articleHref(a)"
|
||||||
|
class="search-result"
|
||||||
|
:class="{ active: i + pageRows.length === activeIndex }"
|
||||||
|
@mouseenter="activeIndex = i + pageRows.length"
|
||||||
@click.prevent="openArticle(a)"
|
@click.prevent="openArticle(a)"
|
||||||
>
|
>
|
||||||
<span class="sr-title" :lang="articleLanguage(a)">
|
<span class="sr-title" :lang="articleLanguage(a)">
|
||||||
@@ -245,6 +321,11 @@ function highlight(text: string): Part[] {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div v-else-if="failed" class="banner">
|
||||||
|
<span class="glyph">!</span>
|
||||||
|
<span>{{ t('search.apiDown') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="loading">
|
<div v-else-if="loading">
|
||||||
<div v-for="i in 3" :key="i" class="search-skeleton" />
|
<div v-for="i in 3" :key="i" class="search-skeleton" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
85
frontend/app/components/ToolPane.vue
Normal file
85
frontend/app/components/ToolPane.vue
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/* One page of the open book that holds one of the wiki's own surfaces — the
|
||||||
|
link map, the graphing calculator — instead of an article. The paper is the
|
||||||
|
same as an article's page: a spine with the title standing on end and the ×
|
||||||
|
that closes the page, click anywhere to turn back to it. What fills the
|
||||||
|
page is not scrolling prose but the live surface, edge to edge — its own
|
||||||
|
instance, kept warm beside the articles you are reading.
|
||||||
|
|
||||||
|
Everything but the spine of a page that isn't in view is `inert`: the map
|
||||||
|
won't take drags and the calculator won't take typing until you turn to it. */
|
||||||
|
import type { ToolPage } from '~/utils/routes'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/** which of the wiki's own pages this page holds */
|
||||||
|
tool: ToolPage
|
||||||
|
index: number
|
||||||
|
active: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
activate: []
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const title = computed(() => t(TOOL_LABEL_KEYS[props.tool]))
|
||||||
|
|
||||||
|
function turnToThisPage() {
|
||||||
|
if (!props.active) emit('activate')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="book-pane pane-tools"
|
||||||
|
:class="{ active }"
|
||||||
|
:data-tab="toolPageIdentity(tool)"
|
||||||
|
:data-index="index"
|
||||||
|
:style="{ '--i': index }"
|
||||||
|
:aria-label="t('pane.aria', { title })"
|
||||||
|
@click="turnToThisPage"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="pane-spine"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
:aria-current="active ? 'page' : undefined"
|
||||||
|
:aria-disabled="active"
|
||||||
|
:title="t('pane.spineHint', { title })"
|
||||||
|
@click.stop="turnToThisPage"
|
||||||
|
@keydown.enter.prevent="turnToThisPage"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="spine-close"
|
||||||
|
:aria-label="t('common.closeTab')"
|
||||||
|
@click.stop="emit('close')"
|
||||||
|
>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
|
||||||
|
<path d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- the surface's own mark, under the ×, so the spine reads as the
|
||||||
|
page it holds even fanned out among articles -->
|
||||||
|
<svg v-if="tool === 'graph'" class="spine-glyph" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<circle cx="18" cy="5" r="3" />
|
||||||
|
<circle cx="6" cy="12" r="3" />
|
||||||
|
<circle cx="18" cy="19" r="3" />
|
||||||
|
<path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else class="spine-glyph" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M4 3v17a1 1 0 0 0 1 1h17" />
|
||||||
|
<path d="M4.5 16.5c2.6 0 3.4-10.5 6.8-10.5s3.9 7 8.2 7" />
|
||||||
|
</svg>
|
||||||
|
<span class="spine-title">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pane-body" :inert="active ? null : ''">
|
||||||
|
<div class="pane-stage">
|
||||||
|
<GraphStage v-if="tool === 'graph'" />
|
||||||
|
<CalculatorStage v-else />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import type { ToolPage } from '~/utils/routes'
|
||||||
|
|
||||||
export interface ArticleTab {
|
export interface ArticleTab {
|
||||||
/** the article this page is of — its canonical slug, and the page's key */
|
/** the article this page is of — its canonical slug, and the page's key */
|
||||||
identity: string
|
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 */
|
/** the language this page is turned to */
|
||||||
lang: string
|
lang: string
|
||||||
/** the version in view, as the library files it: its stored slug */
|
/** the version in view, as the library files it: its stored slug */
|
||||||
@@ -13,14 +19,31 @@ export interface ArticleTab {
|
|||||||
|
|
||||||
const MAX_TABS = 10
|
const MAX_TABS = 10
|
||||||
|
|
||||||
/** The address a page of the book was read from: the version it is turned to. */
|
/** What each of the wiki's own pages is called, as a message key — the label
|
||||||
export function tabHref(tab: Pick<ArticleTab, 'identity' | 'lang'>): string {
|
* the book's spine, chips and dock wear for one of these pages. */
|
||||||
return versionHref(tab.identity, tab.lang)
|
export const TOOL_LABEL_KEYS: Record<ToolPage, string> = {
|
||||||
|
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<ArticleTab, 'identity' | 'lang' | 'tool'>): 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
|
/** 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 {
|
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)
|
const loc = parseReaderPath(path)
|
||||||
if (!loc) return null
|
if (!loc) return null
|
||||||
/* one article is one page of the book, in whichever language that page is
|
/* one article is one page of the book, in whichever language that page is
|
||||||
@@ -99,6 +122,26 @@ export function useArticleTabs() {
|
|||||||
tabs.value = stack
|
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
|
/* 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
|
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
|
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) => {
|
(to) => {
|
||||||
/* filed under the article's identity, so a version turned to another
|
/* filed under the article's identity, so a version turned to another
|
||||||
language is still found as the same page of the book */
|
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) ?? ''
|
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 }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,156 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { PlotFunction } from '~/utils/plot-expression'
|
/* The graphing calculator's own address. Usually the plane fills the page
|
||||||
import type { PlotSeries } from '~/components/PlotCanvas.vue'
|
here; but when the book has this page open — laid on it from the search
|
||||||
|
popup, beside the open articles — the pane holds the instance, and this
|
||||||
/* The graphing calculator: the plane owns the whole page, the way the link
|
host says nothing over it (the way the article route lets the book draw
|
||||||
map does, and the y = f(x) inputs dock to its left edge — foldable, so
|
its pages). */
|
||||||
the plane can have every pixel. Every expression is compiled as it is
|
|
||||||
typed; one that does not read as a function of x goes red and simply
|
|
||||||
stops being drawn. The rows and the dock's fold are the reader's own —
|
|
||||||
kept between visits, the way the link map keeps its display switch. */
|
|
||||||
|
|
||||||
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
|
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { tabs } = useArticleTabs()
|
||||||
|
|
||||||
interface PlotRow {
|
const heldByBook = computed(() => tabAt(tabs.value, route.path)?.tool != null)
|
||||||
id: number
|
|
||||||
expr: string
|
|
||||||
color: string
|
|
||||||
visible: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/* a curve wears one of eight hues, each readable on paper and on slate */
|
|
||||||
const PALETTE = [
|
|
||||||
'#e5484d', '#0091ff', '#46a758', '#f76808', '#8e4ec6', '#12a594', '#e93d82', '#3e63dd',
|
|
||||||
]
|
|
||||||
const STORAGE_KEY = 'mathew-calculator-plots'
|
|
||||||
const DOCK_KEY = 'mathew-calc-dock'
|
|
||||||
const MAX_ROWS = 12
|
|
||||||
|
|
||||||
let nextId = 1
|
|
||||||
const seedRows = (): PlotRow[] => [
|
|
||||||
{ id: nextId++, expr: 'sin(x)', color: PALETTE[0]!, visible: true },
|
|
||||||
{ id: nextId++, expr: 'x^2 / 3 - 2', color: PALETTE[1]!, visible: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
const rows = ref<PlotRow[]>(seedRows())
|
|
||||||
|
|
||||||
/* one compile pass per render: the rows read alongside what they became —
|
|
||||||
a function to plot, and a reason for the ones that read wrong (an empty
|
|
||||||
field is nothing asked for, not a mistake) */
|
|
||||||
interface PlotEntry {
|
|
||||||
row: PlotRow
|
|
||||||
fn: PlotFunction | null
|
|
||||||
error: 'unknown' | 'syntax' | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = computed<PlotEntry[]>(() =>
|
|
||||||
rows.value.map((row) => {
|
|
||||||
const src = row.expr.trim()
|
|
||||||
if (!src) return { row, fn: null, error: null }
|
|
||||||
const result = compilePlotExpression(src)
|
|
||||||
return result.ok
|
|
||||||
? { row, fn: result.fn, error: null }
|
|
||||||
: { row, fn: null, error: result.reason === 'empty' ? null : result.reason }
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
const series = computed<PlotSeries[]>(() =>
|
|
||||||
entries.value.map((e) => ({
|
|
||||||
id: e.row.id,
|
|
||||||
color: e.row.color,
|
|
||||||
visible: e.row.visible && !!e.fn,
|
|
||||||
fn: e.fn,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
/* a new curve takes the first hue no other row is wearing */
|
|
||||||
function nextColor(): string {
|
|
||||||
const used = new Set(rows.value.map((r) => r.color))
|
|
||||||
return PALETTE.find((c) => !used.has(c)) ?? PALETTE[rows.value.length % PALETTE.length]!
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRow() {
|
|
||||||
if (rows.value.length >= MAX_ROWS) return
|
|
||||||
rows.value.push({ id: nextId++, expr: '', color: nextColor(), visible: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeRow(id: number) {
|
|
||||||
rows.value = rows.value.filter((r) => r.id !== id)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleVisible(id: number) {
|
|
||||||
const row = rows.value.find((r) => r.id === id)
|
|
||||||
if (row) row.visible = !row.visible
|
|
||||||
}
|
|
||||||
|
|
||||||
function cycleColor(id: number) {
|
|
||||||
const row = rows.value.find((r) => r.id === id)
|
|
||||||
if (!row) return
|
|
||||||
const at = PALETTE.indexOf(row.color)
|
|
||||||
row.color = PALETTE[(at + 1) % PALETTE.length]!
|
|
||||||
}
|
|
||||||
|
|
||||||
/* pressing Enter on the last row opens the next one, the way the list
|
|
||||||
invites one more function */
|
|
||||||
function onEnter(id: number) {
|
|
||||||
if (id === rows.value[rows.value.length - 1]?.id) addRow()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* whether the dock stands open is the reader's own — the fold slides the
|
|
||||||
rows away and leaves the plane alone; the choice is kept between visits */
|
|
||||||
const dockOpen = ref(true)
|
|
||||||
|
|
||||||
function toggleDock() {
|
|
||||||
dockOpen.value = !dockOpen.value
|
|
||||||
try {
|
|
||||||
localStorage.setItem(DOCK_KEY, dockOpen.value ? '1' : '0')
|
|
||||||
} catch {
|
|
||||||
/* private mode etc. — the fold just won't persist */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* the rows keep themselves between visits */
|
|
||||||
function saveRows() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(
|
|
||||||
STORAGE_KEY,
|
|
||||||
JSON.stringify(rows.value.map(({ expr, color, visible }) => ({ expr, color, visible })))
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
/* private mode etc. — the rows just won't persist */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
watch(rows, saveRows, { deep: true })
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
try {
|
|
||||||
dockOpen.value = localStorage.getItem(DOCK_KEY) !== '0'
|
|
||||||
} catch {
|
|
||||||
/* storage unreadable — the dock stays open */
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
|
||||||
if (!raw) return
|
|
||||||
const stored: unknown = JSON.parse(raw)
|
|
||||||
if (!Array.isArray(stored)) return
|
|
||||||
const restored: PlotRow[] = []
|
|
||||||
for (const item of stored) {
|
|
||||||
if (typeof item !== 'object' || item === null) continue
|
|
||||||
const { expr, color, visible } = item as Partial<Record<'expr' | 'color' | 'visible', unknown>>
|
|
||||||
restored.push({
|
|
||||||
id: nextId++,
|
|
||||||
expr: typeof expr === 'string' ? expr : '',
|
|
||||||
color: typeof color === 'string' ? color : PALETTE[restored.length % PALETTE.length]!,
|
|
||||||
visible: visible !== false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (restored.length) rows.value = restored.slice(0, MAX_ROWS)
|
|
||||||
} catch {
|
|
||||||
/* storage unreadable — the default functions stay */
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: () => t('calc.metaTitle'),
|
title: () => t('calc.metaTitle'),
|
||||||
@@ -159,110 +20,5 @@ useSeoMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="calc-stage">
|
<CalculatorStage v-if="!heldByBook" />
|
||||||
<PlotCanvas :series="series" />
|
|
||||||
|
|
||||||
<Transition name="calc-dock">
|
|
||||||
<aside v-if="dockOpen" class="calc-dock" :aria-label="t('calc.functionsPanel')">
|
|
||||||
<div class="calc-dock-head">
|
|
||||||
<h2 class="calc-dock-title">{{ t('calc.panelHeading') }}</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="panel-icon-btn"
|
|
||||||
:title="t('calc.collapse')"
|
|
||||||
:aria-label="t('calc.collapse')"
|
|
||||||
@click="toggleDock()"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="m15 18-6-6 6-6" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="calc-rows">
|
|
||||||
<li v-for="e in entries" :key="e.row.id" class="calc-item" :class="{ invalid: !!e.error }">
|
|
||||||
<div class="calc-row">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="calc-swatch"
|
|
||||||
:style="{ background: e.row.color }"
|
|
||||||
:title="t('calc.cycleColor')"
|
|
||||||
:aria-label="t('calc.cycleColor')"
|
|
||||||
@click="cycleColor(e.row.id)"
|
|
||||||
/>
|
|
||||||
<label class="calc-input-box">
|
|
||||||
<span class="calc-y" aria-hidden="true">y=</span>
|
|
||||||
<input
|
|
||||||
v-model="e.row.expr"
|
|
||||||
class="calc-input"
|
|
||||||
:class="{ 'input-error': !!e.error }"
|
|
||||||
type="text"
|
|
||||||
:placeholder="t('calc.placeholder')"
|
|
||||||
:aria-label="t('calc.expressionAria')"
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
autocapitalize="off"
|
|
||||||
@keydown.enter="onEnter(e.row.id)"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="panel-icon-btn"
|
|
||||||
:title="e.row.visible ? t('calc.hide') : t('calc.show')"
|
|
||||||
:aria-label="e.row.visible ? t('calc.hide') : t('calc.show')"
|
|
||||||
:aria-pressed="String(e.row.visible)"
|
|
||||||
@click="toggleVisible(e.row.id)"
|
|
||||||
>
|
|
||||||
<svg v-if="e.row.visible" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M2 12s3.5-6.5 10-6.5S22 12 22 12s-3.5 6.5-10 6.5S2 12 2 12z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
<svg v-else width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M9.9 4.24A9.1 9.1 0 0 1 12 4c6.5 0 10 8 10 8a13.2 13.2 0 0 1-1.67 2.68" />
|
|
||||||
<path d="M6.61 6.61A13.5 13.5 0 0 0 2 12s3.5 8 10 8a9.7 9.7 0 0 0 5.39-1.61" />
|
|
||||||
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
|
|
||||||
<path d="m2 2 20 20" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="panel-icon-btn calc-remove"
|
|
||||||
:title="t('calc.remove')"
|
|
||||||
:aria-label="t('calc.remove')"
|
|
||||||
@click="removeRow(e.row.id)"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
|
|
||||||
<path d="M18 6 6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<span v-if="e.error" class="calc-error">{{
|
|
||||||
t(e.error === 'unknown' ? 'calc.errorUnknown' : 'calc.errorSyntax')
|
|
||||||
}}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<button v-if="rows.length < MAX_ROWS" type="button" class="btn btn-ghost btn-sm calc-add" @click="addRow">
|
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
|
|
||||||
<path d="M12 5v14M5 12h14" />
|
|
||||||
</svg>
|
|
||||||
{{ t('calc.add') }}
|
|
||||||
</button>
|
|
||||||
</aside>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<button
|
|
||||||
v-if="!dockOpen"
|
|
||||||
type="button"
|
|
||||||
class="calc-dock-open"
|
|
||||||
:title="t('calc.expand')"
|
|
||||||
:aria-label="t('calc.expand')"
|
|
||||||
@click="toggleDock()"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="m9 18 6-6-6-6" />
|
|
||||||
</svg>
|
|
||||||
{{ t('calc.panelHeading') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { GraphEdgeDto, GraphNodeDto } from '~/components/GraphView.vue'
|
/* The link map's own address. Usually the map fills the page here; but when
|
||||||
|
the book has this page open — laid on it from the search popup, beside the
|
||||||
export interface GraphDto {
|
open articles — the pane holds the instance, and this host says nothing
|
||||||
nodes: GraphNodeDto[]
|
over it (the way the article route lets the book draw its pages). */
|
||||||
edges: GraphEdgeDto[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/* matches the home page's transition so the two surfaces slide past
|
/* matches the home page's transition so the two surfaces slide past
|
||||||
each other the way the reader does (see main.scss) */
|
each other the way the reader does (see main.scss) */
|
||||||
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
|
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
|
||||||
|
|
||||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { tabs } = useArticleTabs()
|
||||||
|
|
||||||
const { data, status, error } = await useFetch<GraphDto>(`${api}/articles/graph`, {
|
const heldByBook = computed(() => tabAt(tabs.value, route.path)?.tool != null)
|
||||||
default: () => ({ nodes: [], edges: [] }),
|
|
||||||
})
|
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: () => t('graph.metaTitle'),
|
title: () => t('graph.metaTitle'),
|
||||||
@@ -24,27 +21,5 @@ useSeoMeta({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="graph-stage">
|
<GraphStage v-if="!heldByBook" />
|
||||||
<div v-if="error" class="graph-state">
|
|
||||||
<div class="banner">
|
|
||||||
<span class="glyph">!</span>
|
|
||||||
<span v-html="t('common.apiDown')"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<GraphView
|
|
||||||
v-else-if="status !== 'pending' && data.nodes.length"
|
|
||||||
:nodes="data.nodes"
|
|
||||||
:edges="data.edges"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-else-if="status !== 'pending'" class="graph-state">
|
|
||||||
<div class="empty">
|
|
||||||
<div class="glyph">∅</div>
|
|
||||||
<h3>{{ t('graph.emptyTitle') }}</h3>
|
|
||||||
<p>{{ t('graph.emptyText') }}</p>
|
|
||||||
<NuxtLink to="/new" class="btn btn-primary">{{ t('common.startWriting') }}</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
/* The graphing calculator's little algebra: a hand-rolled tokenizer and
|
/* The graphing calculator's little algebra: a hand-rolled tokenizer and
|
||||||
recursive-descent parser that reads an expression in x — "sin(x) + x / 4",
|
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
|
"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
|
by pixel. A whole equation answers too: "y^2 + x^2 = 1" reads as a
|
||||||
parser can be strict about what it promises — everything it accepts is a
|
relation F(x, y) = left − right, and the curve is where F is zero. So a
|
||||||
real function of x, and everything else is refused with a reason.
|
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
|
Precedence is the usual one: + - bind loosest; then * / % (left, implicit
|
||||||
multiplication included — "2x" is 2·x); then unary minus, so -x^2 negates
|
multiplication included — "2x" is 2·x); then unary minus, so -x^2 negates
|
||||||
@@ -12,21 +17,27 @@
|
|||||||
|
|
||||||
export type PlotFunction = (x: number) => number
|
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
|
/* why a text was refused: nothing to read, a name the calculator does not
|
||||||
speak, or a shape the grammar rejects */
|
speak, or a shape the grammar rejects */
|
||||||
export type PlotErrorReason = 'empty' | 'unknown' | 'syntax'
|
export type PlotErrorReason = 'empty' | 'unknown' | 'syntax'
|
||||||
|
|
||||||
export type CompiledPlot =
|
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 }
|
| { ok: false; reason: PlotErrorReason }
|
||||||
|
|
||||||
type Op = '+' | '-' | '*' | '/' | '%' | '^' | '(' | ')' | ','
|
type Op = '+' | '-' | '*' | '/' | '%' | '^' | '=' | '(' | ')' | ','
|
||||||
|
|
||||||
type Token = { t: Op } | { t: 'num'; value: number } | { t: 'id'; name: string }
|
type Token = { t: Op } | { t: 'num'; value: number } | { t: 'id'; name: string }
|
||||||
|
|
||||||
type Node =
|
type Node =
|
||||||
| { kind: 'num'; value: number }
|
| { kind: 'num'; value: number }
|
||||||
| { kind: 'var' }
|
| { kind: 'var'; name: 'x' | 'y' }
|
||||||
| { kind: 'const'; value: number }
|
| { kind: 'const'; value: number }
|
||||||
| { kind: 'neg'; arg: Node }
|
| { kind: 'neg'; arg: Node }
|
||||||
| { kind: 'bin'; op: '+' | '-' | '*' | '/' | '%' | '^'; a: Node; b: 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
|
/* the input may wear its "f(x) =" beside the field; a reader who types it
|
||||||
answered the same — the rest is what gets plotted. f(x) = reads too. */
|
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 {
|
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[] {
|
function tokenize(src: string): Token[] {
|
||||||
@@ -140,6 +153,11 @@ function tokenize(src: string): Token[] {
|
|||||||
i++
|
i++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (c === '=') {
|
||||||
|
toks.push({ t: '=' })
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (c === '(' || c === ')' || c === ',') {
|
if (c === '(' || c === ')' || c === ',') {
|
||||||
toks.push({ t: c as Op })
|
toks.push({ t: c as Op })
|
||||||
i++
|
i++
|
||||||
@@ -232,7 +250,8 @@ function parseAtom(p: Parser): Node {
|
|||||||
}
|
}
|
||||||
if (tk.t === 'id') {
|
if (tk.t === 'id') {
|
||||||
p.pos++
|
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(CONSTANTS, tk.name)) return { kind: 'const', value: CONSTANTS[tk.name]! }
|
||||||
if (Object.hasOwn(FUNCTIONS, tk.name)) {
|
if (Object.hasOwn(FUNCTIONS, tk.name)) {
|
||||||
const open = peek(p)
|
const open = peek(p)
|
||||||
@@ -254,23 +273,23 @@ function parseAtom(p: Parser): Node {
|
|||||||
throw new PlotParseError('syntax')
|
throw new PlotParseError('syntax')
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluate(node: Node, x: number): number {
|
function evaluate(node: Node, x: number, y: number): number {
|
||||||
switch (node.kind) {
|
switch (node.kind) {
|
||||||
case 'num':
|
case 'num':
|
||||||
return node.value
|
return node.value
|
||||||
case 'var':
|
case 'var':
|
||||||
return x
|
return node.name === 'x' ? x : y
|
||||||
case 'const':
|
case 'const':
|
||||||
return node.value
|
return node.value
|
||||||
case 'neg':
|
case 'neg':
|
||||||
return -evaluate(node.arg, x)
|
return -evaluate(node.arg, x, y)
|
||||||
case 'call': {
|
case 'call': {
|
||||||
const fn = FUNCTIONS[node.name]!
|
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': {
|
case 'bin': {
|
||||||
const a = evaluate(node.a, x)
|
const a = evaluate(node.a, x, y)
|
||||||
const b = evaluate(node.b, x)
|
const b = evaluate(node.b, x, y)
|
||||||
switch (node.op) {
|
switch (node.op) {
|
||||||
case '+':
|
case '+':
|
||||||
return a + b
|
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
|
/* whether y stands anywhere in a tree — the difference between a function of
|
||||||
shape was not a function — an empty text is the caller's to treat as
|
x, which the canvas can trace column by column, and a relation, which it
|
||||||
"nothing asked for", not as an error */
|
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 {
|
export function compilePlotExpression(src: string): CompiledPlot {
|
||||||
if (src.length > 400) return { ok: false, reason: 'syntax' }
|
if (src.length > 400) return { ok: false, reason: 'syntax' }
|
||||||
try {
|
try {
|
||||||
const toks = tokenize(stripAssignment(src))
|
const toks = tokenize(stripAssignment(src))
|
||||||
if (toks.length === 0) return { ok: false, reason: 'empty' }
|
if (toks.length === 0) return { ok: false, reason: 'empty' }
|
||||||
const p: Parser = { toks, pos: 0 }
|
const at = toks.findIndex((tk) => tk.t === '=')
|
||||||
const ast = parseExpr(p)
|
|
||||||
if (p.pos !== toks.length) throw new PlotParseError('syntax')
|
// A point: "(x, y)" on its own, or "P = (x, y)" with a lone name ahead of
|
||||||
return { ok: true, fn: (x) => evaluate(ast, x) }
|
// 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 = <only x>" 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) {
|
} catch (err) {
|
||||||
if (err instanceof PlotParseError) return { ok: false, reason: err.reason }
|
if (err instanceof PlotParseError) return { ok: false, reason: err.reason }
|
||||||
return { ok: false, reason: 'syntax' }
|
return { ok: false, reason: 'syntax' }
|
||||||
|
|||||||
@@ -64,3 +64,35 @@ export function versionHref(identity: string, language?: string): string {
|
|||||||
export function isReadingSurface(path: string): boolean {
|
export function isReadingSurface(path: string): boolean {
|
||||||
return isReaderPath(path) || WIKI_SUB_PATH.test(path) || path === '/new'
|
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<ToolPage, string> = {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"articles": "Articles",
|
"articles": "Articles",
|
||||||
"graph": "Graf",
|
"graph": "Graf",
|
||||||
"calculator": "Calculadora",
|
"calculator": "Calculadora",
|
||||||
"calculatorHint": "Calculadora — representa funcions de x",
|
"calculatorHint": "Calculadora — representa funcions i equacions",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"adminHint": "Administració — els comptes d'aquesta wiki",
|
"adminHint": "Administració — els comptes d'aquesta wiki",
|
||||||
"requests": "Sol·licituds",
|
"requests": "Sol·licituds",
|
||||||
@@ -162,18 +162,17 @@
|
|||||||
},
|
},
|
||||||
"calc": {
|
"calc": {
|
||||||
"metaTitle": "Calculadora gràfica",
|
"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",
|
"panelHeading": "Funcions",
|
||||||
"functionsPanel": "Funcions a representar",
|
"functionsPanel": "Funcions a representar",
|
||||||
"expressionAria": "Funció de x",
|
"expressionAria": "Funció de x, o una equació en x i y",
|
||||||
"placeholder": "p. ex. sin(x) + x / 4",
|
|
||||||
"cycleColor": "Canvia el color",
|
"cycleColor": "Canvia el color",
|
||||||
"show": "Mostra al pla",
|
"show": "Mostra al pla",
|
||||||
"hide": "Amaga del pla",
|
"hide": "Amaga del pla",
|
||||||
"remove": "Treu la funció",
|
"remove": "Treu la funció",
|
||||||
"add": "Afegeix funció",
|
"add": "Afegeix funció",
|
||||||
"errorUnknown": "No conec aquest nom — la calculadora entén x, pi, e i les funcions habituals.",
|
"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.",
|
"errorSyntax": "Això no es llegeix com una funció de x ni com una equació en x i y.",
|
||||||
"resetView": "Reinicia la vista",
|
"resetView": "Reinicia la vista",
|
||||||
"zoomIn": "Apropa",
|
"zoomIn": "Apropa",
|
||||||
"zoomOut": "Allunya",
|
"zoomOut": "Allunya",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"articles": "Artikel",
|
"articles": "Artikel",
|
||||||
"graph": "Graph",
|
"graph": "Graph",
|
||||||
"calculator": "Rechner",
|
"calculator": "Rechner",
|
||||||
"calculatorHint": "Rechner — Funktionen von x plotten",
|
"calculatorHint": "Rechner — Funktionen und Gleichungen plotten",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"adminHint": "Verwaltung — die Konten in dieser Wiki",
|
"adminHint": "Verwaltung — die Konten in dieser Wiki",
|
||||||
"requests": "Anfragen",
|
"requests": "Anfragen",
|
||||||
@@ -162,18 +162,17 @@
|
|||||||
},
|
},
|
||||||
"calc": {
|
"calc": {
|
||||||
"metaTitle": "Grafikrechner",
|
"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",
|
"panelHeading": "Funktionen",
|
||||||
"functionsPanel": "Zu zeichnende Funktionen",
|
"functionsPanel": "Zu zeichnende Funktionen",
|
||||||
"expressionAria": "Funktion von x",
|
"expressionAria": "Funktion von x oder Gleichung in x und y",
|
||||||
"placeholder": "z. B. sin(x) + x / 4",
|
|
||||||
"cycleColor": "Farbe wechseln",
|
"cycleColor": "Farbe wechseln",
|
||||||
"show": "Auf der Ebene anzeigen",
|
"show": "Auf der Ebene anzeigen",
|
||||||
"hide": "Auf der Ebene ausblenden",
|
"hide": "Auf der Ebene ausblenden",
|
||||||
"remove": "Funktion entfernen",
|
"remove": "Funktion entfernen",
|
||||||
"add": "Funktion hinzufügen",
|
"add": "Funktion hinzufügen",
|
||||||
"errorUnknown": "Den Namen kenne ich nicht — der Rechner spricht x, pi, e und die üblichen Funktionen.",
|
"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.",
|
"errorSyntax": "Das liest sich nicht als Funktion von x oder als Gleichung in x und y.",
|
||||||
"resetView": "Ansicht zurücksetzen",
|
"resetView": "Ansicht zurücksetzen",
|
||||||
"zoomIn": "Vergrößern",
|
"zoomIn": "Vergrößern",
|
||||||
"zoomOut": "Verkleinern",
|
"zoomOut": "Verkleinern",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"articles": "Articles",
|
"articles": "Articles",
|
||||||
"graph": "Graph",
|
"graph": "Graph",
|
||||||
"calculator": "Calculator",
|
"calculator": "Calculator",
|
||||||
"calculatorHint": "Calculator — plot functions of x",
|
"calculatorHint": "Calculator — plot functions and equations",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"adminHint": "Admin — the accounts on this wiki",
|
"adminHint": "Admin — the accounts on this wiki",
|
||||||
"requests": "Requests",
|
"requests": "Requests",
|
||||||
@@ -162,18 +162,17 @@
|
|||||||
},
|
},
|
||||||
"calc": {
|
"calc": {
|
||||||
"metaTitle": "Graphing calculator",
|
"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",
|
"panelHeading": "Functions",
|
||||||
"functionsPanel": "Functions to plot",
|
"functionsPanel": "Functions to plot",
|
||||||
"expressionAria": "Function of x",
|
"expressionAria": "Function of x, or an equation in x and y",
|
||||||
"placeholder": "e.g. sin(x) + x / 4",
|
|
||||||
"cycleColor": "Change colour",
|
"cycleColor": "Change colour",
|
||||||
"show": "Show on the plane",
|
"show": "Show on the plane",
|
||||||
"hide": "Hide from the plane",
|
"hide": "Hide from the plane",
|
||||||
"remove": "Remove function",
|
"remove": "Remove function",
|
||||||
"add": "Add function",
|
"add": "Add function",
|
||||||
"errorUnknown": "I don't know that name — the calculator speaks x, pi, e and the usual functions.",
|
"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.",
|
"errorSyntax": "This does not read as a function of x or an equation in x and y.",
|
||||||
"resetView": "Reset the view",
|
"resetView": "Reset the view",
|
||||||
"zoomIn": "Zoom in",
|
"zoomIn": "Zoom in",
|
||||||
"zoomOut": "Zoom out",
|
"zoomOut": "Zoom out",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"articles": "Artículos",
|
"articles": "Artículos",
|
||||||
"graph": "Grafo",
|
"graph": "Grafo",
|
||||||
"calculator": "Calculadora",
|
"calculator": "Calculadora",
|
||||||
"calculatorHint": "Calculadora — representa funciones de x",
|
"calculatorHint": "Calculadora — representa funciones y ecuaciones",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"adminHint": "Administración — las cuentas de esta wiki",
|
"adminHint": "Administración — las cuentas de esta wiki",
|
||||||
"requests": "Solicitudes",
|
"requests": "Solicitudes",
|
||||||
@@ -162,18 +162,17 @@
|
|||||||
},
|
},
|
||||||
"calc": {
|
"calc": {
|
||||||
"metaTitle": "Calculadora gráfica",
|
"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",
|
"panelHeading": "Funciones",
|
||||||
"functionsPanel": "Funciones a representar",
|
"functionsPanel": "Funciones a representar",
|
||||||
"expressionAria": "Función de x",
|
"expressionAria": "Función de x, o una ecuación en x e y",
|
||||||
"placeholder": "p. ej. sin(x) + x / 4",
|
|
||||||
"cycleColor": "Cambiar color",
|
"cycleColor": "Cambiar color",
|
||||||
"show": "Mostrar en el plano",
|
"show": "Mostrar en el plano",
|
||||||
"hide": "Ocultar del plano",
|
"hide": "Ocultar del plano",
|
||||||
"remove": "Quitar función",
|
"remove": "Quitar función",
|
||||||
"add": "Añadir función",
|
"add": "Añadir función",
|
||||||
"errorUnknown": "No conozco ese nombre — la calculadora habla x, pi, e y las funciones de siempre.",
|
"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.",
|
"errorSyntax": "Esto no se lee como una función de x ni como una ecuación en x e y.",
|
||||||
"resetView": "Reiniciar la vista",
|
"resetView": "Reiniciar la vista",
|
||||||
"zoomIn": "Acercar",
|
"zoomIn": "Acercar",
|
||||||
"zoomOut": "Alejar",
|
"zoomOut": "Alejar",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"articles": "Articles",
|
"articles": "Articles",
|
||||||
"graph": "Graphe",
|
"graph": "Graphe",
|
||||||
"calculator": "Calculatrice",
|
"calculator": "Calculatrice",
|
||||||
"calculatorHint": "Calculatrice — trace des fonctions de x",
|
"calculatorHint": "Calculatrice — trace des fonctions et des équations",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"adminHint": "Administration — les comptes de ce wiki",
|
"adminHint": "Administration — les comptes de ce wiki",
|
||||||
"requests": "Demandes",
|
"requests": "Demandes",
|
||||||
@@ -162,18 +162,17 @@
|
|||||||
},
|
},
|
||||||
"calc": {
|
"calc": {
|
||||||
"metaTitle": "Calculatrice graphique",
|
"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",
|
"panelHeading": "Fonctions",
|
||||||
"functionsPanel": "Fonctions à tracer",
|
"functionsPanel": "Fonctions à tracer",
|
||||||
"expressionAria": "Fonction de x",
|
"expressionAria": "Fonction de x, ou une équation en x et y",
|
||||||
"placeholder": "p. ex. sin(x) + x / 4",
|
|
||||||
"cycleColor": "Changer de couleur",
|
"cycleColor": "Changer de couleur",
|
||||||
"show": "Afficher sur le plan",
|
"show": "Afficher sur le plan",
|
||||||
"hide": "Masquer du plan",
|
"hide": "Masquer du plan",
|
||||||
"remove": "Supprimer la fonction",
|
"remove": "Supprimer la fonction",
|
||||||
"add": "Ajouter une fonction",
|
"add": "Ajouter une fonction",
|
||||||
"errorUnknown": "Je ne connais pas ce nom — la calculatrice parle x, pi, e et les fonctions habituelles.",
|
"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.",
|
"errorSyntax": "Cela ne se lit pas comme une fonction de x ni comme une équation en x et y.",
|
||||||
"resetView": "Réinitialiser la vue",
|
"resetView": "Réinitialiser la vue",
|
||||||
"zoomIn": "Zoomer",
|
"zoomIn": "Zoomer",
|
||||||
"zoomOut": "Dézoomer",
|
"zoomOut": "Dézoomer",
|
||||||
|
|||||||
Reference in New Issue
Block a user