Ai slop
Some checks failed
Build and Deploy Nuxt / build (push) Failing after 41s

This commit is contained in:
2026-10-03 14:13:08 +02:00
parent 68b74eb60b
commit 6df7950161
45 changed files with 4594 additions and 125 deletions

View File

@@ -127,6 +127,21 @@ const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperC
</svg>
<span class="hide-collapsed">{{ t('sidebar.calculator') }}</span>
</NuxtLink>
<NuxtLink to="/calculator3d" class="nav-link" :title="t('sidebar.calculator3dHint')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.calculator3d') }}</span>
</NuxtLink>
<NuxtLink to="/lean" class="nav-link" :title="t('sidebar.leanHint')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14.5 4 6.5 20" />
<path d="m10.5 12 7 8" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.lean') }}</span>
</NuxtLink>
<NuxtLink
v-if="isAdmin"
to="/admin"

View File

@@ -0,0 +1,354 @@
<script setup lang="ts">
import { expressionToLatex } from '~/utils/plot-expression'
import type { SurfaceFunction } from '~/utils/plot-expression'
import type { SurfaceSeries } from './Scene3D.vue'
/* The 3D calculator: the space owns the surface, the inputs dock to its
left edge — foldable, so the space can have every pixel. Each field
reads as z = f(x, y), and the surface standing over that ground is
drawn (Scene3D turns it under the hand). A name that is none of x, y,
the constants or the known functions — "k" — is a slider, as on the
plane: a lone "k" or a "k = 2" claims one, and every surface speaking
the name answers to its value. Everything is compiled as it is typed;
one that reads wrong goes red and simply stops being drawn. The
rows, the sliders' values and the dock's fold are the reader's own —
kept between visits and between instances, the way the plane calculator
keeps its own; the two calculators never share rows, only the shapes of
their docks.
The /calculator3d 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 surface 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-calculator3d-plots'
const VARS_KEY = 'mathew-calculator3d-vars'
const DOCK_KEY = 'mathew-calc3d-dock'
const MAX_ROWS = 12
let nextId = 1
const rows = ref<PlotRow[]>([
{ id: nextId++, expr: 'sin(sqrt(x^2 + y^2))', color: PALETTE[0]!, visible: true },
{ id: nextId++, expr: '(x^2 - y^2) / 4', color: PALETTE[1]!, visible: true },
])
/* one compile pass per render: the rows read alongside what they became —
a surface to draw, a slider to wear, and a reason for the ones that read
wrong (an empty field is nothing asked for, not a mistake). The "z ="
gutter stands only while the field reads as a surface — a slider or
unfinished text wears nothing, and the input takes back the room. One
that reads right carries its LaTeX too: the field echoes it typeset. */
interface PlotEntry {
row: PlotRow
kind: 'surface' | 'variable' | null
fn: SurfaceFunction | null
// the free variables this row speaks, and the slider its own text claims
vars: string[]
declared: { name: string; value: number | null } | null
error: 'unknown' | 'syntax' | null
tex: string | null
}
/* the sliders' values, keyed by variable. The compile pass reads them —
which is what ties every drawn surface to every slider: move one, every
expression speaking it recompiles and the space follows. */
const varValues = reactive<Record<string, number>>({})
const entries = computed<PlotEntry[]>(() => {
const scope = { ...varValues }
return rows.value.map((row) => {
const src = row.expr.trim()
if (!src) return { row, kind: null, fn: null, vars: [], declared: null, error: null, tex: null }
const result = compileSurfaceExpression(src, scope)
if (result.ok)
return {
row,
kind: result.kind,
fn: result.kind === 'surface' ? result.fn : null,
vars: result.vars,
declared: result.kind === 'variable' ? { name: result.name, value: result.value } : null,
error: null,
tex: expressionToLatex(src),
}
return { row, kind: null, fn: null, vars: [], declared: null, error: result.reason === 'empty' ? null : result.reason, tex: null }
})
})
/* the sliders: one per variable, hung under the row that claims it — the
declaration row when one asks, else the first row speaking it — values
kept between visits, as the rows are */
const { slidersByRow, setVar } = useVarSliders(
varValues,
() => entries.value.map((e) => ({ rowId: e.row.id, vars: e.vars, declared: e.declared })),
VARS_KEY,
)
/* the id of a row's field, so a declaration's inline slider can point back at
it (a label waking its input) to edit "k = 3" again — namespaced per
calculator so an instance of each open in the book never shares an id */
const INPUT_SCOPE = 'space'
const fieldId = (rowId: number) => `calc-input-${INPUT_SCOPE}-${rowId}`
/* a row whose own text declared a variable ("k", "k = 3") and whose slider
was anchored here wears that slider in place of its field once the field
rests — the field folds away (see .calc-input-box.var-decl) and the slider
takes the row, so a made variable reads as just a slider, not a "k = 3"
box above a slider */
function wearsInlineSlider(e: PlotEntry): boolean {
return e.kind === 'variable' && (slidersByRow.value.get(e.row.id)?.length ?? 0) > 0
}
const series = computed<SurfaceSeries[]>(() => {
const out: SurfaceSeries[] = []
for (const e of entries.value) {
if (!e.fn || !e.row.visible) continue
out.push({ id: e.row.id, color: e.row.color, visible: true, fn: e.fn })
}
return out
})
/* a new surface 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]!
}
/* Enter finishes a line the way clicking away does — it lets go of the field,
so the text settles (a surface folds to its typeset face, a variable to its
slider). On the last line the list invites one more, so the fresh field
takes the caret and the line you confirmed folds away; on any other line,
or once the list is full, Enter simply releases the field. */
async function onEnter(id: number, ev: KeyboardEvent) {
const el = ev.target as HTMLElement | null
if (id === rows.value[rows.value.length - 1]?.id) {
const before = rows.value.length
addRow()
if (rows.value.length !== before) {
const newId = rows.value[rows.value.length - 1]!.id
await nextTick()
document.getElementById(fieldId(newId))?.focus()
return
}
}
el?.blur()
}
/* whether the dock stands open is the reader's own — the fold slides the
rows away and leaves the space 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 surfaces stay */
}
})
const { t } = useI18n()
</script>
<template>
<div class="calc-stage">
<Scene3D :series="series" />
<Transition name="calc-dock">
<aside v-if="dockOpen" class="calc-dock" :aria-label="t('calc3d.functionsPanel')">
<div class="calc-dock-head">
<h2 class="calc-dock-title">{{ t('calc3d.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">
<!-- a slider declaration draws nothing, so it wears no hue and
has nothing to show or hide in the space -->
<button
v-if="e.kind !== 'variable'"
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="{ bare: e.kind !== 'surface', typeset: !!e.tex && e.kind !== 'variable', 'var-decl': wearsInlineSlider(e) }">
<!-- the z= gutter stands only while the field reads as a
surface; a slider or unfinished text wears nothing -->
<span v-if="e.kind === 'surface'" class="calc-y" aria-hidden="true">z=</span>
<input
:id="fieldId(e.row.id)"
v-model="e.row.expr"
class="calc-input"
:class="{ 'input-error': !!e.error }"
type="text"
:aria-label="t('calc3d.expressionAria')"
autocomplete="off"
spellcheck="false"
autocapitalize="off"
@keydown.enter="onEnter(e.row.id, $event)"
/>
<KatexSpan v-if="e.tex && e.kind !== 'variable'" class="calc-typeset" :tex="e.tex" />
</label>
<!-- a made variable shows the slider in place of its field; the
field stays mounted behind it (folded to nothing), and the
slider's name points back at it to wake the text -->
<VarSliders
v-if="wearsInlineSlider(e)"
class="calc-row-vars"
inline
:input-for="fieldId(e.row.id)"
:sliders="slidersByRow.get(e.row.id) ?? []"
@set="setVar"
/>
<button
v-if="e.kind !== 'variable'"
type="button"
class="panel-icon-btn"
:title="e.row.visible ? t('calc3d.hide') : t('calc3d.show')"
:aria-label="e.row.visible ? t('calc3d.hide') : t('calc3d.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>
<!-- the sliders this row wears, just beneath it (a made variable
wears its slider in the row itself, above, not here) -->
<VarSliders
v-if="!wearsInlineSlider(e)"
:sliders="slidersByRow.get(e.row.id) ?? []"
@set="setVar"
/>
<span v-if="e.error" class="calc-error">{{
t(e.error === 'unknown' ? 'calc.errorUnknown' : 'calc3d.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('calc3d.panelHeading') }}
</button>
</div>
</template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { expressionToLatex } from '~/utils/plot-expression'
import type { PlaneFunction, PlotFunction } from '~/utils/plot-expression'
import type { PlotSeries } from './PlotCanvas.vue'
@@ -6,10 +7,14 @@ import type { PlotSeries } from './PlotCanvas.vue'
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.
so any equation in two variables plots. A name that is none of x, y, the
constants or the known functions — "k" — is a slider: a lone "k" or a
"k = 2" claims one, and every expression speaking the name answers to its
value, so one drag traces a whole family of curves at once. Everything is
compiled as it is typed; one that reads wrong goes red and simply stops
being drawn. The rows, the sliders' values 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. */
@@ -25,6 +30,7 @@ const PALETTE = [
'#e5484d', '#0091ff', '#46a758', '#f76808', '#8e4ec6', '#12a594', '#e93d82', '#3e63dd',
]
const STORAGE_KEY = 'mathew-calculator-plots'
const VARS_KEY = 'mathew-calculator-vars'
const DOCK_KEY = 'mathew-calc-dock'
const MAX_ROWS = 12
@@ -37,34 +43,87 @@ const seedRows = (): PlotRow[] => [
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. */
a function or relation to plot, a point to place, a slider to wear, and a
reason for the ones that read wrong (an empty field is nothing asked for,
not a mistake). The "y =" gutter stands only while the field reads as a
function — a relation, point, slider or unfinished text wears nothing,
and the input takes back the room. One that reads
right carries its LaTeX too: the field echoes it typeset. */
interface PlotEntry {
row: PlotRow
kind: 'function' | 'relation' | null
kind: 'function' | 'relation' | 'point' | 'variable' | null
fn: PlotFunction | PlaneFunction | null
// a point carries no fn — its dot lives at coordinates instead
point: { x: number; y: number; label: string | null } | null
// the free variables this row speaks, and the slider its own text claims
vars: string[]
declared: { name: string; value: number | null } | null
error: 'unknown' | 'syntax' | null
tex: string | null
}
const entries = computed<PlotEntry[]>(() =>
rows.value.map((row) => {
/* the sliders' values, keyed by variable. The compile pass reads them —
which is what ties every drawn curve to every slider: move one, every
expression speaking it recompiles and the plane follows. */
const varValues = reactive<Record<string, number>>({})
const entries = computed<PlotEntry[]>(() => {
const scope = { ...varValues }
return 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 }
}),
if (!src) return { row, kind: null, fn: null, point: null, vars: [], declared: null, error: null, tex: null }
const result = compilePlotExpression(src, scope)
if (result.ok)
return {
row,
kind: result.kind,
fn: 'fn' in result ? result.fn : null,
point: result.kind === 'point' ? { x: result.x, y: result.y, label: result.label } : null,
vars: result.vars,
declared: result.kind === 'variable' ? { name: result.name, value: result.value } : null,
error: null,
tex: expressionToLatex(src),
}
return { row, kind: null, fn: null, point: null, vars: [], declared: null, error: result.reason === 'empty' ? null : result.reason, tex: null }
})
})
/* the sliders: one per variable, hung under the row that claims it — the
declaration row when one asks, else the first row speaking it — values
kept between visits, as the rows are */
const { slidersByRow, setVar } = useVarSliders(
varValues,
() => entries.value.map((e) => ({ rowId: e.row.id, vars: e.vars, declared: e.declared })),
VARS_KEY,
)
/* the id of a row's field, so a declaration's inline slider can point back at
it (a label waking its input) to edit "k = 3" again — namespaced per
calculator so an instance of each open in the book never shares an id */
const INPUT_SCOPE = 'plane'
const fieldId = (rowId: number) => `calc-input-${INPUT_SCOPE}-${rowId}`
/* a row whose own text declared a variable ("k", "k = 3") and whose slider
was anchored here wears that slider in place of its field once the field
rests — the field folds away (see .calc-input-box.var-decl) and the slider
takes the row, so a made variable reads as just a slider, not a "k = 3"
box above a slider */
function wearsInlineSlider(e: PlotEntry): boolean {
return e.kind === 'variable' && (slidersByRow.value.get(e.row.id)?.length ?? 0) > 0
}
const series = computed<PlotSeries[]>(() => {
const out: PlotSeries[] = []
for (const e of entries.value) {
if (!e.kind || !e.fn || !e.row.visible) continue
if (!e.kind || e.kind === 'variable' || !e.row.visible) continue
// the union is keyed to the compile result's kind, which picks fn's shape
if (e.kind === 'function')
if (e.kind === 'point' && e.point) {
out.push({ id: e.row.id, color: e.row.color, visible: true, kind: 'point', ...e.point })
} else if (e.kind === 'function' && e.fn) {
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 })
} else if (e.kind === 'relation' && e.fn) {
out.push({ id: e.row.id, color: e.row.color, visible: true, kind: 'relation', fn: e.fn as PlaneFunction })
}
}
return out
})
@@ -96,10 +155,24 @@ function cycleColor(id: number) {
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()
/* Enter finishes a line the way clicking away does — it lets go of the field,
so the text settles (a function folds to its typeset face, a variable to its
slider). On the last line the list invites one more, so the fresh field
takes the caret and the line you confirmed folds away; on any other line,
or once the list is full, Enter simply releases the field. */
async function onEnter(id: number, ev: KeyboardEvent) {
const el = ev.target as HTMLElement | null
if (id === rows.value[rows.value.length - 1]?.id) {
const before = rows.value.length
addRow()
if (rows.value.length !== before) {
const newId = rows.value[rows.value.length - 1]!.id
await nextTick()
document.getElementById(fieldId(newId))?.focus()
return
}
}
el?.blur()
}
/* whether the dock stands open is the reader's own — the fold slides the
@@ -183,7 +256,10 @@ const { t } = useI18n()
<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">
<!-- a slider declaration draws nothing, so it wears no hue and
has nothing to show or hide on the plane -->
<button
v-if="e.kind !== 'variable'"
type="button"
class="calc-swatch"
:style="{ background: e.row.color }"
@@ -191,9 +267,16 @@ const { t } = useI18n()
: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>
<label
class="calc-input-box"
:class="{ bare: e.kind !== 'function', typeset: !!e.tex && e.kind !== 'variable', 'var-decl': wearsInlineSlider(e) }"
>
<!-- the y= gutter stands only while the field reads as a
function; a relation, point, slider or unfinished
text carries its own sense and wears nothing -->
<span v-if="e.kind === 'function'" class="calc-y" aria-hidden="true">y=</span>
<input
:id="fieldId(e.row.id)"
v-model="e.row.expr"
class="calc-input"
:class="{ 'input-error': !!e.error }"
@@ -202,10 +285,23 @@ const { t } = useI18n()
autocomplete="off"
spellcheck="false"
autocapitalize="off"
@keydown.enter="onEnter(e.row.id)"
@keydown.enter="onEnter(e.row.id, $event)"
/>
<KatexSpan v-if="e.tex && e.kind !== 'variable'" class="calc-typeset" :tex="e.tex" />
</label>
<!-- a made variable shows the slider in place of its field; the
field stays mounted behind it (folded to nothing), and the
slider's name points back at it to wake the text -->
<VarSliders
v-if="wearsInlineSlider(e)"
class="calc-row-vars"
inline
:input-for="fieldId(e.row.id)"
:sliders="slidersByRow.get(e.row.id) ?? []"
@set="setVar"
/>
<button
v-if="e.kind !== 'variable'"
type="button"
class="panel-icon-btn"
:title="e.row.visible ? t('calc.hide') : t('calc.show')"
@@ -236,6 +332,13 @@ const { t } = useI18n()
</svg>
</button>
</div>
<!-- the sliders this row wears, just beneath it (a made variable
wears its slider in the row itself, above, not here) -->
<VarSliders
v-if="!wearsInlineSlider(e)"
:sliders="slidersByRow.get(e.row.id) ?? []"
@set="setVar"
/>
<span v-if="e.error" class="calc-error">{{
t(e.error === 'unknown' ? 'calc.errorUnknown' : 'calc.errorSyntax')
}}</span>

View File

@@ -0,0 +1,424 @@
<script setup lang="ts">
/* The Lean 4 editor, native to the wiki.
*
* Where the iframe framed another server's whole app, this page *is* the
* editor: Monaco wears the wiki's own theme, and the wiki talks Lean
* directly — one websocket to the lean4web server (see lean/), carrying
* the language protocol: the buffer goes across as it is typed, errors and
* `#check` output come back as diagnostics, and the goal at the cursor
* answers `$/lean/plainGoal`. The infoview is therefore the wiki's own —
* Goals and Messages panes below the wiki's own hand — and it follows the
* wiki's theme live, which a cross-origin frame never could.
*
* Lean's unicode input survives too (`\le` → `≤`), and the classic lean4web
* client stays one click away for its own richer toys (collaboration,
* widgets). Monaco is loaded dynamically: this component only ever runs in
* the browser, and the editor is far too heavy to ride along in the
* server bundle. */
import type * as Monaco from 'monaco-editor'
import { leanAbbreviations } from '~/utils/lean/abbreviations'
const { t } = useI18n()
const { th } = useAppLocale()
const session = useLeanSession()
const leanUrl = String(useRuntimeConfig().public.leanUrl || '')
const editorEl = ref<HTMLElement>()
let monaco: typeof Monaco | null = null
let editor: Monaco.editor.IStandaloneCodeEditor | null = null
let disposables: Array<{ dispose: () => void }> = []
let themeObserver: MutationObserver | null = null
/* whether the infoview stands open is the reader's own — the fold slides it
* away and leaves the editor the whole width; the choice is kept between
* visits, the way the calculators keep their docks */
const PANEL_KEY = 'mathew:lean-panel-open'
const panelOpen = ref(true)
function togglePanel() {
panelOpen.value = !panelOpen.value
try {
localStorage.setItem(PANEL_KEY, panelOpen.value ? '1' : '0')
}
catch {
/* private mode etc. — the fold just won't persist */
}
}
const phaseText = computed(() => {
switch (session.phase.value) {
case 'connecting': return t('lean.connecting')
case 'initializing': return t('lean.initializing')
case 'ready': return session.busy.value ? t('lean.elaborating') : t('lean.ready', { version: session.version.value ?? '' })
default: return ''
}
})
onMounted(async () => {
if (!editorEl.value) return
try {
panelOpen.value = localStorage.getItem(PANEL_KEY) !== '0'
}
catch {
/* storage unreadable — the panel stays open */
}
const monacoMod = await import('monaco-editor')
const { registerLeanLanguage, currentLeanTheme, applyLeanTheme, LEAN_LANGUAGE } = await import('~/utils/lean/lean-language')
monaco = monacoMod
registerLeanLanguage()
/* Monaco runs from one web worker (text processing). The `?worker` import
* lives in a wrapper (`utils/lean/monaco-worker`) that this pulls in only
* here, in the browser. */
if (!window.MonacoEnvironment) {
const { EditorWorker } = await import('~/utils/lean/monaco-worker')
window.MonacoEnvironment = { getWorker: () => new EditorWorker() }
}
if (!session.text.value) session.text.value = t('lean.seedDoc')
editor = monacoMod.editor.create(editorEl.value, {
value: session.text.value,
language: LEAN_LANGUAGE,
theme: currentLeanTheme(),
fontFamily: "ui-monospace, 'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, monospace",
fontSize: 13.5,
lineHeight: 23,
tabSize: 2,
minimap: { enabled: false },
lineNumbersMinChars: 2,
scrollBeyondLastLine: false,
smoothScrolling: true,
automaticLayout: true,
padding: { top: 16 },
fixedOverflowWidgets: true,
quickSuggestions: false,
// Lean files are full of legitimate unicode — no confusable warnings
unicodeHighlight: { ambiguousCharacters: false, invisibleCharacters: false },
ariaLabel: t('lean.editorAria'),
})
/* edits → the server (debounced inside the session); cursor → goals */
disposables.push(
editor.onDidChangeModelContent(() => session.change(editor!.getValue())),
editor.onDidChangeCursorPosition(({ position }) => {
session.cursorAt(position.lineNumber - 1, position.column - 1)
}),
)
/* diagnostics → editor marks, in the wiki's reds and ambers (the theme
* owns the colours); the session replays the current set on (re)attach.
* The session hands back a plain unsubscribe function, not an IDisposable
* like Monaco's handles — wrap it, or unmounting would throw over the
* whole page */
const offDiagnostics = session.onDiagnostics((diags) => {
const model = editor?.getModel()
if (!model || !monaco) return
monaco.editor.setModelMarkers(model, 'lean', diags.map(d => ({
severity: d.severity === 1
? monaco!.MarkerSeverity.Error
: d.severity === 2
? monaco!.MarkerSeverity.Warning
: d.severity === 4
? monaco!.MarkerSeverity.Hint
: monaco!.MarkerSeverity.Info,
message: d.message,
startLineNumber: d.range.start.line + 1,
startColumn: d.range.start.character + 1,
endLineNumber: d.range.end.line + 1,
endColumn: d.range.end.character + 1,
})))
})
disposables.push({ dispose: () => offDiagnostics() })
registerProviders(monacoMod, editor)
/* the wiki's theme *and* accent switches reach the editor live — same page
* now; applyLeanTheme re-reads the --accent tokens, so the caret, selection
* and keywords follow whichever accent the account wears */
themeObserver = new MutationObserver(() => {
applyLeanTheme()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'data-accent'],
})
await session.start()
const position = editor.getPosition()
if (position) session.cursorAt(position.lineNumber - 1, position.column - 1)
})
onBeforeUnmount(() => {
// the session outlives the component on purpose (see useLeanSession)
themeObserver?.disconnect()
for (const d of disposables) d.dispose()
disposables = []
editor?.dispose()
editor = null
})
/* ---------- LSP-backed intelligence ---------- */
function registerProviders(monacoMod: typeof Monaco, host: Monaco.editor.IStandaloneCodeEditor) {
const toRange = (r: { start: { line: number, character: number }, end: { line: number, character: number } }) => ({
startLineNumber: r.start.line + 1,
startColumn: r.start.character + 1,
endLineNumber: r.end.line + 1,
endColumn: r.end.character + 1,
})
/* the cursor, in server (0-based) coordinates. Lean's LSP and Monaco both
* count positions in UTF-16 code units, so a column simply shifts by one */
const positionOf = (_model: Monaco.editor.ITextModel, position: Monaco.Position) => ({
line: position.lineNumber - 1,
character: position.column - 1,
})
/* Lean tactic/term completions */
disposables.push(monacoMod.languages.registerCompletionItemProvider('lean4', {
triggerCharacters: [],
provideCompletionItems: async (model, position) => {
const word = model.getWordUntilPosition(position)
const fallbackRange = {
startLineNumber: position.lineNumber,
startColumn: word.wordMinimumColumn,
endLineNumber: position.lineNumber,
endColumn: position.column,
}
try {
const res = await session.request<{ items?: unknown[] } | unknown[]>(
'textDocument/completion',
{
textDocument: { uri: session.serverDocUri.value },
position: positionOf(model, position),
context: { triggerKind: 1 },
},
)
const items = Array.isArray(res) ? res : (res.items ?? [])
return {
incomplete: true,
suggestions: (items as any[]).slice(0, 300).map((item) => {
const insertText = String(item.insertText ?? item.label ?? '')
const range = item.textEdit?.range ?? item.range
const doc = item.documentation
const suggestion = {
label: String(item.label ?? insertText),
kind: lspCompletionKind(monacoMod, item.kind),
insertText,
insertTextRules: item.insertTextFormat === 2
? monacoMod.languages.CompletionItemInsertTextRule.InsertAsSnippet
: undefined,
detail: item.detail ? String(item.detail) : undefined,
documentation: doc
? (typeof doc === 'string'
? { value: doc }
: { value: String(doc.value ?? ''), isMarkdown: doc.kind === 'markdown' })
: undefined,
range: range ? toRange(range) : fallbackRange,
// Lean attachments (auto-imports) ride along once confirmed
additionalTextEdits: undefined,
command: item.additionalTextEdits?.length
? {
id: 'mathew-lean-apply-edits',
title: '',
arguments: [item.additionalTextEdits],
}
: undefined,
}
return suggestion
}),
}
}
catch { return { suggestions: [] } }
},
}))
/* Lean unicode input: `\le` → `≤`, the wiki's own copy of the table */
disposables.push(monacoMod.languages.registerCompletionItemProvider('lean4', {
triggerCharacters: ['\\'],
provideCompletionItems: (model, position) => {
const line = model.getLineContent(position.lineNumber)
const upto = line.slice(0, position.column - 1)
const match = /\\([^\s]*)$/.exec(upto)
if (!match) return { suggestions: [] }
const typed = match[1]
const range = {
startLineNumber: position.lineNumber,
startColumn: position.column - typed.length,
endLineNumber: position.lineNumber,
endColumn: position.column,
}
const keys = Object.keys(leanAbbreviations)
.filter(k => k.startsWith(typed))
.sort((a, b) => a.length - b.length || a.localeCompare(b))
.slice(0, 100)
return {
suggestions: keys.map(k => ({
label: `\\${k}`,
kind: monacoMod.languages.CompletionItemKind.SymbolValue,
insertText: snippetFrom(leanAbbreviations[k]),
insertTextRules: monacoMod.languages.CompletionItemInsertTextRule.InsertAsSnippet,
detail: leanAbbreviations[k].replace(/\$CURSOR/g, ''),
range,
})),
}
},
}))
/* hover: Lean's docstrings, as markdown, in the wiki's widget colours */
disposables.push(monacoMod.languages.registerHoverProvider('lean4', {
provideHover: async (model, position) => {
try {
const res = await session.request<{
contents?: { kind: string, value: string } | string
} | null>('textDocument/hover', {
textDocument: { uri: session.serverDocUri.value },
position: positionOf(model, position),
})
if (!res?.contents) return null
const value = typeof res.contents === 'string' ? res.contents : res.contents.value
return { contents: [{ value }] }
}
catch { return null }
},
}))
/* auto-imports the server attached to a chosen completion: an action
* registers under its own id, which is what the completion's `command`
* invokes after inserting the text */
disposables.push(host.addAction({
id: 'mathew-lean-apply-edits',
label: 'Apply Lean imports',
run: (target, ...args) => {
const edits = args[0] as Array<{
range: { start: { line: number, character: number }, end: { line: number, character: number } }
newText: string
}> | undefined
if (!edits) return
target.executeEdits('lean-import', edits.map(e => ({
range: monacoMod.Range.lift(toRange(e.range)),
text: e.newText,
})))
},
}))
}
/* LSP completion kinds (1-based) → Monaco's; anything unmapped is a value */
function lspCompletionKind(monacoMod: typeof Monaco, kind?: number) {
const K = monacoMod.languages.CompletionItemKind
const map: Record<number, Monaco.languages.CompletionItemKind> = {
1: K.Text, 2: K.Method, 3: K.Function, 4: K.Constructor, 5: K.Field,
6: K.Variable, 7: K.Class, 8: K.Interface, 9: K.Module, 10: K.Property,
11: K.Unit, 12: K.Value, 13: K.Enum, 14: K.Keyword, 15: K.Snippet,
16: K.Color, 17: K.File, 18: K.Reference, 19: K.Folder, 20: K.EnumMember,
21: K.Constant, 22: K.Struct, 23: K.Event, 24: K.Operator, 25: K.TypeParameter,
}
return (kind !== undefined && map[kind]) || K.Value
}
/* the table's `$CURSOR` becomes a Monaco snippet tab stop; other `$` are literal */
function snippetFrom(value: string) {
return value
.split('$CURSOR')
.map(part => part.replace(/\\/g, '\\\\').replace(/\$/g, '\\$'))
.join('$0')
}
</script>
<template>
<div class="lean-stage lean-work-shell">
<div class="lean-work" :data-panel="panelOpen ? 'open' : 'collapsed'">
<div ref="editorEl" class="lean-work-editor" :aria-label="t('lean.editorAria')" />
<aside class="lean-work-panel">
<div class="lean-work-status" :data-phase="session.phase.value" :data-busy="session.busy.value ? 'true' : null">
<span class="lean-work-dot" aria-hidden="true" />
<span class="lean-work-phase">{{ phaseText }}</span>
<a
class="lean-work-classic"
:href="leanUrl"
target="_blank"
rel="noopener"
:title="t('lean.openExternal')"
:aria-label="t('lean.openExternal')"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
</a>
<button
type="button"
class="lean-work-collapse"
:title="t('lean.collapse')"
:aria-label="t('lean.collapse')"
@click="togglePanel()"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
<section class="lean-work-goals">
<h3>{{ t('lean.goals') }}</h3>
<template v-if="session.goals.value.length">
<pre v-for="(goal, i) in session.goals.value" :key="i">{{ goal }}</pre>
</template>
<p v-else class="lean-work-none">{{ t('lean.noGoals') }}</p>
</section>
<section class="lean-work-messages">
<h3>{{ t('lean.messages') }}</h3>
<ul v-if="session.messages.value.length">
<li v-for="(m, i) in session.messages.value" :key="i">
<span class="lean-work-msg-line">{{ m.line }}</span>
<pre>{{ m.text }}</pre>
</li>
</ul>
<p v-else class="lean-work-none">—</p>
</section>
</aside>
<!-- while the panel is folded away, a quiet card at the editor's
top-right corner waits to bring it back -->
<button
v-if="!panelOpen"
type="button"
class="lean-panel-open"
:title="t('lean.expand')"
:aria-label="t('lean.expand')"
@click="togglePanel()"
>
<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>
{{ t('lean.infoview') }}
</button>
</div>
<!-- the editor answers as soon as Lean answers; until then the wiki
speaks for it -->
<div v-if="!leanUrl" class="lean-state">
<div class="banner">
<span class="glyph">!</span>
<span v-html="t('lean.noServer')"></span>
</div>
</div>
<div v-else-if="session.phase.value === 'lost'" class="lean-state">
<div class="banner">
<span class="glyph">!</span>
<span v-html="th('lean.unreachable', { url: leanUrl })"></span>
</div>
<button type="button" class="btn btn-ghost btn-sm lean-retry" @click="session.retry()">
{{ t('lean.retry') }}
</button>
</div>
<div v-else-if="session.phase.value === 'connecting' || session.phase.value === 'initializing'" class="lean-state">
<span class="lean-spinner" aria-hidden="true" />
<p class="lean-loading-text">{{ phaseText }}</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,832 @@
<script setup lang="ts">
/* The calculator's space: one canvas painted with a labelled box — a floor
grid, the three axes with their numbers — and any number of surfaces,
each z = f(x, y) sampled into a mesh over the plane and laid down quad by
quad, farthest first, every one shaded by how its face lies against the
light. No 3D library: the camera orbits the box's centre and the
projection is the schoolbook one — a point lands where the line to the
eye crosses the picture plane. A quad whose corners are not all finite,
or not all under the drawn height, is simply left out, so 1/(x·y) keeps
its holes rather than a false sheet; the height the box spans is the
data's own, its extremes trimmed so one pole cannot flatten the rest.
Drag turns the scene, the wheel moves closer or away, and a double-click
returns to the default view. Grid, axes and labels follow the site
theme, accent included. */
import type { SurfaceFunction } from '~/utils/plot-expression'
export interface SurfaceSeries {
id: number
color: string
visible: boolean
fn: SurfaceFunction
}
const props = defineProps<{ series: SurfaceSeries[] }>()
const wrapEl = ref<HTMLElement | null>(null)
const canvasEl = ref<HTMLCanvasElement | null>(null)
const cursor = ref<'grab' | 'grabbing'>('grab')
const { t } = useI18n()
/* the space the surfaces stand on, as the reader sets it in the card:
where each edge runs to, and how finely the mesh cuts each side —
clamped short, since a frame holds (n − 1)² quads per surface. z by
default follows the surfaces' own height; the card's switch hands the
two bounds to the reader instead. One person's space, kept in
localStorage between visits, the way the rows and the dock are */
const SPACE_KEY = 'mathew-calc3d-space'
const SUBDIV_MIN = 2
const SUBDIV_MAX = 500
const space = reactive({
xMin: -6,
xMax: 6,
yMin: -6,
yMax: 6,
zAuto: true,
zMin: -1,
zMax: 1,
subdiv: 40,
})
/* the height the box drew last — the auto range the card reads out, and
the prefill the manual bounds take when auto switches off, so the
scene never jumps on the toggle */
const drawnZlo = ref(-1)
const drawnZhi = ref(1)
/* the camera: azimuth and elevation about the scene's centre, and how far
in it stands. Plain mutable state — it moves with every drag and wheel,
and the double-click sets it back. */
const cam = { yaw: 0.96, pitch: 0.42, zoom: 1 }
const YAW0 = 0.96
const PITCH0 = 0.42
const MIN_PITCH = -1.42
const MAX_PITCH = 1.42
const MIN_ZOOM = 0.15
const MAX_ZOOM = 30
/* how strong the perspective is: the eye stands this many scene radii
back from the picture plane */
const EYE = 5.5
let width = 0
let height = 0
let dpr = 1
/* the scene is turned and fitted into a unit ball: its centre and radius
follow the space as set — the edges' middle, the height range drawn —
so whatever it holds fills the picture. All are recomputed at the top
of every paint. */
let cx = 0
let cy = 0
let cz = 0
let radius = 1
/* world → camera: yaw about z, then tilt about the screen's horizontal;
x is screen-right, y screen-up, z depth away from the eye */
function toView(x: number, y: number, z: number) {
const px = (x - cx) / radius
const py = (y - cy) / radius
const pz = (z - cz) / radius
const sa = Math.sin(cam.yaw)
const ca = Math.cos(cam.yaw)
const se = Math.sin(cam.pitch)
const ce = Math.cos(cam.pitch)
return {
x: px * ca + py * sa,
y: -px * sa * se + py * ca * se + pz * ce,
z: -px * sa * ce + py * ca * ce - pz * se,
}
}
/* camera → canvas: the line to the eye, read at the picture plane. At the
scene's middle (z = 0) one scene unit is 42% of the shorter edge. */
function toScreen(v: { x: number; y: number; z: number }) {
const s = ((Math.min(width, height) / 2) * 0.84 * EYE * cam.zoom) / (EYE + v.z)
return { x: width / 2 + v.x * s, y: height / 2 - v.y * s }
}
/* canvas inks follow the site theme, including accent switches */
const colors = {
gridMinor: '#e2e6ef',
gridMajor: '#cfd5e3',
axis: '#8a93a8',
tick: '#8a93a8',
}
function readColors() {
const cs = getComputedStyle(document.documentElement)
const at = (name: string, fallback: string) => cs.getPropertyValue(name).trim() || fallback
colors.gridMinor = at('--border', colors.gridMinor)
colors.gridMajor = at('--border-strong', colors.gridMajor)
colors.axis = at('--faint', colors.axis)
colors.tick = at('--faint', colors.tick)
}
/* the light hangs above and to the left of the eye; faces are shaded by
how squarely they turn to it (both sides of a face read, so the mesh's
underside is no darker than its top) */
const LIGHT = { x: -0.3, y: 0.55, z: -0.78 }
const SHADE_CACHE = new Map<string, string>()
function shade(hex: string, light: number): string {
const level = Math.round(light * 48)
const key = `${hex}|${level}`
const cached = SHADE_CACHE.get(key)
if (cached) return cached
const rgb = /^#[0-9a-f]{6}$/i.test(hex) ? Number.parseInt(hex.slice(1), 16) : 0x8a93a8
const at = (shift: number) =>
Math.max(0, Math.min(255, Math.round((((rgb >> shift) & 255) * level) / 48)))
const css = `rgb(${at(16)},${at(8)},${at(0)})`
SHADE_CACHE.set(key, css)
return css
}
/* about six labelled lines along an axis, at a 1/2/5 × 10ⁿ step */
function niceStep(span: number) {
const rough = span / 6
const pow = Math.pow(10, Math.floor(Math.log10(rough)))
const m = rough / pow
const mult = m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10
return mult * pow
}
function formatTick(v: number, step: number): string {
if (Math.abs(v) < step / 2) return '0'
if (step >= 1e6 || step < 1e-4) {
const [mant, exp] = v.toExponential(2).split('e')
return `${mant!.replace(/\.?0+$/, '')}e${exp}`
}
const decimals = Math.min(12, Math.max(0, -Math.floor(Math.log10(step))))
return v.toFixed(decimals)
}
/* the space as it may be drawn: honest edges (an unfinished or reversed
entry falls back to the default pair) and a clamped mesh fineness.
Read at the top of every paint; the height range fills in once the
surfaces have been sampled */
interface Scene {
xLo: number
xHi: number
yLo: number
yHi: number
zlo: number
zhi: number
n: number // mesh points along one edge; the quads are (n − 1)²
}
function pair(lo: unknown, hi: unknown, dLo: number, dHi: number): [number, number] {
return typeof lo === 'number' && typeof hi === 'number' && Number.isFinite(lo) && Number.isFinite(hi) && lo < hi
? [lo, hi]
: [dLo, dHi]
}
function readScene(): Scene {
const [xLo, xHi] = pair(space.xMin, space.xMax, -6, 6)
const [yLo, yHi] = pair(space.yMin, space.yMax, -6, 6)
const raw = Number(space.subdiv)
const subdiv = Number.isFinite(raw)
? Math.min(SUBDIV_MAX, Math.max(SUBDIV_MIN, Math.round(raw)))
: 40
return { xLo, xHi, yLo, yHi, zlo: -1, zhi: 1, n: subdiv + 1 }
}
/* where the i-th mesh point falls along an edge, and where the k-th
multiple of a step falls between two edges, zeros and all */
const at = (lo: number, hi: number, i: number, n: number) => lo + ((hi - lo) * i) / (n - 1)
function marks(step: number, lo: number, hi: number): number[] {
const out: number[] = []
for (let k = Math.ceil(lo / step - 1e-9); k * step <= hi + 1e-9; k++) out.push(k * step)
return out
}
/* the floor line each horizontal axis runs along: 0 when the ground
holds it, else the edge of the ground that does */
const axisLine = (lo: number, hi: number) => Math.min(Math.max(lo, 0), hi)
/* one surface sampled on the mesh; anything the numbers refuse (NaN, ±∞)
stays a hole the mesh will not paper over */
function sample(sc: Scene, s: SurfaceFunction): Float64Array {
const n = sc.n
const z = new Float64Array(n * n)
for (let j = 0; j < n; j++) {
const y = at(sc.yLo, sc.yHi, j, n)
for (let i = 0; i < n; i++) {
const v = s(at(sc.xLo, sc.xHi, i, n), y)
z[j * n + i] = Number.isFinite(v) ? v : NaN
}
}
return z
}
/* one quantile of a sorted row of heights — the range trims to the 1st and
99th so a lone pole cannot stretch the box flat */
function quantile(sorted: number[], p: number): number {
const at = (sorted.length - 1) * p
const lo = Math.floor(at)
const hi = Math.ceil(at)
return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (at - lo)
}
interface Quad {
pts: { x: number; y: number }[]
depth: number
fill: string
}
function draw() {
const ctx = canvasEl.value?.getContext('2d')
if (!ctx || width === 0 || height === 0) return
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, width, height)
const sc = readScene()
/* one pass over the live surfaces: sample them whole, gather every
finite height, and let those decide the box's height — unless the
reader has taken the height in hand and switched the auto off */
const meshes: { color: string; z: Float64Array }[] = []
const seen: number[] = []
for (const s of props.series) {
if (!s.visible) continue
const z = sample(sc, s.fn)
meshes.push({ color: s.color, z })
for (let k = 0; k < z.length; k++) if (Number.isFinite(z[k]!)) seen.push(z[k]!)
}
const manual =
!space.zAuto && Number.isFinite(space.zMin) && Number.isFinite(space.zMax) && space.zMin < space.zMax
let zlo: number
let zhi: number
if (manual) {
zlo = space.zMin
zhi = space.zMax
} else if (seen.length) {
seen.sort((a, b) => a - b)
const lo = quantile(seen, 0.01)
const hi = quantile(seen, 0.99)
if (hi - lo < 1e-12) {
zlo = lo - 1
zhi = lo + 1
} else {
const pad = (hi - lo) * 0.06
zlo = lo - pad
zhi = hi + pad
}
} else {
zlo = -1
zhi = 1
}
sc.zlo = zlo
sc.zhi = zhi
drawnZlo.value = zlo
drawnZhi.value = zhi
cx = (sc.xLo + sc.xHi) / 2
cy = (sc.yLo + sc.yHi) / 2
cz = (zlo + zhi) / 2
radius = Math.hypot((sc.xHi - sc.xLo) / 2, (sc.yHi - sc.yLo) / 2, (zhi - zlo) / 2)
/* the vertical edge nearest the eye is the one that carries the z axis
and its numbers, so they never hide behind the scene's own centre;
of the four, the closest is the one with the least depth in toView */
const sa = Math.sin(cam.yaw)
const ca = Math.cos(cam.yaw)
let zx = sc.xHi
let zy = sc.yHi
let nearest = Infinity
for (const [x, y] of [
[sc.xHi, sc.yHi],
[sc.xHi, sc.yLo],
[sc.xLo, sc.yHi],
[sc.xLo, sc.yLo],
] as [number, number][]) {
const depth = -x * sa + y * ca // the px/py term of toView's z, before the tilt
if (depth < nearest) {
nearest = depth
zx = x
zy = y
}
}
drawBox(ctx, sc, zx, zy)
drawSurfaces(ctx, meshes, sc)
drawLabels(ctx, sc, zx, zy)
}
/* the box the scene lives in: twelve edges and a floor grid the ground
reads against */
function drawBox(ctx: CanvasRenderingContext2D, sc: Scene, zx: number, zy: number) {
const p = (x: number, y: number, z: number) => toScreen(toView(x, y, z))
const { xLo, xHi, yLo, yHi, zlo, zhi } = sc
ctx.strokeStyle = colors.gridMinor
ctx.lineWidth = 1
ctx.beginPath()
for (const m of marks(niceStep(xHi - xLo), xLo, xHi)) {
const a = p(m, yLo, zlo)
const b = p(m, yHi, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
for (const m of marks(niceStep(yHi - yLo), yLo, yHi)) {
const a = p(xLo, m, zlo)
const b = p(xHi, m, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
ctx.stroke()
ctx.strokeStyle = colors.gridMajor
ctx.beginPath()
const edges: [number, number, number, number, number, number][] = [
[xLo, yLo, zlo, xHi, yLo, zlo], [xHi, yLo, zlo, xHi, yHi, zlo],
[xHi, yHi, zlo, xLo, yHi, zlo], [xLo, yHi, zlo, xLo, yLo, zlo],
[xLo, yLo, zhi, xHi, yLo, zhi], [xHi, yLo, zhi, xHi, yHi, zhi],
[xHi, yHi, zhi, xLo, yHi, zhi], [xLo, yHi, zhi, xLo, yLo, zhi],
[xLo, yLo, zlo, xLo, yLo, zhi], [xHi, yLo, zlo, xHi, yLo, zhi],
[xHi, yHi, zlo, xHi, yHi, zhi], [xLo, yHi, zlo, xLo, yHi, zhi],
]
for (const e of edges) {
const a = p(e[0], e[1], e[2])
const b = p(e[3], e[4], e[5])
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
}
ctx.stroke()
// the x and y axes along the floor — along 0 while the ground holds it,
// else along the edge that does — and the z axis up the near edge
const y0 = axisLine(yLo, yHi)
const x0 = axisLine(xLo, xHi)
ctx.strokeStyle = colors.axis
ctx.lineWidth = 1.4
ctx.beginPath()
let a = p(xLo, y0, zlo)
let b = p(xHi, y0, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
a = p(x0, yLo, zlo)
b = p(x0, yHi, zlo)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
a = p(zx, zy, zlo)
b = p(zx, zy, zhi)
ctx.moveTo(a.x, a.y)
ctx.lineTo(b.x, b.y)
ctx.stroke()
}
/* the surfaces, painted farthest-first: every quad of every mesh keeps its
view-space depth, all are sorted once together, so two surfaces crossing
each other still lay down the nearer one last */
function drawSurfaces(
ctx: CanvasRenderingContext2D,
meshes: { color: string; z: Float64Array }[],
sc: Scene,
) {
const n = sc.n
const { zlo, zhi } = sc
const quads: Quad[] = []
for (const m of meshes) {
for (let j = 0; j < n - 1; j++) {
const y0 = at(sc.yLo, sc.yHi, j, n)
const y1 = at(sc.yLo, sc.yHi, j + 1, n)
for (let i = 0; i < n - 1; i++) {
const x0 = at(sc.xLo, sc.xHi, i, n)
const x1 = at(sc.xLo, sc.xHi, i + 1, n)
const h00 = m.z[j * n + i]!
const h10 = m.z[j * n + i + 1]!
const h01 = m.z[(j + 1) * n + i]!
const h11 = m.z[(j + 1) * n + i + 1]!
if (!Number.isFinite(h00) || !Number.isFinite(h10) || !Number.isFinite(h01) || !Number.isFinite(h11))
continue
// a corner over the drawn height belongs to a pole's neighbourhood:
// the quad is left out, and the mesh keeps the hole honest
if (
h00 < zlo || h00 > zhi || h10 < zlo || h10 > zhi ||
h01 < zlo || h01 > zhi || h11 < zlo || h11 > zhi
)
continue
const v00 = toView(x0, y0, h00)
const v10 = toView(x1, y0, h10)
const v01 = toView(x0, y1, h01)
const v11 = toView(x1, y1, h11)
// the face's normal from its diagonals, in the camera's own frame
const ux = v11.x - v00.x
const uy = v11.y - v00.y
const uz = v11.z - v00.z
const wx = v10.x - v01.x
const wy = v10.y - v01.y
const wz = v10.z - v01.z
const nx = uy * wz - uz * wy
const ny = uz * wx - ux * wz
const nz = ux * wy - uy * wx
const len = Math.hypot(nx, ny, nz)
const light = len > 1e-12 ? 0.44 + 0.74 * Math.abs((nx * LIGHT.x + ny * LIGHT.y + nz * LIGHT.z) / len) : 0.74
quads.push({
pts: [toScreen(v00), toScreen(v10), toScreen(v11), toScreen(v01)],
depth: (v00.z + v10.z + v01.z + v11.z) / 4,
fill: shade(m.color, light),
})
}
}
}
quads.sort((a, b) => b.depth - a.depth) // farthest first, painter's order
ctx.lineJoin = 'round'
ctx.lineWidth = 1
for (const q of quads) {
ctx.beginPath()
ctx.moveTo(q.pts[0]!.x, q.pts[0]!.y)
ctx.lineTo(q.pts[1]!.x, q.pts[1]!.y)
ctx.lineTo(q.pts[2]!.x, q.pts[2]!.y)
ctx.lineTo(q.pts[3]!.x, q.pts[3]!.y)
ctx.closePath()
// filled and stroked in the same tone, so the antialiasing seams between
// neighbouring quads close and the surface reads as one sheet
ctx.fillStyle = q.fill
ctx.strokeStyle = q.fill
ctx.fill()
ctx.stroke()
}
}
/* axes' names and numbers, painted last so they read over any surface */
function drawLabels(ctx: CanvasRenderingContext2D, sc: Scene, zx: number, zy: number) {
const p = (x: number, y: number, z: number) => toScreen(toView(x, y, z))
const { xLo, xHi, yLo, yHi, zlo, zhi } = sc
const y0 = axisLine(yLo, yHi)
const x0 = axisLine(xLo, xHi)
ctx.fillStyle = colors.tick
ctx.font = "11px 'Computer Modern', Georgia, serif"
ctx.textAlign = 'center'
ctx.textBaseline = 'top'
const xstep = niceStep(xHi - xLo)
for (const m of marks(xstep, xLo, xHi)) {
if (Math.abs(m) < 1e-9) continue
const a = p(m, y0, zlo)
ctx.fillText(formatTick(m, xstep), a.x, a.y + 5)
}
const ystep = niceStep(yHi - yLo)
for (const m of marks(ystep, yLo, yHi)) {
if (Math.abs(m) < 1e-9) continue
const b = p(x0, m, zlo)
ctx.fillText(formatTick(m, ystep), b.x, b.y + 5)
}
const zstep = niceStep(zhi - zlo)
ctx.textAlign = zx >= cx ? 'left' : 'right'
ctx.textBaseline = 'middle'
const off = zx >= cx ? 8 : -8
for (const m of marks(zstep, (zhi - zlo) / 2)) {
const v = cz + m
if (v < zlo || v > zhi) continue
const a = p(zx, zy, v)
ctx.fillText(formatTick(v, zstep), a.x + off, a.y)
}
ctx.font = "italic 13px 'Computer Modern', Georgia, serif"
ctx.fillStyle = colors.axis
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const xl = p(xHi, y0, zlo)
ctx.fillText('x', xl.x + 10, xl.y + 4)
const yl = p(x0, yHi, zlo)
ctx.fillText('y', yl.x - 10, yl.y + 4)
const zl = p(zx, zy, zhi)
ctx.fillText('z', zl.x + off, zl.y - 8)
}
/* paint on demand: one frame is scheduled whenever something moved */
let scheduled = false
function requestDraw() {
if (scheduled || !import.meta.client) return
scheduled = true
requestAnimationFrame(() => {
scheduled = false
draw()
})
}
function resetView() {
cam.yaw = YAW0
cam.pitch = PITCH0
cam.zoom = 1
requestDraw()
}
function zoomBy(factor: number) {
cam.zoom = Math.min(Math.max(cam.zoom * factor, MIN_ZOOM), MAX_ZOOM)
requestDraw()
}
/* the configuration card that folds out under the toolbar: the space the
surfaces stand in — the ground's two edges, the height last drawn, and
how finely the mesh cuts each side */
const menuOpen = ref(false)
const toolsEl = ref<HTMLElement | null>(null)
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
/* the card folds away the way the graph's display menu does: a click
outside it (the scene's own drags answer as clicks too), or esc */
function onDocClick(e: MouseEvent) {
if (menuOpen.value && !toolsEl.value?.contains(e.target as Node)) menuOpen.value = false
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && menuOpen.value) menuOpen.value = false
}
/* switching the auto height off prefills the manual bounds with the range
the surfaces were drawn at, so the scene never jumps on the toggle */
function toggleZAuto() {
if (space.zAuto) {
space.zMin = Math.round(drawnZlo.value * 1000) / 1000
space.zMax = Math.round(drawnZhi.value * 1000) / 1000
}
space.zAuto = !space.zAuto
}
/* a bound of the auto height, spelled the way the card reads it: three
decimals plain, a power of ten when the number outgrows that */
function bound(v: number): string {
const s = !Number.isFinite(v)
? '—'
: v === 0
? '0'
: Math.abs(v) >= 1e5 || Math.abs(v) < 1e-3
? v.toExponential(2)
: String(Math.round(v * 1000) / 1000)
return s.replace('-', '\u2212')
}
/* the space is the reader's own too: every edit repaints the scene, and
the setting is kept between visits — the unfinished number typed into
a field persists as it was typed and simply draws as nothing */
function saveSpace() {
try {
localStorage.setItem(SPACE_KEY, JSON.stringify(space))
} catch {
/* private mode etc. — the space just won't persist */
}
}
watch(space, () => {
requestDraw()
saveSpace()
})
function restoreSpace() {
try {
const raw = localStorage.getItem(SPACE_KEY)
if (!raw) return
const stored: unknown = JSON.parse(raw)
if (typeof stored !== 'object' || stored === null) return
const s = stored as Partial<Record<keyof typeof space, unknown>>
for (const key of ['xMin', 'xMax', 'yMin', 'yMax', 'zMin', 'zMax', 'subdiv'] as const) {
if (typeof s[key] === 'number') space[key] = s[key] as number
}
if (typeof s.zAuto === 'boolean') space.zAuto = s.zAuto
} catch {
/* storage unreadable — the default space stands */
}
}
let turning = false
let lastPt = { x: 0, y: 0 }
function localPoint(e: PointerEvent | WheelEvent) {
const rect = canvasEl.value!.getBoundingClientRect()
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
}
function onPointerDown(e: PointerEvent) {
canvasEl.value?.setPointerCapture(e.pointerId)
turning = true
lastPt = localPoint(e)
cursor.value = 'grabbing'
}
function onPointerMove(e: PointerEvent) {
if (!turning) return
const p = localPoint(e)
/* the scene turns with the drag, the way a globe does under the hand */
cam.yaw -= (p.x - lastPt.x) * 0.0062
cam.pitch = Math.min(MAX_PITCH, Math.max(MIN_PITCH, cam.pitch + (p.y - lastPt.y) * 0.0062))
lastPt = p
requestDraw()
}
function onPointerUp() {
turning = false
cursor.value = 'grab'
}
function onWheel(e: WheelEvent) {
e.preventDefault()
let dy = e.deltaY
if (e.deltaMode === 1) dy *= 16
else if (e.deltaMode === 2) dy *= 100
/* pinch-to-zoom arrives as ctrl+wheel — calm the large deltas down */
const step = e.ctrlKey ? 0.004 : 0.0016
cam.zoom = Math.min(Math.max(cam.zoom * Math.exp(-dy * step), MIN_ZOOM), MAX_ZOOM)
requestDraw()
}
let resizeObserver: ResizeObserver | null = null
let themeObserver: MutationObserver | null = null
onMounted(() => {
restoreSpace()
readColors()
const wrap = wrapEl.value!
const canvas = canvasEl.value!
const resize = () => {
const rect = wrap.getBoundingClientRect()
width = Math.max(rect.width, 1)
height = Math.max(rect.height, 1)
dpr = window.devicePixelRatio || 1
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
requestDraw()
}
resize()
resizeObserver = new ResizeObserver(resize)
resizeObserver.observe(wrap)
themeObserver = new MutationObserver(() => {
readColors()
requestDraw()
})
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'data-accent'],
})
document.addEventListener('click', onDocClick)
window.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
themeObserver?.disconnect()
document.removeEventListener('click', onDocClick)
window.removeEventListener('keydown', onKeydown)
})
/* a new surface, an edited one, a colour or visibility change — the scene
is repainted */
watch(() => props.series, requestDraw)
</script>
<template>
<div ref="wrapEl" class="plot-wrap">
<canvas
ref="canvasEl"
class="plot-canvas"
:style="{ cursor }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@wheel="onWheel"
@dblclick="resetView"
/>
<div ref="toolsEl" class="plot-tools">
<div class="plot-toolbar">
<button type="button" class="panel-icon-btn" :title="t('calc.resetView')" :aria-label="t('calc.resetView')" @click="resetView">
<svg 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="M8 3H5a2 2 0 0 0-2 2v3" />
<path d="M16 3h3a2 2 0 0 1 2 2v3" />
<path d="M8 21H5a2 2 0 0 1-2-2v-3" />
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
</svg>
</button>
<button type="button" class="panel-icon-btn" :title="t('calc.zoomIn')" :aria-label="t('calc.zoomIn')" @click="zoomBy(1.35)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<button type="button" class="panel-icon-btn" :title="t('calc.zoomOut')" :aria-label="t('calc.zoomOut')" @click="zoomBy(1 / 1.35)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
<path d="M5 12h14" />
</svg>
</button>
<button
type="button"
class="panel-icon-btn"
:class="{ open: menuOpen }"
:title="t('calc3d.config')"
:aria-label="t('calc3d.config')"
:aria-expanded="String(menuOpen)"
@click="toggleMenu"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3" />
<path d="M14 2v4M8 10v4M16 18v4" />
</svg>
</button>
</div>
<div v-if="menuOpen" class="plot-config" role="group" :aria-label="t('calc3d.config')">
<div class="plot-config-row">
<span class="plot-config-axis" aria-hidden="true">x</span>
<span class="plot-config-bounds">
<input
v-model.number="space.xMin"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMin', { axis: 'x' })"
/>
<span class="plot-config-dots" aria-hidden="true">…</span>
<input
v-model.number="space.xMax"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMax', { axis: 'x' })"
/>
</span>
</div>
<div class="plot-config-row">
<span class="plot-config-axis" aria-hidden="true">y</span>
<span class="plot-config-bounds">
<input
v-model.number="space.yMin"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMin', { axis: 'y' })"
/>
<span class="plot-config-dots" aria-hidden="true">…</span>
<input
v-model.number="space.yMax"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMax', { axis: 'y' })"
/>
</span>
</div>
<div class="plot-config-row">
<span class="plot-config-axis" aria-hidden="true">z</span>
<span v-if="space.zAuto" class="plot-config-value">{{ bound(drawnZlo) }} … {{ bound(drawnZhi) }}</span>
<span v-else class="plot-config-bounds">
<input
v-model.number="space.zMin"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMin', { axis: 'z' })"
/>
<span class="plot-config-dots" aria-hidden="true">…</span>
<input
v-model.number="space.zMax"
class="plot-config-input"
type="number"
step="any"
autocomplete="off"
:aria-label="t('calc3d.configMax', { axis: 'z' })"
/>
</span>
</div>
<button
type="button"
class="plot-config-toggle"
role="switch"
:aria-checked="String(space.zAuto)"
@click="toggleZAuto"
>
<span>{{ t('calc3d.configZNote') }}</span>
<span class="plot-config-switch" :class="{ on: space.zAuto }" aria-hidden="true">
<span class="knob" />
</span>
</button>
<div class="plot-config-row">
<span class="plot-config-label">{{ t('calc3d.configSubdivisions') }}</span>
<input
v-model.number="space.subdiv"
class="plot-config-input"
type="number"
min="2"
max="500"
step="1"
autocomplete="off"
:aria-label="t('calc3d.configSubdivisions')"
/>
</div>
</div>
</div>
</div>
</template>

View File

@@ -5,9 +5,9 @@ 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.
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. */
The wiki's own pages — the link map, the graphing calculator, the Lean
editor — 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()
@@ -29,6 +29,14 @@ const PAGE_TOOLS: { tool: ToolPage; terms: string[] }[] = [
tool: 'calculator',
terms: ['calculator', 'calc', 'plot', 'plotter', 'graphing', 'plane'],
},
{
tool: 'calculator3d',
terms: ['3d', '3d calculator', 'surface', 'space', 'solid', 'scene'],
},
{
tool: 'lean',
terms: ['lean', 'lean4', 'lean 4', 'proof', 'prover', 'theorem prover', 'mathlib'],
},
]
const q = ref('')
@@ -270,6 +278,10 @@ function highlight(text: string): Part[] {
<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-if="p.tool === 'lean'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 4 6.5 20" />
<path d="m10.5 12 7 8" />
</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" />

View File

@@ -1,7 +1,8 @@
<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 ×
link map, a calculator, the Lean editor — 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.
@@ -68,6 +69,15 @@ function turnToThisPage() {
<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-if="tool === 'calculator3d'" 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="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
<svg v-else-if="tool === 'lean'" 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="M14.5 4 6.5 20" />
<path d="m10.5 12 7 8" />
</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" />
@@ -78,6 +88,8 @@ function turnToThisPage() {
<div class="pane-body" :inert="active ? null : ''">
<div class="pane-stage">
<GraphStage v-if="tool === 'graph'" />
<Calculator3DStage v-else-if="tool === 'calculator3d'" />
<LeanWorkbench v-else-if="tool === 'lean'" />
<CalculatorStage v-else />
</div>
</div>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { VarSlider } from '~/composables/useVarSliders'
/* the sliders one row wears: each free variable gets a line — the name in
the maths' own face, a drag for the rough and a field for the fine.
Normally hung just beneath the row; a row that only declares a variable
(the "k" or "k = 3" that creates one) instead wears the slider in place of
its text once the field rests — pass `inline` to lay it inside the row,
and `inputFor` (the id of that declaration's field, kept mounted behind
the slider) so a click on the name wakes the text to edit it again.
Shared by both calculators; the logic behind the values lives in
useVarSliders. */
defineProps<{ sliders: VarSlider[]; inline?: boolean; inputFor?: string }>()
const emit = defineEmits<{ set: [name: string, value: number] }>()
/* how finely a slider moves, dragged or typed */
const STEP = 0.1
const { t } = useI18n()
</script>
<template>
<ul
v-if="sliders.length"
class="calc-vars"
:class="{ 'is-inline': inline }"
:aria-label="t('calc.variablesPanel')"
>
<li v-for="s in sliders" :key="s.name" class="calc-var">
<!-- a declaration row's slider wears its name as a door back to the
field behind it: clicking it wakes "k = 3" to be edited again -->
<label
v-if="inputFor"
class="calc-var-name editable"
:for="inputFor"
:title="t('calc.editVariable', { name: s.name })"
>{{ s.name }}</label>
<span v-else class="calc-var-name">{{ s.name }}</span>
<input
type="range"
class="calc-var-range"
:min="s.min"
:max="s.max"
:step="STEP"
:value="s.value"
:aria-label="t('calc.variableValue', { name: s.name })"
@input="emit('set', s.name, ($event.target as HTMLInputElement).valueAsNumber)"
/>
<input
type="number"
class="calc-var-number"
:step="STEP"
:value="s.value"
:aria-label="t('calc.variableValue', { name: s.name })"
@change="emit('set', s.name, ($event.target as HTMLInputElement).valueAsNumber)"
/>
</li>
</ul>
</template>