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

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

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>