Graph calculator improvmentes
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 24s

This commit is contained in:
2026-10-02 14:53:00 +02:00
parent e36fef7290
commit 68b74eb60b
21 changed files with 1092 additions and 392 deletions

View File

@@ -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()`

View File

@@ -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; }

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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,9 +244,19 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
</nav>
<div ref="trackEl" class="book-track" @scroll.passive="watchDock">
<!-- 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-for="(tab, i) in tabs"
:key="tab.identity"
v-else
:identity="tab.identity"
:slug="tab.slug"
:index="i"
@@ -240,6 +265,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
@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>

View 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>

View 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>

View File

@@ -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() {

View File

@@ -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>

View 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>

View File

@@ -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 }
)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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' }

View File

@@ -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
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",