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,
|
||||
and the details diff — the article as sent against the text proposed, both through
|
||||
`MarkdownView`, where a creation reads its asked-for address instead of a page that
|
||||
is not there.
|
||||
is not there. The book can hold the wiki's own pages too: `SearchModal` lists
|
||||
`/graph` and `/calculator` among its results (matched by name or by words like
|
||||
*calc*, *plot*, *map*) and opening one lays the live surface on a page of the book
|
||||
beside the articles — `ToolPane.vue` is that page (spine, ×, `inert` when not in
|
||||
view), holding the surface extracted as `GraphStage.vue` / `CalculatorStage.vue`.
|
||||
`useArticleTabs` files such a page under the identity `page:<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),
|
||||
`useAuth` (the session: token in the `mathew-session` cookie so the server sees it
|
||||
too, `signIn`/`signUp`/`signOut`, `authHeaders()` for write calls, `restoreSession()`
|
||||
|
||||
@@ -269,6 +269,32 @@
|
||||
&:hover { color: var(--text); background: var(--surface-2); }
|
||||
}
|
||||
|
||||
// ---------- A wiki page laid on the book ----------
|
||||
// The link map and the graphing calculator are pages too: the search popup
|
||||
// lays one of them on the book beside the articles (ToolPane.vue). It fills
|
||||
// the page edge to edge — no column of prose scrolls here, the surface keeps
|
||||
// its own docks and toolbars — and it takes the whole share of the desk, a
|
||||
// plane being no narrower read than a page of text. Each stage's own sheet
|
||||
// answers for the rest (_graph.scss, _calculator.scss).
|
||||
|
||||
.book-pane.pane-tools {
|
||||
max-width: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pane-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
// the surface's mark on the spine, under the ×, so a page of the map or the
|
||||
// plane reads as itself fanned in among the articles
|
||||
.pane-spine .spine-glyph { flex: none; opacity: 0.65; }
|
||||
|
||||
.book-pane.active .pane-spine .spine-glyph { opacity: 1; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.book-layer, .reader-note { inset: 0; box-shadow: none; }
|
||||
|
||||
|
||||
@@ -115,6 +115,12 @@
|
||||
font: italic 0.95rem/1 'Computer Modern', Georgia, serif;
|
||||
}
|
||||
|
||||
// a relation or point carries no lone "y =", so it sheds the gutter and the
|
||||
// input takes back the room it stood in
|
||||
.calc-input-box.bare .calc-input {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.calc-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -125,8 +131,6 @@
|
||||
color: var(--text);
|
||||
font: 0.95rem/1.2 'Computer Modern', Georgia, serif;
|
||||
|
||||
&::placeholder { color: var(--faint); opacity: 0.75; }
|
||||
|
||||
&.input-error { color: var(--danger); }
|
||||
}
|
||||
|
||||
@@ -246,3 +250,40 @@
|
||||
// width back at once rather than after the slide
|
||||
.calc-dock-leave-active { position: absolute; left: 0; right: 0; }
|
||||
}
|
||||
|
||||
// ---------- Laid on a page of the book (ToolPane) ----------
|
||||
// A pane of the book is a full surface again, at any screen width: the plane
|
||||
// fills the page and the input dock pins to its edge as on the desktop, and
|
||||
// the fold above — panels of the plain /calculator page — does not reach
|
||||
// into the pane.
|
||||
|
||||
.pane-stage .calc-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.pane-stage .plot-wrap {
|
||||
position: absolute;
|
||||
width: auto;
|
||||
height: auto;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.pane-stage .calc-dock {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: clamp(260px, 24vw, 320px);
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border);
|
||||
border-radius: 0;
|
||||
background: color-mix(in srgb, var(--bg-elev) 92%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -120,3 +120,30 @@
|
||||
|
||||
.graph-state { position: static; display: block; padding: 8px 0 32px; }
|
||||
}
|
||||
|
||||
// ---------- Laid on a page of the book (ToolPane) ----------
|
||||
// A pane of the book is a full surface again, at any screen width: the map
|
||||
// fills the page the way it fills the desk, and the fold above — panels of
|
||||
// the plain /graph page — does not reach into the pane.
|
||||
|
||||
.pane-stage .graph-stage {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.pane-stage .graph-canvas-wrap {
|
||||
position: absolute;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pane-stage .graph-state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@@ -117,6 +117,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
// the wiki's own pages carry their mark beside the title, so a row reads as
|
||||
// the surface it opens rather than an article
|
||||
.sr-page-glyph {
|
||||
display: inline-block;
|
||||
margin-right: 7px;
|
||||
color: var(--muted);
|
||||
vertical-align: -2px;
|
||||
|
||||
svg { display: block; }
|
||||
}
|
||||
|
||||
.sr-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
Narrow screens fold the book to the one page in view, with its neighbours
|
||||
as chips along the top. */
|
||||
|
||||
import type { ArticleTab } from '~/composables/useArticleTabs'
|
||||
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -34,7 +36,18 @@ const { collapsed } = useSidebar()
|
||||
|
||||
const activeTab = computed(() => tabAt(tabs.value, route.path))
|
||||
const activeIdentity = computed(() => activeTab.value?.identity ?? null)
|
||||
const visible = computed(() => isReaderPath(route.path) && tabs.value.length > 0)
|
||||
/* the book lies open on a reader route, and on one of the wiki's own pages
|
||||
while the book holds a page of it — asked plain, those pages draw
|
||||
themselves instead */
|
||||
const visible = computed(
|
||||
() => (isReaderPath(route.path) || tabAt(tabs.value, route.path) !== null) && tabs.value.length > 0
|
||||
)
|
||||
|
||||
/* what a page of the book is called: an article wears its title; one of the
|
||||
wiki's own pages is called by what it is, in the reader's language */
|
||||
function tabLabel(tab: ArticleTab): string {
|
||||
return tab.tool ? t(TOOL_LABEL_KEYS[tab.tool]) : tab.title
|
||||
}
|
||||
|
||||
const trackEl = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -157,11 +170,13 @@ watch(collapsed, () => {
|
||||
})
|
||||
|
||||
/* every page of the book should hold its article: pull the ones the route
|
||||
host hasn't already written in (background pages, client-side only) */
|
||||
host hasn't already written in (background pages, client-side only). The
|
||||
wiki's own pages carry no article to fetch — their surface fetches its
|
||||
own map when it is turned to */
|
||||
watch(
|
||||
tabs,
|
||||
() => {
|
||||
for (const t of tabs.value) void load(t.slug)
|
||||
for (const t of tabs.value) if (!t.tool) void load(t.slug)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
@@ -215,11 +230,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
:aria-selected="tab.identity === activeIdentity"
|
||||
:title="t('book.chipHint', { title: tab.title })"
|
||||
:title="t('book.chipHint', { title: tabLabel(tab) })"
|
||||
@click="turnTo(tab.identity)"
|
||||
@keydown.enter.prevent="turnTo(tab.identity)"
|
||||
>
|
||||
<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)">
|
||||
<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" />
|
||||
@@ -229,17 +244,28 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
</nav>
|
||||
|
||||
<div ref="trackEl" class="book-track" @scroll.passive="watchDock">
|
||||
<ArticlePane
|
||||
v-for="(tab, i) in tabs"
|
||||
:key="tab.identity"
|
||||
:identity="tab.identity"
|
||||
:slug="tab.slug"
|
||||
:index="i"
|
||||
:active="tab.identity === activeIdentity"
|
||||
@activate="turnTo(tab.identity)"
|
||||
@close="closeTab(tab.identity)"
|
||||
@remove="removeArticle(tab.identity)"
|
||||
/>
|
||||
<!-- an article's page holds its text; one of the wiki's own pages
|
||||
holds the live surface itself, the map or the plane -->
|
||||
<template v-for="(tab, i) in tabs" :key="tab.identity">
|
||||
<ToolPane
|
||||
v-if="tab.tool"
|
||||
:tool="tab.tool"
|
||||
:index="i"
|
||||
:active="tab.identity === activeIdentity"
|
||||
@activate="turnTo(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>
|
||||
|
||||
<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"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="t('book.dockHint', { title: tab.title })"
|
||||
:title="t('book.dockHint', { title: tabLabel(tab) })"
|
||||
@click="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" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="spine-title">{{ tab.title }}</span>
|
||||
<span class="spine-title">{{ tabLabel(tab) }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
</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">
|
||||
/* 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.
|
||||
Drag to move 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
|
||||
and any number of curves — each y = f(x) traced once per pixel column,
|
||||
each relation F(x, y) = 0 found by marching squares over a coarse grid
|
||||
(every cell whose corners straddle zero gets a segment, so "y^2 + x^2 = 1"
|
||||
draws its circle) — and each point "(x, y)" as a labelled dot. Drag to move
|
||||
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.
|
||||
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 {
|
||||
id: number
|
||||
color: string
|
||||
visible: boolean
|
||||
fn: PlotFunction | null
|
||||
}
|
||||
export type PlotSeries =
|
||||
| { id: number; color: string; visible: boolean; kind: 'function'; fn: PlotFunction }
|
||||
| { id: number; color: string; visible: boolean; kind: 'relation'; fn: PlaneFunction }
|
||||
// a single dot at fixed coordinates, optionally with a label ("P")
|
||||
| { id: number; color: string; visible: boolean; kind: 'point'; x: number; y: number; label: string | null }
|
||||
|
||||
const props = defineProps<{ series: PlotSeries[] }>()
|
||||
|
||||
@@ -47,6 +49,7 @@ const colors = {
|
||||
gridMajor: '#cfd5e3',
|
||||
axis: '#8a93a8',
|
||||
tick: '#8a93a8',
|
||||
pointRing: '#ffffff',
|
||||
}
|
||||
function readColors() {
|
||||
const cs = getComputedStyle(document.documentElement)
|
||||
@@ -55,6 +58,7 @@ function readColors() {
|
||||
colors.gridMajor = at('--border-strong', colors.gridMajor)
|
||||
colors.axis = at('--faint', colors.axis)
|
||||
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
|
||||
@@ -83,6 +87,12 @@ function formatReadout(v: number): string {
|
||||
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() {
|
||||
const ctx = canvasEl.value?.getContext('2d')
|
||||
if (!ctx || width === 0 || height === 0) return
|
||||
@@ -173,13 +183,25 @@ function draw() {
|
||||
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) {
|
||||
if (!s.visible || !s.fn) continue
|
||||
if (!s.visible) continue
|
||||
ctx.strokeStyle = s.color
|
||||
ctx.lineWidth = 2
|
||||
ctx.lineJoin = '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()
|
||||
let pen = false
|
||||
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 */
|
||||
let scheduled = false
|
||||
function requestDraw() {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleMeta } from './ArticleCard.vue'
|
||||
import type { ToolPage } from '~/utils/routes'
|
||||
|
||||
/* Centered search popup: the sidebar and home search bars (or Ctrl/⌘+K,
|
||||
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 route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -13,9 +17,20 @@ const { open, show, hide, toggle } = useSearch()
|
||||
const { hide: hideSettings } = useSettings()
|
||||
const { hide: hideProfile } = useProfileMenu()
|
||||
const { viewed } = useRecentlyViewed()
|
||||
const { openTool } = useArticleTabs()
|
||||
|
||||
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 results = ref<ArticleMeta[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -24,6 +39,17 @@ const activeIndex = ref(0)
|
||||
|
||||
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 --- */
|
||||
const inputEl = ref<HTMLInputElement | null>(null)
|
||||
let previouslyFocused: HTMLElement | null = null
|
||||
@@ -122,8 +148,19 @@ function openAllResults() {
|
||||
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) {
|
||||
const n = results.value.length
|
||||
const n = pageRows.value.length + results.value.length
|
||||
if (e.key === 'ArrowDown' && n) {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value + 1) % n
|
||||
@@ -132,7 +169,12 @@ function onFieldKeydown(e: KeyboardEvent) {
|
||||
activeIndex.value = (activeIndex.value - 1 + n) % n
|
||||
} else if (e.key === 'Enter') {
|
||||
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)
|
||||
else if (trimmed.value) openAllResults()
|
||||
}
|
||||
@@ -209,18 +251,52 @@ function highlight(text: string): Part[] {
|
||||
</div>
|
||||
|
||||
<div class="search-modal-body">
|
||||
<div v-if="failed" class="banner">
|
||||
<span class="glyph">!</span>
|
||||
<span>{{ t('search.apiDown') }}</span>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<!-- one list, arrowed as one: the wiki's own pages lead when the
|
||||
term names them, the articles answer under them -->
|
||||
<ul v-if="pageRows.length || results.length" class="search-results" role="listbox">
|
||||
<li v-for="(p, i) in pageRows" :key="p.tool" role="option" :aria-selected="i === activeIndex">
|
||||
<NuxtLink
|
||||
:to="articleHref(a)"
|
||||
:to="toolHref(p.tool)"
|
||||
class="search-result"
|
||||
:class="{ active: i === activeIndex }"
|
||||
@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)"
|
||||
>
|
||||
<span class="sr-title" :lang="articleLanguage(a)">
|
||||
@@ -245,6 +321,11 @@ function highlight(text: string): Part[] {
|
||||
</li>
|
||||
</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-for="i in 3" :key="i" class="search-skeleton" />
|
||||
</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 {
|
||||
/** the article this page is of — its canonical slug, and the page's key */
|
||||
identity: string
|
||||
/** set when the page is one of the wiki's own pages — the link map, the
|
||||
* graphing calculator — laid on the book instead of an article: the page
|
||||
* draws that live surface, at its own address, for as long as it is open */
|
||||
tool?: ToolPage
|
||||
/** the language this page is turned to */
|
||||
lang: string
|
||||
/** the version in view, as the library files it: its stored slug */
|
||||
@@ -13,14 +19,31 @@ export interface ArticleTab {
|
||||
|
||||
const MAX_TABS = 10
|
||||
|
||||
/** The address a page of the book was read from: the version it is turned to. */
|
||||
export function tabHref(tab: Pick<ArticleTab, 'identity' | 'lang'>): string {
|
||||
return versionHref(tab.identity, tab.lang)
|
||||
/** What each of the wiki's own pages is called, as a message key — the label
|
||||
* the book's spine, chips and dock wear for one of these pages. */
|
||||
export const TOOL_LABEL_KEYS: Record<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
|
||||
* names no language points at the article's page however it is turned. */
|
||||
* names no language points at the article's page however it is turned; the
|
||||
* wiki's own pages are found by their own addresses. */
|
||||
export function tabAt(tabs: readonly ArticleTab[], path: string): ArticleTab | null {
|
||||
const page = toolFromPath(path)
|
||||
if (page) return tabs.find((t) => t.tool === page) ?? null
|
||||
const loc = parseReaderPath(path)
|
||||
if (!loc) return null
|
||||
/* one article is one page of the book, in whichever language that page is
|
||||
@@ -99,6 +122,26 @@ export function useArticleTabs() {
|
||||
tabs.value = stack
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay one of the wiki's own pages — the link map, the graphing calculator —
|
||||
* on the book beside the articles. One surface is one page however many
|
||||
* times it is asked for: asking again just finds the page already there.
|
||||
*/
|
||||
function openTool(page: ToolPage) {
|
||||
const identity = toolPageIdentity(page)
|
||||
if (tabs.value.some((t) => t.tool === page)) return
|
||||
const stack = [
|
||||
...tabs.value,
|
||||
{ identity, tool: page, lang: DEFAULT_LANGUAGE, slug: identity, title: page },
|
||||
]
|
||||
if (stack.length > MAX_TABS) {
|
||||
/* don't stack forever — retire the oldest page that isn't this one */
|
||||
const drop = stack.findIndex((t) => t.identity !== identity)
|
||||
if (drop !== -1) stack.splice(drop, 1)
|
||||
}
|
||||
tabs.value = stack
|
||||
}
|
||||
|
||||
/* Close a page. Closing one that isn't in view just takes it out of the
|
||||
stack and the rest settle; closing the page in view turns back to the
|
||||
page before it — or returns to the last page outside the book once the
|
||||
@@ -113,7 +156,7 @@ export function useArticleTabs() {
|
||||
}
|
||||
}
|
||||
|
||||
return { tabs, origin, lastIdentity, openVersion, closeTab }
|
||||
return { tabs, origin, lastIdentity, openVersion, openTool, closeTab }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,9 +175,21 @@ export function useArticleTabsSync() {
|
||||
(to) => {
|
||||
/* filed under the article's identity, so a version turned to another
|
||||
language is still found as the same page of the book */
|
||||
if (isReaderPath(to))
|
||||
if (isReaderPath(to)) {
|
||||
lastIdentity.value = tabAt(tabs.value, to)?.identity ?? wikiSlugFromPath(to) ?? ''
|
||||
else if (!isReadingSurface(to)) origin.value = to
|
||||
return
|
||||
}
|
||||
/* one of the wiki's own pages held by the book belongs to the reading
|
||||
surface too — it's where its page is in view, not a place to return
|
||||
to; asked plain (no page open), it's an ordinary page of the site */
|
||||
if (isToolPath(to)) {
|
||||
const page = tabAt(tabs.value, to)
|
||||
if (page) {
|
||||
lastIdentity.value = page.identity
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!isReadingSurface(to)) origin.value = to
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
@@ -1,156 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { PlotFunction } from '~/utils/plot-expression'
|
||||
import type { PlotSeries } from '~/components/PlotCanvas.vue'
|
||||
|
||||
/* The graphing calculator: the plane owns the whole page, the way the link
|
||||
map does, and the y = f(x) inputs dock to its left edge — foldable, so
|
||||
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. */
|
||||
/* The graphing calculator's own address. Usually the plane fills the page
|
||||
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
|
||||
host says nothing over it (the way the article route lets the book draw
|
||||
its pages). */
|
||||
|
||||
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { tabs } = useArticleTabs()
|
||||
|
||||
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 / 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 */
|
||||
}
|
||||
})
|
||||
const heldByBook = computed(() => tabAt(tabs.value, route.path)?.tool != null)
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('calc.metaTitle'),
|
||||
@@ -159,110 +20,5 @@ useSeoMeta({
|
||||
</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">
|
||||
<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>
|
||||
<CalculatorStage v-if="!heldByBook" />
|
||||
</template>
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { GraphEdgeDto, GraphNodeDto } from '~/components/GraphView.vue'
|
||||
|
||||
export interface GraphDto {
|
||||
nodes: GraphNodeDto[]
|
||||
edges: GraphEdgeDto[]
|
||||
}
|
||||
/* 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
|
||||
open articles — the pane holds the instance, and this host says nothing
|
||||
over it (the way the article route lets the book draw its pages). */
|
||||
|
||||
/* matches the home page's transition so the two surfaces slide past
|
||||
each other the way the reader does (see main.scss) */
|
||||
definePageMeta({ transition: { name: 'page', mode: 'out-in' } })
|
||||
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { tabs } = useArticleTabs()
|
||||
|
||||
const { data, status, error } = await useFetch<GraphDto>(`${api}/articles/graph`, {
|
||||
default: () => ({ nodes: [], edges: [] }),
|
||||
})
|
||||
const heldByBook = computed(() => tabAt(tabs.value, route.path)?.tool != null)
|
||||
|
||||
useSeoMeta({
|
||||
title: () => t('graph.metaTitle'),
|
||||
@@ -24,27 +21,5 @@ useSeoMeta({
|
||||
</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>
|
||||
<GraphStage v-if="!heldByBook" />
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
/* The graphing calculator's little algebra: a hand-rolled tokenizer and
|
||||
recursive-descent parser that reads an expression in x — "sin(x) + x / 4",
|
||||
"2^x", "x(x+1)" — and compiles it to a function the canvas evaluates pixel
|
||||
by pixel. No library: the grammar is small enough for one file, and the
|
||||
parser can be strict about what it promises — everything it accepts is a
|
||||
real function of x, and everything else is refused with a reason.
|
||||
by pixel. A whole equation answers too: "y^2 + x^2 = 1" reads as a
|
||||
relation F(x, y) = left − right, and the curve is where F is zero. So a
|
||||
text with an "=" is one relation of x and y; without it, an expression
|
||||
mentioning y is the relation that expression equals zero, and one in x
|
||||
alone is the function y = f(x). No library: the grammar is small enough
|
||||
for one file, and the parser can be strict about what it promises —
|
||||
everything it accepts is a real function of x or a real relation in the
|
||||
plane, and everything else is refused with a reason.
|
||||
|
||||
Precedence is the usual one: + - bind loosest; then * / % (left, implicit
|
||||
multiplication included — "2x" is 2·x); then unary minus, so -x^2 negates
|
||||
@@ -12,21 +17,27 @@
|
||||
|
||||
export type PlotFunction = (x: number) => number
|
||||
|
||||
/* a relation of the plane: the curve is its zero set */
|
||||
export type PlaneFunction = (x: number, y: number) => number
|
||||
|
||||
/* why a text was refused: nothing to read, a name the calculator does not
|
||||
speak, or a shape the grammar rejects */
|
||||
export type PlotErrorReason = 'empty' | 'unknown' | 'syntax'
|
||||
|
||||
export type CompiledPlot =
|
||||
| { ok: true; fn: PlotFunction }
|
||||
| { ok: true; kind: 'function'; fn: PlotFunction }
|
||||
| { ok: true; kind: 'relation'; fn: PlaneFunction }
|
||||
// "(x, y)" or "P = (x, y)": a single dot at fixed coordinates, optionally named
|
||||
| { ok: true; kind: 'point'; x: number; y: number; label: string | null }
|
||||
| { ok: false; reason: PlotErrorReason }
|
||||
|
||||
type Op = '+' | '-' | '*' | '/' | '%' | '^' | '(' | ')' | ','
|
||||
type Op = '+' | '-' | '*' | '/' | '%' | '^' | '=' | '(' | ')' | ','
|
||||
|
||||
type Token = { t: Op } | { t: 'num'; value: number } | { t: 'id'; name: string }
|
||||
|
||||
type Node =
|
||||
| { kind: 'num'; value: number }
|
||||
| { kind: 'var' }
|
||||
| { kind: 'var'; name: 'x' | 'y' }
|
||||
| { kind: 'const'; value: number }
|
||||
| { kind: 'neg'; arg: Node }
|
||||
| { kind: 'bin'; op: '+' | '-' | '*' | '/' | '%' | '^'; a: Node; b: Node }
|
||||
@@ -85,10 +96,12 @@ class PlotParseError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/* the input wears its "y =" beside the field; a reader who types it too is
|
||||
answered the same — the rest is what gets plotted. f(x) = reads too. */
|
||||
/* the input may wear its "f(x) =" beside the field; a reader who types it
|
||||
too is answered the same — the rest is what gets plotted. A leading "y ="
|
||||
is read by the equation rule instead, so a right side that also carries y
|
||||
still means the same relation, not a mystery name. */
|
||||
function stripAssignment(src: string): string {
|
||||
return src.replace(/^\s*(?:y|f\s*\(\s*x\s*\))\s*=/i, '')
|
||||
return src.replace(/^\s*f\s*\(\s*x\s*\)\s*=/i, '')
|
||||
}
|
||||
|
||||
function tokenize(src: string): Token[] {
|
||||
@@ -140,6 +153,11 @@ function tokenize(src: string): Token[] {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '=') {
|
||||
toks.push({ t: '=' })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (c === '(' || c === ')' || c === ',') {
|
||||
toks.push({ t: c as Op })
|
||||
i++
|
||||
@@ -232,7 +250,8 @@ function parseAtom(p: Parser): Node {
|
||||
}
|
||||
if (tk.t === 'id') {
|
||||
p.pos++
|
||||
if (tk.name === 'x') return { kind: 'var' }
|
||||
if (tk.name === 'x') return { kind: 'var', name: 'x' }
|
||||
if (tk.name === 'y') return { kind: 'var', name: 'y' }
|
||||
if (Object.hasOwn(CONSTANTS, tk.name)) return { kind: 'const', value: CONSTANTS[tk.name]! }
|
||||
if (Object.hasOwn(FUNCTIONS, tk.name)) {
|
||||
const open = peek(p)
|
||||
@@ -254,23 +273,23 @@ function parseAtom(p: Parser): Node {
|
||||
throw new PlotParseError('syntax')
|
||||
}
|
||||
|
||||
function evaluate(node: Node, x: number): number {
|
||||
function evaluate(node: Node, x: number, y: number): number {
|
||||
switch (node.kind) {
|
||||
case 'num':
|
||||
return node.value
|
||||
case 'var':
|
||||
return x
|
||||
return node.name === 'x' ? x : y
|
||||
case 'const':
|
||||
return node.value
|
||||
case 'neg':
|
||||
return -evaluate(node.arg, x)
|
||||
return -evaluate(node.arg, x, y)
|
||||
case 'call': {
|
||||
const fn = FUNCTIONS[node.name]!
|
||||
return fn(...node.args.map((a) => evaluate(a, x)))
|
||||
return fn(...node.args.map((a) => evaluate(a, x, y)))
|
||||
}
|
||||
case 'bin': {
|
||||
const a = evaluate(node.a, x)
|
||||
const b = evaluate(node.b, x)
|
||||
const a = evaluate(node.a, x, y)
|
||||
const b = evaluate(node.b, x, y)
|
||||
switch (node.op) {
|
||||
case '+':
|
||||
return a + b
|
||||
@@ -289,18 +308,129 @@ function evaluate(node: Node, x: number): number {
|
||||
}
|
||||
}
|
||||
|
||||
/* read one expression; a refusal says whether a name was not known or the
|
||||
shape was not a function — an empty text is the caller's to treat as
|
||||
"nothing asked for", not as an error */
|
||||
/* whether y stands anywhere in a tree — the difference between a function of
|
||||
x, which the canvas can trace column by column, and a relation, which it
|
||||
must search cell by cell */
|
||||
function mentionsY(node: Node): boolean {
|
||||
switch (node.kind) {
|
||||
case 'var':
|
||||
return node.name === 'y'
|
||||
case 'neg':
|
||||
return mentionsY(node.arg)
|
||||
case 'bin':
|
||||
return mentionsY(node.a) || mentionsY(node.b)
|
||||
case 'call':
|
||||
return node.args.some(mentionsY)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/* a point's coordinates hold no variable — a lone x or y has no single value
|
||||
to place a dot at, so only constant expressions (numbers, pi, sqrt(2)…)
|
||||
make a coordinate */
|
||||
function isConstant(node: Node): boolean {
|
||||
switch (node.kind) {
|
||||
case 'var':
|
||||
return false
|
||||
case 'neg':
|
||||
return isConstant(node.arg)
|
||||
case 'bin':
|
||||
return isConstant(node.a) && isConstant(node.b)
|
||||
case 'call':
|
||||
return node.args.every(isConstant)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/* the inside of a "(…, …)" split into its two coordinate token runs, or null
|
||||
unless there is exactly one comma at this depth */
|
||||
function splitPair(inner: Token[]): [Token[], Token[]] | null {
|
||||
let depth = 0
|
||||
let comma = -1
|
||||
for (let i = 0; i < inner.length; i++) {
|
||||
const t = inner[i]!
|
||||
if (t.t === '(') depth++
|
||||
else if (t.t === ')') depth--
|
||||
else if (t.t === ',' && depth === 0) {
|
||||
if (comma !== -1) return null // more than one separator: not a plane point
|
||||
comma = i
|
||||
}
|
||||
}
|
||||
if (comma === -1) return null
|
||||
return [inner.slice(0, comma), inner.slice(comma + 1)]
|
||||
}
|
||||
|
||||
/* tokens shaped "(x, y)": one pair of parens wrapping the whole run, split by
|
||||
a single top-level comma — the point "(2, 3)". A "(x+1)" has no comma and a
|
||||
"(1)+(2)" closes its parens early, so neither is read as a point. */
|
||||
function pointPair(toks: Token[]): [Token[], Token[]] | null {
|
||||
if (toks.length < 4 || toks[0]!.t !== '(' || toks[toks.length - 1]!.t !== ')') return null
|
||||
let depth = 0
|
||||
for (let i = 0; i < toks.length; i++) {
|
||||
if (toks[i]!.t === '(') depth++
|
||||
else if (toks[i]!.t === ')') depth--
|
||||
if (depth === 0 && i < toks.length - 1) return null // parens closed before the end
|
||||
}
|
||||
return splitPair(toks.slice(1, toks.length - 1))
|
||||
}
|
||||
|
||||
/* one run of tokens, the whole of it — a leftover is a shape the grammar
|
||||
does not know */
|
||||
function parseAll(toks: Token[]): Node {
|
||||
const p: Parser = { toks, pos: 0 }
|
||||
const ast = parseExpr(p)
|
||||
if (p.pos !== toks.length) throw new PlotParseError('syntax')
|
||||
return ast
|
||||
}
|
||||
|
||||
/* read one expression, equation, or point; a refusal says whether a name was
|
||||
not known or the shape was wrong — an empty text is the caller's to treat as
|
||||
"nothing asked for", not as an error. A "(x, y)" — or "P = (x, y)" with a
|
||||
name out front — is a point at fixed coordinates. With an "=" otherwise the
|
||||
text is the relation left − right = 0 (a lone "y =" ahead of an expression
|
||||
in x is still the fast function); without it, an expression in x is y = f(x)
|
||||
and one carrying y is the relation that expression equals zero. */
|
||||
export function compilePlotExpression(src: string): CompiledPlot {
|
||||
if (src.length > 400) return { ok: false, reason: 'syntax' }
|
||||
try {
|
||||
const toks = tokenize(stripAssignment(src))
|
||||
if (toks.length === 0) return { ok: false, reason: 'empty' }
|
||||
const p: Parser = { toks, pos: 0 }
|
||||
const ast = parseExpr(p)
|
||||
if (p.pos !== toks.length) throw new PlotParseError('syntax')
|
||||
return { ok: true, fn: (x) => evaluate(ast, x) }
|
||||
const at = toks.findIndex((tk) => tk.t === '=')
|
||||
|
||||
// A point: "(x, y)" on its own, or "P = (x, y)" with a lone name ahead of
|
||||
// the "=". Coordinates must be constant — a variable has no single value
|
||||
// to place a dot at — and finite once read.
|
||||
const named = at === 1 && toks[0]!.t === 'id'
|
||||
const pair = pointPair(named ? toks.slice(2) : toks)
|
||||
if (pair) {
|
||||
const cx = parseAll(pair[0])
|
||||
const cy = parseAll(pair[1])
|
||||
if (!isConstant(cx) || !isConstant(cy)) return { ok: false, reason: 'syntax' }
|
||||
const x = evaluate(cx, 0, 0)
|
||||
const y = evaluate(cy, 0, 0)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) return { ok: false, reason: 'syntax' }
|
||||
const label = named ? src.slice(0, src.indexOf('=')).trim() : null
|
||||
return { ok: true, kind: 'point', x, y, label: label || null }
|
||||
}
|
||||
|
||||
if (at === -1) {
|
||||
const ast = parseAll(toks)
|
||||
return mentionsY(ast)
|
||||
? { ok: true, kind: 'relation', fn: (x, y) => evaluate(ast, x, y) }
|
||||
: { ok: true, kind: 'function', fn: (x) => evaluate(ast, x, 0) }
|
||||
}
|
||||
const left = toks.slice(0, at)
|
||||
const right = toks.slice(at + 1)
|
||||
if (left.length === 0 || right.length === 0 || right.some((tk) => tk.t === '='))
|
||||
throw new PlotParseError('syntax')
|
||||
const rhs = parseAll(right)
|
||||
// "y = <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) {
|
||||
if (err instanceof PlotParseError) return { ok: false, reason: err.reason }
|
||||
return { ok: false, reason: 'syntax' }
|
||||
|
||||
@@ -64,3 +64,35 @@ export function versionHref(identity: string, language?: string): string {
|
||||
export function isReadingSurface(path: string): boolean {
|
||||
return isReaderPath(path) || WIKI_SUB_PATH.test(path) || path === '/new'
|
||||
}
|
||||
|
||||
/* ---------- the wiki's own pages ----------
|
||||
|
||||
The link map and the graphing calculator are pages the shelf holds no
|
||||
copies of — but the book can read them too. Each has one plain address of
|
||||
its own, and useArticleTabs can lay it on the row beside the open articles:
|
||||
the page then draws the live surface instead of an article, for as long as
|
||||
its page stays open. */
|
||||
|
||||
export type ToolPage = 'graph' | 'calculator'
|
||||
|
||||
const TOOL_PAGE_PATHS: Record<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",
|
||||
"graph": "Graf",
|
||||
"calculator": "Calculadora",
|
||||
"calculatorHint": "Calculadora — representa funcions de x",
|
||||
"calculatorHint": "Calculadora — representa funcions i equacions",
|
||||
"admin": "Admin",
|
||||
"adminHint": "Administració — els comptes d'aquesta wiki",
|
||||
"requests": "Sol·licituds",
|
||||
@@ -162,18 +162,17 @@
|
||||
},
|
||||
"calc": {
|
||||
"metaTitle": "Calculadora gràfica",
|
||||
"metaDescription": "Representa funcions de x — el pla es mou arrossegant i s'apropa amb la roda. sin, cos, ln, sqrt i companyia.",
|
||||
"metaDescription": "Representa funcions de x o equacions en x i y — el pla es mou arrossegant i s'apropa amb la roda. sin, cos, ln, sqrt i companyia.",
|
||||
"panelHeading": "Funcions",
|
||||
"functionsPanel": "Funcions a representar",
|
||||
"expressionAria": "Funció de x",
|
||||
"placeholder": "p. ex. sin(x) + x / 4",
|
||||
"expressionAria": "Funció de x, o una equació en x i y",
|
||||
"cycleColor": "Canvia el color",
|
||||
"show": "Mostra al pla",
|
||||
"hide": "Amaga del pla",
|
||||
"remove": "Treu la funció",
|
||||
"add": "Afegeix funció",
|
||||
"errorUnknown": "No conec aquest nom — la calculadora entén x, pi, e i les funcions habituals.",
|
||||
"errorSyntax": "Això no es llegeix com una funció de x.",
|
||||
"errorUnknown": "No conec aquest nom — la calculadora entén x, y, pi, e i les funcions habituals.",
|
||||
"errorSyntax": "Això no es llegeix com una funció de x ni com una equació en x i y.",
|
||||
"resetView": "Reinicia la vista",
|
||||
"zoomIn": "Apropa",
|
||||
"zoomOut": "Allunya",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"articles": "Artikel",
|
||||
"graph": "Graph",
|
||||
"calculator": "Rechner",
|
||||
"calculatorHint": "Rechner — Funktionen von x plotten",
|
||||
"calculatorHint": "Rechner — Funktionen und Gleichungen plotten",
|
||||
"admin": "Admin",
|
||||
"adminHint": "Verwaltung — die Konten in dieser Wiki",
|
||||
"requests": "Anfragen",
|
||||
@@ -162,18 +162,17 @@
|
||||
},
|
||||
"calc": {
|
||||
"metaTitle": "Grafikrechner",
|
||||
"metaDescription": "Funktionen von x zeichnen — die Ebene zieht man mit der Maus, die Rollen zoomt. sin, cos, ln, sqrt und Co.",
|
||||
"metaDescription": "Funktionen von x oder ganze Gleichungen in x und y zeichnen — die Ebene zieht man mit der Maus, die Rollen zoomt. sin, cos, ln, sqrt und Co.",
|
||||
"panelHeading": "Funktionen",
|
||||
"functionsPanel": "Zu zeichnende Funktionen",
|
||||
"expressionAria": "Funktion von x",
|
||||
"placeholder": "z. B. sin(x) + x / 4",
|
||||
"expressionAria": "Funktion von x oder Gleichung in x und y",
|
||||
"cycleColor": "Farbe wechseln",
|
||||
"show": "Auf der Ebene anzeigen",
|
||||
"hide": "Auf der Ebene ausblenden",
|
||||
"remove": "Funktion entfernen",
|
||||
"add": "Funktion hinzufügen",
|
||||
"errorUnknown": "Den Namen kenne ich nicht — der Rechner spricht x, pi, e und die üblichen Funktionen.",
|
||||
"errorSyntax": "Das liest sich nicht als Funktion von x.",
|
||||
"errorUnknown": "Den Namen kenne ich nicht — der Rechner spricht x, y, pi, e und die üblichen Funktionen.",
|
||||
"errorSyntax": "Das liest sich nicht als Funktion von x oder als Gleichung in x und y.",
|
||||
"resetView": "Ansicht zurücksetzen",
|
||||
"zoomIn": "Vergrößern",
|
||||
"zoomOut": "Verkleinern",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"articles": "Articles",
|
||||
"graph": "Graph",
|
||||
"calculator": "Calculator",
|
||||
"calculatorHint": "Calculator — plot functions of x",
|
||||
"calculatorHint": "Calculator — plot functions and equations",
|
||||
"admin": "Admin",
|
||||
"adminHint": "Admin — the accounts on this wiki",
|
||||
"requests": "Requests",
|
||||
@@ -162,18 +162,17 @@
|
||||
},
|
||||
"calc": {
|
||||
"metaTitle": "Graphing calculator",
|
||||
"metaDescription": "Plot functions of x — the plane moves with a drag and zooms with the wheel. sin, cos, ln, sqrt and friends.",
|
||||
"metaDescription": "Plot functions of x or whole equations in x and y — the plane moves with a drag and zooms with the wheel. sin, cos, ln, sqrt and friends.",
|
||||
"panelHeading": "Functions",
|
||||
"functionsPanel": "Functions to plot",
|
||||
"expressionAria": "Function of x",
|
||||
"placeholder": "e.g. sin(x) + x / 4",
|
||||
"expressionAria": "Function of x, or an equation in x and y",
|
||||
"cycleColor": "Change colour",
|
||||
"show": "Show on the plane",
|
||||
"hide": "Hide from the plane",
|
||||
"remove": "Remove function",
|
||||
"add": "Add function",
|
||||
"errorUnknown": "I don't know that name — the calculator speaks x, pi, e and the usual functions.",
|
||||
"errorSyntax": "This does not read as a function of x.",
|
||||
"errorUnknown": "I don't know that name — the calculator speaks x, y, pi, e and the usual functions.",
|
||||
"errorSyntax": "This does not read as a function of x or an equation in x and y.",
|
||||
"resetView": "Reset the view",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"articles": "Artículos",
|
||||
"graph": "Grafo",
|
||||
"calculator": "Calculadora",
|
||||
"calculatorHint": "Calculadora — representa funciones de x",
|
||||
"calculatorHint": "Calculadora — representa funciones y ecuaciones",
|
||||
"admin": "Admin",
|
||||
"adminHint": "Administración — las cuentas de esta wiki",
|
||||
"requests": "Solicitudes",
|
||||
@@ -162,18 +162,17 @@
|
||||
},
|
||||
"calc": {
|
||||
"metaTitle": "Calculadora gráfica",
|
||||
"metaDescription": "Representa funciones de x — el plano se mueve arrastrando y se acerca con la rueda. sin, cos, ln, sqrt y compañía.",
|
||||
"metaDescription": "Representa funciones de x o ecuaciones en x e y — el plano se mueve arrastrando y se acerca con la rueda. sin, cos, ln, sqrt y compañía.",
|
||||
"panelHeading": "Funciones",
|
||||
"functionsPanel": "Funciones a representar",
|
||||
"expressionAria": "Función de x",
|
||||
"placeholder": "p. ej. sin(x) + x / 4",
|
||||
"expressionAria": "Función de x, o una ecuación en x e y",
|
||||
"cycleColor": "Cambiar color",
|
||||
"show": "Mostrar en el plano",
|
||||
"hide": "Ocultar del plano",
|
||||
"remove": "Quitar función",
|
||||
"add": "Añadir función",
|
||||
"errorUnknown": "No conozco ese nombre — la calculadora habla x, pi, e y las funciones de siempre.",
|
||||
"errorSyntax": "Esto no se lee como una función de x.",
|
||||
"errorUnknown": "No conozco ese nombre — la calculadora habla x, y, pi, e y las funciones de siempre.",
|
||||
"errorSyntax": "Esto no se lee como una función de x ni como una ecuación en x e y.",
|
||||
"resetView": "Reiniciar la vista",
|
||||
"zoomIn": "Acercar",
|
||||
"zoomOut": "Alejar",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"articles": "Articles",
|
||||
"graph": "Graphe",
|
||||
"calculator": "Calculatrice",
|
||||
"calculatorHint": "Calculatrice — trace des fonctions de x",
|
||||
"calculatorHint": "Calculatrice — trace des fonctions et des équations",
|
||||
"admin": "Admin",
|
||||
"adminHint": "Administration — les comptes de ce wiki",
|
||||
"requests": "Demandes",
|
||||
@@ -162,18 +162,17 @@
|
||||
},
|
||||
"calc": {
|
||||
"metaTitle": "Calculatrice graphique",
|
||||
"metaDescription": "Tracez des fonctions de x — le plan se déplace à la souris et se zoome à la molette. sin, cos, ln, sqrt et compagnie.",
|
||||
"metaDescription": "Tracez des fonctions de x ou des équations en x et y — le plan se déplace à la souris et se zoome à la molette. sin, cos, ln, sqrt et compagnie.",
|
||||
"panelHeading": "Fonctions",
|
||||
"functionsPanel": "Fonctions à tracer",
|
||||
"expressionAria": "Fonction de x",
|
||||
"placeholder": "p. ex. sin(x) + x / 4",
|
||||
"expressionAria": "Fonction de x, ou une équation en x et y",
|
||||
"cycleColor": "Changer de couleur",
|
||||
"show": "Afficher sur le plan",
|
||||
"hide": "Masquer du plan",
|
||||
"remove": "Supprimer la fonction",
|
||||
"add": "Ajouter une fonction",
|
||||
"errorUnknown": "Je ne connais pas ce nom — la calculatrice parle x, pi, e et les fonctions habituelles.",
|
||||
"errorSyntax": "Cela ne se lit pas comme une fonction de x.",
|
||||
"errorUnknown": "Je ne connais pas ce nom — la calculatrice parle x, y, pi, e et les fonctions habituelles.",
|
||||
"errorSyntax": "Cela ne se lit pas comme une fonction de x ni comme une équation en x et y.",
|
||||
"resetView": "Réinitialiser la vue",
|
||||
"zoomIn": "Zoomer",
|
||||
"zoomOut": "Dézoomer",
|
||||
|
||||
Reference in New Issue
Block a user