This commit is contained in:
@@ -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"
|
||||
|
||||
354
frontend/app/components/Calculator3DStage.vue
Normal file
354
frontend/app/components/Calculator3DStage.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
424
frontend/app/components/LeanWorkbench.vue
Normal file
424
frontend/app/components/LeanWorkbench.vue
Normal 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>
|
||||
832
frontend/app/components/Scene3D.vue
Normal file
832
frontend/app/components/Scene3D.vue
Normal 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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
60
frontend/app/components/VarSliders.vue
Normal file
60
frontend/app/components/VarSliders.vue
Normal 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>
|
||||
Reference in New Issue
Block a user